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:** {}", 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 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
285fn 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
303fn one_line(s: &str) -> String {
306 s.replace("\r\n", " ").replace(['\n', '\r'], " ")
307}
308
309fn 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
329fn 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 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 assert!(
496 md.contains(r"a\|b\|c"),
497 "check-name pipes not escaped: {}",
498 md
499 );
500 assert!(
503 !md.lines().any(|l| l.starts_with("| a|b|c |")),
504 "raw pipe leaked into table row: {}",
505 md
506 );
507 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 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")); }
601}