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