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