1pub 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
27pub const DEBUG_REFUSAL: &str =
30 "stuffr: bench: timings from a debug build are meaningless; build with --release";
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum Ran {
35 Measured,
37 RefusedDebugBuild,
40}
41
42pub 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 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 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 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
127const REDRAW_NS: u64 = 100_000_000;
129
130const CELLS: u64 = 20;
132
133const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
135
136#[derive(Clone, Copy, Debug)]
138struct RowLabel {
139 k: usize,
140 n: usize,
141 format: &'static str,
142 level: Option<i32>,
143}
144
145struct Meter {
150 out: Option<Box<dyn Write>>,
151 clock: Box<dyn Clock>,
153 repeats: u32,
154 row: Option<RowLabel>,
155 repeat: u32,
157 drawn_at: Option<u64>,
159 frame: usize,
160 shown: bool,
161 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 fn row(&mut self, row: RowLabel) {
187 self.row = Some(row);
188 self.repeat = 0;
189 self.drawn_at = None;
190 }
191
192 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 let line = fit(&line, (self.width)().saturating_sub(1));
212 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
231fn 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 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#[cfg(unix)]
275fn terminal_width() -> usize {
276 let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
278 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#[cfg(not(unix))]
289fn terminal_width() -> usize {
290 80
291}
292
293fn 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
302fn fit(line: &str, cols: usize) -> String {
307 if line.chars().count() <= cols {
308 return line.to_string();
309 }
310 let label = line.split_once(" ").map_or(line, |(l, _)| l);
312 truncate_cols(label, cols).to_string()
313}
314
315fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327struct Planned {
328 format: &'static str,
329 level: Option<i32>,
330 label: Option<i32>,
331}
332
333fn 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
355fn 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
378fn 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
390fn 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
404fn 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 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 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
477fn 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 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 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 #[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 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 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 let m = usage(expand_inputs(&[s.s("locked/*.log")]));
714 assert!(m.contains(": cannot read it ("), "{m}");
715 }
716
717 #[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 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 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 assert_eq!(
784 bar_line(&lz4, Phase::Decompress, None, 2000, 1000, 9),
785 "[8/8] lz4 - decompress ▕████████████████████▏⠏ 100%"
786 );
787 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 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 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 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 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 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 #[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 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 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 assert_eq!(full.chars().count(), 54);
975 assert_eq!(fit(&full, 80), full);
976 assert_eq!(fit(&full, 54), full);
977 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 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 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 assert_eq!(out.lines(), vec!["[3/8] xz 6"]);
1021 }
1022
1023 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 #[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}