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::{Input, Mode, Plan, RealClock, governor_for, load_input, measure, warm};
22use report::Conditions;
23use score::Row;
24
25pub const DEBUG_REFUSAL: &str =
28 "stuffr: bench: timings from a debug build are meaningless; build with --release";
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Ran {
33 Measured,
35 RefusedDebugBuild,
38}
39
40pub fn run(args: BenchArgs) -> stuffr::Result<Ran> {
43 let paths = expand_inputs(&args.inputs)?;
44 let codecs = chosen_codecs(&args.formats)?;
45 let memory_limit = match &args.memory_limit {
46 Some(s) => crate::size::parse_size(s).map_err(Error::Usage)?,
47 None => stuffr::default_memory_limit(),
48 };
49 if cfg!(debug_assertions) {
51 return Ok(Ran::RefusedDebugBuild);
52 }
53
54 let (input, bytes_in) = load_input(&paths, memory_limit)?;
55 warm(&input)?;
56 let plan = Plan {
57 mode: if args.per_file {
58 Mode::PerFile
59 } else {
60 Mode::OneStream
61 },
62 repeat: args.repeat,
63 threads: args.threads,
64 memory_limit,
65 };
66 let planned = plan_rows(&codecs, args.levels);
67 let mut meter = Meter {
68 on: io::stderr().is_terminal() && !(args.json || args.csv || args.table_only),
69 shown: false,
70 };
71 let mut clock = RealClock::new();
72 let mut rows = Vec::with_capacity(planned.len());
73 for (k, p) in planned.iter().enumerate() {
74 meter.show(k + 1, planned.len(), p.format, p.label);
76 let outcome = measure(p.format, p.level, &input, &plan, &mut clock);
77 rows.push(Row {
78 format: p.format,
79 level: p.label,
80 backend: backend_of(p.format),
81 outcome,
82 });
83 }
84 meter.clear();
85
86 let conditions = Conditions {
87 files: paths.len(),
88 bytes_in,
89 mode: plan.mode,
90 threads: plan.threads,
91 workers: governor_for(&plan).map_or(1, |g| g.workers()),
93 repeat: plan.repeat,
94 streamed: matches!(input, Input::Files(_)),
95 };
96 let mut out = io::stdout().lock();
97 if args.json {
98 report::json(&conditions, &rows, &mut out)?;
99 } else if args.csv {
100 report::csv(&conditions, &rows, &mut out)?;
101 } else if args.table_only {
102 report::table_only(&rows, conditions.bytes_in, &mut out)?;
103 } else {
104 report::human(&conditions, &rows, &mut out)?;
105 }
106 out.flush()?;
107 Ok(Ran::Measured)
108}
109
110struct Meter {
113 on: bool,
114 shown: bool,
115}
116
117impl Meter {
118 fn show(&mut self, k: usize, n: usize, format: &str, level: Option<i32>) {
119 if !self.on {
120 return;
121 }
122 let level = level.map_or_else(|| "-".to_string(), |l| l.to_string());
123 eprint!("\r[{k}/{n}] {format} {level}\x1b[K");
125 self.shown = true;
126 }
127
128 fn clear(&mut self) {
129 if self.shown {
130 eprint!("\r\x1b[K");
131 self.shown = false;
132 }
133 }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139struct Planned {
140 format: &'static str,
141 level: Option<i32>,
142 label: Option<i32>,
143}
144
145fn plan_rows(codecs: &[&'static str], sweep: bool) -> Vec<Planned> {
148 let mut rows = Vec::new();
149 for &format in codecs {
150 let lv = levels_of(format);
151 match lv.map(|l| l.sweep).filter(|s| sweep && !s.is_empty()) {
152 Some(levels) => rows.extend(levels.iter().map(|&l| Planned {
153 format,
154 level: Some(l),
155 label: Some(l),
156 })),
157 None => rows.push(Planned {
158 format,
159 level: None,
160 label: lv.and_then(|l| l.default),
161 }),
162 }
163 }
164 rows
165}
166
167fn chosen_codecs(names: &[String]) -> stuffr::Result<Vec<&'static str>> {
170 let benchable = benchable_codecs();
171 if names.is_empty() {
172 return Ok(benchable);
173 }
174 let mut chosen = Vec::new();
175 for name in names {
176 let id = crate::format_by_name(name)?;
177 let Some(&codec) = benchable.iter().find(|c| **c == id.as_str()) else {
178 return Err(Error::Usage(format!(
179 "{name} cannot be benchmarked in this build ({})",
180 why_not_benchable(name)
181 )));
182 };
183 if !chosen.contains(&codec) {
184 chosen.push(codec);
185 }
186 }
187 Ok(chosen)
188}
189
190fn why_not_benchable(name: &str) -> &'static str {
192 let row = stuffr::registry()
193 .matrix()
194 .into_iter()
195 .find(|r| r.id.as_str() == name);
196 match row {
197 Some(r) => why_not(r.kind, r.write),
198 None => why_not(FormatKind::Codec, true),
199 }
200}
201
202fn why_not(kind: FormatKind, write: bool) -> &'static str {
205 match (kind, write) {
206 (FormatKind::Container, _) => "container",
207 (_, false) => "read-only",
208 _ => "weak fallback encoder",
209 }
210}
211
212fn has_glob_chars(s: &str) -> bool {
213 s.contains(['*', '?', '['])
214}
215
216fn expand_inputs(operands: &[String]) -> stuffr::Result<Vec<PathBuf>> {
224 let mut files = Vec::new();
225 for op in operands {
226 let path = Path::new(op);
227 if path.exists() {
228 if path.is_dir() {
229 return Err(directory_error(path));
230 }
231 files.push(regular(path)?);
232 continue;
233 }
234 if !has_glob_chars(op) {
235 return Err(no_match(op));
236 }
237 let opts = glob::MatchOptions {
238 require_literal_leading_dot: true,
239 ..Default::default()
240 };
241 let matches = glob::glob_with(op, opts)
242 .map_err(|e| Error::Usage(format!("bad pattern {op}: {e}")))?;
243 let mut found = Vec::new();
244 let mut first_dir = None;
245 for m in matches {
246 let m = m.map_err(|e| Error::Io(e.into()))?;
247 if m.is_dir() {
248 first_dir.get_or_insert(m);
249 } else {
250 found.push(regular(&m)?);
251 }
252 }
253 if found.is_empty() {
254 return Err(first_dir.map_or_else(|| no_match(op), |d| directory_error(&d)));
255 }
256 found.sort();
257 files.extend(found);
258 }
259 Ok(files)
260}
261
262fn regular(path: &Path) -> stuffr::Result<PathBuf> {
264 if std::fs::metadata(path)?.is_file() {
265 Ok(path.to_path_buf())
266 } else {
267 Err(Error::Usage(format!(
268 "{} is not a regular file; bench needs files whose size it can read",
269 path.display()
270 )))
271 }
272}
273
274fn no_match(op: &str) -> Error {
275 Error::Usage(format!("no input matched {op}"))
276}
277
278fn directory_error(path: &Path) -> Error {
279 let p = path.display();
280 Error::Usage(format!("{p} is a directory; name its files (e.g. {p}/*)"))
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::fs;
287 use std::sync::atomic::{AtomicU32, Ordering};
288
289 struct Scratch(PathBuf);
291
292 impl Scratch {
293 fn new() -> Self {
294 static N: AtomicU32 = AtomicU32::new(0);
295 let n = N.fetch_add(1, Ordering::Relaxed);
296 let dir =
297 std::env::temp_dir().join(format!("stuffr-benchrun-{}-{n}", std::process::id()));
298 fs::create_dir_all(&dir).unwrap();
299 Scratch(dir)
300 }
301
302 fn file(&self, name: &str) -> PathBuf {
303 let p = self.0.join(name);
304 fs::write(&p, b"x").unwrap();
305 p
306 }
307
308 fn s(&self, rest: &str) -> String {
309 format!("{}/{rest}", self.0.display())
310 }
311 }
312
313 impl Drop for Scratch {
314 fn drop(&mut self) {
315 let _ = fs::remove_dir_all(&self.0);
316 }
317 }
318
319 fn usage(r: stuffr::Result<Vec<PathBuf>>) -> String {
320 match r {
321 Err(Error::Usage(m)) => m,
322 other => panic!("expected a usage error, got {other:?}"),
323 }
324 }
325
326 #[test]
327 fn a_default_row_runs_the_encoders_default_labelled_with_the_table_default() {
328 let rows = plan_rows(&["bzip2", "compress", "lz4"], false);
329 assert_eq!(
330 rows,
331 vec![
332 Planned {
333 format: "bzip2",
334 level: None,
335 label: Some(6)
336 },
337 Planned {
338 format: "compress",
339 level: None,
340 label: Some(16)
341 },
342 Planned {
343 format: "lz4",
344 level: None,
345 label: None
346 },
347 ]
348 );
349 }
350
351 #[test]
352 fn a_sweep_runs_every_level_and_one_row_for_a_codec_without_levels() {
353 let rows = plan_rows(&["compress", "snappy"], true);
354 let got: Vec<_> = rows.iter().map(|r| (r.format, r.level, r.label)).collect();
355 let mut want: Vec<_> = (9..=16).map(|l| ("compress", Some(l), Some(l))).collect();
356 want.push(("snappy", None, None));
357 assert_eq!(got, want);
358 }
359
360 #[test]
361 fn a_glob_expands_sorted_and_skips_dot_files_and_directories() {
362 let s = Scratch::new();
363 s.file("b.log");
364 s.file("a.log");
365 s.file(".hidden.log");
366 fs::create_dir(s.0.join("dir.log")).unwrap();
367 let got = expand_inputs(&[s.s("*.log")]).unwrap();
368 assert_eq!(got, vec![s.0.join("a.log"), s.0.join("b.log")]);
369 }
370
371 #[test]
372 fn an_existing_path_is_used_as_is_even_with_glob_characters() {
373 let s = Scratch::new();
374 let odd = s.file("a[1].log");
375 assert_eq!(expand_inputs(&[s.s("a[1].log")]).unwrap(), vec![odd]);
376 }
377
378 #[test]
379 fn no_match_and_a_missing_path_are_usage_errors() {
380 let s = Scratch::new();
381 let pat = s.s("nomatch*.log");
382 assert_eq!(
383 usage(expand_inputs(std::slice::from_ref(&pat))),
384 format!("no input matched {pat}")
385 );
386 let missing = s.s("absent.log");
387 assert!(usage(expand_inputs(&[missing])).starts_with("no input matched"));
388 }
389
390 #[test]
391 fn a_directory_is_refused_named_or_as_a_patterns_only_match() {
392 let s = Scratch::new();
393 fs::create_dir(s.0.join("logs")).unwrap();
394 let named = usage(expand_inputs(&[s.s("logs")]));
395 let dir = s.s("logs");
396 assert_eq!(
397 named,
398 format!("{dir} is a directory; name its files (e.g. {dir}/*)")
399 );
400 assert!(usage(expand_inputs(&[s.s("lo*")])).contains("is a directory"));
401 }
402
403 #[cfg(unix)]
404 #[test]
405 fn a_device_is_refused_and_a_symlink_to_a_file_is_not() {
406 let s = Scratch::new();
407 let want = "/dev/null is not a regular file; bench needs files whose size it can read";
408 assert_eq!(usage(expand_inputs(&["/dev/null".into()])), want);
409 assert_eq!(usage(expand_inputs(&["/dev/nul?".into()])), want);
410 let target = s.file("real.log");
411 let link = s.0.join("link.log");
412 std::os::unix::fs::symlink(&target, &link).unwrap();
413 assert_eq!(expand_inputs(&[s.s("link.log")]).unwrap(), vec![link]);
414 }
415
416 #[test]
417 fn formats_are_checked_and_deduplicated() {
418 let all = benchable_codecs();
419 assert_eq!(chosen_codecs(&[]).unwrap(), all);
420 let gz = vec!["gzip".to_string(), "bzip2".into(), "gzip".into()];
421 assert_eq!(chosen_codecs(&gz).unwrap(), vec!["gzip", "bzip2"]);
422 let msg = |n: &str| match chosen_codecs(&[n.to_string()]) {
423 Err(Error::Usage(m)) => m,
424 other => panic!("{other:?}"),
425 };
426 assert_eq!(
427 msg("tar"),
428 "tar cannot be benchmarked in this build (container)"
429 );
430 assert!(msg("nosuch").starts_with("unknown format `nosuch`"));
431 if !all.contains(&"zstd") {
432 assert_eq!(
433 msg("zstd"),
434 "zstd cannot be benchmarked in this build (weak fallback encoder)"
435 );
436 }
437 }
438
439 #[test]
440 fn the_reason_follows_the_matrix_facts() {
441 assert_eq!(why_not(FormatKind::Container, true), "container");
444 assert_eq!(why_not(FormatKind::Container, false), "container");
445 assert_eq!(why_not(FormatKind::Codec, false), "read-only");
446 assert_eq!(why_not(FormatKind::Codec, true), "weak fallback encoder");
447 assert_eq!(why_not_benchable("tar"), "container");
448 }
449}