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