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