read_file_tool.rs

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