1use anyhow::Context;
9use perfgate_types::{CompareReceipt, Direction, Metric, MetricStatistic, MetricStatus};
10use serde_json::json;
11
12pub fn render_markdown(compare: &CompareReceipt) -> String {
14 let mut out = String::new();
15
16 let header = match compare.verdict.status {
17 perfgate_types::VerdictStatus::Pass => "✅ perfgate: pass",
18 perfgate_types::VerdictStatus::Warn => "⚠️ perfgate: warn",
19 perfgate_types::VerdictStatus::Fail => "❌ perfgate: fail",
20 perfgate_types::VerdictStatus::Skip => "⏭️ perfgate: skip",
21 };
22
23 out.push_str(header);
24 out.push_str("\n\n");
25
26 out.push_str(&format!("**Bench:** `{}`\n\n", compare.bench.name));
27
28 out.push_str("| metric | baseline (median) | current (median) | delta | budget | status |\n");
29 out.push_str("|---|---:|---:|---:|---:|---|\n");
30
31 for (metric, delta) in &compare.deltas {
32 let budget = compare.budgets.get(metric);
33 let (budget_str, direction_str) = if let Some(b) = budget {
34 (
35 format!("{:.1}%", b.threshold * 100.0),
36 direction_str(b.direction),
37 )
38 } else {
39 ("".to_string(), "")
40 };
41
42 let mut status_icon = metric_status_icon(delta.status).to_string();
43
44 if let (Some(cv), Some(limit)) = (delta.cv, delta.noise_threshold)
46 && cv > limit
47 {
48 status_icon.push_str(" (noisy)");
49 }
50
51 out.push_str(&format!(
52 "| `{metric}` | {b} {u} | {c} {u} | {pct} | {budget} ({dir}) | {status} |\n",
53 metric = format_metric_with_statistic(*metric, delta.statistic),
54 b = format_value(*metric, delta.baseline),
55 c = format_value(*metric, delta.current),
56 u = metric.display_unit(),
57 pct = format_pct(delta.pct),
58 budget = budget_str,
59 dir = direction_str,
60 status = status_icon,
61 ));
62 }
63
64 if !compare.verdict.reasons.is_empty() {
65 out.push_str("\n**Notes:**\n");
66 for r in &compare.verdict.reasons {
67 out.push_str(&render_reason_line(compare, r));
68 }
69 }
70
71 out
72}
73
74pub fn render_markdown_template(
76 compare: &CompareReceipt,
77 template: &str,
78) -> anyhow::Result<String> {
79 let mut handlebars = handlebars::Handlebars::new();
80 handlebars.set_strict_mode(true);
81 handlebars
82 .register_template_string("markdown", template)
83 .context("parse markdown template")?;
84
85 let context = markdown_template_context(compare);
86 handlebars
87 .render("markdown", &context)
88 .context("render markdown template")
89}
90
91pub fn github_annotations(compare: &CompareReceipt) -> Vec<String> {
93 let mut lines = Vec::new();
94
95 for (metric, delta) in &compare.deltas {
96 let prefix = match delta.status {
97 MetricStatus::Fail => "::error",
98 MetricStatus::Warn => "::warning",
99 MetricStatus::Pass | MetricStatus::Skip => continue,
100 };
101
102 let msg = format!(
103 "perfgate {bench} {metric}: {pct} (baseline {b}{u}, current {c}{u})",
104 bench = compare.bench.name,
105 metric = format_metric_with_statistic(*metric, delta.statistic),
106 pct = format_pct(delta.pct),
107 b = format_value(*metric, delta.baseline),
108 c = format_value(*metric, delta.current),
109 u = metric.display_unit(),
110 );
111
112 lines.push(format!("{prefix}::{msg}"));
113 }
114
115 lines
116}
117
118pub fn format_metric(metric: Metric) -> &'static str {
120 metric.as_str()
121}
122
123pub fn format_metric_with_statistic(metric: Metric, statistic: MetricStatistic) -> String {
125 if statistic == MetricStatistic::Median {
126 format_metric(metric).to_string()
127 } else {
128 format!("{} ({})", format_metric(metric), statistic.as_str())
129 }
130}
131
132pub fn markdown_template_context(compare: &CompareReceipt) -> serde_json::Value {
134 let header = match compare.verdict.status {
135 perfgate_types::VerdictStatus::Pass => "✅ perfgate: pass",
136 perfgate_types::VerdictStatus::Warn => "⚠️ perfgate: warn",
137 perfgate_types::VerdictStatus::Fail => "❌ perfgate: fail",
138 perfgate_types::VerdictStatus::Skip => "⏭️ perfgate: skip",
139 };
140
141 let rows: Vec<serde_json::Value> = compare
142 .deltas
143 .iter()
144 .map(|(metric, delta)| {
145 let budget = compare.budgets.get(metric);
146 let (budget_threshold_pct, budget_direction) = budget
147 .map(|b| (b.threshold * 100.0, direction_str(b.direction).to_string()))
148 .unwrap_or((0.0, String::new()));
149
150 json!({
151 "metric": format_metric(*metric),
152 "metric_with_statistic": format_metric_with_statistic(*metric, delta.statistic),
153 "statistic": delta.statistic.as_str(),
154 "baseline": format_value(*metric, delta.baseline),
155 "current": format_value(*metric, delta.current),
156 "unit": metric.display_unit(),
157 "delta_pct": format_pct(delta.pct),
158 "budget_threshold_pct": budget_threshold_pct,
159 "budget_direction": budget_direction,
160 "status": metric_status_str(delta.status),
161 "status_icon": metric_status_icon(delta.status),
162 "raw": {
163 "baseline": delta.baseline,
164 "current": delta.current,
165 "pct": delta.pct,
166 "regression": delta.regression,
167 "statistic": delta.statistic.as_str(),
168 "significance": delta.significance
169 }
170 })
171 })
172 .collect();
173
174 json!({
175 "header": header,
176 "bench": compare.bench,
177 "verdict": compare.verdict,
178 "rows": rows,
179 "reasons": compare.verdict.reasons,
180 "compare": compare
181 })
182}
183
184pub fn parse_reason_token(token: &str) -> Option<(Metric, MetricStatus)> {
186 let (metric_part, status_part) = token.rsplit_once('_')?;
187
188 let status = match status_part {
189 "warn" => MetricStatus::Warn,
190 "fail" => MetricStatus::Fail,
191 "skip" => MetricStatus::Skip,
192 _ => return None,
193 };
194
195 let metric = Metric::parse_key(metric_part)?;
196
197 Some((metric, status))
198}
199
200pub fn render_reason_line(compare: &CompareReceipt, token: &str) -> String {
202 let context = parse_reason_token(token).and_then(|(metric, status)| {
203 compare
204 .deltas
205 .get(&metric)
206 .zip(compare.budgets.get(&metric))
207 .map(|(delta, budget)| (status, delta, budget))
208 });
209
210 if let Some((status, delta, budget)) = context {
211 let pct = format_pct(delta.pct);
212 let warn_pct = budget.warn_threshold * 100.0;
213 let fail_pct = budget.threshold * 100.0;
214
215 return match status {
216 MetricStatus::Warn => {
217 let mut msg =
218 format!("- {token}: {pct} (warn >= {warn_pct:.2}%, fail > {fail_pct:.2}%)");
219 if let (Some(cv), Some(limit)) = (delta.cv, delta.noise_threshold)
220 && cv > limit
221 {
222 msg.push_str(&format!(
223 " [NOISY: CV {:.2}% > limit {:.2}%]",
224 cv * 100.0,
225 limit * 100.0
226 ));
227 }
228 msg.push('\n');
229 msg
230 }
231 MetricStatus::Fail => {
232 format!("- {token}: {pct} (fail > {fail_pct:.2}%)\n")
233 }
234 MetricStatus::Skip => {
235 let mut msg = format!("- {token}: skipped");
236 if let (Some(cv), Some(limit)) = (delta.cv, delta.noise_threshold)
237 && cv > limit
238 {
239 msg.push_str(&format!(
240 " [NOISY: CV {:.2}% > limit {:.2}%]",
241 cv * 100.0,
242 limit * 100.0
243 ));
244 }
245 msg.push('\n');
246 msg
247 }
248 MetricStatus::Pass => String::new(),
249 };
250 }
251
252 format!("- {token}\n")
253}
254
255pub fn format_value(metric: Metric, v: f64) -> String {
257 match metric {
258 Metric::BinaryBytes
259 | Metric::CpuMs
260 | Metric::CtxSwitches
261 | Metric::EnergyUj
262 | Metric::IoReadBytes
263 | Metric::IoWriteBytes
264 | Metric::MaxRssKb
265 | Metric::NetworkPackets
266 | Metric::PageFaults
267 | Metric::WallMs => format!("{:.0}", v),
268 Metric::ThroughputPerS => format!("{:.3}", v),
269 }
270}
271
272pub fn format_pct(pct: f64) -> String {
274 let sign = if pct > 0.0 { "+" } else { "" };
275 format!("{}{:.2}%", sign, pct * 100.0)
276}
277
278pub fn direction_str(direction: Direction) -> &'static str {
280 match direction {
281 Direction::Lower => "lower",
282 Direction::Higher => "higher",
283 }
284}
285
286pub fn metric_status_icon(status: MetricStatus) -> &'static str {
288 match status {
289 MetricStatus::Pass => "✅",
290 MetricStatus::Warn => "⚠️",
291 MetricStatus::Fail => "❌",
292 MetricStatus::Skip => "⏭️",
293 }
294}
295
296pub fn metric_status_str(status: MetricStatus) -> &'static str {
298 match status {
299 MetricStatus::Pass => "pass",
300 MetricStatus::Warn => "warn",
301 MetricStatus::Fail => "fail",
302 MetricStatus::Skip => "skip",
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use perfgate_types::{
310 BenchMeta, Budget, CompareRef, Delta, ToolInfo, Verdict, VerdictCounts, VerdictStatus,
311 };
312 use std::collections::BTreeMap;
313
314 fn make_compare_receipt(status: MetricStatus) -> CompareReceipt {
315 let mut budgets = BTreeMap::new();
316 budgets.insert(Metric::WallMs, Budget::new(0.2, 0.1, Direction::Lower));
317
318 let mut deltas = BTreeMap::new();
319 deltas.insert(
320 Metric::WallMs,
321 Delta {
322 baseline: 100.0,
323 current: 115.0,
324 ratio: 1.15,
325 pct: 0.15,
326 regression: 0.15,
327 statistic: MetricStatistic::Median,
328 significance: None,
329 cv: None,
330 noise_threshold: None,
331 status,
332 },
333 );
334
335 CompareReceipt {
336 schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
337 tool: ToolInfo {
338 name: "perfgate".into(),
339 version: "0.1.0".into(),
340 },
341 bench: BenchMeta {
342 name: "bench".into(),
343 cwd: None,
344 command: vec!["true".into()],
345 repeat: 1,
346 warmup: 0,
347 work_units: None,
348 timeout_ms: None,
349 },
350 baseline_ref: CompareRef {
351 path: None,
352 run_id: None,
353 },
354 current_ref: CompareRef {
355 path: None,
356 run_id: None,
357 },
358 budgets,
359 deltas,
360 verdict: Verdict {
361 status: VerdictStatus::Warn,
362 counts: VerdictCounts {
363 pass: 0,
364 warn: 1,
365 fail: 0,
366 skip: 0,
367 },
368 reasons: vec!["wall_ms_warn".to_string()],
369 },
370 }
371 }
372
373 #[test]
374 fn markdown_renders_table() {
375 let receipt = make_compare_receipt(MetricStatus::Pass);
376 let md = render_markdown(&receipt);
377 assert!(md.contains("| metric | baseline"));
378 assert!(md.contains("wall_ms"));
379 }
380
381 #[test]
382 fn markdown_template_renders_context_rows() {
383 let compare = make_compare_receipt(MetricStatus::Warn);
384 let template = "{{header}}\nbench={{bench.name}}\n{{#each rows}}metric={{metric}} status={{status}}\n{{/each}}";
385
386 let rendered = render_markdown_template(&compare, template).expect("render template");
387 assert!(rendered.contains("bench=bench"));
388 assert!(rendered.contains("metric=wall_ms"));
389 assert!(rendered.contains("status=warn"));
390 }
391
392 #[test]
393 fn parse_reason_token_handles_valid_and_invalid() {
394 let parsed = parse_reason_token("wall_ms_warn");
395 assert!(parsed.is_some());
396 let (metric, status) = parsed.unwrap();
397 assert_eq!(metric, Metric::WallMs);
398 assert_eq!(status, MetricStatus::Warn);
399
400 assert!(parse_reason_token("wall_ms_pass").is_none());
401 assert!(parse_reason_token("unknown_warn").is_none());
402 }
403
404 #[test]
405 fn github_annotations_only_warn_and_fail() {
406 let mut compare = make_compare_receipt(MetricStatus::Warn);
407 compare.deltas.insert(
408 Metric::MaxRssKb,
409 Delta {
410 baseline: 100.0,
411 current: 150.0,
412 ratio: 1.5,
413 pct: 0.5,
414 regression: 0.5,
415 statistic: MetricStatistic::Median,
416 significance: None,
417 cv: None,
418 noise_threshold: None,
419 status: MetricStatus::Fail,
420 },
421 );
422
423 let lines = github_annotations(&compare);
424 assert_eq!(lines.len(), 2);
425 assert!(lines.iter().any(|l| l.starts_with("::warning::")));
426 assert!(lines.iter().any(|l| l.starts_with("::error::")));
427 }
428}