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, "| {} | {} | {} |", 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
266fn 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 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 assert!(
437 md.contains(r"a\|b\|c"),
438 "check-name pipes not escaped: {}",
439 md
440 );
441 assert!(
444 !md.lines().any(|l| l.starts_with("| a|b|c |")),
445 "raw pipe leaked into table row: {}",
446 md
447 );
448 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")); }
508}