1use crate::agent::extension::{Extension, ToolDefinition};
2use crate::agent::extension::{ToolRenderContext, ToolRenderer};
3use crate::tui::Theme;
4use crate::tui::ThemeKey;
5
6use base64::Engine as _;
7use std::borrow::Cow;
8use std::path::Path;
9use std::sync::Arc;
10use unicode_normalization::UnicodeNormalization;
11
12pub trait ReadOperations: Send + Sync {
17 fn read_file(&self, absolute_path: &Path) -> anyhow::Result<Vec<u8>>;
19 fn file_size(&self, absolute_path: &Path) -> anyhow::Result<u64>;
21 fn detect_image_mime(&self, absolute_path: &Path) -> anyhow::Result<Option<&'static str>>;
23 fn read_text_file(&self, absolute_path: &Path) -> anyhow::Result<String>;
25}
26
27struct DefaultReadOperations;
28
29impl ReadOperations for DefaultReadOperations {
30 fn read_file(&self, absolute_path: &Path) -> anyhow::Result<Vec<u8>> {
31 Ok(std::fs::read(absolute_path)?)
32 }
33 fn file_size(&self, absolute_path: &Path) -> anyhow::Result<u64> {
34 Ok(std::fs::metadata(absolute_path)?.len())
35 }
36 fn detect_image_mime(&self, absolute_path: &Path) -> anyhow::Result<Option<&'static str>> {
37 detect_image_mime(absolute_path).map_err(anyhow::Error::from)
38 }
39 fn read_text_file(&self, absolute_path: &Path) -> anyhow::Result<String> {
40 Ok(std::fs::read_to_string(absolute_path)?)
41 }
42}
43
44pub struct ReadExtension {
45 cwd: std::path::PathBuf,
46 operations: Arc<dyn ReadOperations>,
47}
48
49impl ReadExtension {
50 pub fn new(cwd: std::path::PathBuf) -> Self {
51 Self {
52 cwd,
53 operations: Arc::new(DefaultReadOperations),
54 }
55 }
56
57 pub fn with_operations(mut self, operations: Arc<dyn ReadOperations>) -> Self {
59 self.operations = operations;
60 self
61 }
62}
63
64impl Extension for ReadExtension {
65 fn name(&self) -> Cow<'static, str> {
66 "read".into()
67 }
68
69 fn as_any(&self) -> &dyn std::any::Any {
70 self
71 }
72
73 fn tools(&self) -> Vec<ToolDefinition> {
74 vec![ToolDefinition {
75 tool: Box::new(ReadTool {
76 cwd: self.cwd.clone(),
77 operations: self.operations.clone(),
78 }),
79 snippet: "Read file contents",
80 guidelines: &["Use read to examine files instead of cat or sed."],
81 prepare_arguments: None,
82 before_tool_call: None,
83 after_tool_call: None,
84 renderer: Some(std::sync::Arc::new(ReadRenderer {
85 cwd: self.cwd.clone(),
86 })),
87 }]
88 }
89}
90
91struct ReadTool {
92 cwd: std::path::PathBuf,
93 operations: Arc<dyn ReadOperations>,
94}
95
96const DEFAULT_MAX_LINES: usize = 2000;
99const DEFAULT_MAX_BYTES: usize = 50 * 1024; fn format_size(bytes: usize) -> String {
105 if bytes < 1024 {
106 format!("{}B", bytes)
107 } else if bytes < 1024 * 1024 {
108 format!("{:.1}KB", bytes as f64 / 1024.0)
109 } else {
110 format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
111 }
112}
113
114fn trim_trailing_empty_lines<'a>(lines: &'a [&'a str]) -> &'a [&'a str] {
116 let mut end = lines.len();
117 while end > 0 && lines[end - 1].is_empty() {
118 end -= 1;
119 }
120 &lines[..end]
121}
122
123fn try_macos_am_pm_variant(path: &str) -> Option<String> {
127 let narrow_nbsp = "\u{202F}";
130 if path.contains(" AM") || path.contains(" PM") {
131 let variant = path
132 .replace(" AM", &format!("{}AM", narrow_nbsp))
133 .replace(" PM", &format!("{}PM", narrow_nbsp));
134 if variant != path && std::path::Path::new(&variant).exists() {
135 return Some(variant);
136 }
137 }
138 None
139}
140
141fn try_nfd_variant(path: &str) -> Option<String> {
143 let nfd: String = path.nfkd().collect();
146 if nfd != path && std::path::Path::new(&nfd).exists() {
147 return Some(nfd);
148 }
149 None
150}
151
152fn try_curly_quote_variant(path: &str) -> Option<String> {
154 let variant = path.replace('\'', "\u{2019}");
157 if variant != path && std::path::Path::new(&variant).exists() {
158 return Some(variant);
159 }
160 None
161}
162
163fn resolve_read_path(path: &str, cwd: &Path) -> std::path::PathBuf {
166 let resolved = {
167 let p = std::path::Path::new(path);
168 if p.is_absolute() {
169 p.to_path_buf()
170 } else {
171 cwd.join(p)
172 }
173 };
174
175 if resolved.exists() {
176 return resolved;
177 }
178
179 let resolved_str = resolved.to_string_lossy();
180
181 if let Some(variant) = try_macos_am_pm_variant(&resolved_str) {
183 return std::path::PathBuf::from(variant);
184 }
185
186 if let Some(variant) = try_nfd_variant(&resolved_str) {
188 return std::path::PathBuf::from(variant);
189 }
190
191 if let Some(variant) = try_curly_quote_variant(&resolved_str) {
193 return std::path::PathBuf::from(variant);
194 }
195
196 resolved
197}
198
199#[allow(clippy::redundant_guards)]
205fn detect_image_mime(path: &Path) -> std::io::Result<Option<&'static str>> {
206 use std::io::Read;
207 let mut file = std::fs::File::open(path)?;
208 let mut buf = [0u8; 12];
209 let n = file.read(&mut buf)?;
210 if n < 4 {
211 return Ok(None);
212 }
213 Ok(match &buf[..n] {
214 b if b.starts_with(b"\x89PNG\r\n\x1a\n") && n >= 8 => Some("image/png"),
215 b if b.starts_with(&[0xFF, 0xD8, 0xFF]) => Some("image/jpeg"),
216 b if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") => Some("image/gif"),
217 b if n >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP" => Some("image/webp"),
218 b if b.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => Some("image/png"),
219 _ => None,
220 })
221}
222
223#[derive(Debug, PartialEq)]
225enum CompactReadKind {
226 Resource,
227 Skill,
228}
229
230fn get_compact_read_classification(path: &str, cwd: &Path) -> Option<(CompactReadKind, String)> {
233 let abs_path = if Path::new(path).is_absolute() {
234 Path::new(path).to_path_buf()
235 } else {
236 cwd.join(path)
237 };
238
239 let file_name = abs_path.file_name()?.to_str()?;
240
241 if file_name.eq_ignore_ascii_case("AGENTS.md") || file_name.eq_ignore_ascii_case("CLAUDE.md") {
243 let display = abs_path
244 .strip_prefix(cwd)
245 .unwrap_or(&abs_path)
246 .to_string_lossy()
247 .to_string();
248 return Some((CompactReadKind::Resource, display));
249 }
250
251 if file_name == "SKILL.md"
253 && let Some(parent) = abs_path.parent()
254 && let Some(dir_name) = parent.file_name()
255 {
256 let dir_name = dir_name.to_str().unwrap_or("unknown");
257 return Some((CompactReadKind::Skill, dir_name.to_string()));
258 }
259
260 None
261}
262
263struct TruncationResult {
267 content: String,
268 truncated: bool,
269 truncated_by: Option<&'static str>, output_lines: usize,
271 total_lines: usize,
272 first_line_exceeds_limit: bool,
273}
274
275fn truncate_head(content: &str, max_lines: usize, max_bytes: usize) -> TruncationResult {
279 let total_bytes = content.len();
280 let lines: Vec<&str> = content.lines().collect();
281 let total_lines = lines.len();
282
283 if total_lines <= max_lines && total_bytes <= max_bytes {
285 return TruncationResult {
286 content: content.to_string(),
287 truncated: false,
288 truncated_by: None,
289 output_lines: total_lines,
290 total_lines,
291 first_line_exceeds_limit: false,
292 };
293 }
294
295 if let Some(first) = lines.first()
297 && first.len() > max_bytes
298 {
299 return TruncationResult {
300 content: String::new(),
301 truncated: true,
302 truncated_by: Some("bytes"),
303 output_lines: 0,
304 total_lines,
305 first_line_exceeds_limit: true,
306 };
307 }
308
309 let mut output: Vec<&str> = Vec::new();
311 let mut byte_count: usize = 0;
312 let mut truncated_by = "lines";
313
314 for line in lines.iter().take(max_lines) {
315 let line_bytes = line.len();
316 let with_newline = if output.is_empty() {
317 line_bytes
318 } else {
319 line_bytes + 1 };
321
322 if byte_count + with_newline > max_bytes {
323 truncated_by = "bytes";
324 break;
325 }
326
327 output.push(line);
328 byte_count += with_newline;
329 }
330
331 if output.len() >= max_lines && byte_count <= max_bytes {
332 truncated_by = "lines";
333 }
334
335 TruncationResult {
336 content: output.join("\n"),
337 truncated: true,
338 truncated_by: Some(truncated_by),
339 output_lines: output.len(),
340 total_lines,
341 first_line_exceeds_limit: false,
342 }
343}
344
345#[async_trait::async_trait]
346impl yoagent::types::AgentTool for ReadTool {
347 fn name(&self) -> &str {
348 "read"
349 }
350 fn label(&self) -> &str {
351 "read"
352 }
353 fn description(&self) -> &str {
354 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). \
355 Images are sent as attachments. For text files, output is truncated to 2000 lines or \
356 50KB (whichever is hit first). Use offset/limit for large files. When you need the \
357 full file, continue with offset until complete."
358 }
359 fn parameters_schema(&self) -> serde_json::Value {
360 serde_json::json!({
361 "type": "object",
362 "required": ["path"],
363 "properties": {
364 "path": {
365 "type": "string",
366 "description": "Path to the file to read (relative or absolute)"
367 },
368 "offset": {
369 "type": "number",
370 "description": "Line number to start reading from (1-indexed)"
371 },
372 "limit": {
373 "type": "number",
374 "description": "Maximum number of lines to read"
375 }
376 }
377 })
378 }
379 async fn execute(
380 &self,
381 params: serde_json::Value,
382 ctx: yoagent::types::ToolContext,
383 ) -> std::result::Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
384 let path = params["path"].as_str().ok_or_else(|| {
385 yoagent::types::ToolError::InvalidArgs("Missing 'path' argument".into())
386 })?;
387 let offset = params["offset"].as_u64().map(|o| o as usize).unwrap_or(0);
388 let limit = params["limit"].as_u64().map(|l| l as usize);
389
390 let abs_path = resolve_read_path(path, &self.cwd);
391
392 if ctx.cancel.is_cancelled() {
393 return Err(yoagent::types::ToolError::Cancelled);
394 }
395
396 if let Ok(Some(mime)) = self.operations.detect_image_mime(&abs_path) {
398 let file_name = abs_path
399 .file_name()
400 .map(|n| n.to_string_lossy())
401 .unwrap_or_default()
402 .to_string();
403 let file_len = self.operations.file_size(&abs_path).unwrap_or(0) as usize;
404 let binary = self.operations.read_file(&abs_path).map_err(|e| {
405 yoagent::types::ToolError::Failed(format!(
406 "Failed to read image {}: {}",
407 abs_path.display(),
408 e
409 ))
410 })?;
411 let b64 = base64::engine::general_purpose::STANDARD.encode(&binary);
412 let msg = format!(
413 "Read image file [{}] - {} ({})\n{}:{};base64,{}",
414 mime,
415 file_name,
416 format_size(file_len),
417 mime,
418 file_name,
419 b64,
420 );
421 return Ok(yoagent::types::ToolResult {
422 content: vec![yoagent::types::Content::Text { text: msg }],
423 details: serde_json::json!({
424 "mimeType": mime,
425 "fileName": file_name,
426 "fileSize": file_len,
427 "imageData": b64,
428 }),
429 });
430 }
431
432 let content = self.operations.read_text_file(&abs_path).map_err(|e| {
433 yoagent::types::ToolError::Failed(format!(
434 "Failed to read {}: {}",
435 abs_path.display(),
436 e
437 ))
438 })?;
439
440 let all_lines: Vec<&str> = content.split('\n').collect();
441 let total_file_lines = if content.ends_with('\n') {
442 all_lines.len() - 1
443 } else {
444 all_lines.len()
445 };
446
447 let start_line = if offset > 0 { offset - 1 } else { 0 };
448 if start_line >= total_file_lines {
449 return Err(yoagent::types::ToolError::Failed(format!(
450 "Offset {} is beyond end of file ({} lines total)",
451 offset, total_file_lines
452 )));
453 }
454
455 if ctx.cancel.is_cancelled() {
456 return Err(yoagent::types::ToolError::Cancelled);
457 }
458
459 let selected_content: String;
460 let user_limited_lines: Option<usize>;
461
462 if let Some(lim) = limit {
463 let end_line = (start_line + lim).min(total_file_lines);
464 let selected_lines = &all_lines[start_line..end_line];
465 selected_content = selected_lines.join("\n");
466 user_limited_lines = Some(end_line - start_line);
467 } else {
468 let selected_lines = &all_lines[start_line..];
469 selected_content = selected_lines.join("\n");
470 user_limited_lines = None;
471 }
472
473 let trunc = truncate_head(&selected_content, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
474
475 if trunc.first_line_exceeds_limit {
476 let first_line_bytes = format_size(all_lines[start_line].len());
477 let msg = format!(
478 "[Line {} is {}, exceeds {} limit. Use bash: sed -n '{}p' {} | head -c {}]",
479 start_line + 1,
480 first_line_bytes,
481 format_size(DEFAULT_MAX_BYTES),
482 start_line + 1,
483 path,
484 DEFAULT_MAX_BYTES,
485 );
486 return Ok(yoagent::types::ToolResult {
487 content: vec![yoagent::types::Content::Text { text: msg }],
488 details: serde_json::json!({
489 "truncation": {
490 "truncated": true,
491 "truncatedBy": "bytes",
492 "totalLines": trunc.total_lines,
493 "outputLines": 0,
494 "firstLineExceedsLimit": true,
495 "maxLines": DEFAULT_MAX_LINES,
496 "maxBytes": DEFAULT_MAX_BYTES,
497 }
498 }),
499 });
500 }
501
502 let output: String;
503 let mut details: Option<serde_json::Value> = None;
504
505 if trunc.truncated {
506 let start_display = start_line + 1;
507 let end_display = start_display + trunc.output_lines - 1;
508 let next_offset = end_display + 1;
509
510 if trunc.truncated_by == Some("lines") {
511 output = format!(
512 "{}\n\n[Showing lines {}-{} of {}. Use offset={} to continue.]",
513 trunc.content, start_display, end_display, total_file_lines, next_offset,
514 );
515 } else {
516 output = format!(
517 "{}\n\n[Showing lines {}-{} of {} ({} limit). Use offset={} to continue.]",
518 trunc.content,
519 start_display,
520 end_display,
521 total_file_lines,
522 format_size(DEFAULT_MAX_BYTES),
523 next_offset,
524 );
525 }
526 details = Some(serde_json::json!({
527 "truncation": {
528 "truncated": true,
529 "truncatedBy": trunc.truncated_by,
530 "totalLines": trunc.total_lines,
531 "outputLines": trunc.output_lines,
532 "firstLineExceedsLimit": false,
533 "maxLines": DEFAULT_MAX_LINES,
534 "maxBytes": DEFAULT_MAX_BYTES,
535 }
536 }));
537 } else if let Some(ul) = user_limited_lines {
538 if start_line + ul < total_file_lines {
539 let remaining = total_file_lines - (start_line + ul);
540 let next_offset = start_line + ul + 1;
541 output = format!(
542 "{}\n\n[{} more lines in file. Use offset={} to continue.]",
543 trunc.content, remaining, next_offset,
544 );
545 } else {
546 let lines: Vec<&str> = trunc.content.lines().collect();
547 let trimmed = trim_trailing_empty_lines(&lines);
548 output = trimmed.join("\n");
549 }
550 } else {
551 let lines: Vec<&str> = trunc.content.lines().collect();
552 let trimmed = trim_trailing_empty_lines(&lines);
553 output = trimmed.join("\n");
554 }
555
556 Ok(yoagent::types::ToolResult {
557 content: vec![yoagent::types::Content::Text { text: output }],
558 details: details.unwrap_or(serde_json::Value::Null),
559 })
560 }
561}
562
563struct ReadRenderer {
566 cwd: std::path::PathBuf,
567}
568
569impl ToolRenderer for ReadRenderer {
570 fn render_call(
571 &self,
572 args: &serde_json::Value,
573 _width: usize,
574 theme: &dyn Theme,
575 ctx: &ToolRenderContext,
576 ) -> Vec<String> {
577 use std::path::Path;
578 let path = args
579 .get("file_path")
580 .or_else(|| args.get("path"))
581 .and_then(|v| v.as_str())
582 .unwrap_or("");
583 let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
584 let limit = args.get("limit").and_then(|v| v.as_u64());
585
586 let classification = if !ctx.expanded {
588 get_compact_read_classification(path, Path::new(&self.cwd))
589 } else {
590 None
591 };
592
593 let range = if offset > 0 || limit.is_some() {
595 let start = if offset > 0 { offset } else { 1 };
596 let range_str = match limit {
597 Some(l) => format!(":{}-{}", start, start + l - 1),
598 None => format!(":{}", start),
599 };
600 theme.fg_key(ThemeKey::Warning, &range_str)
601 } else {
602 String::new()
603 };
604
605 let expand_hint = if !ctx.expanded && !ctx.expand_key.is_empty() {
607 theme.fg_key(ThemeKey::Dim, &format!(" ({} to expand)", ctx.expand_key))
608 } else {
609 String::new()
610 };
611
612 if let Some((kind, label)) = classification {
613 match kind {
614 CompactReadKind::Skill => {
615 let prefix =
618 theme.fg_key(ThemeKey::CustomMessageLabel, "\x1b[1m[skill]\x1b[22m ");
619 let name = theme.fg_key(ThemeKey::CustomMessageText, &label);
620 vec![format!("{}{}{}{}", prefix, name, range, expand_hint)]
621 }
622 CompactReadKind::Resource => {
623 let title_styled =
626 theme.fg_key(ThemeKey::ToolTitle, &theme.bold("read resource"));
627 let path_styled = theme.fg_key(ThemeKey::Accent, &label);
628 vec![format!(
629 "{} {}{}{}",
630 title_styled, path_styled, range, expand_hint
631 )]
632 }
633 }
634 } else {
635 let short = if let Ok(home) = std::env::var("HOME") {
637 path.replacen(&home, "~", 1)
638 } else {
639 path.to_string()
640 };
641 let path_disp = if short.is_empty() {
642 String::new()
643 } else {
644 theme.fg_key(ThemeKey::Accent, &short)
645 };
646 vec![format!(
647 "{} {}{}",
648 theme.fg_key(ThemeKey::ToolTitle, &theme.bold("read")),
649 path_disp,
650 range,
651 )]
652 }
653 }
654
655 fn render_result(
656 &self,
657 content: &str,
658 _width: usize,
659 theme: &dyn Theme,
660 ctx: &ToolRenderContext,
661 ) -> Vec<String> {
662 if content.is_empty() {
663 return vec![];
664 }
665
666 if !ctx.expanded && !ctx.is_error {
668 return vec![];
669 }
670
671 if let Some(ref details) = ctx.details
673 && let Some(mime) = details.get("mimeType").and_then(|v| v.as_str())
674 {
675 let file_name = details
676 .get("fileName")
677 .and_then(|v| v.as_str())
678 .unwrap_or("");
679 let file_size = details
680 .get("fileSize")
681 .and_then(|v| v.as_u64())
682 .unwrap_or(0);
683 let size_str = format_size(file_size as usize);
684
685 if crate::tui::components::markdown::kitty_images_supported()
687 && let Some(b64) = details.get("imageData").and_then(|v| v.as_str())
688 && let Ok(binary) = crate::builtin::base64_decode(b64)
689 {
690 let kitty_seq =
691 crate::tui::components::markdown::kitty_image_sequence(&binary, mime);
692 return vec![
693 String::new(),
694 kitty_seq,
695 theme.fg_key(
696 ThemeKey::ToolOutput,
697 &format!("Read image file [{}] - {} ({})", mime, file_name, size_str),
698 ),
699 ];
700 }
701
702 return vec![
704 String::new(),
705 theme.fg_key(ThemeKey::ToolOutput, &format!("Read image file [{}]", mime)),
706 theme.fg_key(ThemeKey::ToolOutput, &format!(" File: {}", file_name)),
707 theme.fg_key(ThemeKey::ToolOutput, &format!(" Size: {}", size_str)),
708 ];
709 }
710
711 let path = ctx.file_path.as_deref().unwrap_or("");
712 let lang = if !path.is_empty() {
713 crate::tui::components::path_to_language(path)
714 } else {
715 None
716 };
717
718 let all_lines: Vec<&str> = content.lines().collect();
720 let mut end = all_lines.len();
721 while end > 0 && all_lines[end - 1].is_empty() {
722 end -= 1;
723 }
724 let trimmed_lines = &all_lines[..end];
725
726 let max_lines = if ctx.expanded { usize::MAX } else { 10 };
728 let display_lines: Vec<&str> = trimmed_lines.iter().copied().take(max_lines).collect();
729 let remaining = trimmed_lines.len().saturating_sub(display_lines.len());
730
731 let mut result = vec![String::new()];
733
734 #[cfg(feature = "syntect")]
736 {
737 if let Some(lang) = lang {
738 let combined = display_lines.join("\n");
739 let highlighted = crate::tui::components::highlight_code(&combined, Some(lang));
740 for line in highlighted {
741 result.push(line.replace('\t', " "));
742 }
743 } else {
744 for line in &display_lines {
745 let processed = line.replace('\t', " ");
746 result.push(theme.fg_key(ThemeKey::ToolOutput, &processed));
747 }
748 }
749 }
750
751 #[cfg(not(feature = "syntect"))]
752 for line in &display_lines {
753 let processed = line.replace('\t', " ");
754 result.push(theme.fg_key(ThemeKey::ToolOutput, &processed));
755 }
756
757 if remaining > 0 {
759 let hint = if !ctx.expand_key.is_empty() {
760 format!(
761 "... ({} more lines, {} to expand)",
762 remaining, ctx.expand_key
763 )
764 } else {
765 format!("... ({} more lines)", remaining)
766 };
767 result.push(theme.fg_key(ThemeKey::Muted, &hint));
768 }
769
770 if let Some(ref details) = ctx.details
772 && let Some(truncation) = details.get("truncation")
773 {
774 let truncated = truncation
775 .get("truncated")
776 .and_then(|v| v.as_bool())
777 .unwrap_or(false);
778 if truncated {
779 let warning_color = |s: String| theme.fg_key(ThemeKey::Warning, &s);
780 let first_line_exceeds = truncation
781 .get("firstLineExceedsLimit")
782 .and_then(|v| v.as_bool())
783 .unwrap_or(false);
784 if first_line_exceeds {
785 let max_bytes = truncation
786 .get("maxBytes")
787 .and_then(|v| v.as_u64())
788 .unwrap_or(DEFAULT_MAX_BYTES as u64)
789 as usize;
790 result.push(warning_color(format!(
791 "[First line exceeds {} limit]",
792 format_size(max_bytes),
793 )));
794 } else if let Some(truncated_by) =
795 truncation.get("truncatedBy").and_then(|v| v.as_str())
796 {
797 let output_lines = truncation
798 .get("outputLines")
799 .and_then(|v| v.as_u64())
800 .unwrap_or(0) as usize;
801 let total_lines = truncation
802 .get("totalLines")
803 .and_then(|v| v.as_u64())
804 .unwrap_or(0) as usize;
805 if truncated_by == "lines" {
806 let max_lines = truncation
807 .get("maxLines")
808 .and_then(|v| v.as_u64())
809 .unwrap_or(DEFAULT_MAX_LINES as u64)
810 as usize;
811 result.push(warning_color(format!(
812 "[Truncated: showing {} of {} lines ({} line limit)]",
813 output_lines, total_lines, max_lines,
814 )));
815 } else {
816 let max_bytes = truncation
817 .get("maxBytes")
818 .and_then(|v| v.as_u64())
819 .unwrap_or(DEFAULT_MAX_BYTES as u64)
820 as usize;
821 result.push(warning_color(format!(
822 "[Truncated: {} lines shown ({} limit)]",
823 output_lines,
824 format_size(max_bytes),
825 )));
826 }
827 }
828 }
829 }
830
831 result
832 }
833}
834
835#[cfg(test)]
838mod tests {
839 use super::*;
840 use yoagent::AgentTool;
841
842 fn tmp_dir() -> std::path::PathBuf {
843 let d = std::env::temp_dir().join(format!("rab-read-test-{}", uuid::Uuid::new_v4()));
844 std::fs::create_dir_all(&d).unwrap();
845 d
846 }
847
848 fn make_tool() -> (ReadTool, std::path::PathBuf) {
849 let tmp = tmp_dir();
850 (
851 ReadTool {
852 cwd: tmp.clone(),
853 operations: Arc::new(DefaultReadOperations),
854 },
855 tmp,
856 )
857 }
858
859 fn tool_ctx() -> yoagent::types::ToolContext {
860 yoagent::types::ToolContext {
861 tool_call_id: "id".into(),
862 tool_name: "read".into(),
863 cancel: tokio_util::sync::CancellationToken::new(),
864 on_update: None,
865 on_progress: None,
866 }
867 }
868
869 fn yo_msg_text(content: &[yoagent::types::Content]) -> String {
870 content
871 .iter()
872 .filter_map(|c| {
873 if let yoagent::types::Content::Text { text } = c {
874 Some(text.as_str())
875 } else {
876 None
877 }
878 })
879 .collect::<Vec<_>>()
880 .join("")
881 }
882
883 async fn exec_ok(tool: &ReadTool, args: serde_json::Value) -> String {
884 let result = tool.execute(args, tool_ctx()).await.unwrap();
885 yo_msg_text(&result.content)
886 }
887
888 async fn exec_full(tool: &ReadTool, args: serde_json::Value) -> yoagent::types::ToolResult {
889 tool.execute(args, tool_ctx()).await.unwrap()
890 }
891
892 #[test]
895 fn test_no_truncation_needed() {
896 let result = truncate_head("hello\nworld\n", 2000, 50000);
897 assert!(!result.truncated);
898 assert!(!result.first_line_exceeds_limit);
899 assert_eq!(result.content, "hello\nworld\n");
900 }
901
902 #[test]
903 fn test_truncates_by_lines() {
904 let content: String = (1..=5000).map(|i| format!("line {}\n", i)).collect();
905 let result = truncate_head(&content, 2000, 50000);
906 assert!(result.truncated);
907 assert_eq!(result.truncated_by, Some("lines"));
908 assert_eq!(result.output_lines, 2000);
909 assert!(result.content.ends_with("line 2000"));
910 }
911
912 #[test]
913 fn test_truncates_by_bytes() {
914 let content: String = (1..=100)
915 .map(|i| format!("line {} {}\n", i, "x".repeat(1000)))
916 .collect();
917 let result = truncate_head(&content, 2000, 50000);
918 assert!(result.truncated);
919 assert_eq!(result.truncated_by, Some("bytes"));
920 assert!(result.output_lines < 100);
921 }
922
923 #[test]
924 fn test_first_line_exceeds_limit() {
925 let content = format!("{}\nshort\n", "x".repeat(60000));
926 let result = truncate_head(&content, 2000, 50000);
927 assert!(result.truncated);
928 assert!(result.first_line_exceeds_limit);
929 assert!(result.content.is_empty());
930 }
931
932 #[test]
933 fn test_empty_content() {
934 let result = truncate_head("", 2000, 50000);
935 assert!(!result.truncated);
936 assert_eq!(result.content, "");
937 }
938
939 #[test]
940 fn test_exact_fit() {
941 let line = "a".repeat(50000);
942 let result = truncate_head(&line, 2000, 50000);
943 assert!(!result.truncated);
944 }
945
946 #[test]
947 fn test_format_size() {
948 assert_eq!(format_size(500), "500B");
949 assert_eq!(format_size(1024), "1.0KB");
950 assert_eq!(format_size(50 * 1024), "50.0KB");
951 assert_eq!(format_size(1024 * 1024), "1.0MB");
952 }
953
954 #[test]
955 fn test_trim_trailing_empty_lines() {
956 let lines = vec!["a", "b", "", ""];
957 let trimmed = trim_trailing_empty_lines(&lines);
958 assert_eq!(trimmed, &["a", "b"]);
959 }
960
961 #[test]
962 fn test_trim_no_trailing_empty_lines() {
963 let lines = vec!["a", "b"];
964 let trimmed = trim_trailing_empty_lines(&lines);
965 assert_eq!(trimmed, &["a", "b"]);
966 }
967
968 #[test]
969 fn test_trim_all_empty() {
970 let lines: Vec<&str> = vec!["", "", ""];
971 let trimmed = trim_trailing_empty_lines(&lines);
972 assert!(trimmed.is_empty());
973 }
974
975 #[test]
976 fn test_trim_empty_input() {
977 let lines: Vec<&str> = vec![];
978 let trimmed = trim_trailing_empty_lines(&lines);
979 assert!(trimmed.is_empty());
980 }
981
982 #[test]
985 fn test_compact_classification_agents_md() {
986 let result = get_compact_read_classification("path/to/AGENTS.md", Path::new("path"));
987 assert!(result.is_some());
988 let (kind, label) = result.unwrap();
989 assert_eq!(kind, CompactReadKind::Resource);
990 assert!(label.contains("to/AGENTS.md"));
991 }
992
993 #[test]
994 fn test_compact_classification_claude_md() {
995 let result = get_compact_read_classification("CLAUDE.md", Path::new("path"));
996 assert!(result.is_some());
997 let (kind, label) = result.unwrap();
998 assert_eq!(kind, CompactReadKind::Resource);
999 assert_eq!(label, "CLAUDE.md");
1000 }
1001
1002 #[test]
1003 fn test_compact_classification_skill() {
1004 let result = get_compact_read_classification("skills/my-skill/SKILL.md", Path::new("."));
1005 assert!(result.is_some());
1006 let (kind, label) = result.unwrap();
1007 assert_eq!(kind, CompactReadKind::Skill);
1008 assert_eq!(label, "my-skill");
1009 }
1010
1011 #[test]
1012 fn test_compact_classification_regular_file() {
1013 let result = get_compact_read_classification("src/main.rs", Path::new("."));
1014 assert!(result.is_none());
1015 }
1016
1017 #[tokio::test]
1019 async fn reads_file_content() {
1020 let (tool, tmp) = make_tool();
1021 let path = tmp.join("test.txt");
1022 std::fs::write(&path, "hello world\nline two\n").unwrap();
1023
1024 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1025
1026 assert!(result.contains("hello world"));
1027 assert!(result.contains("line two"));
1028 }
1029
1030 #[tokio::test]
1031 async fn read_respects_offset() {
1032 let (tool, tmp) = make_tool();
1033 let path = tmp.join("test.txt");
1034 let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
1035 std::fs::write(&path, content.join("\n")).unwrap();
1036
1037 let result = exec_ok(
1038 &tool,
1039 serde_json::json!({"path": path.to_str().unwrap(), "offset": 5}),
1040 )
1041 .await;
1042
1043 assert!(result.contains("line 5"), "should contain line 5: {result}");
1044 assert!(
1045 !result.lines().any(|l| l == "line 1"),
1046 "should not contain line 1: {result}"
1047 );
1048 }
1049
1050 #[tokio::test]
1051 async fn read_respects_limit() {
1052 let (tool, tmp) = make_tool();
1053 let path = tmp.join("test.txt");
1054 let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
1055 std::fs::write(&path, content.join("\n")).unwrap();
1056
1057 let result = exec_ok(
1058 &tool,
1059 serde_json::json!({"path": path.to_str().unwrap(), "offset": 1, "limit": 3}),
1060 )
1061 .await;
1062
1063 assert!(result.contains("line 1"));
1064 assert!(result.contains("line 3"));
1065 assert!(!result.contains("line 4"));
1066 }
1067
1068 #[tokio::test]
1069 async fn read_nonexistent_file_errors() {
1070 let (tool, _tmp) = make_tool();
1071
1072 let result = tool
1073 .execute(serde_json::json!({"path": "nonexistent.txt"}), tool_ctx())
1074 .await;
1075 assert!(result.is_err());
1076 }
1077
1078 #[tokio::test]
1079 async fn offset_beyond_end_errors() {
1080 let (tool, tmp) = make_tool();
1081 let path = tmp.join("short.txt");
1082 std::fs::write(&path, "only one line\n").unwrap();
1083
1084 let result = tool
1085 .execute(
1086 serde_json::json!({"path": path.to_str().unwrap(), "offset": 100}),
1087 tool_ctx(),
1088 )
1089 .await;
1090 assert!(result.is_err());
1091 let err = result.unwrap_err().to_string();
1092 assert!(err.contains("beyond end of file"));
1093 }
1094
1095 #[tokio::test]
1096 async fn large_file_truncation_by_lines() {
1097 let (tool, tmp) = make_tool();
1098 let path = tmp.join("large.txt");
1099 let content: String = (1..=5000).map(|i| format!("line {}\n", i)).collect();
1100 std::fs::write(&path, &content).unwrap();
1101
1102 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1103
1104 assert!(result.contains("Showing lines 1-"));
1105 assert!(result.contains("offset="));
1106 assert!(result.contains("of 5000."));
1107 }
1108
1109 #[tokio::test]
1110 async fn large_file_truncation_by_bytes() {
1111 let (tool, tmp) = make_tool();
1112 let path = tmp.join("wide.txt");
1113 let content: String = (1..=100)
1114 .map(|i| format!("line {} {}\n", i, "x".repeat(1190)))
1115 .collect();
1116 std::fs::write(&path, &content).unwrap();
1117
1118 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1119
1120 assert!(result.contains("KB limit"));
1121 assert!(result.contains("offset="));
1122 }
1123
1124 #[tokio::test]
1125 async fn first_line_exceeds_limit_shows_bash_hint() {
1126 let (tool, tmp) = make_tool();
1127 let path = tmp.join("huge_first_line.txt");
1128 let content = format!("{}\nshort line\n", "x".repeat(60000));
1129 std::fs::write(&path, &content).unwrap();
1130
1131 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1132
1133 assert!(result.contains("bash"));
1134 assert!(result.contains("sed"));
1135 assert!(result.contains("head -c"));
1136 }
1137
1138 #[tokio::test]
1139 async fn limit_honored_without_truncation() {
1140 let (tool, tmp) = make_tool();
1141 let path = tmp.join("limited.txt");
1142 let content: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
1143 std::fs::write(&path, &content).unwrap();
1144
1145 let result = exec_ok(
1146 &tool,
1147 serde_json::json!({"path": path.to_str().unwrap(), "limit": 5}),
1148 )
1149 .await;
1150
1151 assert!(result.contains("line 1"));
1152 assert!(result.contains("line 5"));
1153 assert!(!result.contains("line 6"));
1154 assert!(result.contains("more lines"));
1155 }
1156
1157 #[tokio::test]
1158 async fn limit_exactly_covers_file() {
1159 let (tool, tmp) = make_tool();
1160 let path = tmp.join("exact.txt");
1161 let content: String = (1..=3).map(|i| format!("line {}\n", i)).collect();
1162 std::fs::write(&path, &content).unwrap();
1163
1164 let result = exec_ok(
1165 &tool,
1166 serde_json::json!({"path": path.to_str().unwrap(), "limit": 3}),
1167 )
1168 .await;
1169
1170 assert!(result.contains("line 1"));
1171 assert!(result.contains("line 2"));
1172 assert!(result.contains("line 3"));
1173 assert!(!result.contains("more lines"));
1174 }
1175
1176 #[tokio::test]
1177 async fn trims_trailing_empty_lines() {
1178 let (tool, tmp) = make_tool();
1179 let path = tmp.join("trailing_empties.txt");
1180 std::fs::write(&path, "hello\nworld\n\n\n").unwrap();
1181
1182 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1183
1184 assert!(result.contains("hello"));
1185 assert!(result.contains("world"));
1186 assert!(!result.ends_with("\n\n\n"));
1187 }
1188
1189 #[tokio::test]
1190 async fn relative_path_resolves_to_cwd() {
1191 let (tool, tmp) = make_tool();
1192 let path = tmp.join("relative.txt");
1193 std::fs::write(&path, "hello\n").unwrap();
1194
1195 let result = exec_ok(&tool, serde_json::json!({"path": "relative.txt"})).await;
1196
1197 assert!(result.contains("hello"));
1198 }
1199
1200 #[tokio::test]
1201 async fn reads_agents_md() {
1202 let (tool, tmp) = make_tool();
1203 let path = tmp.join("AGENTS.md");
1204 std::fs::write(&path, "some instructions\n").unwrap();
1205
1206 let output = exec_full(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1207
1208 let text = yo_msg_text(&output.content);
1209 assert!(text.contains("some instructions"));
1210 }
1211
1212 #[tokio::test]
1213 async fn no_compact_label_for_regular_file() {
1214 let (tool, tmp) = make_tool();
1215 let path = tmp.join("main.rs");
1216 std::fs::write(&path, "fn main() {}\n").unwrap();
1217
1218 let output = exec_full(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1219
1220 let text = yo_msg_text(&output.content);
1221 assert!(text.contains("fn main() {}"));
1222 }
1223
1224 #[tokio::test]
1225 async fn cancel_aborts_read() {
1226 let (tool, tmp) = make_tool();
1227 let path = tmp.join("cancel_test.txt");
1228 std::fs::write(&path, "hello\n").unwrap();
1229
1230 let cancel = tokio_util::sync::CancellationToken::new();
1231 cancel.cancel();
1232
1233 let result = tool
1234 .execute(
1235 serde_json::json!({"path": path.to_str().unwrap()}),
1236 yoagent::types::ToolContext {
1237 tool_call_id: "id".into(),
1238 tool_name: "read".into(),
1239 cancel,
1240 on_update: None,
1241 on_progress: None,
1242 },
1243 )
1244 .await;
1245 assert!(result.is_err());
1246 }
1247}