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