Skip to main content

perfgate_render/
lib.rs

1//! Rendering utilities for perfgate output.
2//!
3//! This crate provides functions for rendering performance comparison results
4//! as markdown tables and GitHub Actions annotations.
5//!
6//! Part of the [perfgate](https://github.com/EffortlessMetrics/perfgate) workspace.
7
8use anyhow::Context;
9use perfgate_types::{CompareReceipt, Direction, Metric, MetricStatistic, MetricStatus};
10use serde_json::json;
11
12/// Render a [`CompareReceipt`] as a Markdown table for PR comments.
13pub 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 noisy, append noise info
45        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
74/// Render a [`CompareReceipt`] using a custom [Handlebars](https://docs.rs/handlebars) template.
75pub 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
91/// Produce GitHub Actions annotation strings from a [`CompareReceipt`].
92pub 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
118/// Return the canonical string key for a [`Metric`].
119pub fn format_metric(metric: Metric) -> &'static str {
120    metric.as_str()
121}
122
123/// Format a metric key, appending the statistic name when it is not the default (median).
124pub 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
132/// Build the JSON context object used by [`render_markdown_template`].
133pub 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
184/// Parse a verdict reason token like `"wall_ms_warn"` into its metric and status.
185pub 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
200/// Render a single verdict reason token as a human-readable bullet line.
201pub 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
255/// Format a metric value for display.
256pub 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
272/// Format a fractional change as a percentage string.
273pub fn format_pct(pct: f64) -> String {
274    let sign = if pct > 0.0 { "+" } else { "" };
275    format!("{}{:.2}%", sign, pct * 100.0)
276}
277
278/// Return a human-readable label for a budget [`Direction`].
279pub fn direction_str(direction: Direction) -> &'static str {
280    match direction {
281        Direction::Lower => "lower",
282        Direction::Higher => "higher",
283    }
284}
285
286/// Return an emoji icon for a [`MetricStatus`].
287pub 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
296/// Return a lowercase string label for a [`MetricStatus`].
297pub 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}