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