1use std::fmt::Write as _;
11
12use crate::{CheckResult, Diff, EvidenceData, FileRef, MultiReport, Report, Severity, Verdict};
13
14pub fn to_terminal(report: &Report) -> String {
29 render(report, false)
30}
31
32pub fn to_terminal_color(report: &Report) -> String {
49 render(report, true)
50}
51
52pub fn diff_to_terminal(diff: &Diff) -> String {
69 render_diff(diff, false)
70}
71
72pub fn diff_to_terminal_color(diff: &Diff) -> String {
74 render_diff(diff, true)
75}
76
77pub fn multi_to_terminal(multi: &MultiReport) -> String {
95 render_multi(multi, false)
96}
97
98pub fn multi_to_terminal_color(multi: &MultiReport) -> String {
100 render_multi(multi, true)
101}
102
103fn render(report: &Report, color: bool) -> String {
104 let mut out = String::with_capacity(256);
105 let _ = write_header(&mut out, report, color);
106 let _ = write_summary(&mut out, report, color);
107 out.push('\n');
108 for check in &report.checks {
109 let _ = write_check(&mut out, check, color);
110 }
111 out
112}
113
114fn write_header(out: &mut String, report: &Report, color: bool) -> std::fmt::Result {
115 let bold = if color { "\x1b[1m" } else { "" };
116 let reset = if color { "\x1b[0m" } else { "" };
117 writeln!(
118 out,
119 "{}=== dev-report :: {} {} ==={}",
120 bold, report.subject, report.subject_version, reset
121 )?;
122 if let Some(p) = &report.producer {
123 writeln!(out, "producer: {}", p)?;
124 }
125 writeln!(out, "schema: v{}", report.schema_version)?;
126 Ok(())
127}
128
129fn write_summary(out: &mut String, report: &Report, color: bool) -> std::fmt::Result {
130 let (mut p, mut f, mut w, mut s) = (0usize, 0usize, 0usize, 0usize);
131 for c in &report.checks {
132 match c.verdict {
133 Verdict::Pass => p += 1,
134 Verdict::Fail => f += 1,
135 Verdict::Warn => w += 1,
136 Verdict::Skip => s += 1,
137 }
138 }
139 let overall = report.overall_verdict();
140 let label = verdict_label(overall, color);
141 writeln!(
142 out,
143 "verdict: {} ({} checks: {} fail, {} warn, {} pass, {} skip)",
144 label,
145 report.checks.len(),
146 f,
147 w,
148 p,
149 s
150 )?;
151 if let Some(end) = report.finished_at {
152 let dur_ms = (end - report.started_at).num_milliseconds();
153 writeln!(
154 out,
155 "duration: {} -> {} ({}ms)",
156 report.started_at.format("%Y-%m-%d %H:%M:%S"),
157 end.format("%H:%M:%S"),
158 dur_ms
159 )?;
160 } else {
161 writeln!(
162 out,
163 "started: {}",
164 report.started_at.format("%Y-%m-%d %H:%M:%S")
165 )?;
166 }
167 Ok(())
168}
169
170fn write_check(out: &mut String, c: &CheckResult, color: bool) -> std::fmt::Result {
171 let badge = check_badge(c, color);
172 let dim = if color { "\x1b[2m" } else { "" };
173 let reset = if color { "\x1b[0m" } else { "" };
174 let dur = c
175 .duration_ms
176 .map(|ms| format!(" {dim}{ms}ms{reset}"))
177 .unwrap_or_default();
178 writeln!(out, "{} {}{}", badge, c.name, dur)?;
179
180 if !c.tags.is_empty() {
181 writeln!(out, " tags: {}", c.tags.join(", "))?;
182 }
183 if let Some(detail) = &c.detail {
184 writeln!(out, " detail: {}", detail)?;
185 }
186 if !c.evidence.is_empty() {
187 writeln!(out, " evidence:")?;
188 for e in &c.evidence {
189 write_evidence(out, &e.label, &e.data)?;
190 }
191 }
192 Ok(())
193}
194
195fn write_evidence(out: &mut String, label: &str, data: &EvidenceData) -> std::fmt::Result {
196 match data {
197 EvidenceData::Numeric(n) => {
198 writeln!(out, " - {}: {}", label, n)
199 }
200 EvidenceData::Snippet(s) => {
201 writeln!(out, " - {}: {:?}", label, s)
202 }
203 EvidenceData::FileRef(f) => {
204 writeln!(out, " - {}: {}", label, file_ref_inline(f))
205 }
206 EvidenceData::KeyValue(map) => {
207 let pairs: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
208 writeln!(out, " - {}: {{ {} }}", label, pairs.join(", "))
209 }
210 }
211}
212
213fn file_ref_inline(f: &FileRef) -> String {
214 match (f.line_start, f.line_end) {
215 (Some(s), Some(e)) if s == e => format!("{}:{}", f.path, s),
216 (Some(s), Some(e)) => format!("{}:{}-{}", f.path, s, e),
217 (Some(s), None) => format!("{}:{}", f.path, s),
218 _ => f.path.clone(),
219 }
220}
221
222fn verdict_label(v: Verdict, color: bool) -> String {
223 if !color {
224 return match v {
225 Verdict::Pass => "PASS",
226 Verdict::Fail => "FAIL",
227 Verdict::Warn => "WARN",
228 Verdict::Skip => "SKIP",
229 }
230 .to_string();
231 }
232 match v {
233 Verdict::Pass => "\x1b[32mPASS\x1b[0m".to_string(),
234 Verdict::Fail => "\x1b[31mFAIL\x1b[0m".to_string(),
235 Verdict::Warn => "\x1b[33mWARN\x1b[0m".to_string(),
236 Verdict::Skip => "\x1b[2mSKIP\x1b[0m".to_string(),
237 }
238}
239
240fn check_badge(c: &CheckResult, color: bool) -> String {
241 let sev = c
242 .severity
243 .map(|s| match s {
244 Severity::Info => "info",
245 Severity::Warning => "warning",
246 Severity::Error => "error",
247 Severity::Critical => "critical",
248 })
249 .map(|s| format!(" {}", s))
250 .unwrap_or_default();
251 let label = match c.verdict {
252 Verdict::Pass => "PASS",
253 Verdict::Fail => "FAIL",
254 Verdict::Warn => "WARN",
255 Verdict::Skip => "SKIP",
256 };
257 if !color {
258 return format!("[{}{}]", label, sev);
259 }
260 let (open, close) = match c.verdict {
261 Verdict::Pass => ("\x1b[32m", "\x1b[0m"),
262 Verdict::Fail => ("\x1b[31m", "\x1b[0m"),
263 Verdict::Warn => ("\x1b[33m", "\x1b[0m"),
264 Verdict::Skip => ("\x1b[2m", "\x1b[0m"),
265 };
266 format!("[{}{}{}{}]", open, label, sev, close)
267}
268
269fn render_diff(diff: &Diff, color: bool) -> String {
270 let bold = if color { "\x1b[1m" } else { "" };
271 let red = if color { "\x1b[31m" } else { "" };
272 let green = if color { "\x1b[32m" } else { "" };
273 let yellow = if color { "\x1b[33m" } else { "" };
274 let dim = if color { "\x1b[2m" } else { "" };
275 let reset = if color { "\x1b[0m" } else { "" };
276
277 let mut out = String::with_capacity(256);
278 let _ = writeln!(out, "{}=== Diff ==={}", bold, reset);
279 if diff.is_clean() {
280 let _ = writeln!(out, "{}clean (no differences){}", green, reset);
281 return out;
282 }
283 write_diff_section(&mut out, "Newly failing", red, reset, &diff.newly_failing);
284 write_diff_section(&mut out, "Newly passing", green, reset, &diff.newly_passing);
285 write_diff_section(&mut out, "Added", dim, reset, &diff.added);
286 write_diff_section(&mut out, "Removed", dim, reset, &diff.removed);
287 if !diff.severity_changes.is_empty() {
288 let _ = writeln!(out, "{}Severity changes{}:", yellow, reset);
289 for c in &diff.severity_changes {
290 let from = c.from.map(severity_word).unwrap_or("none");
291 let to = c.to.map(severity_word).unwrap_or("none");
292 let _ = writeln!(out, " - {} : {} -> {}", c.name, from, to);
293 }
294 }
295 if !diff.duration_regressions.is_empty() {
296 let _ = writeln!(out, "{}Duration regressions{}:", yellow, reset);
297 for r in &diff.duration_regressions {
298 let _ = writeln!(
299 out,
300 " - {} : {}ms -> {}ms ({:+.2}%)",
301 r.name, r.baseline_ms, r.current_ms, r.delta_pct
302 );
303 }
304 }
305 out
306}
307
308fn write_diff_section(
309 out: &mut String,
310 label: &str,
311 color_open: &str,
312 color_close: &str,
313 items: &[String],
314) {
315 if items.is_empty() {
316 return;
317 }
318 let _ = writeln!(out, "{}{}{}:", color_open, label, color_close);
319 for name in items {
320 let _ = writeln!(out, " - {}", name);
321 }
322}
323
324fn severity_word(s: Severity) -> &'static str {
325 match s {
326 Severity::Info => "info",
327 Severity::Warning => "warning",
328 Severity::Error => "error",
329 Severity::Critical => "critical",
330 }
331}
332
333fn render_multi(multi: &MultiReport, color: bool) -> String {
334 let bold = if color { "\x1b[1m" } else { "" };
335 let dim = if color { "\x1b[2m" } else { "" };
336 let reset = if color { "\x1b[0m" } else { "" };
337
338 let mut out = String::with_capacity(512);
339 let _ = writeln!(
340 out,
341 "{}=== MultiReport :: {} {} ==={}",
342 bold, multi.subject, multi.subject_version, reset
343 );
344 let _ = writeln!(
345 out,
346 "{}{} reports, {} total checks{}",
347 dim,
348 multi.reports.len(),
349 multi.total_check_count(),
350 reset
351 );
352 let overall = verdict_label(multi.overall_verdict(), color);
353 let _ = writeln!(out, "verdict: {}", overall);
354 out.push('\n');
355 for r in &multi.reports {
356 let _ = writeln!(
357 out,
358 "{}--- {} ---{}",
359 bold,
360 r.producer.as_deref().unwrap_or("(unknown producer)"),
361 reset
362 );
363 out.push_str(&render(r, color));
364 out.push('\n');
365 }
366 out
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::{Evidence, EvidenceKind};
373
374 fn sample_report() -> Report {
375 let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
376 r.push(CheckResult::pass("compile").with_duration_ms(7));
377 r.push(
378 CheckResult::warn("flaky", Severity::Warning)
379 .with_tag("bench")
380 .with_evidence(Evidence::numeric("mean_ns", 1234.5))
381 .with_evidence(Evidence::kv("env", [("CI", "true")])),
382 );
383 r.push(
384 CheckResult::fail("chaos::recover", Severity::Critical)
385 .with_tags(["chaos", "recovery"])
386 .with_detail("recovery did not restore final state")
387 .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
388 );
389 r.push(CheckResult::skip("not_applicable"));
390 r.finish();
391 r
392 }
393
394 #[test]
395 fn monochrome_render_contains_all_checks() {
396 let out = to_terminal(&sample_report());
397 assert!(out.contains("compile"));
398 assert!(out.contains("flaky"));
399 assert!(out.contains("chaos::recover"));
400 assert!(out.contains("not_applicable"));
401 assert!(out.contains("[PASS]"));
402 assert!(out.contains("[WARN warning]"));
403 assert!(out.contains("[FAIL critical]"));
404 assert!(out.contains("[SKIP]"));
405 }
406
407 #[test]
408 fn monochrome_render_has_no_ansi() {
409 let out = to_terminal(&sample_report());
410 assert!(!out.contains('\x1b'));
411 }
412
413 #[test]
414 fn color_render_has_ansi() {
415 let out = to_terminal_color(&sample_report());
416 assert!(out.contains('\x1b'));
417 assert!(out.contains("\x1b[31m")); assert!(out.contains("\x1b[32m")); assert!(out.contains("\x1b[33m")); }
421
422 #[test]
423 fn render_includes_evidence() {
424 let out = to_terminal(&sample_report());
425 assert!(out.contains("mean_ns"));
426 assert!(out.contains("1234.5"));
427 assert!(out.contains("CI: true"));
428 assert!(out.contains("src/recover.rs:10-20"));
429 }
430
431 #[test]
432 fn render_includes_tags_and_detail() {
433 let out = to_terminal(&sample_report());
434 assert!(out.contains("tags: chaos, recovery"));
435 assert!(out.contains("detail: recovery did not restore final state"));
436 }
437
438 #[test]
439 fn render_includes_summary_counts() {
440 let out = to_terminal(&sample_report());
441 assert!(out.contains("4 checks"));
442 assert!(out.contains("1 fail"));
443 assert!(out.contains("1 warn"));
444 assert!(out.contains("1 pass"));
445 assert!(out.contains("1 skip"));
446 }
447
448 #[test]
449 fn fits_under_80_columns_for_typical_report() {
450 let out = to_terminal(&sample_report());
451 for line in out.lines() {
452 assert!(
453 line.chars().count() <= 80,
454 "line exceeds 80 cols: {:?}",
455 line
456 );
457 }
458 }
459
460 #[test]
461 fn pure_function_same_input_same_output() {
462 let r = sample_report();
463 let a = to_terminal(&r);
464 let b = to_terminal(&r);
465 assert_eq!(a, b);
466 }
467
468 #[test]
469 fn empty_report_renders() {
470 let r = Report::new("nothing", "0.0.0");
471 let out = to_terminal(&r);
472 assert!(out.contains("nothing"));
473 assert!(out.contains("0 checks"));
474 }
475
476 #[test]
477 fn file_ref_inline_formats() {
478 let no_lines = file_ref_inline(&FileRef::new("a.rs"));
479 assert_eq!(no_lines, "a.rs");
480 let single = file_ref_inline(&FileRef::new("a.rs").with_line_range(5, 5));
481 assert_eq!(single, "a.rs:5");
482 let range = file_ref_inline(&FileRef::new("a.rs").with_line_range(5, 9));
483 assert_eq!(range, "a.rs:5-9");
484 }
485
486 #[test]
487 fn evidence_kind_dispatch_covers_all_variants() {
488 let r = sample_report();
489 let kinds: std::collections::HashSet<_> = r
490 .checks
491 .iter()
492 .flat_map(|c| &c.evidence)
493 .map(|e| e.kind())
494 .collect();
495 assert!(kinds.contains(&EvidenceKind::Numeric));
497 assert!(kinds.contains(&EvidenceKind::KeyValue));
498 assert!(kinds.contains(&EvidenceKind::FileRef));
499 }
500
501 #[test]
502 fn diff_render_clean() {
503 let mut a = Report::new("c", "0.1.0");
504 a.push(CheckResult::pass("x"));
505 let b = a.clone();
506 let d = a.diff(&b);
507 let out = diff_to_terminal(&d);
508 assert!(out.contains("clean"));
509 }
510
511 #[test]
512 fn diff_render_with_failures() {
513 let mut prev = Report::new("c", "0.1.0");
514 prev.push(CheckResult::pass("a"));
515 let mut curr = Report::new("c", "0.1.0");
516 curr.push(CheckResult::fail("a", Severity::Error));
517 let d = curr.diff(&prev);
518 let out = diff_to_terminal(&d);
519 assert!(out.contains("Newly failing"));
520 assert!(out.contains("- a"));
521 }
522
523 #[test]
524 fn diff_color_render_has_ansi() {
525 let mut prev = Report::new("c", "0.1.0");
526 prev.push(CheckResult::pass("a"));
527 let mut curr = Report::new("c", "0.1.0");
528 curr.push(CheckResult::fail("a", Severity::Error));
529 let d = curr.diff(&prev);
530 let out = diff_to_terminal_color(&d);
531 assert!(out.contains('\x1b'));
532 }
533
534 #[test]
535 fn multi_render_includes_each_producer() {
536 let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
537 bench.push(CheckResult::pass("hot"));
538 let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
539 chaos.push(CheckResult::fail("recover", Severity::Critical));
540
541 let mut multi = MultiReport::new("c", "0.1.0");
542 multi.push(bench);
543 multi.push(chaos);
544
545 let out = multi_to_terminal(&multi);
546 assert!(out.contains("dev-bench"));
547 assert!(out.contains("dev-chaos"));
548 assert!(out.contains("hot"));
549 assert!(out.contains("recover"));
550 }
551}