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 path_buf = std::path::PathBuf::from(&file_path);
 265                let buffer_content =
 266                    outline::get_buffer_content_or_outline(buffer.clone(), Some(&path_buf), cx)
 267                        .await?;
 268
 269                action_log.update(cx, |log, cx| {
 270                    log.buffer_read(buffer, cx);
 271                })?;
 272
 273                if buffer_content.is_outline {
 274                    Ok(formatdoc! {"
 275                        This file was too big to read all at once.
 276
 277                        {}
 278
 279                        Using the line numbers in this outline, you can call this tool again
 280                        while specifying the start_line and end_line fields to see the
 281                        implementations of symbols in the outline.
 282
 283                        Alternatively, you can fall back to the `grep` tool (if available)
 284                        to search the file for specific content.", buffer_content.text
 285                    }
 286                    .into())
 287                } else {
 288                    Ok(buffer_content.text.into())
 289                }
 290            }
 291        })
 292        .into()
 293    }
 294}
 295
 296#[cfg(test)]
 297mod test {
 298    use super::*;
 299    use gpui::{AppContext, TestAppContext, UpdateGlobal};
 300    use language::{Language, LanguageConfig, LanguageMatcher};
 301    use language_model::fake_provider::FakeLanguageModel;
 302    use project::{FakeFs, Project};
 303    use serde_json::json;
 304    use settings::SettingsStore;
 305    use util::path;
 306
 307    #[gpui::test]
 308    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 309        init_test(cx);
 310
 311        let fs = FakeFs::new(cx.executor());
 312        fs.insert_tree(path!("/root"), json!({})).await;
 313        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 314        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 315        let model = Arc::new(FakeLanguageModel::default());
 316        let result = cx
 317            .update(|cx| {
 318                let input = json!({
 319                    "path": "root/nonexistent_file.txt"
 320                });
 321                Arc::new(ReadFileTool)
 322                    .run(
 323                        input,
 324                        Arc::default(),
 325                        project.clone(),
 326                        action_log,
 327                        model,
 328                        None,
 329                        cx,
 330                    )
 331                    .output
 332            })
 333            .await;
 334        assert_eq!(
 335            result.unwrap_err().to_string(),
 336            "root/nonexistent_file.txt not found"
 337        );
 338    }
 339
 340    #[gpui::test]
 341    async fn test_read_small_file(cx: &mut TestAppContext) {
 342        init_test(cx);
 343
 344        let fs = FakeFs::new(cx.executor());
 345        fs.insert_tree(
 346            path!("/root"),
 347            json!({
 348                "small_file.txt": "This is a small file content"
 349            }),
 350        )
 351        .await;
 352        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 353        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 354        let model = Arc::new(FakeLanguageModel::default());
 355        let result = cx
 356            .update(|cx| {
 357                let input = json!({
 358                    "path": "root/small_file.txt"
 359                });
 360                Arc::new(ReadFileTool)
 361                    .run(
 362                        input,
 363                        Arc::default(),
 364                        project.clone(),
 365                        action_log,
 366                        model,
 367                        None,
 368                        cx,
 369                    )
 370                    .output
 371            })
 372            .await;
 373        assert_eq!(
 374            result.unwrap().content.as_str(),
 375            Some("This is a small file content")
 376        );
 377    }
 378
 379    #[gpui::test]
 380    async fn test_read_large_file(cx: &mut TestAppContext) {
 381        init_test(cx);
 382
 383        let fs = FakeFs::new(cx.executor());
 384        fs.insert_tree(
 385            path!("/root"),
 386            json!({
 387                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 388            }),
 389        )
 390        .await;
 391        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 392        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 393        language_registry.add(Arc::new(rust_lang()));
 394        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 395        let model = Arc::new(FakeLanguageModel::default());
 396
 397        let result = cx
 398            .update(|cx| {
 399                let input = json!({
 400                    "path": "root/large_file.rs"
 401                });
 402                Arc::new(ReadFileTool)
 403                    .run(
 404                        input,
 405                        Arc::default(),
 406                        project.clone(),
 407                        action_log.clone(),
 408                        model.clone(),
 409                        None,
 410                        cx,
 411                    )
 412                    .output
 413            })
 414            .await;
 415        let content = result.unwrap();
 416        let content = content.as_str().unwrap();
 417        assert_eq!(
 418            content.lines().skip(4).take(6).collect::<Vec<_>>(),
 419            vec![
 420                "struct Test0 [L1-4]",
 421                " a [L2]",
 422                " b [L3]",
 423                "struct Test1 [L5-8]",
 424                " a [L6]",
 425                " b [L7]",
 426            ]
 427        );
 428
 429        let result = cx
 430            .update(|cx| {
 431                let input = json!({
 432                    "path": "root/large_file.rs",
 433                    "offset": 1
 434                });
 435                Arc::new(ReadFileTool)
 436                    .run(
 437                        input,
 438                        Arc::default(),
 439                        project.clone(),
 440                        action_log,
 441                        model,
 442                        None,
 443                        cx,
 444                    )
 445                    .output
 446            })
 447            .await;
 448        let content = result.unwrap();
 449        let expected_content = (0..1000)
 450            .flat_map(|i| {
 451                vec![
 452                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 453                    format!(" a [L{}]", i * 4 + 2),
 454                    format!(" b [L{}]", i * 4 + 3),
 455                ]
 456            })
 457            .collect::<Vec<_>>();
 458        pretty_assertions::assert_eq!(
 459            content
 460                .as_str()
 461                .unwrap()
 462                .lines()
 463                .skip(4)
 464                .take(expected_content.len())
 465                .collect::<Vec<_>>(),
 466            expected_content
 467        );
 468    }
 469
 470    #[gpui::test]
 471    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 472        init_test(cx);
 473
 474        let fs = FakeFs::new(cx.executor());
 475        fs.insert_tree(
 476            path!("/root"),
 477            json!({
 478                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 479            }),
 480        )
 481        .await;
 482        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 483        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 484        let model = Arc::new(FakeLanguageModel::default());
 485        let result = cx
 486            .update(|cx| {
 487                let input = json!({
 488                    "path": "root/multiline.txt",
 489                    "start_line": 2,
 490                    "end_line": 4
 491                });
 492                Arc::new(ReadFileTool)
 493                    .run(
 494                        input,
 495                        Arc::default(),
 496                        project.clone(),
 497                        action_log,
 498                        model,
 499                        None,
 500                        cx,
 501                    )
 502                    .output
 503            })
 504            .await;
 505        assert_eq!(
 506            result.unwrap().content.as_str(),
 507            Some("Line 2\nLine 3\nLine 4")
 508        );
 509    }
 510
 511    #[gpui::test]
 512    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 513        init_test(cx);
 514
 515        let fs = FakeFs::new(cx.executor());
 516        fs.insert_tree(
 517            path!("/root"),
 518            json!({
 519                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 520            }),
 521        )
 522        .await;
 523        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 524        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 525        let model = Arc::new(FakeLanguageModel::default());
 526
 527        // start_line of 0 should be treated as 1
 528        let result = cx
 529            .update(|cx| {
 530                let input = json!({
 531                    "path": "root/multiline.txt",
 532                    "start_line": 0,
 533                    "end_line": 2
 534                });
 535                Arc::new(ReadFileTool)
 536                    .run(
 537                        input,
 538                        Arc::default(),
 539                        project.clone(),
 540                        action_log.clone(),
 541                        model.clone(),
 542                        None,
 543                        cx,
 544                    )
 545                    .output
 546            })
 547            .await;
 548        assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
 549
 550        // end_line of 0 should result in at least 1 line
 551        let result = cx
 552            .update(|cx| {
 553                let input = json!({
 554                    "path": "root/multiline.txt",
 555                    "start_line": 1,
 556                    "end_line": 0
 557                });
 558                Arc::new(ReadFileTool)
 559                    .run(
 560                        input,
 561                        Arc::default(),
 562                        project.clone(),
 563                        action_log.clone(),
 564                        model.clone(),
 565                        None,
 566                        cx,
 567                    )
 568                    .output
 569            })
 570            .await;
 571        assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
 572
 573        // when start_line > end_line, should still return at least 1 line
 574        let result = cx
 575            .update(|cx| {
 576                let input = json!({
 577                    "path": "root/multiline.txt",
 578                    "start_line": 3,
 579                    "end_line": 2
 580                });
 581                Arc::new(ReadFileTool)
 582                    .run(
 583                        input,
 584                        Arc::default(),
 585                        project.clone(),
 586                        action_log,
 587                        model,
 588                        None,
 589                        cx,
 590                    )
 591                    .output
 592            })
 593            .await;
 594        assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
 595    }
 596
 597    fn init_test(cx: &mut TestAppContext) {
 598        cx.update(|cx| {
 599            let settings_store = SettingsStore::test(cx);
 600            cx.set_global(settings_store);
 601            SettingsStore::load_registered_settings(cx);
 602
 603            language::init(cx);
 604            Project::init_settings(cx);
 605        });
 606    }
 607
 608    fn rust_lang() -> Language {
 609        Language::new(
 610            LanguageConfig {
 611                name: "Rust".into(),
 612                matcher: LanguageMatcher {
 613                    path_suffixes: vec!["rs".to_string()],
 614                    ..Default::default()
 615                },
 616                ..Default::default()
 617            },
 618            Some(tree_sitter_rust::LANGUAGE.into()),
 619        )
 620        .with_outline_query(
 621            r#"
 622            (line_comment) @annotation
 623
 624            (struct_item
 625                "struct" @context
 626                name: (_) @name) @item
 627            (enum_item
 628                "enum" @context
 629                name: (_) @name) @item
 630            (enum_variant
 631                name: (_) @name) @item
 632            (field_declaration
 633                name: (_) @name) @item
 634            (impl_item
 635                "impl" @context
 636                trait: (_)? @name
 637                "for"? @context
 638                type: (_) @name
 639                body: (_ "{" (_)* "}")) @item
 640            (function_item
 641                "fn" @context
 642                name: (_) @name) @item
 643            (mod_item
 644                "mod" @context
 645                name: (_) @name) @item
 646            "#,
 647        )
 648        .unwrap()
 649    }
 650
 651    #[gpui::test]
 652    async fn test_read_file_security(cx: &mut TestAppContext) {
 653        init_test(cx);
 654
 655        let fs = FakeFs::new(cx.executor());
 656
 657        fs.insert_tree(
 658            path!("/"),
 659            json!({
 660                "project_root": {
 661                    "allowed_file.txt": "This file is in the project",
 662                    ".mysecrets": "SECRET_KEY=abc123",
 663                    ".secretdir": {
 664                        "config": "special configuration"
 665                    },
 666                    ".mymetadata": "custom metadata",
 667                    "subdir": {
 668                        "normal_file.txt": "Normal file content",
 669                        "special.privatekey": "private key content",
 670                        "data.mysensitive": "sensitive data"
 671                    }
 672                },
 673                "outside_project": {
 674                    "sensitive_file.txt": "This file is outside the project"
 675                }
 676            }),
 677        )
 678        .await;
 679
 680        cx.update(|cx| {
 681            use gpui::UpdateGlobal;
 682            use settings::SettingsStore;
 683            SettingsStore::update_global(cx, |store, cx| {
 684                store.update_user_settings(cx, |settings| {
 685                    settings.project.worktree.file_scan_exclusions = Some(vec![
 686                        "**/.secretdir".to_string(),
 687                        "**/.mymetadata".to_string(),
 688                    ]);
 689                    settings.project.worktree.private_files = Some(
 690                        vec![
 691                            "**/.mysecrets".to_string(),
 692                            "**/*.privatekey".to_string(),
 693                            "**/*.mysensitive".to_string(),
 694                        ]
 695                        .into(),
 696                    );
 697                });
 698            });
 699        });
 700
 701        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 702        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 703        let model = Arc::new(FakeLanguageModel::default());
 704
 705        // Reading a file outside the project worktree should fail
 706        let result = cx
 707            .update(|cx| {
 708                let input = json!({
 709                    "path": "/outside_project/sensitive_file.txt"
 710                });
 711                Arc::new(ReadFileTool)
 712                    .run(
 713                        input,
 714                        Arc::default(),
 715                        project.clone(),
 716                        action_log.clone(),
 717                        model.clone(),
 718                        None,
 719                        cx,
 720                    )
 721                    .output
 722            })
 723            .await;
 724        assert!(
 725            result.is_err(),
 726            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 727        );
 728
 729        // Reading a file within the project should succeed
 730        let result = cx
 731            .update(|cx| {
 732                let input = json!({
 733                    "path": "project_root/allowed_file.txt"
 734                });
 735                Arc::new(ReadFileTool)
 736                    .run(
 737                        input,
 738                        Arc::default(),
 739                        project.clone(),
 740                        action_log.clone(),
 741                        model.clone(),
 742                        None,
 743                        cx,
 744                    )
 745                    .output
 746            })
 747            .await;
 748        assert!(
 749            result.is_ok(),
 750            "read_file_tool should be able to read files inside worktrees"
 751        );
 752
 753        // Reading files that match file_scan_exclusions should fail
 754        let result = cx
 755            .update(|cx| {
 756                let input = json!({
 757                    "path": "project_root/.secretdir/config"
 758                });
 759                Arc::new(ReadFileTool)
 760                    .run(
 761                        input,
 762                        Arc::default(),
 763                        project.clone(),
 764                        action_log.clone(),
 765                        model.clone(),
 766                        None,
 767                        cx,
 768                    )
 769                    .output
 770            })
 771            .await;
 772        assert!(
 773            result.is_err(),
 774            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 775        );
 776
 777        let result = cx
 778            .update(|cx| {
 779                let input = json!({
 780                    "path": "project_root/.mymetadata"
 781                });
 782                Arc::new(ReadFileTool)
 783                    .run(
 784                        input,
 785                        Arc::default(),
 786                        project.clone(),
 787                        action_log.clone(),
 788                        model.clone(),
 789                        None,
 790                        cx,
 791                    )
 792                    .output
 793            })
 794            .await;
 795        assert!(
 796            result.is_err(),
 797            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 798        );
 799
 800        // Reading private files should fail
 801        let result = cx
 802            .update(|cx| {
 803                let input = json!({
 804                    "path": "project_root/.mysecrets"
 805                });
 806                Arc::new(ReadFileTool)
 807                    .run(
 808                        input,
 809                        Arc::default(),
 810                        project.clone(),
 811                        action_log.clone(),
 812                        model.clone(),
 813                        None,
 814                        cx,
 815                    )
 816                    .output
 817            })
 818            .await;
 819        assert!(
 820            result.is_err(),
 821            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 822        );
 823
 824        let result = cx
 825            .update(|cx| {
 826                let input = json!({
 827                    "path": "project_root/subdir/special.privatekey"
 828                });
 829                Arc::new(ReadFileTool)
 830                    .run(
 831                        input,
 832                        Arc::default(),
 833                        project.clone(),
 834                        action_log.clone(),
 835                        model.clone(),
 836                        None,
 837                        cx,
 838                    )
 839                    .output
 840            })
 841            .await;
 842        assert!(
 843            result.is_err(),
 844            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 845        );
 846
 847        let result = cx
 848            .update(|cx| {
 849                let input = json!({
 850                    "path": "project_root/subdir/data.mysensitive"
 851                });
 852                Arc::new(ReadFileTool)
 853                    .run(
 854                        input,
 855                        Arc::default(),
 856                        project.clone(),
 857                        action_log.clone(),
 858                        model.clone(),
 859                        None,
 860                        cx,
 861                    )
 862                    .output
 863            })
 864            .await;
 865        assert!(
 866            result.is_err(),
 867            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 868        );
 869
 870        // Reading a normal file should still work, even with private_files configured
 871        let result = cx
 872            .update(|cx| {
 873                let input = json!({
 874                    "path": "project_root/subdir/normal_file.txt"
 875                });
 876                Arc::new(ReadFileTool)
 877                    .run(
 878                        input,
 879                        Arc::default(),
 880                        project.clone(),
 881                        action_log.clone(),
 882                        model.clone(),
 883                        None,
 884                        cx,
 885                    )
 886                    .output
 887            })
 888            .await;
 889        assert!(result.is_ok(), "Should be able to read normal files");
 890        assert_eq!(
 891            result.unwrap().content.as_str().unwrap(),
 892            "Normal file content"
 893        );
 894
 895        // Path traversal attempts with .. should fail
 896        let result = cx
 897            .update(|cx| {
 898                let input = json!({
 899                    "path": "project_root/../outside_project/sensitive_file.txt"
 900                });
 901                Arc::new(ReadFileTool)
 902                    .run(
 903                        input,
 904                        Arc::default(),
 905                        project.clone(),
 906                        action_log.clone(),
 907                        model.clone(),
 908                        None,
 909                        cx,
 910                    )
 911                    .output
 912            })
 913            .await;
 914        assert!(
 915            result.is_err(),
 916            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 917        );
 918    }
 919
 920    #[gpui::test]
 921    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 922        init_test(cx);
 923
 924        let fs = FakeFs::new(cx.executor());
 925
 926        // Create first worktree with its own private_files setting
 927        fs.insert_tree(
 928            path!("/worktree1"),
 929            json!({
 930                "src": {
 931                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 932                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 933                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 934                },
 935                "tests": {
 936                    "test.rs": "mod tests { fn test_it() {} }",
 937                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 938                },
 939                ".zed": {
 940                    "settings.json": r#"{
 941                        "file_scan_exclusions": ["**/fixture.*"],
 942                        "private_files": ["**/secret.rs", "**/config.toml"]
 943                    }"#
 944                }
 945            }),
 946        )
 947        .await;
 948
 949        // Create second worktree with different private_files setting
 950        fs.insert_tree(
 951            path!("/worktree2"),
 952            json!({
 953                "lib": {
 954                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 955                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 956                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 957                },
 958                "docs": {
 959                    "README.md": "# Public Documentation",
 960                    "internal.md": "# Internal Secrets and Configuration"
 961                },
 962                ".zed": {
 963                    "settings.json": r#"{
 964                        "file_scan_exclusions": ["**/internal.*"],
 965                        "private_files": ["**/private.js", "**/data.json"]
 966                    }"#
 967                }
 968            }),
 969        )
 970        .await;
 971
 972        // Set global settings
 973        cx.update(|cx| {
 974            SettingsStore::update_global(cx, |store, cx| {
 975                store.update_user_settings(cx, |settings| {
 976                    settings.project.worktree.file_scan_exclusions =
 977                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 978                    settings.project.worktree.private_files =
 979                        Some(vec!["**/.env".to_string()].into());
 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}