Skip to main content

dev_report/
terminal.rs

1//! Terminal pretty-printer. Available with the `terminal` feature.
2//!
3//! Pure function over a [`Report`], [`Diff`], or [`MultiReport`]. No I/O,
4//! no global state, no extra dependencies. ANSI color is opt-in so the
5//! caller decides based on their own TTY detection.
6//!
7//! [`Diff`]: crate::Diff
8//! [`MultiReport`]: crate::MultiReport
9
10use std::fmt::Write as _;
11
12use crate::{CheckResult, Diff, EvidenceData, FileRef, MultiReport, Report, Severity, Verdict};
13
14/// Render a report to a TTY-friendly string. Monochrome.
15///
16/// # Example
17///
18/// ```
19/// use dev_report::{CheckResult, Report};
20///
21/// let mut r = Report::new("my-crate", "0.1.0");
22/// r.push(CheckResult::pass("compile"));
23/// r.finish();
24/// let out = r.to_terminal();
25/// assert!(out.contains("[PASS]"));
26/// assert!(out.contains("compile"));
27/// ```
28pub fn to_terminal(report: &Report) -> String {
29    render(report, false)
30}
31
32/// Render a report with ANSI color codes for TTY output.
33///
34/// Caller is responsible for checking color support before invoking
35/// (e.g. `std::io::stdout().is_terminal()` and `NO_COLOR` env var).
36///
37/// # Example
38///
39/// ```
40/// use dev_report::{CheckResult, Report};
41///
42/// let mut r = Report::new("my-crate", "0.1.0");
43/// r.push(CheckResult::pass("compile"));
44/// r.finish();
45/// let out = r.to_terminal_color();
46/// assert!(out.contains("\x1b["));
47/// ```
48pub fn to_terminal_color(report: &Report) -> String {
49    render(report, true)
50}
51
52/// Render a [`Diff`] to a TTY-friendly string. Monochrome.
53///
54/// # Example
55///
56/// ```
57/// use dev_report::{terminal, CheckResult, Report, Severity};
58///
59/// let mut prev = Report::new("c", "0.1.0");
60/// prev.push(CheckResult::pass("a"));
61/// let mut curr = Report::new("c", "0.1.0");
62/// curr.push(CheckResult::fail("a", Severity::Error));
63///
64/// let diff = curr.diff(&prev);
65/// let out = terminal::diff_to_terminal(&diff);
66/// assert!(out.contains("Newly failing"));
67/// ```
68pub fn diff_to_terminal(diff: &Diff) -> String {
69    render_diff(diff, false)
70}
71
72/// Render a [`Diff`] with ANSI color codes for TTY output.
73pub fn diff_to_terminal_color(diff: &Diff) -> String {
74    render_diff(diff, true)
75}
76
77/// Render a [`MultiReport`] to a TTY-friendly string. Monochrome.
78///
79/// # Example
80///
81/// ```
82/// use dev_report::{terminal, CheckResult, MultiReport, Report};
83///
84/// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
85/// bench.push(CheckResult::pass("hot"));
86///
87/// let mut multi = MultiReport::new("c", "0.1.0");
88/// multi.push(bench);
89/// multi.finish();
90///
91/// let out = terminal::multi_to_terminal(&multi);
92/// assert!(out.contains("dev-bench"));
93/// ```
94pub fn multi_to_terminal(multi: &MultiReport) -> String {
95    render_multi(multi, false)
96}
97
98/// Render a [`MultiReport`] with ANSI color codes for TTY output.
99pub 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")); // fail red
418        assert!(out.contains("\x1b[32m")); // pass green
419        assert!(out.contains("\x1b[33m")); // warn yellow
420    }
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        // sample_report uses Numeric, KeyValue, FileRef
496        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}