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
169const DISK_NOTE: &str = "* output written to a temp file while timed (too large for memory)";
171
172fn disk_mark(on_disk: bool, any: bool) -> &'static str {
176 match (any, on_disk) {
177 (false, _) => "",
178 (true, true) => "*",
179 (true, false) => " ",
180 }
181}
182
183fn any_on_disk(rows: &[Row]) -> bool {
184 rows.iter().filter_map(measured).any(|m| m.on_disk)
185}
186
187fn output_name(m: &Measured) -> &'static str {
188 if m.on_disk { "disk" } else { "memory" }
189}
190
191fn table_lines(rows: &[Row], bytes_in: u64) -> Vec<String> {
192 let any = any_on_disk(rows);
193 let mut header: Vec<String> = HEADER.iter().map(|s| s.to_string()).collect();
194 header[4].push_str(disk_mark(false, any));
195 let mut cells: Vec<Vec<String>> = vec![header];
196 let mut failures: Vec<Option<String>> = vec![None];
197 for row in rows {
198 let level = row.level.map_or_else(|| "-".into(), |l| l.to_string());
199 match &row.outcome {
200 Outcome::Ok(m) => {
201 cells.push(vec![
202 row.format.into(),
203 level,
204 size_text(m.bytes_out),
205 ratio_text(bytes_in, m.bytes_out),
206 format!("{}{}", time_text(m.compress_ns), disk_mark(m.on_disk, any)),
207 time_text(m.decompress_ns),
208 ]);
209 failures.push(None);
210 }
211 Outcome::Failed(why) => {
212 cells.push(vec![row.format.into(), level]);
213 failures.push(Some(format!("failed: {}", one_line(why))));
214 }
215 }
216 }
217 let widths: Vec<usize> = (0..6)
218 .map(|i| {
219 cells
220 .iter()
221 .filter_map(|r| r.get(i))
222 .map(|s| s.chars().count())
223 .max()
224 .unwrap_or(0)
225 })
226 .collect();
227 cells
228 .iter()
229 .zip(&failures)
230 .map(|(r, failed)| {
231 let mut line = format!("{:<w$}", r[0], w = widths[0]);
232 for (i, cell) in r.iter().enumerate().skip(1) {
233 line.push_str(&format!(" {:>w$}", cell, w = widths[i]));
234 }
235 if let Some(f) = failed {
236 line.push_str(" ");
237 line.push_str(f);
238 }
239 line
240 })
241 .collect()
242}
243
244fn write_table(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
246 for line in table_lines(rows, bytes_in) {
247 writeln!(out, "{}", line.trim_end())?;
248 }
249 if any_on_disk(rows) {
250 writeln!(out, "{DISK_NOTE}")?;
251 }
252 Ok(())
253}
254
255fn measured(row: &Row) -> Option<&Measured> {
256 match &row.outcome {
257 Outcome::Ok(m) => Some(m),
258 Outcome::Failed(_) => None,
259 }
260}
261
262pub fn human(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
264 writeln!(out, "{}", input_line(c, rows))?;
265 writeln!(out)?;
266 write_table(rows, c.bytes_in, out)?;
267 writeln!(out)?;
268 let w = winners(rows);
269 let pick = |i: Option<usize>| i.map(|i| (name_of(&rows[i]), measured(&rows[i]).copied()));
270 let (Some((sn, Some(sm))), Some((cn, Some(cm))), Some((dn, Some(dm))), Some((bn, Some(bm)))) = (
271 pick(w.smallest),
272 pick(w.fastest_compress),
273 pick(w.fastest_decompress),
274 pick(w.balance),
275 ) else {
276 return writeln!(out, "no format succeeded");
277 };
278 let width = [&sn, &cn, &dn, &bn]
279 .iter()
280 .map(|s| s.chars().count() + 2)
281 .max()
282 .unwrap_or(0)
283 .max(12);
284 writeln!(
285 out,
286 "{:<LABEL$}{sn:<width$}{} ({})",
287 "smallest:",
288 size_text(sm.bytes_out),
289 ratio_text(c.bytes_in, sm.bytes_out)
290 )?;
291 writeln!(
292 out,
293 "{:<LABEL$}{cn:<width$}{}",
294 "fastest compress:",
295 time_text(cm.compress_ns)
296 )?;
297 writeln!(
298 out,
299 "{:<LABEL$}{dn:<width$}{}",
300 "fastest decompress:",
301 time_text(dm.decompress_ns)
302 )?;
303 writeln!(
304 out,
305 "{:<LABEL$}{bn:<width$}{} at {} / {}",
306 "best balance:",
307 ratio_text(c.bytes_in, bm.bytes_out),
308 time_text(bm.compress_ns),
309 time_text(bm.decompress_ns)
310 )
311}
312
313pub fn table_only(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
315 write_table(rows, bytes_in, out)
316}
317
318fn finite(x: Option<f64>) -> Value {
319 x.filter(|x| x.is_finite())
320 .map_or(Value::Null, |x| json!(x))
321}
322
323pub fn json(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
326 let scores = balance_scores(rows);
327 let w = winners(rows);
328 let who = |i: Option<usize>| {
329 i.map_or(
330 Value::Null,
331 |i| json!({"format": rows[i].format, "level": rows[i].level}),
332 )
333 };
334 let jrows: Vec<Value> = rows
335 .iter()
336 .zip(&scores)
337 .map(|(r, score)| match &r.outcome {
338 Outcome::Ok(m) => json!({
339 "format": r.format, "level": r.level, "backend": r.backend, "ok": true,
340 "bytes_out": m.bytes_out,
341 "ratio": finite(ratio_of(c.bytes_in, m.bytes_out)),
342 "compress_ns": m.compress_ns, "decompress_ns": m.decompress_ns,
343 "balance": finite(*score), "error": null,
344 "output": output_name(m),
345 }),
346 Outcome::Failed(why) => json!({
347 "format": r.format, "level": r.level, "backend": r.backend, "ok": false,
348 "bytes_out": null, "ratio": null,
349 "compress_ns": null, "decompress_ns": null,
350 "balance": null, "error": why, "output": null,
351 }),
352 })
353 .collect();
354 let doc = json!({
355 "conditions": {
356 "files": c.files,
357 "bytes_in": c.bytes_in,
358 "mode": mode_name(c.mode),
359 "threads": c.threads,
360 "workers": c.workers,
361 "repeat": c.repeat,
362 "streamed": c.streamed,
363 },
364 "rows": jrows,
365 "winners": {
366 "smallest": who(w.smallest),
367 "fastest_compress": who(w.fastest_compress),
368 "fastest_decompress": who(w.fastest_decompress),
369 "balance": who(w.balance),
370 },
371 });
372 writeln!(out, "{doc}")
373}
374
375fn csv_field(s: &str) -> String {
376 if s.contains([',', '"', '\n', '\r']) {
377 format!("\"{}\"", s.replace('"', "\"\""))
378 } else {
379 s.to_string()
380 }
381}
382
383pub fn csv(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
388 writeln!(
389 out,
390 "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
391 files,mode,threads,workers,repeat,streamed,backend,output"
392 )?;
393 let bytes_in = c.bytes_in;
394 let threads = c.threads.map_or(String::new(), |t| t.to_string());
395 let conditions = format!(
396 "{},{},{threads},{},{},{}",
397 c.files,
398 mode_name(c.mode),
399 c.workers,
400 c.repeat,
401 c.streamed
402 );
403 for (r, score) in rows.iter().zip(balance_scores(rows)) {
404 let level = r.level.map_or(String::new(), |l| l.to_string());
405 match &r.outcome {
406 Outcome::Ok(m) => {
407 let ratio =
408 ratio_of(bytes_in, m.bytes_out).map_or(String::new(), |x| x.to_string());
409 let bal = score.map_or(String::new(), |x| x.to_string());
410 writeln!(
411 out,
412 "{},{level},true,{bytes_in},{},{ratio},{},{},{bal},,{conditions},{},{}",
413 r.format,
414 m.bytes_out,
415 m.compress_ns,
416 m.decompress_ns,
417 r.backend,
418 output_name(m)
419 )?;
420 }
421 Outcome::Failed(why) => writeln!(
422 out,
423 "{},{level},false,{bytes_in},,,,,,{},{conditions},{},",
424 r.format,
425 csv_field(why),
426 r.backend
427 )?,
428 }
429 }
430 Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 const MIB: u64 = 1 << 20;
438
439 fn ok(format: &'static str, level: Option<i32>, out: u64, c_ms: u64, d_ms: u64) -> Row {
440 Row {
441 format,
442 level,
443 backend: "pure",
444 outcome: Outcome::Ok(Measured {
445 bytes_out: out,
446 compress_ns: c_ms * 1_000_000,
447 decompress_ns: d_ms * 1_000_000,
448 on_disk: false,
449 }),
450 }
451 }
452
453 fn rows() -> Vec<Row> {
454 vec![
455 ok("zstd", Some(3), 40 * MIB, 900, 300),
456 ok("lz4", None, 100 * MIB, 600, 200),
457 ok("xz", Some(6), 25 * MIB, 8200, 1250),
458 Row {
459 format: "bzip2",
460 level: Some(9),
461 backend: "pure",
462 outcome: Outcome::Failed("no c tier".into()),
463 },
464 ]
465 }
466
467 fn cond() -> Conditions {
468 Conditions {
469 files: 3,
470 bytes_in: 412 * MIB,
471 mode: Mode::OneStream,
472 threads: None,
473 workers: 1,
474 repeat: 1,
475 streamed: false,
476 }
477 }
478
479 fn render(f: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String {
480 let mut v = Vec::new();
481 f(&mut v).unwrap();
482 String::from_utf8(v).unwrap()
483 }
484
485 #[test]
486 fn human_output_is_layout_b() {
487 let got = render(|o| human(&cond(), &rows(), o));
488 let want = include_str!("testdata/human-layout-b.txt");
491 assert_eq!(got, want);
492 }
493
494 #[test]
495 fn table_only_has_no_input_line_or_winners() {
496 let got = render(|o| table_only(&rows(), 412 * MIB, o));
497 assert!(got.starts_with("format"));
498 assert!(!got.contains("input:") && !got.contains("smallest"));
499 assert_eq!(got.lines().count(), 5);
500 }
501
502 #[test]
503 fn json_parses_and_carries_conditions() {
504 let got = render(|o| json(&cond(), &rows(), o));
505 let v: Value = serde_json::from_str(&got).unwrap();
506 assert_eq!(v["conditions"]["mode"], "one-stream");
507 assert!(v["conditions"]["tier"].is_null(), "the backend is per row");
508 assert_eq!(v["rows"][0]["backend"], "pure");
509 assert_eq!(v["rows"].as_array().unwrap().len(), 4);
510 assert_eq!(v["winners"]["smallest"]["format"], "xz");
511 assert_eq!(v["rows"][3]["ok"], false);
512 assert!(!v["rows"][3]["error"].is_null());
513 assert!(v["rows"][1]["level"].is_null());
514 }
515
516 #[test]
517 fn csv_quotes_a_comma_in_an_error() {
518 let rows = vec![Row {
519 format: "xz",
520 level: Some(6),
521 backend: "c",
522 outcome: Outcome::Failed("bad, \"very\" bad".into()),
523 }];
524 let c = Conditions {
525 bytes_in: 10,
526 ..cond()
527 };
528 let got = render(|o| csv(&c, &rows, o));
529 let mut lines = got.lines();
530 assert_eq!(
531 lines.next().unwrap(),
532 "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
533 files,mode,threads,workers,repeat,streamed,backend,output"
534 );
535 assert_eq!(
536 lines.next().unwrap(),
537 "xz,6,false,10,,,,,,\"bad, \"\"very\"\" bad\",3,one-stream,,1,1,false,c,"
538 );
539 }
540
541 #[test]
542 fn zero_input_prints_a_dash_ratio() {
543 let rows = vec![ok("lz4", None, 0, 1, 1)];
544 let got = render(|o| table_only(&rows, 0, o));
545 assert!(got.lines().nth(1).unwrap().contains(" - "), "{got}");
546 let mut c = cond();
547 c.bytes_in = 0;
548 let j = render(|o| json(&c, &rows, o));
549 assert!(serde_json::from_str::<Value>(&j).unwrap()["rows"][0]["ratio"].is_null());
550 }
551
552 #[test]
553 fn no_success_prints_no_format_succeeded() {
554 let rows = vec![Row {
555 format: "xz",
556 level: Some(1),
557 backend: "pure",
558 outcome: Outcome::Failed("x".into()),
559 }];
560 let got = render(|o| human(&cond(), &rows, o));
561 assert!(got.ends_with("\nno format succeeded\n"), "{got}");
562 assert!(!got.contains("smallest"));
563 }
564
565 #[test]
566 fn the_input_line_shows_the_resolved_worker_count() {
567 assert_eq!(threads_text(None, 1), "1 compress thread");
568 assert_eq!(threads_text(Some(1), 1), "1 compress thread");
569 assert_eq!(threads_text(Some(4), 4), "4 compress threads");
570 assert_eq!(threads_text(Some(0), 8), "8 compress threads (auto)");
571 let c = Conditions {
572 threads: Some(0),
573 workers: 8,
574 ..cond()
575 };
576 assert!(input_line(&c, &rows()).contains(" · 8 compress threads (auto) · "));
577 let j = render(|o| json(&c, &[], o));
578 let v: Value = serde_json::from_str(&j).unwrap();
579 assert_eq!(v["conditions"]["threads"], 0);
580 assert_eq!(v["conditions"]["workers"], 8);
581 }
582
583 #[test]
584 fn ms_below_a_second_s_above() {
585 assert_eq!(time_text(850_000_000), "850 ms");
586 assert_eq!(time_text(1_250_000_000), "1.3 s");
587 assert_eq!(time_text(900_000_000), "900 ms");
588 }
589
590 #[test]
591 fn csv_carries_the_conditions_and_each_rows_backend() {
592 let mut rows = rows();
593 rows[0].backend = "c";
594 let c = Conditions {
595 mode: Mode::PerFile,
596 threads: Some(0),
597 workers: 8,
598 repeat: 5,
599 streamed: true,
600 ..cond()
601 };
602 let got = render(|o| csv(&c, &rows, o));
603 let lines: Vec<&str> = got.lines().collect();
604 let header: Vec<&str> = lines[0].split(',').collect();
605 assert_eq!(header.len(), 18);
606 assert_eq!(header[17], "output");
607 assert_eq!(
608 &header[..10],
609 &[
610 "format",
611 "level",
612 "ok",
613 "bytes_in",
614 "bytes_out",
615 "ratio",
616 "compress_ns",
617 "decompress_ns",
618 "balance",
619 "error"
620 ]
621 );
622 for line in &lines[1..] {
623 let f: Vec<&str> = line.split(',').collect();
624 assert_eq!(f.len(), 18, "{line}");
625 assert_eq!(
626 &f[10..16],
627 &["3", "per-file", "0", "8", "5", "true"],
628 "{line}"
629 );
630 }
631 assert!(lines[1].starts_with("zstd,3,true,"), "{}", lines[1]);
632 assert!(lines[1].ends_with(",c,memory"), "{}", lines[1]);
633 assert!(lines[2].ends_with(",pure,memory"), "{}", lines[2]);
634 let got = render(|o| csv(&cond(), &rows, o));
636 assert!(
637 got.lines()
638 .nth(1)
639 .unwrap()
640 .contains(",3,one-stream,,1,1,false,")
641 );
642 }
643
644 #[test]
645 fn the_input_line_names_the_c_backed_codecs() {
646 let mut rows = rows();
647 assert!(input_line(&cond(), &rows).contains(" · pure · "));
648 rows[0].backend = "c";
649 rows[1].backend = "c";
650 rows.push(ok("zstd", Some(19), MIB, 1, 1));
651 rows.last_mut().unwrap().backend = "c";
652 let line = input_line(&cond(), &rows);
653 assert!(line.contains(" · C: zstd, lz4 · "), "{line}");
654 let j = render(|o| json(&cond(), &rows, o));
655 let v: Value = serde_json::from_str(&j).unwrap();
656 let backends: Vec<&str> = v["rows"]
657 .as_array()
658 .unwrap()
659 .iter()
660 .map(|r| r["backend"].as_str().unwrap())
661 .collect();
662 assert_eq!(backends, ["c", "c", "pure", "pure", "c"]);
663 }
664
665 #[test]
666 fn a_multi_line_failure_keeps_to_one_table_line() {
667 let mut rows = rows();
668 rows[3].outcome = Outcome::Failed("first\nsecond\tthird\u{1b}[31m".into());
669 let table = render(|o| table_only(&rows, 412 * MIB, o));
670 assert_eq!(table.lines().count(), 5, "{table}");
671 let last = table.lines().last().unwrap();
672 assert!(last.ends_with("failed: first …"), "{last}");
673 rows[3].outcome = Outcome::Failed("tab\there\u{1b}[31m".into());
674 let table = render(|o| table_only(&rows, 412 * MIB, o));
675 let last = table.lines().last().unwrap();
676 assert!(last.ends_with("failed: tab\\there\\u{1b}[31m"), "{last}");
677 let j = render(|o| json(&cond(), &rows, o));
679 let v: Value = serde_json::from_str(&j).unwrap();
680 assert_eq!(v["rows"][3]["error"], "tab\there\u{1b}[31m");
681 }
682
683 #[test]
684 fn times_under_10_ms_keep_a_decimal_and_edges_round_up_a_unit() {
685 assert_eq!(time_text(40_000), "0.0 ms");
686 assert_eq!(time_text(400_000), "0.4 ms");
687 assert_eq!(time_text(9_940_000), "9.9 ms");
688 assert_eq!(time_text(9_960_000), "10 ms");
690 assert_eq!(time_text(10_000_000), "10 ms");
691 assert_eq!(time_text(999_600_000), "1.0 s");
693 assert_eq!(time_text(999_400_000), "999 ms");
694 assert_eq!(time_text(59_950_000_000), "60.0 s");
695 }
696
697 #[test]
698 fn sizes_round_up_a_unit_at_the_edge() {
699 assert_eq!(size_text(1023), "1023 B");
700 assert_eq!(size_text(1024), "1.0 KiB");
701 assert_eq!(size_text(1_048_535), "1.0 MiB");
703 assert_eq!(size_text((1023.96 * MIB as f64) as u64), "1.0 GiB");
704 assert_eq!(size_text((1023.9 * MIB as f64) as u64), "1023.9 MiB");
705 assert_eq!(size_text(2 << 30), "2.0 GiB");
706 assert_eq!(size_text(412 * MIB), "412.0 MiB");
707 }
708
709 fn marked_rows() -> Vec<Row> {
711 let mut rows = rows();
712 if let Outcome::Ok(m) = &mut rows[2].outcome {
713 m.on_disk = true;
714 }
715 rows
716 }
717
718 #[test]
719 fn a_row_timed_with_disk_output_is_marked_with_a_footnote() {
720 let got = render(|o| human(&cond(), &marked_rows(), o));
721 let want = include_str!("testdata/human-marked.txt");
722 assert_eq!(got, want);
723 let got = render(|o| table_only(&marked_rows(), 412 * MIB, o));
724 let want = include_str!("testdata/table-only-marked.txt");
725 assert_eq!(got, want);
726 let plain = render(|o| table_only(&rows(), 412 * MIB, o));
728 assert!(!plain.contains('*'), "{plain}");
729 }
730
731 #[test]
732 fn json_and_csv_say_where_each_rows_output_went() {
733 let got = render(|o| json(&cond(), &marked_rows(), o));
734 let v: Value = serde_json::from_str(&got).unwrap();
735 assert_eq!(v["rows"][0]["output"], "memory");
736 assert_eq!(v["rows"][2]["output"], "disk");
737 assert!(v["rows"][3]["output"].is_null(), "a failed row");
738 let got = render(|o| csv(&cond(), &marked_rows(), o));
739 let lines: Vec<&str> = got.lines().collect();
740 assert!(lines[0].ends_with(",backend,output"), "{}", lines[0]);
741 assert!(lines[1].ends_with(",pure,memory"), "{}", lines[1]);
742 assert!(lines[3].ends_with(",pure,disk"), "{}", lines[3]);
743 assert!(lines[4].ends_with(",pure,"), "{}", lines[4]);
744 for line in &lines {
745 assert_eq!(line.split(',').count(), 18, "{line}");
746 }
747 }
748}