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