Skip to main content

dev_report/
markdown.rs

1//! Markdown exporter. Available with the `markdown` feature.
2//!
3//! Pure function over a [`Report`] producing a CommonMark-compatible
4//! string. Every fact in the report (verdict, severity, tags, evidence,
5//! durations) is preserved in the output. No external dependencies.
6
7use std::fmt::Write as _;
8
9use crate::{CheckResult, EvidenceData, FileRef, Report, Severity, Verdict};
10
11/// Render a report to a CommonMark-compatible Markdown string.
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 md = r.to_markdown();
22/// assert!(md.starts_with("# Report"));
23/// assert!(md.contains("compile"));
24/// ```
25pub fn to_markdown(report: &Report) -> String {
26    let mut out = String::with_capacity(512);
27    let _ = write_report(&mut out, report);
28    out
29}
30
31fn write_report(out: &mut String, r: &Report) -> std::fmt::Result {
32    writeln!(out, "# Report: {} {}", r.subject, r.subject_version)?;
33    writeln!(out)?;
34    writeln!(out, "- **Schema version:** {}", r.schema_version)?;
35    if let Some(p) = &r.producer {
36        writeln!(out, "- **Producer:** `{}`", p)?;
37    }
38    writeln!(
39        out,
40        "- **Started:** {}",
41        r.started_at.format("%Y-%m-%d %H:%M:%S UTC")
42    )?;
43    if let Some(end) = r.finished_at {
44        writeln!(
45            out,
46            "- **Finished:** {}",
47            end.format("%Y-%m-%d %H:%M:%S UTC")
48        )?;
49    }
50    writeln!(
51        out,
52        "- **Overall verdict:** **{}**",
53        verdict_word(r.overall_verdict())
54    )?;
55    writeln!(out)?;
56    write_summary_table(out, r)?;
57    writeln!(out)?;
58    writeln!(out, "## Checks")?;
59    writeln!(out)?;
60    for c in &r.checks {
61        write_check(out, c)?;
62    }
63    Ok(())
64}
65
66fn write_summary_table(out: &mut String, r: &Report) -> std::fmt::Result {
67    let (mut p, mut f, mut w, mut s) = (0usize, 0usize, 0usize, 0usize);
68    for c in &r.checks {
69        match c.verdict {
70            Verdict::Pass => p += 1,
71            Verdict::Fail => f += 1,
72            Verdict::Warn => w += 1,
73            Verdict::Skip => s += 1,
74        }
75    }
76    writeln!(out, "| Verdict | Count |")?;
77    writeln!(out, "|---------|-------|")?;
78    writeln!(out, "| Fail    | {} |", f)?;
79    writeln!(out, "| Warn    | {} |", w)?;
80    writeln!(out, "| Pass    | {} |", p)?;
81    writeln!(out, "| Skip    | {} |", s)?;
82    writeln!(out, "| **Total** | **{}** |", r.checks.len())
83}
84
85fn write_check(out: &mut String, c: &CheckResult) -> std::fmt::Result {
86    let sev = c
87        .severity
88        .map(|s| format!(" ({})", severity_word(s)))
89        .unwrap_or_default();
90    writeln!(
91        out,
92        "### {} - **{}**{}",
93        c.name,
94        verdict_word(c.verdict),
95        sev
96    )?;
97    writeln!(out)?;
98    if let Some(d) = c.duration_ms {
99        writeln!(out, "- **Duration:** {} ms", d)?;
100    }
101    writeln!(out, "- **At:** {}", c.at.format("%Y-%m-%d %H:%M:%S UTC"))?;
102    if !c.tags.is_empty() {
103        let tags: Vec<String> = c.tags.iter().map(|t| format!("`{}`", t)).collect();
104        writeln!(out, "- **Tags:** {}", tags.join(", "))?;
105    }
106    if let Some(detail) = &c.detail {
107        writeln!(out, "- **Detail:** {}", detail)?;
108    }
109    if !c.evidence.is_empty() {
110        writeln!(out)?;
111        writeln!(out, "**Evidence:**")?;
112        writeln!(out)?;
113        for e in &c.evidence {
114            write_evidence(out, &e.label, &e.data)?;
115        }
116    }
117    writeln!(out)
118}
119
120fn write_evidence(out: &mut String, label: &str, data: &EvidenceData) -> std::fmt::Result {
121    match data {
122        EvidenceData::Numeric(n) => writeln!(out, "- **{}** (numeric): `{}`", label, n),
123        EvidenceData::Snippet(s) => {
124            writeln!(out, "- **{}** (snippet):", label)?;
125            writeln!(out)?;
126            writeln!(out, "  ```")?;
127            for line in s.lines() {
128                writeln!(out, "  {}", line)?;
129            }
130            writeln!(out, "  ```")
131        }
132        EvidenceData::FileRef(f) => {
133            writeln!(out, "- **{}** (file): `{}`", label, file_ref_inline(f))
134        }
135        EvidenceData::KeyValue(map) => {
136            writeln!(out, "- **{}** (key-value):", label)?;
137            for (k, v) in map {
138                writeln!(out, "  - `{}`: {}", k, v)?;
139            }
140            Ok(())
141        }
142    }
143}
144
145fn file_ref_inline(f: &FileRef) -> String {
146    match (f.line_start, f.line_end) {
147        (Some(s), Some(e)) if s == e => format!("{}:{}", f.path, s),
148        (Some(s), Some(e)) => format!("{}:{}-{}", f.path, s, e),
149        (Some(s), None) => format!("{}:{}", f.path, s),
150        _ => f.path.clone(),
151    }
152}
153
154fn verdict_word(v: Verdict) -> &'static str {
155    match v {
156        Verdict::Pass => "PASS",
157        Verdict::Fail => "FAIL",
158        Verdict::Warn => "WARN",
159        Verdict::Skip => "SKIP",
160    }
161}
162
163fn severity_word(s: Severity) -> &'static str {
164    match s {
165        Severity::Info => "info",
166        Severity::Warning => "warning",
167        Severity::Error => "error",
168        Severity::Critical => "critical",
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::Evidence;
176
177    fn sample() -> Report {
178        let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
179        r.push(CheckResult::pass("compile").with_duration_ms(7));
180        r.push(
181            CheckResult::warn("flaky", Severity::Warning)
182                .with_tag("bench")
183                .with_evidence(Evidence::numeric("mean_ns", 1234.5))
184                .with_evidence(Evidence::kv("env", [("CI", "true"), ("RUST_LOG", "debug")])),
185        );
186        r.push(
187            CheckResult::fail("chaos::recover", Severity::Critical)
188                .with_tags(["chaos", "recovery"])
189                .with_detail("recovery did not restore final state")
190                .with_evidence(Evidence::snippet("trace", "panicked at lib.rs:42"))
191                .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
192        );
193        r.push(CheckResult::skip("not_applicable"));
194        r.finish();
195        r
196    }
197
198    #[test]
199    fn renders_report_header() {
200        let md = to_markdown(&sample());
201        assert!(md.starts_with("# Report: widget 0.1.0"));
202        assert!(md.contains("- **Schema version:** 1"));
203        assert!(md.contains("- **Producer:** `dev-report-test`"));
204    }
205
206    #[test]
207    fn renders_summary_table() {
208        let md = to_markdown(&sample());
209        assert!(md.contains("| Verdict | Count |"));
210        assert!(md.contains("| Fail    | 1 |"));
211        assert!(md.contains("| Warn    | 1 |"));
212        assert!(md.contains("| Pass    | 1 |"));
213        assert!(md.contains("| Skip    | 1 |"));
214        assert!(md.contains("| **Total** | **4** |"));
215    }
216
217    #[test]
218    fn renders_each_check_heading() {
219        let md = to_markdown(&sample());
220        assert!(md.contains("### compile - **PASS**"));
221        assert!(md.contains("### flaky - **WARN** (warning)"));
222        assert!(md.contains("### chaos::recover - **FAIL** (critical)"));
223        assert!(md.contains("### not_applicable - **SKIP**"));
224    }
225
226    #[test]
227    fn renders_overall_verdict() {
228        let md = to_markdown(&sample());
229        assert!(md.contains("**Overall verdict:** **FAIL**"));
230    }
231
232    #[test]
233    fn renders_evidence_kinds() {
234        let md = to_markdown(&sample());
235        assert!(md.contains("**mean_ns** (numeric): `1234.5`"));
236        assert!(md.contains("**env** (key-value):"));
237        assert!(md.contains("`CI`: true"));
238        assert!(md.contains("`RUST_LOG`: debug"));
239        assert!(md.contains("**trace** (snippet):"));
240        assert!(md.contains("panicked at lib.rs:42"));
241        assert!(md.contains("**site** (file): `src/recover.rs:10-20`"));
242    }
243
244    #[test]
245    fn renders_tags_and_detail() {
246        let md = to_markdown(&sample());
247        assert!(md.contains("- **Tags:** `chaos`, `recovery`"));
248        assert!(md.contains("- **Detail:** recovery did not restore final state"));
249    }
250
251    #[test]
252    fn pure_function_same_input_same_output() {
253        let r = sample();
254        assert_eq!(to_markdown(&r), to_markdown(&r));
255    }
256
257    #[test]
258    fn empty_report_renders() {
259        let r = Report::new("nothing", "0.0.0");
260        let md = to_markdown(&r);
261        assert!(md.contains("# Report: nothing 0.0.0"));
262        assert!(md.contains("**Overall verdict:** **SKIP**"));
263        assert!(md.contains("| **Total** | **0** |"));
264    }
265}