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