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