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