edit_file_tool.rs

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