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 type Input = ReadFileToolInput;
55
56 fn name(&self) -> String {
57 "read_file".into()
58 }
59
60 fn needs_confirmation(&self, _: &Self::Input, _: &App) -> bool {
61 false
62 }
63
64 fn may_perform_edits(&self) -> bool {
65 false
66 }
67
68 fn description(&self) -> String {
69 include_str!("./read_file_tool/description.md").into()
70 }
71
72 fn icon(&self) -> IconName {
73 IconName::FileSearch
74 }
75
76 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
77 json_schema_for::<ReadFileToolInput>(format)
78 }
79
80 fn ui_text(&self, input: &Self::Input) -> String {
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
89 fn run(
90 self: Arc<Self>,
91 input: Self::Input,
92 _request: Arc<LanguageModelRequest>,
93 project: Entity<Project>,
94 action_log: Entity<ActionLog>,
95 model: Arc<dyn LanguageModel>,
96 _window: Option<AnyWindowHandle>,
97 cx: &mut App,
98 ) -> ToolResult {
99 let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
100 return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
101 };
102
103 // Error out if this path is either excluded or private in global settings
104 let global_settings = WorktreeSettings::get_global(cx);
105 if global_settings.is_path_excluded(&project_path.path) {
106 return Task::ready(Err(anyhow!(
107 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
108 &input.path
109 )))
110 .into();
111 }
112
113 if global_settings.is_path_private(&project_path.path) {
114 return Task::ready(Err(anyhow!(
115 "Cannot read file because its path matches the global `private_files` setting: {}",
116 &input.path
117 )))
118 .into();
119 }
120
121 // Error out if this path is either excluded or private in worktree settings
122 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
123 if worktree_settings.is_path_excluded(&project_path.path) {
124 return Task::ready(Err(anyhow!(
125 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
126 &input.path
127 )))
128 .into();
129 }
130
131 if worktree_settings.is_path_private(&project_path.path) {
132 return Task::ready(Err(anyhow!(
133 "Cannot read file because its path matches the worktree `private_files` setting: {}",
134 &input.path
135 )))
136 .into();
137 }
138
139 let file_path = input.path.clone();
140
141 if image_store::is_image_file(&project, &project_path, cx) {
142 if !model.supports_images() {
143 return Task::ready(Err(anyhow!(
144 "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
145 model.name().0
146 )))
147 .into();
148 }
149
150 let task = cx.spawn(async move |cx| -> Result<ToolResultOutput> {
151 let image_entity: Entity<ImageItem> = cx
152 .update(|cx| {
153 project.update(cx, |project, cx| {
154 project.open_image(project_path.clone(), cx)
155 })
156 })?
157 .await?;
158
159 let image =
160 image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
161
162 let language_model_image = cx
163 .update(|cx| LanguageModelImage::from_image(image, cx))?
164 .await
165 .context("processing image")?;
166
167 Ok(ToolResultOutput {
168 content: ToolResultContent::Image(language_model_image),
169 output: None,
170 })
171 });
172
173 return task.into();
174 }
175
176 cx.spawn(async move |cx| {
177 let buffer = cx
178 .update(|cx| {
179 project.update(cx, |project, cx| project.open_buffer(project_path, cx))
180 })?
181 .await?;
182 if buffer.read_with(cx, |buffer, _| {
183 buffer
184 .file()
185 .as_ref()
186 .map_or(true, |file| !file.disk_state().exists())
187 })? {
188 anyhow::bail!("{file_path} not found");
189 }
190
191 project.update(cx, |project, cx| {
192 project.set_agent_location(
193 Some(AgentLocation {
194 buffer: buffer.downgrade(),
195 position: Anchor::MIN,
196 }),
197 cx,
198 );
199 })?;
200
201 // Check if specific line ranges are provided
202 if input.start_line.is_some() || input.end_line.is_some() {
203 let mut anchor = None;
204 let result = buffer.read_with(cx, |buffer, _cx| {
205 let text = buffer.text();
206 // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
207 let start = input.start_line.unwrap_or(1).max(1);
208 let start_row = start - 1;
209 if start_row <= buffer.max_point().row {
210 let column = buffer.line_indent_for_row(start_row).raw_len();
211 anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
212 }
213
214 let lines = text.split('\n').skip(start_row as usize);
215 if let Some(end) = input.end_line {
216 let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
217 Itertools::intersperse(lines.take(count as usize), "\n")
218 .collect::<String>()
219 .into()
220 } else {
221 Itertools::intersperse(lines, "\n")
222 .collect::<String>()
223 .into()
224 }
225 })?;
226
227 action_log.update(cx, |log, cx| {
228 log.buffer_read(buffer.clone(), cx);
229 })?;
230
231 if let Some(anchor) = anchor {
232 project.update(cx, |project, cx| {
233 project.set_agent_location(
234 Some(AgentLocation {
235 buffer: buffer.downgrade(),
236 position: anchor,
237 }),
238 cx,
239 );
240 })?;
241 }
242
243 Ok(result)
244 } else {
245 // No line ranges specified, so check file size to see if it's too big.
246 let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
247
248 if file_size <= outline::AUTO_OUTLINE_SIZE {
249 // File is small enough, so return its contents.
250 let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
251
252 action_log.update(cx, |log, cx| {
253 log.buffer_read(buffer, cx);
254 })?;
255
256 Ok(result.into())
257 } else {
258 // File is too big, so return the outline
259 // and a suggestion to read again with line numbers.
260 let outline =
261 outline::file_outline(project, file_path, action_log, None, cx).await?;
262 Ok(formatdoc! {"
263 This file was too big to read all at once.
264
265 Here is an outline of its symbols:
266
267 {outline}
268
269 Using the line numbers in this outline, you can call this tool again
270 while specifying the start_line and end_line fields to see the
271 implementations of symbols in the outline."
272 }
273 .into())
274 }
275 }
276 })
277 .into()
278 }
279}
280
281#[cfg(test)]
282mod test {
283 use super::*;
284 use gpui::{AppContext, TestAppContext, UpdateGlobal};
285 use language::{Language, LanguageConfig, LanguageMatcher};
286 use language_model::fake_provider::FakeLanguageModel;
287 use project::{FakeFs, Project, WorktreeSettings};
288 use serde_json::json;
289 use settings::SettingsStore;
290 use util::path;
291
292 #[gpui::test]
293 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
294 init_test(cx);
295
296 let fs = FakeFs::new(cx.executor());
297 fs.insert_tree(path!("/root"), json!({})).await;
298 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
299 let action_log = cx.new(|_| ActionLog::new(project.clone()));
300 let model = Arc::new(FakeLanguageModel::default());
301 let result = cx
302 .update(|cx| {
303 let input = ReadFileToolInput {
304 path: "root/nonexistent_file.txt".to_string(),
305 start_line: None,
306 end_line: None,
307 };
308
309 Arc::new(ReadFileTool)
310 .run(
311 input,
312 Arc::default(),
313 project.clone(),
314 action_log,
315 model,
316 None,
317 cx,
318 )
319 .output
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 model = Arc::new(FakeLanguageModel::default());
343 let result = cx
344 .update(|cx| {
345 let input = ReadFileToolInput {
346 path: "root/small_file.txt".to_string(),
347 start_line: None,
348 end_line: None,
349 };
350 Arc::new(ReadFileTool)
351 .run(
352 input,
353 Arc::default(),
354 project.clone(),
355 action_log,
356 model,
357 None,
358 cx,
359 )
360 .output
361 })
362 .await;
363 assert_eq!(
364 result.unwrap().content.as_str(),
365 Some("This is a small file content")
366 );
367 }
368
369 #[gpui::test]
370 async fn test_read_large_file(cx: &mut TestAppContext) {
371 init_test(cx);
372
373 let fs = FakeFs::new(cx.executor());
374 fs.insert_tree(
375 path!("/root"),
376 json!({
377 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
378 }),
379 )
380 .await;
381 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
382 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
383 language_registry.add(Arc::new(rust_lang()));
384 let action_log = cx.new(|_| ActionLog::new(project.clone()));
385 let model = Arc::new(FakeLanguageModel::default());
386
387 let result = cx
388 .update(|cx| {
389 let input = ReadFileToolInput {
390 path: "root/large_file.rs".to_string(),
391 start_line: None,
392 end_line: None,
393 };
394 Arc::new(ReadFileTool)
395 .run(
396 input,
397 Arc::default(),
398 project.clone(),
399 action_log.clone(),
400 model.clone(),
401 None,
402 cx,
403 )
404 .output
405 })
406 .await;
407 let content = result.unwrap();
408 let content = content.as_str().unwrap();
409 assert_eq!(
410 content.lines().skip(4).take(6).collect::<Vec<_>>(),
411 vec![
412 "struct Test0 [L1-4]",
413 " a [L2]",
414 " b [L3]",
415 "struct Test1 [L5-8]",
416 " a [L6]",
417 " b [L7]",
418 ]
419 );
420
421 let result = cx
422 .update(|cx| {
423 let input = ReadFileToolInput {
424 path: "root/large_file.rs".to_string(),
425 start_line: None,
426 end_line: None,
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 = ReadFileToolInput {
481 path: "root/multiline.txt".to_string(),
482 start_line: Some(2),
483 end_line: Some(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 = ReadFileToolInput {
524 path: "root/multiline.txt".to_string(),
525 start_line: Some(0),
526 end_line: Some(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 = ReadFileToolInput {
547 path: "root/multiline.txt".to_string(),
548 start_line: Some(1),
549 end_line: Some(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 = ReadFileToolInput {
570 path: "root/multiline.txt".to_string(),
571 start_line: Some(3),
572 end_line: Some(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 = ReadFileToolInput {
698 path: "/outside_project/sensitive_file.txt".to_string(),
699 start_line: None,
700 end_line: None,
701 };
702 Arc::new(ReadFileTool)
703 .run(
704 input,
705 Arc::default(),
706 project.clone(),
707 action_log.clone(),
708 model.clone(),
709 None,
710 cx,
711 )
712 .output
713 })
714 .await;
715 assert!(
716 result.is_err(),
717 "read_file_tool should error when attempting to read an absolute path outside a worktree"
718 );
719
720 // Reading a file within the project should succeed
721 let result = cx
722 .update(|cx| {
723 let input = ReadFileToolInput {
724 path: "project_root/allowed_file.txt".to_string(),
725 start_line: None,
726 end_line: None,
727 };
728 Arc::new(ReadFileTool)
729 .run(
730 input,
731 Arc::default(),
732 project.clone(),
733 action_log.clone(),
734 model.clone(),
735 None,
736 cx,
737 )
738 .output
739 })
740 .await;
741 assert!(
742 result.is_ok(),
743 "read_file_tool should be able to read files inside worktrees"
744 );
745
746 // Reading files that match file_scan_exclusions should fail
747 let result = cx
748 .update(|cx| {
749 let input = ReadFileToolInput {
750 path: "project_root/.secretdir/config".to_string(),
751 start_line: None,
752 end_line: None,
753 };
754 Arc::new(ReadFileTool)
755 .run(
756 input,
757 Arc::default(),
758 project.clone(),
759 action_log.clone(),
760 model.clone(),
761 None,
762 cx,
763 )
764 .output
765 })
766 .await;
767 assert!(
768 result.is_err(),
769 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
770 );
771
772 let result = cx
773 .update(|cx| {
774 let input = ReadFileToolInput {
775 path: "project_root/.mymetadata".to_string(),
776 start_line: None,
777 end_line: None,
778 };
779 Arc::new(ReadFileTool)
780 .run(
781 input,
782 Arc::default(),
783 project.clone(),
784 action_log.clone(),
785 model.clone(),
786 None,
787 cx,
788 )
789 .output
790 })
791 .await;
792 assert!(
793 result.is_err(),
794 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
795 );
796
797 // Reading private files should fail
798 let result = cx
799 .update(|cx| {
800 let input = ReadFileToolInput {
801 path: "project_root/secrets/.mysecrets".to_string(),
802 start_line: None,
803 end_line: None,
804 };
805 Arc::new(ReadFileTool)
806 .run(
807 input,
808 Arc::default(),
809 project.clone(),
810 action_log.clone(),
811 model.clone(),
812 None,
813 cx,
814 )
815 .output
816 })
817 .await;
818 assert!(
819 result.is_err(),
820 "read_file_tool should error when attempting to read .mysecrets (private_files)"
821 );
822
823 let result = cx
824 .update(|cx| {
825 let input = ReadFileToolInput {
826 path: "project_root/subdir/special.privatekey".to_string(),
827 start_line: None,
828 end_line: None,
829 };
830 Arc::new(ReadFileTool)
831 .run(
832 input,
833 Arc::default(),
834 project.clone(),
835 action_log.clone(),
836 model.clone(),
837 None,
838 cx,
839 )
840 .output
841 })
842 .await;
843 assert!(
844 result.is_err(),
845 "read_file_tool should error when attempting to read .privatekey files (private_files)"
846 );
847
848 let result = cx
849 .update(|cx| {
850 let input = ReadFileToolInput {
851 path: "project_root/subdir/data.mysensitive".to_string(),
852 start_line: None,
853 end_line: None,
854 };
855 Arc::new(ReadFileTool)
856 .run(
857 input,
858 Arc::default(),
859 project.clone(),
860 action_log.clone(),
861 model.clone(),
862 None,
863 cx,
864 )
865 .output
866 })
867 .await;
868 assert!(
869 result.is_err(),
870 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
871 );
872
873 // Reading a normal file should still work, even with private_files configured
874 let result = cx
875 .update(|cx| {
876 let input = ReadFileToolInput {
877 path: "project_root/subdir/normal_file.txt".to_string(),
878 start_line: None,
879 end_line: None,
880 };
881 Arc::new(ReadFileTool)
882 .run(
883 input,
884 Arc::default(),
885 project.clone(),
886 action_log.clone(),
887 model.clone(),
888 None,
889 cx,
890 )
891 .output
892 })
893 .await;
894 assert!(result.is_ok(), "Should be able to read normal files");
895 assert_eq!(
896 result.unwrap().content.as_str().unwrap(),
897 "Normal file content"
898 );
899
900 // Path traversal attempts with .. should fail
901 let result = cx
902 .update(|cx| {
903 let input = ReadFileToolInput {
904 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
905 start_line: None,
906 end_line: None,
907 };
908 Arc::new(ReadFileTool)
909 .run(
910 input,
911 Arc::default(),
912 project.clone(),
913 action_log.clone(),
914 model.clone(),
915 None,
916 cx,
917 )
918 .output
919 })
920 .await;
921 assert!(
922 result.is_err(),
923 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
924 );
925 }
926
927 #[gpui::test]
928 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
929 init_test(cx);
930
931 let fs = FakeFs::new(cx.executor());
932
933 // Create first worktree with its own private_files setting
934 fs.insert_tree(
935 path!("/worktree1"),
936 json!({
937 "src": {
938 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
939 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
940 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
941 },
942 "tests": {
943 "test.rs": "mod tests { fn test_it() {} }",
944 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
945 },
946 ".zed": {
947 "settings.json": r#"{
948 "file_scan_exclusions": ["**/fixture.*"],
949 "private_files": ["**/secret.rs", "**/config.toml"]
950 }"#
951 }
952 }),
953 )
954 .await;
955
956 // Create second worktree with different private_files setting
957 fs.insert_tree(
958 path!("/worktree2"),
959 json!({
960 "lib": {
961 "public.js": "export function greet() { return 'Hello from worktree2'; }",
962 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
963 "data.json": "{\"api_key\": \"json_secret_key\"}"
964 },
965 "docs": {
966 "README.md": "# Public Documentation",
967 "internal.md": "# Internal Secrets and Configuration"
968 },
969 ".zed": {
970 "settings.json": r#"{
971 "file_scan_exclusions": ["**/internal.*"],
972 "private_files": ["**/private.js", "**/data.json"]
973 }"#
974 }
975 }),
976 )
977 .await;
978
979 // Set global settings
980 cx.update(|cx| {
981 SettingsStore::update_global(cx, |store, cx| {
982 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
983 settings.file_scan_exclusions =
984 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
985 settings.private_files = Some(vec!["**/.env".to_string()]);
986 });
987 });
988 });
989
990 let project = Project::test(
991 fs.clone(),
992 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
993 cx,
994 )
995 .await;
996
997 let action_log = cx.new(|_| ActionLog::new(project.clone()));
998 let model = Arc::new(FakeLanguageModel::default());
999 let tool = Arc::new(ReadFileTool);
1000
1001 // Test reading allowed files in worktree1
1002 let input = ReadFileToolInput {
1003 path: "worktree1/src/main.rs".to_string(),
1004 start_line: None,
1005 end_line: None,
1006 };
1007
1008 let result = cx
1009 .update(|cx| {
1010 tool.clone().run(
1011 input,
1012 Arc::default(),
1013 project.clone(),
1014 action_log.clone(),
1015 model.clone(),
1016 None,
1017 cx,
1018 )
1019 })
1020 .output
1021 .await
1022 .unwrap();
1023
1024 assert_eq!(
1025 result.content.as_str().unwrap(),
1026 "fn main() { println!(\"Hello from worktree1\"); }"
1027 );
1028
1029 // Test reading private file in worktree1 should fail
1030 let input = ReadFileToolInput {
1031 path: "worktree1/src/secret.rs".to_string(),
1032 start_line: None,
1033 end_line: None,
1034 };
1035
1036 let result = cx
1037 .update(|cx| {
1038 tool.clone().run(
1039 input,
1040 Arc::default(),
1041 project.clone(),
1042 action_log.clone(),
1043 model.clone(),
1044 None,
1045 cx,
1046 )
1047 })
1048 .output
1049 .await;
1050
1051 assert!(result.is_err());
1052 assert!(
1053 result
1054 .unwrap_err()
1055 .to_string()
1056 .contains("worktree `private_files` setting"),
1057 "Error should mention worktree private_files setting"
1058 );
1059
1060 // Test reading excluded file in worktree1 should fail
1061 let input = ReadFileToolInput {
1062 path: "worktree1/tests/fixture.sql".to_string(),
1063 start_line: None,
1064 end_line: None,
1065 };
1066
1067 let result = cx
1068 .update(|cx| {
1069 tool.clone().run(
1070 input,
1071 Arc::default(),
1072 project.clone(),
1073 action_log.clone(),
1074 model.clone(),
1075 None,
1076 cx,
1077 )
1078 })
1079 .output
1080 .await;
1081
1082 assert!(result.is_err());
1083 assert!(
1084 result
1085 .unwrap_err()
1086 .to_string()
1087 .contains("worktree `file_scan_exclusions` setting"),
1088 "Error should mention worktree file_scan_exclusions setting"
1089 );
1090
1091 // Test reading allowed files in worktree2
1092 let input = ReadFileToolInput {
1093 path: "worktree2/lib/public.js".to_string(),
1094 start_line: None,
1095 end_line: None,
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 .unwrap();
1113
1114 assert_eq!(
1115 result.content.as_str().unwrap(),
1116 "export function greet() { return 'Hello from worktree2'; }"
1117 );
1118
1119 // Test reading private file in worktree2 should fail
1120 let input = ReadFileToolInput {
1121 path: "worktree2/lib/private.js".to_string(),
1122 start_line: None,
1123 end_line: None,
1124 };
1125
1126 let result = cx
1127 .update(|cx| {
1128 tool.clone().run(
1129 input,
1130 Arc::default(),
1131 project.clone(),
1132 action_log.clone(),
1133 model.clone(),
1134 None,
1135 cx,
1136 )
1137 })
1138 .output
1139 .await;
1140
1141 assert!(result.is_err());
1142 assert!(
1143 result
1144 .unwrap_err()
1145 .to_string()
1146 .contains("worktree `private_files` setting"),
1147 "Error should mention worktree private_files setting"
1148 );
1149
1150 // Test reading excluded file in worktree2 should fail
1151 let input = ReadFileToolInput {
1152 path: "worktree2/docs/internal.md".to_string(),
1153 start_line: None,
1154 end_line: None,
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 `file_scan_exclusions` setting"),
1178 "Error should mention worktree file_scan_exclusions setting"
1179 );
1180
1181 // Test that files allowed in one worktree but not in another are handled correctly
1182 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1183 let input = ReadFileToolInput {
1184 path: "worktree1/src/config.toml".to_string(),
1185 start_line: None,
1186 end_line: None,
1187 };
1188
1189 let result = cx
1190 .update(|cx| {
1191 tool.clone().run(
1192 input,
1193 Arc::default(),
1194 project.clone(),
1195 action_log.clone(),
1196 model.clone(),
1197 None,
1198 cx,
1199 )
1200 })
1201 .output
1202 .await;
1203
1204 assert!(result.is_err());
1205 assert!(
1206 result
1207 .unwrap_err()
1208 .to_string()
1209 .contains("worktree `private_files` setting"),
1210 "Config.toml should be blocked by worktree1's private_files setting"
1211 );
1212 }
1213}