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