read_file_tool.rs

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