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