edit_file_tool.rs

   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}