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