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