1use crate::agent::extension::{AgentTool, Cancel, Extension, ToolOutput};
2use crate::agent::extension::{ToolRenderContext, ToolRenderer};
3use crate::tui::Theme;
4use anyhow::Context;
5use async_trait::async_trait;
6use std::borrow::Cow;
7use std::path::Path;
8use tokio::sync::mpsc::UnboundedSender;
9
10pub struct ReadExtension {
11 cwd: std::path::PathBuf,
12}
13
14impl ReadExtension {
15 pub fn new(cwd: std::path::PathBuf) -> Self {
16 Self { cwd }
17 }
18}
19
20impl Extension for ReadExtension {
21 fn name(&self) -> Cow<'static, str> {
22 "read".into()
23 }
24
25 fn tools(&self) -> Vec<Box<dyn AgentTool>> {
26 vec![Box::new(ReadTool {
27 cwd: self.cwd.clone(),
28 })]
29 }
30}
31
32struct ReadTool {
33 cwd: std::path::PathBuf,
34}
35
36const DEFAULT_MAX_LINES: usize = 2000;
39const DEFAULT_MAX_BYTES: usize = 50 * 1024; fn format_size(bytes: usize) -> String {
45 if bytes < 1024 {
46 format!("{}B", bytes)
47 } else if bytes < 1024 * 1024 {
48 format!("{:.1}KB", bytes as f64 / 1024.0)
49 } else {
50 format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
51 }
52}
53
54fn trim_trailing_empty_lines<'a>(lines: &'a [&'a str]) -> &'a [&'a str] {
56 let mut end = lines.len();
57 while end > 0 && lines[end - 1].is_empty() {
58 end -= 1;
59 }
60 &lines[..end]
61}
62
63#[derive(Debug, PartialEq)]
65enum CompactReadKind {
66 Resource,
67 Skill,
68}
69
70fn get_compact_read_classification(path: &str, cwd: &Path) -> Option<(CompactReadKind, String)> {
73 let abs_path = if Path::new(path).is_absolute() {
74 Path::new(path).to_path_buf()
75 } else {
76 cwd.join(path)
77 };
78
79 let file_name = abs_path.file_name()?.to_str()?;
80
81 if file_name.eq_ignore_ascii_case("AGENTS.md") || file_name.eq_ignore_ascii_case("CLAUDE.md") {
83 let display = abs_path
84 .strip_prefix(cwd)
85 .unwrap_or(&abs_path)
86 .to_string_lossy()
87 .to_string();
88 return Some((CompactReadKind::Resource, display));
89 }
90
91 if file_name == "SKILL.md"
93 && let Some(parent) = abs_path.parent()
94 && let Some(dir_name) = parent.file_name()
95 {
96 let dir_name = dir_name.to_str().unwrap_or("unknown");
97 return Some((CompactReadKind::Skill, dir_name.to_string()));
98 }
99
100 None
101}
102
103#[allow(dead_code)]
107struct TruncationResult {
108 content: String,
109 truncated: bool,
110 truncated_by: &'static str, total_lines: usize,
112 output_lines: usize,
113 first_line_exceeds_limit: bool,
114}
115
116fn truncate_head(content: &str, max_lines: usize, max_bytes: usize) -> TruncationResult {
120 let total_bytes = content.len();
121 let lines: Vec<&str> = content.lines().collect();
122 let total_lines = lines.len();
123
124 if total_lines <= max_lines && total_bytes <= max_bytes {
126 return TruncationResult {
127 content: content.to_string(),
128 truncated: false,
129 truncated_by: "",
130 total_lines,
131 output_lines: total_lines,
132 first_line_exceeds_limit: false,
133 };
134 }
135
136 if let Some(first) = lines.first()
138 && first.len() > max_bytes
139 {
140 return TruncationResult {
141 content: String::new(),
142 truncated: true,
143 truncated_by: "bytes",
144 total_lines,
145 output_lines: 0,
146 first_line_exceeds_limit: true,
147 };
148 }
149
150 let mut output: Vec<&str> = Vec::new();
152 let mut byte_count: usize = 0;
153 let mut truncated_by = "lines";
154
155 for line in lines.iter().take(max_lines) {
156 let line_bytes = line.len();
157 let with_newline = if output.is_empty() {
158 line_bytes
159 } else {
160 line_bytes + 1 };
162
163 if byte_count + with_newline > max_bytes {
164 truncated_by = "bytes";
165 break;
166 }
167
168 output.push(line);
169 byte_count += with_newline;
170 }
171
172 if output.len() >= max_lines && byte_count <= max_bytes {
173 truncated_by = "lines";
174 }
175
176 TruncationResult {
177 content: output.join("\n"),
178 truncated: true,
179 truncated_by,
180 total_lines,
181 output_lines: output.len(),
182 first_line_exceeds_limit: false,
183 }
184}
185
186#[async_trait]
189impl AgentTool for ReadTool {
190 fn name(&self) -> &str {
191 "read"
192 }
193
194 fn description(&self) -> &str {
195 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). \
196 Images are sent as attachments. For text files, output is truncated to 2000 lines or \
197 50KB (whichever is hit first). Use offset/limit for large files. When you need the \
198 full file, continue with offset until complete."
199 }
200
201 fn parameters(&self) -> serde_json::Value {
202 serde_json::json!({
203 "type": "object",
204 "required": ["path"],
205 "properties": {
206 "path": {
207 "type": "string",
208 "description": "Path to the file to read (relative or absolute)"
209 },
210 "offset": {
211 "type": "number",
212 "description": "Line number to start reading from (1-indexed)"
213 },
214 "limit": {
215 "type": "number",
216 "description": "Maximum number of lines to read"
217 }
218 }
219 })
220 }
221
222 fn prompt_guidelines(&self) -> Vec<String> {
223 vec!["Use read to examine files instead of cat or sed.".into()]
224 }
225
226 fn label(&self) -> &str {
227 "Read file contents"
228 }
229
230 fn renderer(&self) -> Option<Box<dyn ToolRenderer>> {
231 Some(Box::new(ReadRenderer {
232 cwd: self.cwd.clone(),
233 }))
234 }
235
236 async fn execute(
237 &self,
238 tool_call_id: String,
239 args: serde_json::Value,
240 cancel: Cancel,
241 _on_update: Option<UnboundedSender<ToolOutput>>,
242 ) -> anyhow::Result<ToolOutput> {
243 let _ = tool_call_id;
244 let path = args["path"]
245 .as_str()
246 .ok_or_else(|| anyhow::anyhow!("Missing 'path' argument"))?;
247 let offset = args["offset"].as_u64().map(|o| o as usize).unwrap_or(0);
248 let limit = args["limit"].as_u64().map(|l| l as usize);
249
250 let abs_path = {
251 let p = std::path::Path::new(path);
252 if p.is_absolute() {
253 p.to_path_buf()
254 } else {
255 self.cwd.join(p)
256 }
257 };
258
259 cancel.check()?;
260
261 if crate::tui::image::is_image_path(&abs_path) {
263 let data_url = crate::tui::image::file_to_data_url(&abs_path)
264 .with_context(|| format!("Failed to read image {}", abs_path.display()))?;
265 return Ok(ToolOutput::ok(data_url));
266 }
267
268 let content = std::fs::read_to_string(&abs_path)
269 .with_context(|| format!("Failed to read {}", abs_path.display()))?;
270
271 let all_lines: Vec<&str> = content.split('\n').collect();
272 let total_file_lines = if content.ends_with('\n') {
273 all_lines.len() - 1
274 } else {
275 all_lines.len()
276 };
277
278 let start_line = if offset > 0 { offset - 1 } else { 0 };
280 if start_line >= total_file_lines {
281 return Err(anyhow::anyhow!(
282 "Offset {} is beyond end of file ({} lines total)",
283 offset,
284 total_file_lines
285 ));
286 }
287
288 cancel.check()?;
289
290 let selected_content: String;
292 let user_limited_lines: Option<usize>;
293
294 if let Some(lim) = limit {
295 let end_line = (start_line + lim).min(total_file_lines);
296 let selected_lines = &all_lines[start_line..end_line];
297 selected_content = selected_lines.join("\n");
298 user_limited_lines = Some(end_line - start_line);
299 } else {
300 let selected_lines = &all_lines[start_line..];
301 selected_content = selected_lines.join("\n");
302 user_limited_lines = None;
303 }
304
305 let compact =
307 get_compact_read_classification(path, &self.cwd).map(|(kind, label)| match kind {
308 CompactReadKind::Resource => format!("read resource {}", label),
309 CompactReadKind::Skill => format!("read skill {}", label),
310 });
311
312 let trunc = truncate_head(&selected_content, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
314
315 if trunc.first_line_exceeds_limit {
316 let first_line_bytes = format_size(all_lines[start_line].len());
317 let msg = format!(
318 "[Line {} is {}, exceeds {} limit. Use bash: sed -n '{}p' {} | head -c {}]",
319 start_line + 1,
320 first_line_bytes,
321 format_size(DEFAULT_MAX_BYTES),
322 start_line + 1,
323 path,
324 DEFAULT_MAX_BYTES,
325 );
326 return Ok(ToolOutput::ok(msg));
327 }
328
329 let output: String;
330
331 if trunc.truncated {
332 let start_display = start_line + 1;
333 let end_display = start_display + trunc.output_lines - 1;
334 let next_offset = end_display + 1;
335
336 if trunc.truncated_by == "lines" {
337 output = format!(
338 "{}\n\n[Showing lines {}-{} of {}. Use offset={} to continue.]",
339 trunc.content, start_display, end_display, total_file_lines, next_offset,
340 );
341 } else {
342 output = format!(
343 "{}\n\n[Showing lines {}-{} of {} ({} limit). Use offset={} to continue.]",
344 trunc.content,
345 start_display,
346 end_display,
347 total_file_lines,
348 format_size(DEFAULT_MAX_BYTES),
349 next_offset,
350 );
351 }
352 } else if let Some(ul) = user_limited_lines {
353 if start_line + ul < total_file_lines {
354 let remaining = total_file_lines - (start_line + ul);
355 let next_offset = start_line + ul + 1;
356 output = format!(
357 "{}\n\n[{} more lines in file. Use offset={} to continue.]",
358 trunc.content, remaining, next_offset,
359 );
360 } else {
361 let lines: Vec<&str> = trunc.content.lines().collect();
362 let trimmed = trim_trailing_empty_lines(&lines);
363 output = trimmed.join("\n");
364 }
365 } else {
366 let lines: Vec<&str> = trunc.content.lines().collect();
367 let trimmed = trim_trailing_empty_lines(&lines);
368 output = trimmed.join("\n");
369 }
370
371 if let Some(label) = compact {
372 Ok(ToolOutput::ok_with_compact(output, label))
373 } else {
374 Ok(ToolOutput::ok(output))
375 }
376 }
377}
378
379struct ReadRenderer {
382 cwd: std::path::PathBuf,
383}
384
385impl ToolRenderer for ReadRenderer {
386 fn render_call(
387 &self,
388 args: &serde_json::Value,
389 _width: usize,
390 theme: &dyn Theme,
391 ctx: &ToolRenderContext,
392 ) -> Vec<String> {
393 use std::path::Path;
394 let path = args
395 .get("file_path")
396 .or_else(|| args.get("path"))
397 .and_then(|v| v.as_str())
398 .unwrap_or("");
399 let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
400 let limit = args.get("limit").and_then(|v| v.as_u64());
401
402 let classification = if !ctx.expanded {
404 get_compact_read_classification(path, Path::new(&self.cwd))
405 } else {
406 None
407 };
408
409 let range = if offset > 0 || limit.is_some() {
411 let start = if offset > 0 { offset } else { 1 };
412 let range_str = match limit {
413 Some(l) => format!(":{}-{}", start, start + l - 1),
414 None => format!(":{}", start),
415 };
416 theme.fg("warning", &range_str)
417 } else {
418 String::new()
419 };
420
421 let expand_hint = if !ctx.expanded && !ctx.expand_key.is_empty() {
423 theme.fg("muted", &format!(" ({}) to expand", ctx.expand_key))
424 } else {
425 String::new()
426 };
427
428 if let Some((kind, label)) = classification {
429 match kind {
430 CompactReadKind::Skill => {
431 let prefix = theme.fg("customMessageLabel", "\x1b[1m[skill]\x1b[22m ");
434 let name = theme.fg("customMessageText", &label);
435 vec![format!("{}{}{}{}", prefix, name, range, expand_hint)]
436 }
437 CompactReadKind::Resource => {
438 let title_styled = theme.fg("toolTitle", &theme.bold("read resource"));
441 let path_styled = theme.fg("accent", &label);
442 vec![format!(
443 "{} {}{}{}",
444 title_styled, path_styled, range, expand_hint
445 )]
446 }
447 }
448 } else {
449 let short = if let Ok(home) = std::env::var("HOME") {
451 path.replacen(&home, "~", 1)
452 } else {
453 path.to_string()
454 };
455 let path_disp = if short.is_empty() {
456 String::new()
457 } else {
458 theme.fg("accent", &short)
459 };
460 vec![format!(
461 "{} {}{}",
462 theme.fg("toolTitle", &theme.bold("read")),
463 path_disp,
464 range,
465 )]
466 }
467 }
468
469 fn render_result(
470 &self,
471 content: &str,
472 _width: usize,
473 theme: &dyn Theme,
474 ctx: &ToolRenderContext,
475 ) -> Vec<String> {
476 if crate::tui::util::is_image_line(content) {
478 let kitty_seq = crate::tui::image::kitty_image_sequence(content);
479 if !kitty_seq.is_empty() {
480 return vec![kitty_seq, String::new()];
481 }
482 }
483
484 if content.is_empty() {
485 return vec![];
486 }
487
488 if !ctx.expanded && !ctx.is_error {
490 return vec![];
491 }
492
493 let path = ctx.file_path.as_deref().unwrap_or("");
494 let lang = if !path.is_empty() {
495 crate::tui::components::path_to_language(path)
496 } else {
497 None
498 };
499
500 let all_lines: Vec<&str> = content.lines().collect();
502 let mut end = all_lines.len();
503 while end > 0 && all_lines[end - 1].is_empty() {
504 end -= 1;
505 }
506 let trimmed_lines = &all_lines[..end];
507
508 let max_lines = if ctx.expanded { usize::MAX } else { 10 };
510 let display_lines: Vec<&str> = trimmed_lines.iter().copied().take(max_lines).collect();
511 let remaining = trimmed_lines.len().saturating_sub(display_lines.len());
512
513 let mut result = vec![String::new()];
515
516 for line in &display_lines {
518 let processed = line.replace('\t', " ");
519 #[cfg(feature = "syntect")]
520 if let Some(lang) = lang {
521 let _ = lang;
523 }
524 result.push(theme.fg("toolOutput", &processed));
525 }
526
527 if remaining > 0 && !ctx.expand_key.is_empty() {
529 result.push(theme.fg(
530 "muted",
531 &format!(
532 "... ({} more lines, {} to expand)",
533 remaining, ctx.expand_key
534 ),
535 ));
536 } else if remaining > 0 {
537 result.push(theme.fg("muted", &format!("... ({} more lines)", remaining)));
538 }
539
540 result
541 }
542}
543
544#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn tmp_dir() -> std::path::PathBuf {
551 let d = std::env::temp_dir().join(format!("rab-read-test-{}", uuid::Uuid::new_v4()));
552 std::fs::create_dir_all(&d).unwrap();
553 d
554 }
555
556 fn make_tool() -> (ReadTool, std::path::PathBuf) {
557 let tmp = tmp_dir();
558 (ReadTool { cwd: tmp.clone() }, tmp)
559 }
560
561 async fn exec_ok(tool: &ReadTool, args: serde_json::Value) -> String {
562 tool.execute("id".into(), args, Cancel::new(), None)
563 .await
564 .unwrap()
565 .content
566 }
567
568 async fn exec_full(tool: &ReadTool, args: serde_json::Value) -> ToolOutput {
569 tool.execute("id".into(), args, Cancel::new(), None)
570 .await
571 .unwrap()
572 }
573
574 #[test]
577 fn test_no_truncation_needed() {
578 let result = truncate_head("hello\nworld\n", 2000, 50000);
579 assert!(!result.truncated);
580 assert!(!result.first_line_exceeds_limit);
581 assert_eq!(result.content, "hello\nworld\n");
582 }
583
584 #[test]
585 fn test_truncates_by_lines() {
586 let content: String = (1..=5000).map(|i| format!("line {}\n", i)).collect();
587 let result = truncate_head(&content, 2000, 50000);
588 assert!(result.truncated);
589 assert_eq!(result.truncated_by, "lines");
590 assert_eq!(result.output_lines, 2000);
591 assert!(result.content.ends_with("line 2000"));
592 }
593
594 #[test]
595 fn test_truncates_by_bytes() {
596 let content: String = (1..=100)
597 .map(|i| format!("line {} {}\n", i, "x".repeat(1000)))
598 .collect();
599 let result = truncate_head(&content, 2000, 50000);
600 assert!(result.truncated);
601 assert_eq!(result.truncated_by, "bytes");
602 assert!(result.output_lines < 100);
603 }
604
605 #[test]
606 fn test_first_line_exceeds_limit() {
607 let content = format!("{}\nshort\n", "x".repeat(60000));
608 let result = truncate_head(&content, 2000, 50000);
609 assert!(result.truncated);
610 assert!(result.first_line_exceeds_limit);
611 assert!(result.content.is_empty());
612 }
613
614 #[test]
615 fn test_empty_content() {
616 let result = truncate_head("", 2000, 50000);
617 assert!(!result.truncated);
618 assert_eq!(result.content, "");
619 }
620
621 #[test]
622 fn test_exact_fit() {
623 let line = "a".repeat(50000);
624 let result = truncate_head(&line, 2000, 50000);
625 assert!(!result.truncated);
626 }
627
628 #[test]
629 fn test_format_size() {
630 assert_eq!(format_size(500), "500B");
631 assert_eq!(format_size(1024), "1.0KB");
632 assert_eq!(format_size(50 * 1024), "50.0KB");
633 assert_eq!(format_size(1024 * 1024), "1.0MB");
634 }
635
636 #[test]
637 fn test_trim_trailing_empty_lines() {
638 let lines = vec!["a", "b", "", ""];
639 let trimmed = trim_trailing_empty_lines(&lines);
640 assert_eq!(trimmed, &["a", "b"]);
641 }
642
643 #[test]
644 fn test_trim_no_trailing_empty_lines() {
645 let lines = vec!["a", "b"];
646 let trimmed = trim_trailing_empty_lines(&lines);
647 assert_eq!(trimmed, &["a", "b"]);
648 }
649
650 #[test]
651 fn test_trim_all_empty() {
652 let lines: Vec<&str> = vec!["", "", ""];
653 let trimmed = trim_trailing_empty_lines(&lines);
654 assert!(trimmed.is_empty());
655 }
656
657 #[test]
658 fn test_trim_empty_input() {
659 let lines: Vec<&str> = vec![];
660 let trimmed = trim_trailing_empty_lines(&lines);
661 assert!(trimmed.is_empty());
662 }
663
664 #[test]
667 fn test_compact_classification_agents_md() {
668 let result = get_compact_read_classification("path/to/AGENTS.md", Path::new("path"));
669 assert!(result.is_some());
670 let (kind, label) = result.unwrap();
671 assert_eq!(kind, CompactReadKind::Resource);
672 assert!(label.contains("to/AGENTS.md"));
673 }
674
675 #[test]
676 fn test_compact_classification_claude_md() {
677 let result = get_compact_read_classification("CLAUDE.md", Path::new("path"));
678 assert!(result.is_some());
679 let (kind, label) = result.unwrap();
680 assert_eq!(kind, CompactReadKind::Resource);
681 assert_eq!(label, "CLAUDE.md");
682 }
683
684 #[test]
685 fn test_compact_classification_skill() {
686 let result = get_compact_read_classification("skills/my-skill/SKILL.md", Path::new("."));
687 assert!(result.is_some());
688 let (kind, label) = result.unwrap();
689 assert_eq!(kind, CompactReadKind::Skill);
690 assert_eq!(label, "my-skill");
691 }
692
693 #[test]
694 fn test_compact_classification_regular_file() {
695 let result = get_compact_read_classification("src/main.rs", Path::new("."));
696 assert!(result.is_none());
697 }
698
699 #[tokio::test]
702 async fn reads_file_content() {
703 let (tool, tmp) = make_tool();
704 let path = tmp.join("test.txt");
705 std::fs::write(&path, "hello world\nline two\n").unwrap();
706
707 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
708
709 assert!(result.contains("hello world"));
710 assert!(result.contains("line two"));
711 }
712
713 #[tokio::test]
714 async fn read_respects_offset() {
715 let (tool, tmp) = make_tool();
716 let path = tmp.join("test.txt");
717 let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
718 std::fs::write(&path, content.join("\n")).unwrap();
719
720 let result = exec_ok(
721 &tool,
722 serde_json::json!({"path": path.to_str().unwrap(), "offset": 5}),
723 )
724 .await;
725
726 assert!(result.contains("line 5"), "should contain line 5: {result}");
727 assert!(
728 !result.lines().any(|l| l == "line 1"),
729 "should not contain line 1: {result}"
730 );
731 }
732
733 #[tokio::test]
734 async fn read_respects_limit() {
735 let (tool, tmp) = make_tool();
736 let path = tmp.join("test.txt");
737 let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
738 std::fs::write(&path, content.join("\n")).unwrap();
739
740 let result = exec_ok(
741 &tool,
742 serde_json::json!({"path": path.to_str().unwrap(), "offset": 1, "limit": 3}),
743 )
744 .await;
745
746 assert!(result.contains("line 1"));
747 assert!(result.contains("line 3"));
748 assert!(!result.contains("line 4"));
749 }
750
751 #[tokio::test]
752 async fn read_nonexistent_file_errors() {
753 let (tool, _tmp) = make_tool();
754
755 let result = tool
756 .execute(
757 "id".into(),
758 serde_json::json!({"path": "nonexistent.txt"}),
759 Cancel::new(),
760 None,
761 )
762 .await;
763 assert!(result.is_err());
764 }
765
766 #[tokio::test]
767 async fn offset_beyond_end_errors() {
768 let (tool, tmp) = make_tool();
769 let path = tmp.join("short.txt");
770 std::fs::write(&path, "only one line\n").unwrap();
771
772 let result = tool
773 .execute(
774 "id".into(),
775 serde_json::json!({"path": path.to_str().unwrap(), "offset": 100}),
776 Cancel::new(),
777 None,
778 )
779 .await;
780 assert!(result.is_err());
781 let err = result.unwrap_err().to_string();
782 assert!(err.contains("beyond end of file"));
783 }
784
785 #[tokio::test]
786 async fn large_file_truncation_by_lines() {
787 let (tool, tmp) = make_tool();
788 let path = tmp.join("large.txt");
789 let content: String = (1..=5000).map(|i| format!("line {}\n", i)).collect();
790 std::fs::write(&path, &content).unwrap();
791
792 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
793
794 assert!(result.contains("Showing lines 1-"));
795 assert!(result.contains("offset="));
796 assert!(result.contains("of 5000."));
797 }
798
799 #[tokio::test]
800 async fn large_file_truncation_by_bytes() {
801 let (tool, tmp) = make_tool();
802 let path = tmp.join("wide.txt");
803 let content: String = (1..=100)
804 .map(|i| format!("line {} {}\n", i, "x".repeat(1190)))
805 .collect();
806 std::fs::write(&path, &content).unwrap();
807
808 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
809
810 assert!(result.contains("KB limit"));
811 assert!(result.contains("offset="));
812 }
813
814 #[tokio::test]
815 async fn first_line_exceeds_limit_shows_bash_hint() {
816 let (tool, tmp) = make_tool();
817 let path = tmp.join("huge_first_line.txt");
818 let content = format!("{}\nshort line\n", "x".repeat(60000));
819 std::fs::write(&path, &content).unwrap();
820
821 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
822
823 assert!(result.contains("bash"));
824 assert!(result.contains("sed"));
825 assert!(result.contains("head -c"));
826 }
827
828 #[tokio::test]
829 async fn limit_honored_without_truncation() {
830 let (tool, tmp) = make_tool();
831 let path = tmp.join("limited.txt");
832 let content: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
833 std::fs::write(&path, &content).unwrap();
834
835 let result = exec_ok(
836 &tool,
837 serde_json::json!({"path": path.to_str().unwrap(), "limit": 5}),
838 )
839 .await;
840
841 assert!(result.contains("line 1"));
842 assert!(result.contains("line 5"));
843 assert!(!result.contains("line 6"));
844 assert!(result.contains("more lines"));
845 }
846
847 #[tokio::test]
848 async fn limit_exactly_covers_file() {
849 let (tool, tmp) = make_tool();
850 let path = tmp.join("exact.txt");
851 let content: String = (1..=3).map(|i| format!("line {}\n", i)).collect();
852 std::fs::write(&path, &content).unwrap();
853
854 let result = exec_ok(
855 &tool,
856 serde_json::json!({"path": path.to_str().unwrap(), "limit": 3}),
857 )
858 .await;
859
860 assert!(result.contains("line 1"));
861 assert!(result.contains("line 2"));
862 assert!(result.contains("line 3"));
863 assert!(!result.contains("more lines"));
864 }
865
866 #[tokio::test]
867 async fn trims_trailing_empty_lines() {
868 let (tool, tmp) = make_tool();
869 let path = tmp.join("trailing_empties.txt");
870 std::fs::write(&path, "hello\nworld\n\n\n").unwrap();
871
872 let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
873
874 assert!(result.contains("hello"));
875 assert!(result.contains("world"));
876 assert!(!result.ends_with("\n\n\n"));
877 }
878
879 #[tokio::test]
880 async fn relative_path_resolves_to_cwd() {
881 let (tool, tmp) = make_tool();
882 let path = tmp.join("relative.txt");
883 std::fs::write(&path, "hello\n").unwrap();
884
885 let result = exec_ok(&tool, serde_json::json!({"path": "relative.txt"})).await;
886
887 assert!(result.contains("hello"));
888 }
889
890 #[tokio::test]
891 async fn compact_label_for_agents_md() {
892 let (tool, tmp) = make_tool();
893 let path = tmp.join("AGENTS.md");
894 std::fs::write(&path, "some instructions\n").unwrap();
895
896 let output = exec_full(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
897
898 assert!(output.compact.is_some());
899 let label = output.compact.unwrap();
900 assert!(label.contains("read resource"));
901 assert!(label.contains("AGENTS.md"));
902 }
903
904 #[tokio::test]
905 async fn no_compact_label_for_regular_file() {
906 let (tool, tmp) = make_tool();
907 let path = tmp.join("main.rs");
908 std::fs::write(&path, "fn main() {}\n").unwrap();
909
910 let output = exec_full(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
911
912 assert!(output.compact.is_none());
913 }
914
915 #[tokio::test]
916 async fn cancel_aborts_read() {
917 let (tool, tmp) = make_tool();
918 let path = tmp.join("cancel_test.txt");
919 std::fs::write(&path, "hello\n").unwrap();
920
921 let cancel = Cancel::new();
922 cancel.cancel();
923
924 let result = tool
925 .execute(
926 "id".into(),
927 serde_json::json!({"path": path.to_str().unwrap()}),
928 cancel,
929 None,
930 )
931 .await;
932 assert!(result.is_err());
933 let err = result.unwrap_err().to_string();
934 assert!(err.contains("cancell") || err.contains("Cancel"));
935 }
936}