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, WorktreeSettings};
 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            language::init(cx);
 602            Project::init_settings(cx);
 603        });
 604    }
 605
 606    fn rust_lang() -> Language {
 607        Language::new(
 608            LanguageConfig {
 609                name: "Rust".into(),
 610                matcher: LanguageMatcher {
 611                    path_suffixes: vec!["rs".to_string()],
 612                    ..Default::default()
 613                },
 614                ..Default::default()
 615            },
 616            Some(tree_sitter_rust::LANGUAGE.into()),
 617        )
 618        .with_outline_query(
 619            r#"
 620            (line_comment) @annotation
 621
 622            (struct_item
 623                "struct" @context
 624                name: (_) @name) @item
 625            (enum_item
 626                "enum" @context
 627                name: (_) @name) @item
 628            (enum_variant
 629                name: (_) @name) @item
 630            (field_declaration
 631                name: (_) @name) @item
 632            (impl_item
 633                "impl" @context
 634                trait: (_)? @name
 635                "for"? @context
 636                type: (_) @name
 637                body: (_ "{" (_)* "}")) @item
 638            (function_item
 639                "fn" @context
 640                name: (_) @name) @item
 641            (mod_item
 642                "mod" @context
 643                name: (_) @name) @item
 644            "#,
 645        )
 646        .unwrap()
 647    }
 648
 649    #[gpui::test]
 650    async fn test_read_file_security(cx: &mut TestAppContext) {
 651        init_test(cx);
 652
 653        let fs = FakeFs::new(cx.executor());
 654
 655        fs.insert_tree(
 656            path!("/"),
 657            json!({
 658                "project_root": {
 659                    "allowed_file.txt": "This file is in the project",
 660                    ".mysecrets": "SECRET_KEY=abc123",
 661                    ".secretdir": {
 662                        "config": "special configuration"
 663                    },
 664                    ".mymetadata": "custom metadata",
 665                    "subdir": {
 666                        "normal_file.txt": "Normal file content",
 667                        "special.privatekey": "private key content",
 668                        "data.mysensitive": "sensitive data"
 669                    }
 670                },
 671                "outside_project": {
 672                    "sensitive_file.txt": "This file is outside the project"
 673                }
 674            }),
 675        )
 676        .await;
 677
 678        cx.update(|cx| {
 679            use gpui::UpdateGlobal;
 680            use project::WorktreeSettings;
 681            use settings::SettingsStore;
 682            SettingsStore::update_global(cx, |store, cx| {
 683                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
 684                    settings.file_scan_exclusions = Some(vec![
 685                        "**/.secretdir".to_string(),
 686                        "**/.mymetadata".to_string(),
 687                    ]);
 688                    settings.private_files = Some(vec![
 689                        "**/.mysecrets".to_string(),
 690                        "**/*.privatekey".to_string(),
 691                        "**/*.mysensitive".to_string(),
 692                    ]);
 693                });
 694            });
 695        });
 696
 697        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 698        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 699        let model = Arc::new(FakeLanguageModel::default());
 700
 701        // Reading a file outside the project worktree should fail
 702        let result = cx
 703            .update(|cx| {
 704                let input = json!({
 705                    "path": "/outside_project/sensitive_file.txt"
 706                });
 707                Arc::new(ReadFileTool)
 708                    .run(
 709                        input,
 710                        Arc::default(),
 711                        project.clone(),
 712                        action_log.clone(),
 713                        model.clone(),
 714                        None,
 715                        cx,
 716                    )
 717                    .output
 718            })
 719            .await;
 720        assert!(
 721            result.is_err(),
 722            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 723        );
 724
 725        // Reading a file within the project should succeed
 726        let result = cx
 727            .update(|cx| {
 728                let input = json!({
 729                    "path": "project_root/allowed_file.txt"
 730                });
 731                Arc::new(ReadFileTool)
 732                    .run(
 733                        input,
 734                        Arc::default(),
 735                        project.clone(),
 736                        action_log.clone(),
 737                        model.clone(),
 738                        None,
 739                        cx,
 740                    )
 741                    .output
 742            })
 743            .await;
 744        assert!(
 745            result.is_ok(),
 746            "read_file_tool should be able to read files inside worktrees"
 747        );
 748
 749        // Reading files that match file_scan_exclusions should fail
 750        let result = cx
 751            .update(|cx| {
 752                let input = json!({
 753                    "path": "project_root/.secretdir/config"
 754                });
 755                Arc::new(ReadFileTool)
 756                    .run(
 757                        input,
 758                        Arc::default(),
 759                        project.clone(),
 760                        action_log.clone(),
 761                        model.clone(),
 762                        None,
 763                        cx,
 764                    )
 765                    .output
 766            })
 767            .await;
 768        assert!(
 769            result.is_err(),
 770            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 771        );
 772
 773        let result = cx
 774            .update(|cx| {
 775                let input = json!({
 776                    "path": "project_root/.mymetadata"
 777                });
 778                Arc::new(ReadFileTool)
 779                    .run(
 780                        input,
 781                        Arc::default(),
 782                        project.clone(),
 783                        action_log.clone(),
 784                        model.clone(),
 785                        None,
 786                        cx,
 787                    )
 788                    .output
 789            })
 790            .await;
 791        assert!(
 792            result.is_err(),
 793            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 794        );
 795
 796        // Reading private files should fail
 797        let result = cx
 798            .update(|cx| {
 799                let input = json!({
 800                    "path": "project_root/.mysecrets"
 801                });
 802                Arc::new(ReadFileTool)
 803                    .run(
 804                        input,
 805                        Arc::default(),
 806                        project.clone(),
 807                        action_log.clone(),
 808                        model.clone(),
 809                        None,
 810                        cx,
 811                    )
 812                    .output
 813            })
 814            .await;
 815        assert!(
 816            result.is_err(),
 817            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 818        );
 819
 820        let result = cx
 821            .update(|cx| {
 822                let input = json!({
 823                    "path": "project_root/subdir/special.privatekey"
 824                });
 825                Arc::new(ReadFileTool)
 826                    .run(
 827                        input,
 828                        Arc::default(),
 829                        project.clone(),
 830                        action_log.clone(),
 831                        model.clone(),
 832                        None,
 833                        cx,
 834                    )
 835                    .output
 836            })
 837            .await;
 838        assert!(
 839            result.is_err(),
 840            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 841        );
 842
 843        let result = cx
 844            .update(|cx| {
 845                let input = json!({
 846                    "path": "project_root/subdir/data.mysensitive"
 847                });
 848                Arc::new(ReadFileTool)
 849                    .run(
 850                        input,
 851                        Arc::default(),
 852                        project.clone(),
 853                        action_log.clone(),
 854                        model.clone(),
 855                        None,
 856                        cx,
 857                    )
 858                    .output
 859            })
 860            .await;
 861        assert!(
 862            result.is_err(),
 863            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 864        );
 865
 866        // Reading a normal file should still work, even with private_files configured
 867        let result = cx
 868            .update(|cx| {
 869                let input = json!({
 870                    "path": "project_root/subdir/normal_file.txt"
 871                });
 872                Arc::new(ReadFileTool)
 873                    .run(
 874                        input,
 875                        Arc::default(),
 876                        project.clone(),
 877                        action_log.clone(),
 878                        model.clone(),
 879                        None,
 880                        cx,
 881                    )
 882                    .output
 883            })
 884            .await;
 885        assert!(result.is_ok(), "Should be able to read normal files");
 886        assert_eq!(
 887            result.unwrap().content.as_str().unwrap(),
 888            "Normal file content"
 889        );
 890
 891        // Path traversal attempts with .. should fail
 892        let result = cx
 893            .update(|cx| {
 894                let input = json!({
 895                    "path": "project_root/../outside_project/sensitive_file.txt"
 896                });
 897                Arc::new(ReadFileTool)
 898                    .run(
 899                        input,
 900                        Arc::default(),
 901                        project.clone(),
 902                        action_log.clone(),
 903                        model.clone(),
 904                        None,
 905                        cx,
 906                    )
 907                    .output
 908            })
 909            .await;
 910        assert!(
 911            result.is_err(),
 912            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 913        );
 914    }
 915
 916    #[gpui::test]
 917    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 918        init_test(cx);
 919
 920        let fs = FakeFs::new(cx.executor());
 921
 922        // Create first worktree with its own private_files setting
 923        fs.insert_tree(
 924            path!("/worktree1"),
 925            json!({
 926                "src": {
 927                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 928                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 929                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 930                },
 931                "tests": {
 932                    "test.rs": "mod tests { fn test_it() {} }",
 933                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 934                },
 935                ".zed": {
 936                    "settings.json": r#"{
 937                        "file_scan_exclusions": ["**/fixture.*"],
 938                        "private_files": ["**/secret.rs", "**/config.toml"]
 939                    }"#
 940                }
 941            }),
 942        )
 943        .await;
 944
 945        // Create second worktree with different private_files setting
 946        fs.insert_tree(
 947            path!("/worktree2"),
 948            json!({
 949                "lib": {
 950                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 951                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 952                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 953                },
 954                "docs": {
 955                    "README.md": "# Public Documentation",
 956                    "internal.md": "# Internal Secrets and Configuration"
 957                },
 958                ".zed": {
 959                    "settings.json": r#"{
 960                        "file_scan_exclusions": ["**/internal.*"],
 961                        "private_files": ["**/private.js", "**/data.json"]
 962                    }"#
 963                }
 964            }),
 965        )
 966        .await;
 967
 968        // Set global settings
 969        cx.update(|cx| {
 970            SettingsStore::update_global(cx, |store, cx| {
 971                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
 972                    settings.file_scan_exclusions =
 973                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 974                    settings.private_files = Some(vec!["**/.env".to_string()]);
 975                });
 976            });
 977        });
 978
 979        let project = Project::test(
 980            fs.clone(),
 981            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 982            cx,
 983        )
 984        .await;
 985
 986        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 987        let model = Arc::new(FakeLanguageModel::default());
 988        let tool = Arc::new(ReadFileTool);
 989
 990        // Test reading allowed files in worktree1
 991        let input = json!({
 992            "path": "worktree1/src/main.rs"
 993        });
 994
 995        let result = cx
 996            .update(|cx| {
 997                tool.clone().run(
 998                    input,
 999                    Arc::default(),
1000                    project.clone(),
1001                    action_log.clone(),
1002                    model.clone(),
1003                    None,
1004                    cx,
1005                )
1006            })
1007            .output
1008            .await
1009            .unwrap();
1010
1011        assert_eq!(
1012            result.content.as_str().unwrap(),
1013            "fn main() { println!(\"Hello from worktree1\"); }"
1014        );
1015
1016        // Test reading private file in worktree1 should fail
1017        let input = json!({
1018            "path": "worktree1/src/secret.rs"
1019        });
1020
1021        let result = cx
1022            .update(|cx| {
1023                tool.clone().run(
1024                    input,
1025                    Arc::default(),
1026                    project.clone(),
1027                    action_log.clone(),
1028                    model.clone(),
1029                    None,
1030                    cx,
1031                )
1032            })
1033            .output
1034            .await;
1035
1036        assert!(result.is_err());
1037        assert!(
1038            result
1039                .unwrap_err()
1040                .to_string()
1041                .contains("worktree `private_files` setting"),
1042            "Error should mention worktree private_files setting"
1043        );
1044
1045        // Test reading excluded file in worktree1 should fail
1046        let input = json!({
1047            "path": "worktree1/tests/fixture.sql"
1048        });
1049
1050        let result = cx
1051            .update(|cx| {
1052                tool.clone().run(
1053                    input,
1054                    Arc::default(),
1055                    project.clone(),
1056                    action_log.clone(),
1057                    model.clone(),
1058                    None,
1059                    cx,
1060                )
1061            })
1062            .output
1063            .await;
1064
1065        assert!(result.is_err());
1066        assert!(
1067            result
1068                .unwrap_err()
1069                .to_string()
1070                .contains("worktree `file_scan_exclusions` setting"),
1071            "Error should mention worktree file_scan_exclusions setting"
1072        );
1073
1074        // Test reading allowed files in worktree2
1075        let input = json!({
1076            "path": "worktree2/lib/public.js"
1077        });
1078
1079        let result = cx
1080            .update(|cx| {
1081                tool.clone().run(
1082                    input,
1083                    Arc::default(),
1084                    project.clone(),
1085                    action_log.clone(),
1086                    model.clone(),
1087                    None,
1088                    cx,
1089                )
1090            })
1091            .output
1092            .await
1093            .unwrap();
1094
1095        assert_eq!(
1096            result.content.as_str().unwrap(),
1097            "export function greet() { return 'Hello from worktree2'; }"
1098        );
1099
1100        // Test reading private file in worktree2 should fail
1101        let input = json!({
1102            "path": "worktree2/lib/private.js"
1103        });
1104
1105        let result = cx
1106            .update(|cx| {
1107                tool.clone().run(
1108                    input,
1109                    Arc::default(),
1110                    project.clone(),
1111                    action_log.clone(),
1112                    model.clone(),
1113                    None,
1114                    cx,
1115                )
1116            })
1117            .output
1118            .await;
1119
1120        assert!(result.is_err());
1121        assert!(
1122            result
1123                .unwrap_err()
1124                .to_string()
1125                .contains("worktree `private_files` setting"),
1126            "Error should mention worktree private_files setting"
1127        );
1128
1129        // Test reading excluded file in worktree2 should fail
1130        let input = json!({
1131            "path": "worktree2/docs/internal.md"
1132        });
1133
1134        let result = cx
1135            .update(|cx| {
1136                tool.clone().run(
1137                    input,
1138                    Arc::default(),
1139                    project.clone(),
1140                    action_log.clone(),
1141                    model.clone(),
1142                    None,
1143                    cx,
1144                )
1145            })
1146            .output
1147            .await;
1148
1149        assert!(result.is_err());
1150        assert!(
1151            result
1152                .unwrap_err()
1153                .to_string()
1154                .contains("worktree `file_scan_exclusions` setting"),
1155            "Error should mention worktree file_scan_exclusions setting"
1156        );
1157
1158        // Test that files allowed in one worktree but not in another are handled correctly
1159        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1160        let input = json!({
1161            "path": "worktree1/src/config.toml"
1162        });
1163
1164        let result = cx
1165            .update(|cx| {
1166                tool.clone().run(
1167                    input,
1168                    Arc::default(),
1169                    project.clone(),
1170                    action_log.clone(),
1171                    model.clone(),
1172                    None,
1173                    cx,
1174                )
1175            })
1176            .output
1177            .await;
1178
1179        assert!(result.is_err());
1180        assert!(
1181            result
1182                .unwrap_err()
1183                .to_string()
1184                .contains("worktree `private_files` setting"),
1185            "Config.toml should be blocked by worktree1's private_files setting"
1186        );
1187    }
1188}