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