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 head tool to get first few bytes to help you understand the file structure.",
 239                            result.len(),
 240                            MAX_LINE_RANGE_SIZE
 241                        }
 242                        .into())
 243                    } else {
 244                        Ok(formatdoc! {"
 245                            The requested line range (lines {}-{}) contains {} bytes of content, which exceeds the maximum size of {} bytes.
 246
 247                            Please request a smaller line range, or use the `grep` tool to search for specific content within this section.",
 248                            start_line,
 249                            end_line,
 250                            result.len(),
 251                            MAX_LINE_RANGE_SIZE
 252                        }
 253                        .into())
 254                    }
 255                } else {
 256                    Ok(result.into())
 257                }
 258            } else {
 259                // No line ranges specified, so check file size to see if it's too big.
 260                let buffer_content = outline::get_buffer_content_or_outline(
 261                    buffer.clone(),
 262                    Some(&abs_path.to_string_lossy()),
 263                    cx,
 264                )
 265                .await?;
 266
 267                action_log.update(cx, |log, cx| {
 268                    log.buffer_read(buffer.clone(), cx);
 269                })?;
 270
 271                if buffer_content.is_outline {
 272                    Ok(formatdoc! {"
 273                        This file was too big to read all at once.
 274
 275                        {}
 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                        Alternatively, you can fall back to the `grep` tool (if available)
 282                        to search the file for specific content.", buffer_content.text
 283                    }
 284                    .into())
 285                } else {
 286                    Ok(buffer_content.text.into())
 287                }
 288            };
 289
 290            project.update(cx, |project, cx| {
 291                project.set_agent_location(
 292                    Some(AgentLocation {
 293                        buffer: buffer.downgrade(),
 294                        position: anchor.unwrap_or(text::Anchor::MIN),
 295                    }),
 296                    cx,
 297                );
 298                if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
 299                    let markdown = MarkdownCodeBlock {
 300                        tag: &input.path,
 301                        text,
 302                    }
 303                    .to_string();
 304                    event_stream.update_fields(ToolCallUpdateFields {
 305                        content: Some(vec![acp::ToolCallContent::Content {
 306                            content: markdown.into(),
 307                        }]),
 308                        ..Default::default()
 309                    })
 310                }
 311            })?;
 312
 313            result
 314        })
 315    }
 316}
 317
 318#[cfg(test)]
 319mod test {
 320    use super::*;
 321    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
 322    use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
 323    use project::{FakeFs, Project};
 324    use serde_json::json;
 325    use settings::SettingsStore;
 326    use util::path;
 327
 328    #[gpui::test]
 329    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 330        init_test(cx);
 331
 332        let fs = FakeFs::new(cx.executor());
 333        fs.insert_tree(path!("/root"), json!({})).await;
 334        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 335        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 336        let tool = Arc::new(ReadFileTool::new(project, action_log));
 337        let (event_stream, _) = ToolCallEventStream::test();
 338
 339        let result = cx
 340            .update(|cx| {
 341                let input = ReadFileToolInput {
 342                    path: "root/nonexistent_file.txt".to_string(),
 343                    start_line: None,
 344                    end_line: None,
 345                };
 346                tool.run(input, event_stream, cx)
 347            })
 348            .await;
 349        assert_eq!(
 350            result.unwrap_err().to_string(),
 351            "root/nonexistent_file.txt not found"
 352        );
 353    }
 354
 355    #[gpui::test]
 356    async fn test_read_small_file(cx: &mut TestAppContext) {
 357        init_test(cx);
 358
 359        let fs = FakeFs::new(cx.executor());
 360        fs.insert_tree(
 361            path!("/root"),
 362            json!({
 363                "small_file.txt": "This is a small file content"
 364            }),
 365        )
 366        .await;
 367        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 368        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 369        let tool = Arc::new(ReadFileTool::new(project, action_log));
 370        let result = cx
 371            .update(|cx| {
 372                let input = ReadFileToolInput {
 373                    path: "root/small_file.txt".into(),
 374                    start_line: None,
 375                    end_line: None,
 376                };
 377                tool.run(input, ToolCallEventStream::test().0, cx)
 378            })
 379            .await;
 380        assert_eq!(result.unwrap(), "This is a small file content".into());
 381    }
 382
 383    #[gpui::test]
 384    async fn test_read_large_file(cx: &mut TestAppContext) {
 385        init_test(cx);
 386
 387        let fs = FakeFs::new(cx.executor());
 388        fs.insert_tree(
 389            path!("/root"),
 390            json!({
 391                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 392            }),
 393        )
 394        .await;
 395        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 396        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 397        language_registry.add(Arc::new(rust_lang()));
 398        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 399        let tool = Arc::new(ReadFileTool::new(project, action_log));
 400        let result = cx
 401            .update(|cx| {
 402                let input = ReadFileToolInput {
 403                    path: "root/large_file.rs".into(),
 404                    start_line: None,
 405                    end_line: None,
 406                };
 407                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 408            })
 409            .await
 410            .unwrap();
 411        let content = result.to_str().unwrap();
 412
 413        assert_eq!(
 414            content.lines().skip(4).take(6).collect::<Vec<_>>(),
 415            vec![
 416                "struct Test0 [L1-4]",
 417                " a [L2]",
 418                " b [L3]",
 419                "struct Test1 [L5-8]",
 420                " a [L6]",
 421                " b [L7]",
 422            ]
 423        );
 424
 425        let result = cx
 426            .update(|cx| {
 427                let input = ReadFileToolInput {
 428                    path: "root/large_file.rs".into(),
 429                    start_line: None,
 430                    end_line: None,
 431                };
 432                tool.run(input, ToolCallEventStream::test().0, cx)
 433            })
 434            .await
 435            .unwrap();
 436        let content = result.to_str().unwrap();
 437        let expected_content = (0..1000)
 438            .flat_map(|i| {
 439                vec![
 440                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 441                    format!(" a [L{}]", i * 4 + 2),
 442                    format!(" b [L{}]", i * 4 + 3),
 443                ]
 444            })
 445            .collect::<Vec<_>>();
 446        pretty_assertions::assert_eq!(
 447            content
 448                .lines()
 449                .skip(4)
 450                .take(expected_content.len())
 451                .collect::<Vec<_>>(),
 452            expected_content
 453        );
 454    }
 455
 456    #[gpui::test]
 457    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 458        init_test(cx);
 459
 460        let fs = FakeFs::new(cx.executor());
 461        fs.insert_tree(
 462            path!("/root"),
 463            json!({
 464                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 465            }),
 466        )
 467        .await;
 468        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 469
 470        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 471        let tool = Arc::new(ReadFileTool::new(project, action_log));
 472        let result = cx
 473            .update(|cx| {
 474                let input = ReadFileToolInput {
 475                    path: "root/multiline.txt".to_string(),
 476                    start_line: Some(2),
 477                    end_line: Some(4),
 478                };
 479                tool.run(input, ToolCallEventStream::test().0, cx)
 480            })
 481            .await;
 482        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
 483    }
 484
 485    #[gpui::test]
 486    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 487        init_test(cx);
 488
 489        let fs = FakeFs::new(cx.executor());
 490        fs.insert_tree(
 491            path!("/root"),
 492            json!({
 493                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 494            }),
 495        )
 496        .await;
 497        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 498        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 499        let tool = Arc::new(ReadFileTool::new(project, action_log));
 500
 501        // start_line of 0 should be treated as 1
 502        let result = cx
 503            .update(|cx| {
 504                let input = ReadFileToolInput {
 505                    path: "root/multiline.txt".to_string(),
 506                    start_line: Some(0),
 507                    end_line: Some(2),
 508                };
 509                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 510            })
 511            .await;
 512        assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
 513
 514        // end_line of 0 should result in at least 1 line
 515        let result = cx
 516            .update(|cx| {
 517                let input = ReadFileToolInput {
 518                    path: "root/multiline.txt".to_string(),
 519                    start_line: Some(1),
 520                    end_line: Some(0),
 521                };
 522                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 523            })
 524            .await;
 525        assert_eq!(result.unwrap(), "Line 1\n".into());
 526
 527        // when start_line > end_line, should still return at least 1 line
 528        let result = cx
 529            .update(|cx| {
 530                let input = ReadFileToolInput {
 531                    path: "root/multiline.txt".to_string(),
 532                    start_line: Some(3),
 533                    end_line: Some(2),
 534                };
 535                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 536            })
 537            .await;
 538        assert_eq!(result.unwrap(), "Line 3\n".into());
 539    }
 540
 541    fn init_test(cx: &mut TestAppContext) {
 542        cx.update(|cx| {
 543            let settings_store = SettingsStore::test(cx);
 544            cx.set_global(settings_store);
 545        });
 546    }
 547
 548    fn rust_lang() -> Language {
 549        Language::new(
 550            LanguageConfig {
 551                name: "Rust".into(),
 552                matcher: LanguageMatcher {
 553                    path_suffixes: vec!["rs".to_string()],
 554                    ..Default::default()
 555                },
 556                ..Default::default()
 557            },
 558            Some(tree_sitter_rust::LANGUAGE.into()),
 559        )
 560        .with_outline_query(
 561            r#"
 562            (line_comment) @annotation
 563
 564            (struct_item
 565                "struct" @context
 566                name: (_) @name) @item
 567            (enum_item
 568                "enum" @context
 569                name: (_) @name) @item
 570            (enum_variant
 571                name: (_) @name) @item
 572            (field_declaration
 573                name: (_) @name) @item
 574            (impl_item
 575                "impl" @context
 576                trait: (_)? @name
 577                "for"? @context
 578                type: (_) @name
 579                body: (_ "{" (_)* "}")) @item
 580            (function_item
 581                "fn" @context
 582                name: (_) @name) @item
 583            (mod_item
 584                "mod" @context
 585                name: (_) @name) @item
 586            "#,
 587        )
 588        .unwrap()
 589    }
 590
 591    #[gpui::test]
 592    async fn test_read_file_security(cx: &mut TestAppContext) {
 593        init_test(cx);
 594
 595        let fs = FakeFs::new(cx.executor());
 596
 597        fs.insert_tree(
 598            path!("/"),
 599            json!({
 600                "project_root": {
 601                    "allowed_file.txt": "This file is in the project",
 602                    ".mysecrets": "SECRET_KEY=abc123",
 603                    ".secretdir": {
 604                        "config": "special configuration"
 605                    },
 606                    ".mymetadata": "custom metadata",
 607                    "subdir": {
 608                        "normal_file.txt": "Normal file content",
 609                        "special.privatekey": "private key content",
 610                        "data.mysensitive": "sensitive data"
 611                    }
 612                },
 613                "outside_project": {
 614                    "sensitive_file.txt": "This file is outside the project"
 615                }
 616            }),
 617        )
 618        .await;
 619
 620        cx.update(|cx| {
 621            use gpui::UpdateGlobal;
 622            use settings::SettingsStore;
 623            SettingsStore::update_global(cx, |store, cx| {
 624                store.update_user_settings(cx, |settings| {
 625                    settings.project.worktree.file_scan_exclusions = Some(vec![
 626                        "**/.secretdir".to_string(),
 627                        "**/.mymetadata".to_string(),
 628                    ]);
 629                    settings.project.worktree.private_files = Some(
 630                        vec![
 631                            "**/.mysecrets".to_string(),
 632                            "**/*.privatekey".to_string(),
 633                            "**/*.mysensitive".to_string(),
 634                        ]
 635                        .into(),
 636                    );
 637                });
 638            });
 639        });
 640
 641        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 642        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 643        let tool = Arc::new(ReadFileTool::new(project, action_log));
 644
 645        // Reading a file outside the project worktree should fail
 646        let result = cx
 647            .update(|cx| {
 648                let input = ReadFileToolInput {
 649                    path: "/outside_project/sensitive_file.txt".to_string(),
 650                    start_line: None,
 651                    end_line: None,
 652                };
 653                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 654            })
 655            .await;
 656        assert!(
 657            result.is_err(),
 658            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 659        );
 660
 661        // Reading a file within the project should succeed
 662        let result = cx
 663            .update(|cx| {
 664                let input = ReadFileToolInput {
 665                    path: "project_root/allowed_file.txt".to_string(),
 666                    start_line: None,
 667                    end_line: None,
 668                };
 669                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 670            })
 671            .await;
 672        assert!(
 673            result.is_ok(),
 674            "read_file_tool should be able to read files inside worktrees"
 675        );
 676
 677        // Reading files that match file_scan_exclusions should fail
 678        let result = cx
 679            .update(|cx| {
 680                let input = ReadFileToolInput {
 681                    path: "project_root/.secretdir/config".to_string(),
 682                    start_line: None,
 683                    end_line: None,
 684                };
 685                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 686            })
 687            .await;
 688        assert!(
 689            result.is_err(),
 690            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 691        );
 692
 693        let result = cx
 694            .update(|cx| {
 695                let input = ReadFileToolInput {
 696                    path: "project_root/.mymetadata".to_string(),
 697                    start_line: None,
 698                    end_line: None,
 699                };
 700                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 701            })
 702            .await;
 703        assert!(
 704            result.is_err(),
 705            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 706        );
 707
 708        // Reading private files should fail
 709        let result = cx
 710            .update(|cx| {
 711                let input = ReadFileToolInput {
 712                    path: "project_root/.mysecrets".to_string(),
 713                    start_line: None,
 714                    end_line: None,
 715                };
 716                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 717            })
 718            .await;
 719        assert!(
 720            result.is_err(),
 721            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 722        );
 723
 724        let result = cx
 725            .update(|cx| {
 726                let input = ReadFileToolInput {
 727                    path: "project_root/subdir/special.privatekey".to_string(),
 728                    start_line: None,
 729                    end_line: None,
 730                };
 731                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 732            })
 733            .await;
 734        assert!(
 735            result.is_err(),
 736            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 737        );
 738
 739        let result = cx
 740            .update(|cx| {
 741                let input = ReadFileToolInput {
 742                    path: "project_root/subdir/data.mysensitive".to_string(),
 743                    start_line: None,
 744                    end_line: None,
 745                };
 746                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 747            })
 748            .await;
 749        assert!(
 750            result.is_err(),
 751            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 752        );
 753
 754        // Reading a normal file should still work, even with private_files configured
 755        let result = cx
 756            .update(|cx| {
 757                let input = ReadFileToolInput {
 758                    path: "project_root/subdir/normal_file.txt".to_string(),
 759                    start_line: None,
 760                    end_line: None,
 761                };
 762                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 763            })
 764            .await;
 765        assert!(result.is_ok(), "Should be able to read normal files");
 766        assert_eq!(result.unwrap(), "Normal file content".into());
 767
 768        // Path traversal attempts with .. should fail
 769        let result = cx
 770            .update(|cx| {
 771                let input = ReadFileToolInput {
 772                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
 773                    start_line: None,
 774                    end_line: None,
 775                };
 776                tool.run(input, ToolCallEventStream::test().0, cx)
 777            })
 778            .await;
 779        assert!(
 780            result.is_err(),
 781            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 782        );
 783    }
 784
 785    #[gpui::test]
 786    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 787        init_test(cx);
 788
 789        let fs = FakeFs::new(cx.executor());
 790
 791        // Create first worktree with its own private_files setting
 792        fs.insert_tree(
 793            path!("/worktree1"),
 794            json!({
 795                "src": {
 796                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 797                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 798                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 799                },
 800                "tests": {
 801                    "test.rs": "mod tests { fn test_it() {} }",
 802                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 803                },
 804                ".zed": {
 805                    "settings.json": r#"{
 806                        "file_scan_exclusions": ["**/fixture.*"],
 807                        "private_files": ["**/secret.rs", "**/config.toml"]
 808                    }"#
 809                }
 810            }),
 811        )
 812        .await;
 813
 814        // Create second worktree with different private_files setting
 815        fs.insert_tree(
 816            path!("/worktree2"),
 817            json!({
 818                "lib": {
 819                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 820                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 821                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 822                },
 823                "docs": {
 824                    "README.md": "# Public Documentation",
 825                    "internal.md": "# Internal Secrets and Configuration"
 826                },
 827                ".zed": {
 828                    "settings.json": r#"{
 829                        "file_scan_exclusions": ["**/internal.*"],
 830                        "private_files": ["**/private.js", "**/data.json"]
 831                    }"#
 832                }
 833            }),
 834        )
 835        .await;
 836
 837        // Set global settings
 838        cx.update(|cx| {
 839            SettingsStore::update_global(cx, |store, cx| {
 840                store.update_user_settings(cx, |settings| {
 841                    settings.project.worktree.file_scan_exclusions =
 842                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 843                    settings.project.worktree.private_files =
 844                        Some(vec!["**/.env".to_string()].into());
 845                });
 846            });
 847        });
 848
 849        let project = Project::test(
 850            fs.clone(),
 851            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 852            cx,
 853        )
 854        .await;
 855
 856        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 857        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
 858
 859        // Test reading allowed files in worktree1
 860        let result = cx
 861            .update(|cx| {
 862                let input = ReadFileToolInput {
 863                    path: "worktree1/src/main.rs".to_string(),
 864                    start_line: None,
 865                    end_line: None,
 866                };
 867                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 868            })
 869            .await
 870            .unwrap();
 871
 872        assert_eq!(
 873            result,
 874            "fn main() { println!(\"Hello from worktree1\"); }".into()
 875        );
 876
 877        // Test reading private file in worktree1 should fail
 878        let result = cx
 879            .update(|cx| {
 880                let input = ReadFileToolInput {
 881                    path: "worktree1/src/secret.rs".to_string(),
 882                    start_line: None,
 883                    end_line: None,
 884                };
 885                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 886            })
 887            .await;
 888
 889        assert!(result.is_err());
 890        assert!(
 891            result
 892                .unwrap_err()
 893                .to_string()
 894                .contains("worktree `private_files` setting"),
 895            "Error should mention worktree private_files setting"
 896        );
 897
 898        // Test reading excluded file in worktree1 should fail
 899        let result = cx
 900            .update(|cx| {
 901                let input = ReadFileToolInput {
 902                    path: "worktree1/tests/fixture.sql".to_string(),
 903                    start_line: None,
 904                    end_line: None,
 905                };
 906                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 907            })
 908            .await;
 909
 910        assert!(result.is_err());
 911        assert!(
 912            result
 913                .unwrap_err()
 914                .to_string()
 915                .contains("worktree `file_scan_exclusions` setting"),
 916            "Error should mention worktree file_scan_exclusions setting"
 917        );
 918
 919        // Test reading allowed files in worktree2
 920        let result = cx
 921            .update(|cx| {
 922                let input = ReadFileToolInput {
 923                    path: "worktree2/lib/public.js".to_string(),
 924                    start_line: None,
 925                    end_line: None,
 926                };
 927                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 928            })
 929            .await
 930            .unwrap();
 931
 932        assert_eq!(
 933            result,
 934            "export function greet() { return 'Hello from worktree2'; }".into()
 935        );
 936
 937        // Test reading private file in worktree2 should fail
 938        let result = cx
 939            .update(|cx| {
 940                let input = ReadFileToolInput {
 941                    path: "worktree2/lib/private.js".to_string(),
 942                    start_line: None,
 943                    end_line: None,
 944                };
 945                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 946            })
 947            .await;
 948
 949        assert!(result.is_err());
 950        assert!(
 951            result
 952                .unwrap_err()
 953                .to_string()
 954                .contains("worktree `private_files` setting"),
 955            "Error should mention worktree private_files setting"
 956        );
 957
 958        // Test reading excluded file in worktree2 should fail
 959        let result = cx
 960            .update(|cx| {
 961                let input = ReadFileToolInput {
 962                    path: "worktree2/docs/internal.md".to_string(),
 963                    start_line: None,
 964                    end_line: None,
 965                };
 966                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 967            })
 968            .await;
 969
 970        assert!(result.is_err());
 971        assert!(
 972            result
 973                .unwrap_err()
 974                .to_string()
 975                .contains("worktree `file_scan_exclusions` setting"),
 976            "Error should mention worktree file_scan_exclusions setting"
 977        );
 978
 979        // Test that files allowed in one worktree but not in another are handled correctly
 980        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
 981        let result = cx
 982            .update(|cx| {
 983                let input = ReadFileToolInput {
 984                    path: "worktree1/src/config.toml".to_string(),
 985                    start_line: None,
 986                    end_line: None,
 987                };
 988                tool.clone().run(input, ToolCallEventStream::test().0, cx)
 989            })
 990            .await;
 991
 992        assert!(result.is_err());
 993        assert!(
 994            result
 995                .unwrap_err()
 996                .to_string()
 997                .contains("worktree `private_files` setting"),
 998            "Config.toml should be blocked by worktree1's private_files setting"
 999        );
1000    }
1001}