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