edit_file_tool.rs

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