use crate::{ config::{Benchmark, Gate}, results::{display_metrics, Comparison}, }; use std::collections::BTreeSet; const COMMENT_MARKER: &str = ""; pub(super) fn render_report(comparisons: &[Comparison]) -> String { let regressions = comparisons.iter().filter(|item| item.regressed()).count(); let warnings = report_warnings(comparisons); let mut lines = vec![ COMMENT_MARKER.to_string(), "## Benchmark results".to_string(), String::new(), format!("Regressions: `{regressions}`."), ]; if !warnings.is_empty() { lines.extend([String::new(), "> [!WARNING]".to_string(), ">".to_string()]); lines.extend(warnings.into_iter().map(|warning| format!("> {warning}"))); } for item in comparisons { lines.extend([ String::new(), format!("
{}", summary_line(item)), String::new(), ]); render_metadata(&mut lines, &item.current.benchmark); render_metrics(&mut lines, item); lines.extend([String::new(), "
".to_string()]); } let commits = comparisons .iter() .filter_map(|item| item.baseline.as_ref()) .filter(|baseline| !baseline.commit.is_empty()) .map(|baseline| baseline.commit.chars().take(12).collect::()) .collect::>(); if !commits.is_empty() { lines.extend([ String::new(), format!( "Baseline commit(s): `{}`", commits.into_iter().collect::>().join(", ") ), ]); } lines.push(String::new()); lines.join("\n") } fn report_warnings(comparisons: &[Comparison]) -> Vec { let mut warnings = Vec::new(); for item in comparisons { let Some(baseline) = &item.baseline else { warnings.push(format!( "Missing baseline for `{}`.", item.current.benchmark.name )); continue; }; for gate in &item.current.benchmark.gates { if !baseline.metrics.contains_key(&gate.label()) { warnings.push(format!( "Baseline for `{}` is missing `{}`; that gate was not evaluated.", item.current.benchmark.name, gate.label() )); } } } warnings } fn summary_line(item: &Comparison) -> String { let name = format!("`{}`", escape_summary(&item.current.benchmark.name)); if item.baseline.is_none() { return format!("⚠️ {name} (baseline missing)"); } let status = if item.regressed() { "❌" } else { "✅" }; let gates = &item.current.benchmark.gates; let gate_summary = if gates.len() == 1 { let gate = &gates[0]; let current = format_count(item.current.metrics[&gate.label()]); let delta = item .deltas .get(&gate.label()) .map_or_else(|| "n/a".to_string(), |delta| format_delta(*delta)); format!( "{}: {current}, delta: {delta}, {}", gate.label(), gate.description() ) } else { let failed = item.gate_failures().len(); format!("{}/{} gates passed", gates.len() - failed, gates.len()) }; format!("{status} {name} ({gate_summary})") } fn render_metadata(lines: &mut Vec, benchmark: &Benchmark) { let mut rows = vec![ ("Package", benchmark.package.clone()), ("Benchmark target", benchmark.target.clone()), ("Variant", benchmark.name.clone()), ("Filter", benchmark.filter.clone()), ("Baseline suite", benchmark.baseline_suite.clone()), ( "Gates", benchmark .gates .iter() .map(Gate::description) .collect::>() .join("; "), ), ]; if !benchmark.cargo_flags.is_empty() { rows.push(("Cargo flags", benchmark.cargo_flags.join(" "))); } lines.extend(["| Field | Value |".to_string(), "|---|---|".to_string()]); for (key, value) in rows { lines.push(format!("| {key} | `{}` |", escape_cell(&value))); } } fn render_metrics(lines: &mut Vec, item: &Comparison) { if let Some(baseline) = &item.baseline { lines.extend([ String::new(), "| Metric | Baseline | Current | Delta | Gate |".to_string(), "|---|---:|---:|---:|---|".to_string(), ]); for metric in display_metrics(&item.current, Some(baseline)) { let baseline_value = baseline .metrics .get(&metric) .map_or_else(|| "n/a".to_string(), |value| format_count(*value)); let current_value = item .current .metrics .get(&metric) .map_or_else(|| "n/a".to_string(), |value| format_count(*value)); let delta = item .deltas .get(&metric) .map_or_else(|| "n/a".to_string(), |value| format_delta(*value)); lines.push(format!( "| `{metric}` | {baseline_value} | {current_value} | {delta} | {} |", gate_text(&item.current.benchmark, &metric) )); } return; } lines.extend([ String::new(), "| Metric | Current | Gate |".to_string(), "|---|---:|---|".to_string(), ]); for metric in display_metrics(&item.current, None) { let Some(value) = item.current.metrics.get(&metric) else { continue; }; lines.push(format!( "| `{metric}` | {} | {} |", format_count(*value), gate_text(&item.current.benchmark, &metric) )); } } fn gate_text(benchmark: &Benchmark, metric: &str) -> String { let gates = benchmark .gates .iter() .filter(|gate| gate.label() == metric) .map(Gate::description) .collect::>() .join("
"); if gates.is_empty() { return "-".to_string(); } gates } fn format_count(value: u64) -> String { let text = value.to_string(); let mut out = String::new(); for (index, ch) in text.chars().rev().enumerate() { if index != 0 && index % 3 == 0 { out.push(','); } out.push(ch); } out.chars().rev().collect() } fn format_delta(value: f64) -> String { format!("{value:+.2}%") } fn escape_cell(value: &str) -> String { value.replace('|', "\\|").replace('\n', "
") } fn escape_summary(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('\n', " ") }