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