read_file_tool.rs

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