1use std::fmt::Write as _;
12
13use crate::{CheckResult, Diff, EvidenceData, FileRef, MultiReport, Report, Severity, Verdict};
14
15pub 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
35pub 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
58pub 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")); }
448}