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