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, _: &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 .into())
291 }
292 }
293 })
294 .into()
295 }
296}
297
298#[cfg(test)]
299mod test {
300 use super::*;
301 use gpui::{AppContext, TestAppContext, UpdateGlobal};
302 use language::{Language, LanguageConfig, LanguageMatcher};
303 use language_model::fake_provider::FakeLanguageModel;
304 use project::{FakeFs, Project, WorktreeSettings};
305 use serde_json::json;
306 use settings::SettingsStore;
307 use util::path;
308
309 #[gpui::test]
310 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
311 init_test(cx);
312
313 let fs = FakeFs::new(cx.executor());
314 fs.insert_tree(path!("/root"), json!({})).await;
315 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
316 let action_log = cx.new(|_| ActionLog::new(project.clone()));
317 let model = Arc::new(FakeLanguageModel::default());
318 let result = cx
319 .update(|cx| {
320 let input = json!({
321 "path": "root/nonexistent_file.txt"
322 });
323 Arc::new(ReadFileTool)
324 .run(
325 input,
326 Arc::default(),
327 project.clone(),
328 action_log,
329 model,
330 None,
331 cx,
332 )
333 .output
334 })
335 .await;
336 assert_eq!(
337 result.unwrap_err().to_string(),
338 "root/nonexistent_file.txt not found"
339 );
340 }
341
342 #[gpui::test]
343 async fn test_read_small_file(cx: &mut TestAppContext) {
344 init_test(cx);
345
346 let fs = FakeFs::new(cx.executor());
347 fs.insert_tree(
348 path!("/root"),
349 json!({
350 "small_file.txt": "This is a small file content"
351 }),
352 )
353 .await;
354 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
355 let action_log = cx.new(|_| ActionLog::new(project.clone()));
356 let model = Arc::new(FakeLanguageModel::default());
357 let result = cx
358 .update(|cx| {
359 let input = json!({
360 "path": "root/small_file.txt"
361 });
362 Arc::new(ReadFileTool)
363 .run(
364 input,
365 Arc::default(),
366 project.clone(),
367 action_log,
368 model,
369 None,
370 cx,
371 )
372 .output
373 })
374 .await;
375 assert_eq!(
376 result.unwrap().content.as_str(),
377 Some("This is a small file content")
378 );
379 }
380
381 #[gpui::test]
382 async fn test_read_large_file(cx: &mut TestAppContext) {
383 init_test(cx);
384
385 let fs = FakeFs::new(cx.executor());
386 fs.insert_tree(
387 path!("/root"),
388 json!({
389 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
390 }),
391 )
392 .await;
393 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
394 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
395 language_registry.add(Arc::new(rust_lang()));
396 let action_log = cx.new(|_| ActionLog::new(project.clone()));
397 let model = Arc::new(FakeLanguageModel::default());
398
399 let result = cx
400 .update(|cx| {
401 let input = json!({
402 "path": "root/large_file.rs"
403 });
404 Arc::new(ReadFileTool)
405 .run(
406 input,
407 Arc::default(),
408 project.clone(),
409 action_log.clone(),
410 model.clone(),
411 None,
412 cx,
413 )
414 .output
415 })
416 .await;
417 let content = result.unwrap();
418 let content = content.as_str().unwrap();
419 assert_eq!(
420 content.lines().skip(4).take(6).collect::<Vec<_>>(),
421 vec![
422 "struct Test0 [L1-4]",
423 " a [L2]",
424 " b [L3]",
425 "struct Test1 [L5-8]",
426 " a [L6]",
427 " b [L7]",
428 ]
429 );
430
431 let result = cx
432 .update(|cx| {
433 let input = json!({
434 "path": "root/large_file.rs",
435 "offset": 1
436 });
437 Arc::new(ReadFileTool)
438 .run(
439 input,
440 Arc::default(),
441 project.clone(),
442 action_log,
443 model,
444 None,
445 cx,
446 )
447 .output
448 })
449 .await;
450 let content = result.unwrap();
451 let expected_content = (0..1000)
452 .flat_map(|i| {
453 vec![
454 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
455 format!(" a [L{}]", i * 4 + 2),
456 format!(" b [L{}]", i * 4 + 3),
457 ]
458 })
459 .collect::<Vec<_>>();
460 pretty_assertions::assert_eq!(
461 content
462 .as_str()
463 .unwrap()
464 .lines()
465 .skip(4)
466 .take(expected_content.len())
467 .collect::<Vec<_>>(),
468 expected_content
469 );
470 }
471
472 #[gpui::test]
473 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
474 init_test(cx);
475
476 let fs = FakeFs::new(cx.executor());
477 fs.insert_tree(
478 path!("/root"),
479 json!({
480 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
481 }),
482 )
483 .await;
484 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
485 let action_log = cx.new(|_| ActionLog::new(project.clone()));
486 let model = Arc::new(FakeLanguageModel::default());
487 let result = cx
488 .update(|cx| {
489 let input = json!({
490 "path": "root/multiline.txt",
491 "start_line": 2,
492 "end_line": 4
493 });
494 Arc::new(ReadFileTool)
495 .run(
496 input,
497 Arc::default(),
498 project.clone(),
499 action_log,
500 model,
501 None,
502 cx,
503 )
504 .output
505 })
506 .await;
507 assert_eq!(
508 result.unwrap().content.as_str(),
509 Some("Line 2\nLine 3\nLine 4")
510 );
511 }
512
513 #[gpui::test]
514 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
515 init_test(cx);
516
517 let fs = FakeFs::new(cx.executor());
518 fs.insert_tree(
519 path!("/root"),
520 json!({
521 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
522 }),
523 )
524 .await;
525 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
526 let action_log = cx.new(|_| ActionLog::new(project.clone()));
527 let model = Arc::new(FakeLanguageModel::default());
528
529 // start_line of 0 should be treated as 1
530 let result = cx
531 .update(|cx| {
532 let input = json!({
533 "path": "root/multiline.txt",
534 "start_line": 0,
535 "end_line": 2
536 });
537 Arc::new(ReadFileTool)
538 .run(
539 input,
540 Arc::default(),
541 project.clone(),
542 action_log.clone(),
543 model.clone(),
544 None,
545 cx,
546 )
547 .output
548 })
549 .await;
550 assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
551
552 // end_line of 0 should result in at least 1 line
553 let result = cx
554 .update(|cx| {
555 let input = json!({
556 "path": "root/multiline.txt",
557 "start_line": 1,
558 "end_line": 0
559 });
560 Arc::new(ReadFileTool)
561 .run(
562 input,
563 Arc::default(),
564 project.clone(),
565 action_log.clone(),
566 model.clone(),
567 None,
568 cx,
569 )
570 .output
571 })
572 .await;
573 assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
574
575 // when start_line > end_line, should still return at least 1 line
576 let result = cx
577 .update(|cx| {
578 let input = json!({
579 "path": "root/multiline.txt",
580 "start_line": 3,
581 "end_line": 2
582 });
583 Arc::new(ReadFileTool)
584 .run(
585 input,
586 Arc::default(),
587 project.clone(),
588 action_log,
589 model,
590 None,
591 cx,
592 )
593 .output
594 })
595 .await;
596 assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
597 }
598
599 fn init_test(cx: &mut TestAppContext) {
600 cx.update(|cx| {
601 let settings_store = SettingsStore::test(cx);
602 cx.set_global(settings_store);
603 language::init(cx);
604 Project::init_settings(cx);
605 });
606 }
607
608 fn rust_lang() -> Language {
609 Language::new(
610 LanguageConfig {
611 name: "Rust".into(),
612 matcher: LanguageMatcher {
613 path_suffixes: vec!["rs".to_string()],
614 ..Default::default()
615 },
616 ..Default::default()
617 },
618 Some(tree_sitter_rust::LANGUAGE.into()),
619 )
620 .with_outline_query(
621 r#"
622 (line_comment) @annotation
623
624 (struct_item
625 "struct" @context
626 name: (_) @name) @item
627 (enum_item
628 "enum" @context
629 name: (_) @name) @item
630 (enum_variant
631 name: (_) @name) @item
632 (field_declaration
633 name: (_) @name) @item
634 (impl_item
635 "impl" @context
636 trait: (_)? @name
637 "for"? @context
638 type: (_) @name
639 body: (_ "{" (_)* "}")) @item
640 (function_item
641 "fn" @context
642 name: (_) @name) @item
643 (mod_item
644 "mod" @context
645 name: (_) @name) @item
646 "#,
647 )
648 .unwrap()
649 }
650
651 #[gpui::test]
652 async fn test_read_file_security(cx: &mut TestAppContext) {
653 init_test(cx);
654
655 let fs = FakeFs::new(cx.executor());
656
657 fs.insert_tree(
658 path!("/"),
659 json!({
660 "project_root": {
661 "allowed_file.txt": "This file is in the project",
662 ".mysecrets": "SECRET_KEY=abc123",
663 ".secretdir": {
664 "config": "special configuration"
665 },
666 ".mymetadata": "custom metadata",
667 "subdir": {
668 "normal_file.txt": "Normal file content",
669 "special.privatekey": "private key content",
670 "data.mysensitive": "sensitive data"
671 }
672 },
673 "outside_project": {
674 "sensitive_file.txt": "This file is outside the project"
675 }
676 }),
677 )
678 .await;
679
680 cx.update(|cx| {
681 use gpui::UpdateGlobal;
682 use project::WorktreeSettings;
683 use settings::SettingsStore;
684 SettingsStore::update_global(cx, |store, cx| {
685 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
686 settings.file_scan_exclusions = Some(vec![
687 "**/.secretdir".to_string(),
688 "**/.mymetadata".to_string(),
689 ]);
690 settings.private_files = Some(vec![
691 "**/.mysecrets".to_string(),
692 "**/*.privatekey".to_string(),
693 "**/*.mysensitive".to_string(),
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::<WorktreeSettings>(cx, |settings| {
974 settings.file_scan_exclusions =
975 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
976 settings.private_files = Some(vec!["**/.env".to_string()]);
977 });
978 });
979 });
980
981 let project = Project::test(
982 fs.clone(),
983 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
984 cx,
985 )
986 .await;
987
988 let action_log = cx.new(|_| ActionLog::new(project.clone()));
989 let model = Arc::new(FakeLanguageModel::default());
990 let tool = Arc::new(ReadFileTool);
991
992 // Test reading allowed files in worktree1
993 let input = json!({
994 "path": "worktree1/src/main.rs"
995 });
996
997 let result = cx
998 .update(|cx| {
999 tool.clone().run(
1000 input,
1001 Arc::default(),
1002 project.clone(),
1003 action_log.clone(),
1004 model.clone(),
1005 None,
1006 cx,
1007 )
1008 })
1009 .output
1010 .await
1011 .unwrap();
1012
1013 assert_eq!(
1014 result.content.as_str().unwrap(),
1015 "fn main() { println!(\"Hello from worktree1\"); }"
1016 );
1017
1018 // Test reading private file in worktree1 should fail
1019 let input = json!({
1020 "path": "worktree1/src/secret.rs"
1021 });
1022
1023 let result = cx
1024 .update(|cx| {
1025 tool.clone().run(
1026 input,
1027 Arc::default(),
1028 project.clone(),
1029 action_log.clone(),
1030 model.clone(),
1031 None,
1032 cx,
1033 )
1034 })
1035 .output
1036 .await;
1037
1038 assert!(result.is_err());
1039 assert!(
1040 result
1041 .unwrap_err()
1042 .to_string()
1043 .contains("worktree `private_files` setting"),
1044 "Error should mention worktree private_files setting"
1045 );
1046
1047 // Test reading excluded file in worktree1 should fail
1048 let input = json!({
1049 "path": "worktree1/tests/fixture.sql"
1050 });
1051
1052 let result = cx
1053 .update(|cx| {
1054 tool.clone().run(
1055 input,
1056 Arc::default(),
1057 project.clone(),
1058 action_log.clone(),
1059 model.clone(),
1060 None,
1061 cx,
1062 )
1063 })
1064 .output
1065 .await;
1066
1067 assert!(result.is_err());
1068 assert!(
1069 result
1070 .unwrap_err()
1071 .to_string()
1072 .contains("worktree `file_scan_exclusions` setting"),
1073 "Error should mention worktree file_scan_exclusions setting"
1074 );
1075
1076 // Test reading allowed files in worktree2
1077 let input = json!({
1078 "path": "worktree2/lib/public.js"
1079 });
1080
1081 let result = cx
1082 .update(|cx| {
1083 tool.clone().run(
1084 input,
1085 Arc::default(),
1086 project.clone(),
1087 action_log.clone(),
1088 model.clone(),
1089 None,
1090 cx,
1091 )
1092 })
1093 .output
1094 .await
1095 .unwrap();
1096
1097 assert_eq!(
1098 result.content.as_str().unwrap(),
1099 "export function greet() { return 'Hello from worktree2'; }"
1100 );
1101
1102 // Test reading private file in worktree2 should fail
1103 let input = json!({
1104 "path": "worktree2/lib/private.js"
1105 });
1106
1107 let result = cx
1108 .update(|cx| {
1109 tool.clone().run(
1110 input,
1111 Arc::default(),
1112 project.clone(),
1113 action_log.clone(),
1114 model.clone(),
1115 None,
1116 cx,
1117 )
1118 })
1119 .output
1120 .await;
1121
1122 assert!(result.is_err());
1123 assert!(
1124 result
1125 .unwrap_err()
1126 .to_string()
1127 .contains("worktree `private_files` setting"),
1128 "Error should mention worktree private_files setting"
1129 );
1130
1131 // Test reading excluded file in worktree2 should fail
1132 let input = json!({
1133 "path": "worktree2/docs/internal.md"
1134 });
1135
1136 let result = cx
1137 .update(|cx| {
1138 tool.clone().run(
1139 input,
1140 Arc::default(),
1141 project.clone(),
1142 action_log.clone(),
1143 model.clone(),
1144 None,
1145 cx,
1146 )
1147 })
1148 .output
1149 .await;
1150
1151 assert!(result.is_err());
1152 assert!(
1153 result
1154 .unwrap_err()
1155 .to_string()
1156 .contains("worktree `file_scan_exclusions` setting"),
1157 "Error should mention worktree file_scan_exclusions setting"
1158 );
1159
1160 // Test that files allowed in one worktree but not in another are handled correctly
1161 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1162 let input = json!({
1163 "path": "worktree1/src/config.toml"
1164 });
1165
1166 let result = cx
1167 .update(|cx| {
1168 tool.clone().run(
1169 input,
1170 Arc::default(),
1171 project.clone(),
1172 action_log.clone(),
1173 model.clone(),
1174 None,
1175 cx,
1176 )
1177 })
1178 .output
1179 .await;
1180
1181 assert!(result.is_err());
1182 assert!(
1183 result
1184 .unwrap_err()
1185 .to_string()
1186 .contains("worktree `private_files` setting"),
1187 "Config.toml should be blocked by worktree1's private_files setting"
1188 );
1189 }
1190}