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