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 `{}` (lines {}-{})", path.display(), start, end,)
 86                }
 87                (Some(start), None) => {
 88                    format!("Read file `{}` (from line {})", path.display(), start)
 89                }
 90                _ => format!("Read file `{}`", path.display()),
 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                    let text = buffer.text();
205                    // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
206                    let start = input.start_line.unwrap_or(1).max(1);
207                    let start_row = start - 1;
208                    if start_row <= buffer.max_point().row {
209                        let column = buffer.line_indent_for_row(start_row).raw_len();
210                        anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
211                    }
212
213                    let lines = text.split('\n').skip(start_row as usize);
214                    if let Some(end) = input.end_line {
215                        let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
216                        itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
217                    } else {
218                        itertools::intersperse(lines, "\n").collect::<String>()
219                    }
220                })?;
221
222                action_log.update(cx, |log, cx| {
223                    log.buffer_read(buffer.clone(), cx);
224                })?;
225
226                Ok(result.into())
227            } else {
228                // No line ranges specified, so check file size to see if it's too big.
229                let buffer_content =
230                    outline::get_buffer_content_or_outline(buffer.clone(), Some(&abs_path), cx)
231                        .await?;
232
233                action_log.update(cx, |log, cx| {
234                    log.buffer_read(buffer.clone(), cx);
235                })?;
236
237                if buffer_content.is_outline {
238                    Ok(formatdoc! {"
239                        This file was too big to read all at once.
240
241                        {}
242
243                        Using the line numbers in this outline, you can call this tool again
244                        while specifying the start_line and end_line fields to see the
245                        implementations of symbols in the outline.
246
247                        Alternatively, you can fall back to the `grep` tool (if available)
248                        to search the file for specific content.", buffer_content.text
249                    }
250                    .into())
251                } else {
252                    Ok(buffer_content.text.into())
253                }
254            };
255
256            project.update(cx, |project, cx| {
257                project.set_agent_location(
258                    Some(AgentLocation {
259                        buffer: buffer.downgrade(),
260                        position: anchor.unwrap_or(text::Anchor::MIN),
261                    }),
262                    cx,
263                );
264                if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
265                    let markdown = MarkdownCodeBlock {
266                        tag: &input.path,
267                        text,
268                    }
269                    .to_string();
270                    event_stream.update_fields(ToolCallUpdateFields {
271                        content: Some(vec![acp::ToolCallContent::Content {
272                            content: markdown.into(),
273                        }]),
274                        ..Default::default()
275                    })
276                }
277            })?;
278
279            result
280        })
281    }
282}
283
284#[cfg(test)]
285mod test {
286    use super::*;
287    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
288    use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
289    use project::{FakeFs, Project};
290    use serde_json::json;
291    use settings::SettingsStore;
292    use util::path;
293
294    #[gpui::test]
295    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
296        init_test(cx);
297
298        let fs = FakeFs::new(cx.executor());
299        fs.insert_tree(path!("/root"), json!({})).await;
300        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
301        let action_log = cx.new(|_| ActionLog::new(project.clone()));
302        let tool = Arc::new(ReadFileTool::new(project, action_log));
303        let (event_stream, _) = ToolCallEventStream::test();
304
305        let result = cx
306            .update(|cx| {
307                let input = ReadFileToolInput {
308                    path: "root/nonexistent_file.txt".to_string(),
309                    start_line: None,
310                    end_line: None,
311                };
312                tool.run(input, event_stream, cx)
313            })
314            .await;
315        assert_eq!(
316            result.unwrap_err().to_string(),
317            "root/nonexistent_file.txt not found"
318        );
319    }
320
321    #[gpui::test]
322    async fn test_read_small_file(cx: &mut TestAppContext) {
323        init_test(cx);
324
325        let fs = FakeFs::new(cx.executor());
326        fs.insert_tree(
327            path!("/root"),
328            json!({
329                "small_file.txt": "This is a small file content"
330            }),
331        )
332        .await;
333        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
334        let action_log = cx.new(|_| ActionLog::new(project.clone()));
335        let tool = Arc::new(ReadFileTool::new(project, action_log));
336        let result = cx
337            .update(|cx| {
338                let input = ReadFileToolInput {
339                    path: "root/small_file.txt".into(),
340                    start_line: None,
341                    end_line: None,
342                };
343                tool.run(input, ToolCallEventStream::test().0, cx)
344            })
345            .await;
346        assert_eq!(result.unwrap(), "This is a small file content".into());
347    }
348
349    #[gpui::test]
350    async fn test_read_large_file(cx: &mut TestAppContext) {
351        init_test(cx);
352
353        let fs = FakeFs::new(cx.executor());
354        fs.insert_tree(
355            path!("/root"),
356            json!({
357                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
358            }),
359        )
360        .await;
361        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
362        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
363        language_registry.add(Arc::new(rust_lang()));
364        let action_log = cx.new(|_| ActionLog::new(project.clone()));
365        let tool = Arc::new(ReadFileTool::new(project, action_log));
366        let result = cx
367            .update(|cx| {
368                let input = ReadFileToolInput {
369                    path: "root/large_file.rs".into(),
370                    start_line: None,
371                    end_line: None,
372                };
373                tool.clone().run(input, ToolCallEventStream::test().0, cx)
374            })
375            .await
376            .unwrap();
377        let content = result.to_str().unwrap();
378
379        assert_eq!(
380            content.lines().skip(4).take(6).collect::<Vec<_>>(),
381            vec![
382                "struct Test0 [L1-4]",
383                " a [L2]",
384                " b [L3]",
385                "struct Test1 [L5-8]",
386                " a [L6]",
387                " b [L7]",
388            ]
389        );
390
391        let result = cx
392            .update(|cx| {
393                let input = ReadFileToolInput {
394                    path: "root/large_file.rs".into(),
395                    start_line: None,
396                    end_line: None,
397                };
398                tool.run(input, ToolCallEventStream::test().0, cx)
399            })
400            .await
401            .unwrap();
402        let content = result.to_str().unwrap();
403        let expected_content = (0..1000)
404            .flat_map(|i| {
405                vec![
406                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
407                    format!(" a [L{}]", i * 4 + 2),
408                    format!(" b [L{}]", i * 4 + 3),
409                ]
410            })
411            .collect::<Vec<_>>();
412        pretty_assertions::assert_eq!(
413            content
414                .lines()
415                .skip(4)
416                .take(expected_content.len())
417                .collect::<Vec<_>>(),
418            expected_content
419        );
420    }
421
422    #[gpui::test]
423    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
424        init_test(cx);
425
426        let fs = FakeFs::new(cx.executor());
427        fs.insert_tree(
428            path!("/root"),
429            json!({
430                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
431            }),
432        )
433        .await;
434        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
435
436        let action_log = cx.new(|_| ActionLog::new(project.clone()));
437        let tool = Arc::new(ReadFileTool::new(project, action_log));
438        let result = cx
439            .update(|cx| {
440                let input = ReadFileToolInput {
441                    path: "root/multiline.txt".to_string(),
442                    start_line: Some(2),
443                    end_line: Some(4),
444                };
445                tool.run(input, ToolCallEventStream::test().0, cx)
446            })
447            .await;
448        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into());
449    }
450
451    #[gpui::test]
452    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
453        init_test(cx);
454
455        let fs = FakeFs::new(cx.executor());
456        fs.insert_tree(
457            path!("/root"),
458            json!({
459                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
460            }),
461        )
462        .await;
463        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
464        let action_log = cx.new(|_| ActionLog::new(project.clone()));
465        let tool = Arc::new(ReadFileTool::new(project, action_log));
466
467        // start_line of 0 should be treated as 1
468        let result = cx
469            .update(|cx| {
470                let input = ReadFileToolInput {
471                    path: "root/multiline.txt".to_string(),
472                    start_line: Some(0),
473                    end_line: Some(2),
474                };
475                tool.clone().run(input, ToolCallEventStream::test().0, cx)
476            })
477            .await;
478        assert_eq!(result.unwrap(), "Line 1\nLine 2".into());
479
480        // end_line of 0 should result in at least 1 line
481        let result = cx
482            .update(|cx| {
483                let input = ReadFileToolInput {
484                    path: "root/multiline.txt".to_string(),
485                    start_line: Some(1),
486                    end_line: Some(0),
487                };
488                tool.clone().run(input, ToolCallEventStream::test().0, cx)
489            })
490            .await;
491        assert_eq!(result.unwrap(), "Line 1".into());
492
493        // when start_line > end_line, should still return at least 1 line
494        let result = cx
495            .update(|cx| {
496                let input = ReadFileToolInput {
497                    path: "root/multiline.txt".to_string(),
498                    start_line: Some(3),
499                    end_line: Some(2),
500                };
501                tool.clone().run(input, ToolCallEventStream::test().0, cx)
502            })
503            .await;
504        assert_eq!(result.unwrap(), "Line 3".into());
505    }
506
507    fn init_test(cx: &mut TestAppContext) {
508        cx.update(|cx| {
509            let settings_store = SettingsStore::test(cx);
510            cx.set_global(settings_store);
511            language::init(cx);
512            Project::init_settings(cx);
513        });
514    }
515
516    fn rust_lang() -> Language {
517        Language::new(
518            LanguageConfig {
519                name: "Rust".into(),
520                matcher: LanguageMatcher {
521                    path_suffixes: vec!["rs".to_string()],
522                    ..Default::default()
523                },
524                ..Default::default()
525            },
526            Some(tree_sitter_rust::LANGUAGE.into()),
527        )
528        .with_outline_query(
529            r#"
530            (line_comment) @annotation
531
532            (struct_item
533                "struct" @context
534                name: (_) @name) @item
535            (enum_item
536                "enum" @context
537                name: (_) @name) @item
538            (enum_variant
539                name: (_) @name) @item
540            (field_declaration
541                name: (_) @name) @item
542            (impl_item
543                "impl" @context
544                trait: (_)? @name
545                "for"? @context
546                type: (_) @name
547                body: (_ "{" (_)* "}")) @item
548            (function_item
549                "fn" @context
550                name: (_) @name) @item
551            (mod_item
552                "mod" @context
553                name: (_) @name) @item
554            "#,
555        )
556        .unwrap()
557    }
558
559    #[gpui::test]
560    async fn test_read_file_security(cx: &mut TestAppContext) {
561        init_test(cx);
562
563        let fs = FakeFs::new(cx.executor());
564
565        fs.insert_tree(
566            path!("/"),
567            json!({
568                "project_root": {
569                    "allowed_file.txt": "This file is in the project",
570                    ".mysecrets": "SECRET_KEY=abc123",
571                    ".secretdir": {
572                        "config": "special configuration"
573                    },
574                    ".mymetadata": "custom metadata",
575                    "subdir": {
576                        "normal_file.txt": "Normal file content",
577                        "special.privatekey": "private key content",
578                        "data.mysensitive": "sensitive data"
579                    }
580                },
581                "outside_project": {
582                    "sensitive_file.txt": "This file is outside the project"
583                }
584            }),
585        )
586        .await;
587
588        cx.update(|cx| {
589            use gpui::UpdateGlobal;
590            use project::WorktreeSettings;
591            use settings::SettingsStore;
592            SettingsStore::update_global(cx, |store, cx| {
593                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
594                    settings.file_scan_exclusions = Some(vec![
595                        "**/.secretdir".to_string(),
596                        "**/.mymetadata".to_string(),
597                    ]);
598                    settings.private_files = Some(vec![
599                        "**/.mysecrets".to_string(),
600                        "**/*.privatekey".to_string(),
601                        "**/*.mysensitive".to_string(),
602                    ]);
603                });
604            });
605        });
606
607        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
608        let action_log = cx.new(|_| ActionLog::new(project.clone()));
609        let tool = Arc::new(ReadFileTool::new(project, action_log));
610
611        // Reading a file outside the project worktree should fail
612        let result = cx
613            .update(|cx| {
614                let input = ReadFileToolInput {
615                    path: "/outside_project/sensitive_file.txt".to_string(),
616                    start_line: None,
617                    end_line: None,
618                };
619                tool.clone().run(input, ToolCallEventStream::test().0, cx)
620            })
621            .await;
622        assert!(
623            result.is_err(),
624            "read_file_tool should error when attempting to read an absolute path outside a worktree"
625        );
626
627        // Reading a file within the project should succeed
628        let result = cx
629            .update(|cx| {
630                let input = ReadFileToolInput {
631                    path: "project_root/allowed_file.txt".to_string(),
632                    start_line: None,
633                    end_line: None,
634                };
635                tool.clone().run(input, ToolCallEventStream::test().0, cx)
636            })
637            .await;
638        assert!(
639            result.is_ok(),
640            "read_file_tool should be able to read files inside worktrees"
641        );
642
643        // Reading files that match file_scan_exclusions should fail
644        let result = cx
645            .update(|cx| {
646                let input = ReadFileToolInput {
647                    path: "project_root/.secretdir/config".to_string(),
648                    start_line: None,
649                    end_line: None,
650                };
651                tool.clone().run(input, ToolCallEventStream::test().0, cx)
652            })
653            .await;
654        assert!(
655            result.is_err(),
656            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
657        );
658
659        let result = cx
660            .update(|cx| {
661                let input = ReadFileToolInput {
662                    path: "project_root/.mymetadata".to_string(),
663                    start_line: None,
664                    end_line: None,
665                };
666                tool.clone().run(input, ToolCallEventStream::test().0, cx)
667            })
668            .await;
669        assert!(
670            result.is_err(),
671            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
672        );
673
674        // Reading private files should fail
675        let result = cx
676            .update(|cx| {
677                let input = ReadFileToolInput {
678                    path: "project_root/.mysecrets".to_string(),
679                    start_line: None,
680                    end_line: None,
681                };
682                tool.clone().run(input, ToolCallEventStream::test().0, cx)
683            })
684            .await;
685        assert!(
686            result.is_err(),
687            "read_file_tool should error when attempting to read .mysecrets (private_files)"
688        );
689
690        let result = cx
691            .update(|cx| {
692                let input = ReadFileToolInput {
693                    path: "project_root/subdir/special.privatekey".to_string(),
694                    start_line: None,
695                    end_line: None,
696                };
697                tool.clone().run(input, ToolCallEventStream::test().0, cx)
698            })
699            .await;
700        assert!(
701            result.is_err(),
702            "read_file_tool should error when attempting to read .privatekey files (private_files)"
703        );
704
705        let result = cx
706            .update(|cx| {
707                let input = ReadFileToolInput {
708                    path: "project_root/subdir/data.mysensitive".to_string(),
709                    start_line: None,
710                    end_line: None,
711                };
712                tool.clone().run(input, ToolCallEventStream::test().0, cx)
713            })
714            .await;
715        assert!(
716            result.is_err(),
717            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
718        );
719
720        // Reading a normal file should still work, even with private_files configured
721        let result = cx
722            .update(|cx| {
723                let input = ReadFileToolInput {
724                    path: "project_root/subdir/normal_file.txt".to_string(),
725                    start_line: None,
726                    end_line: None,
727                };
728                tool.clone().run(input, ToolCallEventStream::test().0, cx)
729            })
730            .await;
731        assert!(result.is_ok(), "Should be able to read normal files");
732        assert_eq!(result.unwrap(), "Normal file content".into());
733
734        // Path traversal attempts with .. should fail
735        let result = cx
736            .update(|cx| {
737                let input = ReadFileToolInput {
738                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
739                    start_line: None,
740                    end_line: None,
741                };
742                tool.run(input, ToolCallEventStream::test().0, cx)
743            })
744            .await;
745        assert!(
746            result.is_err(),
747            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
748        );
749    }
750
751    #[gpui::test]
752    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
753        init_test(cx);
754
755        let fs = FakeFs::new(cx.executor());
756
757        // Create first worktree with its own private_files setting
758        fs.insert_tree(
759            path!("/worktree1"),
760            json!({
761                "src": {
762                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
763                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
764                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
765                },
766                "tests": {
767                    "test.rs": "mod tests { fn test_it() {} }",
768                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
769                },
770                ".zed": {
771                    "settings.json": r#"{
772                        "file_scan_exclusions": ["**/fixture.*"],
773                        "private_files": ["**/secret.rs", "**/config.toml"]
774                    }"#
775                }
776            }),
777        )
778        .await;
779
780        // Create second worktree with different private_files setting
781        fs.insert_tree(
782            path!("/worktree2"),
783            json!({
784                "lib": {
785                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
786                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
787                    "data.json": "{\"api_key\": \"json_secret_key\"}"
788                },
789                "docs": {
790                    "README.md": "# Public Documentation",
791                    "internal.md": "# Internal Secrets and Configuration"
792                },
793                ".zed": {
794                    "settings.json": r#"{
795                        "file_scan_exclusions": ["**/internal.*"],
796                        "private_files": ["**/private.js", "**/data.json"]
797                    }"#
798                }
799            }),
800        )
801        .await;
802
803        // Set global settings
804        cx.update(|cx| {
805            SettingsStore::update_global(cx, |store, cx| {
806                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
807                    settings.file_scan_exclusions =
808                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
809                    settings.private_files = Some(vec!["**/.env".to_string()]);
810                });
811            });
812        });
813
814        let project = Project::test(
815            fs.clone(),
816            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
817            cx,
818        )
819        .await;
820
821        let action_log = cx.new(|_| ActionLog::new(project.clone()));
822        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
823
824        // Test reading allowed files in worktree1
825        let result = cx
826            .update(|cx| {
827                let input = ReadFileToolInput {
828                    path: "worktree1/src/main.rs".to_string(),
829                    start_line: None,
830                    end_line: None,
831                };
832                tool.clone().run(input, ToolCallEventStream::test().0, cx)
833            })
834            .await
835            .unwrap();
836
837        assert_eq!(
838            result,
839            "fn main() { println!(\"Hello from worktree1\"); }".into()
840        );
841
842        // Test reading private file in worktree1 should fail
843        let result = cx
844            .update(|cx| {
845                let input = ReadFileToolInput {
846                    path: "worktree1/src/secret.rs".to_string(),
847                    start_line: None,
848                    end_line: None,
849                };
850                tool.clone().run(input, ToolCallEventStream::test().0, cx)
851            })
852            .await;
853
854        assert!(result.is_err());
855        assert!(
856            result
857                .unwrap_err()
858                .to_string()
859                .contains("worktree `private_files` setting"),
860            "Error should mention worktree private_files setting"
861        );
862
863        // Test reading excluded file in worktree1 should fail
864        let result = cx
865            .update(|cx| {
866                let input = ReadFileToolInput {
867                    path: "worktree1/tests/fixture.sql".to_string(),
868                    start_line: None,
869                    end_line: None,
870                };
871                tool.clone().run(input, ToolCallEventStream::test().0, cx)
872            })
873            .await;
874
875        assert!(result.is_err());
876        assert!(
877            result
878                .unwrap_err()
879                .to_string()
880                .contains("worktree `file_scan_exclusions` setting"),
881            "Error should mention worktree file_scan_exclusions setting"
882        );
883
884        // Test reading allowed files in worktree2
885        let result = cx
886            .update(|cx| {
887                let input = ReadFileToolInput {
888                    path: "worktree2/lib/public.js".to_string(),
889                    start_line: None,
890                    end_line: None,
891                };
892                tool.clone().run(input, ToolCallEventStream::test().0, cx)
893            })
894            .await
895            .unwrap();
896
897        assert_eq!(
898            result,
899            "export function greet() { return 'Hello from worktree2'; }".into()
900        );
901
902        // Test reading private file in worktree2 should fail
903        let result = cx
904            .update(|cx| {
905                let input = ReadFileToolInput {
906                    path: "worktree2/lib/private.js".to_string(),
907                    start_line: None,
908                    end_line: None,
909                };
910                tool.clone().run(input, ToolCallEventStream::test().0, cx)
911            })
912            .await;
913
914        assert!(result.is_err());
915        assert!(
916            result
917                .unwrap_err()
918                .to_string()
919                .contains("worktree `private_files` setting"),
920            "Error should mention worktree private_files setting"
921        );
922
923        // Test reading excluded file in worktree2 should fail
924        let result = cx
925            .update(|cx| {
926                let input = ReadFileToolInput {
927                    path: "worktree2/docs/internal.md".to_string(),
928                    start_line: None,
929                    end_line: None,
930                };
931                tool.clone().run(input, ToolCallEventStream::test().0, cx)
932            })
933            .await;
934
935        assert!(result.is_err());
936        assert!(
937            result
938                .unwrap_err()
939                .to_string()
940                .contains("worktree `file_scan_exclusions` setting"),
941            "Error should mention worktree file_scan_exclusions setting"
942        );
943
944        // Test that files allowed in one worktree but not in another are handled correctly
945        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
946        let result = cx
947            .update(|cx| {
948                let input = ReadFileToolInput {
949                    path: "worktree1/src/config.toml".to_string(),
950                    start_line: None,
951                    end_line: None,
952                };
953                tool.clone().run(input, ToolCallEventStream::test().0, cx)
954            })
955            .await;
956
957        assert!(result.is_err());
958        assert!(
959            result
960                .unwrap_err()
961                .to_string()
962                .contains("worktree `private_files` setting"),
963            "Config.toml should be blocked by worktree1's private_files setting"
964        );
965    }
966}