Skip to main content

stuffr_cli/bench/
mod.rs

1//! `stuffr bench`: compares codecs on the user's own files.
2//!
3//! [`run`] wires the pieces together in order: expand the operands, check
4//! `--formats`, refuse a debug build, load and warm the input once, then
5//! measure every row strictly one after another (there is one process-wide
6//! signal-cleanup slot, so two `measure` calls must never overlap) and
7//! render the result.
8
9pub mod levels;
10pub mod measure;
11pub mod report;
12pub mod score;
13
14use std::io::{self, IsTerminal, Write};
15use std::path::{Path, PathBuf};
16
17use stuffr::{Error, FormatKind};
18
19use crate::cli::BenchArgs;
20use levels::{backend_of, benchable_codecs, levels_of};
21use measure::{
22    Clock, Input, Mode, Phase, Plan, RealClock, governor_for, load_input, measure, warm,
23};
24use report::Conditions;
25use score::Row;
26
27/// The refusal `main` prints, verbatim, for [`Ran::RefusedDebugBuild`] (exit
28/// 2). Not an `Error::Usage`, whose display would prefix "usage error:".
29pub const DEBUG_REFUSAL: &str =
30    "stuffr: bench: timings from a debug build are meaningless; build with --release";
31
32/// How a [`run`] that raised no error ended.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum Ran {
35    /// The rows ran and the report reached stdout.
36    Measured,
37    /// The arguments were valid, but this is a debug build, so nothing was
38    /// timed, written or created.
39    RefusedDebugBuild,
40}
41
42/// Runs `stuffr bench`. Usage problems are `Error::Usage` (exit 2); a format
43/// that fails is a failed row, never an error here.
44pub fn run(args: BenchArgs) -> stuffr::Result<Ran> {
45    let paths = expand_inputs(&args.inputs)?;
46    let codecs = chosen_codecs(&args.formats)?;
47    let memory_limit = match &args.memory_limit {
48        Some(s) => crate::size::parse_size(s).map_err(Error::Usage)?,
49        None => stuffr::default_memory_limit(),
50    };
51    // After validation, so a debug build still reports usage errors.
52    if cfg!(debug_assertions) {
53        return Ok(Ran::RefusedDebugBuild);
54    }
55
56    let (input, bytes_in) = load_input(&paths, memory_limit)?;
57    warm(&input)?;
58    let plan = Plan {
59        mode: if args.per_file {
60            Mode::PerFile
61        } else {
62            Mode::OneStream
63        },
64        repeat: args.repeat,
65        threads: args.threads,
66        memory_limit,
67    };
68    let planned = plan_rows(&codecs, args.levels);
69    let on = io::stderr().is_terminal() && !(args.json || args.csv || args.table_only);
70    let mut meter = Meter::new(
71        on.then(|| Box::new(io::stderr()) as Box<dyn Write>),
72        Box::new(RealClock::new()),
73        plan.repeat,
74        terminal_width,
75    );
76    let mut clock = RealClock::new();
77    let mut rows = Vec::with_capacity(planned.len());
78    for (k, p) in planned.iter().enumerate() {
79        meter.row(RowLabel {
80            k: k + 1,
81            n: planned.len(),
82            format: p.format,
83            level: p.label,
84        });
85        // `measure` reports only between its timed pieces.
86        let outcome = measure(
87            p.format,
88            p.level,
89            &input,
90            &plan,
91            &mut clock,
92            &mut |ph, d, t| meter.progress(ph, d, t),
93        );
94        rows.push(Row {
95            format: p.format,
96            level: p.label,
97            backend: backend_of(p.format),
98            outcome,
99        });
100    }
101    meter.clear();
102
103    let conditions = Conditions {
104        files: paths.len(),
105        bytes_in,
106        mode: plan.mode,
107        threads: plan.threads,
108        // The budget every row's encoder is given; no governor is one.
109        workers: governor_for(&plan).map_or(1, |g| g.workers()),
110        repeat: plan.repeat,
111        streamed: matches!(input, Input::Files(_)),
112    };
113    let mut out = io::stdout().lock();
114    if args.json {
115        report::json(&conditions, &rows, &mut out)?;
116    } else if args.csv {
117        report::csv(&conditions, &rows, &mut out)?;
118    } else if args.table_only {
119        report::table_only(&rows, conditions.bytes_in, &mut out)?;
120    } else {
121        report::human(&conditions, &rows, &mut out)?;
122    }
123    out.flush()?;
124    Ok(Ran::Measured)
125}
126
127/// The least time between two redraws of the bar, in nanoseconds.
128const REDRAW_NS: u64 = 100_000_000;
129
130/// The bar's cells.
131const CELLS: u64 = 20;
132
133/// The spinner after the bar, one frame per redraw.
134const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
135
136/// The row a bar line is about.
137#[derive(Clone, Copy, Debug)]
138struct RowLabel {
139    k: usize,
140    n: usize,
141    format: &'static str,
142    level: Option<i32>,
143}
144
145/// The progress meter on stderr: one bar line for the row being measured,
146/// redrawn from `measure`'s reports (which come only between its timed
147/// pieces) at most every [`REDRAW_NS`], and cleared before anything reaches
148/// stdout. `out` is `None` when the meter is off.
149struct Meter {
150    out: Option<Box<dyn Write>>,
151    /// Read only between timed pieces, for the throttle.
152    clock: Box<dyn Clock>,
153    repeats: u32,
154    row: Option<RowLabel>,
155    /// The repeat in progress, counted from its opening report.
156    repeat: u32,
157    /// When this row's bar was last drawn; `None` before its first draw.
158    drawn_at: Option<u64>,
159    frame: usize,
160    shown: bool,
161    /// The terminal's width in columns, asked at each draw.
162    width: fn() -> usize,
163}
164
165impl Meter {
166    fn new(
167        out: Option<Box<dyn Write>>,
168        clock: Box<dyn Clock>,
169        repeats: u32,
170        width: fn() -> usize,
171    ) -> Self {
172        Self {
173            out,
174            clock,
175            width,
176            repeats: repeats.max(1),
177            row: None,
178            repeat: 0,
179            drawn_at: None,
180            frame: 0,
181            shown: false,
182        }
183    }
184
185    /// The next row; its first report is drawn at once.
186    fn row(&mut self, row: RowLabel) {
187        self.row = Some(row);
188        self.repeat = 0;
189        self.drawn_at = None;
190    }
191
192    /// One report from `measure`. `(Compress, 0, _)` opens a repeat.
193    fn progress(&mut self, phase: Phase, done: u64, total: u64) {
194        let (Some(out), Some(row)) = (self.out.as_mut(), self.row) else {
195            return;
196        };
197        if phase == Phase::Compress && done == 0 {
198            self.repeat = (self.repeat + 1).min(self.repeats);
199        }
200        let now = self.clock.now();
201        if let Some(at) = self.drawn_at
202            && now.saturating_sub(at) < REDRAW_NS
203        {
204            return;
205        }
206        let repeat = (self.repeats > 1).then_some((self.repeat.max(1), self.repeats));
207        let line = bar_line(&row, phase, repeat, done, total, self.frame);
208        // One column short of the width: a line that wrapped would leave
209        // its first part behind on the line above at the next redraw, and
210        // one filling the last column leaves some terminals pending a wrap.
211        let line = fit(&line, (self.width)().saturating_sub(1));
212        // `\x1b[K` after the text clears what a longer previous line left.
213        let _ = write!(out, "\r{line}\x1b[K");
214        let _ = out.flush();
215        self.frame = (self.frame + 1) % SPINNER.len();
216        self.drawn_at = Some(now);
217        self.shown = true;
218    }
219
220    fn clear(&mut self) {
221        if let Some(out) = self.out.as_mut()
222            && self.shown
223        {
224            let _ = write!(out, "\r\x1b[K");
225            let _ = out.flush();
226            self.shown = false;
227        }
228    }
229}
230
231/// `[k/N] <format> <level>  <phase> ▕<bar>▏<spinner> <pct>%`, `<phase>`
232/// followed by ` r/N` when `repeat` is given. `done` past `total` counts
233/// as `total`; a `total` of 0 is 100%, with nothing divided.
234fn bar_line(
235    row: &RowLabel,
236    phase: Phase,
237    repeat: Option<(u32, u32)>,
238    done: u64,
239    total: u64,
240    frame: usize,
241) -> String {
242    let done = done.min(total);
243    // Widened, so `done * 100` cannot overflow; no division when total is 0.
244    let share = |scale: u64| {
245        if total == 0 {
246            scale
247        } else {
248            u64::try_from(u128::from(done) * u128::from(scale) / u128::from(total)).unwrap_or(scale)
249        }
250    };
251    let cells = share(CELLS);
252    let bar: String = (0..CELLS)
253        .map(|i| if i < cells { '█' } else { '░' })
254        .collect();
255    let RowLabel {
256        k,
257        n,
258        format,
259        level,
260    } = *row;
261    let level = level.map_or_else(|| "-".to_string(), |l| l.to_string());
262    let phase = match phase {
263        Phase::Compress => "compress",
264        Phase::Decompress => "decompress",
265    };
266    let repeat = repeat.map_or_else(String::new, |(r, n)| format!(" {r}/{n}"));
267    let spinner = SPINNER[frame % SPINNER.len()];
268    let pct = share(100);
269    format!("[{k}/{n}] {format} {level}  {phase}{repeat} ▕{bar}▏{spinner} {pct}%")
270}
271
272/// stderr's width in columns (`TIOCGWINSZ`); 80 when the ioctl fails or
273/// reports no width.
274#[cfg(unix)]
275fn terminal_width() -> usize {
276    // SAFETY: winsize is plain data, filled by the ioctl.
277    let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
278    // SAFETY: TIOCGWINSZ writes one winsize we own.
279    let ok = unsafe { libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0;
280    if ok && ws.ws_col > 0 {
281        usize::from(ws.ws_col)
282    } else {
283        80
284    }
285}
286
287/// 80 columns: no window-size query off unix.
288#[cfg(not(unix))]
289fn terminal_width() -> usize {
290    80
291}
292
293/// The directories a pattern spells before its first glob character: the
294/// part up to the last `/` before it (`/` itself when that is the first
295/// character). `None` when the glob character is in the first component.
296fn literal_prefix(pattern: &str) -> Option<&Path> {
297    let first_glob = pattern.find(['*', '?', '['])?;
298    let cut = pattern[..first_glob].rfind('/')?;
299    Some(Path::new(&pattern[..cut.max(1)]))
300}
301
302/// The bar line fitted to `cols` columns: whole when it fits; otherwise
303/// the row's label alone (`[k/N] format level`, the bar dropped), itself
304/// cut to `cols` when even that is too wide. Every character [`bar_line`]
305/// draws is one column wide.
306fn fit(line: &str, cols: usize) -> String {
307    if line.chars().count() <= cols {
308        return line.to_string();
309    }
310    // The label ends at the two spaces before the phase.
311    let label = line.split_once("  ").map_or(line, |(l, _)| l);
312    truncate_cols(label, cols).to_string()
313}
314
315/// `s` cut to its first `cols` columns, one per character (never inside
316/// a character's bytes).
317fn truncate_cols(s: &str, cols: usize) -> &str {
318    match s.char_indices().nth(cols) {
319        Some((at, _)) => &s[..at],
320        None => s,
321    }
322}
323
324/// One row to run: `level` is what `measure` gets (`None`: the encoder's
325/// own default), `label` what the table shows.
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327struct Planned {
328    format: &'static str,
329    level: Option<i32>,
330    label: Option<i32>,
331}
332
333/// One default row per codec, or with `sweep` one row per sweep level; a
334/// codec without levels gets its one row either way.
335fn plan_rows(codecs: &[&'static str], sweep: bool) -> Vec<Planned> {
336    let mut rows = Vec::new();
337    for &format in codecs {
338        let lv = levels_of(format);
339        match lv.map(|l| l.sweep).filter(|s| sweep && !s.is_empty()) {
340            Some(levels) => rows.extend(levels.iter().map(|&l| Planned {
341                format,
342                level: Some(l),
343                label: Some(l),
344            })),
345            None => rows.push(Planned {
346                format,
347                level: None,
348                label: lv.and_then(|l| l.default),
349            }),
350        }
351    }
352    rows
353}
354
355/// The codecs to run: `names` in order without repeats, each checked, or
356/// every benchable codec when none are named.
357fn chosen_codecs(names: &[String]) -> stuffr::Result<Vec<&'static str>> {
358    let benchable = benchable_codecs();
359    if names.is_empty() {
360        return Ok(benchable);
361    }
362    let mut chosen = Vec::new();
363    for name in names {
364        let id = crate::format_by_name(name)?;
365        let Some(&codec) = benchable.iter().find(|c| **c == id.as_str()) else {
366            return Err(Error::Usage(format!(
367                "{name} cannot be benchmarked in this build ({})",
368                why_not_benchable(name)
369            )));
370        };
371        if !chosen.contains(&codec) {
372            chosen.push(codec);
373        }
374    }
375    Ok(chosen)
376}
377
378/// Why a registered format is not in `benchable_codecs`.
379fn why_not_benchable(name: &str) -> &'static str {
380    let row = stuffr::registry()
381        .matrix()
382        .into_iter()
383        .find(|r| r.id.as_str() == name);
384    match row {
385        Some(r) => why_not(r.kind, r.write),
386        None => why_not(FormatKind::Codec, true),
387    }
388}
389
390/// The reason, from the matrix facts: a container, a codec that cannot
391/// write, or (`benchable_codecs`' remaining filter) a weak encoder.
392fn why_not(kind: FormatKind, write: bool) -> &'static str {
393    match (kind, write) {
394        (FormatKind::Container, _) => "container",
395        (_, false) => "read-only",
396        _ => "weak fallback encoder",
397    }
398}
399
400fn has_glob_chars(s: &str) -> bool {
401    s.contains(['*', '?', '['])
402}
403
404/// The operands as files, in order. An existing path is used as is; a
405/// missing one with glob characters is expanded (sorted, dot files only
406/// when the pattern spells the dot, as a shell does). Directories a pattern
407/// matches are passed over; one named outright, or the only kind of match a
408/// pattern has, is refused. A FIFO, device or socket, named or matched, is
409/// refused from its metadata (it is never opened): bench needs each file's
410/// size up front. A symlink counts as what it points to. A path that
411/// cannot be read, named, matched or on a pattern's literal prefix, is
412/// refused naming it (exit 2), never reported as no match.
413fn expand_inputs(operands: &[String]) -> stuffr::Result<Vec<PathBuf>> {
414    let mut files = Vec::new();
415    for op in operands {
416        let path = Path::new(op);
417        // A dangling symlink does not "exist" but is named: it is reported
418        // as unreadable, not as a missing path; so is a named path whose
419        // lookup fails for any reason but its absence (a directory without
420        // search permission on the way). A pattern goes on to the glob,
421        // which reports such a directory itself.
422        match path.symlink_metadata() {
423            Ok(_) => {
424                if path.is_dir() {
425                    return Err(directory_error(path));
426                }
427                files.push(regular(path)?);
428                continue;
429            }
430            Err(e) if e.kind() != io::ErrorKind::NotFound && !has_glob_chars(op) => {
431                return Err(unreadable(path, &e));
432            }
433            Err(_) => {}
434        }
435        if !has_glob_chars(op) {
436            return Err(no_match(op));
437        }
438        let opts = glob::MatchOptions {
439            require_literal_leading_dot: true,
440            ..Default::default()
441        };
442        let matches = glob::glob_with(op, opts)
443            .map_err(|e| Error::Usage(format!("bad pattern {op}: {e}")))?;
444        let mut found = Vec::new();
445        let mut first_dir = None;
446        for m in matches {
447            let m = m.map_err(|e| unreadable(e.path(), e.error()))?;
448            if m.is_dir() {
449                first_dir.get_or_insert(m);
450            } else {
451                found.push(regular(&m)?);
452            }
453        }
454        if found.is_empty() {
455            if let Some(d) = first_dir {
456                return Err(directory_error(&d));
457            }
458            // glob drops a literal directory it cannot stat, so a prefix
459            // under an unreadable directory looks like no match.
460            if let Some(prefix) = literal_prefix(op)
461                && let Err(e) = prefix.symlink_metadata()
462                && !matches!(
463                    e.kind(),
464                    io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
465                )
466            {
467                return Err(unreadable(prefix, &e));
468            }
469            return Err(no_match(op));
470        }
471        found.sort();
472        files.extend(found);
473    }
474    Ok(files)
475}
476
477/// `path` if it is (or links to) a regular file, else the usage error. A
478/// path whose metadata cannot be read (a dangling symlink, no permission)
479/// is a usage error too, and so is a regular file that cannot be opened
480/// (it is opened once here, and closed, so a debug build reports it too).
481fn regular(path: &Path) -> stuffr::Result<PathBuf> {
482    let meta = std::fs::metadata(path).map_err(|e| unreadable(path, &e))?;
483    if meta.is_file() {
484        std::fs::File::open(path).map_err(|e| unreadable(path, &e))?;
485        Ok(path.to_path_buf())
486    } else {
487        Err(Error::Usage(format!(
488            "{} is not a regular file; bench needs files whose size it can read",
489            path.display()
490        )))
491    }
492}
493
494fn unreadable(path: &Path, e: &io::Error) -> Error {
495    Error::Usage(format!("{}: cannot read it ({e})", path.display()))
496}
497
498fn no_match(op: &str) -> Error {
499    Error::Usage(format!("no input matched {op}"))
500}
501
502fn directory_error(path: &Path) -> Error {
503    let p = path.display();
504    Error::Usage(format!("{p} is a directory; name its files (e.g. {p}/*)"))
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use std::fs;
511    use std::sync::atomic::{AtomicU32, Ordering};
512
513    /// A scratch directory `stuffr-benchrun-<pid>-<n>`, removed on drop.
514    struct Scratch(PathBuf);
515
516    impl Scratch {
517        fn new() -> Self {
518            static N: AtomicU32 = AtomicU32::new(0);
519            let n = N.fetch_add(1, Ordering::Relaxed);
520            let dir =
521                std::env::temp_dir().join(format!("stuffr-benchrun-{}-{n}", std::process::id()));
522            fs::create_dir_all(&dir).unwrap();
523            Scratch(dir)
524        }
525
526        fn file(&self, name: &str) -> PathBuf {
527            let p = self.0.join(name);
528            fs::write(&p, b"x").unwrap();
529            p
530        }
531
532        fn s(&self, rest: &str) -> String {
533            format!("{}/{rest}", self.0.display())
534        }
535    }
536
537    impl Drop for Scratch {
538        fn drop(&mut self) {
539            let _ = fs::remove_dir_all(&self.0);
540        }
541    }
542
543    fn usage(r: stuffr::Result<Vec<PathBuf>>) -> String {
544        match r {
545            Err(Error::Usage(m)) => m,
546            other => panic!("expected a usage error, got {other:?}"),
547        }
548    }
549
550    #[test]
551    fn a_default_row_runs_the_encoders_default_labelled_with_the_table_default() {
552        let rows = plan_rows(&["bzip2", "compress", "lz4"], false);
553        assert_eq!(
554            rows,
555            vec![
556                Planned {
557                    format: "bzip2",
558                    level: None,
559                    label: Some(6)
560                },
561                Planned {
562                    format: "compress",
563                    level: None,
564                    label: Some(16)
565                },
566                Planned {
567                    format: "lz4",
568                    level: None,
569                    label: None
570                },
571            ]
572        );
573    }
574
575    #[test]
576    fn a_sweep_runs_every_level_and_one_row_for_a_codec_without_levels() {
577        let rows = plan_rows(&["compress", "snappy"], true);
578        let got: Vec<_> = rows.iter().map(|r| (r.format, r.level, r.label)).collect();
579        let mut want: Vec<_> = (9..=16).map(|l| ("compress", Some(l), Some(l))).collect();
580        want.push(("snappy", None, None));
581        assert_eq!(got, want);
582    }
583
584    #[test]
585    fn a_glob_expands_sorted_and_skips_dot_files_and_directories() {
586        let s = Scratch::new();
587        s.file("b.log");
588        s.file("a.log");
589        s.file(".hidden.log");
590        fs::create_dir(s.0.join("dir.log")).unwrap();
591        let got = expand_inputs(&[s.s("*.log")]).unwrap();
592        assert_eq!(got, vec![s.0.join("a.log"), s.0.join("b.log")]);
593    }
594
595    #[test]
596    fn an_existing_path_is_used_as_is_even_with_glob_characters() {
597        let s = Scratch::new();
598        let odd = s.file("a[1].log");
599        assert_eq!(expand_inputs(&[s.s("a[1].log")]).unwrap(), vec![odd]);
600    }
601
602    #[test]
603    fn no_match_and_a_missing_path_are_usage_errors() {
604        let s = Scratch::new();
605        let pat = s.s("nomatch*.log");
606        assert_eq!(
607            usage(expand_inputs(std::slice::from_ref(&pat))),
608            format!("no input matched {pat}")
609        );
610        let missing = s.s("absent.log");
611        assert!(usage(expand_inputs(&[missing])).starts_with("no input matched"));
612    }
613
614    #[test]
615    fn a_directory_is_refused_named_or_as_a_patterns_only_match() {
616        let s = Scratch::new();
617        fs::create_dir(s.0.join("logs")).unwrap();
618        let named = usage(expand_inputs(&[s.s("logs")]));
619        let dir = s.s("logs");
620        assert_eq!(
621            named,
622            format!("{dir} is a directory; name its files (e.g. {dir}/*)")
623        );
624        assert!(usage(expand_inputs(&[s.s("lo*")])).contains("is a directory"));
625    }
626
627    #[cfg(unix)]
628    #[test]
629    fn a_device_is_refused_and_a_symlink_to_a_file_is_not() {
630        let s = Scratch::new();
631        let want = "/dev/null is not a regular file; bench needs files whose size it can read";
632        assert_eq!(usage(expand_inputs(&["/dev/null".into()])), want);
633        assert_eq!(usage(expand_inputs(&["/dev/nul?".into()])), want);
634        let target = s.file("real.log");
635        let link = s.0.join("link.log");
636        std::os::unix::fs::symlink(&target, &link).unwrap();
637        assert_eq!(expand_inputs(&[s.s("link.log")]).unwrap(), vec![link]);
638    }
639
640    #[cfg(unix)]
641    #[test]
642    fn a_dangling_symlink_is_a_usage_error() {
643        let s = Scratch::new();
644        s.file("real.log");
645        let link = s.0.join("dangling.log");
646        std::os::unix::fs::symlink(s.0.join("gone.log"), &link).unwrap();
647        let named = usage(expand_inputs(&[s.s("dangling.log")]));
648        assert!(
649            named.starts_with(&format!("{}: cannot read it (", link.display())),
650            "{named}"
651        );
652        // Matched by a pattern, beside a real file.
653        let globbed = usage(expand_inputs(&[s.s("*.log")]));
654        assert!(
655            globbed.starts_with(&format!("{}: cannot read it (", link.display())),
656            "{globbed}"
657        );
658    }
659
660    /// Restores the mode so `Scratch`'s removal can enter the directory.
661    #[cfg(unix)]
662    struct Mode000(PathBuf);
663
664    #[cfg(unix)]
665    impl Drop for Mode000 {
666        fn drop(&mut self) {
667            use std::os::unix::fs::PermissionsExt;
668            let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700));
669        }
670    }
671
672    #[cfg(unix)]
673    #[test]
674    fn an_unreadable_directory_under_a_pattern_is_a_usage_error() {
675        use std::os::unix::fs::PermissionsExt;
676        let s = Scratch::new();
677        s.file("a.log");
678        let locked = s.0.join("locked");
679        fs::create_dir(&locked).unwrap();
680        fs::write(locked.join("b.log"), b"x").unwrap();
681        let _restore = Mode000(locked.clone());
682        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
683        // Root reads it anyway; there is nothing to test.
684        if fs::read_dir(&locked).is_ok() {
685            return;
686        }
687        let m = usage(expand_inputs(&[s.s("*/*.log")]));
688        assert!(m.contains("locked"), "{m}");
689        assert!(m.contains(": cannot read it ("), "{m}");
690    }
691
692    #[cfg(unix)]
693    #[test]
694    fn a_named_path_in_an_unreadable_directory_cannot_be_read() {
695        use std::os::unix::fs::PermissionsExt;
696        let s = Scratch::new();
697        let locked = s.0.join("locked");
698        fs::create_dir(&locked).unwrap();
699        fs::write(locked.join("b.log"), b"x").unwrap();
700        let _restore = Mode000(locked.clone());
701        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
702        // Root reads it anyway; there is nothing to test.
703        if fs::read_dir(&locked).is_ok() {
704            return;
705        }
706        let named = locked.join("b.log");
707        let m = usage(expand_inputs(&[s.s("locked/b.log")]));
708        assert!(
709            m.starts_with(&format!("{}: cannot read it (", named.display())),
710            "{m}"
711        );
712        // Named by a pattern, the glob reports the directory.
713        let m = usage(expand_inputs(&[s.s("locked/*.log")]));
714        assert!(m.contains(": cannot read it ("), "{m}");
715    }
716
717    /// The bar's text goes here, for the tests to read.
718    #[derive(Clone, Default)]
719    struct Captured(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
720
721    impl Write for Captured {
722        fn write(&mut self, b: &[u8]) -> io::Result<usize> {
723            self.0.borrow_mut().extend_from_slice(b);
724            Ok(b.len())
725        }
726
727        fn flush(&mut self) -> io::Result<()> {
728            Ok(())
729        }
730    }
731
732    impl Captured {
733        /// Each drawn line, without its `\r` and `\x1b[K`.
734        fn lines(&self) -> Vec<String> {
735            String::from_utf8(self.0.borrow().clone())
736                .unwrap()
737                .split('\r')
738                .map(|l| l.trim_end_matches("\x1b[K").to_string())
739                .filter(|l| !l.is_empty())
740                .collect()
741        }
742    }
743
744    /// A clock the test moves by hand.
745    struct HandClock(std::rc::Rc<std::cell::Cell<u64>>);
746
747    impl Clock for HandClock {
748        fn now(&mut self) -> u64 {
749            self.0.get()
750        }
751    }
752
753    fn label(k: usize, n: usize, format: &'static str, level: Option<i32>) -> RowLabel {
754        RowLabel {
755            k,
756            n,
757            format,
758            level,
759        }
760    }
761
762    #[test]
763    fn the_bar_renders_blocks_and_a_spinner() {
764        let xz = label(3, 8, "xz", Some(6));
765        assert_eq!(
766            bar_line(&xz, Phase::Compress, None, 0, 1000, 0),
767            "[3/8] xz 6  compress ▕░░░░░░░░░░░░░░░░░░░░▏⠋ 0%"
768        );
769        assert_eq!(
770            bar_line(&xz, Phase::Compress, None, 370, 1000, 1),
771            "[3/8] xz 6  compress ▕███████░░░░░░░░░░░░░▏⠙ 37%"
772        );
773        assert_eq!(
774            bar_line(&xz, Phase::Decompress, Some((2, 3)), 370, 1000, 2),
775            "[3/8] xz 6  decompress 2/3 ▕███████░░░░░░░░░░░░░▏⠹ 37%"
776        );
777        let lz4 = label(8, 8, "lz4", None);
778        assert_eq!(
779            bar_line(&lz4, Phase::Decompress, None, 1000, 1000, 9),
780            "[8/8] lz4 -  decompress ▕████████████████████▏⠏ 100%"
781        );
782        // Past the total is still 100%, never more.
783        assert_eq!(
784            bar_line(&lz4, Phase::Decompress, None, 2000, 1000, 9),
785            "[8/8] lz4 -  decompress ▕████████████████████▏⠏ 100%"
786        );
787        // The spinner wraps after its ten frames.
788        assert_eq!(
789            bar_line(&lz4, Phase::Compress, None, 0, 1, 10),
790            bar_line(&lz4, Phase::Compress, None, 0, 1, 0)
791        );
792
793        // Through the meter: the first report of a row draws at once, the
794        // next ones at most every 100 ms, the spinner one frame per draw.
795        let out = Captured::default();
796        let t = std::rc::Rc::new(std::cell::Cell::new(0));
797        let mut meter = Meter::new(
798            Some(Box::new(out.clone())),
799            Box::new(HandClock(t.clone())),
800            3,
801            || 80,
802        );
803        meter.row(xz);
804        meter.progress(Phase::Compress, 0, 1000);
805        t.set(REDRAW_NS - 1);
806        meter.progress(Phase::Compress, 370, 1000);
807        t.set(REDRAW_NS);
808        meter.progress(Phase::Compress, 370, 1000);
809        t.set(2 * REDRAW_NS);
810        meter.progress(Phase::Compress, 0, 1000);
811        assert_eq!(
812            out.lines(),
813            vec![
814                "[3/8] xz 6  compress 1/3 ▕░░░░░░░░░░░░░░░░░░░░▏⠋ 0%",
815                "[3/8] xz 6  compress 1/3 ▕███████░░░░░░░░░░░░░▏⠙ 37%",
816                "[3/8] xz 6  compress 2/3 ▕░░░░░░░░░░░░░░░░░░░░▏⠹ 0%",
817            ]
818        );
819        // A new row draws at once, whatever the clock says.
820        meter.row(lz4);
821        meter.progress(Phase::Compress, 0, 1000);
822        assert_eq!(
823            out.lines().last().unwrap(),
824            "[8/8] lz4 -  compress 1/3 ▕░░░░░░░░░░░░░░░░░░░░▏⠸ 0%"
825        );
826        meter.clear();
827        assert!(out.0.borrow().ends_with(b"\r\x1b[K"));
828    }
829
830    #[test]
831    fn an_empty_input_draws_full_and_divides_nothing() {
832        let out = Captured::default();
833        let mut meter = Meter::new(
834            Some(Box::new(out.clone())),
835            Box::new(RealClock::new()),
836            1,
837            || 80,
838        );
839        meter.row(label(1, 1, "gzip", Some(6)));
840        let input = Input::Memory(vec![Vec::new()]);
841        let plan = Plan {
842            mode: Mode::OneStream,
843            repeat: 1,
844            threads: None,
845            memory_limit: 1 << 30,
846        };
847        let mut seen = Vec::new();
848        let o = measure(
849            "gzip",
850            None,
851            &input,
852            &plan,
853            &mut RealClock::new(),
854            &mut |ph, d, t| {
855                seen.push((ph, d, t));
856                meter.progress(ph, d, t)
857            },
858        );
859        assert!(matches!(o, score::Outcome::Ok(_)), "{o:?}");
860        assert_eq!(seen.first(), Some(&(Phase::Compress, 0, 0)));
861        assert_eq!(seen.last(), Some(&(Phase::Decompress, 0, 0)));
862        // The row's first report draws at once, full.
863        assert_eq!(
864            out.lines().first().map(String::as_str),
865            Some("[1/1] gzip 6  compress ▕████████████████████▏⠋ 100%")
866        );
867    }
868
869    #[test]
870    fn formats_are_checked_and_deduplicated() {
871        let all = benchable_codecs();
872        assert_eq!(chosen_codecs(&[]).unwrap(), all);
873        let gz = vec!["gzip".to_string(), "bzip2".into(), "gzip".into()];
874        assert_eq!(chosen_codecs(&gz).unwrap(), vec!["gzip", "bzip2"]);
875        let msg = |n: &str| match chosen_codecs(&[n.to_string()]) {
876            Err(Error::Usage(m)) => m,
877            other => panic!("{other:?}"),
878        };
879        assert_eq!(
880            msg("tar"),
881            "tar cannot be benchmarked in this build (container)"
882        );
883        assert!(msg("nosuch").starts_with("unknown format `nosuch`"));
884        if !all.contains(&"zstd") {
885            assert_eq!(
886                msg("zstd"),
887                "zstd cannot be benchmarked in this build (weak fallback encoder)"
888            );
889        }
890    }
891
892    #[test]
893    fn the_reason_follows_the_matrix_facts() {
894        // No codec in today's builds is read-only, so the classifier is
895        // tested on the facts rather than on a registered format.
896        assert_eq!(why_not(FormatKind::Container, true), "container");
897        assert_eq!(why_not(FormatKind::Container, false), "container");
898        assert_eq!(why_not(FormatKind::Codec, false), "read-only");
899        assert_eq!(why_not(FormatKind::Codec, true), "weak fallback encoder");
900        assert_eq!(why_not_benchable("tar"), "container");
901    }
902
903    #[cfg(unix)]
904    #[test]
905    fn a_file_that_cannot_be_opened_is_a_usage_error_named_or_matched() {
906        use std::os::unix::fs::PermissionsExt;
907        let s = Scratch::new();
908        s.file("a.log");
909        let locked = s.file("locked.log");
910        let _restore = Mode000(locked.clone());
911        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
912        // Root opens it anyway; there is nothing to test.
913        if fs::File::open(&locked).is_ok() {
914            return;
915        }
916        let want = format!("{}: cannot read it (", locked.display());
917        let named = usage(expand_inputs(&[s.s("locked.log")]));
918        assert!(named.starts_with(&want), "{named}");
919        let globbed = usage(expand_inputs(&[s.s("*.log")]));
920        assert!(globbed.starts_with(&want), "{globbed}");
921    }
922
923    /// glob drops a literal component it cannot stat, so a pattern under
924    /// an unreadable directory would match nothing; the literal prefix is
925    /// checked instead.
926    #[cfg(unix)]
927    #[test]
928    fn a_pattern_under_an_unreadable_directory_names_the_prefix() {
929        use std::os::unix::fs::PermissionsExt;
930        let s = Scratch::new();
931        let locked = s.0.join("locked");
932        fs::create_dir_all(locked.join("sub")).unwrap();
933        fs::write(locked.join("sub/b.log"), b"x").unwrap();
934        let _restore = Mode000(locked.clone());
935        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
936        // Root reads it anyway; there is nothing to test.
937        if fs::read_dir(&locked).is_ok() {
938            return;
939        }
940        let m = usage(expand_inputs(&[s.s("locked/sub/*.log")]));
941        let prefix = locked.join("sub");
942        assert!(
943            m.starts_with(&format!("{}: cannot read it (", prefix.display())),
944            "{m}"
945        );
946        // A prefix that is simply absent is still no match.
947        let pat = s.s("absent/sub/*.log");
948        assert_eq!(
949            usage(expand_inputs(std::slice::from_ref(&pat))),
950            format!("no input matched {pat}")
951        );
952    }
953
954    #[test]
955    fn the_literal_prefix_stops_before_the_first_glob_component() {
956        assert_eq!(literal_prefix("a/b/*.log"), Some(Path::new("a/b")));
957        assert_eq!(literal_prefix("/x/l?ck/sub/*.log"), Some(Path::new("/x")));
958        assert_eq!(literal_prefix("/*.log"), Some(Path::new("/")));
959        assert_eq!(literal_prefix("*.log"), None);
960        assert_eq!(literal_prefix("a/[b]/c"), Some(Path::new("a")));
961    }
962
963    #[test]
964    fn the_bar_line_is_cut_to_the_terminal_width() {
965        let full = bar_line(
966            &label(3, 8, "xz", Some(6)),
967            Phase::Decompress,
968            Some((2, 3)),
969            370,
970            1000,
971            2,
972        );
973        // 54 columns, all of them one column wide.
974        assert_eq!(full.chars().count(), 54);
975        assert_eq!(fit(&full, 80), full);
976        assert_eq!(fit(&full, 54), full);
977        // Too narrow for the bar: the row's label alone.
978        assert_eq!(fit(&full, 53), "[3/8] xz 6");
979        assert_eq!(fit(&full, 40), "[3/8] xz 6");
980        assert_eq!(fit(&full, 20), "[3/8] xz 6");
981        // Narrower than the label: the label is cut too.
982        assert_eq!(fit(&full, 5), "[3/8]");
983        assert_eq!(fit(&full, 0), "");
984        let long = bar_line(
985            &label(12, 34, "compress", Some(16)),
986            Phase::Compress,
987            None,
988            0,
989            1,
990            0,
991        );
992        assert_eq!(fit(&long, 20), "[12/34] compress 16");
993        assert_eq!(fit(&long, 10), "[12/34] co");
994    }
995
996    #[test]
997    fn truncation_counts_columns_not_bytes() {
998        let line = "ab ▕██░▏⠹ 37%";
999        // Each block, bar end and spinner is three bytes and one column.
1000        assert_eq!(truncate_cols(line, 3), "ab ");
1001        assert_eq!(truncate_cols(line, 4), "ab ▕");
1002        assert_eq!(truncate_cols(line, 6), "ab ▕██");
1003        assert_eq!(truncate_cols(line, 9), "ab ▕██░▏⠹");
1004        assert_eq!(truncate_cols(line, 100), line);
1005        assert_eq!(truncate_cols(line, 0), "");
1006    }
1007
1008    #[test]
1009    fn the_meter_draws_within_the_terminal_width() {
1010        let out = Captured::default();
1011        let mut meter = Meter::new(
1012            Some(Box::new(out.clone())),
1013            Box::new(HandClock(std::rc::Rc::new(std::cell::Cell::new(0)))),
1014            1,
1015            || 30,
1016        );
1017        meter.row(label(3, 8, "xz", Some(6)));
1018        meter.progress(Phase::Compress, 0, 1000);
1019        // One column kept free, so the cursor never sits past the edge.
1020        assert_eq!(out.lines(), vec!["[3/8] xz 6"]);
1021    }
1022
1023    /// A clock that counts its reads.
1024    struct CountClock(std::rc::Rc<std::cell::Cell<u32>>);
1025
1026    impl Clock for CountClock {
1027        fn now(&mut self) -> u64 {
1028            self.0.set(self.0.get() + 1);
1029            0
1030        }
1031    }
1032
1033    /// Off (no terminal, or a machine-readable report), the meter neither
1034    /// writes nor reads its clock, so it costs a run nothing.
1035    #[test]
1036    fn a_meter_that_is_off_writes_nothing_and_reads_no_clock() {
1037        let reads = std::rc::Rc::new(std::cell::Cell::new(0));
1038        let mut meter = Meter::new(None, Box::new(CountClock(reads.clone())), 3, || {
1039            panic!("an off meter asked for the width")
1040        });
1041        meter.row(label(1, 2, "gzip", Some(6)));
1042        meter.progress(Phase::Compress, 0, 1000);
1043        meter.progress(Phase::Compress, 500, 1000);
1044        meter.progress(Phase::Decompress, 1000, 1000);
1045        meter.row(label(2, 2, "xz", Some(6)));
1046        meter.progress(Phase::Compress, 0, 1000);
1047        meter.clear();
1048        assert_eq!(reads.get(), 0, "an off meter read its clock");
1049        assert!(!meter.shown);
1050    }
1051}