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