read_file_tool.rs

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