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, WorktreeSettings};
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 project::WorktreeSettings;
681 use settings::SettingsStore;
682 SettingsStore::update_global(cx, |store, cx| {
683 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
684 settings.file_scan_exclusions = Some(vec![
685 "**/.secretdir".to_string(),
686 "**/.mymetadata".to_string(),
687 ]);
688 settings.private_files = Some(vec![
689 "**/.mysecrets".to_string(),
690 "**/*.privatekey".to_string(),
691 "**/*.mysensitive".to_string(),
692 ]);
693 });
694 });
695 });
696
697 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
698 let action_log = cx.new(|_| ActionLog::new(project.clone()));
699 let model = Arc::new(FakeLanguageModel::default());
700
701 // Reading a file outside the project worktree should fail
702 let result = cx
703 .update(|cx| {
704 let input = json!({
705 "path": "/outside_project/sensitive_file.txt"
706 });
707 Arc::new(ReadFileTool)
708 .run(
709 input,
710 Arc::default(),
711 project.clone(),
712 action_log.clone(),
713 model.clone(),
714 None,
715 cx,
716 )
717 .output
718 })
719 .await;
720 assert!(
721 result.is_err(),
722 "read_file_tool should error when attempting to read an absolute path outside a worktree"
723 );
724
725 // Reading a file within the project should succeed
726 let result = cx
727 .update(|cx| {
728 let input = json!({
729 "path": "project_root/allowed_file.txt"
730 });
731 Arc::new(ReadFileTool)
732 .run(
733 input,
734 Arc::default(),
735 project.clone(),
736 action_log.clone(),
737 model.clone(),
738 None,
739 cx,
740 )
741 .output
742 })
743 .await;
744 assert!(
745 result.is_ok(),
746 "read_file_tool should be able to read files inside worktrees"
747 );
748
749 // Reading files that match file_scan_exclusions should fail
750 let result = cx
751 .update(|cx| {
752 let input = json!({
753 "path": "project_root/.secretdir/config"
754 });
755 Arc::new(ReadFileTool)
756 .run(
757 input,
758 Arc::default(),
759 project.clone(),
760 action_log.clone(),
761 model.clone(),
762 None,
763 cx,
764 )
765 .output
766 })
767 .await;
768 assert!(
769 result.is_err(),
770 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
771 );
772
773 let result = cx
774 .update(|cx| {
775 let input = json!({
776 "path": "project_root/.mymetadata"
777 });
778 Arc::new(ReadFileTool)
779 .run(
780 input,
781 Arc::default(),
782 project.clone(),
783 action_log.clone(),
784 model.clone(),
785 None,
786 cx,
787 )
788 .output
789 })
790 .await;
791 assert!(
792 result.is_err(),
793 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
794 );
795
796 // Reading private files should fail
797 let result = cx
798 .update(|cx| {
799 let input = json!({
800 "path": "project_root/.mysecrets"
801 });
802 Arc::new(ReadFileTool)
803 .run(
804 input,
805 Arc::default(),
806 project.clone(),
807 action_log.clone(),
808 model.clone(),
809 None,
810 cx,
811 )
812 .output
813 })
814 .await;
815 assert!(
816 result.is_err(),
817 "read_file_tool should error when attempting to read .mysecrets (private_files)"
818 );
819
820 let result = cx
821 .update(|cx| {
822 let input = json!({
823 "path": "project_root/subdir/special.privatekey"
824 });
825 Arc::new(ReadFileTool)
826 .run(
827 input,
828 Arc::default(),
829 project.clone(),
830 action_log.clone(),
831 model.clone(),
832 None,
833 cx,
834 )
835 .output
836 })
837 .await;
838 assert!(
839 result.is_err(),
840 "read_file_tool should error when attempting to read .privatekey files (private_files)"
841 );
842
843 let result = cx
844 .update(|cx| {
845 let input = json!({
846 "path": "project_root/subdir/data.mysensitive"
847 });
848 Arc::new(ReadFileTool)
849 .run(
850 input,
851 Arc::default(),
852 project.clone(),
853 action_log.clone(),
854 model.clone(),
855 None,
856 cx,
857 )
858 .output
859 })
860 .await;
861 assert!(
862 result.is_err(),
863 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
864 );
865
866 // Reading a normal file should still work, even with private_files configured
867 let result = cx
868 .update(|cx| {
869 let input = json!({
870 "path": "project_root/subdir/normal_file.txt"
871 });
872 Arc::new(ReadFileTool)
873 .run(
874 input,
875 Arc::default(),
876 project.clone(),
877 action_log.clone(),
878 model.clone(),
879 None,
880 cx,
881 )
882 .output
883 })
884 .await;
885 assert!(result.is_ok(), "Should be able to read normal files");
886 assert_eq!(
887 result.unwrap().content.as_str().unwrap(),
888 "Normal file content"
889 );
890
891 // Path traversal attempts with .. should fail
892 let result = cx
893 .update(|cx| {
894 let input = json!({
895 "path": "project_root/../outside_project/sensitive_file.txt"
896 });
897 Arc::new(ReadFileTool)
898 .run(
899 input,
900 Arc::default(),
901 project.clone(),
902 action_log.clone(),
903 model.clone(),
904 None,
905 cx,
906 )
907 .output
908 })
909 .await;
910 assert!(
911 result.is_err(),
912 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
913 );
914 }
915
916 #[gpui::test]
917 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
918 init_test(cx);
919
920 let fs = FakeFs::new(cx.executor());
921
922 // Create first worktree with its own private_files setting
923 fs.insert_tree(
924 path!("/worktree1"),
925 json!({
926 "src": {
927 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
928 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
929 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
930 },
931 "tests": {
932 "test.rs": "mod tests { fn test_it() {} }",
933 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
934 },
935 ".zed": {
936 "settings.json": r#"{
937 "file_scan_exclusions": ["**/fixture.*"],
938 "private_files": ["**/secret.rs", "**/config.toml"]
939 }"#
940 }
941 }),
942 )
943 .await;
944
945 // Create second worktree with different private_files setting
946 fs.insert_tree(
947 path!("/worktree2"),
948 json!({
949 "lib": {
950 "public.js": "export function greet() { return 'Hello from worktree2'; }",
951 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
952 "data.json": "{\"api_key\": \"json_secret_key\"}"
953 },
954 "docs": {
955 "README.md": "# Public Documentation",
956 "internal.md": "# Internal Secrets and Configuration"
957 },
958 ".zed": {
959 "settings.json": r#"{
960 "file_scan_exclusions": ["**/internal.*"],
961 "private_files": ["**/private.js", "**/data.json"]
962 }"#
963 }
964 }),
965 )
966 .await;
967
968 // Set global settings
969 cx.update(|cx| {
970 SettingsStore::update_global(cx, |store, cx| {
971 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
972 settings.file_scan_exclusions =
973 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
974 settings.private_files = Some(vec!["**/.env".to_string()]);
975 });
976 });
977 });
978
979 let project = Project::test(
980 fs.clone(),
981 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
982 cx,
983 )
984 .await;
985
986 let action_log = cx.new(|_| ActionLog::new(project.clone()));
987 let model = Arc::new(FakeLanguageModel::default());
988 let tool = Arc::new(ReadFileTool);
989
990 // Test reading allowed files in worktree1
991 let input = json!({
992 "path": "worktree1/src/main.rs"
993 });
994
995 let result = cx
996 .update(|cx| {
997 tool.clone().run(
998 input,
999 Arc::default(),
1000 project.clone(),
1001 action_log.clone(),
1002 model.clone(),
1003 None,
1004 cx,
1005 )
1006 })
1007 .output
1008 .await
1009 .unwrap();
1010
1011 assert_eq!(
1012 result.content.as_str().unwrap(),
1013 "fn main() { println!(\"Hello from worktree1\"); }"
1014 );
1015
1016 // Test reading private file in worktree1 should fail
1017 let input = json!({
1018 "path": "worktree1/src/secret.rs"
1019 });
1020
1021 let result = cx
1022 .update(|cx| {
1023 tool.clone().run(
1024 input,
1025 Arc::default(),
1026 project.clone(),
1027 action_log.clone(),
1028 model.clone(),
1029 None,
1030 cx,
1031 )
1032 })
1033 .output
1034 .await;
1035
1036 assert!(result.is_err());
1037 assert!(
1038 result
1039 .unwrap_err()
1040 .to_string()
1041 .contains("worktree `private_files` setting"),
1042 "Error should mention worktree private_files setting"
1043 );
1044
1045 // Test reading excluded file in worktree1 should fail
1046 let input = json!({
1047 "path": "worktree1/tests/fixture.sql"
1048 });
1049
1050 let result = cx
1051 .update(|cx| {
1052 tool.clone().run(
1053 input,
1054 Arc::default(),
1055 project.clone(),
1056 action_log.clone(),
1057 model.clone(),
1058 None,
1059 cx,
1060 )
1061 })
1062 .output
1063 .await;
1064
1065 assert!(result.is_err());
1066 assert!(
1067 result
1068 .unwrap_err()
1069 .to_string()
1070 .contains("worktree `file_scan_exclusions` setting"),
1071 "Error should mention worktree file_scan_exclusions setting"
1072 );
1073
1074 // Test reading allowed files in worktree2
1075 let input = json!({
1076 "path": "worktree2/lib/public.js"
1077 });
1078
1079 let result = cx
1080 .update(|cx| {
1081 tool.clone().run(
1082 input,
1083 Arc::default(),
1084 project.clone(),
1085 action_log.clone(),
1086 model.clone(),
1087 None,
1088 cx,
1089 )
1090 })
1091 .output
1092 .await
1093 .unwrap();
1094
1095 assert_eq!(
1096 result.content.as_str().unwrap(),
1097 "export function greet() { return 'Hello from worktree2'; }"
1098 );
1099
1100 // Test reading private file in worktree2 should fail
1101 let input = json!({
1102 "path": "worktree2/lib/private.js"
1103 });
1104
1105 let result = cx
1106 .update(|cx| {
1107 tool.clone().run(
1108 input,
1109 Arc::default(),
1110 project.clone(),
1111 action_log.clone(),
1112 model.clone(),
1113 None,
1114 cx,
1115 )
1116 })
1117 .output
1118 .await;
1119
1120 assert!(result.is_err());
1121 assert!(
1122 result
1123 .unwrap_err()
1124 .to_string()
1125 .contains("worktree `private_files` setting"),
1126 "Error should mention worktree private_files setting"
1127 );
1128
1129 // Test reading excluded file in worktree2 should fail
1130 let input = json!({
1131 "path": "worktree2/docs/internal.md"
1132 });
1133
1134 let result = cx
1135 .update(|cx| {
1136 tool.clone().run(
1137 input,
1138 Arc::default(),
1139 project.clone(),
1140 action_log.clone(),
1141 model.clone(),
1142 None,
1143 cx,
1144 )
1145 })
1146 .output
1147 .await;
1148
1149 assert!(result.is_err());
1150 assert!(
1151 result
1152 .unwrap_err()
1153 .to_string()
1154 .contains("worktree `file_scan_exclusions` setting"),
1155 "Error should mention worktree file_scan_exclusions setting"
1156 );
1157
1158 // Test that files allowed in one worktree but not in another are handled correctly
1159 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1160 let input = json!({
1161 "path": "worktree1/src/config.toml"
1162 });
1163
1164 let result = cx
1165 .update(|cx| {
1166 tool.clone().run(
1167 input,
1168 Arc::default(),
1169 project.clone(),
1170 action_log.clone(),
1171 model.clone(),
1172 None,
1173 cx,
1174 )
1175 })
1176 .output
1177 .await;
1178
1179 assert!(result.is_err());
1180 assert!(
1181 result
1182 .unwrap_err()
1183 .to_string()
1184 .contains("worktree `private_files` setting"),
1185 "Config.toml should be blocked by worktree1's private_files setting"
1186 );
1187 }
1188}