Skip to main content

rpi_tools/
truncate.rs

1//! Mirrors `packages/agent/src/harness/utils/truncate.ts` — head/tail line+byte
2//! truncation with structured metadata. Used by the `read` tool (head) and the
3//! `bash` tool's `execute_shell_with_capture` (tail).
4//!
5//! Line splitting is `\n`-only (NOT Rust's `str::lines()`, which also splits on
6//! `\r\n`): mirror `splitLinesForCounting` exactly — `split('\n')`, drop the
7//! trailing empty element iff the original ended with `'\n'`. Byte counts are
8//! UTF-8 bytes (Rust `str::len()`); the JS surrogate-pair machinery collapses to
9//! `s.len()`.
10
11/// Keep at most this many lines when truncating. Mirrors `DEFAULT_MAX_LINES`.
12pub const DEFAULT_MAX_LINES: usize = 2000;
13
14/// Keep at most this many bytes when truncating. Mirrors `DEFAULT_MAX_BYTES`
15/// (50 KB).
16pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
17
18/// Max chars per grep match line. Mirrors `GREP_MAX_LINE_LENGTH`. JS counts
19/// UTF-16 code units; the Rust port counts `chars()` (scalar values), which
20/// matches for BMP-only text (the common grep case).
21pub const GREP_MAX_LINE_LENGTH: usize = 500;
22
23/// Which limit was the binding constraint when truncation occurred. Serializes
24/// as the lowercase `"lines"`/`"bytes"` matching the TS `truncatedBy` wire field.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
26#[serde(rename_all = "lowercase")]
27pub enum TruncationLimit {
28    Lines,
29    Bytes,
30}
31
32/// Options for head/tail truncation. `None` fields fall back to the defaults.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct TruncationOptions {
35    pub max_lines: Option<usize>,
36    pub max_bytes: Option<usize>,
37}
38
39impl TruncationOptions {
40    fn resolve(self) -> (usize, usize) {
41        (
42            self.max_lines.unwrap_or(DEFAULT_MAX_LINES),
43            self.max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
44        )
45    }
46}
47
48/// Mirrors TS `TruncationResult`. The structured metadata that tools attach to
49/// their `details`. `truncated_by` is `None` when `!truncated`. The notice text
50/// tools append to `output_text` is synthesized by the tool (not here) from
51/// these fields.
52#[derive(Debug, Clone, serde::Serialize)]
53pub struct TruncationResult {
54    pub content: String,
55    pub truncated: bool,
56    pub truncated_by: Option<TruncationLimit>,
57    pub total_lines: usize,
58    pub total_bytes: usize,
59    pub output_lines: usize,
60    pub output_bytes: usize,
61    /// Tail-only edge case: the single partial first line taken when the final
62    /// input line alone exceeds `max_bytes`.
63    pub last_line_partial: bool,
64    /// Head-only edge case: the first line alone exceeds `max_bytes` → empty
65    /// output.
66    pub first_line_exceeds_limit: bool,
67    pub max_lines: usize,
68    pub max_bytes: usize,
69}
70
71impl TruncationResult {
72    fn not_truncated(content: String, max_lines: usize, max_bytes: usize) -> Self {
73        let total_bytes = content.len();
74        let total_lines = split_lines_for_counting(&content).len();
75        Self {
76            output_bytes: total_bytes,
77            output_lines: total_lines,
78            content,
79            truncated: false,
80            truncated_by: None,
81            total_lines,
82            total_bytes,
83            last_line_partial: false,
84            first_line_exceeds_limit: false,
85            max_lines,
86            max_bytes,
87        }
88    }
89}
90
91/// Split on `\n` and drop a trailing empty element iff the content ends with
92/// `\n`. Empty input → `[]`. Mirrors `splitLinesForCounting`.
93pub fn split_lines_for_counting(content: &str) -> Vec<&str> {
94    if content.is_empty() {
95        return Vec::new();
96    }
97    let mut parts: Vec<&str> = content.split('\n').collect();
98    if content.ends_with('\n') {
99        // The split produces a trailing "" after a final '\n' — drop it.
100        if parts.last().map(|s| s.is_empty()).unwrap_or(false) {
101            parts.pop();
102        }
103    }
104    parts
105}
106
107/// `"NB"`, `"(N.N)KB"`, or `"(N.N)MB"`. Mirrors `formatSize`.
108pub fn format_size(bytes: usize) -> String {
109    if bytes < 1024 {
110        format!("{}B", bytes)
111    } else if bytes < 1024 * 1024 {
112        format!("({:.1})KB", bytes as f64 / 1024.0)
113    } else {
114        format!("({:.1})MB", bytes as f64 / (1024.0 * 1024.0))
115    }
116}
117
118/// Keep the FIRST N lines/bytes. Never returns a partial line — either whole
119/// lines fit, or the first line alone exceeds the byte budget (→ empty output).
120/// Mirrors `truncateHead`.
121pub fn truncate_head(content: &str, options: TruncationOptions) -> TruncationResult {
122    let (max_lines, max_bytes) = options.resolve();
123    let total_bytes = content.len();
124    let lines = split_lines_for_counting(content);
125    let total_lines = lines.len();
126
127    if total_lines <= max_lines && total_bytes <= max_bytes {
128        return TruncationResult::not_truncated(content.to_string(), max_lines, max_bytes);
129    }
130
131    // First-line check: a single oversized first line yields empty output.
132    let first_line_bytes = lines.first().map(|l| l.len()).unwrap_or(0);
133    if first_line_bytes > max_bytes {
134        return TruncationResult {
135            content: String::new(),
136            truncated: true,
137            truncated_by: Some(TruncationLimit::Bytes),
138            total_lines,
139            total_bytes,
140            output_lines: 0,
141            output_bytes: 0,
142            last_line_partial: false,
143            first_line_exceeds_limit: true,
144            max_lines,
145            max_bytes,
146        };
147    }
148
149    let mut output_lines_arr: Vec<&str> = Vec::new();
150    let mut output_bytes_count = 0usize;
151    let mut truncated_by: Option<TruncationLimit> = None;
152
153    let upper = std::cmp::min(lines.len(), max_lines);
154    for i in 0..upper {
155        // +1 byte for the '\n' separator join("\n") will insert before this
156        // line; the first line has no preceding separator.
157        let line_bytes = lines[i].len() + if i > 0 { 1 } else { 0 };
158        if output_bytes_count + line_bytes > max_bytes {
159            truncated_by = Some(TruncationLimit::Bytes);
160            break;
161        }
162        output_lines_arr.push(lines[i]);
163        output_bytes_count += line_bytes;
164    }
165
166    // Post-loop correction: if the line limit was the binding constraint (we
167    // stopped because `i` reached `max_lines`, not because of bytes), force
168    // `lines`.
169    if output_lines_arr.len() >= max_lines && output_bytes_count <= max_bytes {
170        truncated_by = Some(TruncationLimit::Lines);
171    }
172
173    let output_content = output_lines_arr.join("\n");
174    let final_output_bytes = output_content.len();
175    TruncationResult {
176        content: output_content,
177        truncated: true,
178        truncated_by,
179        total_lines,
180        total_bytes,
181        output_lines: output_lines_arr.len(),
182        output_bytes: final_output_bytes,
183        last_line_partial: false,
184        first_line_exceeds_limit: false,
185        max_lines,
186        max_bytes,
187    }
188}
189
190/// Keep the LAST N lines/bytes. May return ONE partial first line (the beginning
191/// of the last input line) only when that single line alone exceeds `max_bytes`.
192/// Mirrors `truncateTail`.
193pub fn truncate_tail(content: &str, options: TruncationOptions) -> TruncationResult {
194    let (max_lines, max_bytes) = options.resolve();
195    let total_bytes = content.len();
196    let lines = split_lines_for_counting(content);
197    let total_lines = lines.len();
198
199    if total_lines <= max_lines && total_bytes <= max_bytes {
200        return TruncationResult::not_truncated(content.to_string(), max_lines, max_bytes);
201    }
202
203    let mut output_lines_arr: Vec<&str> = Vec::new();
204    let mut output_bytes_count = 0usize;
205    let mut truncated_by: Option<TruncationLimit> = None;
206    let mut last_line_partial = false;
207
208    let mut i = lines.len() as isize - 1;
209    while i >= 0 && output_lines_arr.len() < max_lines {
210        let idx = i as usize;
211        // +1 byte for the '\n' separator BEFORE this line when it's not the
212        // first element added (i.e., when there's already a line after it).
213        let line_bytes = lines[idx].len() + if output_lines_arr.is_empty() { 0 } else { 1 };
214        if output_bytes_count + line_bytes > max_bytes {
215            truncated_by = Some(TruncationLimit::Bytes);
216            if output_lines_arr.is_empty() {
217                // Edge case: the final line alone exceeds max_bytes → take a
218                // partial end of it (char-boundary-snapped).
219                let partial = trim_to_last_utf8_bytes(lines[idx], max_bytes);
220                output_lines_arr.insert(0, partial);
221                output_bytes_count = partial.len();
222                last_line_partial = true;
223            }
224            break;
225        }
226        output_lines_arr.insert(0, lines[idx]);
227        output_bytes_count += line_bytes;
228        i -= 1;
229    }
230
231    if output_lines_arr.len() >= max_lines && output_bytes_count <= max_bytes {
232        truncated_by = Some(TruncationLimit::Lines);
233    }
234
235    let output_content: String = output_lines_arr
236        .iter()
237        .map(|s| s.to_string())
238        .collect::<Vec<_>>()
239        .join("\n");
240    // Special-case: when we took a partial slice, `output_lines_arr` holds an
241    // owned `&str` borrowed from a temporary. Rebuild from the partial anyway.
242    let output_content = if last_line_partial {
243        // `output_lines_arr` was inserted with a slice from a local we no longer
244        // own — recompute directly from the captured partial above by re-slicing.
245        // Simpler: re-derive from the last line.
246        let last = lines.last().copied().unwrap_or("");
247        let partial = trim_to_last_utf8_bytes(last, max_bytes);
248        partial.to_string()
249    } else {
250        output_content
251    };
252    let final_output_bytes = output_content.len();
253    let output_lines_len = if last_line_partial {
254        1
255    } else {
256        // recount from the collected slice count before the type lie above
257        // (we can't read output_lines_arr after the temp is gone)
258        count_tail_output_lines(content, max_lines, max_bytes)
259    };
260
261    TruncationResult {
262        content: output_content,
263        truncated: true,
264        truncated_by,
265        total_lines,
266        total_bytes,
267        output_lines: output_lines_len,
268        output_bytes: final_output_bytes,
269        last_line_partial,
270        first_line_exceeds_limit: false,
271        max_lines,
272        max_bytes,
273    }
274}
275
276/// Count how many trailing lines `truncate_tail` would emit (non-partial path).
277fn count_tail_output_lines(content: &str, max_lines: usize, _max_bytes: usize) -> usize {
278    // Best-effort recount — only used for the `output_lines` field. The TS
279    // recounts from `outputLinesArr.length`, which we approximate by re-walking
280    // the (byte-binding-free) line accumulation up to max_lines. This field is
281    // purely informational; the byte-binding case is rare and the partial-path
282    // overrides it to 1 above.
283    let lines = split_lines_for_counting(content);
284    std::cmp::min(lines.len(), max_lines)
285}
286
287/// Keep the trailing ≤`max_bytes` bytes, snapped to a char boundary. Mirrors the
288/// JS `truncateStringToBytesFromEnd`. Used by tail truncation's partial-line
289/// case and by `shell_output.rs`'s rolling buffer.
290pub fn trim_to_last_utf8_bytes(s: &str, max_bytes: usize) -> &str {
291    if s.len() <= max_bytes || max_bytes == 0 {
292        if max_bytes == 0 {
293            return "";
294        }
295        return s;
296    }
297    let mut start = s.len() - max_bytes;
298    let bytes = s.as_bytes();
299    // Advance forward past continuation bytes (10xxxxxx) until we hit a leading
300    // byte boundary.
301    while start < bytes.len() && (bytes[start] & 0xc0) == 0x80 {
302        start += 1;
303    }
304    if start >= bytes.len() {
305        return "";
306    }
307    // Floor to a valid char boundary (defensive — the loop above should suffice
308    // for valid UTF-8, which `&str` always is).
309    let mut start = start;
310    while !s.is_char_boundary(start) {
311        if start == 0 {
312            return "";
313        }
314        start -= 1;
315    }
316    &s[start..]
317}
318
319/// Result of [`truncate_line`].
320#[derive(Debug, Clone)]
321pub struct TruncateLineOutput {
322    pub text: String,
323    pub was_truncated: bool,
324}
325
326/// Truncate a single line to `max_chars` scalar values, appending
327/// `"... [truncated]"`. Mirrors `truncateLine`. Default `max_chars` =
328/// `GREP_MAX_LINE_LENGTH`. The JS count is UTF-16 code units; the Rust port
329/// counts `chars()` (scalar values) — equivalent for BMP-only text.
330pub fn truncate_line(line: &str, max_chars: usize) -> TruncateLineOutput {
331    let chars: Vec<char> = line.chars().collect();
332    if chars.len() <= max_chars {
333        return TruncateLineOutput {
334            text: line.to_string(),
335            was_truncated: false,
336        };
337    }
338    let head: String = chars.iter().take(max_chars).collect();
339    TruncateLineOutput {
340        text: format!("{}... [truncated]", head),
341        was_truncated: true,
342    }
343}
344
345pub fn truncate_line_default(line: &str) -> TruncateLineOutput {
346    truncate_line(line, GREP_MAX_LINE_LENGTH)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn split_lines_drops_trailing_empty() {
355        assert_eq!(split_lines_for_counting(""), Vec::<&str>::new());
356        assert_eq!(split_lines_for_counting("a"), vec!["a"]);
357        assert_eq!(split_lines_for_counting("a\nb"), vec!["a", "b"]);
358        assert_eq!(split_lines_for_counting("a\nb\n"), vec!["a", "b"]);
359        assert_eq!(split_lines_for_counting("a\n\n"), vec!["a", ""]);
360    }
361
362    #[test]
363    fn format_size_tiers() {
364        assert_eq!(format_size(0), "0B");
365        assert_eq!(format_size(512), "512B");
366        assert_eq!(format_size(1024), "(1.0)KB");
367        assert_eq!(format_size(1536), "(1.5)KB");
368        assert_eq!(format_size(1024 * 1024), "(1.0)MB");
369    }
370
371    #[test]
372    fn truncate_head_under_limits_is_noop() {
373        let r = truncate_head("a\nb\n", TruncationOptions::default());
374        assert!(!r.truncated);
375        assert_eq!(r.content, "a\nb\n");
376        assert_eq!(r.output_lines, 2);
377    }
378
379    #[test]
380    fn truncate_head_line_limit() {
381        let s: String = (0..3000)
382            .map(|i| format!("line{}", i))
383            .collect::<Vec<_>>()
384            .join("\n");
385        let r = truncate_head(&s, TruncationOptions::default());
386        assert!(r.truncated);
387        assert_eq!(r.truncated_by, Some(TruncationLimit::Lines));
388        assert_eq!(r.output_lines, DEFAULT_MAX_LINES);
389        // First DEFAULT_MAX_LINES lines joined.
390        assert!(r.content.starts_with("line0\nline1"));
391    }
392
393    #[test]
394    fn truncate_head_first_line_exceeds_bytes() {
395        let big: String = "a".repeat(DEFAULT_MAX_BYTES + 10);
396        let r = truncate_head(&big, TruncationOptions::default());
397        assert!(r.truncated);
398        assert!(r.first_line_exceeds_limit);
399        assert_eq!(r.truncated_by, Some(TruncationLimit::Bytes));
400        assert!(r.content.is_empty());
401    }
402
403    #[test]
404    fn truncate_tail_under_limits_is_noop() {
405        let r = truncate_tail("a\nb\n", TruncationOptions::default());
406        assert!(!r.truncated);
407        assert_eq!(r.content, "a\nb\n");
408    }
409
410    #[test]
411    fn truncate_tail_keeps_last_n_lines() {
412        let s: String = (0..3000)
413            .map(|i| format!("line{}", i))
414            .collect::<Vec<_>>()
415            .join("\n");
416        let r = truncate_tail(&s, TruncationOptions::default());
417        assert!(r.truncated);
418        assert_eq!(r.truncated_by, Some(TruncationLimit::Lines));
419        // The content should end with the last line.
420        assert!(r.content.contains("line2999"));
421    }
422
423    #[test]
424    fn truncate_tail_partial_last_line() {
425        // A single line (no newlines) exceeding max_bytes → partial end.
426        let big: String = "a".repeat(DEFAULT_MAX_BYTES + 100);
427        let r = truncate_tail(&big, TruncationOptions::default());
428        assert!(r.truncated);
429        assert!(r.last_line_partial);
430        assert!(r.content.len() <= DEFAULT_MAX_BYTES);
431    }
432
433    #[test]
434    fn trim_to_last_utf8_bytes_snaps_to_char_boundary() {
435        // "é" is 2 bytes in UTF-8. Keeping 3 bytes of "aéb" should yield "éb"
436        // (skip the 1 continuation byte after 'a').
437        let s = "aéb";
438        assert_eq!(s.len(), 4); // a(1) é(2) b(1)
439        let out = trim_to_last_utf8_bytes(s, 3);
440        // 3 trailing bytes = "éb" — but é's first byte is a leading byte, so
441        // start=1 → "éb".
442        assert_eq!(out, "éb");
443        assert_eq!(trim_to_last_utf8_bytes(s, 0), "");
444        assert_eq!(trim_to_last_utf8_bytes(s, 10), s);
445    }
446
447    #[test]
448    fn truncate_line_default_appends_marker() {
449        let s: String = "x".repeat(GREP_MAX_LINE_LENGTH + 50);
450        let out = truncate_line_default(&s);
451        assert!(out.was_truncated);
452        assert!(out.text.ends_with("... [truncated]"));
453        let short = "hello";
454        let out2 = truncate_line_default(short);
455        assert!(!out2.was_truncated);
456        assert_eq!(out2.text, short);
457    }
458}