1use crate::schema::json_schema_for;
2use anyhow::{Context as _, Result, anyhow};
3use assistant_tool::{ActionLog, Tool, ToolResult};
4use assistant_tool::{ToolResultContent, outline};
5use gpui::{AnyWindowHandle, App, Entity, Task};
6use project::{ImageItem, image_store};
7
8use assistant_tool::ToolResultOutput;
9use indoc::formatdoc;
10use itertools::Itertools;
11use language::{Anchor, Point};
12use language_model::{
13 LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat,
14};
15use project::{AgentLocation, Project, WorktreeSettings};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use settings::Settings;
19use std::sync::Arc;
20use ui::IconName;
21use util::markdown::MarkdownInlineCode;
22
23/// If the model requests to read a file whose size exceeds this, then
24#[derive(Debug, Serialize, Deserialize, JsonSchema)]
25pub struct ReadFileToolInput {
26 /// The relative path of the file to read.
27 ///
28 /// This path should never be absolute, and the first component
29 /// of the path should always be a root directory in a project.
30 ///
31 /// <example>
32 /// If the project has the following root directories:
33 ///
34 /// - /a/b/directory1
35 /// - /c/d/directory2
36 ///
37 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
38 /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
39 /// </example>
40 pub path: String,
41
42 /// Optional line number to start reading on (1-based index)
43 #[serde(default)]
44 pub start_line: Option<u32>,
45
46 /// Optional line number to end reading on (1-based index, inclusive)
47 #[serde(default)]
48 pub end_line: Option<u32>,
49}
50
51pub struct ReadFileTool;
52
53impl Tool for ReadFileTool {
54 fn name(&self) -> String {
55 "read_file".into()
56 }
57
58 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
59 false
60 }
61
62 fn may_perform_edits(&self) -> bool {
63 false
64 }
65
66 fn description(&self) -> String {
67 include_str!("./read_file_tool/description.md").into()
68 }
69
70 fn icon(&self) -> IconName {
71 IconName::FileSearch
72 }
73
74 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
75 json_schema_for::<ReadFileToolInput>(format)
76 }
77
78 fn ui_text(&self, input: &serde_json::Value) -> String {
79 match serde_json::from_value::<ReadFileToolInput>(input.clone()) {
80 Ok(input) => {
81 let path = MarkdownInlineCode(&input.path);
82 match (input.start_line, input.end_line) {
83 (Some(start), None) => format!("Read file {path} (from line {start})"),
84 (Some(start), Some(end)) => format!("Read file {path} (lines {start}-{end})"),
85 _ => format!("Read file {path}"),
86 }
87 }
88 Err(_) => "Read file".to_string(),
89 }
90 }
91
92 fn run(
93 self: Arc<Self>,
94 input: serde_json::Value,
95 _request: Arc<LanguageModelRequest>,
96 project: Entity<Project>,
97 action_log: Entity<ActionLog>,
98 model: Arc<dyn LanguageModel>,
99 _window: Option<AnyWindowHandle>,
100 cx: &mut App,
101 ) -> ToolResult {
102 let input = match serde_json::from_value::<ReadFileToolInput>(input) {
103 Ok(input) => input,
104 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
105 };
106
107 let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
108 return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
109 };
110
111 // Error out if this path is either excluded or private in global settings
112 let global_settings = WorktreeSettings::get_global(cx);
113 if global_settings.is_path_excluded(&project_path.path) {
114 return Task::ready(Err(anyhow!(
115 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
116 &input.path
117 )))
118 .into();
119 }
120
121 if global_settings.is_path_private(&project_path.path) {
122 return Task::ready(Err(anyhow!(
123 "Cannot read file because its path matches the global `private_files` setting: {}",
124 &input.path
125 )))
126 .into();
127 }
128
129 // Error out if this path is either excluded or private in worktree settings
130 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
131 if worktree_settings.is_path_excluded(&project_path.path) {
132 return Task::ready(Err(anyhow!(
133 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
134 &input.path
135 )))
136 .into();
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 .into();
145 }
146
147 let file_path = input.path.clone();
148
149 if image_store::is_image_file(&project, &project_path, cx) {
150 if !model.supports_images() {
151 return Task::ready(Err(anyhow!(
152 "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
153 model.name().0
154 )))
155 .into();
156 }
157
158 let task = cx.spawn(async move |cx| -> Result<ToolResultOutput> {
159 let image_entity: Entity<ImageItem> = cx
160 .update(|cx| {
161 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(ToolResultOutput {
176 content: ToolResultContent::Image(language_model_image),
177 output: None,
178 })
179 });
180
181 return task.into();
182 }
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")
226 .collect::<String>()
227 .into()
228 } else {
229 Itertools::intersperse(lines, "\n")
230 .collect::<String>()
231 .into()
232 }
233 })?;
234
235 action_log.update(cx, |log, cx| {
236 log.buffer_read(buffer.clone(), cx);
237 })?;
238
239 if let Some(anchor) = anchor {
240 project.update(cx, |project, cx| {
241 project.set_agent_location(
242 Some(AgentLocation {
243 buffer: buffer.downgrade(),
244 position: anchor,
245 }),
246 cx,
247 );
248 })?;
249 }
250
251 Ok(result)
252 } else {
253 // No line ranges specified, so check file size to see if it's too big.
254 let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
255
256 if file_size <= outline::AUTO_OUTLINE_SIZE {
257 // File is small enough, so return its contents.
258 let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
259
260 action_log.update(cx, |log, cx| {
261 log.buffer_read(buffer, cx);
262 })?;
263
264 Ok(result.into())
265 } else {
266 // File is too big, so return the outline
267 // and a suggestion to read again with line numbers.
268 let outline =
269 outline::file_outline(project, file_path, action_log, None, cx).await?;
270 Ok(formatdoc! {"
271 This file was too big to read all at once.
272
273 Here is an outline of its symbols:
274
275 {outline}
276
277 Using the line numbers in this outline, you can call this tool again
278 while specifying the start_line and end_line fields to see the
279 implementations of symbols in the outline."
280 }
281 .into())
282 }
283 }
284 })
285 .into()
286 }
287}
288
289#[cfg(test)]
290mod test {
291 use super::*;
292 use gpui::{AppContext, TestAppContext, UpdateGlobal};
293 use language::{Language, LanguageConfig, LanguageMatcher};
294 use language_model::fake_provider::FakeLanguageModel;
295 use project::{FakeFs, Project, WorktreeSettings};
296 use serde_json::json;
297 use settings::SettingsStore;
298 use util::path;
299
300 #[gpui::test]
301 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
302 init_test(cx);
303
304 let fs = FakeFs::new(cx.executor());
305 fs.insert_tree(path!("/root"), json!({})).await;
306 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
307 let action_log = cx.new(|_| ActionLog::new(project.clone()));
308 let model = Arc::new(FakeLanguageModel::default());
309 let result = cx
310 .update(|cx| {
311 let input = json!({
312 "path": "root/nonexistent_file.txt"
313 });
314 Arc::new(ReadFileTool)
315 .run(
316 input,
317 Arc::default(),
318 project.clone(),
319 action_log,
320 model,
321 None,
322 cx,
323 )
324 .output
325 })
326 .await;
327 assert_eq!(
328 result.unwrap_err().to_string(),
329 "root/nonexistent_file.txt not found"
330 );
331 }
332
333 #[gpui::test]
334 async fn test_read_small_file(cx: &mut TestAppContext) {
335 init_test(cx);
336
337 let fs = FakeFs::new(cx.executor());
338 fs.insert_tree(
339 path!("/root"),
340 json!({
341 "small_file.txt": "This is a small file content"
342 }),
343 )
344 .await;
345 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
346 let action_log = cx.new(|_| ActionLog::new(project.clone()));
347 let model = Arc::new(FakeLanguageModel::default());
348 let result = cx
349 .update(|cx| {
350 let input = json!({
351 "path": "root/small_file.txt"
352 });
353 Arc::new(ReadFileTool)
354 .run(
355 input,
356 Arc::default(),
357 project.clone(),
358 action_log,
359 model,
360 None,
361 cx,
362 )
363 .output
364 })
365 .await;
366 assert_eq!(
367 result.unwrap().content.as_str(),
368 Some("This is a small file content")
369 );
370 }
371
372 #[gpui::test]
373 async fn test_read_large_file(cx: &mut TestAppContext) {
374 init_test(cx);
375
376 let fs = FakeFs::new(cx.executor());
377 fs.insert_tree(
378 path!("/root"),
379 json!({
380 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
381 }),
382 )
383 .await;
384 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
385 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
386 language_registry.add(Arc::new(rust_lang()));
387 let action_log = cx.new(|_| ActionLog::new(project.clone()));
388 let model = Arc::new(FakeLanguageModel::default());
389
390 let result = cx
391 .update(|cx| {
392 let input = json!({
393 "path": "root/large_file.rs"
394 });
395 Arc::new(ReadFileTool)
396 .run(
397 input,
398 Arc::default(),
399 project.clone(),
400 action_log.clone(),
401 model.clone(),
402 None,
403 cx,
404 )
405 .output
406 })
407 .await;
408 let content = result.unwrap();
409 let content = content.as_str().unwrap();
410 assert_eq!(
411 content.lines().skip(4).take(6).collect::<Vec<_>>(),
412 vec![
413 "struct Test0 [L1-4]",
414 " a [L2]",
415 " b [L3]",
416 "struct Test1 [L5-8]",
417 " a [L6]",
418 " b [L7]",
419 ]
420 );
421
422 let result = cx
423 .update(|cx| {
424 let input = json!({
425 "path": "root/large_file.rs",
426 "offset": 1
427 });
428 Arc::new(ReadFileTool)
429 .run(
430 input,
431 Arc::default(),
432 project.clone(),
433 action_log,
434 model,
435 None,
436 cx,
437 )
438 .output
439 })
440 .await;
441 let content = result.unwrap();
442 let expected_content = (0..1000)
443 .flat_map(|i| {
444 vec![
445 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
446 format!(" a [L{}]", i * 4 + 2),
447 format!(" b [L{}]", i * 4 + 3),
448 ]
449 })
450 .collect::<Vec<_>>();
451 pretty_assertions::assert_eq!(
452 content
453 .as_str()
454 .unwrap()
455 .lines()
456 .skip(4)
457 .take(expected_content.len())
458 .collect::<Vec<_>>(),
459 expected_content
460 );
461 }
462
463 #[gpui::test]
464 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
465 init_test(cx);
466
467 let fs = FakeFs::new(cx.executor());
468 fs.insert_tree(
469 path!("/root"),
470 json!({
471 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
472 }),
473 )
474 .await;
475 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
476 let action_log = cx.new(|_| ActionLog::new(project.clone()));
477 let model = Arc::new(FakeLanguageModel::default());
478 let result = cx
479 .update(|cx| {
480 let input = json!({
481 "path": "root/multiline.txt",
482 "start_line": 2,
483 "end_line": 4
484 });
485 Arc::new(ReadFileTool)
486 .run(
487 input,
488 Arc::default(),
489 project.clone(),
490 action_log,
491 model,
492 None,
493 cx,
494 )
495 .output
496 })
497 .await;
498 assert_eq!(
499 result.unwrap().content.as_str(),
500 Some("Line 2\nLine 3\nLine 4")
501 );
502 }
503
504 #[gpui::test]
505 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
506 init_test(cx);
507
508 let fs = FakeFs::new(cx.executor());
509 fs.insert_tree(
510 path!("/root"),
511 json!({
512 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
513 }),
514 )
515 .await;
516 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
517 let action_log = cx.new(|_| ActionLog::new(project.clone()));
518 let model = Arc::new(FakeLanguageModel::default());
519
520 // start_line of 0 should be treated as 1
521 let result = cx
522 .update(|cx| {
523 let input = json!({
524 "path": "root/multiline.txt",
525 "start_line": 0,
526 "end_line": 2
527 });
528 Arc::new(ReadFileTool)
529 .run(
530 input,
531 Arc::default(),
532 project.clone(),
533 action_log.clone(),
534 model.clone(),
535 None,
536 cx,
537 )
538 .output
539 })
540 .await;
541 assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
542
543 // end_line of 0 should result in at least 1 line
544 let result = cx
545 .update(|cx| {
546 let input = json!({
547 "path": "root/multiline.txt",
548 "start_line": 1,
549 "end_line": 0
550 });
551 Arc::new(ReadFileTool)
552 .run(
553 input,
554 Arc::default(),
555 project.clone(),
556 action_log.clone(),
557 model.clone(),
558 None,
559 cx,
560 )
561 .output
562 })
563 .await;
564 assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
565
566 // when start_line > end_line, should still return at least 1 line
567 let result = cx
568 .update(|cx| {
569 let input = json!({
570 "path": "root/multiline.txt",
571 "start_line": 3,
572 "end_line": 2
573 });
574 Arc::new(ReadFileTool)
575 .run(
576 input,
577 Arc::default(),
578 project.clone(),
579 action_log,
580 model,
581 None,
582 cx,
583 )
584 .output
585 })
586 .await;
587 assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
588 }
589
590 fn init_test(cx: &mut TestAppContext) {
591 cx.update(|cx| {
592 let settings_store = SettingsStore::test(cx);
593 cx.set_global(settings_store);
594 language::init(cx);
595 Project::init_settings(cx);
596 });
597 }
598
599 fn rust_lang() -> Language {
600 Language::new(
601 LanguageConfig {
602 name: "Rust".into(),
603 matcher: LanguageMatcher {
604 path_suffixes: vec!["rs".to_string()],
605 ..Default::default()
606 },
607 ..Default::default()
608 },
609 Some(tree_sitter_rust::LANGUAGE.into()),
610 )
611 .with_outline_query(
612 r#"
613 (line_comment) @annotation
614
615 (struct_item
616 "struct" @context
617 name: (_) @name) @item
618 (enum_item
619 "enum" @context
620 name: (_) @name) @item
621 (enum_variant
622 name: (_) @name) @item
623 (field_declaration
624 name: (_) @name) @item
625 (impl_item
626 "impl" @context
627 trait: (_)? @name
628 "for"? @context
629 type: (_) @name
630 body: (_ "{" (_)* "}")) @item
631 (function_item
632 "fn" @context
633 name: (_) @name) @item
634 (mod_item
635 "mod" @context
636 name: (_) @name) @item
637 "#,
638 )
639 .unwrap()
640 }
641
642 #[gpui::test]
643 async fn test_read_file_security(cx: &mut TestAppContext) {
644 init_test(cx);
645
646 let fs = FakeFs::new(cx.executor());
647
648 fs.insert_tree(
649 path!("/"),
650 json!({
651 "project_root": {
652 "allowed_file.txt": "This file is in the project",
653 ".mysecrets": "SECRET_KEY=abc123",
654 ".secretdir": {
655 "config": "special configuration"
656 },
657 ".mymetadata": "custom metadata",
658 "subdir": {
659 "normal_file.txt": "Normal file content",
660 "special.privatekey": "private key content",
661 "data.mysensitive": "sensitive data"
662 }
663 },
664 "outside_project": {
665 "sensitive_file.txt": "This file is outside the project"
666 }
667 }),
668 )
669 .await;
670
671 cx.update(|cx| {
672 use gpui::UpdateGlobal;
673 use project::WorktreeSettings;
674 use settings::SettingsStore;
675 SettingsStore::update_global(cx, |store, cx| {
676 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
677 settings.file_scan_exclusions = Some(vec![
678 "**/.secretdir".to_string(),
679 "**/.mymetadata".to_string(),
680 ]);
681 settings.private_files = Some(vec![
682 "**/.mysecrets".to_string(),
683 "**/*.privatekey".to_string(),
684 "**/*.mysensitive".to_string(),
685 ]);
686 });
687 });
688 });
689
690 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
691 let action_log = cx.new(|_| ActionLog::new(project.clone()));
692 let model = Arc::new(FakeLanguageModel::default());
693
694 // Reading a file outside the project worktree should fail
695 let result = cx
696 .update(|cx| {
697 let input = json!({
698 "path": "/outside_project/sensitive_file.txt"
699 });
700 Arc::new(ReadFileTool)
701 .run(
702 input,
703 Arc::default(),
704 project.clone(),
705 action_log.clone(),
706 model.clone(),
707 None,
708 cx,
709 )
710 .output
711 })
712 .await;
713 assert!(
714 result.is_err(),
715 "read_file_tool should error when attempting to read an absolute path outside a worktree"
716 );
717
718 // Reading a file within the project should succeed
719 let result = cx
720 .update(|cx| {
721 let input = json!({
722 "path": "project_root/allowed_file.txt"
723 });
724 Arc::new(ReadFileTool)
725 .run(
726 input,
727 Arc::default(),
728 project.clone(),
729 action_log.clone(),
730 model.clone(),
731 None,
732 cx,
733 )
734 .output
735 })
736 .await;
737 assert!(
738 result.is_ok(),
739 "read_file_tool should be able to read files inside worktrees"
740 );
741
742 // Reading files that match file_scan_exclusions should fail
743 let result = cx
744 .update(|cx| {
745 let input = json!({
746 "path": "project_root/.secretdir/config"
747 });
748 Arc::new(ReadFileTool)
749 .run(
750 input,
751 Arc::default(),
752 project.clone(),
753 action_log.clone(),
754 model.clone(),
755 None,
756 cx,
757 )
758 .output
759 })
760 .await;
761 assert!(
762 result.is_err(),
763 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
764 );
765
766 let result = cx
767 .update(|cx| {
768 let input = json!({
769 "path": "project_root/.mymetadata"
770 });
771 Arc::new(ReadFileTool)
772 .run(
773 input,
774 Arc::default(),
775 project.clone(),
776 action_log.clone(),
777 model.clone(),
778 None,
779 cx,
780 )
781 .output
782 })
783 .await;
784 assert!(
785 result.is_err(),
786 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
787 );
788
789 // Reading private files should fail
790 let result = cx
791 .update(|cx| {
792 let input = json!({
793 "path": "project_root/.mysecrets"
794 });
795 Arc::new(ReadFileTool)
796 .run(
797 input,
798 Arc::default(),
799 project.clone(),
800 action_log.clone(),
801 model.clone(),
802 None,
803 cx,
804 )
805 .output
806 })
807 .await;
808 assert!(
809 result.is_err(),
810 "read_file_tool should error when attempting to read .mysecrets (private_files)"
811 );
812
813 let result = cx
814 .update(|cx| {
815 let input = json!({
816 "path": "project_root/subdir/special.privatekey"
817 });
818 Arc::new(ReadFileTool)
819 .run(
820 input,
821 Arc::default(),
822 project.clone(),
823 action_log.clone(),
824 model.clone(),
825 None,
826 cx,
827 )
828 .output
829 })
830 .await;
831 assert!(
832 result.is_err(),
833 "read_file_tool should error when attempting to read .privatekey files (private_files)"
834 );
835
836 let result = cx
837 .update(|cx| {
838 let input = json!({
839 "path": "project_root/subdir/data.mysensitive"
840 });
841 Arc::new(ReadFileTool)
842 .run(
843 input,
844 Arc::default(),
845 project.clone(),
846 action_log.clone(),
847 model.clone(),
848 None,
849 cx,
850 )
851 .output
852 })
853 .await;
854 assert!(
855 result.is_err(),
856 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
857 );
858
859 // Reading a normal file should still work, even with private_files configured
860 let result = cx
861 .update(|cx| {
862 let input = json!({
863 "path": "project_root/subdir/normal_file.txt"
864 });
865 Arc::new(ReadFileTool)
866 .run(
867 input,
868 Arc::default(),
869 project.clone(),
870 action_log.clone(),
871 model.clone(),
872 None,
873 cx,
874 )
875 .output
876 })
877 .await;
878 assert!(result.is_ok(), "Should be able to read normal files");
879 assert_eq!(
880 result.unwrap().content.as_str().unwrap(),
881 "Normal file content"
882 );
883
884 // Path traversal attempts with .. should fail
885 let result = cx
886 .update(|cx| {
887 let input = json!({
888 "path": "project_root/../outside_project/sensitive_file.txt"
889 });
890 Arc::new(ReadFileTool)
891 .run(
892 input,
893 Arc::default(),
894 project.clone(),
895 action_log.clone(),
896 model.clone(),
897 None,
898 cx,
899 )
900 .output
901 })
902 .await;
903 assert!(
904 result.is_err(),
905 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
906 );
907 }
908
909 #[gpui::test]
910 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
911 init_test(cx);
912
913 let fs = FakeFs::new(cx.executor());
914
915 // Create first worktree with its own private_files setting
916 fs.insert_tree(
917 path!("/worktree1"),
918 json!({
919 "src": {
920 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
921 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
922 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
923 },
924 "tests": {
925 "test.rs": "mod tests { fn test_it() {} }",
926 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
927 },
928 ".zed": {
929 "settings.json": r#"{
930 "file_scan_exclusions": ["**/fixture.*"],
931 "private_files": ["**/secret.rs", "**/config.toml"]
932 }"#
933 }
934 }),
935 )
936 .await;
937
938 // Create second worktree with different private_files setting
939 fs.insert_tree(
940 path!("/worktree2"),
941 json!({
942 "lib": {
943 "public.js": "export function greet() { return 'Hello from worktree2'; }",
944 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
945 "data.json": "{\"api_key\": \"json_secret_key\"}"
946 },
947 "docs": {
948 "README.md": "# Public Documentation",
949 "internal.md": "# Internal Secrets and Configuration"
950 },
951 ".zed": {
952 "settings.json": r#"{
953 "file_scan_exclusions": ["**/internal.*"],
954 "private_files": ["**/private.js", "**/data.json"]
955 }"#
956 }
957 }),
958 )
959 .await;
960
961 // Set global settings
962 cx.update(|cx| {
963 SettingsStore::update_global(cx, |store, cx| {
964 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
965 settings.file_scan_exclusions =
966 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
967 settings.private_files = Some(vec!["**/.env".to_string()]);
968 });
969 });
970 });
971
972 let project = Project::test(
973 fs.clone(),
974 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
975 cx,
976 )
977 .await;
978
979 let action_log = cx.new(|_| ActionLog::new(project.clone()));
980 let model = Arc::new(FakeLanguageModel::default());
981 let tool = Arc::new(ReadFileTool);
982
983 // Test reading allowed files in worktree1
984 let input = json!({
985 "path": "worktree1/src/main.rs"
986 });
987
988 let result = cx
989 .update(|cx| {
990 tool.clone().run(
991 input,
992 Arc::default(),
993 project.clone(),
994 action_log.clone(),
995 model.clone(),
996 None,
997 cx,
998 )
999 })
1000 .output
1001 .await
1002 .unwrap();
1003
1004 assert_eq!(
1005 result.content.as_str().unwrap(),
1006 "fn main() { println!(\"Hello from worktree1\"); }"
1007 );
1008
1009 // Test reading private file in worktree1 should fail
1010 let input = json!({
1011 "path": "worktree1/src/secret.rs"
1012 });
1013
1014 let result = cx
1015 .update(|cx| {
1016 tool.clone().run(
1017 input,
1018 Arc::default(),
1019 project.clone(),
1020 action_log.clone(),
1021 model.clone(),
1022 None,
1023 cx,
1024 )
1025 })
1026 .output
1027 .await;
1028
1029 assert!(result.is_err());
1030 assert!(
1031 result
1032 .unwrap_err()
1033 .to_string()
1034 .contains("worktree `private_files` setting"),
1035 "Error should mention worktree private_files setting"
1036 );
1037
1038 // Test reading excluded file in worktree1 should fail
1039 let input = json!({
1040 "path": "worktree1/tests/fixture.sql"
1041 });
1042
1043 let result = cx
1044 .update(|cx| {
1045 tool.clone().run(
1046 input,
1047 Arc::default(),
1048 project.clone(),
1049 action_log.clone(),
1050 model.clone(),
1051 None,
1052 cx,
1053 )
1054 })
1055 .output
1056 .await;
1057
1058 assert!(result.is_err());
1059 assert!(
1060 result
1061 .unwrap_err()
1062 .to_string()
1063 .contains("worktree `file_scan_exclusions` setting"),
1064 "Error should mention worktree file_scan_exclusions setting"
1065 );
1066
1067 // Test reading allowed files in worktree2
1068 let input = json!({
1069 "path": "worktree2/lib/public.js"
1070 });
1071
1072 let result = cx
1073 .update(|cx| {
1074 tool.clone().run(
1075 input,
1076 Arc::default(),
1077 project.clone(),
1078 action_log.clone(),
1079 model.clone(),
1080 None,
1081 cx,
1082 )
1083 })
1084 .output
1085 .await
1086 .unwrap();
1087
1088 assert_eq!(
1089 result.content.as_str().unwrap(),
1090 "export function greet() { return 'Hello from worktree2'; }"
1091 );
1092
1093 // Test reading private file in worktree2 should fail
1094 let input = json!({
1095 "path": "worktree2/lib/private.js"
1096 });
1097
1098 let result = cx
1099 .update(|cx| {
1100 tool.clone().run(
1101 input,
1102 Arc::default(),
1103 project.clone(),
1104 action_log.clone(),
1105 model.clone(),
1106 None,
1107 cx,
1108 )
1109 })
1110 .output
1111 .await;
1112
1113 assert!(result.is_err());
1114 assert!(
1115 result
1116 .unwrap_err()
1117 .to_string()
1118 .contains("worktree `private_files` setting"),
1119 "Error should mention worktree private_files setting"
1120 );
1121
1122 // Test reading excluded file in worktree2 should fail
1123 let input = json!({
1124 "path": "worktree2/docs/internal.md"
1125 });
1126
1127 let result = cx
1128 .update(|cx| {
1129 tool.clone().run(
1130 input,
1131 Arc::default(),
1132 project.clone(),
1133 action_log.clone(),
1134 model.clone(),
1135 None,
1136 cx,
1137 )
1138 })
1139 .output
1140 .await;
1141
1142 assert!(result.is_err());
1143 assert!(
1144 result
1145 .unwrap_err()
1146 .to_string()
1147 .contains("worktree `file_scan_exclusions` setting"),
1148 "Error should mention worktree file_scan_exclusions setting"
1149 );
1150
1151 // Test that files allowed in one worktree but not in another are handled correctly
1152 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1153 let input = json!({
1154 "path": "worktree1/src/config.toml"
1155 });
1156
1157 let result = cx
1158 .update(|cx| {
1159 tool.clone().run(
1160 input,
1161 Arc::default(),
1162 project.clone(),
1163 action_log.clone(),
1164 model.clone(),
1165 None,
1166 cx,
1167 )
1168 })
1169 .output
1170 .await;
1171
1172 assert!(result.is_err());
1173 assert!(
1174 result
1175 .unwrap_err()
1176 .to_string()
1177 .contains("worktree `private_files` setting"),
1178 "Config.toml should be blocked by worktree1's private_files setting"
1179 );
1180 }
1181}