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