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