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, "| {} | {} | {} |", escape_table_cell(&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                escape_table_cell(&r.name),
256                r.baseline_ms,
257                r.current_ms,
258                r.delta_pct
259            )?;
260        }
261        writeln!(out)?;
262    }
263    Ok(())
264}
265
266/// Escape a string for safe inclusion in a markdown table cell.
267///
268/// The cell delimiter is `|`; a literal pipe inside a value would
269/// split the cell and shift later columns. CommonMark allows
270/// backslash-escaping the pipe (`\|`) inside a table cell. Newlines
271/// also break tables; replace them with `<br>` so the layout survives.
272fn escape_table_cell(s: &str) -> String {
273    let mut out = String::with_capacity(s.len());
274    for ch in s.chars() {
275        match ch {
276            '|' => out.push_str("\\|"),
277            '\n' | '\r' => out.push_str("<br>"),
278            c => out.push(c),
279        }
280    }
281    out
282}
283
284fn write_diff_list(out: &mut String, title: &str, items: &[String]) -> std::fmt::Result {
285    if items.is_empty() {
286        return Ok(());
287    }
288    writeln!(out, "## {}", title)?;
289    writeln!(out)?;
290    for name in items {
291        writeln!(out, "- `{}`", name)?;
292    }
293    writeln!(out)
294}
295
296fn write_multi(out: &mut String, m: &MultiReport) -> std::fmt::Result {
297    writeln!(out, "# MultiReport: {} {}", m.subject, m.subject_version)?;
298    writeln!(out)?;
299    writeln!(out, "- **Schema version:** {}", m.schema_version)?;
300    writeln!(out, "- **Reports:** {}", m.reports.len())?;
301    writeln!(out, "- **Total checks:** {}", m.total_check_count())?;
302    writeln!(
303        out,
304        "- **Started:** {}",
305        m.started_at.format("%Y-%m-%d %H:%M:%S UTC")
306    )?;
307    if let Some(end) = m.finished_at {
308        writeln!(
309            out,
310            "- **Finished:** {}",
311            end.format("%Y-%m-%d %H:%M:%S UTC")
312        )?;
313    }
314    writeln!(
315        out,
316        "- **Overall verdict:** **{}**",
317        verdict_word(m.overall_verdict())
318    )?;
319    writeln!(out)?;
320    writeln!(out, "---")?;
321    writeln!(out)?;
322    for r in &m.reports {
323        write_report(out, r)?;
324        writeln!(out)?;
325    }
326    Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::Evidence;
333
334    fn sample() -> Report {
335        let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
336        r.push(CheckResult::pass("compile").with_duration_ms(7));
337        r.push(
338            CheckResult::warn("flaky", Severity::Warning)
339                .with_tag("bench")
340                .with_evidence(Evidence::numeric("mean_ns", 1234.5))
341                .with_evidence(Evidence::kv("env", [("CI", "true"), ("RUST_LOG", "debug")])),
342        );
343        r.push(
344            CheckResult::fail("chaos::recover", Severity::Critical)
345                .with_tags(["chaos", "recovery"])
346                .with_detail("recovery did not restore final state")
347                .with_evidence(Evidence::snippet("trace", "panicked at lib.rs:42"))
348                .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
349        );
350        r.push(CheckResult::skip("not_applicable"));
351        r.finish();
352        r
353    }
354
355    #[test]
356    fn renders_report_header() {
357        let md = to_markdown(&sample());
358        assert!(md.starts_with("# Report: widget 0.1.0"));
359        assert!(md.contains("- **Schema version:** 1"));
360        assert!(md.contains("- **Producer:** `dev-report-test`"));
361    }
362
363    #[test]
364    fn renders_summary_table() {
365        let md = to_markdown(&sample());
366        assert!(md.contains("| Verdict | Count |"));
367        assert!(md.contains("| Fail    | 1 |"));
368        assert!(md.contains("| Warn    | 1 |"));
369        assert!(md.contains("| Pass    | 1 |"));
370        assert!(md.contains("| Skip    | 1 |"));
371        assert!(md.contains("| **Total** | **4** |"));
372    }
373
374    #[test]
375    fn renders_each_check_heading() {
376        let md = to_markdown(&sample());
377        assert!(md.contains("### compile - **PASS**"));
378        assert!(md.contains("### flaky - **WARN** (warning)"));
379        assert!(md.contains("### chaos::recover - **FAIL** (critical)"));
380        assert!(md.contains("### not_applicable - **SKIP**"));
381    }
382
383    #[test]
384    fn renders_overall_verdict() {
385        let md = to_markdown(&sample());
386        assert!(md.contains("**Overall verdict:** **FAIL**"));
387    }
388
389    #[test]
390    fn renders_evidence_kinds() {
391        let md = to_markdown(&sample());
392        assert!(md.contains("**mean_ns** (numeric): `1234.5`"));
393        assert!(md.contains("**env** (key-value):"));
394        assert!(md.contains("`CI`: true"));
395        assert!(md.contains("`RUST_LOG`: debug"));
396        assert!(md.contains("**trace** (snippet):"));
397        assert!(md.contains("panicked at lib.rs:42"));
398        assert!(md.contains("**site** (file): `src/recover.rs:10-20`"));
399    }
400
401    #[test]
402    fn renders_tags_and_detail() {
403        let md = to_markdown(&sample());
404        assert!(md.contains("- **Tags:** `chaos`, `recovery`"));
405        assert!(md.contains("- **Detail:** recovery did not restore final state"));
406    }
407
408    #[test]
409    fn pure_function_same_input_same_output() {
410        let r = sample();
411        assert_eq!(to_markdown(&r), to_markdown(&r));
412    }
413
414    #[test]
415    fn diff_table_escapes_pipes_in_check_names() {
416        use crate::{DiffOptions, Verdict};
417
418        // Curr has a fail; baseline has a pass, with a check name
419        // containing pipes — must be escaped or the markdown table breaks.
420        let mut base = Report::new("c", "0.1.0");
421        base.push(CheckResult::pass("a|b|c").with_duration_ms(100));
422        let mut curr = Report::new("c", "0.1.0");
423        curr.push(CheckResult::fail("a|b|c", Severity::Error).with_duration_ms(220));
424        let diff = curr.diff_with(
425            &base,
426            &DiffOptions {
427                duration_regression_pct: Some(20.0),
428                duration_regression_abs_ms: None,
429            },
430        );
431        assert!(!diff.is_clean());
432        let md = diff.to_markdown();
433
434        // Pipes inside the check name must be backslash-escaped so the
435        // surrounding `|`-delimited table layout survives.
436        assert!(
437            md.contains(r"a\|b\|c"),
438            "check-name pipes not escaped: {}",
439            md
440        );
441        // And the raw form (unescaped pipes) must NOT appear in the table
442        // row, since that would corrupt the column count.
443        assert!(
444            !md.lines().any(|l| l.starts_with("| a|b|c |")),
445            "raw pipe leaked into table row: {}",
446            md
447        );
448        // sanity: this test only matters if the diff actually emits the
449        // expected sections.
450        assert!(matches!(curr.overall_verdict(), Verdict::Fail));
451    }
452
453    #[test]
454    fn empty_report_renders() {
455        let r = Report::new("nothing", "0.0.0");
456        let md = to_markdown(&r);
457        assert!(md.contains("# Report: nothing 0.0.0"));
458        assert!(md.contains("**Overall verdict:** **SKIP**"));
459        assert!(md.contains("| **Total** | **0** |"));
460    }
461
462    #[test]
463    fn diff_clean_renders() {
464        let mut a = Report::new("c", "0.1.0");
465        a.push(CheckResult::pass("x"));
466        let b = a.clone();
467        let md = diff_to_markdown(&a.diff(&b));
468        assert!(md.starts_with("# Diff"));
469        assert!(md.contains("clean"));
470    }
471
472    #[test]
473    fn diff_with_changes_renders_sections() {
474        let mut prev = Report::new("c", "0.1.0");
475        prev.push(CheckResult::pass("a"));
476        prev.push(CheckResult::pass("b"));
477
478        let mut curr = Report::new("c", "0.1.0");
479        curr.push(CheckResult::fail("a", Severity::Error));
480        curr.push(CheckResult::pass("c"));
481
482        let md = diff_to_markdown(&curr.diff(&prev));
483        assert!(md.contains("## Newly failing"));
484        assert!(md.contains("- `a`"));
485        assert!(md.contains("## Added"));
486        assert!(md.contains("- `c`"));
487        assert!(md.contains("## Removed"));
488        assert!(md.contains("- `b`"));
489    }
490
491    #[test]
492    fn multi_renders_each_report() {
493        let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
494        bench.push(CheckResult::pass("hot"));
495        let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
496        chaos.push(CheckResult::fail("recover", Severity::Critical));
497
498        let mut multi = MultiReport::new("c", "0.1.0");
499        multi.push(bench);
500        multi.push(chaos);
501
502        let md = multi_to_markdown(&multi);
503        assert!(md.starts_with("# MultiReport"));
504        assert!(md.contains("**Reports:** 2"));
505        assert!(md.contains("**Total checks:** 2"));
506        assert!(md.contains("# Report: c 0.1.0")); // each report rendered as section
507    }
508}