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