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