Skip to main content

rab/builtin/
read.rs

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
36// ── Constants ────────────────────────────────────────────────────
37
38const DEFAULT_MAX_LINES: usize = 2000;
39const DEFAULT_MAX_BYTES: usize = 50 * 1024; // 50KB
40
41// ── Helpers ──────────────────────────────────────────────────────
42
43/// Format bytes as a human-readable size string, matching pi's format.
44fn 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
54/// Trim trailing empty lines from a slice of lines.
55fn 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/// Compact read classification matching pi's `CompactReadClassification`.
64#[derive(Debug, PartialEq)]
65enum CompactReadKind {
66    Resource,
67    Skill,
68}
69
70/// Build a compact classification for the read tool output, matching pi's `getCompactReadClassification`.
71/// Returns `None` for regular files.
72fn 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    // AGENTS.md / CLAUDE.md → resource with path relative to cwd
82    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    // SKILL.md → skill with parent directory name
92    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// ── Truncation ──────────────────────────────────────────────────
104
105/// Truncation result, mirroring pi's `TruncationResult`.
106#[allow(dead_code)]
107struct TruncationResult {
108    content: String,
109    truncated: bool,
110    truncated_by: &'static str, // "lines" | "bytes"
111    total_lines: usize,
112    output_lines: usize,
113    first_line_exceeds_limit: bool,
114}
115
116/// Truncate content from the head, keeping complete lines that fit within limits.
117/// Never returns partial lines. If first line exceeds the byte limit,
118/// returns empty content with `first_line_exceeds_limit = true`.
119fn 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    // Check if no truncation needed
125    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    // Check if first line alone exceeds the byte limit
137    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    // Accumulate complete lines that fit within both limits
151    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 // +1 for the preceding newline
161        };
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// ── AgentTool implementation ─────────────────────────────────────
187
188#[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        // ── Image file handling ──
262        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        // Apply offset (1-indexed → 0-indexed)
279        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        // Build the selected content based on offset/limit
291        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        // Compute compact classification label for the legacy DisplayMsg path
306        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        // Apply truncation
313        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
379/// Tool renderer for the `read` tool.
380/// Formats call headers with compact labels and result content with syntax highlighting.
381struct 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        // Compute compact classification
403        let classification = if !ctx.expanded {
404            get_compact_read_classification(path, Path::new(&self.cwd))
405        } else {
406            None
407        };
408
409        // Format line range (matching pi's formatReadLineRange)
410        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        // Expand hint (matching pi's ` (Ctrl+O to expand)` in `dim` color)
422        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                    // Pi: `[skill] name:range (Ctrl+O to expand)`
432                    // [skill] in customMessageLabel bold, name in customMessageText
433                    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                    // Pi: `read resource  path:range (Ctrl+O to expand)`
439                    // "read resource" in bold toolTitle, path in accent
440                    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            // Regular call: `read  path:range`
450            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        // ── Image: render using Kitty image protocol ──
477        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        // Pi: return empty when collapsed and not error (result is hidden until expanded)
489        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        // Pi: trim trailing empty lines
501        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        // Pi: show up to 10 lines when collapsed, full when expanded
509        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        // Pi: start with blank line (`\n` before content)
514        let mut result = vec![String::new()];
515
516        // Pi: apply replaceTabs and color each line
517        for line in &display_lines {
518            let processed = line.replace('\t', "   ");
519            #[cfg(feature = "syntect")]
520            if let Some(lang) = lang {
521                // Pi uses highlightCode on the full text, then replaceTabs on each line
522                let _ = lang;
523            }
524            result.push(theme.fg("toolOutput", &processed));
525        }
526
527        // Pi: remaining lines hint
528        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// ── Tests ────────────────────────────────────────────────────────
545
546#[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    // ── Truncation unit tests ─────────────────────────────────
575
576    #[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    // ── Compact classification tests ─────────────────────────
665
666    #[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    // ── Integration tests ────────────────────────────────────
700
701    #[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}