Skip to main content

stuffr_cli/bench/
report.rs

1//! Output renderers for `stuffr bench`: the human table (layout B), JSON and
2//! CSV. Pure formatting over already-measured rows; no timing happens here.
3//!
4//! The size formatter is local: `stuffr_core::format_size` prints exact
5//! multiples without a decimal and uses `bytes` as the unit, while the table
6//! wants a uniform one-decimal `38.1 MiB` and `512 B`.
7
8use std::io::{self, Write};
9
10use serde_json::{Value, json};
11
12use super::measure::Mode;
13use super::score::{Measured, Outcome, Row, balance_scores, winners};
14
15/// The conditions the run was made under, shown on the input line and in
16/// JSON and CSV. The backend is per row (`Row::backend`), not here: one
17/// build can bench some codecs through C and others in pure Rust.
18#[derive(Clone, Debug)]
19pub struct Conditions {
20    pub files: usize,
21    pub bytes_in: u64,
22    pub mode: Mode,
23    /// `--threads` as given.
24    pub threads: Option<usize>,
25    /// The worker budget that setting resolved to (1 without `--threads`).
26    pub workers: usize,
27    pub repeat: u32,
28    pub streamed: bool,
29}
30
31/// The winners block's label column: the longest label plus two spaces.
32/// Padded by format rather than spelled with spaces in the literal, which
33/// the workspace's collapsed-indentation guard would flag.
34const LABEL: usize = 21;
35
36const HEADER: [&str; 6] = ["format", "level", "size", "ratio", "compress", "decompress"];
37
38/// `512 B`, else one decimal of the largest unit up to GiB. The unit is
39/// chosen on the rounded value, so 1023.96 KiB reads `1.0 MiB`, never
40/// `1024.0 KiB`.
41fn size_text(bytes: u64) -> String {
42    if bytes < 1024 {
43        return format!("{bytes} B");
44    }
45    const UNITS: [&str; 3] = ["KiB", "MiB", "GiB"];
46    let mut tenths = 0u128;
47    for (i, name) in UNITS.iter().enumerate() {
48        let unit = 1u128 << (10 * (i + 1));
49        tenths = (u128::from(bytes) * 10 + unit / 2) / unit;
50        if tenths < 10_240 || i == UNITS.len() - 1 {
51            return format!("{}.{} {name}", tenths / 10, tenths % 10);
52        }
53    }
54    unreachable!("the last unit always returns ({tenths})")
55}
56
57/// `bytes_in / bytes_out`, `None` when either is 0.
58fn ratio_of(bytes_in: u64, bytes_out: u64) -> Option<f64> {
59    (bytes_in > 0 && bytes_out > 0).then(|| bytes_in as f64 / bytes_out as f64)
60}
61
62fn ratio_text(bytes_in: u64, bytes_out: u64) -> String {
63    ratio_of(bytes_in, bytes_out).map_or_else(|| "-".into(), |r| format!("{r:.1}x"))
64}
65
66/// `0.4 ms` below 10 ms, `850 ms` below one second, otherwise `1.3 s`:
67/// rounded half up, in integers, and each form chosen on its rounded value,
68/// so 9.96 ms reads `10 ms` and 999.6 ms reads `1.0 s`.
69fn time_text(ns: u64) -> String {
70    let ns = u128::from(ns);
71    let tenth_ms = (ns + 50_000) / 100_000;
72    if tenth_ms < 100 {
73        return format!("{}.{} ms", tenth_ms / 10, tenth_ms % 10);
74    }
75    let ms = (ns + 500_000) / 1_000_000;
76    if ms < 1000 {
77        return format!("{ms} ms");
78    }
79    let tenths = (ns + 50_000_000) / 100_000_000;
80    format!("{}.{} s", tenths / 10, tenths % 10)
81}
82
83/// A failure reason fit for one table line: its first line, any other
84/// control character escaped, and `…` when lines were dropped. JSON and CSV
85/// keep the whole text.
86fn one_line(why: &str) -> String {
87    let mut lines = why.lines();
88    let first = lines.next().unwrap_or("");
89    let mut out = String::with_capacity(first.len());
90    for c in first.chars() {
91        if c.is_control() {
92            out.extend(c.escape_default());
93        } else {
94            out.push(c);
95        }
96    }
97    if lines.next().is_some() {
98        out.push_str(" …");
99    }
100    out
101}
102
103/// The input line's backend: `pure` when every row ran in pure Rust, else
104/// `C: ` and the codecs that ran through a C library, in table order.
105fn backend_text(rows: &[Row]) -> String {
106    let mut c: Vec<&str> = Vec::new();
107    for r in rows.iter().filter(|r| r.backend == "c") {
108        if !c.contains(&r.format) {
109            c.push(r.format);
110        }
111    }
112    if c.is_empty() {
113        "pure".into()
114    } else {
115        format!("C: {}", c.join(", "))
116    }
117}
118
119fn mode_name(mode: Mode) -> &'static str {
120    match mode {
121        Mode::OneStream => "one-stream",
122        Mode::PerFile => "per-file",
123    }
124}
125
126fn name_of(row: &Row) -> String {
127    match row.level {
128        Some(l) => format!("{} -{l}", row.format),
129        None => row.format.to_string(),
130    }
131}
132
133/// The resolved compression worker budget; `(auto)` when `--threads 0`
134/// chose it. Decompression is always single-threaded.
135fn threads_text(threads: Option<usize>, workers: usize) -> String {
136    let unit = if workers == 1 {
137        "compress thread"
138    } else {
139        "compress threads"
140    };
141    match threads {
142        Some(0) => format!("{workers} {unit} (auto)"),
143        _ => format!("{workers} {unit}"),
144    }
145}
146
147fn input_line(c: &Conditions, rows: &[Row]) -> String {
148    let mode = match c.mode {
149        Mode::OneStream => "one stream",
150        Mode::PerFile => "per file",
151    };
152    let files = if c.files == 1 { "file" } else { "files" };
153    let runs = if c.repeat == 1 { "run" } else { "runs" };
154    format!(
155        "input: {} {files}, {} · {mode} · {} · {} · {} {runs}{}",
156        c.files,
157        size_text(c.bytes_in),
158        threads_text(c.threads, c.workers),
159        backend_text(rows),
160        c.repeat,
161        if c.streamed {
162            ", streamed from disk"
163        } else {
164            ""
165        },
166    )
167}
168
169/// The footnote under a table with a marked row.
170const DISK_NOTE: &str = "* output written to a temp file while timed (too large for memory)";
171
172/// The compress-time cell's mark: `*` for a row whose output went to the
173/// temp file, a space for the others when any row is marked (so the
174/// column stays aligned), nothing when none is.
175fn disk_mark(on_disk: bool, any: bool) -> &'static str {
176    match (any, on_disk) {
177        (false, _) => "",
178        (true, true) => "*",
179        (true, false) => " ",
180    }
181}
182
183fn any_on_disk(rows: &[Row]) -> bool {
184    rows.iter().filter_map(measured).any(|m| m.on_disk)
185}
186
187fn output_name(m: &Measured) -> &'static str {
188    if m.on_disk { "disk" } else { "memory" }
189}
190
191fn table_lines(rows: &[Row], bytes_in: u64) -> Vec<String> {
192    let any = any_on_disk(rows);
193    let mut header: Vec<String> = HEADER.iter().map(|s| s.to_string()).collect();
194    header[4].push_str(disk_mark(false, any));
195    let mut cells: Vec<Vec<String>> = vec![header];
196    let mut failures: Vec<Option<String>> = vec![None];
197    for row in rows {
198        let level = row.level.map_or_else(|| "-".into(), |l| l.to_string());
199        match &row.outcome {
200            Outcome::Ok(m) => {
201                cells.push(vec![
202                    row.format.into(),
203                    level,
204                    size_text(m.bytes_out),
205                    ratio_text(bytes_in, m.bytes_out),
206                    format!("{}{}", time_text(m.compress_ns), disk_mark(m.on_disk, any)),
207                    time_text(m.decompress_ns),
208                ]);
209                failures.push(None);
210            }
211            Outcome::Failed(why) => {
212                cells.push(vec![row.format.into(), level]);
213                failures.push(Some(format!("failed: {}", one_line(why))));
214            }
215        }
216    }
217    let widths: Vec<usize> = (0..6)
218        .map(|i| {
219            cells
220                .iter()
221                .filter_map(|r| r.get(i))
222                .map(|s| s.chars().count())
223                .max()
224                .unwrap_or(0)
225        })
226        .collect();
227    cells
228        .iter()
229        .zip(&failures)
230        .map(|(r, failed)| {
231            let mut line = format!("{:<w$}", r[0], w = widths[0]);
232            for (i, cell) in r.iter().enumerate().skip(1) {
233                line.push_str(&format!("  {:>w$}", cell, w = widths[i]));
234            }
235            if let Some(f) = failed {
236                line.push_str("  ");
237                line.push_str(f);
238            }
239            line
240        })
241        .collect()
242}
243
244/// The table, then the footnote when a row is marked.
245fn write_table(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
246    for line in table_lines(rows, bytes_in) {
247        writeln!(out, "{}", line.trim_end())?;
248    }
249    if any_on_disk(rows) {
250        writeln!(out, "{DISK_NOTE}")?;
251    }
252    Ok(())
253}
254
255fn measured(row: &Row) -> Option<&Measured> {
256    match &row.outcome {
257        Outcome::Ok(m) => Some(m),
258        Outcome::Failed(_) => None,
259    }
260}
261
262/// The input line, the table and the winners block.
263pub fn human(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
264    writeln!(out, "{}", input_line(c, rows))?;
265    writeln!(out)?;
266    write_table(rows, c.bytes_in, out)?;
267    writeln!(out)?;
268    let w = winners(rows);
269    let pick = |i: Option<usize>| i.map(|i| (name_of(&rows[i]), measured(&rows[i]).copied()));
270    let (Some((sn, Some(sm))), Some((cn, Some(cm))), Some((dn, Some(dm))), Some((bn, Some(bm)))) = (
271        pick(w.smallest),
272        pick(w.fastest_compress),
273        pick(w.fastest_decompress),
274        pick(w.balance),
275    ) else {
276        return writeln!(out, "no format succeeded");
277    };
278    let width = [&sn, &cn, &dn, &bn]
279        .iter()
280        .map(|s| s.chars().count() + 2)
281        .max()
282        .unwrap_or(0)
283        .max(12);
284    writeln!(
285        out,
286        "{:<LABEL$}{sn:<width$}{} ({})",
287        "smallest:",
288        size_text(sm.bytes_out),
289        ratio_text(c.bytes_in, sm.bytes_out)
290    )?;
291    writeln!(
292        out,
293        "{:<LABEL$}{cn:<width$}{}",
294        "fastest compress:",
295        time_text(cm.compress_ns)
296    )?;
297    writeln!(
298        out,
299        "{:<LABEL$}{dn:<width$}{}",
300        "fastest decompress:",
301        time_text(dm.decompress_ns)
302    )?;
303    writeln!(
304        out,
305        "{:<LABEL$}{bn:<width$}{} at {} / {}",
306        "best balance:",
307        ratio_text(c.bytes_in, bm.bytes_out),
308        time_text(bm.compress_ns),
309        time_text(bm.decompress_ns)
310    )
311}
312
313/// The table alone: header, rows and the footnote when a row is marked.
314pub fn table_only(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
315    write_table(rows, bytes_in, out)
316}
317
318fn finite(x: Option<f64>) -> Value {
319    x.filter(|x| x.is_finite())
320        .map_or(Value::Null, |x| json!(x))
321}
322
323/// One JSON object: conditions, rows and winners. Scores are relative to
324/// this run.
325pub fn json(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
326    let scores = balance_scores(rows);
327    let w = winners(rows);
328    let who = |i: Option<usize>| {
329        i.map_or(
330            Value::Null,
331            |i| json!({"format": rows[i].format, "level": rows[i].level}),
332        )
333    };
334    let jrows: Vec<Value> = rows
335        .iter()
336        .zip(&scores)
337        .map(|(r, score)| match &r.outcome {
338            Outcome::Ok(m) => json!({
339                "format": r.format, "level": r.level, "backend": r.backend, "ok": true,
340                "bytes_out": m.bytes_out,
341                "ratio": finite(ratio_of(c.bytes_in, m.bytes_out)),
342                "compress_ns": m.compress_ns, "decompress_ns": m.decompress_ns,
343                "balance": finite(*score), "error": null,
344                "output": output_name(m),
345            }),
346            Outcome::Failed(why) => json!({
347                "format": r.format, "level": r.level, "backend": r.backend, "ok": false,
348                "bytes_out": null, "ratio": null,
349                "compress_ns": null, "decompress_ns": null,
350                "balance": null, "error": why, "output": null,
351            }),
352        })
353        .collect();
354    let doc = json!({
355        "conditions": {
356            "files": c.files,
357            "bytes_in": c.bytes_in,
358            "mode": mode_name(c.mode),
359            "threads": c.threads,
360            "workers": c.workers,
361            "repeat": c.repeat,
362            "streamed": c.streamed,
363        },
364        "rows": jrows,
365        "winners": {
366            "smallest": who(w.smallest),
367            "fastest_compress": who(w.fastest_compress),
368            "fastest_decompress": who(w.fastest_decompress),
369            "balance": who(w.balance),
370        },
371    });
372    writeln!(out, "{doc}")
373}
374
375fn csv_field(s: &str) -> String {
376    if s.contains([',', '"', '\n', '\r']) {
377        format!("\"{}\"", s.replace('"', "\"\""))
378    } else {
379        s.to_string()
380    }
381}
382
383/// Header plus one line per row; empty fields stand for none. The run's
384/// conditions follow the measurements on every line, then the row's
385/// backend, and last its `output` (`memory` or `disk`, empty for a failed
386/// row).
387pub fn csv(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
388    writeln!(
389        out,
390        "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
391         files,mode,threads,workers,repeat,streamed,backend,output"
392    )?;
393    let bytes_in = c.bytes_in;
394    let threads = c.threads.map_or(String::new(), |t| t.to_string());
395    let conditions = format!(
396        "{},{},{threads},{},{},{}",
397        c.files,
398        mode_name(c.mode),
399        c.workers,
400        c.repeat,
401        c.streamed
402    );
403    for (r, score) in rows.iter().zip(balance_scores(rows)) {
404        let level = r.level.map_or(String::new(), |l| l.to_string());
405        match &r.outcome {
406            Outcome::Ok(m) => {
407                let ratio =
408                    ratio_of(bytes_in, m.bytes_out).map_or(String::new(), |x| x.to_string());
409                let bal = score.map_or(String::new(), |x| x.to_string());
410                writeln!(
411                    out,
412                    "{},{level},true,{bytes_in},{},{ratio},{},{},{bal},,{conditions},{},{}",
413                    r.format,
414                    m.bytes_out,
415                    m.compress_ns,
416                    m.decompress_ns,
417                    r.backend,
418                    output_name(m)
419                )?;
420            }
421            Outcome::Failed(why) => writeln!(
422                out,
423                "{},{level},false,{bytes_in},,,,,,{},{conditions},{},",
424                r.format,
425                csv_field(why),
426                r.backend
427            )?,
428        }
429    }
430    Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    const MIB: u64 = 1 << 20;
438
439    fn ok(format: &'static str, level: Option<i32>, out: u64, c_ms: u64, d_ms: u64) -> Row {
440        Row {
441            format,
442            level,
443            backend: "pure",
444            outcome: Outcome::Ok(Measured {
445                bytes_out: out,
446                compress_ns: c_ms * 1_000_000,
447                decompress_ns: d_ms * 1_000_000,
448                on_disk: false,
449            }),
450        }
451    }
452
453    fn rows() -> Vec<Row> {
454        vec![
455            ok("zstd", Some(3), 40 * MIB, 900, 300),
456            ok("lz4", None, 100 * MIB, 600, 200),
457            ok("xz", Some(6), 25 * MIB, 8200, 1250),
458            Row {
459                format: "bzip2",
460                level: Some(9),
461                backend: "pure",
462                outcome: Outcome::Failed("no c tier".into()),
463            },
464        ]
465    }
466
467    fn cond() -> Conditions {
468        Conditions {
469            files: 3,
470            bytes_in: 412 * MIB,
471            mode: Mode::OneStream,
472            threads: None,
473            workers: 1,
474            repeat: 1,
475            streamed: false,
476        }
477    }
478
479    fn render(f: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String {
480        let mut v = Vec::new();
481        f(&mut v).unwrap();
482        String::from_utf8(v).unwrap()
483    }
484
485    #[test]
486    fn human_output_is_layout_b() {
487        let got = render(|o| human(&cond(), &rows(), o));
488        // A file, not a literal: the table's column runs would trip the
489        // workspace's collapsed-indentation guard, which scans `.rs` files.
490        let want = include_str!("testdata/human-layout-b.txt");
491        assert_eq!(got, want);
492    }
493
494    #[test]
495    fn table_only_has_no_input_line_or_winners() {
496        let got = render(|o| table_only(&rows(), 412 * MIB, o));
497        assert!(got.starts_with("format"));
498        assert!(!got.contains("input:") && !got.contains("smallest"));
499        assert_eq!(got.lines().count(), 5);
500    }
501
502    #[test]
503    fn json_parses_and_carries_conditions() {
504        let got = render(|o| json(&cond(), &rows(), o));
505        let v: Value = serde_json::from_str(&got).unwrap();
506        assert_eq!(v["conditions"]["mode"], "one-stream");
507        assert!(v["conditions"]["tier"].is_null(), "the backend is per row");
508        assert_eq!(v["rows"][0]["backend"], "pure");
509        assert_eq!(v["rows"].as_array().unwrap().len(), 4);
510        assert_eq!(v["winners"]["smallest"]["format"], "xz");
511        assert_eq!(v["rows"][3]["ok"], false);
512        assert!(!v["rows"][3]["error"].is_null());
513        assert!(v["rows"][1]["level"].is_null());
514    }
515
516    #[test]
517    fn csv_quotes_a_comma_in_an_error() {
518        let rows = vec![Row {
519            format: "xz",
520            level: Some(6),
521            backend: "c",
522            outcome: Outcome::Failed("bad, \"very\" bad".into()),
523        }];
524        let c = Conditions {
525            bytes_in: 10,
526            ..cond()
527        };
528        let got = render(|o| csv(&c, &rows, o));
529        let mut lines = got.lines();
530        assert_eq!(
531            lines.next().unwrap(),
532            "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
533             files,mode,threads,workers,repeat,streamed,backend,output"
534        );
535        assert_eq!(
536            lines.next().unwrap(),
537            "xz,6,false,10,,,,,,\"bad, \"\"very\"\" bad\",3,one-stream,,1,1,false,c,"
538        );
539    }
540
541    #[test]
542    fn zero_input_prints_a_dash_ratio() {
543        let rows = vec![ok("lz4", None, 0, 1, 1)];
544        let got = render(|o| table_only(&rows, 0, o));
545        assert!(got.lines().nth(1).unwrap().contains("  -  "), "{got}");
546        let mut c = cond();
547        c.bytes_in = 0;
548        let j = render(|o| json(&c, &rows, o));
549        assert!(serde_json::from_str::<Value>(&j).unwrap()["rows"][0]["ratio"].is_null());
550    }
551
552    #[test]
553    fn no_success_prints_no_format_succeeded() {
554        let rows = vec![Row {
555            format: "xz",
556            level: Some(1),
557            backend: "pure",
558            outcome: Outcome::Failed("x".into()),
559        }];
560        let got = render(|o| human(&cond(), &rows, o));
561        assert!(got.ends_with("\nno format succeeded\n"), "{got}");
562        assert!(!got.contains("smallest"));
563    }
564
565    #[test]
566    fn the_input_line_shows_the_resolved_worker_count() {
567        assert_eq!(threads_text(None, 1), "1 compress thread");
568        assert_eq!(threads_text(Some(1), 1), "1 compress thread");
569        assert_eq!(threads_text(Some(4), 4), "4 compress threads");
570        assert_eq!(threads_text(Some(0), 8), "8 compress threads (auto)");
571        let c = Conditions {
572            threads: Some(0),
573            workers: 8,
574            ..cond()
575        };
576        assert!(input_line(&c, &rows()).contains(" · 8 compress threads (auto) · "));
577        let j = render(|o| json(&c, &[], o));
578        let v: Value = serde_json::from_str(&j).unwrap();
579        assert_eq!(v["conditions"]["threads"], 0);
580        assert_eq!(v["conditions"]["workers"], 8);
581    }
582
583    #[test]
584    fn ms_below_a_second_s_above() {
585        assert_eq!(time_text(850_000_000), "850 ms");
586        assert_eq!(time_text(1_250_000_000), "1.3 s");
587        assert_eq!(time_text(900_000_000), "900 ms");
588    }
589
590    #[test]
591    fn csv_carries_the_conditions_and_each_rows_backend() {
592        let mut rows = rows();
593        rows[0].backend = "c";
594        let c = Conditions {
595            mode: Mode::PerFile,
596            threads: Some(0),
597            workers: 8,
598            repeat: 5,
599            streamed: true,
600            ..cond()
601        };
602        let got = render(|o| csv(&c, &rows, o));
603        let lines: Vec<&str> = got.lines().collect();
604        let header: Vec<&str> = lines[0].split(',').collect();
605        assert_eq!(header.len(), 18);
606        assert_eq!(header[17], "output");
607        assert_eq!(
608            &header[..10],
609            &[
610                "format",
611                "level",
612                "ok",
613                "bytes_in",
614                "bytes_out",
615                "ratio",
616                "compress_ns",
617                "decompress_ns",
618                "balance",
619                "error"
620            ]
621        );
622        for line in &lines[1..] {
623            let f: Vec<&str> = line.split(',').collect();
624            assert_eq!(f.len(), 18, "{line}");
625            assert_eq!(
626                &f[10..16],
627                &["3", "per-file", "0", "8", "5", "true"],
628                "{line}"
629            );
630        }
631        assert!(lines[1].starts_with("zstd,3,true,"), "{}", lines[1]);
632        assert!(lines[1].ends_with(",c,memory"), "{}", lines[1]);
633        assert!(lines[2].ends_with(",pure,memory"), "{}", lines[2]);
634        // No --threads: an empty field, not 0.
635        let got = render(|o| csv(&cond(), &rows, o));
636        assert!(
637            got.lines()
638                .nth(1)
639                .unwrap()
640                .contains(",3,one-stream,,1,1,false,")
641        );
642    }
643
644    #[test]
645    fn the_input_line_names_the_c_backed_codecs() {
646        let mut rows = rows();
647        assert!(input_line(&cond(), &rows).contains(" · pure · "));
648        rows[0].backend = "c";
649        rows[1].backend = "c";
650        rows.push(ok("zstd", Some(19), MIB, 1, 1));
651        rows.last_mut().unwrap().backend = "c";
652        let line = input_line(&cond(), &rows);
653        assert!(line.contains(" · C: zstd, lz4 · "), "{line}");
654        let j = render(|o| json(&cond(), &rows, o));
655        let v: Value = serde_json::from_str(&j).unwrap();
656        let backends: Vec<&str> = v["rows"]
657            .as_array()
658            .unwrap()
659            .iter()
660            .map(|r| r["backend"].as_str().unwrap())
661            .collect();
662        assert_eq!(backends, ["c", "c", "pure", "pure", "c"]);
663    }
664
665    #[test]
666    fn a_multi_line_failure_keeps_to_one_table_line() {
667        let mut rows = rows();
668        rows[3].outcome = Outcome::Failed("first\nsecond\tthird\u{1b}[31m".into());
669        let table = render(|o| table_only(&rows, 412 * MIB, o));
670        assert_eq!(table.lines().count(), 5, "{table}");
671        let last = table.lines().last().unwrap();
672        assert!(last.ends_with("failed: first …"), "{last}");
673        rows[3].outcome = Outcome::Failed("tab\there\u{1b}[31m".into());
674        let table = render(|o| table_only(&rows, 412 * MIB, o));
675        let last = table.lines().last().unwrap();
676        assert!(last.ends_with("failed: tab\\there\\u{1b}[31m"), "{last}");
677        // JSON and CSV keep the whole text.
678        let j = render(|o| json(&cond(), &rows, o));
679        let v: Value = serde_json::from_str(&j).unwrap();
680        assert_eq!(v["rows"][3]["error"], "tab\there\u{1b}[31m");
681    }
682
683    #[test]
684    fn times_under_10_ms_keep_a_decimal_and_edges_round_up_a_unit() {
685        assert_eq!(time_text(40_000), "0.0 ms");
686        assert_eq!(time_text(400_000), "0.4 ms");
687        assert_eq!(time_text(9_940_000), "9.9 ms");
688        // 9.96 ms rounds to 10.0, so it takes the whole-ms form.
689        assert_eq!(time_text(9_960_000), "10 ms");
690        assert_eq!(time_text(10_000_000), "10 ms");
691        // 999.6 ms rounds to 1000 ms: a second.
692        assert_eq!(time_text(999_600_000), "1.0 s");
693        assert_eq!(time_text(999_400_000), "999 ms");
694        assert_eq!(time_text(59_950_000_000), "60.0 s");
695    }
696
697    #[test]
698    fn sizes_round_up_a_unit_at_the_edge() {
699        assert_eq!(size_text(1023), "1023 B");
700        assert_eq!(size_text(1024), "1.0 KiB");
701        // 1023.96 KiB and MiB print as 1024.0 of the unit below otherwise.
702        assert_eq!(size_text(1_048_535), "1.0 MiB");
703        assert_eq!(size_text((1023.96 * MIB as f64) as u64), "1.0 GiB");
704        assert_eq!(size_text((1023.9 * MIB as f64) as u64), "1023.9 MiB");
705        assert_eq!(size_text(2 << 30), "2.0 GiB");
706        assert_eq!(size_text(412 * MIB), "412.0 MiB");
707    }
708
709    /// `rows()` with xz's output on the temp file.
710    fn marked_rows() -> Vec<Row> {
711        let mut rows = rows();
712        if let Outcome::Ok(m) = &mut rows[2].outcome {
713            m.on_disk = true;
714        }
715        rows
716    }
717
718    #[test]
719    fn a_row_timed_with_disk_output_is_marked_with_a_footnote() {
720        let got = render(|o| human(&cond(), &marked_rows(), o));
721        let want = include_str!("testdata/human-marked.txt");
722        assert_eq!(got, want);
723        let got = render(|o| table_only(&marked_rows(), 412 * MIB, o));
724        let want = include_str!("testdata/table-only-marked.txt");
725        assert_eq!(got, want);
726        // No marked row: no mark column, no footnote.
727        let plain = render(|o| table_only(&rows(), 412 * MIB, o));
728        assert!(!plain.contains('*'), "{plain}");
729    }
730
731    #[test]
732    fn json_and_csv_say_where_each_rows_output_went() {
733        let got = render(|o| json(&cond(), &marked_rows(), o));
734        let v: Value = serde_json::from_str(&got).unwrap();
735        assert_eq!(v["rows"][0]["output"], "memory");
736        assert_eq!(v["rows"][2]["output"], "disk");
737        assert!(v["rows"][3]["output"].is_null(), "a failed row");
738        let got = render(|o| csv(&cond(), &marked_rows(), o));
739        let lines: Vec<&str> = got.lines().collect();
740        assert!(lines[0].ends_with(",backend,output"), "{}", lines[0]);
741        assert!(lines[1].ends_with(",pure,memory"), "{}", lines[1]);
742        assert!(lines[3].ends_with(",pure,disk"), "{}", lines[3]);
743        assert!(lines[4].ends_with(",pure,"), "{}", lines[4]);
744        for line in &lines {
745            assert_eq!(line.split(',').count(), 18, "{line}");
746        }
747    }
748}