read_file_tool.rs

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