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:** {}", code_span(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        one_line(&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| code_span(t)).collect();
155        writeln!(out, "- **Tags:** {}", tags.join(", "))?;
156    }
157    if let Some(detail) = &c.detail {
158        writeln!(out, "- **Detail:** {}", indent_continuation(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            // The fence must be longer than any backtick run inside the
176            // snippet, or a snippet containing ``` would close it early.
177            let fence = "`".repeat((longest_backtick_run(s) + 1).max(3));
178            writeln!(out, "- **{}** (snippet):", label)?;
179            writeln!(out)?;
180            writeln!(out, "  {}", fence)?;
181            for line in s.lines() {
182                writeln!(out, "  {}", line)?;
183            }
184            writeln!(out, "  {}", fence)
185        }
186        EvidenceData::FileRef(f) => {
187            writeln!(
188                out,
189                "- **{}** (file): {}",
190                label,
191                code_span(&file_ref_inline(f))
192            )
193        }
194        EvidenceData::KeyValue(map) => {
195            writeln!(out, "- **{}** (key-value):", label)?;
196            for (k, v) in map {
197                writeln!(
198                    out,
199                    "  - {}: {}",
200                    code_span(k),
201                    indent_continuation(v, "    ")
202                )?;
203            }
204            Ok(())
205        }
206    }
207}
208
209fn file_ref_inline(f: &FileRef) -> String {
210    match (f.line_start, f.line_end) {
211        (Some(s), Some(e)) if s == e => format!("{}:{}", f.path, s),
212        (Some(s), Some(e)) => format!("{}:{}-{}", f.path, s, e),
213        (Some(s), None) => format!("{}:{}", f.path, s),
214        _ => f.path.clone(),
215    }
216}
217
218fn verdict_word(v: Verdict) -> &'static str {
219    match v {
220        Verdict::Pass => "PASS",
221        Verdict::Fail => "FAIL",
222        Verdict::Warn => "WARN",
223        Verdict::Skip => "SKIP",
224    }
225}
226
227fn severity_word(s: Severity) -> &'static str {
228    match s {
229        Severity::Info => "info",
230        Severity::Warning => "warning",
231        Severity::Error => "error",
232        Severity::Critical => "critical",
233    }
234}
235
236fn write_diff(out: &mut String, d: &Diff) -> std::fmt::Result {
237    writeln!(out, "# Diff")?;
238    writeln!(out)?;
239    if d.is_clean() {
240        writeln!(out, "_clean (no differences)_")?;
241        return Ok(());
242    }
243    write_diff_list(out, "Newly failing", &d.newly_failing)?;
244    write_diff_list(out, "Newly passing", &d.newly_passing)?;
245    write_diff_list(out, "Added", &d.added)?;
246    write_diff_list(out, "Removed", &d.removed)?;
247    if !d.severity_changes.is_empty() {
248        writeln!(out, "## Severity changes")?;
249        writeln!(out)?;
250        writeln!(out, "| Check | From | To |")?;
251        writeln!(out, "|-------|------|----|")?;
252        for c in &d.severity_changes {
253            let from = c.from.map(severity_word).unwrap_or("none");
254            let to = c.to.map(severity_word).unwrap_or("none");
255            writeln!(
256                out,
257                "| {} | {} | {} |",
258                escape_table_cell(&c.name),
259                from,
260                to
261            )?;
262        }
263        writeln!(out)?;
264    }
265    if !d.duration_regressions.is_empty() {
266        writeln!(out, "## Duration regressions")?;
267        writeln!(out)?;
268        writeln!(out, "| Check | Baseline (ms) | Current (ms) | Delta |")?;
269        writeln!(out, "|-------|---------------|--------------|-------|")?;
270        for r in &d.duration_regressions {
271            writeln!(
272                out,
273                "| {} | {} | {} | {:+.2}% |",
274                escape_table_cell(&r.name),
275                r.baseline_ms,
276                r.current_ms,
277                r.delta_pct
278            )?;
279        }
280        writeln!(out)?;
281    }
282    Ok(())
283}
284
285/// Escape a string for safe inclusion in a markdown table cell.
286///
287/// The cell delimiter is `|`; a literal pipe inside a value would
288/// split the cell and shift later columns. CommonMark allows
289/// backslash-escaping the pipe (`\|`) inside a table cell. Newlines
290/// also break tables; replace them with `<br>` so the layout survives.
291fn escape_table_cell(s: &str) -> String {
292    let mut out = String::with_capacity(s.len());
293    for ch in s.chars() {
294        match ch {
295            '|' => out.push_str("\\|"),
296            '\n' | '\r' => out.push_str("<br>"),
297            c => out.push(c),
298        }
299    }
300    out
301}
302
303/// Collapse line breaks to spaces for contexts that must stay on one
304/// line (headings, code spans).
305fn one_line(s: &str) -> String {
306    s.replace("\r\n", " ").replace(['\n', '\r'], " ")
307}
308
309/// Indent every line after the first so multi-line text stays inside
310/// the surrounding list item.
311fn indent_continuation(s: &str, indent: &str) -> String {
312    let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
313    normalized.replace('\n', &format!("\n{}", indent))
314}
315
316fn longest_backtick_run(s: &str) -> usize {
317    let (mut longest, mut current) = (0, 0);
318    for ch in s.chars() {
319        if ch == '`' {
320            current += 1;
321            longest = longest.max(current);
322        } else {
323            current = 0;
324        }
325    }
326    longest
327}
328
329/// Wrap `s` in an inline code span that survives backticks and line
330/// breaks in the value. CommonMark: the delimiter is a backtick run
331/// longer than any run inside, padded with spaces when the content
332/// starts or ends with a backtick.
333fn code_span(s: &str) -> String {
334    let body = one_line(s);
335    let ticks = "`".repeat(longest_backtick_run(&body) + 1);
336    if body.starts_with('`') || body.ends_with('`') {
337        format!("{ticks} {body} {ticks}")
338    } else {
339        format!("{ticks}{body}{ticks}")
340    }
341}
342
343fn write_diff_list(out: &mut String, title: &str, items: &[String]) -> std::fmt::Result {
344    if items.is_empty() {
345        return Ok(());
346    }
347    writeln!(out, "## {}", title)?;
348    writeln!(out)?;
349    for name in items {
350        writeln!(out, "- {}", code_span(name))?;
351    }
352    writeln!(out)
353}
354
355fn write_multi(out: &mut String, m: &MultiReport) -> std::fmt::Result {
356    writeln!(out, "# MultiReport: {} {}", m.subject, m.subject_version)?;
357    writeln!(out)?;
358    writeln!(out, "- **Schema version:** {}", m.schema_version)?;
359    writeln!(out, "- **Reports:** {}", m.reports.len())?;
360    writeln!(out, "- **Total checks:** {}", m.total_check_count())?;
361    writeln!(
362        out,
363        "- **Started:** {}",
364        m.started_at.format("%Y-%m-%d %H:%M:%S UTC")
365    )?;
366    if let Some(end) = m.finished_at {
367        writeln!(
368            out,
369            "- **Finished:** {}",
370            end.format("%Y-%m-%d %H:%M:%S UTC")
371        )?;
372    }
373    writeln!(
374        out,
375        "- **Overall verdict:** **{}**",
376        verdict_word(m.overall_verdict())
377    )?;
378    writeln!(out)?;
379    writeln!(out, "---")?;
380    writeln!(out)?;
381    for r in &m.reports {
382        write_report(out, r)?;
383        writeln!(out)?;
384    }
385    Ok(())
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::Evidence;
392
393    fn sample() -> Report {
394        let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
395        r.push(CheckResult::pass("compile").with_duration_ms(7));
396        r.push(
397            CheckResult::warn("flaky", Severity::Warning)
398                .with_tag("bench")
399                .with_evidence(Evidence::numeric("mean_ns", 1234.5))
400                .with_evidence(Evidence::kv("env", [("CI", "true"), ("RUST_LOG", "debug")])),
401        );
402        r.push(
403            CheckResult::fail("chaos::recover", Severity::Critical)
404                .with_tags(["chaos", "recovery"])
405                .with_detail("recovery did not restore final state")
406                .with_evidence(Evidence::snippet("trace", "panicked at lib.rs:42"))
407                .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
408        );
409        r.push(CheckResult::skip("not_applicable"));
410        r.finish();
411        r
412    }
413
414    #[test]
415    fn renders_report_header() {
416        let md = to_markdown(&sample());
417        assert!(md.starts_with("# Report: widget 0.1.0"));
418        assert!(md.contains("- **Schema version:** 1"));
419        assert!(md.contains("- **Producer:** `dev-report-test`"));
420    }
421
422    #[test]
423    fn renders_summary_table() {
424        let md = to_markdown(&sample());
425        assert!(md.contains("| Verdict | Count |"));
426        assert!(md.contains("| Fail    | 1 |"));
427        assert!(md.contains("| Warn    | 1 |"));
428        assert!(md.contains("| Pass    | 1 |"));
429        assert!(md.contains("| Skip    | 1 |"));
430        assert!(md.contains("| **Total** | **4** |"));
431    }
432
433    #[test]
434    fn renders_each_check_heading() {
435        let md = to_markdown(&sample());
436        assert!(md.contains("### compile - **PASS**"));
437        assert!(md.contains("### flaky - **WARN** (warning)"));
438        assert!(md.contains("### chaos::recover - **FAIL** (critical)"));
439        assert!(md.contains("### not_applicable - **SKIP**"));
440    }
441
442    #[test]
443    fn renders_overall_verdict() {
444        let md = to_markdown(&sample());
445        assert!(md.contains("**Overall verdict:** **FAIL**"));
446    }
447
448    #[test]
449    fn renders_evidence_kinds() {
450        let md = to_markdown(&sample());
451        assert!(md.contains("**mean_ns** (numeric): `1234.5`"));
452        assert!(md.contains("**env** (key-value):"));
453        assert!(md.contains("`CI`: true"));
454        assert!(md.contains("`RUST_LOG`: debug"));
455        assert!(md.contains("**trace** (snippet):"));
456        assert!(md.contains("panicked at lib.rs:42"));
457        assert!(md.contains("**site** (file): `src/recover.rs:10-20`"));
458    }
459
460    #[test]
461    fn renders_tags_and_detail() {
462        let md = to_markdown(&sample());
463        assert!(md.contains("- **Tags:** `chaos`, `recovery`"));
464        assert!(md.contains("- **Detail:** recovery did not restore final state"));
465    }
466
467    #[test]
468    fn pure_function_same_input_same_output() {
469        let r = sample();
470        assert_eq!(to_markdown(&r), to_markdown(&r));
471    }
472
473    #[test]
474    fn diff_table_escapes_pipes_in_check_names() {
475        use crate::{DiffOptions, Verdict};
476
477        // Curr has a fail; baseline has a pass, with a check name
478        // containing pipes — must be escaped or the markdown table breaks.
479        let mut base = Report::new("c", "0.1.0");
480        base.push(CheckResult::pass("a|b|c").with_duration_ms(100));
481        let mut curr = Report::new("c", "0.1.0");
482        curr.push(CheckResult::fail("a|b|c", Severity::Error).with_duration_ms(220));
483        let diff = curr.diff_with(
484            &base,
485            &DiffOptions {
486                duration_regression_pct: Some(20.0),
487                duration_regression_abs_ms: None,
488            },
489        );
490        assert!(!diff.is_clean());
491        let md = diff.to_markdown();
492
493        // Pipes inside the check name must be backslash-escaped so the
494        // surrounding `|`-delimited table layout survives.
495        assert!(
496            md.contains(r"a\|b\|c"),
497            "check-name pipes not escaped: {}",
498            md
499        );
500        // And the raw form (unescaped pipes) must NOT appear in the table
501        // row, since that would corrupt the column count.
502        assert!(
503            !md.lines().any(|l| l.starts_with("| a|b|c |")),
504            "raw pipe leaked into table row: {}",
505            md
506        );
507        // sanity: this test only matters if the diff actually emits the
508        // expected sections.
509        assert!(matches!(curr.overall_verdict(), Verdict::Fail));
510    }
511
512    #[test]
513    fn backticks_and_line_breaks_cannot_break_the_layout() {
514        let mut r = Report::new("c", "0.1.0").with_producer("dev`x");
515        r.push(
516            CheckResult::fail("multi\nline", Severity::Error)
517                .with_tag("a`b")
518                .with_detail("first\nsecond")
519                .with_evidence(Evidence::snippet("md", "before\n```\nafter"))
520                .with_evidence(Evidence::kv("env", [("K`", "v1\nv2")]))
521                .with_evidence(Evidence::file_ref("f", "dir/`odd`.rs")),
522        );
523        let md = to_markdown(&r);
524        assert!(md.contains("- **Producer:** ``dev`x``"), "{md}");
525        assert!(md.contains("### multi line - **FAIL** (error)"), "{md}");
526        assert!(md.contains("- **Tags:** ``a`b``"), "{md}");
527        assert!(md.contains("- **Detail:** first\n  second\n"), "{md}");
528        // Snippet fence is longer than the ``` inside it.
529        assert!(
530            md.contains("  ````\n  before\n  ```\n  after\n  ````\n"),
531            "{md}"
532        );
533        assert!(md.contains("  - `` K` ``: v1\n    v2\n"), "{md}");
534        assert!(md.contains("(file): ``dir/`odd`.rs``"), "{md}");
535    }
536
537    #[test]
538    fn diff_list_names_with_backticks_are_code_spans() {
539        let prev = Report::new("c", "0.1.0");
540        let mut curr = Report::new("c", "0.1.0");
541        curr.push(CheckResult::fail("a`b", Severity::Error));
542        let md = diff_to_markdown(&curr.diff(&prev));
543        assert!(md.contains("- ``a`b``"), "{md}");
544    }
545
546    #[test]
547    fn empty_report_renders() {
548        let r = Report::new("nothing", "0.0.0");
549        let md = to_markdown(&r);
550        assert!(md.contains("# Report: nothing 0.0.0"));
551        assert!(md.contains("**Overall verdict:** **SKIP**"));
552        assert!(md.contains("| **Total** | **0** |"));
553    }
554
555    #[test]
556    fn diff_clean_renders() {
557        let mut a = Report::new("c", "0.1.0");
558        a.push(CheckResult::pass("x"));
559        let b = a.clone();
560        let md = diff_to_markdown(&a.diff(&b));
561        assert!(md.starts_with("# Diff"));
562        assert!(md.contains("clean"));
563    }
564
565    #[test]
566    fn diff_with_changes_renders_sections() {
567        let mut prev = Report::new("c", "0.1.0");
568        prev.push(CheckResult::pass("a"));
569        prev.push(CheckResult::pass("b"));
570
571        let mut curr = Report::new("c", "0.1.0");
572        curr.push(CheckResult::fail("a", Severity::Error));
573        curr.push(CheckResult::pass("c"));
574
575        let md = diff_to_markdown(&curr.diff(&prev));
576        assert!(md.contains("## Newly failing"));
577        assert!(md.contains("- `a`"));
578        assert!(md.contains("## Added"));
579        assert!(md.contains("- `c`"));
580        assert!(md.contains("## Removed"));
581        assert!(md.contains("- `b`"));
582    }
583
584    #[test]
585    fn multi_renders_each_report() {
586        let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
587        bench.push(CheckResult::pass("hot"));
588        let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
589        chaos.push(CheckResult::fail("recover", Severity::Critical));
590
591        let mut multi = MultiReport::new("c", "0.1.0");
592        multi.push(bench);
593        multi.push(chaos);
594
595        let md = multi_to_markdown(&multi);
596        assert!(md.starts_with("# MultiReport"));
597        assert!(md.contains("**Reports:** 2"));
598        assert!(md.contains("**Total checks:** 2"));
599        assert!(md.contains("# Report: c 0.1.0")); // each report rendered as section
600    }
601}