1use action_log::ActionLog;
2use agent_client_protocol::{self as acp, ToolCallUpdateFields};
3use anyhow::{Context as _, Result, anyhow};
4use assistant_tool::outline;
5use gpui::{App, Entity, SharedString, Task};
6use indoc::formatdoc;
7use language::Point;
8use language_model::{LanguageModelImage, LanguageModelToolResultContent};
9use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use settings::Settings;
13use std::sync::Arc;
14
15use crate::{AgentTool, ToolCallEventStream};
16
17/// Reads the content of the given file in the project.
18///
19/// - Never attempt to read a path that hasn't been previously mentioned.
20#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21pub struct ReadFileToolInput {
22 /// The relative path of the file to read.
23 ///
24 /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
25 ///
26 /// <example>
27 /// If the project has the following root directories:
28 ///
29 /// - /a/b/directory1
30 /// - /c/d/directory2
31 ///
32 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
33 /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
34 /// </example>
35 pub path: String,
36 /// Optional line number to start reading on (1-based index)
37 #[serde(default)]
38 pub start_line: Option<u32>,
39 /// Optional line number to end reading on (1-based index, inclusive)
40 #[serde(default)]
41 pub end_line: Option<u32>,
42}
43
44pub struct ReadFileTool {
45 project: Entity<Project>,
46 action_log: Entity<ActionLog>,
47}
48
49impl ReadFileTool {
50 pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
51 Self {
52 project,
53 action_log,
54 }
55 }
56}
57
58impl AgentTool for ReadFileTool {
59 type Input = ReadFileToolInput;
60 type Output = LanguageModelToolResultContent;
61
62 fn name(&self) -> SharedString {
63 "read_file".into()
64 }
65
66 fn kind(&self) -> acp::ToolKind {
67 acp::ToolKind::Read
68 }
69
70 fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
71 if let Ok(input) = input {
72 let path = &input.path;
73 match (input.start_line, input.end_line) {
74 (Some(start), Some(end)) => {
75 format!(
76 "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))",
77 path, start, end, path, start, end
78 )
79 }
80 (Some(start), None) => {
81 format!(
82 "[Read file `{}` (from line {})](@selection:{}:({}-{}))",
83 path, start, path, start, start
84 )
85 }
86 _ => format!("[Read file `{}`](@file:{})", path, path),
87 }
88 .into()
89 } else {
90 "Read file".into()
91 }
92 }
93
94 fn run(
95 self: Arc<Self>,
96 input: Self::Input,
97 event_stream: ToolCallEventStream,
98 cx: &mut App,
99 ) -> Task<Result<LanguageModelToolResultContent>> {
100 let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
101 return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
102 };
103
104 // Error out if this path is either excluded or private in global settings
105 let global_settings = WorktreeSettings::get_global(cx);
106 if global_settings.is_path_excluded(&project_path.path) {
107 return Task::ready(Err(anyhow!(
108 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
109 &input.path
110 )));
111 }
112
113 if global_settings.is_path_private(&project_path.path) {
114 return Task::ready(Err(anyhow!(
115 "Cannot read file because its path matches the global `private_files` setting: {}",
116 &input.path
117 )));
118 }
119
120 // Error out if this path is either excluded or private in worktree settings
121 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
122 if worktree_settings.is_path_excluded(&project_path.path) {
123 return Task::ready(Err(anyhow!(
124 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
125 &input.path
126 )));
127 }
128
129 if worktree_settings.is_path_private(&project_path.path) {
130 return Task::ready(Err(anyhow!(
131 "Cannot read file because its path matches the worktree `private_files` setting: {}",
132 &input.path
133 )));
134 }
135
136 let file_path = input.path.clone();
137
138 if image_store::is_image_file(&self.project, &project_path, cx) {
139 return cx.spawn(async move |cx| {
140 let image_entity: Entity<ImageItem> = cx
141 .update(|cx| {
142 self.project.update(cx, |project, cx| {
143 project.open_image(project_path.clone(), cx)
144 })
145 })?
146 .await?;
147
148 let image =
149 image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
150
151 let language_model_image = cx
152 .update(|cx| LanguageModelImage::from_image(image, cx))?
153 .await
154 .context("processing image")?;
155
156 Ok(language_model_image.into())
157 });
158 }
159
160 let project = self.project.clone();
161 let action_log = self.action_log.clone();
162
163 cx.spawn(async move |cx| {
164 let buffer = cx
165 .update(|cx| {
166 project.update(cx, |project, cx| {
167 project.open_buffer(project_path.clone(), cx)
168 })
169 })?
170 .await?;
171 if buffer.read_with(cx, |buffer, _| {
172 buffer
173 .file()
174 .as_ref()
175 .is_none_or(|file| !file.disk_state().exists())
176 })? {
177 anyhow::bail!("{file_path} not found");
178 }
179
180 let mut anchor = None;
181
182 // Check if specific line ranges are provided
183 let result = if input.start_line.is_some() || input.end_line.is_some() {
184 let result = buffer.read_with(cx, |buffer, _cx| {
185 let text = buffer.text();
186 // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
187 let start = input.start_line.unwrap_or(1).max(1);
188 let start_row = start - 1;
189 if start_row <= buffer.max_point().row {
190 let column = buffer.line_indent_for_row(start_row).raw_len();
191 anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
192 }
193
194 let lines = text.split('\n').skip(start_row as usize);
195 if let Some(end) = input.end_line {
196 let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
197 itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
198 } else {
199 itertools::intersperse(lines, "\n").collect::<String>()
200 }
201 })?;
202
203 action_log.update(cx, |log, cx| {
204 log.buffer_read(buffer.clone(), cx);
205 })?;
206
207 Ok(result.into())
208 } else {
209 // No line ranges specified, so check file size to see if it's too big.
210 let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
211
212 if file_size <= outline::AUTO_OUTLINE_SIZE {
213 // File is small enough, so return its contents.
214 let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
215
216 action_log.update(cx, |log, cx| {
217 log.buffer_read(buffer.clone(), cx);
218 })?;
219
220 Ok(result.into())
221 } else {
222 // File is too big, so return the outline
223 // and a suggestion to read again with line numbers.
224 let outline =
225 outline::file_outline(project.clone(), file_path, action_log, None, cx)
226 .await?;
227 Ok(formatdoc! {"
228 This file was too big to read all at once.
229
230 Here is an outline of its symbols:
231
232 {outline}
233
234 Using the line numbers in this outline, you can call this tool again
235 while specifying the start_line and end_line fields to see the
236 implementations of symbols in the outline.
237
238 Alternatively, you can fall back to the `grep` tool (if available)
239 to search the file for specific content."
240 }
241 .into())
242 }
243 };
244
245 project.update(cx, |project, cx| {
246 if let Some(abs_path) = project.absolute_path(&project_path, cx) {
247 project.set_agent_location(
248 Some(AgentLocation {
249 buffer: buffer.downgrade(),
250 position: anchor.unwrap_or(text::Anchor::MIN),
251 }),
252 cx,
253 );
254 event_stream.update_fields(ToolCallUpdateFields {
255 locations: Some(vec![acp::ToolCallLocation {
256 path: abs_path,
257 line: input.start_line.map(|line| line.saturating_sub(1)),
258 }]),
259 ..Default::default()
260 });
261 }
262 })?;
263
264 result
265 })
266 }
267}
268
269#[cfg(test)]
270mod test {
271 use super::*;
272 use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
273 use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
274 use project::{FakeFs, Project};
275 use serde_json::json;
276 use settings::SettingsStore;
277 use util::path;
278
279 #[gpui::test]
280 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
281 init_test(cx);
282
283 let fs = FakeFs::new(cx.executor());
284 fs.insert_tree(path!("/root"), json!({})).await;
285 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
286 let action_log = cx.new(|_| ActionLog::new(project.clone()));
287 let tool = Arc::new(ReadFileTool::new(project, action_log));
288 let (event_stream, _) = ToolCallEventStream::test();
289
290 let result = cx
291 .update(|cx| {
292 let input = ReadFileToolInput {
293 path: "root/nonexistent_file.txt".to_string(),
294 start_line: None,
295 end_line: None,
296 };
297 tool.run(input, event_stream, cx)
298 })
299 .await;
300 assert_eq!(
301 result.unwrap_err().to_string(),
302 "root/nonexistent_file.txt not found"
303 );
304 }
305
306 #[gpui::test]
307 async fn test_read_small_file(cx: &mut TestAppContext) {
308 init_test(cx);
309
310 let fs = FakeFs::new(cx.executor());
311 fs.insert_tree(
312 path!("/root"),
313 json!({
314 "small_file.txt": "This is a small file content"
315 }),
316 )
317 .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 tool = Arc::new(ReadFileTool::new(project, action_log));
321 let result = cx
322 .update(|cx| {
323 let input = ReadFileToolInput {
324 path: "root/small_file.txt".into(),
325 start_line: None,
326 end_line: None,
327 };
328 tool.run(input, ToolCallEventStream::test().0, cx)
329 })
330 .await;
331 assert_eq!(result.unwrap(), "This is a small file content".into());
332 }
333
334 #[gpui::test]
335 async fn test_read_large_file(cx: &mut TestAppContext) {
336 init_test(cx);
337
338 let fs = FakeFs::new(cx.executor());
339 fs.insert_tree(
340 path!("/root"),
341 json!({
342 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
343 }),
344 )
345 .await;
346 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
347 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
348 language_registry.add(Arc::new(rust_lang()));
349 let action_log = cx.new(|_| ActionLog::new(project.clone()));
350 let tool = Arc::new(ReadFileTool::new(project, action_log));
351 let result = cx
352 .update(|cx| {
353 let input = ReadFileToolInput {
354 path: "root/large_file.rs".into(),
355 start_line: None,
356 end_line: None,
357 };
358 tool.clone().run(input, ToolCallEventStream::test().0, cx)
359 })
360 .await
361 .unwrap();
362 let content = result.to_str().unwrap();
363
364 assert_eq!(
365 content.lines().skip(4).take(6).collect::<Vec<_>>(),
366 vec![
367 "struct Test0 [L1-4]",
368 " a [L2]",
369 " b [L3]",
370 "struct Test1 [L5-8]",
371 " a [L6]",
372 " b [L7]",
373 ]
374 );
375
376 let result = cx
377 .update(|cx| {
378 let input = ReadFileToolInput {
379 path: "root/large_file.rs".into(),
380 start_line: None,
381 end_line: None,
382 };
383 tool.run(input, ToolCallEventStream::test().0, cx)
384 })
385 .await
386 .unwrap();
387 let content = result.to_str().unwrap();
388 let expected_content = (0..1000)
389 .flat_map(|i| {
390 vec![
391 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
392 format!(" a [L{}]", i * 4 + 2),
393 format!(" b [L{}]", i * 4 + 3),
394 ]
395 })
396 .collect::<Vec<_>>();
397 pretty_assertions::assert_eq!(
398 content
399 .lines()
400 .skip(4)
401 .take(expected_content.len())
402 .collect::<Vec<_>>(),
403 expected_content
404 );
405 }
406
407 #[gpui::test]
408 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
409 init_test(cx);
410
411 let fs = FakeFs::new(cx.executor());
412 fs.insert_tree(
413 path!("/root"),
414 json!({
415 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
416 }),
417 )
418 .await;
419 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
420
421 let action_log = cx.new(|_| ActionLog::new(project.clone()));
422 let tool = Arc::new(ReadFileTool::new(project, action_log));
423 let result = cx
424 .update(|cx| {
425 let input = ReadFileToolInput {
426 path: "root/multiline.txt".to_string(),
427 start_line: Some(2),
428 end_line: Some(4),
429 };
430 tool.run(input, ToolCallEventStream::test().0, cx)
431 })
432 .await;
433 assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into());
434 }
435
436 #[gpui::test]
437 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
438 init_test(cx);
439
440 let fs = FakeFs::new(cx.executor());
441 fs.insert_tree(
442 path!("/root"),
443 json!({
444 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
445 }),
446 )
447 .await;
448 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
449 let action_log = cx.new(|_| ActionLog::new(project.clone()));
450 let tool = Arc::new(ReadFileTool::new(project, action_log));
451
452 // start_line of 0 should be treated as 1
453 let result = cx
454 .update(|cx| {
455 let input = ReadFileToolInput {
456 path: "root/multiline.txt".to_string(),
457 start_line: Some(0),
458 end_line: Some(2),
459 };
460 tool.clone().run(input, ToolCallEventStream::test().0, cx)
461 })
462 .await;
463 assert_eq!(result.unwrap(), "Line 1\nLine 2".into());
464
465 // end_line of 0 should result in at least 1 line
466 let result = cx
467 .update(|cx| {
468 let input = ReadFileToolInput {
469 path: "root/multiline.txt".to_string(),
470 start_line: Some(1),
471 end_line: Some(0),
472 };
473 tool.clone().run(input, ToolCallEventStream::test().0, cx)
474 })
475 .await;
476 assert_eq!(result.unwrap(), "Line 1".into());
477
478 // when start_line > end_line, should still return at least 1 line
479 let result = cx
480 .update(|cx| {
481 let input = ReadFileToolInput {
482 path: "root/multiline.txt".to_string(),
483 start_line: Some(3),
484 end_line: Some(2),
485 };
486 tool.clone().run(input, ToolCallEventStream::test().0, cx)
487 })
488 .await;
489 assert_eq!(result.unwrap(), "Line 3".into());
490 }
491
492 fn init_test(cx: &mut TestAppContext) {
493 cx.update(|cx| {
494 let settings_store = SettingsStore::test(cx);
495 cx.set_global(settings_store);
496 language::init(cx);
497 Project::init_settings(cx);
498 });
499 }
500
501 fn rust_lang() -> Language {
502 Language::new(
503 LanguageConfig {
504 name: "Rust".into(),
505 matcher: LanguageMatcher {
506 path_suffixes: vec!["rs".to_string()],
507 ..Default::default()
508 },
509 ..Default::default()
510 },
511 Some(tree_sitter_rust::LANGUAGE.into()),
512 )
513 .with_outline_query(
514 r#"
515 (line_comment) @annotation
516
517 (struct_item
518 "struct" @context
519 name: (_) @name) @item
520 (enum_item
521 "enum" @context
522 name: (_) @name) @item
523 (enum_variant
524 name: (_) @name) @item
525 (field_declaration
526 name: (_) @name) @item
527 (impl_item
528 "impl" @context
529 trait: (_)? @name
530 "for"? @context
531 type: (_) @name
532 body: (_ "{" (_)* "}")) @item
533 (function_item
534 "fn" @context
535 name: (_) @name) @item
536 (mod_item
537 "mod" @context
538 name: (_) @name) @item
539 "#,
540 )
541 .unwrap()
542 }
543
544 #[gpui::test]
545 async fn test_read_file_security(cx: &mut TestAppContext) {
546 init_test(cx);
547
548 let fs = FakeFs::new(cx.executor());
549
550 fs.insert_tree(
551 path!("/"),
552 json!({
553 "project_root": {
554 "allowed_file.txt": "This file is in the project",
555 ".mysecrets": "SECRET_KEY=abc123",
556 ".secretdir": {
557 "config": "special configuration"
558 },
559 ".mymetadata": "custom metadata",
560 "subdir": {
561 "normal_file.txt": "Normal file content",
562 "special.privatekey": "private key content",
563 "data.mysensitive": "sensitive data"
564 }
565 },
566 "outside_project": {
567 "sensitive_file.txt": "This file is outside the project"
568 }
569 }),
570 )
571 .await;
572
573 cx.update(|cx| {
574 use gpui::UpdateGlobal;
575 use project::WorktreeSettings;
576 use settings::SettingsStore;
577 SettingsStore::update_global(cx, |store, cx| {
578 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
579 settings.file_scan_exclusions = Some(vec![
580 "**/.secretdir".to_string(),
581 "**/.mymetadata".to_string(),
582 ]);
583 settings.private_files = Some(vec![
584 "**/.mysecrets".to_string(),
585 "**/*.privatekey".to_string(),
586 "**/*.mysensitive".to_string(),
587 ]);
588 });
589 });
590 });
591
592 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
593 let action_log = cx.new(|_| ActionLog::new(project.clone()));
594 let tool = Arc::new(ReadFileTool::new(project, action_log));
595
596 // Reading a file outside the project worktree should fail
597 let result = cx
598 .update(|cx| {
599 let input = ReadFileToolInput {
600 path: "/outside_project/sensitive_file.txt".to_string(),
601 start_line: None,
602 end_line: None,
603 };
604 tool.clone().run(input, ToolCallEventStream::test().0, cx)
605 })
606 .await;
607 assert!(
608 result.is_err(),
609 "read_file_tool should error when attempting to read an absolute path outside a worktree"
610 );
611
612 // Reading a file within the project should succeed
613 let result = cx
614 .update(|cx| {
615 let input = ReadFileToolInput {
616 path: "project_root/allowed_file.txt".to_string(),
617 start_line: None,
618 end_line: None,
619 };
620 tool.clone().run(input, ToolCallEventStream::test().0, cx)
621 })
622 .await;
623 assert!(
624 result.is_ok(),
625 "read_file_tool should be able to read files inside worktrees"
626 );
627
628 // Reading files that match file_scan_exclusions should fail
629 let result = cx
630 .update(|cx| {
631 let input = ReadFileToolInput {
632 path: "project_root/.secretdir/config".to_string(),
633 start_line: None,
634 end_line: None,
635 };
636 tool.clone().run(input, ToolCallEventStream::test().0, cx)
637 })
638 .await;
639 assert!(
640 result.is_err(),
641 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
642 );
643
644 let result = cx
645 .update(|cx| {
646 let input = ReadFileToolInput {
647 path: "project_root/.mymetadata".to_string(),
648 start_line: None,
649 end_line: None,
650 };
651 tool.clone().run(input, ToolCallEventStream::test().0, cx)
652 })
653 .await;
654 assert!(
655 result.is_err(),
656 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
657 );
658
659 // Reading private files should fail
660 let result = cx
661 .update(|cx| {
662 let input = ReadFileToolInput {
663 path: "project_root/.mysecrets".to_string(),
664 start_line: None,
665 end_line: None,
666 };
667 tool.clone().run(input, ToolCallEventStream::test().0, cx)
668 })
669 .await;
670 assert!(
671 result.is_err(),
672 "read_file_tool should error when attempting to read .mysecrets (private_files)"
673 );
674
675 let result = cx
676 .update(|cx| {
677 let input = ReadFileToolInput {
678 path: "project_root/subdir/special.privatekey".to_string(),
679 start_line: None,
680 end_line: None,
681 };
682 tool.clone().run(input, ToolCallEventStream::test().0, cx)
683 })
684 .await;
685 assert!(
686 result.is_err(),
687 "read_file_tool should error when attempting to read .privatekey files (private_files)"
688 );
689
690 let result = cx
691 .update(|cx| {
692 let input = ReadFileToolInput {
693 path: "project_root/subdir/data.mysensitive".to_string(),
694 start_line: None,
695 end_line: None,
696 };
697 tool.clone().run(input, ToolCallEventStream::test().0, cx)
698 })
699 .await;
700 assert!(
701 result.is_err(),
702 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
703 );
704
705 // Reading a normal file should still work, even with private_files configured
706 let result = cx
707 .update(|cx| {
708 let input = ReadFileToolInput {
709 path: "project_root/subdir/normal_file.txt".to_string(),
710 start_line: None,
711 end_line: None,
712 };
713 tool.clone().run(input, ToolCallEventStream::test().0, cx)
714 })
715 .await;
716 assert!(result.is_ok(), "Should be able to read normal files");
717 assert_eq!(result.unwrap(), "Normal file content".into());
718
719 // Path traversal attempts with .. should fail
720 let result = cx
721 .update(|cx| {
722 let input = ReadFileToolInput {
723 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
724 start_line: None,
725 end_line: None,
726 };
727 tool.run(input, ToolCallEventStream::test().0, cx)
728 })
729 .await;
730 assert!(
731 result.is_err(),
732 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
733 );
734 }
735
736 #[gpui::test]
737 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
738 init_test(cx);
739
740 let fs = FakeFs::new(cx.executor());
741
742 // Create first worktree with its own private_files setting
743 fs.insert_tree(
744 path!("/worktree1"),
745 json!({
746 "src": {
747 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
748 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
749 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
750 },
751 "tests": {
752 "test.rs": "mod tests { fn test_it() {} }",
753 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
754 },
755 ".zed": {
756 "settings.json": r#"{
757 "file_scan_exclusions": ["**/fixture.*"],
758 "private_files": ["**/secret.rs", "**/config.toml"]
759 }"#
760 }
761 }),
762 )
763 .await;
764
765 // Create second worktree with different private_files setting
766 fs.insert_tree(
767 path!("/worktree2"),
768 json!({
769 "lib": {
770 "public.js": "export function greet() { return 'Hello from worktree2'; }",
771 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
772 "data.json": "{\"api_key\": \"json_secret_key\"}"
773 },
774 "docs": {
775 "README.md": "# Public Documentation",
776 "internal.md": "# Internal Secrets and Configuration"
777 },
778 ".zed": {
779 "settings.json": r#"{
780 "file_scan_exclusions": ["**/internal.*"],
781 "private_files": ["**/private.js", "**/data.json"]
782 }"#
783 }
784 }),
785 )
786 .await;
787
788 // Set global settings
789 cx.update(|cx| {
790 SettingsStore::update_global(cx, |store, cx| {
791 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
792 settings.file_scan_exclusions =
793 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
794 settings.private_files = Some(vec!["**/.env".to_string()]);
795 });
796 });
797 });
798
799 let project = Project::test(
800 fs.clone(),
801 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
802 cx,
803 )
804 .await;
805
806 let action_log = cx.new(|_| ActionLog::new(project.clone()));
807 let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
808
809 // Test reading allowed files in worktree1
810 let result = cx
811 .update(|cx| {
812 let input = ReadFileToolInput {
813 path: "worktree1/src/main.rs".to_string(),
814 start_line: None,
815 end_line: None,
816 };
817 tool.clone().run(input, ToolCallEventStream::test().0, cx)
818 })
819 .await
820 .unwrap();
821
822 assert_eq!(
823 result,
824 "fn main() { println!(\"Hello from worktree1\"); }".into()
825 );
826
827 // Test reading private file in worktree1 should fail
828 let result = cx
829 .update(|cx| {
830 let input = ReadFileToolInput {
831 path: "worktree1/src/secret.rs".to_string(),
832 start_line: None,
833 end_line: None,
834 };
835 tool.clone().run(input, ToolCallEventStream::test().0, cx)
836 })
837 .await;
838
839 assert!(result.is_err());
840 assert!(
841 result
842 .unwrap_err()
843 .to_string()
844 .contains("worktree `private_files` setting"),
845 "Error should mention worktree private_files setting"
846 );
847
848 // Test reading excluded file in worktree1 should fail
849 let result = cx
850 .update(|cx| {
851 let input = ReadFileToolInput {
852 path: "worktree1/tests/fixture.sql".to_string(),
853 start_line: None,
854 end_line: None,
855 };
856 tool.clone().run(input, ToolCallEventStream::test().0, cx)
857 })
858 .await;
859
860 assert!(result.is_err());
861 assert!(
862 result
863 .unwrap_err()
864 .to_string()
865 .contains("worktree `file_scan_exclusions` setting"),
866 "Error should mention worktree file_scan_exclusions setting"
867 );
868
869 // Test reading allowed files in worktree2
870 let result = cx
871 .update(|cx| {
872 let input = ReadFileToolInput {
873 path: "worktree2/lib/public.js".to_string(),
874 start_line: None,
875 end_line: None,
876 };
877 tool.clone().run(input, ToolCallEventStream::test().0, cx)
878 })
879 .await
880 .unwrap();
881
882 assert_eq!(
883 result,
884 "export function greet() { return 'Hello from worktree2'; }".into()
885 );
886
887 // Test reading private file in worktree2 should fail
888 let result = cx
889 .update(|cx| {
890 let input = ReadFileToolInput {
891 path: "worktree2/lib/private.js".to_string(),
892 start_line: None,
893 end_line: None,
894 };
895 tool.clone().run(input, ToolCallEventStream::test().0, cx)
896 })
897 .await;
898
899 assert!(result.is_err());
900 assert!(
901 result
902 .unwrap_err()
903 .to_string()
904 .contains("worktree `private_files` setting"),
905 "Error should mention worktree private_files setting"
906 );
907
908 // Test reading excluded file in worktree2 should fail
909 let result = cx
910 .update(|cx| {
911 let input = ReadFileToolInput {
912 path: "worktree2/docs/internal.md".to_string(),
913 start_line: None,
914 end_line: None,
915 };
916 tool.clone().run(input, ToolCallEventStream::test().0, cx)
917 })
918 .await;
919
920 assert!(result.is_err());
921 assert!(
922 result
923 .unwrap_err()
924 .to_string()
925 .contains("worktree `file_scan_exclusions` setting"),
926 "Error should mention worktree file_scan_exclusions setting"
927 );
928
929 // Test that files allowed in one worktree but not in another are handled correctly
930 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
931 let result = cx
932 .update(|cx| {
933 let input = ReadFileToolInput {
934 path: "worktree1/src/config.toml".to_string(),
935 start_line: None,
936 end_line: None,
937 };
938 tool.clone().run(input, ToolCallEventStream::test().0, cx)
939 })
940 .await;
941
942 assert!(result.is_err());
943 assert!(
944 result
945 .unwrap_err()
946 .to_string()
947 .contains("worktree `private_files` setting"),
948 "Config.toml should be blocked by worktree1's private_files setting"
949 );
950 }
951}