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