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