edit_file_tool.rs

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