read_file_tool.rs

   1use action_log::ActionLog;
   2use agent_client_protocol::{self as acp, ToolCallUpdateFields};
   3use anyhow::{Context as _, Result, anyhow};
   4use gpui::{App, Entity, SharedString, Task};
   5use indoc::formatdoc;
   6use language::Point;
   7use language_model::{LanguageModelImage, LanguageModelToolResultContent};
   8use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
   9use schemars::JsonSchema;
  10use serde::{Deserialize, Serialize};
  11use settings::Settings;
  12use std::sync::Arc;
  13use util::markdown::MarkdownCodeBlock;
  14
  15use crate::{AgentTool, ToolCallEventStream, outline};
  16
  17/// Maximum allowed size for reading file content with line ranges.
  18/// If a line range request exceeds this size, it will be rejected.
  19pub const MAX_LINE_RANGE_SIZE: usize = 262144; // 256 KB
  20
  21/// Reads the content of the given file in the project.
  22///
  23/// - Never attempt to read a path that hasn't been previously mentioned.
  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 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    /// Optional line number to start reading on (1-based index)
  41    #[serde(default)]
  42    pub start_line: Option<u32>,
  43    /// Optional line number to end reading on (1-based index, inclusive)
  44    #[serde(default)]
  45    pub end_line: Option<u32>,
  46}
  47
  48pub struct ReadFileTool {
  49    project: Entity<Project>,
  50    action_log: Entity<ActionLog>,
  51}
  52
  53impl ReadFileTool {
  54    pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
  55        Self {
  56            project,
  57            action_log,
  58        }
  59    }
  60}
  61
  62impl AgentTool for ReadFileTool {
  63    type Input = ReadFileToolInput;
  64    type Output = LanguageModelToolResultContent;
  65
  66    fn name() -> &'static str {
  67        "read_file"
  68    }
  69
  70    fn kind() -> acp::ToolKind {
  71        acp::ToolKind::Read
  72    }
  73
  74    fn initial_title(
  75        &self,
  76        input: Result<Self::Input, serde_json::Value>,
  77        cx: &mut App,
  78    ) -> SharedString {
  79        if let Ok(input) = input
  80            && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
  81            && let Some(path) = self
  82                .project
  83                .read(cx)
  84                .short_full_path_for_project_path(&project_path, cx)
  85        {
  86            match (input.start_line, input.end_line) {
  87                (Some(start), Some(end)) => {
  88                    format!("Read file `{path}` (lines {}-{})", start, end,)
  89                }
  90                (Some(start), None) => {
  91                    format!("Read file `{path}` (from line {})", start)
  92                }
  93                _ => format!("Read file `{path}`"),
  94            }
  95            .into()
  96        } else {
  97            "Read file".into()
  98        }
  99    }
 100
 101    fn run(
 102        self: Arc<Self>,
 103        input: Self::Input,
 104        event_stream: ToolCallEventStream,
 105        cx: &mut App,
 106    ) -> Task<Result<LanguageModelToolResultContent>> {
 107        let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
 108            return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
 109        };
 110        let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
 111            return Task::ready(Err(anyhow!(
 112                "Failed to convert {} to absolute path",
 113                &input.path
 114            )));
 115        };
 116
 117        // Error out if this path is either excluded or private in global settings
 118        let global_settings = WorktreeSettings::get_global(cx);
 119        if global_settings.is_path_excluded(&project_path.path) {
 120            return Task::ready(Err(anyhow!(
 121                "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
 122                &input.path
 123            )));
 124        }
 125
 126        if global_settings.is_path_private(&project_path.path) {
 127            return Task::ready(Err(anyhow!(
 128                "Cannot read file because its path matches the global `private_files` setting: {}",
 129                &input.path
 130            )));
 131        }
 132
 133        // Error out if this path is either excluded or private in worktree settings
 134        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
 135        if worktree_settings.is_path_excluded(&project_path.path) {
 136            return Task::ready(Err(anyhow!(
 137                "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
 138                &input.path
 139            )));
 140        }
 141
 142        if worktree_settings.is_path_private(&project_path.path) {
 143            return Task::ready(Err(anyhow!(
 144                "Cannot read file because its path matches the worktree `private_files` setting: {}",
 145                &input.path
 146            )));
 147        }
 148
 149        let file_path = input.path.clone();
 150
 151        event_stream.update_fields(ToolCallUpdateFields {
 152            locations: Some(vec![acp::ToolCallLocation {
 153                path: abs_path.clone(),
 154                line: input.start_line.map(|line| line.saturating_sub(1)),
 155                meta: None,
 156            }]),
 157            ..Default::default()
 158        });
 159
 160        if image_store::is_image_file(&self.project, &project_path, cx) {
 161            return cx.spawn(async move |cx| {
 162                let image_entity: Entity<ImageItem> = cx
 163                    .update(|cx| {
 164                        self.project.update(cx, |project, cx| {
 165                            project.open_image(project_path.clone(), cx)
 166                        })
 167                    })?
 168                    .await?;
 169
 170                let image =
 171                    image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
 172
 173                let language_model_image = cx
 174                    .update(|cx| LanguageModelImage::from_image(image, cx))?
 175                    .await
 176                    .context("processing image")?;
 177
 178                Ok(language_model_image.into())
 179            });
 180        }
 181
 182        let project = self.project.clone();
 183        let action_log = self.action_log.clone();
 184
 185        cx.spawn(async move |cx| {
 186            let buffer = cx
 187                .update(|cx| {
 188                    project.update(cx, |project, cx| {
 189                        project.open_buffer(project_path.clone(), cx)
 190                    })
 191                })?
 192                .await?;
 193            if buffer.read_with(cx, |buffer, _| {
 194                buffer
 195                    .file()
 196                    .as_ref()
 197                    .is_none_or(|file| !file.disk_state().exists())
 198            })? {
 199                anyhow::bail!("{file_path} not found");
 200            }
 201
 202            let mut anchor = None;
 203
 204            // Check if specific line ranges are provided
 205            let result = if input.start_line.is_some() || input.end_line.is_some() {
 206                let result = buffer.read_with(cx, |buffer, _cx| {
 207                    // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
 208                    let start = input.start_line.unwrap_or(1).max(1);
 209                    let start_row = start - 1;
 210                    if start_row <= buffer.max_point().row {
 211                        let column = buffer.line_indent_for_row(start_row).raw_len();
 212                        anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
 213                    }
 214
 215                    let mut end_row = input.end_line.unwrap_or(u32::MAX);
 216                    if end_row <= start_row {
 217                        end_row = start_row + 1; // read at least one lines
 218                    }
 219                    let start = buffer.anchor_before(Point::new(start_row, 0));
 220                    let end = buffer.anchor_before(Point::new(end_row, 0));
 221                    buffer.text_for_range(start..end).collect::<String>()
 222                })?;
 223
 224                action_log.update(cx, |log, cx| {
 225                    log.buffer_read(buffer.clone(), cx);
 226                })?;
 227
 228                if result.len() > MAX_LINE_RANGE_SIZE {
 229                    let start_line = input.start_line.unwrap_or(1);
 230                    let end_line = input.end_line.unwrap_or(u32::MAX);
 231                    let line_count = end_line.saturating_sub(start_line).saturating_add(1);
 232
 233                    if line_count <= 1 {
 234                        // Likely minified or single-line file
 235                        Ok(formatdoc! {"
 236                            The requested line contains {} bytes of content, which exceeds the maximum size of {} bytes.
 237
 238                            This appears to be a minified or single-line file. Use the terminal tool to run: `head -c 4096 {}`
 239
 240                            This will read the first 4096 bytes to help you understand the file structure.",
 241                            result.len(),
 242                            MAX_LINE_RANGE_SIZE,
 243                            abs_path.display()
 244                        }
 245                        .into())
 246                    } else {
 247                        Ok(formatdoc! {"
 248                            The requested line range (lines {}-{}) contains {} bytes of content, which exceeds the maximum size of {} bytes.
 249
 250                            Please request a smaller line range, or use the `grep` tool to search for specific content within this section.",
 251                            start_line,
 252                            end_line,
 253                            result.len(),
 254                            MAX_LINE_RANGE_SIZE
 255                        }
 256                        .into())
 257                    }
 258                } else {
 259                    Ok(result.into())
 260                }
 261            } else {
 262                // No line ranges specified, so check file size to see if it's too big.
 263                let buffer_content = outline::get_buffer_content_or_outline(
 264                    buffer.clone(),
 265                    Some(&abs_path.to_string_lossy()),
 266                    cx,
 267                )
 268                .await?;
 269
 270                action_log.update(cx, |log, cx| {
 271                    log.buffer_read(buffer.clone(), cx);
 272                })?;
 273
 274                if buffer_content.is_outline {
 275                    Ok(formatdoc! {"
 276                        This file was too big to read all at once.
 277
 278                        {}
 279
 280                        Using the line numbers in this outline, you can call this tool again
 281                        while specifying the start_line and end_line fields to see the
 282                        implementations of symbols in the outline.
 283
 284                        Alternatively, you can fall back to the `grep` tool (if available)
 285                        to search the file for specific content.", buffer_content.text
 286                    }
 287                    .into())
 288                } else {
 289                    Ok(buffer_content.text.into())
 290                }
 291            };
 292
 293            project.update(cx, |project, cx| {
 294                project.set_agent_location(
 295                    Some(AgentLocation {
 296                        buffer: buffer.downgrade(),
 297                        position: anchor.unwrap_or(text::Anchor::MIN),
 298                    }),
 299                    cx,
 300                );
 301                if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
 302                    let markdown = MarkdownCodeBlock {
 303                        tag: &input.path,
 304                        text,
 305                    }
 306                    .to_string();
 307                    event_stream.update_fields(ToolCallUpdateFields {
 308                        content: Some(vec![acp::ToolCallContent::Content {
 309                            content: markdown.into(),
 310                        }]),
 311                        ..Default::default()
 312                    })
 313                }
 314            })?;
 315
 316            result
 317        })
 318    }
 319}
 320
 321#[cfg(test)]
 322mod test {
 323    use super::*;
 324    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
 325    use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
 326    use project::{FakeFs, Project};
 327    use serde_json::json;
 328    use settings::SettingsStore;
 329    use util::path;
 330
 331    #[gpui::test]
 332    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 333        init_test(cx);
 334
 335        let fs = FakeFs::new(cx.executor());
 336        fs.insert_tree(path!("/root"), json!({})).await;
 337        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 338        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 339        let tool = Arc::new(ReadFileTool::new(project, action_log));
 340        let (event_stream, _) = ToolCallEventStream::test();
 341
 342        let result = cx
 343            .update(|cx| {
 344                let input = ReadFileToolInput {
 345                    path: "root/nonexistent_file.txt".to_string(),
 346                    start_line: None,
 347                    end_line: None,
 348                };
 349                tool.run(input, event_stream, cx)
 350            })
 351            .await;
 352        assert_eq!(
 353            result.unwrap_err().to_string(),
 354            "root/nonexistent_file.txt not found"
 355        );
 356    }
 357
 358    #[gpui::test]
 359    async fn test_read_small_file(cx: &mut TestAppContext) {
 360        init_test(cx);
 361
 362        let fs = FakeFs::new(cx.executor());
 363        fs.insert_tree(
 364            path!("/root"),
 365            json!({
 366                "small_file.txt": "This is a small file content"
 367            }),
 368        )
 369        .await;
 370        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 371        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 372        let tool = Arc::new(ReadFileTool::new(project, action_log));
 373        let result = cx
 374            .update(|cx| {
 375                let input = ReadFileToolInput {
 376                    path: "root/small_file.txt".into(),
 377                    start_line: None,
 378                    end_line: None,
 379                };
 380                tool.run(input, ToolCallEventStream::test().0, cx)
 381            })
 382            .await;
 383        assert_eq!(result.unwrap(), "This is a small file content".into());
 384    }
 385
 386    #[gpui::test]
 387    async fn test_read_large_file(cx: &mut TestAppContext) {
 388        init_test(cx);
 389
 390        let fs = FakeFs::new(cx.executor());
 391        fs.insert_tree(
 392            path!("/root"),
 393            json!({
 394                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 395            }),
 396        )
 397        .await;
 398        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 399        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 400        language_registry.add(Arc::new(rust_lang()));
 401        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 402        let tool = Arc::new(ReadFileTool::new(project, action_log));
 403        let result = cx
 404            .update(|cx| {
 405                let input = ReadFileToolInput {
 406                    path: "root/large_file.rs".into(),
 407                    start_line: None,
 408                    end_line: None,
 409                };
 410                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 411            })
 412            .await
 413            .unwrap();
 414        let content = result.to_str().unwrap();
 415
 416        assert_eq!(
 417            content.lines().skip(4).take(6).collect::<Vec<_>>(),
 418            vec![
 419                "struct Test0 [L1-4]",
 420                " a [L2]",
 421                " b [L3]",
 422                "struct Test1 [L5-8]",
 423                " a [L6]",
 424                " b [L7]",
 425            ]
 426        );
 427
 428        let result = cx
 429            .update(|cx| {
 430                let input = ReadFileToolInput {
 431                    path: "root/large_file.rs".into(),
 432                    start_line: None,
 433                    end_line: None,
 434                };
 435                tool.run(input, ToolCallEventStream::test().0, cx)
 436            })
 437            .await
 438            .unwrap();
 439        let content = result.to_str().unwrap();
 440        let expected_content = (0..1000)
 441            .flat_map(|i| {
 442                vec![
 443                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 444                    format!(" a [L{}]", i * 4 + 2),
 445                    format!(" b [L{}]", i * 4 + 3),
 446                ]
 447            })
 448            .collect::<Vec<_>>();
 449        pretty_assertions::assert_eq!(
 450            content
 451                .lines()
 452                .skip(4)
 453                .take(expected_content.len())
 454                .collect::<Vec<_>>(),
 455            expected_content
 456        );
 457    }
 458
 459    #[gpui::test]
 460    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 461        init_test(cx);
 462
 463        let fs = FakeFs::new(cx.executor());
 464        fs.insert_tree(
 465            path!("/root"),
 466            json!({
 467                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 468            }),
 469        )
 470        .await;
 471        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 472
 473        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 474        let tool = Arc::new(ReadFileTool::new(project, action_log));
 475        let result = cx
 476            .update(|cx| {
 477                let input = ReadFileToolInput {
 478                    path: "root/multiline.txt".to_string(),
 479                    start_line: Some(2),
 480                    end_line: Some(4),
 481                };
 482                tool.run(input, ToolCallEventStream::test().0, cx)
 483            })
 484            .await;
 485        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
 486    }
 487
 488    #[gpui::test]
 489    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 490        init_test(cx);
 491
 492        let fs = FakeFs::new(cx.executor());
 493        fs.insert_tree(
 494            path!("/root"),
 495            json!({
 496                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 497            }),
 498        )
 499        .await;
 500        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 501        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 502        let tool = Arc::new(ReadFileTool::new(project, action_log));
 503
 504        // start_line of 0 should be treated as 1
 505        let result = cx
 506            .update(|cx| {
 507                let input = ReadFileToolInput {
 508                    path: "root/multiline.txt".to_string(),
 509                    start_line: Some(0),
 510                    end_line: Some(2),
 511                };
 512                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 513            })
 514            .await;
 515        assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
 516
 517        // end_line of 0 should result in at least 1 line
 518        let result = cx
 519            .update(|cx| {
 520                let input = ReadFileToolInput {
 521                    path: "root/multiline.txt".to_string(),
 522                    start_line: Some(1),
 523                    end_line: Some(0),
 524                };
 525                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 526            })
 527            .await;
 528        assert_eq!(result.unwrap(), "Line 1\n".into());
 529
 530        // when start_line > end_line, should still return at least 1 line
 531        let result = cx
 532            .update(|cx| {
 533                let input = ReadFileToolInput {
 534                    path: "root/multiline.txt".to_string(),
 535                    start_line: Some(3),
 536                    end_line: Some(2),
 537                };
 538                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 539            })
 540            .await;
 541        assert_eq!(result.unwrap(), "Line 3\n".into());
 542    }
 543
 544    fn init_test(cx: &mut TestAppContext) {
 545        cx.update(|cx| {
 546            let settings_store = SettingsStore::test(cx);
 547            cx.set_global(settings_store);
 548        });
 549    }
 550
 551    fn rust_lang() -> Language {
 552        Language::new(
 553            LanguageConfig {
 554                name: "Rust".into(),
 555                matcher: LanguageMatcher {
 556                    path_suffixes: vec!["rs".to_string()],
 557                    ..Default::default()
 558                },
 559                ..Default::default()
 560            },
 561            Some(tree_sitter_rust::LANGUAGE.into()),
 562        )
 563        .with_outline_query(
 564            r#"
 565            (line_comment) @annotation
 566
 567            (struct_item
 568                "struct" @context
 569                name: (_) @name) @item
 570            (enum_item
 571                "enum" @context
 572                name: (_) @name) @item
 573            (enum_variant
 574                name: (_) @name) @item
 575            (field_declaration
 576                name: (_) @name) @item
 577            (impl_item
 578                "impl" @context
 579                trait: (_)? @name
 580                "for"? @context
 581                type: (_) @name
 582                body: (_ "{" (_)* "}")) @item
 583            (function_item
 584                "fn" @context
 585                name: (_) @name) @item
 586            (mod_item
 587                "mod" @context
 588                name: (_) @name) @item
 589            "#,
 590        )
 591        .unwrap()
 592    }
 593
 594    #[gpui::test]
 595    async fn test_read_file_security(cx: &mut TestAppContext) {
 596        init_test(cx);
 597
 598        let fs = FakeFs::new(cx.executor());
 599
 600        fs.insert_tree(
 601            path!("/"),
 602            json!({
 603                "project_root": {
 604                    "allowed_file.txt": "This file is in the project",
 605                    ".mysecrets": "SECRET_KEY=abc123",
 606                    ".secretdir": {
 607                        "config": "special configuration"
 608                    },
 609                    ".mymetadata": "custom metadata",
 610                    "subdir": {
 611                        "normal_file.txt": "Normal file content",
 612                        "special.privatekey": "private key content",
 613                        "data.mysensitive": "sensitive data"
 614                    }
 615                },
 616                "outside_project": {
 617                    "sensitive_file.txt": "This file is outside the project"
 618                }
 619            }),
 620        )
 621        .await;
 622
 623        cx.update(|cx| {
 624            use gpui::UpdateGlobal;
 625            use settings::SettingsStore;
 626            SettingsStore::update_global(cx, |store, cx| {
 627                store.update_user_settings(cx, |settings| {
 628                    settings.project.worktree.file_scan_exclusions = Some(vec![
 629                        "**/.secretdir".to_string(),
 630                        "**/.mymetadata".to_string(),
 631                    ]);
 632                    settings.project.worktree.private_files = Some(
 633                        vec![
 634                            "**/.mysecrets".to_string(),
 635                            "**/*.privatekey".to_string(),
 636                            "**/*.mysensitive".to_string(),
 637                        ]
 638                        .into(),
 639                    );
 640                });
 641            });
 642        });
 643
 644        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 645        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 646        let tool = Arc::new(ReadFileTool::new(project, action_log));
 647
 648        // Reading a file outside the project worktree should fail
 649        let result = cx
 650            .update(|cx| {
 651                let input = ReadFileToolInput {
 652                    path: "/outside_project/sensitive_file.txt".to_string(),
 653                    start_line: None,
 654                    end_line: None,
 655                };
 656                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 657            })
 658            .await;
 659        assert!(
 660            result.is_err(),
 661            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 662        );
 663
 664        // Reading a file within the project should succeed
 665        let result = cx
 666            .update(|cx| {
 667                let input = ReadFileToolInput {
 668                    path: "project_root/allowed_file.txt".to_string(),
 669                    start_line: None,
 670                    end_line: None,
 671                };
 672                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 673            })
 674            .await;
 675        assert!(
 676            result.is_ok(),
 677            "read_file_tool should be able to read files inside worktrees"
 678        );
 679
 680        // Reading files that match file_scan_exclusions should fail
 681        let result = cx
 682            .update(|cx| {
 683                let input = ReadFileToolInput {
 684                    path: "project_root/.secretdir/config".to_string(),
 685                    start_line: None,
 686                    end_line: None,
 687                };
 688                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 689            })
 690            .await;
 691        assert!(
 692            result.is_err(),
 693            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 694        );
 695
 696        let result = cx
 697            .update(|cx| {
 698                let input = ReadFileToolInput {
 699                    path: "project_root/.mymetadata".to_string(),
 700                    start_line: None,
 701                    end_line: None,
 702                };
 703                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 704            })
 705            .await;
 706        assert!(
 707            result.is_err(),
 708            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 709        );
 710
 711        // Reading private files should fail
 712        let result = cx
 713            .update(|cx| {
 714                let input = ReadFileToolInput {
 715                    path: "project_root/.mysecrets".to_string(),
 716                    start_line: None,
 717                    end_line: None,
 718                };
 719                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 720            })
 721            .await;
 722        assert!(
 723            result.is_err(),
 724            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 725        );
 726
 727        let result = cx
 728            .update(|cx| {
 729                let input = ReadFileToolInput {
 730                    path: "project_root/subdir/special.privatekey".to_string(),
 731                    start_line: None,
 732                    end_line: None,
 733                };
 734                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 735            })
 736            .await;
 737        assert!(
 738            result.is_err(),
 739            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 740        );
 741
 742        let result = cx
 743            .update(|cx| {
 744                let input = ReadFileToolInput {
 745                    path: "project_root/subdir/data.mysensitive".to_string(),
 746                    start_line: None,
 747                    end_line: None,
 748                };
 749                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 750            })
 751            .await;
 752        assert!(
 753            result.is_err(),
 754            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 755        );
 756
 757        // Reading a normal file should still work, even with private_files configured
 758        let result = cx
 759            .update(|cx| {
 760                let input = ReadFileToolInput {
 761                    path: "project_root/subdir/normal_file.txt".to_string(),
 762                    start_line: None,
 763                    end_line: None,
 764                };
 765                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 766            })
 767            .await;
 768        assert!(result.is_ok(), "Should be able to read normal files");
 769        assert_eq!(result.unwrap(), "Normal file content".into());
 770
 771        // Path traversal attempts with .. should fail
 772        let result = cx
 773            .update(|cx| {
 774                let input = ReadFileToolInput {
 775                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
 776                    start_line: None,
 777                    end_line: None,
 778                };
 779                tool.run(input, ToolCallEventStream::test().0, cx)
 780            })
 781            .await;
 782        assert!(
 783            result.is_err(),
 784            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 785        );
 786    }
 787
 788    #[gpui::test]
 789    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 790        init_test(cx);
 791
 792        let fs = FakeFs::new(cx.executor());
 793
 794        // Create first worktree with its own private_files setting
 795        fs.insert_tree(
 796            path!("/worktree1"),
 797            json!({
 798                "src": {
 799                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 800                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 801                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 802                },
 803                "tests": {
 804                    "test.rs": "mod tests { fn test_it() {} }",
 805                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 806                },
 807                ".zed": {
 808                    "settings.json": r#"{
 809                        "file_scan_exclusions": ["**/fixture.*"],
 810                        "private_files": ["**/secret.rs", "**/config.toml"]
 811                    }"#
 812                }
 813            }),
 814        )
 815        .await;
 816
 817        // Create second worktree with different private_files setting
 818        fs.insert_tree(
 819            path!("/worktree2"),
 820            json!({
 821                "lib": {
 822                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 823                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 824                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 825                },
 826                "docs": {
 827                    "README.md": "# Public Documentation",
 828                    "internal.md": "# Internal Secrets and Configuration"
 829                },
 830                ".zed": {
 831                    "settings.json": r#"{
 832                        "file_scan_exclusions": ["**/internal.*"],
 833                        "private_files": ["**/private.js", "**/data.json"]
 834                    }"#
 835                }
 836            }),
 837        )
 838        .await;
 839
 840        // Set global settings
 841        cx.update(|cx| {
 842            SettingsStore::update_global(cx, |store, cx| {
 843                store.update_user_settings(cx, |settings| {
 844                    settings.project.worktree.file_scan_exclusions =
 845                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 846                    settings.project.worktree.private_files =
 847                        Some(vec!["**/.env".to_string()].into());
 848                });
 849            });
 850        });
 851
 852        let project = Project::test(
 853            fs.clone(),
 854            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 855            cx,
 856        )
 857        .await;
 858
 859        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 860        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
 861
 862        // Test reading allowed files in worktree1
 863        let result = cx
 864            .update(|cx| {
 865                let input = ReadFileToolInput {
 866                    path: "worktree1/src/main.rs".to_string(),
 867                    start_line: None,
 868                    end_line: None,
 869                };
 870                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 871            })
 872            .await
 873            .unwrap();
 874
 875        assert_eq!(
 876            result,
 877            "fn main() { println!(\"Hello from worktree1\"); }".into()
 878        );
 879
 880        // Test reading private file in worktree1 should fail
 881        let result = cx
 882            .update(|cx| {
 883                let input = ReadFileToolInput {
 884                    path: "worktree1/src/secret.rs".to_string(),
 885                    start_line: None,
 886                    end_line: None,
 887                };
 888                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 889            })
 890            .await;
 891
 892        assert!(result.is_err());
 893        assert!(
 894            result
 895                .unwrap_err()
 896                .to_string()
 897                .contains("worktree `private_files` setting"),
 898            "Error should mention worktree private_files setting"
 899        );
 900
 901        // Test reading excluded file in worktree1 should fail
 902        let result = cx
 903            .update(|cx| {
 904                let input = ReadFileToolInput {
 905                    path: "worktree1/tests/fixture.sql".to_string(),
 906                    start_line: None,
 907                    end_line: None,
 908                };
 909                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 910            })
 911            .await;
 912
 913        assert!(result.is_err());
 914        assert!(
 915            result
 916                .unwrap_err()
 917                .to_string()
 918                .contains("worktree `file_scan_exclusions` setting"),
 919            "Error should mention worktree file_scan_exclusions setting"
 920        );
 921
 922        // Test reading allowed files in worktree2
 923        let result = cx
 924            .update(|cx| {
 925                let input = ReadFileToolInput {
 926                    path: "worktree2/lib/public.js".to_string(),
 927                    start_line: None,
 928                    end_line: None,
 929                };
 930                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 931            })
 932            .await
 933            .unwrap();
 934
 935        assert_eq!(
 936            result,
 937            "export function greet() { return 'Hello from worktree2'; }".into()
 938        );
 939
 940        // Test reading private file in worktree2 should fail
 941        let result = cx
 942            .update(|cx| {
 943                let input = ReadFileToolInput {
 944                    path: "worktree2/lib/private.js".to_string(),
 945                    start_line: None,
 946                    end_line: None,
 947                };
 948                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 949            })
 950            .await;
 951
 952        assert!(result.is_err());
 953        assert!(
 954            result
 955                .unwrap_err()
 956                .to_string()
 957                .contains("worktree `private_files` setting"),
 958            "Error should mention worktree private_files setting"
 959        );
 960
 961        // Test reading excluded file in worktree2 should fail
 962        let result = cx
 963            .update(|cx| {
 964                let input = ReadFileToolInput {
 965                    path: "worktree2/docs/internal.md".to_string(),
 966                    start_line: None,
 967                    end_line: None,
 968                };
 969                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 970            })
 971            .await;
 972
 973        assert!(result.is_err());
 974        assert!(
 975            result
 976                .unwrap_err()
 977                .to_string()
 978                .contains("worktree `file_scan_exclusions` setting"),
 979            "Error should mention worktree file_scan_exclusions setting"
 980        );
 981
 982        // Test that files allowed in one worktree but not in another are handled correctly
 983        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
 984        let result = cx
 985            .update(|cx| {
 986                let input = ReadFileToolInput {
 987                    path: "worktree1/src/config.toml".to_string(),
 988                    start_line: None,
 989                    end_line: None,
 990                };
 991                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 992            })
 993            .await;
 994
 995        assert!(result.is_err());
 996        assert!(
 997            result
 998                .unwrap_err()
 999                .to_string()
1000                .contains("worktree `private_files` setting"),
1001            "Config.toml should be blocked by worktree1's private_files setting"
1002        );
1003    }
1004}