1use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
2use super::save_file_tool::SaveFileTool;
3use crate::{
4 AgentTool, Templates, Thread, ToolCallEventStream, ToolPermissionDecision,
5 decide_permission_for_path,
6 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
7};
8use acp_thread::Diff;
9use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
10use anyhow::{Context as _, Result, anyhow};
11use cloud_llm_client::CompletionIntent;
12use collections::HashSet;
13use futures::{FutureExt as _, StreamExt as _};
14use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
15use indoc::formatdoc;
16use language::language_settings::{self, FormatOnSave};
17use language::{LanguageRegistry, ToPoint};
18use language_model::LanguageModelToolResultContent;
19use paths;
20use project::lsp_store::{FormatTrigger, LspFormatTarget};
21use project::{Project, ProjectPath};
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use settings::Settings;
25use std::ffi::OsStr;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use ui::SharedString;
29use util::ResultExt;
30use util::rel_path::RelPath;
31
32const DEFAULT_UI_TEXT: &str = "Editing file";
33
34/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead.
35///
36/// Before using this tool:
37///
38/// 1. Use the `read_file` tool to understand the file's contents and context
39///
40/// 2. Verify the directory path is correct (only applicable when creating new files):
41/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
42#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
43pub struct EditFileToolInput {
44 /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit.
45 ///
46 /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
47 ///
48 /// NEVER mention the file path in this description.
49 ///
50 /// <example>Fix API endpoint URLs</example>
51 /// <example>Update copyright year in `page_footer`</example>
52 ///
53 /// Make sure to include this field before all the others in the input object so that we can display it immediately.
54 pub display_description: String,
55
56 /// The full path of the file to create or modify in the project.
57 ///
58 /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
59 ///
60 /// The following examples assume we have two root directories in the project:
61 /// - /a/b/backend
62 /// - /c/d/frontend
63 ///
64 /// <example>
65 /// `backend/src/main.rs`
66 ///
67 /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
68 /// </example>
69 ///
70 /// <example>
71 /// `frontend/db.js`
72 /// </example>
73 pub path: PathBuf,
74 /// The mode of operation on the file. Possible values:
75 /// - 'edit': Make granular edits to an existing file.
76 /// - 'create': Create a new file if it doesn't exist.
77 /// - 'overwrite': Replace the entire contents of an existing file.
78 ///
79 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
80 pub mode: EditFileMode,
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
84struct EditFileToolPartialInput {
85 #[serde(default)]
86 path: String,
87 #[serde(default)]
88 display_description: String,
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "lowercase")]
93#[schemars(inline)]
94pub enum EditFileMode {
95 Edit,
96 Create,
97 Overwrite,
98}
99
100#[derive(Debug, Serialize, Deserialize)]
101pub struct EditFileToolOutput {
102 #[serde(alias = "original_path")]
103 input_path: PathBuf,
104 new_text: String,
105 old_text: Arc<String>,
106 #[serde(default)]
107 diff: String,
108 #[serde(alias = "raw_output")]
109 edit_agent_output: EditAgentOutput,
110}
111
112impl From<EditFileToolOutput> for LanguageModelToolResultContent {
113 fn from(output: EditFileToolOutput) -> Self {
114 if output.diff.is_empty() {
115 "No edits were made.".into()
116 } else {
117 format!(
118 "Edited {}:\n\n```diff\n{}\n```",
119 output.input_path.display(),
120 output.diff
121 )
122 .into()
123 }
124 }
125}
126
127pub struct EditFileTool {
128 thread: WeakEntity<Thread>,
129 language_registry: Arc<LanguageRegistry>,
130 project: Entity<Project>,
131 templates: Arc<Templates>,
132}
133
134impl EditFileTool {
135 pub fn new(
136 project: Entity<Project>,
137 thread: WeakEntity<Thread>,
138 language_registry: Arc<LanguageRegistry>,
139 templates: Arc<Templates>,
140 ) -> Self {
141 Self {
142 project,
143 thread,
144 language_registry,
145 templates,
146 }
147 }
148
149 fn authorize(
150 &self,
151 input: &EditFileToolInput,
152 event_stream: &ToolCallEventStream,
153 cx: &mut App,
154 ) -> Task<Result<()>> {
155 authorize_file_edit(
156 Self::NAME,
157 &input.path,
158 &input.display_description,
159 &self.thread,
160 event_stream,
161 cx,
162 )
163 }
164}
165
166pub enum SensitiveSettingsKind {
167 Local,
168 Global,
169}
170
171/// Canonicalize a path, stripping the Windows extended-length path prefix (`\\?\`)
172/// that `std::fs::canonicalize` adds on Windows. This ensures that canonicalized
173/// paths can be compared with non-canonicalized paths via `starts_with`.
174fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
175 let canonical = std::fs::canonicalize(path)?;
176 #[cfg(target_os = "windows")]
177 {
178 let s = canonical.to_string_lossy();
179 if let Some(stripped) = s.strip_prefix("\\\\?\\") {
180 return Ok(PathBuf::from(stripped));
181 }
182 }
183 Ok(canonical)
184}
185
186/// Returns the kind of sensitive settings location this path targets, if any:
187/// either inside a `.zed/` local-settings directory or inside the global config dir.
188pub fn sensitive_settings_kind(path: &Path) -> Option<SensitiveSettingsKind> {
189 let local_settings_folder = paths::local_settings_folder_name();
190 if path.components().any(|component| {
191 component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
192 }) {
193 return Some(SensitiveSettingsKind::Local);
194 }
195
196 // Walk up the path hierarchy until we find an ancestor that exists and can
197 // be canonicalized, then reconstruct the path from there. This handles
198 // cases where multiple levels of subdirectories don't exist yet (e.g.
199 // ~/.config/zed/new_subdir/evil.json).
200 let canonical_path = {
201 let mut current: Option<&Path> = Some(path);
202 let mut suffix_components = Vec::new();
203 loop {
204 match current {
205 Some(ancestor) => match safe_canonicalize(ancestor) {
206 Ok(canonical) => {
207 let mut result = canonical;
208 for component in suffix_components.into_iter().rev() {
209 result.push(component);
210 }
211 break Some(result);
212 }
213 Err(_) => {
214 if let Some(file_name) = ancestor.file_name() {
215 suffix_components.push(file_name.to_os_string());
216 }
217 current = ancestor.parent();
218 }
219 },
220 None => break None,
221 }
222 }
223 };
224 if let Some(canonical_path) = canonical_path {
225 let config_dir = safe_canonicalize(paths::config_dir())
226 .unwrap_or_else(|_| paths::config_dir().to_path_buf());
227 if canonical_path.starts_with(&config_dir) {
228 return Some(SensitiveSettingsKind::Global);
229 }
230 }
231
232 None
233}
234
235pub fn is_sensitive_settings_path(path: &Path) -> bool {
236 sensitive_settings_kind(path).is_some()
237}
238
239pub fn authorize_file_edit(
240 tool_name: &str,
241 path: &Path,
242 display_description: &str,
243 thread: &WeakEntity<Thread>,
244 event_stream: &ToolCallEventStream,
245 cx: &mut App,
246) -> Task<Result<()>> {
247 let path_str = path.to_string_lossy();
248 let settings = agent_settings::AgentSettings::get_global(cx);
249 let decision = decide_permission_for_path(tool_name, &path_str, settings);
250
251 if let ToolPermissionDecision::Deny(reason) = decision {
252 return Task::ready(Err(anyhow!("{}", reason)));
253 }
254
255 let explicitly_allowed = matches!(decision, ToolPermissionDecision::Allow);
256
257 if explicitly_allowed && !is_sensitive_settings_path(path) {
258 return Task::ready(Ok(()));
259 }
260
261 match sensitive_settings_kind(path) {
262 Some(SensitiveSettingsKind::Local) => {
263 let context = crate::ToolPermissionContext {
264 tool_name: tool_name.to_string(),
265 input_values: vec![path_str.to_string()],
266 };
267 return event_stream.authorize(
268 format!("{} (local settings)", display_description),
269 context,
270 cx,
271 );
272 }
273 Some(SensitiveSettingsKind::Global) => {
274 let context = crate::ToolPermissionContext {
275 tool_name: tool_name.to_string(),
276 input_values: vec![path_str.to_string()],
277 };
278 return event_stream.authorize(
279 format!("{} (settings)", display_description),
280 context,
281 cx,
282 );
283 }
284 None => {}
285 }
286
287 let Ok(project_path) = thread.read_with(cx, |thread, cx| {
288 thread.project().read(cx).find_project_path(path, cx)
289 }) else {
290 return Task::ready(Err(anyhow!("thread was dropped")));
291 };
292
293 if project_path.is_some() {
294 Task::ready(Ok(()))
295 } else {
296 let context = crate::ToolPermissionContext {
297 tool_name: tool_name.to_string(),
298 input_values: vec![path_str.to_string()],
299 };
300 event_stream.authorize(display_description, context, cx)
301 }
302}
303
304impl AgentTool for EditFileTool {
305 type Input = EditFileToolInput;
306 type Output = EditFileToolOutput;
307
308 const NAME: &'static str = "edit_file";
309
310 fn kind() -> acp::ToolKind {
311 acp::ToolKind::Edit
312 }
313
314 fn initial_title(
315 &self,
316 input: Result<Self::Input, serde_json::Value>,
317 cx: &mut App,
318 ) -> SharedString {
319 match input {
320 Ok(input) => self
321 .project
322 .read(cx)
323 .find_project_path(&input.path, cx)
324 .and_then(|project_path| {
325 self.project
326 .read(cx)
327 .short_full_path_for_project_path(&project_path, cx)
328 })
329 .unwrap_or(input.path.to_string_lossy().into_owned())
330 .into(),
331 Err(raw_input) => {
332 if let Some(input) =
333 serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
334 {
335 let path = input.path.trim();
336 if !path.is_empty() {
337 return self
338 .project
339 .read(cx)
340 .find_project_path(&input.path, cx)
341 .and_then(|project_path| {
342 self.project
343 .read(cx)
344 .short_full_path_for_project_path(&project_path, cx)
345 })
346 .unwrap_or(input.path)
347 .into();
348 }
349
350 let description = input.display_description.trim();
351 if !description.is_empty() {
352 return description.to_string().into();
353 }
354 }
355
356 DEFAULT_UI_TEXT.into()
357 }
358 }
359 }
360
361 fn run(
362 self: Arc<Self>,
363 input: Self::Input,
364 event_stream: ToolCallEventStream,
365 cx: &mut App,
366 ) -> Task<Result<Self::Output>> {
367 let Ok(project) = self
368 .thread
369 .read_with(cx, |thread, _cx| thread.project().clone())
370 else {
371 return Task::ready(Err(anyhow!("thread was dropped")));
372 };
373 let project_path = match resolve_path(&input, project.clone(), cx) {
374 Ok(path) => path,
375 Err(err) => return Task::ready(Err(anyhow!(err))),
376 };
377 let abs_path = project.read(cx).absolute_path(&project_path, cx);
378 if let Some(abs_path) = abs_path.clone() {
379 event_stream.update_fields(
380 ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
381 );
382 }
383
384 let authorize = self.authorize(&input, &event_stream, cx);
385 cx.spawn(async move |cx: &mut AsyncApp| {
386 authorize.await?;
387
388 let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
389 let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
390 (request, thread.model().cloned(), thread.action_log().clone())
391 })?;
392 let request = request?;
393 let model = model.context("No language model configured")?;
394
395 let edit_format = EditFormat::from_model(model.clone())?;
396 let edit_agent = EditAgent::new(
397 model,
398 project.clone(),
399 action_log.clone(),
400 self.templates.clone(),
401 edit_format,
402 );
403
404 let buffer = project
405 .update(cx, |project, cx| {
406 project.open_buffer(project_path.clone(), cx)
407 })
408 .await?;
409
410 // Check if the file has been modified since the agent last read it
411 if let Some(abs_path) = abs_path.as_ref() {
412 let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
413 let last_read = thread.file_read_times.get(abs_path).copied();
414 let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
415 let dirty = buffer.read(cx).is_dirty();
416 let has_save = thread.has_tool(SaveFileTool::NAME);
417 let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
418 (last_read, current, dirty, has_save, has_restore)
419 })?;
420
421 // Check for unsaved changes first - these indicate modifications we don't know about
422 if is_dirty {
423 let message = match (has_save_tool, has_restore_tool) {
424 (true, true) => {
425 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
426 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
427 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
428 }
429 (true, false) => {
430 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
431 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
432 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
433 }
434 (false, true) => {
435 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
436 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
437 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
438 }
439 (false, false) => {
440 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
441 then ask them to save or revert the file manually and inform you when it's ok to proceed."
442 }
443 };
444 anyhow::bail!("{}", message);
445 }
446
447 // Check if the file was modified on disk since we last read it
448 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
449 // MTime can be unreliable for comparisons, so our newtype intentionally
450 // doesn't support comparing them. If the mtime at all different
451 // (which could be because of a modification or because e.g. system clock changed),
452 // we pessimistically assume it was modified.
453 if current != last_read {
454 anyhow::bail!(
455 "The file {} has been modified since you last read it. \
456 Please read the file again to get the current state before editing it.",
457 input.path.display()
458 );
459 }
460 }
461 }
462
463 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
464 event_stream.update_diff(diff.clone());
465 let _finalize_diff = util::defer({
466 let diff = diff.downgrade();
467 let mut cx = cx.clone();
468 move || {
469 diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
470 }
471 });
472
473 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
474 let old_text = cx
475 .background_spawn({
476 let old_snapshot = old_snapshot.clone();
477 async move { Arc::new(old_snapshot.text()) }
478 })
479 .await;
480
481 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
482 edit_agent.edit(
483 buffer.clone(),
484 input.display_description.clone(),
485 &request,
486 cx,
487 )
488 } else {
489 edit_agent.overwrite(
490 buffer.clone(),
491 input.display_description.clone(),
492 &request,
493 cx,
494 )
495 };
496
497 let mut hallucinated_old_text = false;
498 let mut ambiguous_ranges = Vec::new();
499 let mut emitted_location = false;
500 loop {
501 let event = futures::select! {
502 event = events.next().fuse() => match event {
503 Some(event) => event,
504 None => break,
505 },
506 _ = event_stream.cancelled_by_user().fuse() => {
507 anyhow::bail!("Edit cancelled by user");
508 }
509 };
510 match event {
511 EditAgentOutputEvent::Edited(range) => {
512 if !emitted_location {
513 let line = Some(buffer.update(cx, |buffer, _cx| {
514 range.start.to_point(&buffer.snapshot()).row
515 }));
516 if let Some(abs_path) = abs_path.clone() {
517 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));
518 }
519 emitted_location = true;
520 }
521 },
522 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
523 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
524 EditAgentOutputEvent::ResolvingEditRange(range) => {
525 diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx));
526 // if !emitted_location {
527 // let line = buffer.update(cx, |buffer, _cx| {
528 // range.start.to_point(&buffer.snapshot()).row
529 // }).ok();
530 // if let Some(abs_path) = abs_path.clone() {
531 // event_stream.update_fields(ToolCallUpdateFields {
532 // locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
533 // ..Default::default()
534 // });
535 // }
536 // }
537 }
538 }
539 }
540
541 let edit_agent_output = output.await?;
542
543 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
544 let settings = language_settings::language_settings(
545 buffer.language().map(|l| l.name()),
546 buffer.file(),
547 cx,
548 );
549 settings.format_on_save != FormatOnSave::Off
550 });
551
552 if format_on_save_enabled {
553 action_log.update(cx, |log, cx| {
554 log.buffer_edited(buffer.clone(), cx);
555 });
556
557 let format_task = project.update(cx, |project, cx| {
558 project.format(
559 HashSet::from_iter([buffer.clone()]),
560 LspFormatTarget::Buffers,
561 false, // Don't push to history since the tool did it.
562 FormatTrigger::Save,
563 cx,
564 )
565 });
566 format_task.await.log_err();
567 }
568
569 project
570 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
571 .await?;
572
573 action_log.update(cx, |log, cx| {
574 log.buffer_edited(buffer.clone(), cx);
575 });
576
577 // Update the recorded read time after a successful edit so consecutive edits work
578 if let Some(abs_path) = abs_path.as_ref() {
579 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
580 buffer.file().and_then(|file| file.disk_state().mtime())
581 }) {
582 self.thread.update(cx, |thread, _| {
583 thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
584 })?;
585 }
586 }
587
588 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
589 let (new_text, unified_diff) = cx
590 .background_spawn({
591 let new_snapshot = new_snapshot.clone();
592 let old_text = old_text.clone();
593 async move {
594 let new_text = new_snapshot.text();
595 let diff = language::unified_diff(&old_text, &new_text);
596 (new_text, diff)
597 }
598 })
599 .await;
600
601 let input_path = input.path.display();
602 if unified_diff.is_empty() {
603 anyhow::ensure!(
604 !hallucinated_old_text,
605 formatdoc! {"
606 Some edits were produced but none of them could be applied.
607 Read the relevant sections of {input_path} again so that
608 I can perform the requested edits.
609 "}
610 );
611 anyhow::ensure!(
612 ambiguous_ranges.is_empty(),
613 {
614 let line_numbers = ambiguous_ranges
615 .iter()
616 .map(|range| range.start.to_string())
617 .collect::<Vec<_>>()
618 .join(", ");
619 formatdoc! {"
620 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
621 relevant sections of {input_path} again and extend <old_text> so
622 that I can perform the requested edits.
623 "}
624 }
625 );
626 }
627
628 Ok(EditFileToolOutput {
629 input_path: input.path,
630 new_text,
631 old_text,
632 diff: unified_diff,
633 edit_agent_output,
634 })
635 })
636 }
637
638 fn replay(
639 &self,
640 _input: Self::Input,
641 output: Self::Output,
642 event_stream: ToolCallEventStream,
643 cx: &mut App,
644 ) -> Result<()> {
645 event_stream.update_diff(cx.new(|cx| {
646 Diff::finalized(
647 output.input_path.to_string_lossy().into_owned(),
648 Some(output.old_text.to_string()),
649 output.new_text,
650 self.language_registry.clone(),
651 cx,
652 )
653 }));
654 Ok(())
655 }
656}
657
658/// Validate that the file path is valid, meaning:
659///
660/// - For `edit` and `overwrite`, the path must point to an existing file.
661/// - For `create`, the file must not already exist, but it's parent dir must exist.
662fn resolve_path(
663 input: &EditFileToolInput,
664 project: Entity<Project>,
665 cx: &mut App,
666) -> Result<ProjectPath> {
667 let project = project.read(cx);
668
669 match input.mode {
670 EditFileMode::Edit | EditFileMode::Overwrite => {
671 let path = project
672 .find_project_path(&input.path, cx)
673 .context("Can't edit file: path not found")?;
674
675 let entry = project
676 .entry_for_path(&path, cx)
677 .context("Can't edit file: path not found")?;
678
679 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
680 Ok(path)
681 }
682
683 EditFileMode::Create => {
684 if let Some(path) = project.find_project_path(&input.path, cx) {
685 anyhow::ensure!(
686 project.entry_for_path(&path, cx).is_none(),
687 "Can't create file: file already exists"
688 );
689 }
690
691 let parent_path = input
692 .path
693 .parent()
694 .context("Can't create file: incorrect path")?;
695
696 let parent_project_path = project.find_project_path(&parent_path, cx);
697
698 let parent_entry = parent_project_path
699 .as_ref()
700 .and_then(|path| project.entry_for_path(path, cx))
701 .context("Can't create file: parent directory doesn't exist")?;
702
703 anyhow::ensure!(
704 parent_entry.is_dir(),
705 "Can't create file: parent is not a directory"
706 );
707
708 let file_name = input
709 .path
710 .file_name()
711 .and_then(|file_name| file_name.to_str())
712 .and_then(|file_name| RelPath::unix(file_name).ok())
713 .context("Can't create file: invalid filename")?;
714
715 let new_file_path = parent_project_path.map(|parent| ProjectPath {
716 path: parent.path.join(file_name),
717 ..parent
718 });
719
720 new_file_path.context("Can't create file")
721 }
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use crate::{ContextServerRegistry, Templates};
729 use fs::Fs;
730 use gpui::{TestAppContext, UpdateGlobal};
731 use language_model::fake_provider::FakeLanguageModel;
732 use prompt_store::ProjectContext;
733 use serde_json::json;
734 use settings::SettingsStore;
735 use util::{path, rel_path::rel_path};
736
737 #[gpui::test]
738 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
739 init_test(cx);
740
741 let fs = project::FakeFs::new(cx.executor());
742 fs.insert_tree("/root", json!({})).await;
743 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
744 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
745 let context_server_registry =
746 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
747 let model = Arc::new(FakeLanguageModel::default());
748 let thread = cx.new(|cx| {
749 Thread::new(
750 project.clone(),
751 cx.new(|_cx| ProjectContext::default()),
752 context_server_registry,
753 Templates::new(),
754 Some(model),
755 cx,
756 )
757 });
758 let result = cx
759 .update(|cx| {
760 let input = EditFileToolInput {
761 display_description: "Some edit".into(),
762 path: "root/nonexistent_file.txt".into(),
763 mode: EditFileMode::Edit,
764 };
765 Arc::new(EditFileTool::new(
766 project,
767 thread.downgrade(),
768 language_registry,
769 Templates::new(),
770 ))
771 .run(input, ToolCallEventStream::test().0, cx)
772 })
773 .await;
774 assert_eq!(
775 result.unwrap_err().to_string(),
776 "Can't edit file: path not found"
777 );
778 }
779
780 #[gpui::test]
781 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
782 let mode = &EditFileMode::Create;
783
784 let result = test_resolve_path(mode, "root/new.txt", cx);
785 assert_resolved_path_eq(result.await, rel_path("new.txt"));
786
787 let result = test_resolve_path(mode, "new.txt", cx);
788 assert_resolved_path_eq(result.await, rel_path("new.txt"));
789
790 let result = test_resolve_path(mode, "dir/new.txt", cx);
791 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
792
793 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
794 assert_eq!(
795 result.await.unwrap_err().to_string(),
796 "Can't create file: file already exists"
797 );
798
799 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
800 assert_eq!(
801 result.await.unwrap_err().to_string(),
802 "Can't create file: parent directory doesn't exist"
803 );
804 }
805
806 #[gpui::test]
807 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
808 let mode = &EditFileMode::Edit;
809
810 let path_with_root = "root/dir/subdir/existing.txt";
811 let path_without_root = "dir/subdir/existing.txt";
812 let result = test_resolve_path(mode, path_with_root, cx);
813 assert_resolved_path_eq(result.await, rel_path(path_without_root));
814
815 let result = test_resolve_path(mode, path_without_root, cx);
816 assert_resolved_path_eq(result.await, rel_path(path_without_root));
817
818 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
819 assert_eq!(
820 result.await.unwrap_err().to_string(),
821 "Can't edit file: path not found"
822 );
823
824 let result = test_resolve_path(mode, "root/dir", cx);
825 assert_eq!(
826 result.await.unwrap_err().to_string(),
827 "Can't edit file: path is a directory"
828 );
829 }
830
831 async fn test_resolve_path(
832 mode: &EditFileMode,
833 path: &str,
834 cx: &mut TestAppContext,
835 ) -> anyhow::Result<ProjectPath> {
836 init_test(cx);
837
838 let fs = project::FakeFs::new(cx.executor());
839 fs.insert_tree(
840 "/root",
841 json!({
842 "dir": {
843 "subdir": {
844 "existing.txt": "hello"
845 }
846 }
847 }),
848 )
849 .await;
850 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
851
852 let input = EditFileToolInput {
853 display_description: "Some edit".into(),
854 path: path.into(),
855 mode: mode.clone(),
856 };
857
858 cx.update(|cx| resolve_path(&input, project, cx))
859 }
860
861 #[track_caller]
862 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
863 let actual = path.expect("Should return valid path").path;
864 assert_eq!(actual.as_ref(), expected);
865 }
866
867 #[gpui::test]
868 async fn test_format_on_save(cx: &mut TestAppContext) {
869 init_test(cx);
870
871 let fs = project::FakeFs::new(cx.executor());
872 fs.insert_tree("/root", json!({"src": {}})).await;
873
874 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
875
876 // Set up a Rust language with LSP formatting support
877 let rust_language = Arc::new(language::Language::new(
878 language::LanguageConfig {
879 name: "Rust".into(),
880 matcher: language::LanguageMatcher {
881 path_suffixes: vec!["rs".to_string()],
882 ..Default::default()
883 },
884 ..Default::default()
885 },
886 None,
887 ));
888
889 // Register the language and fake LSP
890 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
891 language_registry.add(rust_language);
892
893 let mut fake_language_servers = language_registry.register_fake_lsp(
894 "Rust",
895 language::FakeLspAdapter {
896 capabilities: lsp::ServerCapabilities {
897 document_formatting_provider: Some(lsp::OneOf::Left(true)),
898 ..Default::default()
899 },
900 ..Default::default()
901 },
902 );
903
904 // Create the file
905 fs.save(
906 path!("/root/src/main.rs").as_ref(),
907 &"initial content".into(),
908 language::LineEnding::Unix,
909 )
910 .await
911 .unwrap();
912
913 // Open the buffer to trigger LSP initialization
914 let buffer = project
915 .update(cx, |project, cx| {
916 project.open_local_buffer(path!("/root/src/main.rs"), cx)
917 })
918 .await
919 .unwrap();
920
921 // Register the buffer with language servers
922 let _handle = project.update(cx, |project, cx| {
923 project.register_buffer_with_language_servers(&buffer, cx)
924 });
925
926 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
927 const FORMATTED_CONTENT: &str =
928 "This file was formatted by the fake formatter in the test.\n";
929
930 // Get the fake language server and set up formatting handler
931 let fake_language_server = fake_language_servers.next().await.unwrap();
932 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
933 |_, _| async move {
934 Ok(Some(vec![lsp::TextEdit {
935 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
936 new_text: FORMATTED_CONTENT.to_string(),
937 }]))
938 }
939 });
940
941 let context_server_registry =
942 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
943 let model = Arc::new(FakeLanguageModel::default());
944 let thread = cx.new(|cx| {
945 Thread::new(
946 project.clone(),
947 cx.new(|_cx| ProjectContext::default()),
948 context_server_registry,
949 Templates::new(),
950 Some(model.clone()),
951 cx,
952 )
953 });
954
955 // First, test with format_on_save enabled
956 cx.update(|cx| {
957 SettingsStore::update_global(cx, |store, cx| {
958 store.update_user_settings(cx, |settings| {
959 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
960 settings.project.all_languages.defaults.formatter =
961 Some(language::language_settings::FormatterList::default());
962 });
963 });
964 });
965
966 // Have the model stream unformatted content
967 let edit_result = {
968 let edit_task = cx.update(|cx| {
969 let input = EditFileToolInput {
970 display_description: "Create main function".into(),
971 path: "root/src/main.rs".into(),
972 mode: EditFileMode::Overwrite,
973 };
974 Arc::new(EditFileTool::new(
975 project.clone(),
976 thread.downgrade(),
977 language_registry.clone(),
978 Templates::new(),
979 ))
980 .run(input, ToolCallEventStream::test().0, cx)
981 });
982
983 // Stream the unformatted content
984 cx.executor().run_until_parked();
985 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
986 model.end_last_completion_stream();
987
988 edit_task.await
989 };
990 assert!(edit_result.is_ok());
991
992 // Wait for any async operations (e.g. formatting) to complete
993 cx.executor().run_until_parked();
994
995 // Read the file to verify it was formatted automatically
996 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
997 assert_eq!(
998 // Ignore carriage returns on Windows
999 new_content.replace("\r\n", "\n"),
1000 FORMATTED_CONTENT,
1001 "Code should be formatted when format_on_save is enabled"
1002 );
1003
1004 let stale_buffer_count = thread
1005 .read_with(cx, |thread, _cx| thread.action_log.clone())
1006 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
1007
1008 assert_eq!(
1009 stale_buffer_count, 0,
1010 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
1011 This causes the agent to think the file was modified externally when it was just formatted.",
1012 stale_buffer_count
1013 );
1014
1015 // Next, test with format_on_save disabled
1016 cx.update(|cx| {
1017 SettingsStore::update_global(cx, |store, cx| {
1018 store.update_user_settings(cx, |settings| {
1019 settings.project.all_languages.defaults.format_on_save =
1020 Some(FormatOnSave::Off);
1021 });
1022 });
1023 });
1024
1025 // Stream unformatted edits again
1026 let edit_result = {
1027 let edit_task = cx.update(|cx| {
1028 let input = EditFileToolInput {
1029 display_description: "Update main function".into(),
1030 path: "root/src/main.rs".into(),
1031 mode: EditFileMode::Overwrite,
1032 };
1033 Arc::new(EditFileTool::new(
1034 project.clone(),
1035 thread.downgrade(),
1036 language_registry,
1037 Templates::new(),
1038 ))
1039 .run(input, ToolCallEventStream::test().0, cx)
1040 });
1041
1042 // Stream the unformatted content
1043 cx.executor().run_until_parked();
1044 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1045 model.end_last_completion_stream();
1046
1047 edit_task.await
1048 };
1049 assert!(edit_result.is_ok());
1050
1051 // Wait for any async operations (e.g. formatting) to complete
1052 cx.executor().run_until_parked();
1053
1054 // Verify the file was not formatted
1055 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1056 assert_eq!(
1057 // Ignore carriage returns on Windows
1058 new_content.replace("\r\n", "\n"),
1059 UNFORMATTED_CONTENT,
1060 "Code should not be formatted when format_on_save is disabled"
1061 );
1062 }
1063
1064 #[gpui::test]
1065 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1066 init_test(cx);
1067
1068 let fs = project::FakeFs::new(cx.executor());
1069 fs.insert_tree("/root", json!({"src": {}})).await;
1070
1071 // Create a simple file with trailing whitespace
1072 fs.save(
1073 path!("/root/src/main.rs").as_ref(),
1074 &"initial content".into(),
1075 language::LineEnding::Unix,
1076 )
1077 .await
1078 .unwrap();
1079
1080 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1081 let context_server_registry =
1082 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1083 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1084 let model = Arc::new(FakeLanguageModel::default());
1085 let thread = cx.new(|cx| {
1086 Thread::new(
1087 project.clone(),
1088 cx.new(|_cx| ProjectContext::default()),
1089 context_server_registry,
1090 Templates::new(),
1091 Some(model.clone()),
1092 cx,
1093 )
1094 });
1095
1096 // First, test with remove_trailing_whitespace_on_save enabled
1097 cx.update(|cx| {
1098 SettingsStore::update_global(cx, |store, cx| {
1099 store.update_user_settings(cx, |settings| {
1100 settings
1101 .project
1102 .all_languages
1103 .defaults
1104 .remove_trailing_whitespace_on_save = Some(true);
1105 });
1106 });
1107 });
1108
1109 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1110 "fn main() { \n println!(\"Hello!\"); \n}\n";
1111
1112 // Have the model stream content that contains trailing whitespace
1113 let edit_result = {
1114 let edit_task = cx.update(|cx| {
1115 let input = EditFileToolInput {
1116 display_description: "Create main function".into(),
1117 path: "root/src/main.rs".into(),
1118 mode: EditFileMode::Overwrite,
1119 };
1120 Arc::new(EditFileTool::new(
1121 project.clone(),
1122 thread.downgrade(),
1123 language_registry.clone(),
1124 Templates::new(),
1125 ))
1126 .run(input, ToolCallEventStream::test().0, cx)
1127 });
1128
1129 // Stream the content with trailing whitespace
1130 cx.executor().run_until_parked();
1131 model.send_last_completion_stream_text_chunk(
1132 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1133 );
1134 model.end_last_completion_stream();
1135
1136 edit_task.await
1137 };
1138 assert!(edit_result.is_ok());
1139
1140 // Wait for any async operations (e.g. formatting) to complete
1141 cx.executor().run_until_parked();
1142
1143 // Read the file to verify trailing whitespace was removed automatically
1144 assert_eq!(
1145 // Ignore carriage returns on Windows
1146 fs.load(path!("/root/src/main.rs").as_ref())
1147 .await
1148 .unwrap()
1149 .replace("\r\n", "\n"),
1150 "fn main() {\n println!(\"Hello!\");\n}\n",
1151 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1152 );
1153
1154 // Next, test with remove_trailing_whitespace_on_save disabled
1155 cx.update(|cx| {
1156 SettingsStore::update_global(cx, |store, cx| {
1157 store.update_user_settings(cx, |settings| {
1158 settings
1159 .project
1160 .all_languages
1161 .defaults
1162 .remove_trailing_whitespace_on_save = Some(false);
1163 });
1164 });
1165 });
1166
1167 // Stream edits again with trailing whitespace
1168 let edit_result = {
1169 let edit_task = cx.update(|cx| {
1170 let input = EditFileToolInput {
1171 display_description: "Update main function".into(),
1172 path: "root/src/main.rs".into(),
1173 mode: EditFileMode::Overwrite,
1174 };
1175 Arc::new(EditFileTool::new(
1176 project.clone(),
1177 thread.downgrade(),
1178 language_registry,
1179 Templates::new(),
1180 ))
1181 .run(input, ToolCallEventStream::test().0, cx)
1182 });
1183
1184 // Stream the content with trailing whitespace
1185 cx.executor().run_until_parked();
1186 model.send_last_completion_stream_text_chunk(
1187 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1188 );
1189 model.end_last_completion_stream();
1190
1191 edit_task.await
1192 };
1193 assert!(edit_result.is_ok());
1194
1195 // Wait for any async operations (e.g. formatting) to complete
1196 cx.executor().run_until_parked();
1197
1198 // Verify the file still has trailing whitespace
1199 // Read the file again - it should still have trailing whitespace
1200 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1201 assert_eq!(
1202 // Ignore carriage returns on Windows
1203 final_content.replace("\r\n", "\n"),
1204 CONTENT_WITH_TRAILING_WHITESPACE,
1205 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1206 );
1207 }
1208
1209 #[gpui::test]
1210 async fn test_authorize(cx: &mut TestAppContext) {
1211 init_test(cx);
1212 let fs = project::FakeFs::new(cx.executor());
1213 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1214 let context_server_registry =
1215 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1216 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1217 let model = Arc::new(FakeLanguageModel::default());
1218 let thread = cx.new(|cx| {
1219 Thread::new(
1220 project.clone(),
1221 cx.new(|_cx| ProjectContext::default()),
1222 context_server_registry,
1223 Templates::new(),
1224 Some(model.clone()),
1225 cx,
1226 )
1227 });
1228 let tool = Arc::new(EditFileTool::new(
1229 project.clone(),
1230 thread.downgrade(),
1231 language_registry,
1232 Templates::new(),
1233 ));
1234 fs.insert_tree("/root", json!({})).await;
1235
1236 // Test 1: Path with .zed component should require confirmation
1237 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1238 let _auth = cx.update(|cx| {
1239 tool.authorize(
1240 &EditFileToolInput {
1241 display_description: "test 1".into(),
1242 path: ".zed/settings.json".into(),
1243 mode: EditFileMode::Edit,
1244 },
1245 &stream_tx,
1246 cx,
1247 )
1248 });
1249
1250 let event = stream_rx.expect_authorization().await;
1251 assert_eq!(
1252 event.tool_call.fields.title,
1253 Some("test 1 (local settings)".into())
1254 );
1255
1256 // Test 2: Path outside project should require confirmation
1257 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1258 let _auth = cx.update(|cx| {
1259 tool.authorize(
1260 &EditFileToolInput {
1261 display_description: "test 2".into(),
1262 path: "/etc/hosts".into(),
1263 mode: EditFileMode::Edit,
1264 },
1265 &stream_tx,
1266 cx,
1267 )
1268 });
1269
1270 let event = stream_rx.expect_authorization().await;
1271 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1272
1273 // Test 3: Relative path without .zed should not require confirmation
1274 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1275 cx.update(|cx| {
1276 tool.authorize(
1277 &EditFileToolInput {
1278 display_description: "test 3".into(),
1279 path: "root/src/main.rs".into(),
1280 mode: EditFileMode::Edit,
1281 },
1282 &stream_tx,
1283 cx,
1284 )
1285 })
1286 .await
1287 .unwrap();
1288 assert!(stream_rx.try_next().is_err());
1289
1290 // Test 4: Path with .zed in the middle should require confirmation
1291 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1292 let _auth = cx.update(|cx| {
1293 tool.authorize(
1294 &EditFileToolInput {
1295 display_description: "test 4".into(),
1296 path: "root/.zed/tasks.json".into(),
1297 mode: EditFileMode::Edit,
1298 },
1299 &stream_tx,
1300 cx,
1301 )
1302 });
1303 let event = stream_rx.expect_authorization().await;
1304 assert_eq!(
1305 event.tool_call.fields.title,
1306 Some("test 4 (local settings)".into())
1307 );
1308
1309 // Test 5: When global default is allow, sensitive and outside-project
1310 // paths still require confirmation
1311 cx.update(|cx| {
1312 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1313 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1314 agent_settings::AgentSettings::override_global(settings, cx);
1315 });
1316
1317 // 5.1: .zed/settings.json is a sensitive path — still prompts
1318 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1319 let _auth = cx.update(|cx| {
1320 tool.authorize(
1321 &EditFileToolInput {
1322 display_description: "test 5.1".into(),
1323 path: ".zed/settings.json".into(),
1324 mode: EditFileMode::Edit,
1325 },
1326 &stream_tx,
1327 cx,
1328 )
1329 });
1330 let event = stream_rx.expect_authorization().await;
1331 assert_eq!(
1332 event.tool_call.fields.title,
1333 Some("test 5.1 (local settings)".into())
1334 );
1335
1336 // 5.2: /etc/hosts is outside the project, but Allow auto-approves
1337 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1338 cx.update(|cx| {
1339 tool.authorize(
1340 &EditFileToolInput {
1341 display_description: "test 5.2".into(),
1342 path: "/etc/hosts".into(),
1343 mode: EditFileMode::Edit,
1344 },
1345 &stream_tx,
1346 cx,
1347 )
1348 })
1349 .await
1350 .unwrap();
1351 assert!(stream_rx.try_next().is_err());
1352
1353 // 5.3: Normal in-project path with allow — no confirmation needed
1354 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1355 cx.update(|cx| {
1356 tool.authorize(
1357 &EditFileToolInput {
1358 display_description: "test 5.3".into(),
1359 path: "root/src/main.rs".into(),
1360 mode: EditFileMode::Edit,
1361 },
1362 &stream_tx,
1363 cx,
1364 )
1365 })
1366 .await
1367 .unwrap();
1368 assert!(stream_rx.try_next().is_err());
1369
1370 // 5.4: With Confirm default, non-project paths still prompt
1371 cx.update(|cx| {
1372 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1373 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
1374 agent_settings::AgentSettings::override_global(settings, cx);
1375 });
1376
1377 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1378 let _auth = cx.update(|cx| {
1379 tool.authorize(
1380 &EditFileToolInput {
1381 display_description: "test 5.4".into(),
1382 path: "/etc/hosts".into(),
1383 mode: EditFileMode::Edit,
1384 },
1385 &stream_tx,
1386 cx,
1387 )
1388 });
1389
1390 let event = stream_rx.expect_authorization().await;
1391 assert_eq!(event.tool_call.fields.title, Some("test 5.4".into()));
1392 }
1393
1394 #[gpui::test]
1395 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1396 init_test(cx);
1397 let fs = project::FakeFs::new(cx.executor());
1398 fs.insert_tree("/project", json!({})).await;
1399 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1400 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1401 let context_server_registry =
1402 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1403 let model = Arc::new(FakeLanguageModel::default());
1404 let thread = cx.new(|cx| {
1405 Thread::new(
1406 project.clone(),
1407 cx.new(|_cx| ProjectContext::default()),
1408 context_server_registry,
1409 Templates::new(),
1410 Some(model.clone()),
1411 cx,
1412 )
1413 });
1414 let tool = Arc::new(EditFileTool::new(
1415 project.clone(),
1416 thread.downgrade(),
1417 language_registry,
1418 Templates::new(),
1419 ));
1420
1421 // Test global config paths - these should require confirmation if they exist and are outside the project
1422 let test_cases = vec![
1423 (
1424 "/etc/hosts",
1425 true,
1426 "System file should require confirmation",
1427 ),
1428 (
1429 "/usr/local/bin/script",
1430 true,
1431 "System bin file should require confirmation",
1432 ),
1433 (
1434 "project/normal_file.rs",
1435 false,
1436 "Normal project file should not require confirmation",
1437 ),
1438 ];
1439
1440 for (path, should_confirm, description) in test_cases {
1441 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1442 let auth = cx.update(|cx| {
1443 tool.authorize(
1444 &EditFileToolInput {
1445 display_description: "Edit file".into(),
1446 path: path.into(),
1447 mode: EditFileMode::Edit,
1448 },
1449 &stream_tx,
1450 cx,
1451 )
1452 });
1453
1454 if should_confirm {
1455 stream_rx.expect_authorization().await;
1456 } else {
1457 auth.await.unwrap();
1458 assert!(
1459 stream_rx.try_next().is_err(),
1460 "Failed for case: {} - path: {} - expected no confirmation but got one",
1461 description,
1462 path
1463 );
1464 }
1465 }
1466 }
1467
1468 #[gpui::test]
1469 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1470 init_test(cx);
1471 let fs = project::FakeFs::new(cx.executor());
1472
1473 // Create multiple worktree directories
1474 fs.insert_tree(
1475 "/workspace/frontend",
1476 json!({
1477 "src": {
1478 "main.js": "console.log('frontend');"
1479 }
1480 }),
1481 )
1482 .await;
1483 fs.insert_tree(
1484 "/workspace/backend",
1485 json!({
1486 "src": {
1487 "main.rs": "fn main() {}"
1488 }
1489 }),
1490 )
1491 .await;
1492 fs.insert_tree(
1493 "/workspace/shared",
1494 json!({
1495 ".zed": {
1496 "settings.json": "{}"
1497 }
1498 }),
1499 )
1500 .await;
1501
1502 // Create project with multiple worktrees
1503 let project = Project::test(
1504 fs.clone(),
1505 [
1506 path!("/workspace/frontend").as_ref(),
1507 path!("/workspace/backend").as_ref(),
1508 path!("/workspace/shared").as_ref(),
1509 ],
1510 cx,
1511 )
1512 .await;
1513 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1514 let context_server_registry =
1515 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1516 let model = Arc::new(FakeLanguageModel::default());
1517 let thread = cx.new(|cx| {
1518 Thread::new(
1519 project.clone(),
1520 cx.new(|_cx| ProjectContext::default()),
1521 context_server_registry.clone(),
1522 Templates::new(),
1523 Some(model.clone()),
1524 cx,
1525 )
1526 });
1527 let tool = Arc::new(EditFileTool::new(
1528 project.clone(),
1529 thread.downgrade(),
1530 language_registry,
1531 Templates::new(),
1532 ));
1533
1534 // Test files in different worktrees
1535 let test_cases = vec![
1536 ("frontend/src/main.js", false, "File in first worktree"),
1537 ("backend/src/main.rs", false, "File in second worktree"),
1538 (
1539 "shared/.zed/settings.json",
1540 true,
1541 ".zed file in third worktree",
1542 ),
1543 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1544 (
1545 "../outside/file.txt",
1546 true,
1547 "Relative path outside worktrees",
1548 ),
1549 ];
1550
1551 for (path, should_confirm, description) in test_cases {
1552 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1553 let auth = cx.update(|cx| {
1554 tool.authorize(
1555 &EditFileToolInput {
1556 display_description: "Edit file".into(),
1557 path: path.into(),
1558 mode: EditFileMode::Edit,
1559 },
1560 &stream_tx,
1561 cx,
1562 )
1563 });
1564
1565 if should_confirm {
1566 stream_rx.expect_authorization().await;
1567 } else {
1568 auth.await.unwrap();
1569 assert!(
1570 stream_rx.try_next().is_err(),
1571 "Failed for case: {} - path: {} - expected no confirmation but got one",
1572 description,
1573 path
1574 );
1575 }
1576 }
1577 }
1578
1579 #[gpui::test]
1580 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1581 init_test(cx);
1582 let fs = project::FakeFs::new(cx.executor());
1583 fs.insert_tree(
1584 "/project",
1585 json!({
1586 ".zed": {
1587 "settings.json": "{}"
1588 },
1589 "src": {
1590 ".zed": {
1591 "local.json": "{}"
1592 }
1593 }
1594 }),
1595 )
1596 .await;
1597 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1598 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1599 let context_server_registry =
1600 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1601 let model = Arc::new(FakeLanguageModel::default());
1602 let thread = cx.new(|cx| {
1603 Thread::new(
1604 project.clone(),
1605 cx.new(|_cx| ProjectContext::default()),
1606 context_server_registry.clone(),
1607 Templates::new(),
1608 Some(model.clone()),
1609 cx,
1610 )
1611 });
1612 let tool = Arc::new(EditFileTool::new(
1613 project.clone(),
1614 thread.downgrade(),
1615 language_registry,
1616 Templates::new(),
1617 ));
1618
1619 // Test edge cases
1620 let test_cases = vec![
1621 // Empty path - find_project_path returns Some for empty paths
1622 ("", false, "Empty path is treated as project root"),
1623 // Root directory
1624 ("/", true, "Root directory should be outside project"),
1625 // Parent directory references - find_project_path resolves these
1626 (
1627 "project/../other",
1628 true,
1629 "Path with .. that goes outside of root directory",
1630 ),
1631 (
1632 "project/./src/file.rs",
1633 false,
1634 "Path with . should work normally",
1635 ),
1636 // Windows-style paths (if on Windows)
1637 #[cfg(target_os = "windows")]
1638 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1639 #[cfg(target_os = "windows")]
1640 ("project\\src\\main.rs", false, "Windows-style project path"),
1641 ];
1642
1643 for (path, should_confirm, description) in test_cases {
1644 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1645 let auth = cx.update(|cx| {
1646 tool.authorize(
1647 &EditFileToolInput {
1648 display_description: "Edit file".into(),
1649 path: path.into(),
1650 mode: EditFileMode::Edit,
1651 },
1652 &stream_tx,
1653 cx,
1654 )
1655 });
1656
1657 cx.run_until_parked();
1658
1659 if should_confirm {
1660 stream_rx.expect_authorization().await;
1661 } else {
1662 assert!(
1663 stream_rx.try_next().is_err(),
1664 "Failed for case: {} - path: {} - expected no confirmation but got one",
1665 description,
1666 path
1667 );
1668 auth.await.unwrap();
1669 }
1670 }
1671 }
1672
1673 #[gpui::test]
1674 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1675 init_test(cx);
1676 let fs = project::FakeFs::new(cx.executor());
1677 fs.insert_tree(
1678 "/project",
1679 json!({
1680 "existing.txt": "content",
1681 ".zed": {
1682 "settings.json": "{}"
1683 }
1684 }),
1685 )
1686 .await;
1687 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1688 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1689 let context_server_registry =
1690 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1691 let model = Arc::new(FakeLanguageModel::default());
1692 let thread = cx.new(|cx| {
1693 Thread::new(
1694 project.clone(),
1695 cx.new(|_cx| ProjectContext::default()),
1696 context_server_registry.clone(),
1697 Templates::new(),
1698 Some(model.clone()),
1699 cx,
1700 )
1701 });
1702 let tool = Arc::new(EditFileTool::new(
1703 project.clone(),
1704 thread.downgrade(),
1705 language_registry,
1706 Templates::new(),
1707 ));
1708
1709 // Test different EditFileMode values
1710 let modes = vec![
1711 EditFileMode::Edit,
1712 EditFileMode::Create,
1713 EditFileMode::Overwrite,
1714 ];
1715
1716 for mode in modes {
1717 // Test .zed path with different modes
1718 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1719 let _auth = cx.update(|cx| {
1720 tool.authorize(
1721 &EditFileToolInput {
1722 display_description: "Edit settings".into(),
1723 path: "project/.zed/settings.json".into(),
1724 mode: mode.clone(),
1725 },
1726 &stream_tx,
1727 cx,
1728 )
1729 });
1730
1731 stream_rx.expect_authorization().await;
1732
1733 // Test outside path with different modes
1734 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1735 let _auth = cx.update(|cx| {
1736 tool.authorize(
1737 &EditFileToolInput {
1738 display_description: "Edit file".into(),
1739 path: "/outside/file.txt".into(),
1740 mode: mode.clone(),
1741 },
1742 &stream_tx,
1743 cx,
1744 )
1745 });
1746
1747 stream_rx.expect_authorization().await;
1748
1749 // Test normal path with different modes
1750 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1751 cx.update(|cx| {
1752 tool.authorize(
1753 &EditFileToolInput {
1754 display_description: "Edit file".into(),
1755 path: "project/normal.txt".into(),
1756 mode: mode.clone(),
1757 },
1758 &stream_tx,
1759 cx,
1760 )
1761 })
1762 .await
1763 .unwrap();
1764 assert!(stream_rx.try_next().is_err());
1765 }
1766 }
1767
1768 #[gpui::test]
1769 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1770 init_test(cx);
1771 let fs = project::FakeFs::new(cx.executor());
1772 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1773 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1774 let context_server_registry =
1775 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1776 let model = Arc::new(FakeLanguageModel::default());
1777 let thread = cx.new(|cx| {
1778 Thread::new(
1779 project.clone(),
1780 cx.new(|_cx| ProjectContext::default()),
1781 context_server_registry,
1782 Templates::new(),
1783 Some(model.clone()),
1784 cx,
1785 )
1786 });
1787 let tool = Arc::new(EditFileTool::new(
1788 project,
1789 thread.downgrade(),
1790 language_registry,
1791 Templates::new(),
1792 ));
1793
1794 cx.update(|cx| {
1795 // ...
1796 assert_eq!(
1797 tool.initial_title(
1798 Err(json!({
1799 "path": "src/main.rs",
1800 "display_description": "",
1801 "old_string": "old code",
1802 "new_string": "new code"
1803 })),
1804 cx
1805 ),
1806 "src/main.rs"
1807 );
1808 assert_eq!(
1809 tool.initial_title(
1810 Err(json!({
1811 "path": "",
1812 "display_description": "Fix error handling",
1813 "old_string": "old code",
1814 "new_string": "new code"
1815 })),
1816 cx
1817 ),
1818 "Fix error handling"
1819 );
1820 assert_eq!(
1821 tool.initial_title(
1822 Err(json!({
1823 "path": "src/main.rs",
1824 "display_description": "Fix error handling",
1825 "old_string": "old code",
1826 "new_string": "new code"
1827 })),
1828 cx
1829 ),
1830 "src/main.rs"
1831 );
1832 assert_eq!(
1833 tool.initial_title(
1834 Err(json!({
1835 "path": "",
1836 "display_description": "",
1837 "old_string": "old code",
1838 "new_string": "new code"
1839 })),
1840 cx
1841 ),
1842 DEFAULT_UI_TEXT
1843 );
1844 assert_eq!(
1845 tool.initial_title(Err(serde_json::Value::Null), cx),
1846 DEFAULT_UI_TEXT
1847 );
1848 });
1849 }
1850
1851 #[gpui::test]
1852 async fn test_diff_finalization(cx: &mut TestAppContext) {
1853 init_test(cx);
1854 let fs = project::FakeFs::new(cx.executor());
1855 fs.insert_tree("/", json!({"main.rs": ""})).await;
1856
1857 let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
1858 let languages = project.read_with(cx, |project, _cx| project.languages().clone());
1859 let context_server_registry =
1860 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1861 let model = Arc::new(FakeLanguageModel::default());
1862 let thread = cx.new(|cx| {
1863 Thread::new(
1864 project.clone(),
1865 cx.new(|_cx| ProjectContext::default()),
1866 context_server_registry.clone(),
1867 Templates::new(),
1868 Some(model.clone()),
1869 cx,
1870 )
1871 });
1872
1873 // Ensure the diff is finalized after the edit completes.
1874 {
1875 let tool = Arc::new(EditFileTool::new(
1876 project.clone(),
1877 thread.downgrade(),
1878 languages.clone(),
1879 Templates::new(),
1880 ));
1881 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1882 let edit = cx.update(|cx| {
1883 tool.run(
1884 EditFileToolInput {
1885 display_description: "Edit file".into(),
1886 path: path!("/main.rs").into(),
1887 mode: EditFileMode::Edit,
1888 },
1889 stream_tx,
1890 cx,
1891 )
1892 });
1893 stream_rx.expect_update_fields().await;
1894 let diff = stream_rx.expect_diff().await;
1895 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1896 cx.run_until_parked();
1897 model.end_last_completion_stream();
1898 edit.await.unwrap();
1899 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1900 }
1901
1902 // Ensure the diff is finalized if an error occurs while editing.
1903 {
1904 model.forbid_requests();
1905 let tool = Arc::new(EditFileTool::new(
1906 project.clone(),
1907 thread.downgrade(),
1908 languages.clone(),
1909 Templates::new(),
1910 ));
1911 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1912 let edit = cx.update(|cx| {
1913 tool.run(
1914 EditFileToolInput {
1915 display_description: "Edit file".into(),
1916 path: path!("/main.rs").into(),
1917 mode: EditFileMode::Edit,
1918 },
1919 stream_tx,
1920 cx,
1921 )
1922 });
1923 stream_rx.expect_update_fields().await;
1924 let diff = stream_rx.expect_diff().await;
1925 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1926 edit.await.unwrap_err();
1927 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1928 model.allow_requests();
1929 }
1930
1931 // Ensure the diff is finalized if the tool call gets dropped.
1932 {
1933 let tool = Arc::new(EditFileTool::new(
1934 project.clone(),
1935 thread.downgrade(),
1936 languages.clone(),
1937 Templates::new(),
1938 ));
1939 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1940 let edit = cx.update(|cx| {
1941 tool.run(
1942 EditFileToolInput {
1943 display_description: "Edit file".into(),
1944 path: path!("/main.rs").into(),
1945 mode: EditFileMode::Edit,
1946 },
1947 stream_tx,
1948 cx,
1949 )
1950 });
1951 stream_rx.expect_update_fields().await;
1952 let diff = stream_rx.expect_diff().await;
1953 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1954 drop(edit);
1955 cx.run_until_parked();
1956 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1957 }
1958 }
1959
1960 #[gpui::test]
1961 async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
1962 init_test(cx);
1963
1964 let fs = project::FakeFs::new(cx.executor());
1965 fs.insert_tree(
1966 "/root",
1967 json!({
1968 "test.txt": "original content"
1969 }),
1970 )
1971 .await;
1972 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1973 let context_server_registry =
1974 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1975 let model = Arc::new(FakeLanguageModel::default());
1976 let thread = cx.new(|cx| {
1977 Thread::new(
1978 project.clone(),
1979 cx.new(|_cx| ProjectContext::default()),
1980 context_server_registry,
1981 Templates::new(),
1982 Some(model.clone()),
1983 cx,
1984 )
1985 });
1986 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1987
1988 // Initially, file_read_times should be empty
1989 let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
1990 assert!(is_empty, "file_read_times should start empty");
1991
1992 // Create read tool
1993 let read_tool = Arc::new(crate::ReadFileTool::new(
1994 thread.downgrade(),
1995 project.clone(),
1996 action_log,
1997 ));
1998
1999 // Read the file to record the read time
2000 cx.update(|cx| {
2001 read_tool.clone().run(
2002 crate::ReadFileToolInput {
2003 path: "root/test.txt".to_string(),
2004 start_line: None,
2005 end_line: None,
2006 },
2007 ToolCallEventStream::test().0,
2008 cx,
2009 )
2010 })
2011 .await
2012 .unwrap();
2013
2014 // Verify that file_read_times now contains an entry for the file
2015 let has_entry = thread.read_with(cx, |thread, _| {
2016 thread.file_read_times.len() == 1
2017 && thread
2018 .file_read_times
2019 .keys()
2020 .any(|path| path.ends_with("test.txt"))
2021 });
2022 assert!(
2023 has_entry,
2024 "file_read_times should contain an entry after reading the file"
2025 );
2026
2027 // Read the file again - should update the entry
2028 cx.update(|cx| {
2029 read_tool.clone().run(
2030 crate::ReadFileToolInput {
2031 path: "root/test.txt".to_string(),
2032 start_line: None,
2033 end_line: None,
2034 },
2035 ToolCallEventStream::test().0,
2036 cx,
2037 )
2038 })
2039 .await
2040 .unwrap();
2041
2042 // Should still have exactly one entry
2043 let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
2044 assert!(
2045 has_one_entry,
2046 "file_read_times should still have one entry after re-reading"
2047 );
2048 }
2049
2050 fn init_test(cx: &mut TestAppContext) {
2051 cx.update(|cx| {
2052 let settings_store = SettingsStore::test(cx);
2053 cx.set_global(settings_store);
2054 });
2055 }
2056
2057 #[gpui::test]
2058 async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
2059 init_test(cx);
2060
2061 let fs = project::FakeFs::new(cx.executor());
2062 fs.insert_tree(
2063 "/root",
2064 json!({
2065 "test.txt": "original content"
2066 }),
2067 )
2068 .await;
2069 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2070 let context_server_registry =
2071 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2072 let model = Arc::new(FakeLanguageModel::default());
2073 let thread = cx.new(|cx| {
2074 Thread::new(
2075 project.clone(),
2076 cx.new(|_cx| ProjectContext::default()),
2077 context_server_registry,
2078 Templates::new(),
2079 Some(model.clone()),
2080 cx,
2081 )
2082 });
2083 let languages = project.read_with(cx, |project, _| project.languages().clone());
2084 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2085
2086 let read_tool = Arc::new(crate::ReadFileTool::new(
2087 thread.downgrade(),
2088 project.clone(),
2089 action_log,
2090 ));
2091 let edit_tool = Arc::new(EditFileTool::new(
2092 project.clone(),
2093 thread.downgrade(),
2094 languages,
2095 Templates::new(),
2096 ));
2097
2098 // Read the file first
2099 cx.update(|cx| {
2100 read_tool.clone().run(
2101 crate::ReadFileToolInput {
2102 path: "root/test.txt".to_string(),
2103 start_line: None,
2104 end_line: None,
2105 },
2106 ToolCallEventStream::test().0,
2107 cx,
2108 )
2109 })
2110 .await
2111 .unwrap();
2112
2113 // First edit should work
2114 let edit_result = {
2115 let edit_task = cx.update(|cx| {
2116 edit_tool.clone().run(
2117 EditFileToolInput {
2118 display_description: "First edit".into(),
2119 path: "root/test.txt".into(),
2120 mode: EditFileMode::Edit,
2121 },
2122 ToolCallEventStream::test().0,
2123 cx,
2124 )
2125 });
2126
2127 cx.executor().run_until_parked();
2128 model.send_last_completion_stream_text_chunk(
2129 "<old_text>original content</old_text><new_text>modified content</new_text>"
2130 .to_string(),
2131 );
2132 model.end_last_completion_stream();
2133
2134 edit_task.await
2135 };
2136 assert!(
2137 edit_result.is_ok(),
2138 "First edit should succeed, got error: {:?}",
2139 edit_result.as_ref().err()
2140 );
2141
2142 // Second edit should also work because the edit updated the recorded read time
2143 let edit_result = {
2144 let edit_task = cx.update(|cx| {
2145 edit_tool.clone().run(
2146 EditFileToolInput {
2147 display_description: "Second edit".into(),
2148 path: "root/test.txt".into(),
2149 mode: EditFileMode::Edit,
2150 },
2151 ToolCallEventStream::test().0,
2152 cx,
2153 )
2154 });
2155
2156 cx.executor().run_until_parked();
2157 model.send_last_completion_stream_text_chunk(
2158 "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2159 );
2160 model.end_last_completion_stream();
2161
2162 edit_task.await
2163 };
2164 assert!(
2165 edit_result.is_ok(),
2166 "Second consecutive edit should succeed, got error: {:?}",
2167 edit_result.as_ref().err()
2168 );
2169 }
2170
2171 #[gpui::test]
2172 async fn test_external_modification_detected(cx: &mut TestAppContext) {
2173 init_test(cx);
2174
2175 let fs = project::FakeFs::new(cx.executor());
2176 fs.insert_tree(
2177 "/root",
2178 json!({
2179 "test.txt": "original content"
2180 }),
2181 )
2182 .await;
2183 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2184 let context_server_registry =
2185 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2186 let model = Arc::new(FakeLanguageModel::default());
2187 let thread = cx.new(|cx| {
2188 Thread::new(
2189 project.clone(),
2190 cx.new(|_cx| ProjectContext::default()),
2191 context_server_registry,
2192 Templates::new(),
2193 Some(model.clone()),
2194 cx,
2195 )
2196 });
2197 let languages = project.read_with(cx, |project, _| project.languages().clone());
2198 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2199
2200 let read_tool = Arc::new(crate::ReadFileTool::new(
2201 thread.downgrade(),
2202 project.clone(),
2203 action_log,
2204 ));
2205 let edit_tool = Arc::new(EditFileTool::new(
2206 project.clone(),
2207 thread.downgrade(),
2208 languages,
2209 Templates::new(),
2210 ));
2211
2212 // Read the file first
2213 cx.update(|cx| {
2214 read_tool.clone().run(
2215 crate::ReadFileToolInput {
2216 path: "root/test.txt".to_string(),
2217 start_line: None,
2218 end_line: None,
2219 },
2220 ToolCallEventStream::test().0,
2221 cx,
2222 )
2223 })
2224 .await
2225 .unwrap();
2226
2227 // Simulate external modification - advance time and save file
2228 cx.background_executor
2229 .advance_clock(std::time::Duration::from_secs(2));
2230 fs.save(
2231 path!("/root/test.txt").as_ref(),
2232 &"externally modified content".into(),
2233 language::LineEnding::Unix,
2234 )
2235 .await
2236 .unwrap();
2237
2238 // Reload the buffer to pick up the new mtime
2239 let project_path = project
2240 .read_with(cx, |project, cx| {
2241 project.find_project_path("root/test.txt", cx)
2242 })
2243 .expect("Should find project path");
2244 let buffer = project
2245 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2246 .await
2247 .unwrap();
2248 buffer
2249 .update(cx, |buffer, cx| buffer.reload(cx))
2250 .await
2251 .unwrap();
2252
2253 cx.executor().run_until_parked();
2254
2255 // Try to edit - should fail because file was modified externally
2256 let result = cx
2257 .update(|cx| {
2258 edit_tool.clone().run(
2259 EditFileToolInput {
2260 display_description: "Edit after external change".into(),
2261 path: "root/test.txt".into(),
2262 mode: EditFileMode::Edit,
2263 },
2264 ToolCallEventStream::test().0,
2265 cx,
2266 )
2267 })
2268 .await;
2269
2270 assert!(
2271 result.is_err(),
2272 "Edit should fail after external modification"
2273 );
2274 let error_msg = result.unwrap_err().to_string();
2275 assert!(
2276 error_msg.contains("has been modified since you last read it"),
2277 "Error should mention file modification, got: {}",
2278 error_msg
2279 );
2280 }
2281
2282 #[gpui::test]
2283 async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2284 init_test(cx);
2285
2286 let fs = project::FakeFs::new(cx.executor());
2287 fs.insert_tree(
2288 "/root",
2289 json!({
2290 "test.txt": "original content"
2291 }),
2292 )
2293 .await;
2294 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2295 let context_server_registry =
2296 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2297 let model = Arc::new(FakeLanguageModel::default());
2298 let thread = cx.new(|cx| {
2299 Thread::new(
2300 project.clone(),
2301 cx.new(|_cx| ProjectContext::default()),
2302 context_server_registry,
2303 Templates::new(),
2304 Some(model.clone()),
2305 cx,
2306 )
2307 });
2308 let languages = project.read_with(cx, |project, _| project.languages().clone());
2309 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2310
2311 let read_tool = Arc::new(crate::ReadFileTool::new(
2312 thread.downgrade(),
2313 project.clone(),
2314 action_log,
2315 ));
2316 let edit_tool = Arc::new(EditFileTool::new(
2317 project.clone(),
2318 thread.downgrade(),
2319 languages,
2320 Templates::new(),
2321 ));
2322
2323 // Read the file first
2324 cx.update(|cx| {
2325 read_tool.clone().run(
2326 crate::ReadFileToolInput {
2327 path: "root/test.txt".to_string(),
2328 start_line: None,
2329 end_line: None,
2330 },
2331 ToolCallEventStream::test().0,
2332 cx,
2333 )
2334 })
2335 .await
2336 .unwrap();
2337
2338 // Open the buffer and make it dirty by editing without saving
2339 let project_path = project
2340 .read_with(cx, |project, cx| {
2341 project.find_project_path("root/test.txt", cx)
2342 })
2343 .expect("Should find project path");
2344 let buffer = project
2345 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2346 .await
2347 .unwrap();
2348
2349 // Make an in-memory edit to the buffer (making it dirty)
2350 buffer.update(cx, |buffer, cx| {
2351 let end_point = buffer.max_point();
2352 buffer.edit([(end_point..end_point, " added text")], None, cx);
2353 });
2354
2355 // Verify buffer is dirty
2356 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2357 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2358
2359 // Try to edit - should fail because buffer has unsaved changes
2360 let result = cx
2361 .update(|cx| {
2362 edit_tool.clone().run(
2363 EditFileToolInput {
2364 display_description: "Edit with dirty buffer".into(),
2365 path: "root/test.txt".into(),
2366 mode: EditFileMode::Edit,
2367 },
2368 ToolCallEventStream::test().0,
2369 cx,
2370 )
2371 })
2372 .await;
2373
2374 assert!(result.is_err(), "Edit should fail when buffer is dirty");
2375 let error_msg = result.unwrap_err().to_string();
2376 assert!(
2377 error_msg.contains("This file has unsaved changes."),
2378 "Error should mention unsaved changes, got: {}",
2379 error_msg
2380 );
2381 assert!(
2382 error_msg.contains("keep or discard"),
2383 "Error should ask whether to keep or discard changes, got: {}",
2384 error_msg
2385 );
2386 // Since save_file and restore_file_from_disk tools aren't added to the thread,
2387 // the error message should ask the user to manually save or revert
2388 assert!(
2389 error_msg.contains("save or revert the file manually"),
2390 "Error should ask user to manually save or revert when tools aren't available, got: {}",
2391 error_msg
2392 );
2393 }
2394
2395 #[test]
2396 fn test_sensitive_settings_kind_detects_nonexistent_subdirectory() {
2397 let config_dir = paths::config_dir();
2398 let path = config_dir.join("nonexistent_subdir_xyz").join("evil.json");
2399 assert!(
2400 matches!(
2401 sensitive_settings_kind(&path),
2402 Some(SensitiveSettingsKind::Global)
2403 ),
2404 "Path in non-existent subdirectory of config dir should be detected as sensitive: {:?}",
2405 path
2406 );
2407 }
2408
2409 #[test]
2410 fn test_sensitive_settings_kind_detects_deeply_nested_nonexistent_subdirectory() {
2411 let config_dir = paths::config_dir();
2412 let path = config_dir.join("a").join("b").join("c").join("evil.json");
2413 assert!(
2414 matches!(
2415 sensitive_settings_kind(&path),
2416 Some(SensitiveSettingsKind::Global)
2417 ),
2418 "Path in deeply nested non-existent subdirectory of config dir should be detected as sensitive: {:?}",
2419 path
2420 );
2421 }
2422
2423 #[test]
2424 fn test_sensitive_settings_kind_returns_none_for_non_config_path() {
2425 let path = PathBuf::from("/tmp/not_a_config_dir/some_file.json");
2426 assert!(
2427 sensitive_settings_kind(&path).is_none(),
2428 "Path outside config dir should not be detected as sensitive: {:?}",
2429 path
2430 );
2431 }
2432}