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