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