Skip to main content

supercode/reduce/
normalize.rs

1//! T30/TR-4 — terminal-noise normalization (`ReductionKind::OutputNormalized`):
2//! a small, deterministic line-buffer terminal simulator that collapses ANSI
3//! color/style codes and carriage-return/erase-line/cursor-up redraws down to
4//! the FINAL rendered content of each line — the same content a human
5//! watching the build would actually see, without the hundreds of
6//! intermediate redraws a captured progress bar otherwise leaves in the
7//! transcript.
8//!
9//! **Not a full `vte` emulation** (per SPEC.md TR-4's approach sketch): this
10//! supports exactly the sequences that dominate real `cargo`/`npm`/`pip`/
11//! `docker` output —
12//!
13//! - SGR (`ESC[...m`, colors/styles) — stripped; it never prints or moves.
14//! - CR (`\r`) — cursor to column 0 of the current row.
15//! - LF (`\n`) — cursor to column 0 of the NEXT row (a deliberate
16//!   simplification: real LF preserves column, but every real capture in
17//!   this codebase's fixtures pairs LF with either a preceding CR or content
18//!   that starts a fresh line anyway, so this never diverges from the
19//!   fixtures' actual rendering and keeps the model trivial to reason about).
20//! - EL (`ESC[K`, `ESC[0K`, `ESC[1K`, `ESC[2K`) — erase to end / to start /
21//!   whole line.
22//! - CUU / CUD (`ESC[<n>A` / `ESC[<n>B`) — cursor up/down `n` rows (the
23//!   multi-line redraw idiom `docker pull` uses for concurrent layers).
24//! - CHA (`ESC[<n>G`) — cursor to absolute column `n` (1-based); the idiom
25//!   modern `npm`'s spinner uses instead of `\r`.
26//! - DEC private mode set/reset (`ESC[?...h` / `ESC[?...l`) — e.g. `?25l`/
27//!   `?25h` (cursor hide/show around a spinner): dropped silently. This is a
28//!   deliberately narrow carve-out (real DEC private modes can do much more,
29//!   e.g. the alternate screen buffer) but build-tool output never uses those
30//!   — see the module-level safety note below.
31//!
32//! Everything else — including a CSI sequence with a final byte this module
33//! doesn't recognize (device status report, cursor-position, scroll, etc.),
34//! an OSC sequence, or any escape truncated by an upstream byte cap before
35//! its terminator — is passed through **verbatim, as literal printable
36//! text**, landing in the rendered output unchanged. "Never guess": an
37//! unrecognized sequence is never assumed to be a no-op, so its bytes are
38//! never silently dropped, and the reduction is content-preserving even for
39//! escape vocabulary this module has never seen.
40//!
41//! # Safety / no-panic guarantee
42//!
43//! [`normalize`] never panics on any input, including a `&str` truncated
44//! mid-escape-sequence (the TR-1 gotcha this module was warned about:
45//! `Agent::cap_tool_output`'s 100 KB history cap can slice a raw tool output
46//! anywhere, including through the middle of a CSI/OSC sequence, before this
47//! module ever sees it). A truncated sequence at the end of the input is
48//! detected (no terminator found before the string ends) and copied through
49//! as literal text, same as any other unrecognized sequence — never a panic,
50//! never an out-of-bounds slice. See `tests::never_panics_on_malformed_input`
51//! for a sweep over adversarial byte patterns (including sequences chopped at
52//! every possible byte boundary).
53//!
54//! All scanning here is on `&str` byte offsets, but every control byte this
55//! module inspects (`ESC` 0x1B, `CR` 0x0D, `LF` 0x0A, CSI param/final bytes
56//! 0x20-0x7E) is ASCII — and ASCII bytes are never a continuation byte
57//! (0x80-0xBF) or a lead byte (0xC0-0xFF) of a multi-byte UTF-8 sequence, so
58//! every position this module treats as a slice boundary is guaranteed to
59//! already be a valid `char` boundary in a well-formed `&str`. Regular
60//! (non-control) runs between control bytes are therefore always safe to
61//! slice directly.
62
63use std::fmt::Write as _;
64
65/// Minimum byte savings (`original.len() - normalized.len()`) for
66/// [`crate::reduce::project_messages`] to accept a normalization candidate —
67/// SPEC.md TR-4's "savings floor" knob, mirrored as
68/// [`crate::reduce::ReductionPolicy::terminal_output_min_savings`]. Exposed
69/// here as the documented default; the policy field is what callers actually
70/// tune.
71pub const DEFAULT_MIN_SAVINGS: usize = 128;
72
73/// Tool names T30/TR-4's candidate rule treats as "terminal/exec" — a result
74/// from one of these is eligible for [`ReductionKind::OutputNormalized`].
75/// Compared against the INVOKING tool call's function name (see
76/// `crate::reduce::detect_normalize_candidates`), the same pattern
77/// [`crate::reduce::READ_TOOLS`] (A8) uses for read-type tools:
78///
79/// - `"bash"` — this SDK's own built-in (`tools/builtins.rs`'s
80///   `BashTool::name`); `B6` must keep this in sync with any built-in tool
81///   rename.
82/// - `"shell"` — the other shell-tool name this SDK already anticipates for
83///   embedder-registered tools (see `tools/mod.rs`'s
84///   `shell_sandbox_unenforceable`, which checks the identical pair).
85/// - `"exec_command"` — Codex's own native exec tool name, so a Codex log
86///   loaded via `Session::from_codex` (whose `function_call`/
87///   `function_call_output` records never carry a `ChatMessage::name` at
88///   all — see `detect_normalize_candidates`'s doc comment) is covered too.
89///
90/// [`ReductionKind::OutputNormalized`]: crate::reduce::ReductionKind::OutputNormalized
91pub const NORMALIZE_TOOLS: &[&str] = &["bash", "shell", "exec_command"];
92
93/// One simulated terminal row: a flat char buffer supporting index-based
94/// overwrite (what CR/EL/cursor-up redraws need) without tracking style —
95/// SGR is stripped at parse time, never simulated as row state.
96type Row = Vec<char>;
97
98/// The minimal line-buffer terminal state [`normalize`] drives.
99struct Screen {
100    rows: Vec<Row>,
101    row: usize,
102    col: usize,
103}
104
105impl Screen {
106    fn new() -> Self {
107        Screen {
108            rows: vec![Vec::new()],
109            row: 0,
110            col: 0,
111        }
112    }
113
114    /// Ensure row `r` exists, extending with empty rows as needed. Used by
115    /// LF and CUD, both of which can move onto a row not yet materialized.
116    fn ensure_row(&mut self, r: usize) {
117        while self.rows.len() <= r {
118            self.rows.push(Vec::new());
119        }
120    }
121
122    /// Write one printable char at the cursor, overwriting in place (the
123    /// redraw semantics this whole module exists for), padding with spaces
124    /// if the cursor sits past the row's current end (e.g. after a
125    /// cursor-up onto a shorter row). Then advances the cursor one column.
126    fn write_char(&mut self, c: char) {
127        let row = &mut self.rows[self.row];
128        match self.col.cmp(&row.len()) {
129            std::cmp::Ordering::Less => row[self.col] = c,
130            std::cmp::Ordering::Equal => row.push(c),
131            std::cmp::Ordering::Greater => {
132                row.resize(self.col, ' ');
133                row.push(c);
134            }
135        }
136        self.col += 1;
137    }
138
139    /// Write a run of printable text (no control bytes) starting at the
140    /// cursor — char-by-char, so multi-byte UTF-8 content (a spinner's
141    /// Braille glyphs, non-ASCII build output) is never split.
142    fn write_str(&mut self, s: &str) {
143        for c in s.chars() {
144            self.write_char(c);
145        }
146    }
147
148    fn carriage_return(&mut self) {
149        self.col = 0;
150    }
151
152    fn line_feed(&mut self) {
153        self.row += 1;
154        self.ensure_row(self.row);
155        self.col = 0;
156    }
157
158    fn cursor_up(&mut self, n: usize) {
159        self.row = self.row.saturating_sub(n);
160    }
161
162    fn cursor_down(&mut self, n: usize) {
163        self.row = (self.row + n).min(self.rows.len().saturating_sub(1));
164        self.ensure_row(self.row);
165    }
166
167    fn cursor_col_absolute(&mut self, n: usize) {
168        // CHA is 1-based; column 0 is `n == 1`. `n == 0` is out of spec but
169        // never guessed at — clamp to column 0 rather than underflowing.
170        self.col = n.saturating_sub(1);
171    }
172
173    /// EL — erase in line. `param` is the parsed numeric argument (default
174    /// `0` when absent, ECMA-48's own default for `K`).
175    fn erase_line(&mut self, param: u32) {
176        let row = &mut self.rows[self.row];
177        match param {
178            // 0: cursor to end of line.
179            0 => row.truncate(self.col.min(row.len())),
180            // 1: start of line to cursor, inclusive.
181            1 => {
182                let end = (self.col + 1).min(row.len());
183                for cell in row.iter_mut().take(end) {
184                    *cell = ' ';
185                }
186            }
187            // 2 (or anything else we don't special-case): whole line.
188            _ => row.clear(),
189        }
190    }
191
192    /// Render the final settled content: one line per row, joined by `\n` —
193    /// exactly what a plain-text capture of the same input (no CR/ESC at
194    /// all) would already look like, which is what makes [`normalize`] a
195    /// byte-exact no-op on plain output (SPEC.md TR-4 dev/04).
196    fn render(&self) -> String {
197        self.rows
198            .iter()
199            .map(|r| r.iter().collect::<String>())
200            .collect::<Vec<_>>()
201            .join("\n")
202    }
203}
204
205/// Is `b` a CSI parameter byte (ECMA-48: `0x30..=0x3F`, i.e. digits, `;`,
206/// `:`, `<`, `=`, `>`, `?`)?
207fn is_csi_param_byte(b: u8) -> bool {
208    (0x30..=0x3F).contains(&b)
209}
210
211/// Is `b` a CSI final byte (ECMA-48: `0x40..=0x7E`)?
212fn is_csi_final_byte(b: u8) -> bool {
213    (0x40..=0x7E).contains(&b)
214}
215
216/// Parse the (at most one) leading numeric parameter of a CSI param string,
217/// ignoring everything after the first `;` (none of the sequences this
218/// module simulates take more than one meaningful parameter) and any leading
219/// `?` (DEC private-mode prefix, stripped by the caller's own dispatch, but
220/// tolerated here too so a stray `?` never breaks the digit parse).
221fn first_param(params: &str) -> Option<u32> {
222    let digits: String = params
223        .split(&[';', ':'][..])
224        .next()
225        .unwrap_or("")
226        .chars()
227        .filter(|c| c.is_ascii_digit())
228        .collect();
229    if digits.is_empty() {
230        None
231    } else {
232        digits.parse().ok()
233    }
234}
235
236/// Normalize `input`: strip ANSI SGR, simulate CR/EL/CUU/CUD/CHA redraws, and
237/// return the final rendered text. Deterministic and pure — same bytes in,
238/// byte-identical text out, every call (SPEC.md TR-4's determinism
239/// requirement; see `tests::deterministic_across_repeated_runs`).
240///
241/// Never panics (see the module doc comment's safety note): a malformed or
242/// truncated escape sequence is copied through as literal text rather than
243/// ever indexing out of bounds or asserting on unexpected structure.
244pub fn normalize(input: &str) -> String {
245    let bytes = input.as_bytes();
246    let mut screen = Screen::new();
247    let mut i = 0usize;
248    let n = bytes.len();
249
250    while i < n {
251        match bytes[i] {
252            b'\r' => {
253                screen.carriage_return();
254                i += 1;
255            }
256            b'\n' => {
257                screen.line_feed();
258                i += 1;
259            }
260            0x1B => {
261                // ESC. Every branch below either fully consumes a
262                // recognized sequence, or falls back to copying whatever
263                // bytes it looked at as literal text — there is no path
264                // that advances `i` without having accounted for the bytes
265                // in between.
266                if i + 1 < n && bytes[i + 1] == b'[' {
267                    i = consume_csi(input, &mut screen, i);
268                } else if i + 1 < n && bytes[i + 1] == b']' {
269                    i = consume_osc(input, &mut screen, i);
270                } else {
271                    // A bare ESC (not CSI/OSC), or ESC as the very last
272                    // byte (truncated). Never guessed at: pass the ESC
273                    // itself through as literal text; whatever follows (if
274                    // anything) is reprocessed independently on the next
275                    // loop iteration.
276                    screen.write_char('\u{1B}');
277                    i += 1;
278                }
279            }
280            _ => {
281                // A run of regular (non-control) text up to the next
282                // control byte or end of input. Safe to slice directly —
283                // see the module doc comment on ASCII control-byte
284                // boundaries.
285                let start = i;
286                while i < n && !matches!(bytes[i], b'\r' | b'\n' | 0x1B) {
287                    i += 1;
288                }
289                screen.write_str(&input[start..i]);
290            }
291        }
292    }
293
294    screen.render()
295}
296
297/// Consume one CSI sequence (`ESC [ params final`) starting at `esc_pos`
298/// (the index of the `ESC` byte, with `bytes[esc_pos + 1] == b'['` already
299/// verified by the caller). Dispatches recognized final bytes to `screen`;
300/// anything else — including a sequence with no final byte before the input
301/// ends (truncated) — is written through as literal text. Returns the index
302/// to resume scanning from.
303fn consume_csi(input: &str, screen: &mut Screen, esc_pos: usize) -> usize {
304    let bytes = input.as_bytes();
305    let n = bytes.len();
306    let params_start = esc_pos + 2; // past `ESC [`
307    let mut j = params_start;
308    while j < n && is_csi_param_byte(bytes[j]) {
309        j += 1;
310    }
311    if j >= n || !is_csi_final_byte(bytes[j]) {
312        // No final byte found before the string ends: a truncated CSI
313        // sequence (the TR-1-flagged 100KB-cap boundary case). Pass
314        // everything from ESC to the end of input through verbatim and
315        // stop — there is nothing left to parse.
316        screen.write_str(&input[esc_pos..]);
317        return n;
318    }
319
320    let params = &input[params_start..j];
321    let final_byte = bytes[j];
322    let private = params.starts_with('?');
323
324    match final_byte {
325        b'm' => {} // SGR: stripped, no rendered effect.
326        b'K' => screen.erase_line(first_param(params).unwrap_or(0)),
327        b'A' => screen.cursor_up(first_param(params).unwrap_or(1).max(1) as usize),
328        b'B' => screen.cursor_down(first_param(params).unwrap_or(1).max(1) as usize),
329        b'G' => screen.cursor_col_absolute(first_param(params).unwrap_or(1) as usize),
330        b'h' | b'l' if private => {
331            // DEC private mode set/reset (`?25l`/`?25h` cursor hide/show,
332            // `?2004h/l` bracketed paste, etc.) — no rendered-content
333            // effect for the modes real build tools use. See the module
334            // doc comment's scoped carve-out.
335        }
336        _ => {
337            // Recognized CSI *shape*, unrecognized final byte (cursor
338            // position, device status report, erase-display, scroll,
339            // ...). Never guessed at: the whole sequence, verbatim.
340            screen.write_str(&input[esc_pos..=j]);
341        }
342    }
343    j + 1
344}
345
346/// Consume one OSC sequence (`ESC ] ... (BEL | ESC \\)`) starting at
347/// `esc_pos` (with `bytes[esc_pos + 1] == b']'` already verified). OSC
348/// payloads (window title, etc.) never move the cursor or print visible
349/// content themselves, but this module still does not special-case them —
350/// "never guess" applies to properties like OSC-8 hyperlinks wrapping
351/// visible text, which real build tools do not use but this module has no
352/// way to rule out categorically. So: pass the whole sequence through
353/// verbatim, same as any other unrecognized escape. An OSC with no
354/// terminator before the input ends is likewise passed through verbatim to
355/// the end (the truncated-sequence case). Returns the index to resume
356/// scanning from.
357fn consume_osc(input: &str, screen: &mut Screen, esc_pos: usize) -> usize {
358    let bytes = input.as_bytes();
359    let n = bytes.len();
360    let mut j = esc_pos + 2; // past `ESC ]`
361    while j < n {
362        if bytes[j] == 0x07 {
363            // BEL terminator, inclusive.
364            screen.write_str(&input[esc_pos..=j]);
365            return j + 1;
366        }
367        if bytes[j] == 0x1B && j + 1 < n && bytes[j + 1] == b'\\' {
368            // ST (`ESC \`) terminator, inclusive.
369            screen.write_str(&input[esc_pos..=(j + 1)]);
370            return j + 2;
371        }
372        j += 1;
373    }
374    // Truncated: no terminator before the input ends.
375    screen.write_str(&input[esc_pos..]);
376    n
377}
378
379/// Format the honesty trailer's summary text (SPEC.md TR-4: "normalized text
380/// must remain honest"): plain ASCII, one line, no `]` — same constraints
381/// [`crate::reduce::stub::format`] already enforces on every summary, so
382/// this is folded into the shared `[sc-reduced output-normalized <id>: ...]`
383/// grammar (see `reduce.rs`'s `OutputNormalized` candidate pass) rather than
384/// a bespoke sentinel — that keeps the existing leak-guard (A11),
385/// `stub::parse` (`sessions show-reductions`), and `Kind::from` dispatch all
386/// working for this kind with no special-casing.
387pub fn summary(original_bytes: usize, normalized_bytes: usize) -> String {
388    let mut s = String::new();
389    let _ = write!(
390        s,
391        "ANSI/redraw collapsed, {}B -> {}B - raw output in session sidecar",
392        crate::reduce::format_commas(original_bytes),
393        crate::reduce::format_commas(normalized_bytes),
394    );
395    s
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn strips_sgr_color_and_style() {
404        let input = "\x1b[1m\x1b[32m   Compiling\x1b[0m serde v1.0.0\r\n";
405        assert_eq!(normalize(input), "   Compiling serde v1.0.0\n");
406    }
407
408    #[test]
409    fn cr_overwrite_collapses_to_last_frame() {
410        // Three redraws of the same progress line via bare `\r`; only the
411        // final frame should survive.
412        let input = "Progress: 10%\rProgress: 55%\rProgress: 100% done";
413        assert_eq!(normalize(input), "Progress: 100% done");
414    }
415
416    #[test]
417    fn cr_overwrite_shorter_frame_leaves_stale_tail_untouched() {
418        // A real terminal does NOT erase what a shorter overwrite doesn't
419        // reach — that's what EL is for. Verifies our model matches that
420        // (rather than assuming CR alone clears the rest of the line).
421        let input = "AAAAAAAAAA\rBB";
422        assert_eq!(normalize(input), "BBAAAAAAAA");
423    }
424
425    #[test]
426    fn el0_erase_to_end_then_overwrite_prefix() {
427        let input = "hello world\r\x1b[0Khi";
428        // \r -> col 0; EL0 erases the whole line (cursor at col 0, erase to
429        // end == everything); "hi" is then written at col 0-1.
430        assert_eq!(normalize(input), "hi");
431    }
432
433    #[test]
434    fn el2_erases_whole_line_regardless_of_cursor() {
435        let input = "some stale content\x1b[5G\x1b[2Kfresh";
436        assert_eq!(normalize(input), "    fresh");
437    }
438
439    #[test]
440    fn el_bare_defaults_to_param_zero() {
441        // "keep" -> cursor to col 3 (1-based `3G`) -> bare `K` (default
442        // param 0: erase cursor-to-end, dropping the trailing "ep") -> "?"
443        // appended. If the bare form were mis-defaulted to "no erase" the
444        // result would instead be "ke?p" (the un-erased "p" surviving).
445        let input = "keep\x1b[3G\x1b[K?";
446        assert_eq!(normalize(input), "ke?");
447    }
448
449    #[test]
450    fn cursor_up_multiline_redraw_collapses_to_final_frame() {
451        // Two "layers" printed on their own lines, then cursor-up 2 to
452        // redraw the first one, cursor-down back to the bottom.
453        let input = "layer-1: 10%\nlayer-2: 20%\n\x1b[2A\rlayer-1: 100%\x1b[K\x1b[2B";
454        assert_eq!(normalize(input), "layer-1: 100%\nlayer-2: 20%\n");
455    }
456
457    #[test]
458    fn cha_moves_to_absolute_column_like_npm_spinner() {
459        // The real npm idiom captured in this branch's fixtures: glyph,
460        // CHA(1), EL0 — the glyph is printed then immediately erased.
461        let input = "\u{280f}\x1b[1G\x1b[0Kdone";
462        assert_eq!(normalize(input), "done");
463    }
464
465    #[test]
466    fn cursor_hide_show_stripped_silently() {
467        let input = "\x1b[?25lworking\x1b[?25h";
468        assert_eq!(normalize(input), "working");
469    }
470
471    #[test]
472    fn unknown_csi_sequence_passes_through_verbatim() {
473        // Cursor Position Report (DSR, `ESC[6n`) — not simulated, must
474        // survive byte-for-byte (SPEC.md TR-4 dev/03's named example).
475        let input = "before\x1b[6nafter";
476        assert_eq!(normalize(input), "before\x1b[6nafter");
477    }
478
479    #[test]
480    fn unknown_osc_sequence_passes_through_verbatim() {
481        let input = "\x1b]0;window title\x07visible";
482        assert_eq!(normalize(input), "\x1b]0;window title\x07visible");
483    }
484
485    #[test]
486    fn plain_text_is_byte_identical() {
487        for input in [
488            "no escapes here at all\nsecond line\n",
489            "single line, no trailing newline",
490            "",
491            "unicode: caf\u{e9}, \u{1f980}, \u{4e2d}\u{6587}\n",
492        ] {
493            assert_eq!(normalize(input), input, "input={input:?}");
494        }
495    }
496
497    #[test]
498    fn idempotent_on_already_normalized_text() {
499        let cases = [
500            "\x1b[1m\x1b[32m   Compiling\x1b[0m serde v1.0.0\r\n",
501            "Progress: 10%\rProgress: 55%\rProgress: 100% done",
502            "layer-1: 10%\nlayer-2: 20%\n\x1b[2A\rlayer-1: 100%\x1b[K\x1b[2B",
503            "before\x1b[6nafter",
504        ];
505        for input in cases {
506            let once = normalize(input);
507            let twice = normalize(&once);
508            assert_eq!(once, twice, "not idempotent for input={input:?}");
509        }
510    }
511
512    #[test]
513    fn deterministic_across_repeated_runs() {
514        let input = "\x1b[1mA\x1b[0m\rB\x1b[Khello\x1b[2Ax\x1b[2B\x1b[?25lY\x1b[?25h";
515        let first = normalize(input);
516        for _ in 0..20 {
517            assert_eq!(normalize(input), first);
518        }
519    }
520
521    #[test]
522    fn truncated_trailing_csi_does_not_panic() {
523        // Simulates the 100KB history-cap boundary slicing a CSI sequence
524        // at every possible point.
525        let full = "hello\x1b[1;32mworld\x1b[0m\r\nmore\x1b[38;5;196m!!";
526        for end in 0..=full.len() {
527            if !full.is_char_boundary(end) {
528                continue;
529            }
530            let slice = &full[..end];
531            let _ = normalize(slice); // must not panic
532        }
533    }
534
535    #[test]
536    fn truncated_trailing_osc_does_not_panic() {
537        let full = "before\x1b]0;some long title that never terminates";
538        for end in 0..=full.len() {
539            if !full.is_char_boundary(end) {
540                continue;
541            }
542            let _ = normalize(&full[..end]);
543        }
544    }
545
546    #[test]
547    fn never_panics_on_malformed_input() {
548        // A fuzz-ish sweep: raw ESC bytes in arbitrary positions/combinations
549        // that are not well-formed CSI/OSC sequences at all.
550        let seeds: &[&str] = &[
551            "\x1b",
552            "\x1b[",
553            "\x1b]",
554            "\x1b[?",
555            "\x1b[;;;;",
556            "\x1b[999999999999999999999999999999A",
557            "\x1bXY\x1b[Z\x1b]nope",
558            "\r\r\r\r\n\n\n\x1b[K\x1b[2A\x1b[500B",
559            "\x1b[?25h\x1b[?25l\x1b[?1049h",
560            "plain \x1b[38;2;255;0;0mtruecolor\x1b[0m text",
561        ];
562        for s in seeds {
563            let _ = normalize(s);
564        }
565        // Byte-level garbage that is not even valid UTF-8 on its own is not
566        // a concern here since `normalize` takes `&str` (already-validated
567        // text, matching `ChatMessage::content`'s type) — but a lone ESC
568        // followed by high-bit-set-but-still-valid-UTF8 sequences is worth
569        // covering explicitly.
570        let with_unicode = "\x1b[1m\u{1f680}\x1b[0m\r\u{1f525}\x1b[K";
571        let _ = normalize(with_unicode);
572    }
573
574    #[test]
575    fn summary_is_plain_ascii_one_line_no_bracket() {
576        let s = summary(41_203, 1_876);
577        assert!(s.is_ascii(), "{s:?}");
578        assert!(!s.contains('\n'), "{s:?}");
579        assert!(!s.contains(']'), "{s:?}");
580        assert!(s.contains("41,203B"), "{s:?}");
581        assert!(s.contains("1,876B"), "{s:?}");
582    }
583}