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