1use acp_thread::Diff;
2use agent_client_protocol as acp;
3use anyhow::{anyhow, Context as _, Result};
4use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat};
5use cloud_llm_client::CompletionIntent;
6use collections::HashSet;
7use gpui::{App, AppContext, AsyncApp, Entity, Task};
8use indoc::formatdoc;
9use language::language_settings::{self, FormatOnSave};
10use language_model::LanguageModelToolResultContent;
11use paths;
12use project::lsp_store::{FormatTrigger, LspFormatTarget};
13use project::{Project, ProjectPath};
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use settings::Settings;
17use smol::stream::StreamExt as _;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use ui::SharedString;
21use util::ResultExt;
22
23use crate::{AgentTool, Thread, ToolCallEventStream};
24
25/// 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.
26///
27/// Before using this tool:
28///
29/// 1. Use the `read_file` tool to understand the file's contents and context
30///
31/// 2. Verify the directory path is correct (only applicable when creating new files):
32/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
33#[derive(Debug, Serialize, Deserialize, JsonSchema)]
34pub struct EditFileToolInput {
35 /// A one-line, user-friendly markdown description of the edit. This will be
36 /// shown in the UI and also passed to another model to perform the edit.
37 ///
38 /// Be terse, but also descriptive in what you want to achieve with this
39 /// edit. Avoid generic instructions.
40 ///
41 /// NEVER mention the file path in this description.
42 ///
43 /// <example>Fix API endpoint URLs</example>
44 /// <example>Update copyright year in `page_footer`</example>
45 ///
46 /// Make sure to include this field before all the others in the input object
47 /// so that we can display it immediately.
48 pub display_description: String,
49
50 /// The full path of the file to create or modify in the project.
51 ///
52 /// WARNING: When specifying which file path need changing, you MUST
53 /// start each path with one of the project's root directories.
54 ///
55 /// The following examples assume we have two root directories in the project:
56 /// - /a/b/backend
57 /// - /c/d/frontend
58 ///
59 /// <example>
60 /// `backend/src/main.rs`
61 ///
62 /// Notice how the file path starts with `backend`. Without that, the path
63 /// would be ambiguous and the call would fail!
64 /// </example>
65 ///
66 /// <example>
67 /// `frontend/db.js`
68 /// </example>
69 pub path: PathBuf,
70
71 /// The mode of operation on the file. Possible values:
72 /// - 'edit': Make granular edits to an existing file.
73 /// - 'create': Create a new file if it doesn't exist.
74 /// - 'overwrite': Replace the entire contents of an existing file.
75 ///
76 /// When a file already exists or you just created it, prefer editing
77 /// it as opposed to recreating it from scratch.
78 pub mode: EditFileMode,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
82#[serde(rename_all = "lowercase")]
83pub enum EditFileMode {
84 Edit,
85 Create,
86 Overwrite,
87}
88
89#[derive(Debug, Serialize, Deserialize)]
90pub struct EditFileToolOutput {
91 input_path: PathBuf,
92 project_path: PathBuf,
93 new_text: String,
94 old_text: Arc<String>,
95 diff: String,
96 edit_agent_output: EditAgentOutput,
97}
98
99impl From<EditFileToolOutput> for LanguageModelToolResultContent {
100 fn from(output: EditFileToolOutput) -> Self {
101 if output.diff.is_empty() {
102 "No edits were made.".into()
103 } else {
104 format!(
105 "Edited {}:\n\n```diff\n{}\n```",
106 output.input_path.display(),
107 output.diff
108 )
109 .into()
110 }
111 }
112}
113
114pub struct EditFileTool {
115 thread: Entity<Thread>,
116}
117
118impl EditFileTool {
119 pub fn new(thread: Entity<Thread>) -> Self {
120 Self { thread }
121 }
122
123 fn authorize(
124 &self,
125 input: &EditFileToolInput,
126 event_stream: &ToolCallEventStream,
127 cx: &App,
128 ) -> Task<Result<()>> {
129 if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
130 return Task::ready(Ok(()));
131 }
132
133 // If any path component matches the local settings folder, then this could affect
134 // the editor in ways beyond the project source, so prompt.
135 let local_settings_folder = paths::local_settings_folder_relative_path();
136 let path = Path::new(&input.path);
137 if path
138 .components()
139 .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
140 {
141 return cx.foreground_executor().spawn(
142 event_stream.authorize(format!("{} (local settings)", input.display_description)),
143 );
144 }
145
146 // It's also possible that the global config dir is configured to be inside the project,
147 // so check for that edge case too.
148 if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
149 if canonical_path.starts_with(paths::config_dir()) {
150 return cx.foreground_executor().spawn(
151 event_stream
152 .authorize(format!("{} (global settings)", input.display_description)),
153 );
154 }
155 }
156
157 // Check if path is inside the global config directory
158 // First check if it's already inside project - if not, try to canonicalize
159 let thread = self.thread.read(cx);
160 let project_path = thread.project().read(cx).find_project_path(&input.path, cx);
161
162 // If the path is inside the project, and it's not one of the above edge cases,
163 // then no confirmation is necessary. Otherwise, confirmation is necessary.
164 if project_path.is_some() {
165 Task::ready(Ok(()))
166 } else {
167 cx.foreground_executor()
168 .spawn(event_stream.authorize(input.display_description.clone()))
169 }
170 }
171}
172
173impl AgentTool for EditFileTool {
174 type Input = EditFileToolInput;
175 type Output = EditFileToolOutput;
176
177 fn name(&self) -> SharedString {
178 "edit_file".into()
179 }
180
181 fn kind(&self) -> acp::ToolKind {
182 acp::ToolKind::Edit
183 }
184
185 fn initial_title(&self, input: Self::Input) -> SharedString {
186 input.display_description.into()
187 }
188
189 fn run(
190 self: Arc<Self>,
191 input: Self::Input,
192 event_stream: ToolCallEventStream,
193 cx: &mut App,
194 ) -> Task<Result<Self::Output>> {
195 let project = self.thread.read(cx).project().clone();
196 let project_path = match resolve_path(&input, project.clone(), cx) {
197 Ok(path) => path,
198 Err(err) => return Task::ready(Err(anyhow!(err))),
199 };
200
201 let request = self.thread.update(cx, |thread, cx| {
202 thread.build_completion_request(CompletionIntent::ToolResults, cx)
203 });
204 let thread = self.thread.read(cx);
205 let model = thread.selected_model.clone();
206 let action_log = thread.action_log().clone();
207
208 let authorize = self.authorize(&input, &event_stream, cx);
209 cx.spawn(async move |cx: &mut AsyncApp| {
210 authorize.await?;
211
212 let edit_format = EditFormat::from_model(model.clone())?;
213 let edit_agent = EditAgent::new(
214 model,
215 project.clone(),
216 action_log.clone(),
217 // TODO: move edit agent to this crate so we can use our templates
218 assistant_tools::templates::Templates::new(),
219 edit_format,
220 );
221
222 let buffer = project
223 .update(cx, |project, cx| {
224 project.open_buffer(project_path.clone(), cx)
225 })?
226 .await?;
227
228 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?;
229 event_stream.send_diff(diff.clone());
230
231 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
232 let old_text = cx
233 .background_spawn({
234 let old_snapshot = old_snapshot.clone();
235 async move { Arc::new(old_snapshot.text()) }
236 })
237 .await;
238
239
240 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
241 edit_agent.edit(
242 buffer.clone(),
243 input.display_description.clone(),
244 &request,
245 cx,
246 )
247 } else {
248 edit_agent.overwrite(
249 buffer.clone(),
250 input.display_description.clone(),
251 &request,
252 cx,
253 )
254 };
255
256 let mut hallucinated_old_text = false;
257 let mut ambiguous_ranges = Vec::new();
258 while let Some(event) = events.next().await {
259 match event {
260 EditAgentOutputEvent::Edited => {},
261 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
262 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
263 EditAgentOutputEvent::ResolvingEditRange(range) => {
264 diff.update(cx, |card, cx| card.reveal_range(range, cx))?;
265 }
266 }
267 }
268
269 // If format_on_save is enabled, format the buffer
270 let format_on_save_enabled = buffer
271 .read_with(cx, |buffer, cx| {
272 let settings = language_settings::language_settings(
273 buffer.language().map(|l| l.name()),
274 buffer.file(),
275 cx,
276 );
277 settings.format_on_save != FormatOnSave::Off
278 })
279 .unwrap_or(false);
280
281 let edit_agent_output = output.await?;
282
283 if format_on_save_enabled {
284 action_log.update(cx, |log, cx| {
285 log.buffer_edited(buffer.clone(), cx);
286 })?;
287
288 let format_task = project.update(cx, |project, cx| {
289 project.format(
290 HashSet::from_iter([buffer.clone()]),
291 LspFormatTarget::Buffers,
292 false, // Don't push to history since the tool did it.
293 FormatTrigger::Save,
294 cx,
295 )
296 })?;
297 format_task.await.log_err();
298 }
299
300 project
301 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
302 .await?;
303
304 action_log.update(cx, |log, cx| {
305 log.buffer_edited(buffer.clone(), cx);
306 })?;
307
308 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
309 let (new_text, unified_diff) = cx
310 .background_spawn({
311 let new_snapshot = new_snapshot.clone();
312 let old_text = old_text.clone();
313 async move {
314 let new_text = new_snapshot.text();
315 let diff = language::unified_diff(&old_text, &new_text);
316 (new_text, diff)
317 }
318 })
319 .await;
320
321 diff.update(cx, |diff, cx| diff.finalize(cx)).ok();
322
323 let input_path = input.path.display();
324 if unified_diff.is_empty() {
325 anyhow::ensure!(
326 !hallucinated_old_text,
327 formatdoc! {"
328 Some edits were produced but none of them could be applied.
329 Read the relevant sections of {input_path} again so that
330 I can perform the requested edits.
331 "}
332 );
333 anyhow::ensure!(
334 ambiguous_ranges.is_empty(),
335 {
336 let line_numbers = ambiguous_ranges
337 .iter()
338 .map(|range| range.start.to_string())
339 .collect::<Vec<_>>()
340 .join(", ");
341 formatdoc! {"
342 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
343 relevant sections of {input_path} again and extend <old_text> so
344 that I can perform the requested edits.
345 "}
346 }
347 );
348 }
349
350 Ok(EditFileToolOutput {
351 input_path: input.path,
352 project_path: project_path.path.to_path_buf(),
353 new_text: new_text.clone(),
354 old_text,
355 diff: unified_diff,
356 edit_agent_output,
357 })
358 })
359 }
360}
361
362/// Validate that the file path is valid, meaning:
363///
364/// - For `edit` and `overwrite`, the path must point to an existing file.
365/// - For `create`, the file must not already exist, but it's parent dir must exist.
366fn resolve_path(
367 input: &EditFileToolInput,
368 project: Entity<Project>,
369 cx: &mut App,
370) -> Result<ProjectPath> {
371 let project = project.read(cx);
372
373 match input.mode {
374 EditFileMode::Edit | EditFileMode::Overwrite => {
375 let path = project
376 .find_project_path(&input.path, cx)
377 .context("Can't edit file: path not found")?;
378
379 let entry = project
380 .entry_for_path(&path, cx)
381 .context("Can't edit file: path not found")?;
382
383 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
384 Ok(path)
385 }
386
387 EditFileMode::Create => {
388 if let Some(path) = project.find_project_path(&input.path, cx) {
389 anyhow::ensure!(
390 project.entry_for_path(&path, cx).is_none(),
391 "Can't create file: file already exists"
392 );
393 }
394
395 let parent_path = input
396 .path
397 .parent()
398 .context("Can't create file: incorrect path")?;
399
400 let parent_project_path = project.find_project_path(&parent_path, cx);
401
402 let parent_entry = parent_project_path
403 .as_ref()
404 .and_then(|path| project.entry_for_path(&path, cx))
405 .context("Can't create file: parent directory doesn't exist")?;
406
407 anyhow::ensure!(
408 parent_entry.is_dir(),
409 "Can't create file: parent is not a directory"
410 );
411
412 let file_name = input
413 .path
414 .file_name()
415 .context("Can't create file: invalid filename")?;
416
417 let new_file_path = parent_project_path.map(|parent| ProjectPath {
418 path: Arc::from(parent.path.join(file_name)),
419 ..parent
420 });
421
422 new_file_path.context("Can't create file")
423 }
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use crate::Templates;
430
431 use super::*;
432 use assistant_tool::ActionLog;
433 use client::TelemetrySettings;
434 use fs::Fs;
435 use gpui::{TestAppContext, UpdateGlobal};
436 use language_model::fake_provider::FakeLanguageModel;
437 use serde_json::json;
438 use settings::SettingsStore;
439 use std::rc::Rc;
440 use util::path;
441
442 #[gpui::test]
443 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
444 init_test(cx);
445
446 let fs = project::FakeFs::new(cx.executor());
447 fs.insert_tree("/root", json!({})).await;
448 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
449 let action_log = cx.new(|_| ActionLog::new(project.clone()));
450 let model = Arc::new(FakeLanguageModel::default());
451 let thread =
452 cx.new(|_| Thread::new(project, Rc::default(), action_log, Templates::new(), model));
453 let result = cx
454 .update(|cx| {
455 let input = EditFileToolInput {
456 display_description: "Some edit".into(),
457 path: "root/nonexistent_file.txt".into(),
458 mode: EditFileMode::Edit,
459 };
460 Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx)
461 })
462 .await;
463 assert_eq!(
464 result.unwrap_err().to_string(),
465 "Can't edit file: path not found"
466 );
467 }
468
469 #[gpui::test]
470 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
471 let mode = &EditFileMode::Create;
472
473 let result = test_resolve_path(mode, "root/new.txt", cx);
474 assert_resolved_path_eq(result.await, "new.txt");
475
476 let result = test_resolve_path(mode, "new.txt", cx);
477 assert_resolved_path_eq(result.await, "new.txt");
478
479 let result = test_resolve_path(mode, "dir/new.txt", cx);
480 assert_resolved_path_eq(result.await, "dir/new.txt");
481
482 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
483 assert_eq!(
484 result.await.unwrap_err().to_string(),
485 "Can't create file: file already exists"
486 );
487
488 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
489 assert_eq!(
490 result.await.unwrap_err().to_string(),
491 "Can't create file: parent directory doesn't exist"
492 );
493 }
494
495 #[gpui::test]
496 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
497 let mode = &EditFileMode::Edit;
498
499 let path_with_root = "root/dir/subdir/existing.txt";
500 let path_without_root = "dir/subdir/existing.txt";
501 let result = test_resolve_path(mode, path_with_root, cx);
502 assert_resolved_path_eq(result.await, path_without_root);
503
504 let result = test_resolve_path(mode, path_without_root, cx);
505 assert_resolved_path_eq(result.await, path_without_root);
506
507 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
508 assert_eq!(
509 result.await.unwrap_err().to_string(),
510 "Can't edit file: path not found"
511 );
512
513 let result = test_resolve_path(mode, "root/dir", cx);
514 assert_eq!(
515 result.await.unwrap_err().to_string(),
516 "Can't edit file: path is a directory"
517 );
518 }
519
520 async fn test_resolve_path(
521 mode: &EditFileMode,
522 path: &str,
523 cx: &mut TestAppContext,
524 ) -> anyhow::Result<ProjectPath> {
525 init_test(cx);
526
527 let fs = project::FakeFs::new(cx.executor());
528 fs.insert_tree(
529 "/root",
530 json!({
531 "dir": {
532 "subdir": {
533 "existing.txt": "hello"
534 }
535 }
536 }),
537 )
538 .await;
539 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
540
541 let input = EditFileToolInput {
542 display_description: "Some edit".into(),
543 path: path.into(),
544 mode: mode.clone(),
545 };
546
547 let result = cx.update(|cx| resolve_path(&input, project, cx));
548 result
549 }
550
551 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
552 let actual = path
553 .expect("Should return valid path")
554 .path
555 .to_str()
556 .unwrap()
557 .replace("\\", "/"); // Naive Windows paths normalization
558 assert_eq!(actual, expected);
559 }
560
561 #[gpui::test]
562 async fn test_format_on_save(cx: &mut TestAppContext) {
563 init_test(cx);
564
565 let fs = project::FakeFs::new(cx.executor());
566 fs.insert_tree("/root", json!({"src": {}})).await;
567
568 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
569
570 // Set up a Rust language with LSP formatting support
571 let rust_language = Arc::new(language::Language::new(
572 language::LanguageConfig {
573 name: "Rust".into(),
574 matcher: language::LanguageMatcher {
575 path_suffixes: vec!["rs".to_string()],
576 ..Default::default()
577 },
578 ..Default::default()
579 },
580 None,
581 ));
582
583 // Register the language and fake LSP
584 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
585 language_registry.add(rust_language);
586
587 let mut fake_language_servers = language_registry.register_fake_lsp(
588 "Rust",
589 language::FakeLspAdapter {
590 capabilities: lsp::ServerCapabilities {
591 document_formatting_provider: Some(lsp::OneOf::Left(true)),
592 ..Default::default()
593 },
594 ..Default::default()
595 },
596 );
597
598 // Create the file
599 fs.save(
600 path!("/root/src/main.rs").as_ref(),
601 &"initial content".into(),
602 language::LineEnding::Unix,
603 )
604 .await
605 .unwrap();
606
607 // Open the buffer to trigger LSP initialization
608 let buffer = project
609 .update(cx, |project, cx| {
610 project.open_local_buffer(path!("/root/src/main.rs"), cx)
611 })
612 .await
613 .unwrap();
614
615 // Register the buffer with language servers
616 let _handle = project.update(cx, |project, cx| {
617 project.register_buffer_with_language_servers(&buffer, cx)
618 });
619
620 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
621 const FORMATTED_CONTENT: &str =
622 "This file was formatted by the fake formatter in the test.\n";
623
624 // Get the fake language server and set up formatting handler
625 let fake_language_server = fake_language_servers.next().await.unwrap();
626 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
627 |_, _| async move {
628 Ok(Some(vec![lsp::TextEdit {
629 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
630 new_text: FORMATTED_CONTENT.to_string(),
631 }]))
632 }
633 });
634
635 let action_log = cx.new(|_| ActionLog::new(project.clone()));
636 let model = Arc::new(FakeLanguageModel::default());
637 let thread = cx.new(|_| {
638 Thread::new(
639 project,
640 Rc::default(),
641 action_log.clone(),
642 Templates::new(),
643 model.clone(),
644 )
645 });
646
647 // First, test with format_on_save enabled
648 cx.update(|cx| {
649 SettingsStore::update_global(cx, |store, cx| {
650 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
651 cx,
652 |settings| {
653 settings.defaults.format_on_save = Some(FormatOnSave::On);
654 settings.defaults.formatter =
655 Some(language::language_settings::SelectedFormatter::Auto);
656 },
657 );
658 });
659 });
660
661 // Have the model stream unformatted content
662 let edit_result = {
663 let edit_task = cx.update(|cx| {
664 let input = EditFileToolInput {
665 display_description: "Create main function".into(),
666 path: "root/src/main.rs".into(),
667 mode: EditFileMode::Overwrite,
668 };
669 Arc::new(EditFileTool {
670 thread: thread.clone(),
671 })
672 .run(input, ToolCallEventStream::test().0, cx)
673 });
674
675 // Stream the unformatted content
676 cx.executor().run_until_parked();
677 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
678 model.end_last_completion_stream();
679
680 edit_task.await
681 };
682 assert!(edit_result.is_ok());
683
684 // Wait for any async operations (e.g. formatting) to complete
685 cx.executor().run_until_parked();
686
687 // Read the file to verify it was formatted automatically
688 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
689 assert_eq!(
690 // Ignore carriage returns on Windows
691 new_content.replace("\r\n", "\n"),
692 FORMATTED_CONTENT,
693 "Code should be formatted when format_on_save is enabled"
694 );
695
696 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
697
698 assert_eq!(
699 stale_buffer_count, 0,
700 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
701 This causes the agent to think the file was modified externally when it was just formatted.",
702 stale_buffer_count
703 );
704
705 // Next, test with format_on_save disabled
706 cx.update(|cx| {
707 SettingsStore::update_global(cx, |store, cx| {
708 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
709 cx,
710 |settings| {
711 settings.defaults.format_on_save = Some(FormatOnSave::Off);
712 },
713 );
714 });
715 });
716
717 // Stream unformatted edits again
718 let edit_result = {
719 let edit_task = cx.update(|cx| {
720 let input = EditFileToolInput {
721 display_description: "Update main function".into(),
722 path: "root/src/main.rs".into(),
723 mode: EditFileMode::Overwrite,
724 };
725 Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx)
726 });
727
728 // Stream the unformatted content
729 cx.executor().run_until_parked();
730 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
731 model.end_last_completion_stream();
732
733 edit_task.await
734 };
735 assert!(edit_result.is_ok());
736
737 // Wait for any async operations (e.g. formatting) to complete
738 cx.executor().run_until_parked();
739
740 // Verify the file was not formatted
741 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
742 assert_eq!(
743 // Ignore carriage returns on Windows
744 new_content.replace("\r\n", "\n"),
745 UNFORMATTED_CONTENT,
746 "Code should not be formatted when format_on_save is disabled"
747 );
748 }
749
750 #[gpui::test]
751 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
752 init_test(cx);
753
754 let fs = project::FakeFs::new(cx.executor());
755 fs.insert_tree("/root", json!({"src": {}})).await;
756
757 // Create a simple file with trailing whitespace
758 fs.save(
759 path!("/root/src/main.rs").as_ref(),
760 &"initial content".into(),
761 language::LineEnding::Unix,
762 )
763 .await
764 .unwrap();
765
766 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
767 let action_log = cx.new(|_| ActionLog::new(project.clone()));
768 let model = Arc::new(FakeLanguageModel::default());
769 let thread = cx.new(|_| {
770 Thread::new(
771 project,
772 Rc::default(),
773 action_log.clone(),
774 Templates::new(),
775 model.clone(),
776 )
777 });
778
779 // First, test with remove_trailing_whitespace_on_save enabled
780 cx.update(|cx| {
781 SettingsStore::update_global(cx, |store, cx| {
782 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
783 cx,
784 |settings| {
785 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
786 },
787 );
788 });
789 });
790
791 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
792 "fn main() { \n println!(\"Hello!\"); \n}\n";
793
794 // Have the model stream content that contains trailing whitespace
795 let edit_result = {
796 let edit_task = cx.update(|cx| {
797 let input = EditFileToolInput {
798 display_description: "Create main function".into(),
799 path: "root/src/main.rs".into(),
800 mode: EditFileMode::Overwrite,
801 };
802 Arc::new(EditFileTool {
803 thread: thread.clone(),
804 })
805 .run(input, ToolCallEventStream::test().0, cx)
806 });
807
808 // Stream the content with trailing whitespace
809 cx.executor().run_until_parked();
810 model.send_last_completion_stream_text_chunk(
811 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
812 );
813 model.end_last_completion_stream();
814
815 edit_task.await
816 };
817 assert!(edit_result.is_ok());
818
819 // Wait for any async operations (e.g. formatting) to complete
820 cx.executor().run_until_parked();
821
822 // Read the file to verify trailing whitespace was removed automatically
823 assert_eq!(
824 // Ignore carriage returns on Windows
825 fs.load(path!("/root/src/main.rs").as_ref())
826 .await
827 .unwrap()
828 .replace("\r\n", "\n"),
829 "fn main() {\n println!(\"Hello!\");\n}\n",
830 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
831 );
832
833 // Next, test with remove_trailing_whitespace_on_save disabled
834 cx.update(|cx| {
835 SettingsStore::update_global(cx, |store, cx| {
836 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
837 cx,
838 |settings| {
839 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
840 },
841 );
842 });
843 });
844
845 // Stream edits again with trailing whitespace
846 let edit_result = {
847 let edit_task = cx.update(|cx| {
848 let input = EditFileToolInput {
849 display_description: "Update main function".into(),
850 path: "root/src/main.rs".into(),
851 mode: EditFileMode::Overwrite,
852 };
853 Arc::new(EditFileTool {
854 thread: thread.clone(),
855 })
856 .run(input, ToolCallEventStream::test().0, cx)
857 });
858
859 // Stream the content with trailing whitespace
860 cx.executor().run_until_parked();
861 model.send_last_completion_stream_text_chunk(
862 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
863 );
864 model.end_last_completion_stream();
865
866 edit_task.await
867 };
868 assert!(edit_result.is_ok());
869
870 // Wait for any async operations (e.g. formatting) to complete
871 cx.executor().run_until_parked();
872
873 // Verify the file still has trailing whitespace
874 // Read the file again - it should still have trailing whitespace
875 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
876 assert_eq!(
877 // Ignore carriage returns on Windows
878 final_content.replace("\r\n", "\n"),
879 CONTENT_WITH_TRAILING_WHITESPACE,
880 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
881 );
882 }
883
884 #[gpui::test]
885 async fn test_authorize(cx: &mut TestAppContext) {
886 init_test(cx);
887 let fs = project::FakeFs::new(cx.executor());
888 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
889 let action_log = cx.new(|_| ActionLog::new(project.clone()));
890 let model = Arc::new(FakeLanguageModel::default());
891 let thread = cx.new(|_| {
892 Thread::new(
893 project,
894 Rc::default(),
895 action_log.clone(),
896 Templates::new(),
897 model.clone(),
898 )
899 });
900 let tool = Arc::new(EditFileTool { thread });
901 fs.insert_tree("/root", json!({})).await;
902
903 // Test 1: Path with .zed component should require confirmation
904 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
905 let _auth = cx.update(|cx| {
906 tool.authorize(
907 &EditFileToolInput {
908 display_description: "test 1".into(),
909 path: ".zed/settings.json".into(),
910 mode: EditFileMode::Edit,
911 },
912 &stream_tx,
913 cx,
914 )
915 });
916
917 let event = stream_rx.expect_tool_authorization().await;
918 assert_eq!(event.tool_call.title, "test 1 (local settings)");
919
920 // Test 2: Path outside project should require confirmation
921 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
922 let _auth = cx.update(|cx| {
923 tool.authorize(
924 &EditFileToolInput {
925 display_description: "test 2".into(),
926 path: "/etc/hosts".into(),
927 mode: EditFileMode::Edit,
928 },
929 &stream_tx,
930 cx,
931 )
932 });
933
934 let event = stream_rx.expect_tool_authorization().await;
935 assert_eq!(event.tool_call.title, "test 2");
936
937 // Test 3: Relative path without .zed should not require confirmation
938 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
939 cx.update(|cx| {
940 tool.authorize(
941 &EditFileToolInput {
942 display_description: "test 3".into(),
943 path: "root/src/main.rs".into(),
944 mode: EditFileMode::Edit,
945 },
946 &stream_tx,
947 cx,
948 )
949 })
950 .await
951 .unwrap();
952 assert!(stream_rx.try_next().is_err());
953
954 // Test 4: Path with .zed in the middle should require confirmation
955 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
956 let _auth = cx.update(|cx| {
957 tool.authorize(
958 &EditFileToolInput {
959 display_description: "test 4".into(),
960 path: "root/.zed/tasks.json".into(),
961 mode: EditFileMode::Edit,
962 },
963 &stream_tx,
964 cx,
965 )
966 });
967 let event = stream_rx.expect_tool_authorization().await;
968 assert_eq!(event.tool_call.title, "test 4 (local settings)");
969
970 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
971 cx.update(|cx| {
972 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
973 settings.always_allow_tool_actions = true;
974 agent_settings::AgentSettings::override_global(settings, cx);
975 });
976
977 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
978 cx.update(|cx| {
979 tool.authorize(
980 &EditFileToolInput {
981 display_description: "test 5.1".into(),
982 path: ".zed/settings.json".into(),
983 mode: EditFileMode::Edit,
984 },
985 &stream_tx,
986 cx,
987 )
988 })
989 .await
990 .unwrap();
991 assert!(stream_rx.try_next().is_err());
992
993 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
994 cx.update(|cx| {
995 tool.authorize(
996 &EditFileToolInput {
997 display_description: "test 5.2".into(),
998 path: "/etc/hosts".into(),
999 mode: EditFileMode::Edit,
1000 },
1001 &stream_tx,
1002 cx,
1003 )
1004 })
1005 .await
1006 .unwrap();
1007 assert!(stream_rx.try_next().is_err());
1008 }
1009
1010 #[gpui::test]
1011 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1012 init_test(cx);
1013 let fs = project::FakeFs::new(cx.executor());
1014 fs.insert_tree("/project", json!({})).await;
1015 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1016 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1017 let model = Arc::new(FakeLanguageModel::default());
1018 let thread = cx.new(|_| {
1019 Thread::new(
1020 project,
1021 Rc::default(),
1022 action_log.clone(),
1023 Templates::new(),
1024 model.clone(),
1025 )
1026 });
1027 let tool = Arc::new(EditFileTool { thread });
1028
1029 // Test global config paths - these should require confirmation if they exist and are outside the project
1030 let test_cases = vec![
1031 (
1032 "/etc/hosts",
1033 true,
1034 "System file should require confirmation",
1035 ),
1036 (
1037 "/usr/local/bin/script",
1038 true,
1039 "System bin file should require confirmation",
1040 ),
1041 (
1042 "project/normal_file.rs",
1043 false,
1044 "Normal project file should not require confirmation",
1045 ),
1046 ];
1047
1048 for (path, should_confirm, description) in test_cases {
1049 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1050 let auth = cx.update(|cx| {
1051 tool.authorize(
1052 &EditFileToolInput {
1053 display_description: "Edit file".into(),
1054 path: path.into(),
1055 mode: EditFileMode::Edit,
1056 },
1057 &stream_tx,
1058 cx,
1059 )
1060 });
1061
1062 if should_confirm {
1063 stream_rx.expect_tool_authorization().await;
1064 } else {
1065 auth.await.unwrap();
1066 assert!(
1067 stream_rx.try_next().is_err(),
1068 "Failed for case: {} - path: {} - expected no confirmation but got one",
1069 description,
1070 path
1071 );
1072 }
1073 }
1074 }
1075
1076 #[gpui::test]
1077 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1078 init_test(cx);
1079 let fs = project::FakeFs::new(cx.executor());
1080
1081 // Create multiple worktree directories
1082 fs.insert_tree(
1083 "/workspace/frontend",
1084 json!({
1085 "src": {
1086 "main.js": "console.log('frontend');"
1087 }
1088 }),
1089 )
1090 .await;
1091 fs.insert_tree(
1092 "/workspace/backend",
1093 json!({
1094 "src": {
1095 "main.rs": "fn main() {}"
1096 }
1097 }),
1098 )
1099 .await;
1100 fs.insert_tree(
1101 "/workspace/shared",
1102 json!({
1103 ".zed": {
1104 "settings.json": "{}"
1105 }
1106 }),
1107 )
1108 .await;
1109
1110 // Create project with multiple worktrees
1111 let project = Project::test(
1112 fs.clone(),
1113 [
1114 path!("/workspace/frontend").as_ref(),
1115 path!("/workspace/backend").as_ref(),
1116 path!("/workspace/shared").as_ref(),
1117 ],
1118 cx,
1119 )
1120 .await;
1121
1122 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1123 let model = Arc::new(FakeLanguageModel::default());
1124 let thread = cx.new(|_| {
1125 Thread::new(
1126 project.clone(),
1127 Rc::default(),
1128 action_log.clone(),
1129 Templates::new(),
1130 model.clone(),
1131 )
1132 });
1133 let tool = Arc::new(EditFileTool { thread });
1134
1135 // Test files in different worktrees
1136 let test_cases = vec![
1137 ("frontend/src/main.js", false, "File in first worktree"),
1138 ("backend/src/main.rs", false, "File in second worktree"),
1139 (
1140 "shared/.zed/settings.json",
1141 true,
1142 ".zed file in third worktree",
1143 ),
1144 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1145 (
1146 "../outside/file.txt",
1147 true,
1148 "Relative path outside worktrees",
1149 ),
1150 ];
1151
1152 for (path, should_confirm, description) in test_cases {
1153 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1154 let auth = cx.update(|cx| {
1155 tool.authorize(
1156 &EditFileToolInput {
1157 display_description: "Edit file".into(),
1158 path: path.into(),
1159 mode: EditFileMode::Edit,
1160 },
1161 &stream_tx,
1162 cx,
1163 )
1164 });
1165
1166 if should_confirm {
1167 stream_rx.expect_tool_authorization().await;
1168 } else {
1169 auth.await.unwrap();
1170 assert!(
1171 stream_rx.try_next().is_err(),
1172 "Failed for case: {} - path: {} - expected no confirmation but got one",
1173 description,
1174 path
1175 );
1176 }
1177 }
1178 }
1179
1180 #[gpui::test]
1181 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1182 init_test(cx);
1183 let fs = project::FakeFs::new(cx.executor());
1184 fs.insert_tree(
1185 "/project",
1186 json!({
1187 ".zed": {
1188 "settings.json": "{}"
1189 },
1190 "src": {
1191 ".zed": {
1192 "local.json": "{}"
1193 }
1194 }
1195 }),
1196 )
1197 .await;
1198 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1199 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1200 let model = Arc::new(FakeLanguageModel::default());
1201 let thread = cx.new(|_| {
1202 Thread::new(
1203 project.clone(),
1204 Rc::default(),
1205 action_log.clone(),
1206 Templates::new(),
1207 model.clone(),
1208 )
1209 });
1210 let tool = Arc::new(EditFileTool { thread });
1211
1212 // Test edge cases
1213 let test_cases = vec![
1214 // Empty path - find_project_path returns Some for empty paths
1215 ("", false, "Empty path is treated as project root"),
1216 // Root directory
1217 ("/", true, "Root directory should be outside project"),
1218 // Parent directory references - find_project_path resolves these
1219 (
1220 "project/../other",
1221 false,
1222 "Path with .. is resolved by find_project_path",
1223 ),
1224 (
1225 "project/./src/file.rs",
1226 false,
1227 "Path with . should work normally",
1228 ),
1229 // Windows-style paths (if on Windows)
1230 #[cfg(target_os = "windows")]
1231 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1232 #[cfg(target_os = "windows")]
1233 ("project\\src\\main.rs", false, "Windows-style project path"),
1234 ];
1235
1236 for (path, should_confirm, description) in test_cases {
1237 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1238 let auth = cx.update(|cx| {
1239 tool.authorize(
1240 &EditFileToolInput {
1241 display_description: "Edit file".into(),
1242 path: path.into(),
1243 mode: EditFileMode::Edit,
1244 },
1245 &stream_tx,
1246 cx,
1247 )
1248 });
1249
1250 if should_confirm {
1251 stream_rx.expect_tool_authorization().await;
1252 } else {
1253 auth.await.unwrap();
1254 assert!(
1255 stream_rx.try_next().is_err(),
1256 "Failed for case: {} - path: {} - expected no confirmation but got one",
1257 description,
1258 path
1259 );
1260 }
1261 }
1262 }
1263
1264 #[gpui::test]
1265 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1266 init_test(cx);
1267 let fs = project::FakeFs::new(cx.executor());
1268 fs.insert_tree(
1269 "/project",
1270 json!({
1271 "existing.txt": "content",
1272 ".zed": {
1273 "settings.json": "{}"
1274 }
1275 }),
1276 )
1277 .await;
1278 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1279 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1280 let model = Arc::new(FakeLanguageModel::default());
1281 let thread = cx.new(|_| {
1282 Thread::new(
1283 project.clone(),
1284 Rc::default(),
1285 action_log.clone(),
1286 Templates::new(),
1287 model.clone(),
1288 )
1289 });
1290 let tool = Arc::new(EditFileTool { thread });
1291
1292 // Test different EditFileMode values
1293 let modes = vec![
1294 EditFileMode::Edit,
1295 EditFileMode::Create,
1296 EditFileMode::Overwrite,
1297 ];
1298
1299 for mode in modes {
1300 // Test .zed path with different modes
1301 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1302 let _auth = cx.update(|cx| {
1303 tool.authorize(
1304 &EditFileToolInput {
1305 display_description: "Edit settings".into(),
1306 path: "project/.zed/settings.json".into(),
1307 mode: mode.clone(),
1308 },
1309 &stream_tx,
1310 cx,
1311 )
1312 });
1313
1314 stream_rx.expect_tool_authorization().await;
1315
1316 // Test outside path with different modes
1317 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1318 let _auth = cx.update(|cx| {
1319 tool.authorize(
1320 &EditFileToolInput {
1321 display_description: "Edit file".into(),
1322 path: "/outside/file.txt".into(),
1323 mode: mode.clone(),
1324 },
1325 &stream_tx,
1326 cx,
1327 )
1328 });
1329
1330 stream_rx.expect_tool_authorization().await;
1331
1332 // Test normal path with different modes
1333 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1334 cx.update(|cx| {
1335 tool.authorize(
1336 &EditFileToolInput {
1337 display_description: "Edit file".into(),
1338 path: "project/normal.txt".into(),
1339 mode: mode.clone(),
1340 },
1341 &stream_tx,
1342 cx,
1343 )
1344 })
1345 .await
1346 .unwrap();
1347 assert!(stream_rx.try_next().is_err());
1348 }
1349 }
1350
1351 fn init_test(cx: &mut TestAppContext) {
1352 cx.update(|cx| {
1353 let settings_store = SettingsStore::test(cx);
1354 cx.set_global(settings_store);
1355 language::init(cx);
1356 TelemetrySettings::register(cx);
1357 agent_settings::AgentSettings::register(cx);
1358 Project::init_settings(cx);
1359 });
1360 }
1361}