read_file_tool.rs

   1use action_log::ActionLog;
   2use agent_client_protocol::{self as acp, ToolCallUpdateFields};
   3use anyhow::{Context as _, Result, anyhow};
   4use gpui::{App, Entity, SharedString, Task, WeakEntity};
   5use indoc::formatdoc;
   6use language::Point;
   7use language_model::{LanguageModelImage, LanguageModelToolResultContent};
   8use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
   9use schemars::JsonSchema;
  10use serde::{Deserialize, Serialize};
  11use settings::Settings;
  12use std::sync::Arc;
  13use util::markdown::MarkdownCodeBlock;
  14
  15use crate::{AgentTool, Thread, ToolCallEventStream, outline};
  16
  17/// Reads the content of the given file in the project.
  18///
  19/// - Never attempt to read a path that hasn't been previously mentioned.
  20/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
  21///   This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
  22///   Do NOT retry reading the same file without line numbers if you receive an outline.
  23#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  24pub struct ReadFileToolInput {
  25    /// The relative path of the file to read.
  26    ///
  27    /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
  28    ///
  29    /// <example>
  30    /// If the project has the following root directories:
  31    ///
  32    /// - /a/b/directory1
  33    /// - /c/d/directory2
  34    ///
  35    /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
  36    /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
  37    /// </example>
  38    pub path: String,
  39    /// Optional line number to start reading on (1-based index)
  40    #[serde(default)]
  41    pub start_line: Option<u32>,
  42    /// Optional line number to end reading on (1-based index, inclusive)
  43    #[serde(default)]
  44    pub end_line: Option<u32>,
  45}
  46
  47pub struct ReadFileTool {
  48    thread: WeakEntity<Thread>,
  49    project: Entity<Project>,
  50    action_log: Entity<ActionLog>,
  51}
  52
  53impl ReadFileTool {
  54    pub fn new(
  55        thread: WeakEntity<Thread>,
  56        project: Entity<Project>,
  57        action_log: Entity<ActionLog>,
  58    ) -> Self {
  59        Self {
  60            thread,
  61            project,
  62            action_log,
  63        }
  64    }
  65}
  66
  67impl AgentTool for ReadFileTool {
  68    type Input = ReadFileToolInput;
  69    type Output = LanguageModelToolResultContent;
  70
  71    fn name() -> &'static str {
  72        "read_file"
  73    }
  74
  75    fn kind() -> acp::ToolKind {
  76        acp::ToolKind::Read
  77    }
  78
  79    fn initial_title(
  80        &self,
  81        input: Result<Self::Input, serde_json::Value>,
  82        cx: &mut App,
  83    ) -> SharedString {
  84        if let Ok(input) = input
  85            && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
  86            && let Some(path) = self
  87                .project
  88                .read(cx)
  89                .short_full_path_for_project_path(&project_path, cx)
  90        {
  91            match (input.start_line, input.end_line) {
  92                (Some(start), Some(end)) => {
  93                    format!("Read file `{path}` (lines {}-{})", start, end,)
  94                }
  95                (Some(start), None) => {
  96                    format!("Read file `{path}` (from line {})", start)
  97                }
  98                _ => format!("Read file `{path}`"),
  99            }
 100            .into()
 101        } else {
 102            "Read file".into()
 103        }
 104    }
 105
 106    fn run(
 107        self: Arc<Self>,
 108        input: Self::Input,
 109        event_stream: ToolCallEventStream,
 110        cx: &mut App,
 111    ) -> Task<Result<LanguageModelToolResultContent>> {
 112        let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
 113            return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
 114        };
 115        let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
 116            return Task::ready(Err(anyhow!(
 117                "Failed to convert {} to absolute path",
 118                &input.path
 119            )));
 120        };
 121
 122        // Error out if this path is either excluded or private in global settings
 123        let global_settings = WorktreeSettings::get_global(cx);
 124        if global_settings.is_path_excluded(&project_path.path) {
 125            return Task::ready(Err(anyhow!(
 126                "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
 127                &input.path
 128            )));
 129        }
 130
 131        if global_settings.is_path_private(&project_path.path) {
 132            return Task::ready(Err(anyhow!(
 133                "Cannot read file because its path matches the global `private_files` setting: {}",
 134                &input.path
 135            )));
 136        }
 137
 138        // Error out if this path is either excluded or private in worktree settings
 139        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
 140        if worktree_settings.is_path_excluded(&project_path.path) {
 141            return Task::ready(Err(anyhow!(
 142                "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
 143                &input.path
 144            )));
 145        }
 146
 147        if worktree_settings.is_path_private(&project_path.path) {
 148            return Task::ready(Err(anyhow!(
 149                "Cannot read file because its path matches the worktree `private_files` setting: {}",
 150                &input.path
 151            )));
 152        }
 153
 154        let file_path = input.path.clone();
 155
 156        event_stream.update_fields(ToolCallUpdateFields {
 157            locations: Some(vec![acp::ToolCallLocation {
 158                path: abs_path.clone(),
 159                line: input.start_line.map(|line| line.saturating_sub(1)),
 160                meta: None,
 161            }]),
 162            ..Default::default()
 163        });
 164
 165        if image_store::is_image_file(&self.project, &project_path, cx) {
 166            return cx.spawn(async move |cx| {
 167                let image_entity: Entity<ImageItem> = cx
 168                    .update(|cx| {
 169                        self.project.update(cx, |project, cx| {
 170                            project.open_image(project_path.clone(), cx)
 171                        })
 172                    })?
 173                    .await?;
 174
 175                let image =
 176                    image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
 177
 178                let language_model_image = cx
 179                    .update(|cx| LanguageModelImage::from_image(image, cx))?
 180                    .await
 181                    .context("processing image")?;
 182
 183                Ok(language_model_image.into())
 184            });
 185        }
 186
 187        let project = self.project.clone();
 188        let action_log = self.action_log.clone();
 189
 190        cx.spawn(async move |cx| {
 191            let buffer = cx
 192                .update(|cx| {
 193                    project.update(cx, |project, cx| {
 194                        project.open_buffer(project_path.clone(), cx)
 195                    })
 196                })?
 197                .await?;
 198            if buffer.read_with(cx, |buffer, _| {
 199                buffer
 200                    .file()
 201                    .as_ref()
 202                    .is_none_or(|file| !file.disk_state().exists())
 203            })? {
 204                anyhow::bail!("{file_path} not found");
 205            }
 206
 207            // Record the file read time and mtime
 208            if let Some(mtime) = buffer.read_with(cx, |buffer, _| {
 209                buffer.file().and_then(|file| file.disk_state().mtime())
 210            })? {
 211                self.thread
 212                    .update(cx, |thread, _| {
 213                        thread.file_read_times.insert(abs_path.to_path_buf(), mtime);
 214                    })
 215                    .ok();
 216            }
 217
 218            let mut anchor = None;
 219
 220            // Check if specific line ranges are provided
 221            let result = if input.start_line.is_some() || input.end_line.is_some() {
 222                let result = buffer.read_with(cx, |buffer, _cx| {
 223                    // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
 224                    let start = input.start_line.unwrap_or(1).max(1);
 225                    let start_row = start - 1;
 226                    if start_row <= buffer.max_point().row {
 227                        let column = buffer.line_indent_for_row(start_row).raw_len();
 228                        anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
 229                    }
 230
 231                    let mut end_row = input.end_line.unwrap_or(u32::MAX);
 232                    if end_row <= start_row {
 233                        end_row = start_row + 1; // read at least one lines
 234                    }
 235                    let start = buffer.anchor_before(Point::new(start_row, 0));
 236                    let end = buffer.anchor_before(Point::new(end_row, 0));
 237                    buffer.text_for_range(start..end).collect::<String>()
 238                })?;
 239
 240                action_log.update(cx, |log, cx| {
 241                    log.buffer_read(buffer.clone(), cx);
 242                })?;
 243
 244                Ok(result.into())
 245            } else {
 246                // No line ranges specified, so check file size to see if it's too big.
 247                let buffer_content = outline::get_buffer_content_or_outline(
 248                    buffer.clone(),
 249                    Some(&abs_path.to_string_lossy()),
 250                    cx,
 251                )
 252                .await?;
 253
 254                action_log.update(cx, |log, cx| {
 255                    log.buffer_read(buffer.clone(), cx);
 256                })?;
 257
 258                if buffer_content.is_outline {
 259                    Ok(formatdoc! {"
 260                        SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers.
 261
 262                        IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
 263                        Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
 264
 265                        {}
 266
 267                        NEXT STEPS: To read a specific symbol's implementation, call read_file with the same path plus start_line and end_line from the outline above.
 268                        For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
 269                    }
 270                    .into())
 271                } else {
 272                    Ok(buffer_content.text.into())
 273                }
 274            };
 275
 276            project.update(cx, |project, cx| {
 277                project.set_agent_location(
 278                    Some(AgentLocation {
 279                        buffer: buffer.downgrade(),
 280                        position: anchor.unwrap_or_else(|| {
 281                            text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
 282                        }),
 283                    }),
 284                    cx,
 285                );
 286                if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
 287                    let markdown = MarkdownCodeBlock {
 288                        tag: &input.path,
 289                        text,
 290                    }
 291                    .to_string();
 292                    event_stream.update_fields(ToolCallUpdateFields {
 293                        content: Some(vec![acp::ToolCallContent::Content {
 294                            content: markdown.into(),
 295                        }]),
 296                        ..Default::default()
 297                    })
 298                }
 299            })?;
 300
 301            result
 302        })
 303    }
 304}
 305
 306#[cfg(test)]
 307mod test {
 308    use super::*;
 309    use crate::{ContextServerRegistry, Templates, Thread};
 310    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
 311    use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
 312    use language_model::fake_provider::FakeLanguageModel;
 313    use project::{FakeFs, Project};
 314    use prompt_store::ProjectContext;
 315    use serde_json::json;
 316    use settings::SettingsStore;
 317    use std::sync::Arc;
 318    use util::path;
 319
 320    #[gpui::test]
 321    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 322        init_test(cx);
 323
 324        let fs = FakeFs::new(cx.executor());
 325        fs.insert_tree(path!("/root"), json!({})).await;
 326        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 327        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 328        let context_server_registry =
 329            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 330        let model = Arc::new(FakeLanguageModel::default());
 331        let thread = cx.new(|cx| {
 332            Thread::new(
 333                project.clone(),
 334                cx.new(|_cx| ProjectContext::default()),
 335                context_server_registry,
 336                Templates::new(),
 337                Some(model),
 338                cx,
 339            )
 340        });
 341        let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
 342        let (event_stream, _) = ToolCallEventStream::test();
 343
 344        let result = cx
 345            .update(|cx| {
 346                let input = ReadFileToolInput {
 347                    path: "root/nonexistent_file.txt".to_string(),
 348                    start_line: None,
 349                    end_line: None,
 350                };
 351                tool.run(input, event_stream, cx)
 352            })
 353            .await;
 354        assert_eq!(
 355            result.unwrap_err().to_string(),
 356            "root/nonexistent_file.txt not found"
 357        );
 358    }
 359
 360    #[gpui::test]
 361    async fn test_read_small_file(cx: &mut TestAppContext) {
 362        init_test(cx);
 363
 364        let fs = FakeFs::new(cx.executor());
 365        fs.insert_tree(
 366            path!("/root"),
 367            json!({
 368                "small_file.txt": "This is a small file content"
 369            }),
 370        )
 371        .await;
 372        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 373        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 374        let context_server_registry =
 375            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 376        let model = Arc::new(FakeLanguageModel::default());
 377        let thread = cx.new(|cx| {
 378            Thread::new(
 379                project.clone(),
 380                cx.new(|_cx| ProjectContext::default()),
 381                context_server_registry,
 382                Templates::new(),
 383                Some(model),
 384                cx,
 385            )
 386        });
 387        let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
 388        let result = cx
 389            .update(|cx| {
 390                let input = ReadFileToolInput {
 391                    path: "root/small_file.txt".into(),
 392                    start_line: None,
 393                    end_line: None,
 394                };
 395                tool.run(input, ToolCallEventStream::test().0, cx)
 396            })
 397            .await;
 398        assert_eq!(result.unwrap(), "This is a small file content".into());
 399    }
 400
 401    #[gpui::test]
 402    async fn test_read_large_file(cx: &mut TestAppContext) {
 403        init_test(cx);
 404
 405        let fs = FakeFs::new(cx.executor());
 406        fs.insert_tree(
 407            path!("/root"),
 408            json!({
 409                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 410            }),
 411        )
 412        .await;
 413        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 414        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 415        language_registry.add(Arc::new(rust_lang()));
 416        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 417        let context_server_registry =
 418            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 419        let model = Arc::new(FakeLanguageModel::default());
 420        let thread = cx.new(|cx| {
 421            Thread::new(
 422                project.clone(),
 423                cx.new(|_cx| ProjectContext::default()),
 424                context_server_registry,
 425                Templates::new(),
 426                Some(model),
 427                cx,
 428            )
 429        });
 430        let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
 431        let result = cx
 432            .update(|cx| {
 433                let input = ReadFileToolInput {
 434                    path: "root/large_file.rs".into(),
 435                    start_line: None,
 436                    end_line: None,
 437                };
 438                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 439            })
 440            .await
 441            .unwrap();
 442        let content = result.to_str().unwrap();
 443
 444        assert_eq!(
 445            content.lines().skip(7).take(6).collect::<Vec<_>>(),
 446            vec![
 447                "struct Test0 [L1-4]",
 448                " a [L2]",
 449                " b [L3]",
 450                "struct Test1 [L5-8]",
 451                " a [L6]",
 452                " b [L7]",
 453            ]
 454        );
 455
 456        let result = cx
 457            .update(|cx| {
 458                let input = ReadFileToolInput {
 459                    path: "root/large_file.rs".into(),
 460                    start_line: None,
 461                    end_line: None,
 462                };
 463                tool.run(input, ToolCallEventStream::test().0, cx)
 464            })
 465            .await
 466            .unwrap();
 467        let content = result.to_str().unwrap();
 468        let expected_content = (0..1000)
 469            .flat_map(|i| {
 470                vec![
 471                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 472                    format!(" a [L{}]", i * 4 + 2),
 473                    format!(" b [L{}]", i * 4 + 3),
 474                ]
 475            })
 476            .collect::<Vec<_>>();
 477        pretty_assertions::assert_eq!(
 478            content
 479                .lines()
 480                .skip(7)
 481                .take(expected_content.len())
 482                .collect::<Vec<_>>(),
 483            expected_content
 484        );
 485    }
 486
 487    #[gpui::test]
 488    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 489        init_test(cx);
 490
 491        let fs = FakeFs::new(cx.executor());
 492        fs.insert_tree(
 493            path!("/root"),
 494            json!({
 495                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 496            }),
 497        )
 498        .await;
 499        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 500
 501        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 502        let context_server_registry =
 503            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 504        let model = Arc::new(FakeLanguageModel::default());
 505        let thread = cx.new(|cx| {
 506            Thread::new(
 507                project.clone(),
 508                cx.new(|_cx| ProjectContext::default()),
 509                context_server_registry,
 510                Templates::new(),
 511                Some(model),
 512                cx,
 513            )
 514        });
 515        let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
 516        let result = cx
 517            .update(|cx| {
 518                let input = ReadFileToolInput {
 519                    path: "root/multiline.txt".to_string(),
 520                    start_line: Some(2),
 521                    end_line: Some(4),
 522                };
 523                tool.run(input, ToolCallEventStream::test().0, cx)
 524            })
 525            .await;
 526        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
 527    }
 528
 529    #[gpui::test]
 530    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 531        init_test(cx);
 532
 533        let fs = FakeFs::new(cx.executor());
 534        fs.insert_tree(
 535            path!("/root"),
 536            json!({
 537                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 538            }),
 539        )
 540        .await;
 541        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 542        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 543        let context_server_registry =
 544            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 545        let model = Arc::new(FakeLanguageModel::default());
 546        let thread = cx.new(|cx| {
 547            Thread::new(
 548                project.clone(),
 549                cx.new(|_cx| ProjectContext::default()),
 550                context_server_registry,
 551                Templates::new(),
 552                Some(model),
 553                cx,
 554            )
 555        });
 556        let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
 557
 558        // start_line of 0 should be treated as 1
 559        let result = cx
 560            .update(|cx| {
 561                let input = ReadFileToolInput {
 562                    path: "root/multiline.txt".to_string(),
 563                    start_line: Some(0),
 564                    end_line: Some(2),
 565                };
 566                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 567            })
 568            .await;
 569        assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
 570
 571        // end_line of 0 should result in at least 1 line
 572        let result = cx
 573            .update(|cx| {
 574                let input = ReadFileToolInput {
 575                    path: "root/multiline.txt".to_string(),
 576                    start_line: Some(1),
 577                    end_line: Some(0),
 578                };
 579                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 580            })
 581            .await;
 582        assert_eq!(result.unwrap(), "Line 1\n".into());
 583
 584        // when start_line > end_line, should still return at least 1 line
 585        let result = cx
 586            .update(|cx| {
 587                let input = ReadFileToolInput {
 588                    path: "root/multiline.txt".to_string(),
 589                    start_line: Some(3),
 590                    end_line: Some(2),
 591                };
 592                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 593            })
 594            .await;
 595        assert_eq!(result.unwrap(), "Line 3\n".into());
 596    }
 597
 598    fn init_test(cx: &mut TestAppContext) {
 599        cx.update(|cx| {
 600            let settings_store = SettingsStore::test(cx);
 601            cx.set_global(settings_store);
 602        });
 603    }
 604
 605    fn rust_lang() -> Language {
 606        Language::new(
 607            LanguageConfig {
 608                name: "Rust".into(),
 609                matcher: LanguageMatcher {
 610                    path_suffixes: vec!["rs".to_string()],
 611                    ..Default::default()
 612                },
 613                ..Default::default()
 614            },
 615            Some(tree_sitter_rust::LANGUAGE.into()),
 616        )
 617        .with_outline_query(
 618            r#"
 619            (line_comment) @annotation
 620
 621            (struct_item
 622                "struct" @context
 623                name: (_) @name) @item
 624            (enum_item
 625                "enum" @context
 626                name: (_) @name) @item
 627            (enum_variant
 628                name: (_) @name) @item
 629            (field_declaration
 630                name: (_) @name) @item
 631            (impl_item
 632                "impl" @context
 633                trait: (_)? @name
 634                "for"? @context
 635                type: (_) @name
 636                body: (_ "{" (_)* "}")) @item
 637            (function_item
 638                "fn" @context
 639                name: (_) @name) @item
 640            (mod_item
 641                "mod" @context
 642                name: (_) @name) @item
 643            "#,
 644        )
 645        .unwrap()
 646    }
 647
 648    #[gpui::test]
 649    async fn test_read_file_security(cx: &mut TestAppContext) {
 650        init_test(cx);
 651
 652        let fs = FakeFs::new(cx.executor());
 653
 654        fs.insert_tree(
 655            path!("/"),
 656            json!({
 657                "project_root": {
 658                    "allowed_file.txt": "This file is in the project",
 659                    ".mysecrets": "SECRET_KEY=abc123",
 660                    ".secretdir": {
 661                        "config": "special configuration"
 662                    },
 663                    ".mymetadata": "custom metadata",
 664                    "subdir": {
 665                        "normal_file.txt": "Normal file content",
 666                        "special.privatekey": "private key content",
 667                        "data.mysensitive": "sensitive data"
 668                    }
 669                },
 670                "outside_project": {
 671                    "sensitive_file.txt": "This file is outside the project"
 672                }
 673            }),
 674        )
 675        .await;
 676
 677        cx.update(|cx| {
 678            use gpui::UpdateGlobal;
 679            use settings::SettingsStore;
 680            SettingsStore::update_global(cx, |store, cx| {
 681                store.update_user_settings(cx, |settings| {
 682                    settings.project.worktree.file_scan_exclusions = Some(vec![
 683                        "**/.secretdir".to_string(),
 684                        "**/.mymetadata".to_string(),
 685                    ]);
 686                    settings.project.worktree.private_files = Some(
 687                        vec![
 688                            "**/.mysecrets".to_string(),
 689                            "**/*.privatekey".to_string(),
 690                            "**/*.mysensitive".to_string(),
 691                        ]
 692                        .into(),
 693                    );
 694                });
 695            });
 696        });
 697
 698        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 699        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 700        let context_server_registry =
 701            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 702        let model = Arc::new(FakeLanguageModel::default());
 703        let thread = cx.new(|cx| {
 704            Thread::new(
 705                project.clone(),
 706                cx.new(|_cx| ProjectContext::default()),
 707                context_server_registry,
 708                Templates::new(),
 709                Some(model),
 710                cx,
 711            )
 712        });
 713        let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
 714
 715        // Reading a file outside the project worktree should fail
 716        let result = cx
 717            .update(|cx| {
 718                let input = ReadFileToolInput {
 719                    path: "/outside_project/sensitive_file.txt".to_string(),
 720                    start_line: None,
 721                    end_line: None,
 722                };
 723                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 724            })
 725            .await;
 726        assert!(
 727            result.is_err(),
 728            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 729        );
 730
 731        // Reading a file within the project should succeed
 732        let result = cx
 733            .update(|cx| {
 734                let input = ReadFileToolInput {
 735                    path: "project_root/allowed_file.txt".to_string(),
 736                    start_line: None,
 737                    end_line: None,
 738                };
 739                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 740            })
 741            .await;
 742        assert!(
 743            result.is_ok(),
 744            "read_file_tool should be able to read files inside worktrees"
 745        );
 746
 747        // Reading files that match file_scan_exclusions should fail
 748        let result = cx
 749            .update(|cx| {
 750                let input = ReadFileToolInput {
 751                    path: "project_root/.secretdir/config".to_string(),
 752                    start_line: None,
 753                    end_line: None,
 754                };
 755                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 756            })
 757            .await;
 758        assert!(
 759            result.is_err(),
 760            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 761        );
 762
 763        let result = cx
 764            .update(|cx| {
 765                let input = ReadFileToolInput {
 766                    path: "project_root/.mymetadata".to_string(),
 767                    start_line: None,
 768                    end_line: None,
 769                };
 770                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 771            })
 772            .await;
 773        assert!(
 774            result.is_err(),
 775            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 776        );
 777
 778        // Reading private files should fail
 779        let result = cx
 780            .update(|cx| {
 781                let input = ReadFileToolInput {
 782                    path: "project_root/.mysecrets".to_string(),
 783                    start_line: None,
 784                    end_line: None,
 785                };
 786                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 787            })
 788            .await;
 789        assert!(
 790            result.is_err(),
 791            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 792        );
 793
 794        let result = cx
 795            .update(|cx| {
 796                let input = ReadFileToolInput {
 797                    path: "project_root/subdir/special.privatekey".to_string(),
 798                    start_line: None,
 799                    end_line: None,
 800                };
 801                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 802            })
 803            .await;
 804        assert!(
 805            result.is_err(),
 806            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 807        );
 808
 809        let result = cx
 810            .update(|cx| {
 811                let input = ReadFileToolInput {
 812                    path: "project_root/subdir/data.mysensitive".to_string(),
 813                    start_line: None,
 814                    end_line: None,
 815                };
 816                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 817            })
 818            .await;
 819        assert!(
 820            result.is_err(),
 821            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 822        );
 823
 824        // Reading a normal file should still work, even with private_files configured
 825        let result = cx
 826            .update(|cx| {
 827                let input = ReadFileToolInput {
 828                    path: "project_root/subdir/normal_file.txt".to_string(),
 829                    start_line: None,
 830                    end_line: None,
 831                };
 832                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 833            })
 834            .await;
 835        assert!(result.is_ok(), "Should be able to read normal files");
 836        assert_eq!(result.unwrap(), "Normal file content".into());
 837
 838        // Path traversal attempts with .. should fail
 839        let result = cx
 840            .update(|cx| {
 841                let input = ReadFileToolInput {
 842                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
 843                    start_line: None,
 844                    end_line: None,
 845                };
 846                tool.run(input, ToolCallEventStream::test().0, cx)
 847            })
 848            .await;
 849        assert!(
 850            result.is_err(),
 851            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 852        );
 853    }
 854
 855    #[gpui::test]
 856    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 857        init_test(cx);
 858
 859        let fs = FakeFs::new(cx.executor());
 860
 861        // Create first worktree with its own private_files setting
 862        fs.insert_tree(
 863            path!("/worktree1"),
 864            json!({
 865                "src": {
 866                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 867                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 868                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 869                },
 870                "tests": {
 871                    "test.rs": "mod tests { fn test_it() {} }",
 872                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 873                },
 874                ".zed": {
 875                    "settings.json": r#"{
 876                        "file_scan_exclusions": ["**/fixture.*"],
 877                        "private_files": ["**/secret.rs", "**/config.toml"]
 878                    }"#
 879                }
 880            }),
 881        )
 882        .await;
 883
 884        // Create second worktree with different private_files setting
 885        fs.insert_tree(
 886            path!("/worktree2"),
 887            json!({
 888                "lib": {
 889                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 890                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 891                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 892                },
 893                "docs": {
 894                    "README.md": "# Public Documentation",
 895                    "internal.md": "# Internal Secrets and Configuration"
 896                },
 897                ".zed": {
 898                    "settings.json": r#"{
 899                        "file_scan_exclusions": ["**/internal.*"],
 900                        "private_files": ["**/private.js", "**/data.json"]
 901                    }"#
 902                }
 903            }),
 904        )
 905        .await;
 906
 907        // Set global settings
 908        cx.update(|cx| {
 909            SettingsStore::update_global(cx, |store, cx| {
 910                store.update_user_settings(cx, |settings| {
 911                    settings.project.worktree.file_scan_exclusions =
 912                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 913                    settings.project.worktree.private_files =
 914                        Some(vec!["**/.env".to_string()].into());
 915                });
 916            });
 917        });
 918
 919        let project = Project::test(
 920            fs.clone(),
 921            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 922            cx,
 923        )
 924        .await;
 925
 926        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 927        let context_server_registry =
 928            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 929        let model = Arc::new(FakeLanguageModel::default());
 930        let thread = cx.new(|cx| {
 931            Thread::new(
 932                project.clone(),
 933                cx.new(|_cx| ProjectContext::default()),
 934                context_server_registry,
 935                Templates::new(),
 936                Some(model),
 937                cx,
 938            )
 939        });
 940        let tool = Arc::new(ReadFileTool::new(
 941            thread.downgrade(),
 942            project.clone(),
 943            action_log.clone(),
 944        ));
 945
 946        // Test reading allowed files in worktree1
 947        let result = cx
 948            .update(|cx| {
 949                let input = ReadFileToolInput {
 950                    path: "worktree1/src/main.rs".to_string(),
 951                    start_line: None,
 952                    end_line: None,
 953                };
 954                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 955            })
 956            .await
 957            .unwrap();
 958
 959        assert_eq!(
 960            result,
 961            "fn main() { println!(\"Hello from worktree1\"); }".into()
 962        );
 963
 964        // Test reading private file in worktree1 should fail
 965        let result = cx
 966            .update(|cx| {
 967                let input = ReadFileToolInput {
 968                    path: "worktree1/src/secret.rs".to_string(),
 969                    start_line: None,
 970                    end_line: None,
 971                };
 972                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 973            })
 974            .await;
 975
 976        assert!(result.is_err());
 977        assert!(
 978            result
 979                .unwrap_err()
 980                .to_string()
 981                .contains("worktree `private_files` setting"),
 982            "Error should mention worktree private_files setting"
 983        );
 984
 985        // Test reading excluded file in worktree1 should fail
 986        let result = cx
 987            .update(|cx| {
 988                let input = ReadFileToolInput {
 989                    path: "worktree1/tests/fixture.sql".to_string(),
 990                    start_line: None,
 991                    end_line: None,
 992                };
 993                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 994            })
 995            .await;
 996
 997        assert!(result.is_err());
 998        assert!(
 999            result
1000                .unwrap_err()
1001                .to_string()
1002                .contains("worktree `file_scan_exclusions` setting"),
1003            "Error should mention worktree file_scan_exclusions setting"
1004        );
1005
1006        // Test reading allowed files in worktree2
1007        let result = cx
1008            .update(|cx| {
1009                let input = ReadFileToolInput {
1010                    path: "worktree2/lib/public.js".to_string(),
1011                    start_line: None,
1012                    end_line: None,
1013                };
1014                tool.clone().run(input, ToolCallEventStream::test().0, cx)
1015            })
1016            .await
1017            .unwrap();
1018
1019        assert_eq!(
1020            result,
1021            "export function greet() { return 'Hello from worktree2'; }".into()
1022        );
1023
1024        // Test reading private file in worktree2 should fail
1025        let result = cx
1026            .update(|cx| {
1027                let input = ReadFileToolInput {
1028                    path: "worktree2/lib/private.js".to_string(),
1029                    start_line: None,
1030                    end_line: None,
1031                };
1032                tool.clone().run(input, ToolCallEventStream::test().0, cx)
1033            })
1034            .await;
1035
1036        assert!(result.is_err());
1037        assert!(
1038            result
1039                .unwrap_err()
1040                .to_string()
1041                .contains("worktree `private_files` setting"),
1042            "Error should mention worktree private_files setting"
1043        );
1044
1045        // Test reading excluded file in worktree2 should fail
1046        let result = cx
1047            .update(|cx| {
1048                let input = ReadFileToolInput {
1049                    path: "worktree2/docs/internal.md".to_string(),
1050                    start_line: None,
1051                    end_line: None,
1052                };
1053                tool.clone().run(input, ToolCallEventStream::test().0, cx)
1054            })
1055            .await;
1056
1057        assert!(result.is_err());
1058        assert!(
1059            result
1060                .unwrap_err()
1061                .to_string()
1062                .contains("worktree `file_scan_exclusions` setting"),
1063            "Error should mention worktree file_scan_exclusions setting"
1064        );
1065
1066        // Test that files allowed in one worktree but not in another are handled correctly
1067        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1068        let result = cx
1069            .update(|cx| {
1070                let input = ReadFileToolInput {
1071                    path: "worktree1/src/config.toml".to_string(),
1072                    start_line: None,
1073                    end_line: None,
1074                };
1075                tool.clone().run(input, ToolCallEventStream::test().0, cx)
1076            })
1077            .await;
1078
1079        assert!(result.is_err());
1080        assert!(
1081            result
1082                .unwrap_err()
1083                .to_string()
1084                .contains("worktree `private_files` setting"),
1085            "Config.toml should be blocked by worktree1's private_files setting"
1086        );
1087    }
1088}