read_file_tool.rs

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