Skip to main content

amont_runtime/
excerpt.rs

1//! The part of a captured run worth reading when it failed.
2//!
3//! `amont-agent quiet` keeps the whole output in a log file and prints only
4//! this: the failure blocks of the run, then the runner's own summary. The
5//! heuristic never decides pass or fail (the child's exit code does); it only
6//! chooses which lines to show.
7//!
8//! Hand-written over lowercased lines with word-boundary checks, on purpose:
9//! no `regex` dependency (docs/plans/2026-10-11-test-output-keeps-its-status.md,
10//! Decisions). The same function exists in amont, which shares no crate with
11//! this one; the golden fixtures in `tests/fixtures/excerpt/` are what keep the
12//! two copies saying the same thing.
13//!
14//! ## Memory
15//!
16//! The run's output can be gigabytes, so the accumulator is fed in chunks and
17//! holds at most 64 KB whatever it is fed: the line being assembled (1 KB), a
18//! ring of the last 40 lines held to `60 KB - bound`, and the excerpt itself
19//! (finished blocks plus the block in progress) held to `bound`.
20
21use std::collections::hash_map::DefaultHasher;
22use std::collections::{HashSet, VecDeque};
23use std::hash::{Hash, Hasher};
24
25/// The excerpt total when `--max-bytes` is not given.
26pub const DEFAULT_BOUND: usize = 16 * 1024;
27/// The largest `--max-bytes` accepted.
28pub const MAX_BOUND: usize = 48 * 1024;
29
30/// What the excerpt and the ring may hold together, leaving the rest of the
31/// 64 KB for the line being assembled and bookkeeping.
32const HELD: usize = 60 * 1024;
33const LINE_CAP: usize = 1024;
34const RING_LINES: usize = 40;
35const SUMMARY_LINES: usize = 12;
36
37#[derive(Clone, Copy, PartialEq, Eq)]
38enum Mode {
39    Idle,
40    /// A block started at a matched line; it runs to a blank line or a header.
41    Block,
42    /// cargo's `failures:` … `test result:` section, taken whole.
43    Section,
44}
45
46pub struct Accumulator {
47    bound: usize,
48    partial: Vec<u8>,
49    partial_cut: bool,
50    ring: VecDeque<String>,
51    ring_bytes: usize,
52    mode: Mode,
53    block: Vec<String>,
54    block_bytes: usize,
55    block_dropped: usize,
56    seen: HashSet<u64>,
57    excerpt: Vec<String>,
58    excerpt_bytes: usize,
59    dropped: usize,
60    /// Lines seen so far; the line numbers the open block spans; and, beside
61    /// each ring line, whether a printed block already holds it — the
62    /// summary skips those, so a block near the end is not printed twice.
63    lines: usize,
64    block_first: usize,
65    block_last: usize,
66    ring_printed: VecDeque<bool>,
67    #[cfg(feature = "test-hooks")]
68    peak: usize,
69}
70
71impl Accumulator {
72    pub fn new(bound: usize) -> Self {
73        Accumulator {
74            bound: bound.clamp(1, MAX_BOUND),
75            partial: Vec::new(),
76            partial_cut: false,
77            ring: VecDeque::new(),
78            ring_bytes: 0,
79            mode: Mode::Idle,
80            block: Vec::new(),
81            block_bytes: 0,
82            block_dropped: 0,
83            seen: HashSet::new(),
84            excerpt: Vec::new(),
85            excerpt_bytes: 0,
86            dropped: 0,
87            lines: 0,
88            block_first: 0,
89            block_last: 0,
90            ring_printed: VecDeque::new(),
91            #[cfg(feature = "test-hooks")]
92            peak: 0,
93        }
94    }
95
96    /// Bytes of text currently held, the figure the 64 KB bound is about.
97    #[cfg(test)]
98    pub fn held(&self) -> usize {
99        self.partial.len() + self.ring_bytes + self.excerpt_bytes + self.block_bytes
100    }
101
102    #[cfg(feature = "test-hooks")]
103    pub fn peak(&self) -> usize {
104        self.peak
105    }
106
107    pub fn feed(&mut self, bytes: &[u8]) {
108        for &b in bytes {
109            if b == b'\n' {
110                self.end_line();
111            } else if self.partial.len() < LINE_CAP {
112                self.partial.push(b);
113            } else {
114                self.partial_cut = true;
115            }
116        }
117        #[cfg(feature = "test-hooks")]
118        {
119            let now = self.partial.len() + self.ring_bytes + self.excerpt_bytes + self.block_bytes;
120            self.peak = self.peak.max(now);
121        }
122    }
123
124    fn end_line(&mut self) {
125        let raw = std::mem::take(&mut self.partial);
126        let cut = std::mem::take(&mut self.partial_cut);
127        let mut text = clean(&String::from_utf8_lossy(&raw));
128        if cut {
129            text.push_str(" …[line cut]");
130        }
131        self.line(text);
132    }
133
134    fn line(&mut self, text: String) {
135        self.lines += 1;
136        self.ring_bytes += text.len() + 1;
137        self.ring.push_back(text.clone());
138        self.ring_printed.push_back(false);
139        let room = HELD.saturating_sub(self.bound);
140        while self.ring.len() > RING_LINES || (self.ring_bytes > room && self.ring.len() > 1) {
141            if let Some(old) = self.ring.pop_front() {
142                self.ring_bytes -= old.len() + 1;
143                self.ring_printed.pop_front();
144            }
145        }
146        // A single line over the room (bound 48 KB leaves 12 KB, a line is
147        // at most ~1 KB) cannot happen; the guard above keeps one line.
148
149        let lower = text.to_lowercase();
150        let trim = lower.trim();
151
152        match self.mode {
153            Mode::Section => {
154                if trim.starts_with("test result:") {
155                    self.flush();
156                    self.mode = Mode::Idle;
157                } else {
158                    self.push_block(text);
159                }
160            }
161            Mode::Block => {
162                if trim.is_empty() {
163                    self.flush();
164                    self.mode = Mode::Idle;
165                } else if trim == "failures:" {
166                    self.flush();
167                    self.mode = Mode::Section;
168                    self.push_block(text);
169                } else if is_header(&text, trim) {
170                    self.flush();
171                    self.mode = Mode::Idle;
172                    self.maybe_start(text, trim);
173                } else {
174                    self.push_block(text);
175                }
176            }
177            Mode::Idle => {
178                if trim == "failures:" {
179                    self.mode = Mode::Section;
180                    self.push_block(text);
181                } else {
182                    self.maybe_start(text, trim);
183                }
184            }
185        }
186    }
187
188    fn maybe_start(&mut self, text: String, trim: &str) {
189        if trim.starts_with("test result:") || trim.starts_with("---- ") {
190            return;
191        }
192        if matches(&text, trim) {
193            self.mode = Mode::Block;
194            self.push_block(text);
195        }
196    }
197
198    fn push_block(&mut self, text: String) {
199        let room = self
200            .bound
201            .saturating_sub(self.excerpt_bytes + self.block_bytes);
202        if self.block.is_empty() && self.block_dropped == 0 {
203            self.block_first = self.lines;
204        }
205        self.block_last = self.lines;
206        if text.len() < room {
207            self.block_bytes += text.len() + 1;
208            self.block.push(text);
209        } else {
210            self.block_dropped += 1;
211        }
212    }
213
214    fn flush(&mut self) {
215        let lines = std::mem::take(&mut self.block);
216        let dropped = std::mem::take(&mut self.block_dropped);
217        self.block_bytes = 0;
218        if lines.is_empty() && dropped == 0 {
219            return;
220        }
221        if self.excerpt_bytes >= self.bound {
222            // Full: count, and keep no hash, so the set stays bounded too.
223            self.dropped += lines.len() + dropped;
224            return;
225        }
226        let mut h = DefaultHasher::new();
227        lines.hash(&mut h);
228        dropped.hash(&mut h);
229        // Printed now, or already printed as an identical block: either way
230        // the summary must not repeat these lines.
231        let first_in_ring = self.lines + 1 - self.ring.len();
232        for (i, printed) in self.ring_printed.iter_mut().enumerate() {
233            let n = first_in_ring + i;
234            if (self.block_first..=self.block_last).contains(&n) {
235                *printed = true;
236            }
237        }
238        if !self.seen.insert(h.finish()) {
239            return;
240        }
241        self.dropped += dropped;
242        for l in lines {
243            if self.excerpt_bytes + l.len() < self.bound {
244                self.excerpt_bytes += l.len() + 1;
245                self.excerpt.push(l);
246            } else {
247                self.dropped += 1;
248            }
249        }
250    }
251
252    /// The text to print for a failed run. `log` names the file the rest is in.
253    pub fn finish(&mut self, log: &str) -> String {
254        if !self.partial.is_empty() || self.partial_cut {
255            self.end_line();
256        }
257        self.flush();
258        self.mode = Mode::Idle;
259
260        let mut out = String::new();
261        if self.excerpt.is_empty() && self.dropped == 0 {
262            for l in tail(&self.ring, RING_LINES) {
263                out.push_str(&l);
264                out.push('\n');
265            }
266        } else {
267            for l in &self.excerpt {
268                out.push_str(l);
269                out.push('\n');
270            }
271            if self.dropped > 0 {
272                out.push_str(&format!("… {} more lines, full log: {log}\n", self.dropped));
273            }
274            // The last lines of the output, less those a block printed.
275            let start = self.ring.len().saturating_sub(SUMMARY_LINES);
276            let unprinted: VecDeque<String> = self
277                .ring
278                .iter()
279                .zip(&self.ring_printed)
280                .skip(start)
281                .filter(|(_, printed)| !**printed)
282                .map(|(l, _)| l.clone())
283                .collect();
284            for l in tail(&unprinted, SUMMARY_LINES) {
285                out.push_str(&l);
286                out.push('\n');
287            }
288        }
289        if out.is_empty() {
290            out.push_str("(no output)\n");
291        }
292        out
293    }
294}
295
296/// The last `n` lines of the ring without blank lines at either edge.
297fn tail(ring: &VecDeque<String>, n: usize) -> Vec<String> {
298    let skip = ring.len().saturating_sub(n);
299    let mut v: Vec<String> = ring.iter().skip(skip).cloned().collect();
300    while v.last().is_some_and(|l| l.trim().is_empty()) {
301        v.pop();
302    }
303    let lead = v.iter().take_while(|l| l.trim().is_empty()).count();
304    v.drain(..lead);
305    v
306}
307
308/// Strip ANSI escape sequences and keep what a terminal would show after a
309/// carriage return (progress bars rewrite their line).
310pub fn clean(line: &str) -> String {
311    let mut out = String::with_capacity(line.len());
312    let mut it = line.chars().peekable();
313    while let Some(c) = it.next() {
314        if c != '\u{1b}' {
315            out.push(c);
316            continue;
317        }
318        match it.next() {
319            Some('[') => {
320                for n in it.by_ref() {
321                    if ('\u{40}'..='\u{7e}').contains(&n) {
322                        break;
323                    }
324                }
325            }
326            Some(']') => {
327                while let Some(n) = it.next() {
328                    if n == '\u{7}' {
329                        break;
330                    }
331                    if n == '\u{1b}' {
332                        it.next();
333                        break;
334                    }
335                }
336            }
337            _ => {}
338        }
339    }
340    let last = out
341        .split('\r')
342        .rfind(|s| !s.is_empty())
343        .unwrap_or("")
344        .to_string();
345    last.trim_end().to_string()
346}
347
348fn is_header(text: &str, trim: &str) -> bool {
349    trim.starts_with("---- ")
350        || trim == "failures:"
351        || trim.starts_with("test result:")
352        || trim.starts_with("=====")
353        || text.trim_start().starts_with("FAIL ")
354}
355
356fn is_noise(trim: &str) -> bool {
357    [
358        "compiling ",
359        "downloaded ",
360        "downloading ",
361        "finished ",
362        "fresh ",
363        "updating ",
364        "locking ",
365        "blocking ",
366        "running ",
367    ]
368    .iter()
369    .any(|p| trim.starts_with(p))
370}
371
372fn is_word(c: char) -> bool {
373    c.is_alphanumeric() || c == '_'
374}
375
376/// Whether a line reports a failure: a failure word on a word boundary that
377/// is not a success report (`0 failed`, `no errors found`, a passing test
378/// whose name contains `error`).
379fn matches(text: &str, trim: &str) -> bool {
380    if is_noise(trim) {
381        return false;
382    }
383    if trim.starts_with('✓') || trim.starts_with('✔') {
384        return false;
385    }
386    if trim.starts_with("test ") && trim.ends_with("... ok") {
387        return false;
388    }
389    if text.contains('✗') || text.contains('×') {
390        return true;
391    }
392    let mut prev = String::new();
393    for tok in text.split(|c: char| !is_word(c)).filter(|t| !t.is_empty()) {
394        let lower = tok.to_lowercase();
395        let keyword = matches!(
396            lower.as_str(),
397            "error"
398                | "errors"
399                | "failed"
400                | "failure"
401                | "failures"
402                | "fail"
403                | "panicked"
404                | "assert"
405                | "assertion"
406                | "assertionerror"
407                | "traceback"
408                | "exception"
409        ) || (tok.len() > 5
410            && (tok.ends_with("Error") || tok.ends_with("Exception")));
411        if keyword && prev != "0" && prev != "no" {
412            return true;
413        }
414        prev = lower;
415    }
416    false
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn run(input: &str) -> String {
424        let mut a = Accumulator::new(DEFAULT_BOUND);
425        a.feed(input.as_bytes());
426        a.finish("LOG")
427    }
428
429    #[test]
430    fn a_block_near_the_end_is_not_printed_twice() {
431        let out = run("building\nthread 'x' panicked at src/a.rs:3:5:\n  assertion left == right\n\ntest result: FAILED. 1 passed; 1 failed\n");
432        assert_eq!(out.matches("panicked at").count(), 1, "{out}");
433        assert!(out.contains("test result: FAILED"), "{out}");
434    }
435
436    #[test]
437    fn success_lines_do_not_match() {
438        for l in [
439            "test result: ok. 3 passed; 0 failed; 0 ignored",
440            "no errors found",
441            "0 errors, 0 warnings",
442            "test parse::handles_error_variants ... ok",
443            "   Compiling error-chain v0.12.4",
444            "    ✓ reports the error",
445        ] {
446            let lower = l.to_lowercase();
447            assert!(!matches(l, lower.trim()), "{l}");
448        }
449    }
450
451    #[test]
452    fn failure_lines_match() {
453        for l in [
454            "thread 'x' panicked at src/a.rs:1:2:",
455            "test a::b ... FAILED",
456            "E   AssertionError: no",
457            "tests/t.py:14: AssertionError",
458            "error[E0308]: mismatched types",
459            "  3:7   error    'unused' is assigned",
460            "× formats currency",
461            "FAIL  src/format.test.ts",
462            "TypeError: x is not a function",
463        ] {
464            let lower = l.to_lowercase();
465            assert!(matches(l, lower.trim()), "{l}");
466        }
467    }
468
469    #[test]
470    fn nothing_matched_gives_the_last_lines() {
471        let input: String = (0..100).map(|i| format!("line {i}\n")).collect();
472        let out = run(&input);
473        assert_eq!(out.lines().count(), 40);
474        assert!(out.ends_with("line 99\n"));
475    }
476
477    #[test]
478    fn empty_output_says_so() {
479        assert_eq!(run(""), "(no output)\n");
480    }
481
482    #[test]
483    fn a_long_line_is_cut_with_a_marker() {
484        let out = run(&format!("error {}\n", "x".repeat(5000)));
485        assert!(out.contains("…[line cut]"));
486        assert!(out.len() < 3000);
487    }
488
489    #[test]
490    fn ansi_and_carriage_returns_are_stripped() {
491        assert_eq!(clean("\u{1b}[31merror\u{1b}[0m: x"), "error: x");
492        assert_eq!(clean("10%\r50%\r100% done"), "100% done");
493    }
494
495    #[test]
496    fn the_accumulator_stays_bounded() {
497        for bound in [1024, DEFAULT_BOUND, MAX_BOUND] {
498            let mut a = Accumulator::new(bound);
499            let chunk: String = (0..2000)
500                .map(|i| {
501                    if i % 3 == 0 {
502                        format!("error: failure number {i} {}\n", "y".repeat(900))
503                    } else {
504                        format!("plain line {i} {}\n", "z".repeat(1500))
505                    }
506                })
507                .collect();
508            // ~7 MB per pass, a few passes: far past every bound.
509            for _ in 0..4 {
510                a.feed(chunk.as_bytes());
511                assert!(a.held() <= 64 * 1024, "held {} at bound {bound}", a.held());
512            }
513            let out = a.finish("LOG");
514            assert!(out.contains("more lines, full log: LOG"));
515        }
516    }
517}