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(
597 vec![
598 "**/.mysecrets".to_string(),
599 "**/*.privatekey".to_string(),
600 "**/*.mysensitive".to_string(),
601 ]
602 .into(),
603 );
604 });
605 });
606 });
607
608 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
609 let action_log = cx.new(|_| ActionLog::new(project.clone()));
610 let tool = Arc::new(ReadFileTool::new(project, action_log));
611
612 // Reading a file outside the project worktree should fail
613 let result = cx
614 .update(|cx| {
615 let input = ReadFileToolInput {
616 path: "/outside_project/sensitive_file.txt".to_string(),
617 start_line: None,
618 end_line: None,
619 };
620 tool.clone().run(input, ToolCallEventStream::test().0, cx)
621 })
622 .await;
623 assert!(
624 result.is_err(),
625 "read_file_tool should error when attempting to read an absolute path outside a worktree"
626 );
627
628 // Reading a file within the project should succeed
629 let result = cx
630 .update(|cx| {
631 let input = ReadFileToolInput {
632 path: "project_root/allowed_file.txt".to_string(),
633 start_line: None,
634 end_line: None,
635 };
636 tool.clone().run(input, ToolCallEventStream::test().0, cx)
637 })
638 .await;
639 assert!(
640 result.is_ok(),
641 "read_file_tool should be able to read files inside worktrees"
642 );
643
644 // Reading files that match file_scan_exclusions should fail
645 let result = cx
646 .update(|cx| {
647 let input = ReadFileToolInput {
648 path: "project_root/.secretdir/config".to_string(),
649 start_line: None,
650 end_line: None,
651 };
652 tool.clone().run(input, ToolCallEventStream::test().0, cx)
653 })
654 .await;
655 assert!(
656 result.is_err(),
657 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
658 );
659
660 let result = cx
661 .update(|cx| {
662 let input = ReadFileToolInput {
663 path: "project_root/.mymetadata".to_string(),
664 start_line: None,
665 end_line: None,
666 };
667 tool.clone().run(input, ToolCallEventStream::test().0, cx)
668 })
669 .await;
670 assert!(
671 result.is_err(),
672 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
673 );
674
675 // Reading private files should fail
676 let result = cx
677 .update(|cx| {
678 let input = ReadFileToolInput {
679 path: "project_root/.mysecrets".to_string(),
680 start_line: None,
681 end_line: None,
682 };
683 tool.clone().run(input, ToolCallEventStream::test().0, cx)
684 })
685 .await;
686 assert!(
687 result.is_err(),
688 "read_file_tool should error when attempting to read .mysecrets (private_files)"
689 );
690
691 let result = cx
692 .update(|cx| {
693 let input = ReadFileToolInput {
694 path: "project_root/subdir/special.privatekey".to_string(),
695 start_line: None,
696 end_line: None,
697 };
698 tool.clone().run(input, ToolCallEventStream::test().0, cx)
699 })
700 .await;
701 assert!(
702 result.is_err(),
703 "read_file_tool should error when attempting to read .privatekey files (private_files)"
704 );
705
706 let result = cx
707 .update(|cx| {
708 let input = ReadFileToolInput {
709 path: "project_root/subdir/data.mysensitive".to_string(),
710 start_line: None,
711 end_line: None,
712 };
713 tool.clone().run(input, ToolCallEventStream::test().0, cx)
714 })
715 .await;
716 assert!(
717 result.is_err(),
718 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
719 );
720
721 // Reading a normal file should still work, even with private_files configured
722 let result = cx
723 .update(|cx| {
724 let input = ReadFileToolInput {
725 path: "project_root/subdir/normal_file.txt".to_string(),
726 start_line: None,
727 end_line: None,
728 };
729 tool.clone().run(input, ToolCallEventStream::test().0, cx)
730 })
731 .await;
732 assert!(result.is_ok(), "Should be able to read normal files");
733 assert_eq!(result.unwrap(), "Normal file content".into());
734
735 // Path traversal attempts with .. should fail
736 let result = cx
737 .update(|cx| {
738 let input = ReadFileToolInput {
739 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
740 start_line: None,
741 end_line: None,
742 };
743 tool.run(input, ToolCallEventStream::test().0, cx)
744 })
745 .await;
746 assert!(
747 result.is_err(),
748 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
749 );
750 }
751
752 #[gpui::test]
753 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
754 init_test(cx);
755
756 let fs = FakeFs::new(cx.executor());
757
758 // Create first worktree with its own private_files setting
759 fs.insert_tree(
760 path!("/worktree1"),
761 json!({
762 "src": {
763 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
764 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
765 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
766 },
767 "tests": {
768 "test.rs": "mod tests { fn test_it() {} }",
769 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
770 },
771 ".zed": {
772 "settings.json": r#"{
773 "file_scan_exclusions": ["**/fixture.*"],
774 "private_files": ["**/secret.rs", "**/config.toml"]
775 }"#
776 }
777 }),
778 )
779 .await;
780
781 // Create second worktree with different private_files setting
782 fs.insert_tree(
783 path!("/worktree2"),
784 json!({
785 "lib": {
786 "public.js": "export function greet() { return 'Hello from worktree2'; }",
787 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
788 "data.json": "{\"api_key\": \"json_secret_key\"}"
789 },
790 "docs": {
791 "README.md": "# Public Documentation",
792 "internal.md": "# Internal Secrets and Configuration"
793 },
794 ".zed": {
795 "settings.json": r#"{
796 "file_scan_exclusions": ["**/internal.*"],
797 "private_files": ["**/private.js", "**/data.json"]
798 }"#
799 }
800 }),
801 )
802 .await;
803
804 // Set global settings
805 cx.update(|cx| {
806 SettingsStore::update_global(cx, |store, cx| {
807 store.update_user_settings(cx, |settings| {
808 settings.project.worktree.file_scan_exclusions =
809 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
810 settings.project.worktree.private_files =
811 Some(vec!["**/.env".to_string()].into());
812 });
813 });
814 });
815
816 let project = Project::test(
817 fs.clone(),
818 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
819 cx,
820 )
821 .await;
822
823 let action_log = cx.new(|_| ActionLog::new(project.clone()));
824 let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
825
826 // Test reading allowed files in worktree1
827 let result = cx
828 .update(|cx| {
829 let input = ReadFileToolInput {
830 path: "worktree1/src/main.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 .unwrap();
838
839 assert_eq!(
840 result,
841 "fn main() { println!(\"Hello from worktree1\"); }".into()
842 );
843
844 // Test reading private file in worktree1 should fail
845 let result = cx
846 .update(|cx| {
847 let input = ReadFileToolInput {
848 path: "worktree1/src/secret.rs".to_string(),
849 start_line: None,
850 end_line: None,
851 };
852 tool.clone().run(input, ToolCallEventStream::test().0, cx)
853 })
854 .await;
855
856 assert!(result.is_err());
857 assert!(
858 result
859 .unwrap_err()
860 .to_string()
861 .contains("worktree `private_files` setting"),
862 "Error should mention worktree private_files setting"
863 );
864
865 // Test reading excluded file in worktree1 should fail
866 let result = cx
867 .update(|cx| {
868 let input = ReadFileToolInput {
869 path: "worktree1/tests/fixture.sql".to_string(),
870 start_line: None,
871 end_line: None,
872 };
873 tool.clone().run(input, ToolCallEventStream::test().0, cx)
874 })
875 .await;
876
877 assert!(result.is_err());
878 assert!(
879 result
880 .unwrap_err()
881 .to_string()
882 .contains("worktree `file_scan_exclusions` setting"),
883 "Error should mention worktree file_scan_exclusions setting"
884 );
885
886 // Test reading allowed files in worktree2
887 let result = cx
888 .update(|cx| {
889 let input = ReadFileToolInput {
890 path: "worktree2/lib/public.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 .unwrap();
898
899 assert_eq!(
900 result,
901 "export function greet() { return 'Hello from worktree2'; }".into()
902 );
903
904 // Test reading private file in worktree2 should fail
905 let result = cx
906 .update(|cx| {
907 let input = ReadFileToolInput {
908 path: "worktree2/lib/private.js".to_string(),
909 start_line: None,
910 end_line: None,
911 };
912 tool.clone().run(input, ToolCallEventStream::test().0, cx)
913 })
914 .await;
915
916 assert!(result.is_err());
917 assert!(
918 result
919 .unwrap_err()
920 .to_string()
921 .contains("worktree `private_files` setting"),
922 "Error should mention worktree private_files setting"
923 );
924
925 // Test reading excluded file in worktree2 should fail
926 let result = cx
927 .update(|cx| {
928 let input = ReadFileToolInput {
929 path: "worktree2/docs/internal.md".to_string(),
930 start_line: None,
931 end_line: None,
932 };
933 tool.clone().run(input, ToolCallEventStream::test().0, cx)
934 })
935 .await;
936
937 assert!(result.is_err());
938 assert!(
939 result
940 .unwrap_err()
941 .to_string()
942 .contains("worktree `file_scan_exclusions` setting"),
943 "Error should mention worktree file_scan_exclusions setting"
944 );
945
946 // Test that files allowed in one worktree but not in another are handled correctly
947 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
948 let result = cx
949 .update(|cx| {
950 let input = ReadFileToolInput {
951 path: "worktree1/src/config.toml".to_string(),
952 start_line: None,
953 end_line: None,
954 };
955 tool.clone().run(input, ToolCallEventStream::test().0, cx)
956 })
957 .await;
958
959 assert!(result.is_err());
960 assert!(
961 result
962 .unwrap_err()
963 .to_string()
964 .contains("worktree `private_files` setting"),
965 "Config.toml should be blocked by worktree1's private_files setting"
966 );
967 }
968}