read_file_tool.rs

   1use crate::schema::json_schema_for;
   2use anyhow::{Context as _, Result, anyhow};
   3use assistant_tool::{ActionLog, Tool, ToolResult};
   4use assistant_tool::{ToolResultContent, outline};
   5use gpui::{AnyWindowHandle, App, Entity, Task};
   6use project::{ImageItem, image_store};
   7
   8use assistant_tool::ToolResultOutput;
   9use indoc::formatdoc;
  10use itertools::Itertools;
  11use language::{Anchor, Point};
  12use language_model::{
  13    LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat,
  14};
  15use project::{AgentLocation, Project, WorktreeSettings};
  16use schemars::JsonSchema;
  17use serde::{Deserialize, Serialize};
  18use settings::Settings;
  19use std::sync::Arc;
  20use ui::IconName;
  21use util::markdown::MarkdownInlineCode;
  22
  23/// If the model requests to read a file whose size exceeds this, then
  24#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  25pub struct ReadFileToolInput {
  26    /// The relative path of the file to read.
  27    ///
  28    /// This path should never be absolute, and the first component
  29    /// of the path should always be a root directory in a project.
  30    ///
  31    /// <example>
  32    /// If the project has the following root directories:
  33    ///
  34    /// - /a/b/directory1
  35    /// - /c/d/directory2
  36    ///
  37    /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
  38    /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
  39    /// </example>
  40    pub path: String,
  41
  42    /// Optional line number to start reading on (1-based index)
  43    #[serde(default)]
  44    pub start_line: Option<u32>,
  45
  46    /// Optional line number to end reading on (1-based index, inclusive)
  47    #[serde(default)]
  48    pub end_line: Option<u32>,
  49}
  50
  51pub struct ReadFileTool;
  52
  53impl Tool for ReadFileTool {
  54    type Input = ReadFileToolInput;
  55
  56    fn name(&self) -> String {
  57        "read_file".into()
  58    }
  59
  60    fn needs_confirmation(&self, _: &Self::Input, _: &App) -> bool {
  61        false
  62    }
  63
  64    fn may_perform_edits(&self) -> bool {
  65        false
  66    }
  67
  68    fn description(&self) -> String {
  69        include_str!("./read_file_tool/description.md").into()
  70    }
  71
  72    fn icon(&self) -> IconName {
  73        IconName::FileSearch
  74    }
  75
  76    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
  77        json_schema_for::<ReadFileToolInput>(format)
  78    }
  79
  80    fn ui_text(&self, input: &Self::Input) -> String {
  81        let path = MarkdownInlineCode(&input.path);
  82        match (input.start_line, input.end_line) {
  83            (Some(start), None) => format!("Read file {path} (from line {start})"),
  84            (Some(start), Some(end)) => format!("Read file {path} (lines {start}-{end})"),
  85            _ => format!("Read file {path}"),
  86        }
  87    }
  88
  89    fn run(
  90        self: Arc<Self>,
  91        input: Self::Input,
  92        _request: Arc<LanguageModelRequest>,
  93        project: Entity<Project>,
  94        action_log: Entity<ActionLog>,
  95        model: Arc<dyn LanguageModel>,
  96        _window: Option<AnyWindowHandle>,
  97        cx: &mut App,
  98    ) -> ToolResult {
  99        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
 100            return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
 101        };
 102
 103        // Error out if this path is either excluded or private in global settings
 104        let global_settings = WorktreeSettings::get_global(cx);
 105        if global_settings.is_path_excluded(&project_path.path) {
 106            return Task::ready(Err(anyhow!(
 107                "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
 108                &input.path
 109            )))
 110            .into();
 111        }
 112
 113        if global_settings.is_path_private(&project_path.path) {
 114            return Task::ready(Err(anyhow!(
 115                "Cannot read file because its path matches the global `private_files` setting: {}",
 116                &input.path
 117            )))
 118            .into();
 119        }
 120
 121        // Error out if this path is either excluded or private in worktree settings
 122        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
 123        if worktree_settings.is_path_excluded(&project_path.path) {
 124            return Task::ready(Err(anyhow!(
 125                "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
 126                &input.path
 127            )))
 128            .into();
 129        }
 130
 131        if worktree_settings.is_path_private(&project_path.path) {
 132            return Task::ready(Err(anyhow!(
 133                "Cannot read file because its path matches the worktree `private_files` setting: {}",
 134                &input.path
 135            )))
 136            .into();
 137        }
 138
 139        let file_path = input.path.clone();
 140
 141        if image_store::is_image_file(&project, &project_path, cx) {
 142            if !model.supports_images() {
 143                return Task::ready(Err(anyhow!(
 144                    "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
 145                    model.name().0
 146                )))
 147                .into();
 148            }
 149
 150            let task = cx.spawn(async move |cx| -> Result<ToolResultOutput> {
 151                let image_entity: Entity<ImageItem> = cx
 152                    .update(|cx| {
 153                        project.update(cx, |project, cx| {
 154                            project.open_image(project_path.clone(), cx)
 155                        })
 156                    })?
 157                    .await?;
 158
 159                let image =
 160                    image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
 161
 162                let language_model_image = cx
 163                    .update(|cx| LanguageModelImage::from_image(image, cx))?
 164                    .await
 165                    .context("processing image")?;
 166
 167                Ok(ToolResultOutput {
 168                    content: ToolResultContent::Image(language_model_image),
 169                    output: None,
 170                })
 171            });
 172
 173            return task.into();
 174        }
 175
 176        cx.spawn(async move |cx| {
 177            let buffer = cx
 178                .update(|cx| {
 179                    project.update(cx, |project, cx| project.open_buffer(project_path, cx))
 180                })?
 181                .await?;
 182            if buffer.read_with(cx, |buffer, _| {
 183                buffer
 184                    .file()
 185                    .as_ref()
 186                    .map_or(true, |file| !file.disk_state().exists())
 187            })? {
 188                anyhow::bail!("{file_path} not found");
 189            }
 190
 191            project.update(cx, |project, cx| {
 192                project.set_agent_location(
 193                    Some(AgentLocation {
 194                        buffer: buffer.downgrade(),
 195                        position: Anchor::MIN,
 196                    }),
 197                    cx,
 198                );
 199            })?;
 200
 201            // Check if specific line ranges are provided
 202            if input.start_line.is_some() || input.end_line.is_some() {
 203                let mut anchor = None;
 204                let result = buffer.read_with(cx, |buffer, _cx| {
 205                    let text = buffer.text();
 206                    // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
 207                    let start = input.start_line.unwrap_or(1).max(1);
 208                    let start_row = start - 1;
 209                    if start_row <= buffer.max_point().row {
 210                        let column = buffer.line_indent_for_row(start_row).raw_len();
 211                        anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
 212                    }
 213
 214                    let lines = text.split('\n').skip(start_row as usize);
 215                    if let Some(end) = input.end_line {
 216                        let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
 217                        Itertools::intersperse(lines.take(count as usize), "\n")
 218                            .collect::<String>()
 219                            .into()
 220                    } else {
 221                        Itertools::intersperse(lines, "\n")
 222                            .collect::<String>()
 223                            .into()
 224                    }
 225                })?;
 226
 227                action_log.update(cx, |log, cx| {
 228                    log.buffer_read(buffer.clone(), cx);
 229                })?;
 230
 231                if let Some(anchor) = anchor {
 232                    project.update(cx, |project, cx| {
 233                        project.set_agent_location(
 234                            Some(AgentLocation {
 235                                buffer: buffer.downgrade(),
 236                                position: anchor,
 237                            }),
 238                            cx,
 239                        );
 240                    })?;
 241                }
 242
 243                Ok(result)
 244            } else {
 245                // No line ranges specified, so check file size to see if it's too big.
 246                let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
 247
 248                if file_size <= outline::AUTO_OUTLINE_SIZE {
 249                    // File is small enough, so return its contents.
 250                    let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
 251
 252                    action_log.update(cx, |log, cx| {
 253                        log.buffer_read(buffer, cx);
 254                    })?;
 255
 256                    Ok(result.into())
 257                } else {
 258                    // File is too big, so return the outline
 259                    // and a suggestion to read again with line numbers.
 260                    let outline =
 261                        outline::file_outline(project, file_path, action_log, None, cx).await?;
 262                    Ok(formatdoc! {"
 263                        This file was too big to read all at once.
 264
 265                        Here is an outline of its symbols:
 266
 267                        {outline}
 268
 269                        Using the line numbers in this outline, you can call this tool again
 270                        while specifying the start_line and end_line fields to see the
 271                        implementations of symbols in the outline."
 272                    }
 273                    .into())
 274                }
 275            }
 276        })
 277        .into()
 278    }
 279}
 280
 281#[cfg(test)]
 282mod test {
 283    use super::*;
 284    use gpui::{AppContext, TestAppContext, UpdateGlobal};
 285    use language::{Language, LanguageConfig, LanguageMatcher};
 286    use language_model::fake_provider::FakeLanguageModel;
 287    use project::{FakeFs, Project, WorktreeSettings};
 288    use serde_json::json;
 289    use settings::SettingsStore;
 290    use util::path;
 291
 292    #[gpui::test]
 293    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 294        init_test(cx);
 295
 296        let fs = FakeFs::new(cx.executor());
 297        fs.insert_tree(path!("/root"), json!({})).await;
 298        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 299        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 300        let model = Arc::new(FakeLanguageModel::default());
 301        let result = cx
 302            .update(|cx| {
 303                let input = ReadFileToolInput {
 304                    path: "root/nonexistent_file.txt".to_string(),
 305                    start_line: None,
 306                    end_line: None,
 307                };
 308
 309                Arc::new(ReadFileTool)
 310                    .run(
 311                        input,
 312                        Arc::default(),
 313                        project.clone(),
 314                        action_log,
 315                        model,
 316                        None,
 317                        cx,
 318                    )
 319                    .output
 320            })
 321            .await;
 322        assert_eq!(
 323            result.unwrap_err().to_string(),
 324            "root/nonexistent_file.txt not found"
 325        );
 326    }
 327
 328    #[gpui::test]
 329    async fn test_read_small_file(cx: &mut TestAppContext) {
 330        init_test(cx);
 331
 332        let fs = FakeFs::new(cx.executor());
 333        fs.insert_tree(
 334            path!("/root"),
 335            json!({
 336                "small_file.txt": "This is a small file content"
 337            }),
 338        )
 339        .await;
 340        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 341        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 342        let model = Arc::new(FakeLanguageModel::default());
 343        let result = cx
 344            .update(|cx| {
 345                let input = ReadFileToolInput {
 346                    path: "root/small_file.txt".to_string(),
 347                    start_line: None,
 348                    end_line: None,
 349                };
 350                Arc::new(ReadFileTool)
 351                    .run(
 352                        input,
 353                        Arc::default(),
 354                        project.clone(),
 355                        action_log,
 356                        model,
 357                        None,
 358                        cx,
 359                    )
 360                    .output
 361            })
 362            .await;
 363        assert_eq!(
 364            result.unwrap().content.as_str(),
 365            Some("This is a small file content")
 366        );
 367    }
 368
 369    #[gpui::test]
 370    async fn test_read_large_file(cx: &mut TestAppContext) {
 371        init_test(cx);
 372
 373        let fs = FakeFs::new(cx.executor());
 374        fs.insert_tree(
 375            path!("/root"),
 376            json!({
 377                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 378            }),
 379        )
 380        .await;
 381        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 382        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 383        language_registry.add(Arc::new(rust_lang()));
 384        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 385        let model = Arc::new(FakeLanguageModel::default());
 386
 387        let result = cx
 388            .update(|cx| {
 389                let input = ReadFileToolInput {
 390                    path: "root/large_file.rs".to_string(),
 391                    start_line: None,
 392                    end_line: None,
 393                };
 394                Arc::new(ReadFileTool)
 395                    .run(
 396                        input,
 397                        Arc::default(),
 398                        project.clone(),
 399                        action_log.clone(),
 400                        model.clone(),
 401                        None,
 402                        cx,
 403                    )
 404                    .output
 405            })
 406            .await;
 407        let content = result.unwrap();
 408        let content = content.as_str().unwrap();
 409        assert_eq!(
 410            content.lines().skip(4).take(6).collect::<Vec<_>>(),
 411            vec![
 412                "struct Test0 [L1-4]",
 413                " a [L2]",
 414                " b [L3]",
 415                "struct Test1 [L5-8]",
 416                " a [L6]",
 417                " b [L7]",
 418            ]
 419        );
 420
 421        let result = cx
 422            .update(|cx| {
 423                let input = ReadFileToolInput {
 424                    path: "root/large_file.rs".to_string(),
 425                    start_line: None,
 426                    end_line: None,
 427                };
 428                Arc::new(ReadFileTool)
 429                    .run(
 430                        input,
 431                        Arc::default(),
 432                        project.clone(),
 433                        action_log,
 434                        model,
 435                        None,
 436                        cx,
 437                    )
 438                    .output
 439            })
 440            .await;
 441        let content = result.unwrap();
 442        let expected_content = (0..1000)
 443            .flat_map(|i| {
 444                vec![
 445                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 446                    format!(" a [L{}]", i * 4 + 2),
 447                    format!(" b [L{}]", i * 4 + 3),
 448                ]
 449            })
 450            .collect::<Vec<_>>();
 451        pretty_assertions::assert_eq!(
 452            content
 453                .as_str()
 454                .unwrap()
 455                .lines()
 456                .skip(4)
 457                .take(expected_content.len())
 458                .collect::<Vec<_>>(),
 459            expected_content
 460        );
 461    }
 462
 463    #[gpui::test]
 464    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 465        init_test(cx);
 466
 467        let fs = FakeFs::new(cx.executor());
 468        fs.insert_tree(
 469            path!("/root"),
 470            json!({
 471                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 472            }),
 473        )
 474        .await;
 475        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 476        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 477        let model = Arc::new(FakeLanguageModel::default());
 478        let result = cx
 479            .update(|cx| {
 480                let input = ReadFileToolInput {
 481                    path: "root/multiline.txt".to_string(),
 482                    start_line: Some(2),
 483                    end_line: Some(4),
 484                };
 485                Arc::new(ReadFileTool)
 486                    .run(
 487                        input,
 488                        Arc::default(),
 489                        project.clone(),
 490                        action_log,
 491                        model,
 492                        None,
 493                        cx,
 494                    )
 495                    .output
 496            })
 497            .await;
 498        assert_eq!(
 499            result.unwrap().content.as_str(),
 500            Some("Line 2\nLine 3\nLine 4")
 501        );
 502    }
 503
 504    #[gpui::test]
 505    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 506        init_test(cx);
 507
 508        let fs = FakeFs::new(cx.executor());
 509        fs.insert_tree(
 510            path!("/root"),
 511            json!({
 512                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 513            }),
 514        )
 515        .await;
 516        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 517        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 518        let model = Arc::new(FakeLanguageModel::default());
 519
 520        // start_line of 0 should be treated as 1
 521        let result = cx
 522            .update(|cx| {
 523                let input = ReadFileToolInput {
 524                    path: "root/multiline.txt".to_string(),
 525                    start_line: Some(0),
 526                    end_line: Some(2),
 527                };
 528                Arc::new(ReadFileTool)
 529                    .run(
 530                        input,
 531                        Arc::default(),
 532                        project.clone(),
 533                        action_log.clone(),
 534                        model.clone(),
 535                        None,
 536                        cx,
 537                    )
 538                    .output
 539            })
 540            .await;
 541        assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
 542
 543        // end_line of 0 should result in at least 1 line
 544        let result = cx
 545            .update(|cx| {
 546                let input = ReadFileToolInput {
 547                    path: "root/multiline.txt".to_string(),
 548                    start_line: Some(1),
 549                    end_line: Some(0),
 550                };
 551                Arc::new(ReadFileTool)
 552                    .run(
 553                        input,
 554                        Arc::default(),
 555                        project.clone(),
 556                        action_log.clone(),
 557                        model.clone(),
 558                        None,
 559                        cx,
 560                    )
 561                    .output
 562            })
 563            .await;
 564        assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
 565
 566        // when start_line > end_line, should still return at least 1 line
 567        let result = cx
 568            .update(|cx| {
 569                let input = ReadFileToolInput {
 570                    path: "root/multiline.txt".to_string(),
 571                    start_line: Some(3),
 572                    end_line: Some(2),
 573                };
 574                Arc::new(ReadFileTool)
 575                    .run(
 576                        input,
 577                        Arc::default(),
 578                        project.clone(),
 579                        action_log,
 580                        model,
 581                        None,
 582                        cx,
 583                    )
 584                    .output
 585            })
 586            .await;
 587        assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
 588    }
 589
 590    fn init_test(cx: &mut TestAppContext) {
 591        cx.update(|cx| {
 592            let settings_store = SettingsStore::test(cx);
 593            cx.set_global(settings_store);
 594            language::init(cx);
 595            Project::init_settings(cx);
 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 project::WorktreeSettings;
 674            use settings::SettingsStore;
 675            SettingsStore::update_global(cx, |store, cx| {
 676                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
 677                    settings.file_scan_exclusions = Some(vec![
 678                        "**/.secretdir".to_string(),
 679                        "**/.mymetadata".to_string(),
 680                    ]);
 681                    settings.private_files = Some(vec![
 682                        "**/.mysecrets".to_string(),
 683                        "**/*.privatekey".to_string(),
 684                        "**/*.mysensitive".to_string(),
 685                    ]);
 686                });
 687            });
 688        });
 689
 690        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 691        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 692        let model = Arc::new(FakeLanguageModel::default());
 693
 694        // Reading a file outside the project worktree should fail
 695        let result = cx
 696            .update(|cx| {
 697                let input = ReadFileToolInput {
 698                    path: "/outside_project/sensitive_file.txt".to_string(),
 699                    start_line: None,
 700                    end_line: None,
 701                };
 702                Arc::new(ReadFileTool)
 703                    .run(
 704                        input,
 705                        Arc::default(),
 706                        project.clone(),
 707                        action_log.clone(),
 708                        model.clone(),
 709                        None,
 710                        cx,
 711                    )
 712                    .output
 713            })
 714            .await;
 715        assert!(
 716            result.is_err(),
 717            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 718        );
 719
 720        // Reading a file within the project should succeed
 721        let result = cx
 722            .update(|cx| {
 723                let input = ReadFileToolInput {
 724                    path: "project_root/allowed_file.txt".to_string(),
 725                    start_line: None,
 726                    end_line: None,
 727                };
 728                Arc::new(ReadFileTool)
 729                    .run(
 730                        input,
 731                        Arc::default(),
 732                        project.clone(),
 733                        action_log.clone(),
 734                        model.clone(),
 735                        None,
 736                        cx,
 737                    )
 738                    .output
 739            })
 740            .await;
 741        assert!(
 742            result.is_ok(),
 743            "read_file_tool should be able to read files inside worktrees"
 744        );
 745
 746        // Reading files that match file_scan_exclusions should fail
 747        let result = cx
 748            .update(|cx| {
 749                let input = ReadFileToolInput {
 750                    path: "project_root/.secretdir/config".to_string(),
 751                    start_line: None,
 752                    end_line: None,
 753                };
 754                Arc::new(ReadFileTool)
 755                    .run(
 756                        input,
 757                        Arc::default(),
 758                        project.clone(),
 759                        action_log.clone(),
 760                        model.clone(),
 761                        None,
 762                        cx,
 763                    )
 764                    .output
 765            })
 766            .await;
 767        assert!(
 768            result.is_err(),
 769            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 770        );
 771
 772        let result = cx
 773            .update(|cx| {
 774                let input = ReadFileToolInput {
 775                    path: "project_root/.mymetadata".to_string(),
 776                    start_line: None,
 777                    end_line: None,
 778                };
 779                Arc::new(ReadFileTool)
 780                    .run(
 781                        input,
 782                        Arc::default(),
 783                        project.clone(),
 784                        action_log.clone(),
 785                        model.clone(),
 786                        None,
 787                        cx,
 788                    )
 789                    .output
 790            })
 791            .await;
 792        assert!(
 793            result.is_err(),
 794            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 795        );
 796
 797        // Reading private files should fail
 798        let result = cx
 799            .update(|cx| {
 800                let input = ReadFileToolInput {
 801                    path: "project_root/secrets/.mysecrets".to_string(),
 802                    start_line: None,
 803                    end_line: None,
 804                };
 805                Arc::new(ReadFileTool)
 806                    .run(
 807                        input,
 808                        Arc::default(),
 809                        project.clone(),
 810                        action_log.clone(),
 811                        model.clone(),
 812                        None,
 813                        cx,
 814                    )
 815                    .output
 816            })
 817            .await;
 818        assert!(
 819            result.is_err(),
 820            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 821        );
 822
 823        let result = cx
 824            .update(|cx| {
 825                let input = ReadFileToolInput {
 826                    path: "project_root/subdir/special.privatekey".to_string(),
 827                    start_line: None,
 828                    end_line: None,
 829                };
 830                Arc::new(ReadFileTool)
 831                    .run(
 832                        input,
 833                        Arc::default(),
 834                        project.clone(),
 835                        action_log.clone(),
 836                        model.clone(),
 837                        None,
 838                        cx,
 839                    )
 840                    .output
 841            })
 842            .await;
 843        assert!(
 844            result.is_err(),
 845            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 846        );
 847
 848        let result = cx
 849            .update(|cx| {
 850                let input = ReadFileToolInput {
 851                    path: "project_root/subdir/data.mysensitive".to_string(),
 852                    start_line: None,
 853                    end_line: None,
 854                };
 855                Arc::new(ReadFileTool)
 856                    .run(
 857                        input,
 858                        Arc::default(),
 859                        project.clone(),
 860                        action_log.clone(),
 861                        model.clone(),
 862                        None,
 863                        cx,
 864                    )
 865                    .output
 866            })
 867            .await;
 868        assert!(
 869            result.is_err(),
 870            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 871        );
 872
 873        // Reading a normal file should still work, even with private_files configured
 874        let result = cx
 875            .update(|cx| {
 876                let input = ReadFileToolInput {
 877                    path: "project_root/subdir/normal_file.txt".to_string(),
 878                    start_line: None,
 879                    end_line: None,
 880                };
 881                Arc::new(ReadFileTool)
 882                    .run(
 883                        input,
 884                        Arc::default(),
 885                        project.clone(),
 886                        action_log.clone(),
 887                        model.clone(),
 888                        None,
 889                        cx,
 890                    )
 891                    .output
 892            })
 893            .await;
 894        assert!(result.is_ok(), "Should be able to read normal files");
 895        assert_eq!(
 896            result.unwrap().content.as_str().unwrap(),
 897            "Normal file content"
 898        );
 899
 900        // Path traversal attempts with .. should fail
 901        let result = cx
 902            .update(|cx| {
 903                let input = ReadFileToolInput {
 904                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
 905                    start_line: None,
 906                    end_line: None,
 907                };
 908                Arc::new(ReadFileTool)
 909                    .run(
 910                        input,
 911                        Arc::default(),
 912                        project.clone(),
 913                        action_log.clone(),
 914                        model.clone(),
 915                        None,
 916                        cx,
 917                    )
 918                    .output
 919            })
 920            .await;
 921        assert!(
 922            result.is_err(),
 923            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 924        );
 925    }
 926
 927    #[gpui::test]
 928    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 929        init_test(cx);
 930
 931        let fs = FakeFs::new(cx.executor());
 932
 933        // Create first worktree with its own private_files setting
 934        fs.insert_tree(
 935            path!("/worktree1"),
 936            json!({
 937                "src": {
 938                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 939                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 940                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 941                },
 942                "tests": {
 943                    "test.rs": "mod tests { fn test_it() {} }",
 944                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 945                },
 946                ".zed": {
 947                    "settings.json": r#"{
 948                        "file_scan_exclusions": ["**/fixture.*"],
 949                        "private_files": ["**/secret.rs", "**/config.toml"]
 950                    }"#
 951                }
 952            }),
 953        )
 954        .await;
 955
 956        // Create second worktree with different private_files setting
 957        fs.insert_tree(
 958            path!("/worktree2"),
 959            json!({
 960                "lib": {
 961                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 962                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 963                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 964                },
 965                "docs": {
 966                    "README.md": "# Public Documentation",
 967                    "internal.md": "# Internal Secrets and Configuration"
 968                },
 969                ".zed": {
 970                    "settings.json": r#"{
 971                        "file_scan_exclusions": ["**/internal.*"],
 972                        "private_files": ["**/private.js", "**/data.json"]
 973                    }"#
 974                }
 975            }),
 976        )
 977        .await;
 978
 979        // Set global settings
 980        cx.update(|cx| {
 981            SettingsStore::update_global(cx, |store, cx| {
 982                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
 983                    settings.file_scan_exclusions =
 984                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 985                    settings.private_files = Some(vec!["**/.env".to_string()]);
 986                });
 987            });
 988        });
 989
 990        let project = Project::test(
 991            fs.clone(),
 992            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 993            cx,
 994        )
 995        .await;
 996
 997        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 998        let model = Arc::new(FakeLanguageModel::default());
 999        let tool = Arc::new(ReadFileTool);
1000
1001        // Test reading allowed files in worktree1
1002        let input = ReadFileToolInput {
1003            path: "worktree1/src/main.rs".to_string(),
1004            start_line: None,
1005            end_line: None,
1006        };
1007
1008        let result = cx
1009            .update(|cx| {
1010                tool.clone().run(
1011                    input,
1012                    Arc::default(),
1013                    project.clone(),
1014                    action_log.clone(),
1015                    model.clone(),
1016                    None,
1017                    cx,
1018                )
1019            })
1020            .output
1021            .await
1022            .unwrap();
1023
1024        assert_eq!(
1025            result.content.as_str().unwrap(),
1026            "fn main() { println!(\"Hello from worktree1\"); }"
1027        );
1028
1029        // Test reading private file in worktree1 should fail
1030        let input = ReadFileToolInput {
1031            path: "worktree1/src/secret.rs".to_string(),
1032            start_line: None,
1033            end_line: None,
1034        };
1035
1036        let result = cx
1037            .update(|cx| {
1038                tool.clone().run(
1039                    input,
1040                    Arc::default(),
1041                    project.clone(),
1042                    action_log.clone(),
1043                    model.clone(),
1044                    None,
1045                    cx,
1046                )
1047            })
1048            .output
1049            .await;
1050
1051        assert!(result.is_err());
1052        assert!(
1053            result
1054                .unwrap_err()
1055                .to_string()
1056                .contains("worktree `private_files` setting"),
1057            "Error should mention worktree private_files setting"
1058        );
1059
1060        // Test reading excluded file in worktree1 should fail
1061        let input = ReadFileToolInput {
1062            path: "worktree1/tests/fixture.sql".to_string(),
1063            start_line: None,
1064            end_line: None,
1065        };
1066
1067        let result = cx
1068            .update(|cx| {
1069                tool.clone().run(
1070                    input,
1071                    Arc::default(),
1072                    project.clone(),
1073                    action_log.clone(),
1074                    model.clone(),
1075                    None,
1076                    cx,
1077                )
1078            })
1079            .output
1080            .await;
1081
1082        assert!(result.is_err());
1083        assert!(
1084            result
1085                .unwrap_err()
1086                .to_string()
1087                .contains("worktree `file_scan_exclusions` setting"),
1088            "Error should mention worktree file_scan_exclusions setting"
1089        );
1090
1091        // Test reading allowed files in worktree2
1092        let input = ReadFileToolInput {
1093            path: "worktree2/lib/public.js".to_string(),
1094            start_line: None,
1095            end_line: None,
1096        };
1097
1098        let result = cx
1099            .update(|cx| {
1100                tool.clone().run(
1101                    input,
1102                    Arc::default(),
1103                    project.clone(),
1104                    action_log.clone(),
1105                    model.clone(),
1106                    None,
1107                    cx,
1108                )
1109            })
1110            .output
1111            .await
1112            .unwrap();
1113
1114        assert_eq!(
1115            result.content.as_str().unwrap(),
1116            "export function greet() { return 'Hello from worktree2'; }"
1117        );
1118
1119        // Test reading private file in worktree2 should fail
1120        let input = ReadFileToolInput {
1121            path: "worktree2/lib/private.js".to_string(),
1122            start_line: None,
1123            end_line: None,
1124        };
1125
1126        let result = cx
1127            .update(|cx| {
1128                tool.clone().run(
1129                    input,
1130                    Arc::default(),
1131                    project.clone(),
1132                    action_log.clone(),
1133                    model.clone(),
1134                    None,
1135                    cx,
1136                )
1137            })
1138            .output
1139            .await;
1140
1141        assert!(result.is_err());
1142        assert!(
1143            result
1144                .unwrap_err()
1145                .to_string()
1146                .contains("worktree `private_files` setting"),
1147            "Error should mention worktree private_files setting"
1148        );
1149
1150        // Test reading excluded file in worktree2 should fail
1151        let input = ReadFileToolInput {
1152            path: "worktree2/docs/internal.md".to_string(),
1153            start_line: None,
1154            end_line: None,
1155        };
1156
1157        let result = cx
1158            .update(|cx| {
1159                tool.clone().run(
1160                    input,
1161                    Arc::default(),
1162                    project.clone(),
1163                    action_log.clone(),
1164                    model.clone(),
1165                    None,
1166                    cx,
1167                )
1168            })
1169            .output
1170            .await;
1171
1172        assert!(result.is_err());
1173        assert!(
1174            result
1175                .unwrap_err()
1176                .to_string()
1177                .contains("worktree `file_scan_exclusions` setting"),
1178            "Error should mention worktree file_scan_exclusions setting"
1179        );
1180
1181        // Test that files allowed in one worktree but not in another are handled correctly
1182        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1183        let input = ReadFileToolInput {
1184            path: "worktree1/src/config.toml".to_string(),
1185            start_line: None,
1186            end_line: None,
1187        };
1188
1189        let result = cx
1190            .update(|cx| {
1191                tool.clone().run(
1192                    input,
1193                    Arc::default(),
1194                    project.clone(),
1195                    action_log.clone(),
1196                    model.clone(),
1197                    None,
1198                    cx,
1199                )
1200            })
1201            .output
1202            .await;
1203
1204        assert!(result.is_err());
1205        assert!(
1206            result
1207                .unwrap_err()
1208                .to_string()
1209                .contains("worktree `private_files` setting"),
1210            "Config.toml should be blocked by worktree1's private_files setting"
1211        );
1212    }
1213}