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