read_file_tool.rs

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