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