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    pub memory: MemoryMode,
30}
31
32/// Whether the memory columns exist in this run.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum MemoryMode {
35    /// The memory runs were made: columns, winner line, JSON and CSV values.
36    Measured,
37    /// `--no-memory`: nothing about memory is shown.
38    Skipped,
39    /// A build without `getrusage` max-RSS: only the input line says so.
40    Unsupported,
41}
42
43/// The winners block's label column: the longest label plus two spaces.
44/// Padded by format rather than spelled with spaces in the literal, which
45/// the workspace's collapsed-indentation guard would flag.
46const LABEL: usize = 21;
47
48const HEADER: [&str; 6] = ["format", "level", "size", "ratio", "compress", "decompress"];
49const MEM_HEADER: [&str; 2] = ["c-mem", "d-mem"];
50
51/// A memory cell: the size, or `-` for a number that was not measured.
52fn mem_text(bytes: Option<u64>) -> String {
53    bytes.map_or_else(|| "-".into(), size_text)
54}
55
56/// `512 B`, else one decimal of the largest unit up to GiB. The unit is
57/// chosen on the rounded value, so 1023.96 KiB reads `1.0 MiB`, never
58/// `1024.0 KiB`.
59fn size_text(bytes: u64) -> String {
60    if bytes < 1024 {
61        return format!("{bytes} B");
62    }
63    const UNITS: [&str; 3] = ["KiB", "MiB", "GiB"];
64    let mut tenths = 0u128;
65    for (i, name) in UNITS.iter().enumerate() {
66        let unit = 1u128 << (10 * (i + 1));
67        tenths = (u128::from(bytes) * 10 + unit / 2) / unit;
68        if tenths < 10_240 || i == UNITS.len() - 1 {
69            return format!("{}.{} {name}", tenths / 10, tenths % 10);
70        }
71    }
72    unreachable!("the last unit always returns ({tenths})")
73}
74
75/// `bytes_in / bytes_out`, `None` when either is 0.
76fn ratio_of(bytes_in: u64, bytes_out: u64) -> Option<f64> {
77    (bytes_in > 0 && bytes_out > 0).then(|| bytes_in as f64 / bytes_out as f64)
78}
79
80fn ratio_text(bytes_in: u64, bytes_out: u64) -> String {
81    ratio_of(bytes_in, bytes_out).map_or_else(|| "-".into(), |r| format!("{r:.1}x"))
82}
83
84/// `0.4 ms` below 10 ms, `850 ms` below one second, otherwise `1.3 s`:
85/// rounded half up, in integers, and each form chosen on its rounded value,
86/// so 9.96 ms reads `10 ms` and 999.6 ms reads `1.0 s`.
87fn time_text(ns: u64) -> String {
88    let ns = u128::from(ns);
89    let tenth_ms = (ns + 50_000) / 100_000;
90    if tenth_ms < 100 {
91        return format!("{}.{} ms", tenth_ms / 10, tenth_ms % 10);
92    }
93    let ms = (ns + 500_000) / 1_000_000;
94    if ms < 1000 {
95        return format!("{ms} ms");
96    }
97    let tenths = (ns + 50_000_000) / 100_000_000;
98    format!("{}.{} s", tenths / 10, tenths % 10)
99}
100
101/// A failure reason fit for one table line: its first line, any other
102/// control character escaped, and `…` when lines were dropped. JSON and CSV
103/// keep the whole text.
104fn one_line(why: &str) -> String {
105    let mut lines = why.lines();
106    let first = lines.next().unwrap_or("");
107    let mut out = String::with_capacity(first.len());
108    for c in first.chars() {
109        if c.is_control() {
110            out.extend(c.escape_default());
111        } else {
112            out.push(c);
113        }
114    }
115    if lines.next().is_some() {
116        out.push_str(" …");
117    }
118    out
119}
120
121/// The input line's backend: `pure` when every row ran in pure Rust, else
122/// `C: ` and the codecs that ran through a C library, in table order.
123fn backend_text(rows: &[Row]) -> String {
124    let mut c: Vec<&str> = Vec::new();
125    for r in rows.iter().filter(|r| r.backend == "c") {
126        if !c.contains(&r.format) {
127            c.push(r.format);
128        }
129    }
130    if c.is_empty() {
131        "pure".into()
132    } else {
133        format!("C: {}", c.join(", "))
134    }
135}
136
137fn mode_name(mode: Mode) -> &'static str {
138    match mode {
139        Mode::OneStream => "one-stream",
140        Mode::PerFile => "per-file",
141    }
142}
143
144fn name_of(row: &Row) -> String {
145    match row.level {
146        Some(l) => format!("{} -{l}", row.format),
147        None => row.format.to_string(),
148    }
149}
150
151/// The resolved compression worker budget; `(auto)` when `--threads 0`
152/// chose it. Decompression is always single-threaded.
153fn threads_text(threads: Option<usize>, workers: usize) -> String {
154    let unit = if workers == 1 {
155        "compress thread"
156    } else {
157        "compress threads"
158    };
159    match threads {
160        Some(0) => format!("{workers} {unit} (auto)"),
161        _ => format!("{workers} {unit}"),
162    }
163}
164
165fn input_line(c: &Conditions, rows: &[Row]) -> String {
166    let mode = match c.mode {
167        Mode::OneStream => "one stream",
168        Mode::PerFile => "per file",
169    };
170    let files = if c.files == 1 { "file" } else { "files" };
171    let runs = if c.repeat == 1 { "run" } else { "runs" };
172    format!(
173        "input: {} {files}, {} · {mode} · {} · {} · {} {runs}{}",
174        c.files,
175        size_text(c.bytes_in),
176        threads_text(c.threads, c.workers),
177        backend_text(rows),
178        c.repeat,
179        if c.streamed {
180            ", streamed from disk"
181        } else {
182            ""
183        },
184    ) + if c.memory == MemoryMode::Unsupported {
185        ", memory not measured on this platform"
186    } else {
187        ""
188    }
189}
190
191/// The footnote under a table with a marked row.
192const DISK_NOTE: &str = "* output written to a temp file while timed (too large for memory)";
193
194/// The compress-time cell's mark: `*` for a row whose output went to the
195/// temp file, a space for the others when any row is marked (so the
196/// column stays aligned), nothing when none is.
197fn disk_mark(on_disk: bool, any: bool) -> &'static str {
198    match (any, on_disk) {
199        (false, _) => "",
200        (true, true) => "*",
201        (true, false) => " ",
202    }
203}
204
205fn any_on_disk(rows: &[Row]) -> bool {
206    rows.iter().filter_map(measured).any(|m| m.on_disk)
207}
208
209fn output_name(m: &Measured) -> &'static str {
210    if m.on_disk { "disk" } else { "memory" }
211}
212
213fn table_lines(rows: &[Row], bytes_in: u64, memory: MemoryMode) -> Vec<String> {
214    let any = any_on_disk(rows);
215    let with_mem = matches!(memory, MemoryMode::Measured | MemoryMode::Unsupported);
216    let measured_mem = memory == MemoryMode::Measured;
217    let mut header: Vec<String> = HEADER.iter().map(|s| s.to_string()).collect();
218    if with_mem {
219        header.extend(MEM_HEADER.iter().map(|s| s.to_string()));
220    }
221    header[4].push_str(disk_mark(false, any));
222    let mut cells: Vec<Vec<String>> = vec![header];
223    let mut failures: Vec<Option<String>> = vec![None];
224    for row in rows {
225        let level = row.level.map_or_else(|| "-".into(), |l| l.to_string());
226        match &row.outcome {
227            Outcome::Ok(m) => {
228                let mut line = vec![
229                    row.format.into(),
230                    level,
231                    size_text(m.bytes_out),
232                    ratio_text(bytes_in, m.bytes_out),
233                    format!("{}{}", time_text(m.compress_ns), disk_mark(m.on_disk, any)),
234                    time_text(m.decompress_ns),
235                ];
236                if with_mem {
237                    let mem = row.memory.as_ref().filter(|_| measured_mem);
238                    line.push(mem_text(mem.and_then(|m| m.compress)));
239                    line.push(mem_text(mem.and_then(|m| m.decompress)));
240                }
241                cells.push(line);
242                failures.push(None);
243            }
244            Outcome::Failed(why) => {
245                cells.push(vec![row.format.into(), level]);
246                failures.push(Some(format!("failed: {}", one_line(why))));
247            }
248        }
249    }
250    let widths: Vec<usize> = (0..cells[0].len())
251        .map(|i| {
252            cells
253                .iter()
254                .filter_map(|r| r.get(i))
255                .map(|s| s.chars().count())
256                .max()
257                .unwrap_or(0)
258        })
259        .collect();
260    cells
261        .iter()
262        .zip(&failures)
263        .map(|(r, failed)| {
264            let mut line = format!("{:<w$}", r[0], w = widths[0]);
265            for (i, cell) in r.iter().enumerate().skip(1) {
266                line.push_str(&format!("  {:>w$}", cell, w = widths[i]));
267            }
268            if let Some(f) = failed {
269                line.push_str("  ");
270                line.push_str(f);
271            }
272            line
273        })
274        .collect()
275}
276
277/// The footnote's start when a memory cell is `-` for a reason.
278const MEM_NOTE: &str = "- memory not measured: ";
279
280/// The first reason a measured run's memory cell shows `-`: a row that ran
281/// whose memory run failed on at least one side.
282fn first_mem_error(rows: &[Row], memory: MemoryMode) -> Option<&str> {
283    if memory != MemoryMode::Measured {
284        return None;
285    }
286    rows.iter()
287        .filter(|r| measured(r).is_some())
288        .filter_map(|r| r.memory.as_ref())
289        .filter(|m| m.compress.is_none() || m.decompress.is_none())
290        .find_map(|m| m.error.as_deref())
291}
292
293/// The table, then the footnotes: one when a row is marked, one naming
294/// the first reason a memory cell is `-`.
295fn write_table(
296    rows: &[Row],
297    bytes_in: u64,
298    memory: MemoryMode,
299    out: &mut dyn Write,
300) -> io::Result<()> {
301    for line in table_lines(rows, bytes_in, memory) {
302        writeln!(out, "{}", line.trim_end())?;
303    }
304    if any_on_disk(rows) {
305        writeln!(out, "{DISK_NOTE}")?;
306    }
307    if let Some(why) = first_mem_error(rows, memory) {
308        writeln!(out, "{MEM_NOTE}{}", one_line(why))?;
309    }
310    Ok(())
311}
312
313fn measured(row: &Row) -> Option<&Measured> {
314    match &row.outcome {
315        Outcome::Ok(m) => Some(m),
316        Outcome::Failed(_) => None,
317    }
318}
319
320/// The input line, the table and the winners block.
321pub fn human(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
322    writeln!(out, "{}", input_line(c, rows))?;
323    writeln!(out)?;
324    write_table(rows, c.bytes_in, c.memory, out)?;
325    writeln!(out)?;
326    let w = winners(rows);
327    let pick = |i: Option<usize>| i.map(|i| (name_of(&rows[i]), measured(&rows[i]).copied()));
328    let (Some((sn, Some(sm))), Some((cn, Some(cm))), Some((dn, Some(dm))), Some((bn, Some(bm)))) = (
329        pick(w.smallest),
330        pick(w.fastest_compress),
331        pick(w.fastest_decompress),
332        pick(w.balance),
333    ) else {
334        return writeln!(out, "no format succeeded");
335    };
336    // The least-memory row, with its two cells, when the run measured memory.
337    let least = if c.memory == MemoryMode::Measured {
338        w.least_memory.and_then(|i| {
339            let m = rows[i].memory.as_ref()?;
340            Some((name_of(&rows[i]), m.compress?, m.decompress?))
341        })
342    } else {
343        None
344    };
345    let ln = least.as_ref().map_or("", |l| l.0.as_str());
346    let width = [sn.as_str(), cn.as_str(), dn.as_str(), bn.as_str(), ln]
347        .iter()
348        .map(|s| s.chars().count() + 2)
349        .max()
350        .unwrap_or(0)
351        .max(12);
352    writeln!(
353        out,
354        "{:<LABEL$}{sn:<width$}{} ({})",
355        "smallest:",
356        size_text(sm.bytes_out),
357        ratio_text(c.bytes_in, sm.bytes_out)
358    )?;
359    writeln!(
360        out,
361        "{:<LABEL$}{cn:<width$}{}",
362        "fastest compress:",
363        time_text(cm.compress_ns)
364    )?;
365    writeln!(
366        out,
367        "{:<LABEL$}{dn:<width$}{}",
368        "fastest decompress:",
369        time_text(dm.decompress_ns)
370    )?;
371    if let Some((name, c_mem, d_mem)) = &least {
372        writeln!(
373            out,
374            "{:<LABEL$}{name:<width$}{} / {}",
375            "least memory:",
376            size_text(*c_mem),
377            size_text(*d_mem)
378        )?;
379    }
380    writeln!(
381        out,
382        "{:<LABEL$}{bn:<width$}{} at {} / {}",
383        "best balance:",
384        ratio_text(c.bytes_in, bm.bytes_out),
385        time_text(bm.compress_ns),
386        time_text(bm.decompress_ns)
387    )
388}
389
390/// The table alone: header, rows and the footnote when a row is marked.
391pub fn table_only(
392    rows: &[Row],
393    bytes_in: u64,
394    memory: MemoryMode,
395    out: &mut dyn Write,
396) -> io::Result<()> {
397    write_table(rows, bytes_in, memory, out)
398}
399
400fn finite(x: Option<f64>) -> Value {
401    x.filter(|x| x.is_finite())
402        .map_or(Value::Null, |x| json!(x))
403}
404
405/// One JSON object: conditions, rows and winners. Scores are relative to
406/// this run.
407pub fn json(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
408    let scores = balance_scores(rows);
409    let w = winners(rows);
410    let who = |i: Option<usize>| {
411        i.map_or(
412            Value::Null,
413            |i| json!({"format": rows[i].format, "level": rows[i].level}),
414        )
415    };
416    let measured_mem = c.memory == MemoryMode::Measured;
417    let jrows: Vec<Value> = rows
418        .iter()
419        .zip(&scores)
420        .map(|(r, score)| {
421            let mem = r.memory.as_ref().filter(|_| measured_mem);
422            let mut row = match &r.outcome {
423                Outcome::Ok(m) => json!({
424                    "format": r.format, "level": r.level, "backend": r.backend, "ok": true,
425                    "bytes_out": m.bytes_out,
426                    "ratio": finite(ratio_of(c.bytes_in, m.bytes_out)),
427                    "compress_ns": m.compress_ns, "decompress_ns": m.decompress_ns,
428                    "balance": finite(*score), "error": null,
429                    "output": output_name(m),
430                }),
431                Outcome::Failed(why) => json!({
432                    "format": r.format, "level": r.level, "backend": r.backend, "ok": false,
433                    "bytes_out": null, "ratio": null,
434                    "compress_ns": null, "decompress_ns": null,
435                    "balance": null, "error": why, "output": null,
436                }),
437            };
438            row["compress_mem"] = json!(mem.and_then(|m| m.compress));
439            row["decompress_mem"] = json!(mem.and_then(|m| m.decompress));
440            row["mem_error"] = json!(mem.and_then(|m| m.error.as_deref()));
441            row
442        })
443        .collect();
444    let doc = json!({
445        "conditions": {
446            "files": c.files,
447            "bytes_in": c.bytes_in,
448            "mode": mode_name(c.mode),
449            "threads": c.threads,
450            "workers": c.workers,
451            "repeat": c.repeat,
452            "streamed": c.streamed,
453            "memory": measured_mem,
454        },
455        "rows": jrows,
456        "winners": {
457            "smallest": who(w.smallest),
458            "fastest_compress": who(w.fastest_compress),
459            "fastest_decompress": who(w.fastest_decompress),
460            "balance": who(w.balance),
461            "least_memory": who(w.least_memory.filter(|_| measured_mem)),
462        },
463    });
464    writeln!(out, "{doc}")
465}
466
467fn csv_field(s: &str) -> String {
468    if s.contains([',', '"', '\n', '\r']) {
469        format!("\"{}\"", s.replace('"', "\"\""))
470    } else {
471        s.to_string()
472    }
473}
474
475/// Header plus one line per row; empty fields stand for none. The run's
476/// conditions follow the measurements on every line, then the row's
477/// backend, its `output` (`memory` or `disk`, empty for a failed row) and
478/// last the memory peaks and `mem_error`, empty when not measured.
479pub fn csv(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
480    writeln!(
481        out,
482        "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
483         files,mode,threads,workers,repeat,streamed,backend,output,\
484         compress_mem,decompress_mem,mem_error"
485    )?;
486    let measured_mem = c.memory == MemoryMode::Measured;
487    let mem_fields = |r: &Row| {
488        let mem = r.memory.as_ref().filter(|_| measured_mem);
489        let num = |n: Option<u64>| n.map_or(String::new(), |n| n.to_string());
490        format!(
491            "{},{},{}",
492            num(mem.and_then(|m| m.compress)),
493            num(mem.and_then(|m| m.decompress)),
494            mem.and_then(|m| m.error.as_deref())
495                .map_or(String::new(), csv_field)
496        )
497    };
498    let bytes_in = c.bytes_in;
499    let threads = c.threads.map_or(String::new(), |t| t.to_string());
500    let conditions = format!(
501        "{},{},{threads},{},{},{}",
502        c.files,
503        mode_name(c.mode),
504        c.workers,
505        c.repeat,
506        c.streamed
507    );
508    for (r, score) in rows.iter().zip(balance_scores(rows)) {
509        let level = r.level.map_or(String::new(), |l| l.to_string());
510        match &r.outcome {
511            Outcome::Ok(m) => {
512                let ratio =
513                    ratio_of(bytes_in, m.bytes_out).map_or(String::new(), |x| x.to_string());
514                let bal = score.map_or(String::new(), |x| x.to_string());
515                writeln!(
516                    out,
517                    "{},{level},true,{bytes_in},{},{ratio},{},{},{bal},,{conditions},{},{},{}",
518                    r.format,
519                    m.bytes_out,
520                    m.compress_ns,
521                    m.decompress_ns,
522                    r.backend,
523                    output_name(m),
524                    mem_fields(r)
525                )?;
526            }
527            Outcome::Failed(why) => writeln!(
528                out,
529                "{},{level},false,{bytes_in},,,,,,{},{conditions},{},,{}",
530                r.format,
531                csv_field(why),
532                r.backend,
533                mem_fields(r)
534            )?,
535        }
536    }
537    Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542    use super::super::score::Memory;
543    use super::*;
544
545    const MIB: u64 = 1 << 20;
546
547    fn ok(format: &'static str, level: Option<i32>, out: u64, c_ms: u64, d_ms: u64) -> Row {
548        Row {
549            format,
550            level,
551            backend: "pure",
552            outcome: Outcome::Ok(Measured {
553                bytes_out: out,
554                compress_ns: c_ms * 1_000_000,
555                decompress_ns: d_ms * 1_000_000,
556                on_disk: false,
557            }),
558            memory: None,
559        }
560    }
561
562    fn rows() -> Vec<Row> {
563        vec![
564            ok("zstd", Some(3), 40 * MIB, 900, 300),
565            ok("lz4", None, 100 * MIB, 600, 200),
566            ok("xz", Some(6), 25 * MIB, 8200, 1250),
567            Row {
568                format: "bzip2",
569                level: Some(9),
570                backend: "pure",
571                outcome: Outcome::Failed("no c tier".into()),
572                memory: None,
573            },
574        ]
575    }
576
577    fn cond() -> Conditions {
578        Conditions {
579            files: 3,
580            bytes_in: 412 * MIB,
581            mode: Mode::OneStream,
582            threads: None,
583            workers: 1,
584            repeat: 1,
585            streamed: false,
586            memory: MemoryMode::Skipped,
587        }
588    }
589
590    fn render(f: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String {
591        let mut v = Vec::new();
592        f(&mut v).unwrap();
593        String::from_utf8(v).unwrap()
594    }
595
596    #[test]
597    fn human_output_is_layout_b() {
598        let got = render(|o| human(&cond(), &rows(), o));
599        // A file, not a literal: the table's column runs would trip the
600        // workspace's collapsed-indentation guard, which scans `.rs` files.
601        let want = include_str!("testdata/human-layout-b.txt");
602        assert_eq!(got, want);
603    }
604
605    #[test]
606    fn table_only_has_no_input_line_or_winners() {
607        let got = render(|o| table_only(&rows(), 412 * MIB, MemoryMode::Skipped, o));
608        assert!(got.starts_with("format"));
609        assert!(!got.contains("input:") && !got.contains("smallest"));
610        assert_eq!(got.lines().count(), 5);
611    }
612
613    #[test]
614    fn json_parses_and_carries_conditions() {
615        let got = render(|o| json(&cond(), &rows(), o));
616        let v: Value = serde_json::from_str(&got).unwrap();
617        assert_eq!(v["conditions"]["mode"], "one-stream");
618        assert!(v["conditions"]["tier"].is_null(), "the backend is per row");
619        assert_eq!(v["rows"][0]["backend"], "pure");
620        assert_eq!(v["rows"].as_array().unwrap().len(), 4);
621        assert_eq!(v["winners"]["smallest"]["format"], "xz");
622        assert_eq!(v["rows"][3]["ok"], false);
623        assert!(!v["rows"][3]["error"].is_null());
624        assert!(v["rows"][1]["level"].is_null());
625    }
626
627    #[test]
628    fn csv_quotes_a_comma_in_an_error() {
629        let rows = vec![Row {
630            format: "xz",
631            level: Some(6),
632            backend: "c",
633            outcome: Outcome::Failed("bad, \"very\" bad".into()),
634            memory: None,
635        }];
636        let c = Conditions {
637            bytes_in: 10,
638            ..cond()
639        };
640        let got = render(|o| csv(&c, &rows, o));
641        let mut lines = got.lines();
642        assert_eq!(
643            lines.next().unwrap(),
644            "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
645             files,mode,threads,workers,repeat,streamed,backend,output,\
646             compress_mem,decompress_mem,mem_error"
647        );
648        assert_eq!(
649            lines.next().unwrap(),
650            "xz,6,false,10,,,,,,\"bad, \"\"very\"\" bad\",3,one-stream,,1,1,false,c,,,,"
651        );
652    }
653
654    #[test]
655    fn zero_input_prints_a_dash_ratio() {
656        let rows = vec![ok("lz4", None, 0, 1, 1)];
657        let got = render(|o| table_only(&rows, 0, MemoryMode::Skipped, o));
658        assert!(got.lines().nth(1).unwrap().contains("  -  "), "{got}");
659        let mut c = cond();
660        c.bytes_in = 0;
661        let j = render(|o| json(&c, &rows, o));
662        assert!(serde_json::from_str::<Value>(&j).unwrap()["rows"][0]["ratio"].is_null());
663    }
664
665    #[test]
666    fn no_success_prints_no_format_succeeded() {
667        let rows = vec![Row {
668            format: "xz",
669            level: Some(1),
670            backend: "pure",
671            outcome: Outcome::Failed("x".into()),
672            memory: None,
673        }];
674        let got = render(|o| human(&cond(), &rows, o));
675        assert!(got.ends_with("\nno format succeeded\n"), "{got}");
676        assert!(!got.contains("smallest"));
677    }
678
679    #[test]
680    fn the_input_line_shows_the_resolved_worker_count() {
681        assert_eq!(threads_text(None, 1), "1 compress thread");
682        assert_eq!(threads_text(Some(1), 1), "1 compress thread");
683        assert_eq!(threads_text(Some(4), 4), "4 compress threads");
684        assert_eq!(threads_text(Some(0), 8), "8 compress threads (auto)");
685        let c = Conditions {
686            threads: Some(0),
687            workers: 8,
688            ..cond()
689        };
690        assert!(input_line(&c, &rows()).contains(" · 8 compress threads (auto) · "));
691        let j = render(|o| json(&c, &[], o));
692        let v: Value = serde_json::from_str(&j).unwrap();
693        assert_eq!(v["conditions"]["threads"], 0);
694        assert_eq!(v["conditions"]["workers"], 8);
695    }
696
697    #[test]
698    fn ms_below_a_second_s_above() {
699        assert_eq!(time_text(850_000_000), "850 ms");
700        assert_eq!(time_text(1_250_000_000), "1.3 s");
701        assert_eq!(time_text(900_000_000), "900 ms");
702    }
703
704    #[test]
705    fn csv_carries_the_conditions_and_each_rows_backend() {
706        let mut rows = rows();
707        rows[0].backend = "c";
708        let c = Conditions {
709            mode: Mode::PerFile,
710            threads: Some(0),
711            workers: 8,
712            repeat: 5,
713            streamed: true,
714            ..cond()
715        };
716        let got = render(|o| csv(&c, &rows, o));
717        let lines: Vec<&str> = got.lines().collect();
718        let header: Vec<&str> = lines[0].split(',').collect();
719        assert_eq!(header.len(), 21);
720        assert_eq!(header[17], "output");
721        assert_eq!(
722            &header[..10],
723            &[
724                "format",
725                "level",
726                "ok",
727                "bytes_in",
728                "bytes_out",
729                "ratio",
730                "compress_ns",
731                "decompress_ns",
732                "balance",
733                "error"
734            ]
735        );
736        for line in &lines[1..] {
737            let f: Vec<&str> = line.split(',').collect();
738            assert_eq!(f.len(), 21, "{line}");
739            assert_eq!(
740                &f[10..16],
741                &["3", "per-file", "0", "8", "5", "true"],
742                "{line}"
743            );
744        }
745        assert!(lines[1].starts_with("zstd,3,true,"), "{}", lines[1]);
746        assert!(lines[1].ends_with(",c,memory,,,"), "{}", lines[1]);
747        assert!(lines[2].ends_with(",pure,memory,,,"), "{}", lines[2]);
748        // No --threads: an empty field, not 0.
749        let got = render(|o| csv(&cond(), &rows, o));
750        assert!(
751            got.lines()
752                .nth(1)
753                .unwrap()
754                .contains(",3,one-stream,,1,1,false,")
755        );
756    }
757
758    #[test]
759    fn the_input_line_names_the_c_backed_codecs() {
760        let mut rows = rows();
761        assert!(input_line(&cond(), &rows).contains(" · pure · "));
762        rows[0].backend = "c";
763        rows[1].backend = "c";
764        rows.push(ok("zstd", Some(19), MIB, 1, 1));
765        rows.last_mut().unwrap().backend = "c";
766        let line = input_line(&cond(), &rows);
767        assert!(line.contains(" · C: zstd, lz4 · "), "{line}");
768        let j = render(|o| json(&cond(), &rows, o));
769        let v: Value = serde_json::from_str(&j).unwrap();
770        let backends: Vec<&str> = v["rows"]
771            .as_array()
772            .unwrap()
773            .iter()
774            .map(|r| r["backend"].as_str().unwrap())
775            .collect();
776        assert_eq!(backends, ["c", "c", "pure", "pure", "c"]);
777    }
778
779    #[test]
780    fn a_multi_line_failure_keeps_to_one_table_line() {
781        let mut rows = rows();
782        rows[3].outcome = Outcome::Failed("first\nsecond\tthird\u{1b}[31m".into());
783        let table = render(|o| table_only(&rows, 412 * MIB, MemoryMode::Skipped, o));
784        assert_eq!(table.lines().count(), 5, "{table}");
785        let last = table.lines().last().unwrap();
786        assert!(last.ends_with("failed: first …"), "{last}");
787        rows[3].outcome = Outcome::Failed("tab\there\u{1b}[31m".into());
788        let table = render(|o| table_only(&rows, 412 * MIB, MemoryMode::Skipped, o));
789        let last = table.lines().last().unwrap();
790        assert!(last.ends_with("failed: tab\\there\\u{1b}[31m"), "{last}");
791        // JSON and CSV keep the whole text.
792        let j = render(|o| json(&cond(), &rows, o));
793        let v: Value = serde_json::from_str(&j).unwrap();
794        assert_eq!(v["rows"][3]["error"], "tab\there\u{1b}[31m");
795    }
796
797    #[test]
798    fn times_under_10_ms_keep_a_decimal_and_edges_round_up_a_unit() {
799        assert_eq!(time_text(40_000), "0.0 ms");
800        assert_eq!(time_text(400_000), "0.4 ms");
801        assert_eq!(time_text(9_940_000), "9.9 ms");
802        // 9.96 ms rounds to 10.0, so it takes the whole-ms form.
803        assert_eq!(time_text(9_960_000), "10 ms");
804        assert_eq!(time_text(10_000_000), "10 ms");
805        // 999.6 ms rounds to 1000 ms: a second.
806        assert_eq!(time_text(999_600_000), "1.0 s");
807        assert_eq!(time_text(999_400_000), "999 ms");
808        assert_eq!(time_text(59_950_000_000), "60.0 s");
809    }
810
811    #[test]
812    fn sizes_round_up_a_unit_at_the_edge() {
813        assert_eq!(size_text(1023), "1023 B");
814        assert_eq!(size_text(1024), "1.0 KiB");
815        // 1023.96 KiB and MiB print as 1024.0 of the unit below otherwise.
816        assert_eq!(size_text(1_048_535), "1.0 MiB");
817        assert_eq!(size_text((1023.96 * MIB as f64) as u64), "1.0 GiB");
818        assert_eq!(size_text((1023.9 * MIB as f64) as u64), "1023.9 MiB");
819        assert_eq!(size_text(2 << 30), "2.0 GiB");
820        assert_eq!(size_text(412 * MIB), "412.0 MiB");
821    }
822
823    /// `rows()` with xz's output on the temp file.
824    fn marked_rows() -> Vec<Row> {
825        let mut rows = rows();
826        if let Outcome::Ok(m) = &mut rows[2].outcome {
827            m.on_disk = true;
828        }
829        rows
830    }
831
832    #[test]
833    fn a_row_timed_with_disk_output_is_marked_with_a_footnote() {
834        let got = render(|o| human(&cond(), &marked_rows(), o));
835        let want = include_str!("testdata/human-marked.txt");
836        assert_eq!(got, want);
837        let got = render(|o| table_only(&marked_rows(), 412 * MIB, MemoryMode::Skipped, o));
838        let want = include_str!("testdata/table-only-marked.txt");
839        assert_eq!(got, want);
840        // No marked row: no mark column, no footnote.
841        let plain = render(|o| table_only(&rows(), 412 * MIB, MemoryMode::Skipped, o));
842        assert!(!plain.contains('*'), "{plain}");
843    }
844
845    #[test]
846    fn json_and_csv_say_where_each_rows_output_went() {
847        let got = render(|o| json(&cond(), &marked_rows(), o));
848        let v: Value = serde_json::from_str(&got).unwrap();
849        assert_eq!(v["rows"][0]["output"], "memory");
850        assert_eq!(v["rows"][2]["output"], "disk");
851        assert!(v["rows"][3]["output"].is_null(), "a failed row");
852        let got = render(|o| csv(&cond(), &marked_rows(), o));
853        let lines: Vec<&str> = got.lines().collect();
854        assert!(lines[0].contains(",backend,output,"), "{}", lines[0]);
855        assert!(lines[1].ends_with(",pure,memory,,,"), "{}", lines[1]);
856        assert!(lines[3].ends_with(",pure,disk,,,"), "{}", lines[3]);
857        assert!(lines[4].ends_with(",pure,,,,"), "{}", lines[4]);
858        for line in &lines {
859            assert_eq!(line.split(',').count(), 21, "{line}");
860        }
861    }
862
863    const KIB: u64 = 1 << 10;
864
865    /// `rows()` with memory: zstd lacks its decompress number, xz is the
866    /// hungry one, lz4 needs the least.
867    fn mem_rows() -> Vec<Row> {
868        let mut rows = rows();
869        let mem = |c, d| {
870            Some(Memory {
871                compress: c,
872                decompress: d,
873                error: None,
874            })
875        };
876        rows[0].memory = mem(Some(8 * MIB), None);
877        rows[1].memory = mem(Some(MIB), Some(512 * KIB));
878        rows[2].memory = mem(Some(670 * MIB), Some(65 * MIB));
879        rows
880    }
881
882    fn mem_cond() -> Conditions {
883        Conditions {
884            memory: MemoryMode::Measured,
885            ..cond()
886        }
887    }
888
889    #[test]
890    fn human_output_with_memory() {
891        let got = render(|o| human(&mem_cond(), &mem_rows(), o));
892        assert_eq!(got, include_str!("testdata/human-memory.txt"));
893        let table = render(|o| table_only(&mem_rows(), 412 * MIB, MemoryMode::Measured, o));
894        assert_eq!(table, include_str!("testdata/table-only-memory.txt"));
895    }
896
897    /// A memory cell that is `-` because its run failed gets one footnote
898    /// under the table, naming the first such reason on one line; a cell
899    /// that is `-` without an error, or a run without memory, gets none.
900    #[test]
901    fn a_failed_memory_run_gets_a_footnote() {
902        let mut rows = mem_rows();
903        rows[0].memory.as_mut().unwrap().error =
904            Some("decompress: refused by --memory-limit: needs 64 MiB\nsecond line".into());
905        rows[2].memory = Some(Memory {
906            compress: None,
907            decompress: None,
908            error: Some("compress: later reason".into()),
909        });
910        let got = render(|o| human(&mem_cond(), &rows, o));
911        assert_eq!(got, include_str!("testdata/human-memory-error.txt"));
912        let table = render(|o| table_only(&rows, 412 * MIB, MemoryMode::Measured, o));
913        assert!(
914            table.ends_with(
915                "- memory not measured: decompress: refused by --memory-limit: needs 64 MiB …\n"
916            ),
917            "{table}"
918        );
919        // No error text, no footnote (the existing golden), and none when
920        // memory was skipped.
921        let plain = render(|o| table_only(&mem_rows(), 412 * MIB, MemoryMode::Measured, o));
922        assert!(!plain.contains(MEM_NOTE), "{plain}");
923        let skipped = render(|o| table_only(&rows, 412 * MIB, MemoryMode::Skipped, o));
924        assert!(!skipped.contains(MEM_NOTE), "{skipped}");
925    }
926
927    #[test]
928    fn human_output_without_memory() {
929        // Skipped: byte-identical to the layout before memory existed, even
930        // when the rows carry numbers.
931        let got = render(|o| human(&cond(), &mem_rows(), o));
932        assert_eq!(got, include_str!("testdata/human-layout-b.txt"));
933        let got = render(|o| human(&cond(), &rows(), o));
934        assert_eq!(got, include_str!("testdata/human-layout-b.txt"));
935    }
936
937    #[test]
938    fn unsupported_platform_line() {
939        let c = Conditions {
940            memory: MemoryMode::Unsupported,
941            ..cond()
942        };
943        let got = render(|o| human(&c, &rows(), o));
944        let first = got.lines().next().unwrap();
945        assert!(
946            first.ends_with(" · 1 run, memory not measured on this platform"),
947            "{first}"
948        );
949        assert!(!got.contains("least memory"), "{got}");
950        // Columns of `-`, even when the rows carry numbers.
951        let got = render(|o| human(&c, &mem_rows(), o));
952        assert_eq!(got, include_str!("testdata/human-unsupported.txt"));
953        for m in [MemoryMode::Measured, MemoryMode::Skipped] {
954            let c = Conditions {
955                memory: m,
956                ..cond()
957            };
958            assert!(!input_line(&c, &rows()).contains("memory"), "{m:?}");
959        }
960    }
961
962    #[test]
963    fn json_memory_fields_and_nulls() {
964        let parse = |c: &Conditions, rows: &[Row]| -> Value {
965            serde_json::from_str(&render(|o| json(c, rows, o))).unwrap()
966        };
967        let mut rows = mem_rows();
968        rows[0].memory.as_mut().unwrap().error = Some("refused by --memory-limit".into());
969        let v = parse(&mem_cond(), &rows);
970        assert_eq!(v["conditions"]["memory"], true);
971        assert_eq!(v["rows"][2]["compress_mem"], 670 * MIB);
972        assert_eq!(v["rows"][2]["decompress_mem"], 65 * MIB);
973        assert!(v["rows"][2]["mem_error"].is_null());
974        assert_eq!(v["rows"][0]["compress_mem"], 8 * MIB);
975        assert!(v["rows"][0]["decompress_mem"].is_null());
976        assert_eq!(v["rows"][0]["mem_error"], "refused by --memory-limit");
977        assert!(v["rows"][3]["compress_mem"].is_null(), "a failed row");
978        assert_eq!(v["winners"]["least_memory"]["format"], "lz4");
979        // Skipped and Unsupported: false, null fields, no winner.
980        for m in [MemoryMode::Skipped, MemoryMode::Unsupported] {
981            let c = Conditions {
982                memory: m,
983                ..cond()
984            };
985            let v = parse(&c, &mem_rows());
986            assert_eq!(v["conditions"]["memory"], false, "{m:?}");
987            for r in v["rows"].as_array().unwrap() {
988                assert!(r["compress_mem"].is_null() && r["decompress_mem"].is_null());
989                assert!(r["mem_error"].is_null());
990            }
991            assert!(v["winners"]["least_memory"].is_null(), "{m:?}");
992        }
993    }
994
995    #[test]
996    fn memory_never_leaks_from_a_mode_that_did_not_measure() {
997        for m in [MemoryMode::Skipped, MemoryMode::Unsupported] {
998            let c = Conditions {
999                memory: m,
1000                ..cond()
1001            };
1002            let got = render(|o| csv(&c, &mem_rows(), o));
1003            for line in got.lines().skip(1) {
1004                assert!(line.ends_with(",,,"), "{m:?}: {line}");
1005            }
1006        }
1007    }
1008
1009    #[test]
1010    fn a_disk_marked_row_keeps_the_memory_columns_aligned() {
1011        let mut rows = mem_rows();
1012        if let Outcome::Ok(m) = &mut rows[2].outcome {
1013            m.on_disk = true;
1014        }
1015        let got = render(|o| human(&mem_cond(), &rows, o));
1016        assert_eq!(got, include_str!("testdata/human-memory-marked.txt"));
1017    }
1018
1019    #[test]
1020    fn csv_appends_memory_columns() {
1021        let mut rows = mem_rows();
1022        rows[0].memory.as_mut().unwrap().error = Some("a, b".into());
1023        let got = render(|o| csv(&mem_cond(), &rows, o));
1024        let lines: Vec<&str> = got.lines().collect();
1025        assert!(
1026            lines[0].ends_with(",backend,output,compress_mem,decompress_mem,mem_error"),
1027            "{}",
1028            lines[0]
1029        );
1030        assert!(
1031            lines[1].ends_with(&format!(",pure,memory,{},,\"a, b\"", 8 * MIB)),
1032            "{}",
1033            lines[1]
1034        );
1035        assert!(
1036            lines[2].ends_with(&format!(",pure,memory,{},{},", MIB, 512 * KIB)),
1037            "{}",
1038            lines[2]
1039        );
1040        assert!(
1041            lines[4].ends_with(",pure,,,,"),
1042            "a failed row: {}",
1043            lines[4]
1044        );
1045    }
1046}