1use 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#[derive(Clone, Debug)]
19pub struct Conditions {
20 pub files: usize,
21 pub bytes_in: u64,
22 pub mode: Mode,
23 pub threads: Option<usize>,
25 pub workers: usize,
27 pub repeat: u32,
28 pub streamed: bool,
29}
30
31const LABEL: usize = 21;
35
36const HEADER: [&str; 6] = ["format", "level", "size", "ratio", "compress", "decompress"];
37
38fn size_text(bytes: u64) -> String {
42 if bytes < 1024 {
43 return format!("{bytes} B");
44 }
45 const UNITS: [&str; 3] = ["KiB", "MiB", "GiB"];
46 let mut tenths = 0u128;
47 for (i, name) in UNITS.iter().enumerate() {
48 let unit = 1u128 << (10 * (i + 1));
49 tenths = (u128::from(bytes) * 10 + unit / 2) / unit;
50 if tenths < 10_240 || i == UNITS.len() - 1 {
51 return format!("{}.{} {name}", tenths / 10, tenths % 10);
52 }
53 }
54 unreachable!("the last unit always returns ({tenths})")
55}
56
57fn ratio_of(bytes_in: u64, bytes_out: u64) -> Option<f64> {
59 (bytes_in > 0 && bytes_out > 0).then(|| bytes_in as f64 / bytes_out as f64)
60}
61
62fn ratio_text(bytes_in: u64, bytes_out: u64) -> String {
63 ratio_of(bytes_in, bytes_out).map_or_else(|| "-".into(), |r| format!("{r:.1}x"))
64}
65
66fn time_text(ns: u64) -> String {
70 let ns = u128::from(ns);
71 let tenth_ms = (ns + 50_000) / 100_000;
72 if tenth_ms < 100 {
73 return format!("{}.{} ms", tenth_ms / 10, tenth_ms % 10);
74 }
75 let ms = (ns + 500_000) / 1_000_000;
76 if ms < 1000 {
77 return format!("{ms} ms");
78 }
79 let tenths = (ns + 50_000_000) / 100_000_000;
80 format!("{}.{} s", tenths / 10, tenths % 10)
81}
82
83fn one_line(why: &str) -> String {
87 let mut lines = why.lines();
88 let first = lines.next().unwrap_or("");
89 let mut out = String::with_capacity(first.len());
90 for c in first.chars() {
91 if c.is_control() {
92 out.extend(c.escape_default());
93 } else {
94 out.push(c);
95 }
96 }
97 if lines.next().is_some() {
98 out.push_str(" …");
99 }
100 out
101}
102
103fn backend_text(rows: &[Row]) -> String {
106 let mut c: Vec<&str> = Vec::new();
107 for r in rows.iter().filter(|r| r.backend == "c") {
108 if !c.contains(&r.format) {
109 c.push(r.format);
110 }
111 }
112 if c.is_empty() {
113 "pure".into()
114 } else {
115 format!("C: {}", c.join(", "))
116 }
117}
118
119fn mode_name(mode: Mode) -> &'static str {
120 match mode {
121 Mode::OneStream => "one-stream",
122 Mode::PerFile => "per-file",
123 }
124}
125
126fn name_of(row: &Row) -> String {
127 match row.level {
128 Some(l) => format!("{} -{l}", row.format),
129 None => row.format.to_string(),
130 }
131}
132
133fn threads_text(threads: Option<usize>, workers: usize) -> String {
136 let unit = if workers == 1 {
137 "compress thread"
138 } else {
139 "compress threads"
140 };
141 match threads {
142 Some(0) => format!("{workers} {unit} (auto)"),
143 _ => format!("{workers} {unit}"),
144 }
145}
146
147fn input_line(c: &Conditions, rows: &[Row]) -> String {
148 let mode = match c.mode {
149 Mode::OneStream => "one stream",
150 Mode::PerFile => "per file",
151 };
152 let files = if c.files == 1 { "file" } else { "files" };
153 let runs = if c.repeat == 1 { "run" } else { "runs" };
154 format!(
155 "input: {} {files}, {} · {mode} · {} · {} · {} {runs}{}",
156 c.files,
157 size_text(c.bytes_in),
158 threads_text(c.threads, c.workers),
159 backend_text(rows),
160 c.repeat,
161 if c.streamed {
162 ", streamed from disk"
163 } else {
164 ""
165 },
166 )
167}
168
169fn table_lines(rows: &[Row], bytes_in: u64) -> Vec<String> {
170 let mut cells: Vec<Vec<String>> = vec![HEADER.iter().map(|s| s.to_string()).collect()];
171 let mut failures: Vec<Option<String>> = vec![None];
172 for row in rows {
173 let level = row.level.map_or_else(|| "-".into(), |l| l.to_string());
174 match &row.outcome {
175 Outcome::Ok(m) => {
176 cells.push(vec![
177 row.format.into(),
178 level,
179 size_text(m.bytes_out),
180 ratio_text(bytes_in, m.bytes_out),
181 time_text(m.compress_ns),
182 time_text(m.decompress_ns),
183 ]);
184 failures.push(None);
185 }
186 Outcome::Failed(why) => {
187 cells.push(vec![row.format.into(), level]);
188 failures.push(Some(format!("failed: {}", one_line(why))));
189 }
190 }
191 }
192 let widths: Vec<usize> = (0..6)
193 .map(|i| {
194 cells
195 .iter()
196 .filter_map(|r| r.get(i))
197 .map(|s| s.chars().count())
198 .max()
199 .unwrap_or(0)
200 })
201 .collect();
202 cells
203 .iter()
204 .zip(&failures)
205 .map(|(r, failed)| {
206 let mut line = format!("{:<w$}", r[0], w = widths[0]);
207 for (i, cell) in r.iter().enumerate().skip(1) {
208 line.push_str(&format!(" {:>w$}", cell, w = widths[i]));
209 }
210 if let Some(f) = failed {
211 line.push_str(" ");
212 line.push_str(f);
213 }
214 line
215 })
216 .collect()
217}
218
219fn write_table(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
220 for line in table_lines(rows, bytes_in) {
221 writeln!(out, "{}", line.trim_end())?;
222 }
223 Ok(())
224}
225
226fn measured(row: &Row) -> Option<&Measured> {
227 match &row.outcome {
228 Outcome::Ok(m) => Some(m),
229 Outcome::Failed(_) => None,
230 }
231}
232
233pub fn human(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
235 writeln!(out, "{}", input_line(c, rows))?;
236 writeln!(out)?;
237 write_table(rows, c.bytes_in, out)?;
238 writeln!(out)?;
239 let w = winners(rows);
240 let pick = |i: Option<usize>| i.map(|i| (name_of(&rows[i]), measured(&rows[i]).copied()));
241 let (Some((sn, Some(sm))), Some((cn, Some(cm))), Some((dn, Some(dm))), Some((bn, Some(bm)))) = (
242 pick(w.smallest),
243 pick(w.fastest_compress),
244 pick(w.fastest_decompress),
245 pick(w.balance),
246 ) else {
247 return writeln!(out, "no format succeeded");
248 };
249 let width = [&sn, &cn, &dn, &bn]
250 .iter()
251 .map(|s| s.chars().count() + 2)
252 .max()
253 .unwrap_or(0)
254 .max(12);
255 writeln!(
256 out,
257 "{:<LABEL$}{sn:<width$}{} ({})",
258 "smallest:",
259 size_text(sm.bytes_out),
260 ratio_text(c.bytes_in, sm.bytes_out)
261 )?;
262 writeln!(
263 out,
264 "{:<LABEL$}{cn:<width$}{}",
265 "fastest compress:",
266 time_text(cm.compress_ns)
267 )?;
268 writeln!(
269 out,
270 "{:<LABEL$}{dn:<width$}{}",
271 "fastest decompress:",
272 time_text(dm.decompress_ns)
273 )?;
274 writeln!(
275 out,
276 "{:<LABEL$}{bn:<width$}{} at {} / {}",
277 "best balance:",
278 ratio_text(c.bytes_in, bm.bytes_out),
279 time_text(bm.compress_ns),
280 time_text(bm.decompress_ns)
281 )
282}
283
284pub fn table_only(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
286 write_table(rows, bytes_in, out)
287}
288
289fn finite(x: Option<f64>) -> Value {
290 x.filter(|x| x.is_finite())
291 .map_or(Value::Null, |x| json!(x))
292}
293
294pub fn json(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
297 let scores = balance_scores(rows);
298 let w = winners(rows);
299 let who = |i: Option<usize>| {
300 i.map_or(
301 Value::Null,
302 |i| json!({"format": rows[i].format, "level": rows[i].level}),
303 )
304 };
305 let jrows: Vec<Value> = rows
306 .iter()
307 .zip(&scores)
308 .map(|(r, score)| match &r.outcome {
309 Outcome::Ok(m) => json!({
310 "format": r.format, "level": r.level, "backend": r.backend, "ok": true,
311 "bytes_out": m.bytes_out,
312 "ratio": finite(ratio_of(c.bytes_in, m.bytes_out)),
313 "compress_ns": m.compress_ns, "decompress_ns": m.decompress_ns,
314 "balance": finite(*score), "error": null,
315 }),
316 Outcome::Failed(why) => json!({
317 "format": r.format, "level": r.level, "backend": r.backend, "ok": false,
318 "bytes_out": null, "ratio": null,
319 "compress_ns": null, "decompress_ns": null,
320 "balance": null, "error": why,
321 }),
322 })
323 .collect();
324 let doc = json!({
325 "conditions": {
326 "files": c.files,
327 "bytes_in": c.bytes_in,
328 "mode": mode_name(c.mode),
329 "threads": c.threads,
330 "workers": c.workers,
331 "repeat": c.repeat,
332 "streamed": c.streamed,
333 },
334 "rows": jrows,
335 "winners": {
336 "smallest": who(w.smallest),
337 "fastest_compress": who(w.fastest_compress),
338 "fastest_decompress": who(w.fastest_decompress),
339 "balance": who(w.balance),
340 },
341 });
342 writeln!(out, "{doc}")
343}
344
345fn csv_field(s: &str) -> String {
346 if s.contains([',', '"', '\n', '\r']) {
347 format!("\"{}\"", s.replace('"', "\"\""))
348 } else {
349 s.to_string()
350 }
351}
352
353pub fn csv(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
357 writeln!(
358 out,
359 "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
360 files,mode,threads,workers,repeat,streamed,backend"
361 )?;
362 let bytes_in = c.bytes_in;
363 let threads = c.threads.map_or(String::new(), |t| t.to_string());
364 let conditions = format!(
365 "{},{},{threads},{},{},{}",
366 c.files,
367 mode_name(c.mode),
368 c.workers,
369 c.repeat,
370 c.streamed
371 );
372 for (r, score) in rows.iter().zip(balance_scores(rows)) {
373 let level = r.level.map_or(String::new(), |l| l.to_string());
374 match &r.outcome {
375 Outcome::Ok(m) => {
376 let ratio =
377 ratio_of(bytes_in, m.bytes_out).map_or(String::new(), |x| x.to_string());
378 let bal = score.map_or(String::new(), |x| x.to_string());
379 writeln!(
380 out,
381 "{},{level},true,{bytes_in},{},{ratio},{},{},{bal},,{conditions},{}",
382 r.format, m.bytes_out, m.compress_ns, m.decompress_ns, r.backend
383 )?;
384 }
385 Outcome::Failed(why) => writeln!(
386 out,
387 "{},{level},false,{bytes_in},,,,,,{},{conditions},{}",
388 r.format,
389 csv_field(why),
390 r.backend
391 )?,
392 }
393 }
394 Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 const MIB: u64 = 1 << 20;
402
403 fn ok(format: &'static str, level: Option<i32>, out: u64, c_ms: u64, d_ms: u64) -> Row {
404 Row {
405 format,
406 level,
407 backend: "pure",
408 outcome: Outcome::Ok(Measured {
409 bytes_out: out,
410 compress_ns: c_ms * 1_000_000,
411 decompress_ns: d_ms * 1_000_000,
412 }),
413 }
414 }
415
416 fn rows() -> Vec<Row> {
417 vec![
418 ok("zstd", Some(3), 40 * MIB, 900, 300),
419 ok("lz4", None, 100 * MIB, 600, 200),
420 ok("xz", Some(6), 25 * MIB, 8200, 1250),
421 Row {
422 format: "bzip2",
423 level: Some(9),
424 backend: "pure",
425 outcome: Outcome::Failed("no c tier".into()),
426 },
427 ]
428 }
429
430 fn cond() -> Conditions {
431 Conditions {
432 files: 3,
433 bytes_in: 412 * MIB,
434 mode: Mode::OneStream,
435 threads: None,
436 workers: 1,
437 repeat: 1,
438 streamed: false,
439 }
440 }
441
442 fn render(f: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String {
443 let mut v = Vec::new();
444 f(&mut v).unwrap();
445 String::from_utf8(v).unwrap()
446 }
447
448 #[test]
449 fn human_output_is_layout_b() {
450 let got = render(|o| human(&cond(), &rows(), o));
451 let want = include_str!("testdata/human-layout-b.txt");
454 assert_eq!(got, want);
455 }
456
457 #[test]
458 fn table_only_has_no_input_line_or_winners() {
459 let got = render(|o| table_only(&rows(), 412 * MIB, o));
460 assert!(got.starts_with("format"));
461 assert!(!got.contains("input:") && !got.contains("smallest"));
462 assert_eq!(got.lines().count(), 5);
463 }
464
465 #[test]
466 fn json_parses_and_carries_conditions() {
467 let got = render(|o| json(&cond(), &rows(), o));
468 let v: Value = serde_json::from_str(&got).unwrap();
469 assert_eq!(v["conditions"]["mode"], "one-stream");
470 assert!(v["conditions"]["tier"].is_null(), "the backend is per row");
471 assert_eq!(v["rows"][0]["backend"], "pure");
472 assert_eq!(v["rows"].as_array().unwrap().len(), 4);
473 assert_eq!(v["winners"]["smallest"]["format"], "xz");
474 assert_eq!(v["rows"][3]["ok"], false);
475 assert!(!v["rows"][3]["error"].is_null());
476 assert!(v["rows"][1]["level"].is_null());
477 }
478
479 #[test]
480 fn csv_quotes_a_comma_in_an_error() {
481 let rows = vec![Row {
482 format: "xz",
483 level: Some(6),
484 backend: "c",
485 outcome: Outcome::Failed("bad, \"very\" bad".into()),
486 }];
487 let c = Conditions {
488 bytes_in: 10,
489 ..cond()
490 };
491 let got = render(|o| csv(&c, &rows, o));
492 let mut lines = got.lines();
493 assert_eq!(
494 lines.next().unwrap(),
495 "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
496 files,mode,threads,workers,repeat,streamed,backend"
497 );
498 assert_eq!(
499 lines.next().unwrap(),
500 "xz,6,false,10,,,,,,\"bad, \"\"very\"\" bad\",3,one-stream,,1,1,false,c"
501 );
502 }
503
504 #[test]
505 fn zero_input_prints_a_dash_ratio() {
506 let rows = vec![ok("lz4", None, 0, 1, 1)];
507 let got = render(|o| table_only(&rows, 0, o));
508 assert!(got.lines().nth(1).unwrap().contains(" - "), "{got}");
509 let mut c = cond();
510 c.bytes_in = 0;
511 let j = render(|o| json(&c, &rows, o));
512 assert!(serde_json::from_str::<Value>(&j).unwrap()["rows"][0]["ratio"].is_null());
513 }
514
515 #[test]
516 fn no_success_prints_no_format_succeeded() {
517 let rows = vec![Row {
518 format: "xz",
519 level: Some(1),
520 backend: "pure",
521 outcome: Outcome::Failed("x".into()),
522 }];
523 let got = render(|o| human(&cond(), &rows, o));
524 assert!(got.ends_with("\nno format succeeded\n"), "{got}");
525 assert!(!got.contains("smallest"));
526 }
527
528 #[test]
529 fn the_input_line_shows_the_resolved_worker_count() {
530 assert_eq!(threads_text(None, 1), "1 compress thread");
531 assert_eq!(threads_text(Some(1), 1), "1 compress thread");
532 assert_eq!(threads_text(Some(4), 4), "4 compress threads");
533 assert_eq!(threads_text(Some(0), 8), "8 compress threads (auto)");
534 let c = Conditions {
535 threads: Some(0),
536 workers: 8,
537 ..cond()
538 };
539 assert!(input_line(&c, &rows()).contains(" · 8 compress threads (auto) · "));
540 let j = render(|o| json(&c, &[], o));
541 let v: Value = serde_json::from_str(&j).unwrap();
542 assert_eq!(v["conditions"]["threads"], 0);
543 assert_eq!(v["conditions"]["workers"], 8);
544 }
545
546 #[test]
547 fn ms_below_a_second_s_above() {
548 assert_eq!(time_text(850_000_000), "850 ms");
549 assert_eq!(time_text(1_250_000_000), "1.3 s");
550 assert_eq!(time_text(900_000_000), "900 ms");
551 }
552
553 #[test]
554 fn csv_carries_the_conditions_and_each_rows_backend() {
555 let mut rows = rows();
556 rows[0].backend = "c";
557 let c = Conditions {
558 mode: Mode::PerFile,
559 threads: Some(0),
560 workers: 8,
561 repeat: 5,
562 streamed: true,
563 ..cond()
564 };
565 let got = render(|o| csv(&c, &rows, o));
566 let lines: Vec<&str> = got.lines().collect();
567 let header: Vec<&str> = lines[0].split(',').collect();
568 assert_eq!(header.len(), 17);
569 assert_eq!(
570 &header[..10],
571 &[
572 "format",
573 "level",
574 "ok",
575 "bytes_in",
576 "bytes_out",
577 "ratio",
578 "compress_ns",
579 "decompress_ns",
580 "balance",
581 "error"
582 ]
583 );
584 for line in &lines[1..] {
585 let f: Vec<&str> = line.split(',').collect();
586 assert_eq!(f.len(), 17, "{line}");
587 assert_eq!(
588 &f[10..16],
589 &["3", "per-file", "0", "8", "5", "true"],
590 "{line}"
591 );
592 }
593 assert!(lines[1].starts_with("zstd,3,true,"), "{}", lines[1]);
594 assert!(lines[1].ends_with(",c"), "{}", lines[1]);
595 assert!(lines[2].ends_with(",pure"), "{}", lines[2]);
596 let got = render(|o| csv(&cond(), &rows, o));
598 assert!(
599 got.lines()
600 .nth(1)
601 .unwrap()
602 .contains(",3,one-stream,,1,1,false,")
603 );
604 }
605
606 #[test]
607 fn the_input_line_names_the_c_backed_codecs() {
608 let mut rows = rows();
609 assert!(input_line(&cond(), &rows).contains(" · pure · "));
610 rows[0].backend = "c";
611 rows[1].backend = "c";
612 rows.push(ok("zstd", Some(19), MIB, 1, 1));
613 rows.last_mut().unwrap().backend = "c";
614 let line = input_line(&cond(), &rows);
615 assert!(line.contains(" · C: zstd, lz4 · "), "{line}");
616 let j = render(|o| json(&cond(), &rows, o));
617 let v: Value = serde_json::from_str(&j).unwrap();
618 let backends: Vec<&str> = v["rows"]
619 .as_array()
620 .unwrap()
621 .iter()
622 .map(|r| r["backend"].as_str().unwrap())
623 .collect();
624 assert_eq!(backends, ["c", "c", "pure", "pure", "c"]);
625 }
626
627 #[test]
628 fn a_multi_line_failure_keeps_to_one_table_line() {
629 let mut rows = rows();
630 rows[3].outcome = Outcome::Failed("first\nsecond\tthird\u{1b}[31m".into());
631 let table = render(|o| table_only(&rows, 412 * MIB, o));
632 assert_eq!(table.lines().count(), 5, "{table}");
633 let last = table.lines().last().unwrap();
634 assert!(last.ends_with("failed: first …"), "{last}");
635 rows[3].outcome = Outcome::Failed("tab\there\u{1b}[31m".into());
636 let table = render(|o| table_only(&rows, 412 * MIB, o));
637 let last = table.lines().last().unwrap();
638 assert!(last.ends_with("failed: tab\\there\\u{1b}[31m"), "{last}");
639 let j = render(|o| json(&cond(), &rows, o));
641 let v: Value = serde_json::from_str(&j).unwrap();
642 assert_eq!(v["rows"][3]["error"], "tab\there\u{1b}[31m");
643 }
644
645 #[test]
646 fn times_under_10_ms_keep_a_decimal_and_edges_round_up_a_unit() {
647 assert_eq!(time_text(40_000), "0.0 ms");
648 assert_eq!(time_text(400_000), "0.4 ms");
649 assert_eq!(time_text(9_940_000), "9.9 ms");
650 assert_eq!(time_text(9_960_000), "10 ms");
652 assert_eq!(time_text(10_000_000), "10 ms");
653 assert_eq!(time_text(999_600_000), "1.0 s");
655 assert_eq!(time_text(999_400_000), "999 ms");
656 assert_eq!(time_text(59_950_000_000), "60.0 s");
657 }
658
659 #[test]
660 fn sizes_round_up_a_unit_at_the_edge() {
661 assert_eq!(size_text(1023), "1023 B");
662 assert_eq!(size_text(1024), "1.0 KiB");
663 assert_eq!(size_text(1_048_535), "1.0 MiB");
665 assert_eq!(size_text((1023.96 * MIB as f64) as u64), "1.0 GiB");
666 assert_eq!(size_text((1023.9 * MIB as f64) as u64), "1023.9 MiB");
667 assert_eq!(size_text(2 << 30), "2.0 GiB");
668 assert_eq!(size_text(412 * MIB), "412.0 MiB");
669 }
670}