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