read_file_tool.rs

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