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 buffer_content =
265 outline::get_buffer_content_or_outline(buffer.clone(), Some(&file_path), cx)
266 .await?;
267
268 action_log.update(cx, |log, cx| {
269 log.buffer_read(buffer, cx);
270 })?;
271
272 if buffer_content.is_outline {
273 Ok(formatdoc! {"
274 This file was too big to read all at once.
275
276 {}
277
278 Using the line numbers in this outline, you can call this tool again
279 while specifying the start_line and end_line fields to see the
280 implementations of symbols in the outline.
281
282 Alternatively, you can fall back to the `grep` tool (if available)
283 to search the file for specific content.", buffer_content.text
284 }
285 .into())
286 } else {
287 Ok(buffer_content.text.into())
288 }
289 }
290 })
291 .into()
292 }
293}
294
295#[cfg(test)]
296mod test {
297 use super::*;
298 use gpui::{AppContext, TestAppContext, UpdateGlobal};
299 use language::{Language, LanguageConfig, LanguageMatcher};
300 use language_model::fake_provider::FakeLanguageModel;
301 use project::{FakeFs, Project};
302 use serde_json::json;
303 use settings::SettingsStore;
304 use util::path;
305
306 #[gpui::test]
307 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
308 init_test(cx);
309
310 let fs = FakeFs::new(cx.executor());
311 fs.insert_tree(path!("/root"), json!({})).await;
312 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
313 let action_log = cx.new(|_| ActionLog::new(project.clone()));
314 let model = Arc::new(FakeLanguageModel::default());
315 let result = cx
316 .update(|cx| {
317 let input = json!({
318 "path": "root/nonexistent_file.txt"
319 });
320 Arc::new(ReadFileTool)
321 .run(
322 input,
323 Arc::default(),
324 project.clone(),
325 action_log,
326 model,
327 None,
328 cx,
329 )
330 .output
331 })
332 .await;
333 assert_eq!(
334 result.unwrap_err().to_string(),
335 "root/nonexistent_file.txt not found"
336 );
337 }
338
339 #[gpui::test]
340 async fn test_read_small_file(cx: &mut TestAppContext) {
341 init_test(cx);
342
343 let fs = FakeFs::new(cx.executor());
344 fs.insert_tree(
345 path!("/root"),
346 json!({
347 "small_file.txt": "This is a small file content"
348 }),
349 )
350 .await;
351 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
352 let action_log = cx.new(|_| ActionLog::new(project.clone()));
353 let model = Arc::new(FakeLanguageModel::default());
354 let result = cx
355 .update(|cx| {
356 let input = json!({
357 "path": "root/small_file.txt"
358 });
359 Arc::new(ReadFileTool)
360 .run(
361 input,
362 Arc::default(),
363 project.clone(),
364 action_log,
365 model,
366 None,
367 cx,
368 )
369 .output
370 })
371 .await;
372 assert_eq!(
373 result.unwrap().content.as_str(),
374 Some("This is a small file content")
375 );
376 }
377
378 #[gpui::test]
379 async fn test_read_large_file(cx: &mut TestAppContext) {
380 init_test(cx);
381
382 let fs = FakeFs::new(cx.executor());
383 fs.insert_tree(
384 path!("/root"),
385 json!({
386 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
387 }),
388 )
389 .await;
390 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
391 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
392 language_registry.add(Arc::new(rust_lang()));
393 let action_log = cx.new(|_| ActionLog::new(project.clone()));
394 let model = Arc::new(FakeLanguageModel::default());
395
396 let result = cx
397 .update(|cx| {
398 let input = json!({
399 "path": "root/large_file.rs"
400 });
401 Arc::new(ReadFileTool)
402 .run(
403 input,
404 Arc::default(),
405 project.clone(),
406 action_log.clone(),
407 model.clone(),
408 None,
409 cx,
410 )
411 .output
412 })
413 .await;
414 let content = result.unwrap();
415 let content = content.as_str().unwrap();
416 assert_eq!(
417 content.lines().skip(4).take(6).collect::<Vec<_>>(),
418 vec![
419 "struct Test0 [L1-4]",
420 " a [L2]",
421 " b [L3]",
422 "struct Test1 [L5-8]",
423 " a [L6]",
424 " b [L7]",
425 ]
426 );
427
428 let result = cx
429 .update(|cx| {
430 let input = json!({
431 "path": "root/large_file.rs",
432 "offset": 1
433 });
434 Arc::new(ReadFileTool)
435 .run(
436 input,
437 Arc::default(),
438 project.clone(),
439 action_log,
440 model,
441 None,
442 cx,
443 )
444 .output
445 })
446 .await;
447 let content = result.unwrap();
448 let expected_content = (0..1000)
449 .flat_map(|i| {
450 vec![
451 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
452 format!(" a [L{}]", i * 4 + 2),
453 format!(" b [L{}]", i * 4 + 3),
454 ]
455 })
456 .collect::<Vec<_>>();
457 pretty_assertions::assert_eq!(
458 content
459 .as_str()
460 .unwrap()
461 .lines()
462 .skip(4)
463 .take(expected_content.len())
464 .collect::<Vec<_>>(),
465 expected_content
466 );
467 }
468
469 #[gpui::test]
470 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
471 init_test(cx);
472
473 let fs = FakeFs::new(cx.executor());
474 fs.insert_tree(
475 path!("/root"),
476 json!({
477 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
478 }),
479 )
480 .await;
481 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
482 let action_log = cx.new(|_| ActionLog::new(project.clone()));
483 let model = Arc::new(FakeLanguageModel::default());
484 let result = cx
485 .update(|cx| {
486 let input = json!({
487 "path": "root/multiline.txt",
488 "start_line": 2,
489 "end_line": 4
490 });
491 Arc::new(ReadFileTool)
492 .run(
493 input,
494 Arc::default(),
495 project.clone(),
496 action_log,
497 model,
498 None,
499 cx,
500 )
501 .output
502 })
503 .await;
504 assert_eq!(
505 result.unwrap().content.as_str(),
506 Some("Line 2\nLine 3\nLine 4")
507 );
508 }
509
510 #[gpui::test]
511 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
512 init_test(cx);
513
514 let fs = FakeFs::new(cx.executor());
515 fs.insert_tree(
516 path!("/root"),
517 json!({
518 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
519 }),
520 )
521 .await;
522 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
523 let action_log = cx.new(|_| ActionLog::new(project.clone()));
524 let model = Arc::new(FakeLanguageModel::default());
525
526 // start_line of 0 should be treated as 1
527 let result = cx
528 .update(|cx| {
529 let input = json!({
530 "path": "root/multiline.txt",
531 "start_line": 0,
532 "end_line": 2
533 });
534 Arc::new(ReadFileTool)
535 .run(
536 input,
537 Arc::default(),
538 project.clone(),
539 action_log.clone(),
540 model.clone(),
541 None,
542 cx,
543 )
544 .output
545 })
546 .await;
547 assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
548
549 // end_line of 0 should result in at least 1 line
550 let result = cx
551 .update(|cx| {
552 let input = json!({
553 "path": "root/multiline.txt",
554 "start_line": 1,
555 "end_line": 0
556 });
557 Arc::new(ReadFileTool)
558 .run(
559 input,
560 Arc::default(),
561 project.clone(),
562 action_log.clone(),
563 model.clone(),
564 None,
565 cx,
566 )
567 .output
568 })
569 .await;
570 assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
571
572 // when start_line > end_line, should still return at least 1 line
573 let result = cx
574 .update(|cx| {
575 let input = json!({
576 "path": "root/multiline.txt",
577 "start_line": 3,
578 "end_line": 2
579 });
580 Arc::new(ReadFileTool)
581 .run(
582 input,
583 Arc::default(),
584 project.clone(),
585 action_log,
586 model,
587 None,
588 cx,
589 )
590 .output
591 })
592 .await;
593 assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
594 }
595
596 fn init_test(cx: &mut TestAppContext) {
597 cx.update(|cx| {
598 let settings_store = SettingsStore::test(cx);
599 cx.set_global(settings_store);
600 language::init(cx);
601 Project::init_settings(cx);
602 });
603 }
604
605 fn rust_lang() -> Language {
606 Language::new(
607 LanguageConfig {
608 name: "Rust".into(),
609 matcher: LanguageMatcher {
610 path_suffixes: vec!["rs".to_string()],
611 ..Default::default()
612 },
613 ..Default::default()
614 },
615 Some(tree_sitter_rust::LANGUAGE.into()),
616 )
617 .with_outline_query(
618 r#"
619 (line_comment) @annotation
620
621 (struct_item
622 "struct" @context
623 name: (_) @name) @item
624 (enum_item
625 "enum" @context
626 name: (_) @name) @item
627 (enum_variant
628 name: (_) @name) @item
629 (field_declaration
630 name: (_) @name) @item
631 (impl_item
632 "impl" @context
633 trait: (_)? @name
634 "for"? @context
635 type: (_) @name
636 body: (_ "{" (_)* "}")) @item
637 (function_item
638 "fn" @context
639 name: (_) @name) @item
640 (mod_item
641 "mod" @context
642 name: (_) @name) @item
643 "#,
644 )
645 .unwrap()
646 }
647
648 #[gpui::test]
649 async fn test_read_file_security(cx: &mut TestAppContext) {
650 init_test(cx);
651
652 let fs = FakeFs::new(cx.executor());
653
654 fs.insert_tree(
655 path!("/"),
656 json!({
657 "project_root": {
658 "allowed_file.txt": "This file is in the project",
659 ".mysecrets": "SECRET_KEY=abc123",
660 ".secretdir": {
661 "config": "special configuration"
662 },
663 ".mymetadata": "custom metadata",
664 "subdir": {
665 "normal_file.txt": "Normal file content",
666 "special.privatekey": "private key content",
667 "data.mysensitive": "sensitive data"
668 }
669 },
670 "outside_project": {
671 "sensitive_file.txt": "This file is outside the project"
672 }
673 }),
674 )
675 .await;
676
677 cx.update(|cx| {
678 use gpui::UpdateGlobal;
679 use settings::SettingsStore;
680 SettingsStore::update_global(cx, |store, cx| {
681 store.update_user_settings(cx, |settings| {
682 settings.project.worktree.file_scan_exclusions = Some(vec![
683 "**/.secretdir".to_string(),
684 "**/.mymetadata".to_string(),
685 ]);
686 settings.project.worktree.private_files = Some(
687 vec![
688 "**/.mysecrets".to_string(),
689 "**/*.privatekey".to_string(),
690 "**/*.mysensitive".to_string(),
691 ]
692 .into(),
693 );
694 });
695 });
696 });
697
698 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
699 let action_log = cx.new(|_| ActionLog::new(project.clone()));
700 let model = Arc::new(FakeLanguageModel::default());
701
702 // Reading a file outside the project worktree should fail
703 let result = cx
704 .update(|cx| {
705 let input = json!({
706 "path": "/outside_project/sensitive_file.txt"
707 });
708 Arc::new(ReadFileTool)
709 .run(
710 input,
711 Arc::default(),
712 project.clone(),
713 action_log.clone(),
714 model.clone(),
715 None,
716 cx,
717 )
718 .output
719 })
720 .await;
721 assert!(
722 result.is_err(),
723 "read_file_tool should error when attempting to read an absolute path outside a worktree"
724 );
725
726 // Reading a file within the project should succeed
727 let result = cx
728 .update(|cx| {
729 let input = json!({
730 "path": "project_root/allowed_file.txt"
731 });
732 Arc::new(ReadFileTool)
733 .run(
734 input,
735 Arc::default(),
736 project.clone(),
737 action_log.clone(),
738 model.clone(),
739 None,
740 cx,
741 )
742 .output
743 })
744 .await;
745 assert!(
746 result.is_ok(),
747 "read_file_tool should be able to read files inside worktrees"
748 );
749
750 // Reading files that match file_scan_exclusions should fail
751 let result = cx
752 .update(|cx| {
753 let input = json!({
754 "path": "project_root/.secretdir/config"
755 });
756 Arc::new(ReadFileTool)
757 .run(
758 input,
759 Arc::default(),
760 project.clone(),
761 action_log.clone(),
762 model.clone(),
763 None,
764 cx,
765 )
766 .output
767 })
768 .await;
769 assert!(
770 result.is_err(),
771 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
772 );
773
774 let result = cx
775 .update(|cx| {
776 let input = json!({
777 "path": "project_root/.mymetadata"
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 = json!({
801 "path": "project_root/.mysecrets"
802 });
803 Arc::new(ReadFileTool)
804 .run(
805 input,
806 Arc::default(),
807 project.clone(),
808 action_log.clone(),
809 model.clone(),
810 None,
811 cx,
812 )
813 .output
814 })
815 .await;
816 assert!(
817 result.is_err(),
818 "read_file_tool should error when attempting to read .mysecrets (private_files)"
819 );
820
821 let result = cx
822 .update(|cx| {
823 let input = json!({
824 "path": "project_root/subdir/special.privatekey"
825 });
826 Arc::new(ReadFileTool)
827 .run(
828 input,
829 Arc::default(),
830 project.clone(),
831 action_log.clone(),
832 model.clone(),
833 None,
834 cx,
835 )
836 .output
837 })
838 .await;
839 assert!(
840 result.is_err(),
841 "read_file_tool should error when attempting to read .privatekey files (private_files)"
842 );
843
844 let result = cx
845 .update(|cx| {
846 let input = json!({
847 "path": "project_root/subdir/data.mysensitive"
848 });
849 Arc::new(ReadFileTool)
850 .run(
851 input,
852 Arc::default(),
853 project.clone(),
854 action_log.clone(),
855 model.clone(),
856 None,
857 cx,
858 )
859 .output
860 })
861 .await;
862 assert!(
863 result.is_err(),
864 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
865 );
866
867 // Reading a normal file should still work, even with private_files configured
868 let result = cx
869 .update(|cx| {
870 let input = json!({
871 "path": "project_root/subdir/normal_file.txt"
872 });
873 Arc::new(ReadFileTool)
874 .run(
875 input,
876 Arc::default(),
877 project.clone(),
878 action_log.clone(),
879 model.clone(),
880 None,
881 cx,
882 )
883 .output
884 })
885 .await;
886 assert!(result.is_ok(), "Should be able to read normal files");
887 assert_eq!(
888 result.unwrap().content.as_str().unwrap(),
889 "Normal file content"
890 );
891
892 // Path traversal attempts with .. should fail
893 let result = cx
894 .update(|cx| {
895 let input = json!({
896 "path": "project_root/../outside_project/sensitive_file.txt"
897 });
898 Arc::new(ReadFileTool)
899 .run(
900 input,
901 Arc::default(),
902 project.clone(),
903 action_log.clone(),
904 model.clone(),
905 None,
906 cx,
907 )
908 .output
909 })
910 .await;
911 assert!(
912 result.is_err(),
913 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
914 );
915 }
916
917 #[gpui::test]
918 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
919 init_test(cx);
920
921 let fs = FakeFs::new(cx.executor());
922
923 // Create first worktree with its own private_files setting
924 fs.insert_tree(
925 path!("/worktree1"),
926 json!({
927 "src": {
928 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
929 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
930 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
931 },
932 "tests": {
933 "test.rs": "mod tests { fn test_it() {} }",
934 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
935 },
936 ".zed": {
937 "settings.json": r#"{
938 "file_scan_exclusions": ["**/fixture.*"],
939 "private_files": ["**/secret.rs", "**/config.toml"]
940 }"#
941 }
942 }),
943 )
944 .await;
945
946 // Create second worktree with different private_files setting
947 fs.insert_tree(
948 path!("/worktree2"),
949 json!({
950 "lib": {
951 "public.js": "export function greet() { return 'Hello from worktree2'; }",
952 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
953 "data.json": "{\"api_key\": \"json_secret_key\"}"
954 },
955 "docs": {
956 "README.md": "# Public Documentation",
957 "internal.md": "# Internal Secrets and Configuration"
958 },
959 ".zed": {
960 "settings.json": r#"{
961 "file_scan_exclusions": ["**/internal.*"],
962 "private_files": ["**/private.js", "**/data.json"]
963 }"#
964 }
965 }),
966 )
967 .await;
968
969 // Set global settings
970 cx.update(|cx| {
971 SettingsStore::update_global(cx, |store, cx| {
972 store.update_user_settings(cx, |settings| {
973 settings.project.worktree.file_scan_exclusions =
974 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
975 settings.project.worktree.private_files =
976 Some(vec!["**/.env".to_string()].into());
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}