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