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