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