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}