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