Skip to main content

dev_report/
terminal.rs

1//! Terminal pretty-printer. Available with the `terminal` feature.
2//!
3//! Pure function over a [`Report`]. No I/O, no global state, no extra
4//! dependencies. ANSI color is opt-in so the caller decides based on
5//! their own TTY detection.
6
7use std::fmt::Write as _;
8
9use crate::{CheckResult, EvidenceData, FileRef, Report, Severity, Verdict};
10
11/// Render a report to a TTY-friendly string. Monochrome.
12///
13/// # Example
14///
15/// ```
16/// use dev_report::{CheckResult, Report};
17///
18/// let mut r = Report::new("my-crate", "0.1.0");
19/// r.push(CheckResult::pass("compile"));
20/// r.finish();
21/// let out = r.to_terminal();
22/// assert!(out.contains("[PASS]"));
23/// assert!(out.contains("compile"));
24/// ```
25pub fn to_terminal(report: &Report) -> String {
26    render(report, false)
27}
28
29/// Render a report with ANSI color codes for TTY output.
30///
31/// Caller is responsible for checking color support before invoking
32/// (e.g. `std::io::stdout().is_terminal()` and `NO_COLOR` env var).
33///
34/// # Example
35///
36/// ```
37/// use dev_report::{CheckResult, Report};
38///
39/// let mut r = Report::new("my-crate", "0.1.0");
40/// r.push(CheckResult::pass("compile"));
41/// r.finish();
42/// let out = r.to_terminal_color();
43/// assert!(out.contains("\x1b["));
44/// ```
45pub fn to_terminal_color(report: &Report) -> String {
46    render(report, true)
47}
48
49fn render(report: &Report, color: bool) -> String {
50    let mut out = String::with_capacity(256);
51    let _ = write_header(&mut out, report, color);
52    let _ = write_summary(&mut out, report, color);
53    out.push('\n');
54    for check in &report.checks {
55        let _ = write_check(&mut out, check, color);
56    }
57    out
58}
59
60fn write_header(out: &mut String, report: &Report, color: bool) -> std::fmt::Result {
61    let bold = if color { "\x1b[1m" } else { "" };
62    let reset = if color { "\x1b[0m" } else { "" };
63    writeln!(
64        out,
65        "{}=== dev-report :: {} {} ==={}",
66        bold, report.subject, report.subject_version, reset
67    )?;
68    if let Some(p) = &report.producer {
69        writeln!(out, "producer: {}", p)?;
70    }
71    writeln!(out, "schema:   v{}", report.schema_version)?;
72    Ok(())
73}
74
75fn write_summary(out: &mut String, report: &Report, color: bool) -> std::fmt::Result {
76    let (mut p, mut f, mut w, mut s) = (0usize, 0usize, 0usize, 0usize);
77    for c in &report.checks {
78        match c.verdict {
79            Verdict::Pass => p += 1,
80            Verdict::Fail => f += 1,
81            Verdict::Warn => w += 1,
82            Verdict::Skip => s += 1,
83        }
84    }
85    let overall = report.overall_verdict();
86    let label = verdict_label(overall, color);
87    writeln!(
88        out,
89        "verdict:  {}  ({} checks: {} fail, {} warn, {} pass, {} skip)",
90        label,
91        report.checks.len(),
92        f,
93        w,
94        p,
95        s
96    )?;
97    if let Some(end) = report.finished_at {
98        let dur_ms = (end - report.started_at).num_milliseconds();
99        writeln!(
100            out,
101            "duration: {} -> {} ({}ms)",
102            report.started_at.format("%Y-%m-%d %H:%M:%S"),
103            end.format("%H:%M:%S"),
104            dur_ms
105        )?;
106    } else {
107        writeln!(
108            out,
109            "started:  {}",
110            report.started_at.format("%Y-%m-%d %H:%M:%S")
111        )?;
112    }
113    Ok(())
114}
115
116fn write_check(out: &mut String, c: &CheckResult, color: bool) -> std::fmt::Result {
117    let badge = check_badge(c, color);
118    let dim = if color { "\x1b[2m" } else { "" };
119    let reset = if color { "\x1b[0m" } else { "" };
120    let dur = c
121        .duration_ms
122        .map(|ms| format!("  {dim}{ms}ms{reset}"))
123        .unwrap_or_default();
124    writeln!(out, "{} {}{}", badge, c.name, dur)?;
125
126    if !c.tags.is_empty() {
127        writeln!(out, "   tags: {}", c.tags.join(", "))?;
128    }
129    if let Some(detail) = &c.detail {
130        writeln!(out, "   detail: {}", detail)?;
131    }
132    if !c.evidence.is_empty() {
133        writeln!(out, "   evidence:")?;
134        for e in &c.evidence {
135            write_evidence(out, &e.label, &e.data)?;
136        }
137    }
138    Ok(())
139}
140
141fn write_evidence(out: &mut String, label: &str, data: &EvidenceData) -> std::fmt::Result {
142    match data {
143        EvidenceData::Numeric(n) => {
144            writeln!(out, "     - {}: {}", label, n)
145        }
146        EvidenceData::Snippet(s) => {
147            writeln!(out, "     - {}: {:?}", label, s)
148        }
149        EvidenceData::FileRef(f) => {
150            writeln!(out, "     - {}: {}", label, file_ref_inline(f))
151        }
152        EvidenceData::KeyValue(map) => {
153            let pairs: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
154            writeln!(out, "     - {}: {{ {} }}", label, pairs.join(", "))
155        }
156    }
157}
158
159fn file_ref_inline(f: &FileRef) -> String {
160    match (f.line_start, f.line_end) {
161        (Some(s), Some(e)) if s == e => format!("{}:{}", f.path, s),
162        (Some(s), Some(e)) => format!("{}:{}-{}", f.path, s, e),
163        (Some(s), None) => format!("{}:{}", f.path, s),
164        _ => f.path.clone(),
165    }
166}
167
168fn verdict_label(v: Verdict, color: bool) -> String {
169    if !color {
170        return match v {
171            Verdict::Pass => "PASS",
172            Verdict::Fail => "FAIL",
173            Verdict::Warn => "WARN",
174            Verdict::Skip => "SKIP",
175        }
176        .to_string();
177    }
178    match v {
179        Verdict::Pass => "\x1b[32mPASS\x1b[0m".to_string(),
180        Verdict::Fail => "\x1b[31mFAIL\x1b[0m".to_string(),
181        Verdict::Warn => "\x1b[33mWARN\x1b[0m".to_string(),
182        Verdict::Skip => "\x1b[2mSKIP\x1b[0m".to_string(),
183    }
184}
185
186fn check_badge(c: &CheckResult, color: bool) -> String {
187    let sev = c
188        .severity
189        .map(|s| match s {
190            Severity::Info => "info",
191            Severity::Warning => "warning",
192            Severity::Error => "error",
193            Severity::Critical => "critical",
194        })
195        .map(|s| format!(" {}", s))
196        .unwrap_or_default();
197    let label = match c.verdict {
198        Verdict::Pass => "PASS",
199        Verdict::Fail => "FAIL",
200        Verdict::Warn => "WARN",
201        Verdict::Skip => "SKIP",
202    };
203    if !color {
204        return format!("[{}{}]", label, sev);
205    }
206    let (open, close) = match c.verdict {
207        Verdict::Pass => ("\x1b[32m", "\x1b[0m"),
208        Verdict::Fail => ("\x1b[31m", "\x1b[0m"),
209        Verdict::Warn => ("\x1b[33m", "\x1b[0m"),
210        Verdict::Skip => ("\x1b[2m", "\x1b[0m"),
211    };
212    format!("[{}{}{}{}]", open, label, sev, close)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::{Evidence, EvidenceKind};
219
220    fn sample_report() -> Report {
221        let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
222        r.push(CheckResult::pass("compile").with_duration_ms(7));
223        r.push(
224            CheckResult::warn("flaky", Severity::Warning)
225                .with_tag("bench")
226                .with_evidence(Evidence::numeric("mean_ns", 1234.5))
227                .with_evidence(Evidence::kv("env", [("CI", "true")])),
228        );
229        r.push(
230            CheckResult::fail("chaos::recover", Severity::Critical)
231                .with_tags(["chaos", "recovery"])
232                .with_detail("recovery did not restore final state")
233                .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
234        );
235        r.push(CheckResult::skip("not_applicable"));
236        r.finish();
237        r
238    }
239
240    #[test]
241    fn monochrome_render_contains_all_checks() {
242        let out = to_terminal(&sample_report());
243        assert!(out.contains("compile"));
244        assert!(out.contains("flaky"));
245        assert!(out.contains("chaos::recover"));
246        assert!(out.contains("not_applicable"));
247        assert!(out.contains("[PASS]"));
248        assert!(out.contains("[WARN warning]"));
249        assert!(out.contains("[FAIL critical]"));
250        assert!(out.contains("[SKIP]"));
251    }
252
253    #[test]
254    fn monochrome_render_has_no_ansi() {
255        let out = to_terminal(&sample_report());
256        assert!(!out.contains('\x1b'));
257    }
258
259    #[test]
260    fn color_render_has_ansi() {
261        let out = to_terminal_color(&sample_report());
262        assert!(out.contains('\x1b'));
263        assert!(out.contains("\x1b[31m")); // fail red
264        assert!(out.contains("\x1b[32m")); // pass green
265        assert!(out.contains("\x1b[33m")); // warn yellow
266    }
267
268    #[test]
269    fn render_includes_evidence() {
270        let out = to_terminal(&sample_report());
271        assert!(out.contains("mean_ns"));
272        assert!(out.contains("1234.5"));
273        assert!(out.contains("CI: true"));
274        assert!(out.contains("src/recover.rs:10-20"));
275    }
276
277    #[test]
278    fn render_includes_tags_and_detail() {
279        let out = to_terminal(&sample_report());
280        assert!(out.contains("tags: chaos, recovery"));
281        assert!(out.contains("detail: recovery did not restore final state"));
282    }
283
284    #[test]
285    fn render_includes_summary_counts() {
286        let out = to_terminal(&sample_report());
287        assert!(out.contains("4 checks"));
288        assert!(out.contains("1 fail"));
289        assert!(out.contains("1 warn"));
290        assert!(out.contains("1 pass"));
291        assert!(out.contains("1 skip"));
292    }
293
294    #[test]
295    fn fits_under_80_columns_for_typical_report() {
296        let out = to_terminal(&sample_report());
297        for line in out.lines() {
298            assert!(
299                line.chars().count() <= 80,
300                "line exceeds 80 cols: {:?}",
301                line
302            );
303        }
304    }
305
306    #[test]
307    fn pure_function_same_input_same_output() {
308        let r = sample_report();
309        let a = to_terminal(&r);
310        let b = to_terminal(&r);
311        assert_eq!(a, b);
312    }
313
314    #[test]
315    fn empty_report_renders() {
316        let r = Report::new("nothing", "0.0.0");
317        let out = to_terminal(&r);
318        assert!(out.contains("nothing"));
319        assert!(out.contains("0 checks"));
320    }
321
322    #[test]
323    fn file_ref_inline_formats() {
324        let no_lines = file_ref_inline(&FileRef::new("a.rs"));
325        assert_eq!(no_lines, "a.rs");
326        let single = file_ref_inline(&FileRef::new("a.rs").with_line_range(5, 5));
327        assert_eq!(single, "a.rs:5");
328        let range = file_ref_inline(&FileRef::new("a.rs").with_line_range(5, 9));
329        assert_eq!(range, "a.rs:5-9");
330    }
331
332    #[test]
333    fn evidence_kind_dispatch_covers_all_variants() {
334        let r = sample_report();
335        let kinds: std::collections::HashSet<_> = r
336            .checks
337            .iter()
338            .flat_map(|c| &c.evidence)
339            .map(|e| e.kind())
340            .collect();
341        // sample_report uses Numeric, KeyValue, FileRef
342        assert!(kinds.contains(&EvidenceKind::Numeric));
343        assert!(kinds.contains(&EvidenceKind::KeyValue));
344        assert!(kinds.contains(&EvidenceKind::FileRef));
345    }
346}