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