read_file_tool.rs

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