1use std::fmt::Write as _;
8
9use crate::{CheckResult, EvidenceData, FileRef, Report, Severity, Verdict};
10
11pub 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}