Skip to main content

dev_report/
markdown.rs

1//! Markdown exporter. Available with the `markdown` feature.
2//!
3//! Pure function over a [`Report`], [`Diff`], or [`MultiReport`]
4//! producing a CommonMark-compatible string. Every fact (verdict,
5//! severity, tags, evidence, durations) is preserved in the output.
6//! No external dependencies.
7//!
8//! [`Diff`]: crate::Diff
9//! [`MultiReport`]: crate::MultiReport
10
11use std::fmt::Write as _;
12
13use crate::{CheckResult, Diff, EvidenceData, FileRef, MultiReport, Report, Severity, Verdict};
14
15/// Render a report to a CommonMark-compatible Markdown string.
16///
17/// # Example
18///
19/// ```
20/// use dev_report::{CheckResult, Report};
21///
22/// let mut r = Report::new("my-crate", "0.1.0");
23/// r.push(CheckResult::pass("compile"));
24/// r.finish();
25/// let md = r.to_markdown();
26/// assert!(md.starts_with("# Report"));
27/// assert!(md.contains("compile"));
28/// ```
29pub fn to_markdown(report: &Report) -> String {
30    let mut out = String::with_capacity(512);
31    let _ = write_report(&mut out, report);
32    out
33}
34
35/// Render a [`Diff`] to a CommonMark-compatible Markdown string.
36///
37/// # Example
38///
39/// ```
40/// use dev_report::{markdown, CheckResult, Report, Severity};
41///
42/// let mut prev = Report::new("c", "0.1.0");
43/// prev.push(CheckResult::pass("a"));
44/// let mut curr = Report::new("c", "0.1.0");
45/// curr.push(CheckResult::fail("a", Severity::Error));
46///
47/// let diff = curr.diff(&prev);
48/// let md = markdown::diff_to_markdown(&diff);
49/// assert!(md.starts_with("# Diff"));
50/// assert!(md.contains("Newly failing"));
51/// ```
52pub fn diff_to_markdown(diff: &Diff) -> String {
53    let mut out = String::with_capacity(256);
54    let _ = write_diff(&mut out, diff);
55    out
56}
57
58/// Render a [`MultiReport`] to a CommonMark-compatible Markdown string.
59///
60/// Renders a top-level summary followed by each constituent report
61/// as an `## H2` section.
62///
63/// # Example
64///
65/// ```
66/// use dev_report::{markdown, CheckResult, MultiReport, Report};
67///
68/// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
69/// bench.push(CheckResult::pass("hot"));
70/// let mut multi = MultiReport::new("c", "0.1.0");
71/// multi.push(bench);
72///
73/// let md = markdown::multi_to_markdown(&multi);
74/// assert!(md.starts_with("# MultiReport"));
75/// ```
76pub fn multi_to_markdown(multi: &MultiReport) -> String {
77    let mut out = String::with_capacity(512);
78    let _ = write_multi(&mut out, multi);
79    out
80}
81
82fn write_report(out: &mut String, r: &Report) -> std::fmt::Result {
83    writeln!(out, "# Report: {} {}", r.subject, r.subject_version)?;
84    writeln!(out)?;
85    writeln!(out, "- **Schema version:** {}", r.schema_version)?;
86    if let Some(p) = &r.producer {
87        writeln!(out, "- **Producer:** `{}`", p)?;
88    }
89    writeln!(
90        out,
91        "- **Started:** {}",
92        r.started_at.format("%Y-%m-%d %H:%M:%S UTC")
93    )?;
94    if let Some(end) = r.finished_at {
95        writeln!(
96            out,
97            "- **Finished:** {}",
98            end.format("%Y-%m-%d %H:%M:%S UTC")
99        )?;
100    }
101    writeln!(
102        out,
103        "- **Overall verdict:** **{}**",
104        verdict_word(r.overall_verdict())
105    )?;
106    writeln!(out)?;
107    write_summary_table(out, r)?;
108    writeln!(out)?;
109    writeln!(out, "## Checks")?;
110    writeln!(out)?;
111    for c in &r.checks {
112        write_check(out, c)?;
113    }
114    Ok(())
115}
116
117fn write_summary_table(out: &mut String, r: &Report) -> std::fmt::Result {
118    let (mut p, mut f, mut w, mut s) = (0usize, 0usize, 0usize, 0usize);
119    for c in &r.checks {
120        match c.verdict {
121            Verdict::Pass => p += 1,
122            Verdict::Fail => f += 1,
123            Verdict::Warn => w += 1,
124            Verdict::Skip => s += 1,
125        }
126    }
127    writeln!(out, "| Verdict | Count |")?;
128    writeln!(out, "|---------|-------|")?;
129    writeln!(out, "| Fail    | {} |", f)?;
130    writeln!(out, "| Warn    | {} |", w)?;
131    writeln!(out, "| Pass    | {} |", p)?;
132    writeln!(out, "| Skip    | {} |", s)?;
133    writeln!(out, "| **Total** | **{}** |", r.checks.len())
134}
135
136fn write_check(out: &mut String, c: &CheckResult) -> std::fmt::Result {
137    let sev = c
138        .severity
139        .map(|s| format!(" ({})", severity_word(s)))
140        .unwrap_or_default();
141    writeln!(
142        out,
143        "### {} - **{}**{}",
144        c.name,
145        verdict_word(c.verdict),
146        sev
147    )?;
148    writeln!(out)?;
149    if let Some(d) = c.duration_ms {
150        writeln!(out, "- **Duration:** {} ms", d)?;
151    }
152    writeln!(out, "- **At:** {}", c.at.format("%Y-%m-%d %H:%M:%S UTC"))?;
153    if !c.tags.is_empty() {
154        let tags: Vec<String> = c.tags.iter().map(|t| format!("`{}`", t)).collect();
155        writeln!(out, "- **Tags:** {}", tags.join(", "))?;
156    }
157    if let Some(detail) = &c.detail {
158        writeln!(out, "- **Detail:** {}", detail)?;
159    }
160    if !c.evidence.is_empty() {
161        writeln!(out)?;
162        writeln!(out, "**Evidence:**")?;
163        writeln!(out)?;
164        for e in &c.evidence {
165            write_evidence(out, &e.label, &e.data)?;
166        }
167    }
168    writeln!(out)
169}
170
171fn write_evidence(out: &mut String, label: &str, data: &EvidenceData) -> std::fmt::Result {
172    match data {
173        EvidenceData::Numeric(n) => writeln!(out, "- **{}** (numeric): `{}`", label, n),
174        EvidenceData::Snippet(s) => {
175            writeln!(out, "- **{}** (snippet):", label)?;
176            writeln!(out)?;
177            writeln!(out, "  ```")?;
178            for line in s.lines() {
179                writeln!(out, "  {}", line)?;
180            }
181            writeln!(out, "  ```")
182        }
183        EvidenceData::FileRef(f) => {
184            writeln!(out, "- **{}** (file): `{}`", label, file_ref_inline(f))
185        }
186        EvidenceData::KeyValue(map) => {
187            writeln!(out, "- **{}** (key-value):", label)?;
188            for (k, v) in map {
189                writeln!(out, "  - `{}`: {}", k, v)?;
190            }
191            Ok(())
192        }
193    }
194}
195
196fn file_ref_inline(f: &FileRef) -> String {
197    match (f.line_start, f.line_end) {
198        (Some(s), Some(e)) if s == e => format!("{}:{}", f.path, s),
199        (Some(s), Some(e)) => format!("{}:{}-{}", f.path, s, e),
200        (Some(s), None) => format!("{}:{}", f.path, s),
201        _ => f.path.clone(),
202    }
203}
204
205fn verdict_word(v: Verdict) -> &'static str {
206    match v {
207        Verdict::Pass => "PASS",
208        Verdict::Fail => "FAIL",
209        Verdict::Warn => "WARN",
210        Verdict::Skip => "SKIP",
211    }
212}
213
214fn severity_word(s: Severity) -> &'static str {
215    match s {
216        Severity::Info => "info",
217        Severity::Warning => "warning",
218        Severity::Error => "error",
219        Severity::Critical => "critical",
220    }
221}
222
223fn write_diff(out: &mut String, d: &Diff) -> std::fmt::Result {
224    writeln!(out, "# Diff")?;
225    writeln!(out)?;
226    if d.is_clean() {
227        writeln!(out, "_clean (no differences)_")?;
228        return Ok(());
229    }
230    write_diff_list(out, "Newly failing", &d.newly_failing)?;
231    write_diff_list(out, "Newly passing", &d.newly_passing)?;
232    write_diff_list(out, "Added", &d.added)?;
233    write_diff_list(out, "Removed", &d.removed)?;
234    if !d.severity_changes.is_empty() {
235        writeln!(out, "## Severity changes")?;
236        writeln!(out)?;
237        writeln!(out, "| Check | From | To |")?;
238        writeln!(out, "|-------|------|----|")?;
239        for c in &d.severity_changes {
240            let from = c.from.map(severity_word).unwrap_or("none");
241            let to = c.to.map(severity_word).unwrap_or("none");
242            writeln!(out, "| {} | {} | {} |", c.name, from, to)?;
243        }
244        writeln!(out)?;
245    }
246    if !d.duration_regressions.is_empty() {
247        writeln!(out, "## Duration regressions")?;
248        writeln!(out)?;
249        writeln!(out, "| Check | Baseline (ms) | Current (ms) | Delta |")?;
250        writeln!(out, "|-------|---------------|--------------|-------|")?;
251        for r in &d.duration_regressions {
252            writeln!(
253                out,
254                "| {} | {} | {} | {:+.2}% |",
255                r.name, r.baseline_ms, r.current_ms, r.delta_pct
256            )?;
257        }
258        writeln!(out)?;
259    }
260    Ok(())
261}
262
263fn write_diff_list(out: &mut String, title: &str, items: &[String]) -> std::fmt::Result {
264    if items.is_empty() {
265        return Ok(());
266    }
267    writeln!(out, "## {}", title)?;
268    writeln!(out)?;
269    for name in items {
270        writeln!(out, "- `{}`", name)?;
271    }
272    writeln!(out)
273}
274
275fn write_multi(out: &mut String, m: &MultiReport) -> std::fmt::Result {
276    writeln!(out, "# MultiReport: {} {}", m.subject, m.subject_version)?;
277    writeln!(out)?;
278    writeln!(out, "- **Schema version:** {}", m.schema_version)?;
279    writeln!(out, "- **Reports:** {}", m.reports.len())?;
280    writeln!(out, "- **Total checks:** {}", m.total_check_count())?;
281    writeln!(
282        out,
283        "- **Started:** {}",
284        m.started_at.format("%Y-%m-%d %H:%M:%S UTC")
285    )?;
286    if let Some(end) = m.finished_at {
287        writeln!(
288            out,
289            "- **Finished:** {}",
290            end.format("%Y-%m-%d %H:%M:%S UTC")
291        )?;
292    }
293    writeln!(
294        out,
295        "- **Overall verdict:** **{}**",
296        verdict_word(m.overall_verdict())
297    )?;
298    writeln!(out)?;
299    writeln!(out, "---")?;
300    writeln!(out)?;
301    for r in &m.reports {
302        write_report(out, r)?;
303        writeln!(out)?;
304    }
305    Ok(())
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::Evidence;
312
313    fn sample() -> Report {
314        let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
315        r.push(CheckResult::pass("compile").with_duration_ms(7));
316        r.push(
317            CheckResult::warn("flaky", Severity::Warning)
318                .with_tag("bench")
319                .with_evidence(Evidence::numeric("mean_ns", 1234.5))
320                .with_evidence(Evidence::kv("env", [("CI", "true"), ("RUST_LOG", "debug")])),
321        );
322        r.push(
323            CheckResult::fail("chaos::recover", Severity::Critical)
324                .with_tags(["chaos", "recovery"])
325                .with_detail("recovery did not restore final state")
326                .with_evidence(Evidence::snippet("trace", "panicked at lib.rs:42"))
327                .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
328        );
329        r.push(CheckResult::skip("not_applicable"));
330        r.finish();
331        r
332    }
333
334    #[test]
335    fn renders_report_header() {
336        let md = to_markdown(&sample());
337        assert!(md.starts_with("# Report: widget 0.1.0"));
338        assert!(md.contains("- **Schema version:** 1"));
339        assert!(md.contains("- **Producer:** `dev-report-test`"));
340    }
341
342    #[test]
343    fn renders_summary_table() {
344        let md = to_markdown(&sample());
345        assert!(md.contains("| Verdict | Count |"));
346        assert!(md.contains("| Fail    | 1 |"));
347        assert!(md.contains("| Warn    | 1 |"));
348        assert!(md.contains("| Pass    | 1 |"));
349        assert!(md.contains("| Skip    | 1 |"));
350        assert!(md.contains("| **Total** | **4** |"));
351    }
352
353    #[test]
354    fn renders_each_check_heading() {
355        let md = to_markdown(&sample());
356        assert!(md.contains("### compile - **PASS**"));
357        assert!(md.contains("### flaky - **WARN** (warning)"));
358        assert!(md.contains("### chaos::recover - **FAIL** (critical)"));
359        assert!(md.contains("### not_applicable - **SKIP**"));
360    }
361
362    #[test]
363    fn renders_overall_verdict() {
364        let md = to_markdown(&sample());
365        assert!(md.contains("**Overall verdict:** **FAIL**"));
366    }
367
368    #[test]
369    fn renders_evidence_kinds() {
370        let md = to_markdown(&sample());
371        assert!(md.contains("**mean_ns** (numeric): `1234.5`"));
372        assert!(md.contains("**env** (key-value):"));
373        assert!(md.contains("`CI`: true"));
374        assert!(md.contains("`RUST_LOG`: debug"));
375        assert!(md.contains("**trace** (snippet):"));
376        assert!(md.contains("panicked at lib.rs:42"));
377        assert!(md.contains("**site** (file): `src/recover.rs:10-20`"));
378    }
379
380    #[test]
381    fn renders_tags_and_detail() {
382        let md = to_markdown(&sample());
383        assert!(md.contains("- **Tags:** `chaos`, `recovery`"));
384        assert!(md.contains("- **Detail:** recovery did not restore final state"));
385    }
386
387    #[test]
388    fn pure_function_same_input_same_output() {
389        let r = sample();
390        assert_eq!(to_markdown(&r), to_markdown(&r));
391    }
392
393    #[test]
394    fn empty_report_renders() {
395        let r = Report::new("nothing", "0.0.0");
396        let md = to_markdown(&r);
397        assert!(md.contains("# Report: nothing 0.0.0"));
398        assert!(md.contains("**Overall verdict:** **SKIP**"));
399        assert!(md.contains("| **Total** | **0** |"));
400    }
401
402    #[test]
403    fn diff_clean_renders() {
404        let mut a = Report::new("c", "0.1.0");
405        a.push(CheckResult::pass("x"));
406        let b = a.clone();
407        let md = diff_to_markdown(&a.diff(&b));
408        assert!(md.starts_with("# Diff"));
409        assert!(md.contains("clean"));
410    }
411
412    #[test]
413    fn diff_with_changes_renders_sections() {
414        let mut prev = Report::new("c", "0.1.0");
415        prev.push(CheckResult::pass("a"));
416        prev.push(CheckResult::pass("b"));
417
418        let mut curr = Report::new("c", "0.1.0");
419        curr.push(CheckResult::fail("a", Severity::Error));
420        curr.push(CheckResult::pass("c"));
421
422        let md = diff_to_markdown(&curr.diff(&prev));
423        assert!(md.contains("## Newly failing"));
424        assert!(md.contains("- `a`"));
425        assert!(md.contains("## Added"));
426        assert!(md.contains("- `c`"));
427        assert!(md.contains("## Removed"));
428        assert!(md.contains("- `b`"));
429    }
430
431    #[test]
432    fn multi_renders_each_report() {
433        let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
434        bench.push(CheckResult::pass("hot"));
435        let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
436        chaos.push(CheckResult::fail("recover", Severity::Critical));
437
438        let mut multi = MultiReport::new("c", "0.1.0");
439        multi.push(bench);
440        multi.push(chaos);
441
442        let md = multi_to_markdown(&multi);
443        assert!(md.starts_with("# MultiReport"));
444        assert!(md.contains("**Reports:** 2"));
445        assert!(md.contains("**Total checks:** 2"));
446        assert!(md.contains("# Report: c 0.1.0")); // each report rendered as section
447    }
448}