Skip to main content

criterion_markdown/
lib.rs

1//! Reads criterion benchmark results from `target/criterion/` JSON files and
2//! renders a markdown table similar to criterion-table.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use criterion_markdown::{Emojis, Renderer, Thresholds};
8//!
9//! fn main() -> anyhow::Result<()> {
10//!     let thresholds = Thresholds::default()
11//!         .improvement_ratio(1.1)
12//!         .strong_improvement_ratio(1.5)
13//!         .regression_ratio(0.95);
14//!     let emojis = Emojis::default().strong_improvement("🔥");
15//!     let markdown = Renderer::new("target/criterion")
16//!         .candidate("new")
17//!         .baseline_root("artifacts/criterion")
18//!         .baseline("main")
19//!         .benchmarks(["group/benchmark/1", "group/benchmark/2"])
20//!         .thresholds(thresholds)
21//!         .emojis(emojis)
22//!         .summary_limit(5)
23//!         .title("Benchmark Results")
24//!         .collapsible(true)
25//!         .render()?;
26//!     println!("{markdown}");
27//!     Ok(())
28//! }
29//! ```
30
31use std::path::{Path, PathBuf};
32
33use anyhow::Result;
34
35mod discovery;
36mod markdown;
37mod model;
38
39/// Ratio thresholds controlling change indicators.
40///
41/// Ratios are calculated as `baseline time / candidate time` and classified as
42/// regression, neutral, improvement, or strong improvement.
43#[derive(Debug, Clone, Copy)]
44pub struct Thresholds {
45    improvement_ratio: f64,
46    strong_improvement_ratio: f64,
47    regression_ratio: f64,
48}
49
50impl Thresholds {
51    /// Sets the minimum ratio rendered as an improvement. Defaults to `1.1`.
52    pub fn improvement_ratio(mut self, ratio: f64) -> Self {
53        self.improvement_ratio = ratio;
54        self
55    }
56
57    /// Sets the minimum ratio rendered as a strong improvement. Defaults to `1.8`.
58    pub fn strong_improvement_ratio(mut self, ratio: f64) -> Self {
59        self.strong_improvement_ratio = ratio;
60        self
61    }
62
63    /// Sets the maximum ratio rendered as a regression. Defaults to `0.9`.
64    pub fn regression_ratio(mut self, ratio: f64) -> Self {
65        self.regression_ratio = ratio;
66        self
67    }
68
69    fn validate(self) -> Result<()> {
70        if !self.improvement_ratio.is_finite() || self.improvement_ratio < 1.0 {
71            anyhow::bail!("improvement ratio must be finite and at least 1.0");
72        }
73        if !self.strong_improvement_ratio.is_finite()
74            || self.strong_improvement_ratio < self.improvement_ratio
75        {
76            anyhow::bail!(
77                "strong improvement ratio must be finite and at least the improvement ratio"
78            );
79        }
80        if !self.regression_ratio.is_finite()
81            || self.regression_ratio <= 0.0
82            || self.regression_ratio > 1.0
83        {
84            anyhow::bail!("regression ratio must be finite, positive, and at most 1.0");
85        }
86        Ok(())
87    }
88}
89
90impl Default for Thresholds {
91    fn default() -> Self {
92        Self {
93            improvement_ratio: 1.1,
94            strong_improvement_ratio: 1.8,
95            regression_ratio: 0.9,
96        }
97    }
98}
99
100/// Emojis used to represent benchmark change classifications.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Emojis {
103    pub(crate) regression: String,
104    pub(crate) stable: String,
105    pub(crate) improvement: String,
106    pub(crate) strong_improvement: String,
107}
108
109impl Emojis {
110    /// Sets the emoji rendered for regressions. Defaults to `❌`.
111    pub fn regression(mut self, emoji: impl AsRef<str>) -> Self {
112        self.regression = emoji.as_ref().to_string();
113        self
114    }
115
116    /// Sets the emoji rendered for stable results. Defaults to `➖`.
117    pub fn stable(mut self, emoji: impl AsRef<str>) -> Self {
118        self.stable = emoji.as_ref().to_string();
119        self
120    }
121
122    /// Sets the emoji rendered for improvements. Defaults to `↗️`.
123    pub fn improvement(mut self, emoji: impl AsRef<str>) -> Self {
124        self.improvement = emoji.as_ref().to_string();
125        self
126    }
127
128    /// Sets the emoji rendered for strong improvements. Defaults to `🚀`.
129    pub fn strong_improvement(mut self, emoji: impl AsRef<str>) -> Self {
130        self.strong_improvement = emoji.as_ref().to_string();
131        self
132    }
133}
134
135impl Default for Emojis {
136    fn default() -> Self {
137        Self {
138            regression: "❌".to_string(),
139            stable: "➖".to_string(),
140            improvement: "↗️".to_string(),
141            strong_improvement: "🚀".to_string(),
142        }
143    }
144}
145
146/// Builder for loading Criterion results and rendering them as markdown.
147#[derive(Debug, Clone)]
148pub struct Renderer {
149    criterion_dir: PathBuf,
150    baseline_root: PathBuf,
151    candidate: String,
152    baseline: Option<String>,
153    included_entries: Vec<String>,
154    title: String,
155    collapsible: bool,
156    thresholds: Thresholds,
157    emojis: Emojis,
158    summary_limit: usize,
159}
160
161impl Renderer {
162    /// Creates a renderer for a Criterion output directory.
163    pub fn new(criterion_dir: impl AsRef<Path>) -> Self {
164        let criterion_dir = criterion_dir.as_ref().to_path_buf();
165        Self {
166            baseline_root: criterion_dir.clone(),
167            criterion_dir,
168            candidate: "new".to_string(),
169            baseline: None,
170            included_entries: Vec::new(),
171            title: "Benchmarks".to_string(),
172            collapsible: false,
173            thresholds: Thresholds::default(),
174            emojis: Emojis::default(),
175            summary_limit: 3,
176        }
177    }
178
179    /// Selects the dataset to render. Defaults to `new`.
180    pub fn candidate(mut self, candidate: impl AsRef<str>) -> Self {
181        self.candidate = candidate.as_ref().to_string();
182        self
183    }
184
185    /// Selects the baseline used to compute changes.
186    ///
187    /// If not selected, the renderer uses Criterion's default `base` dataset
188    /// when it exists and otherwise omits comparison information. Rendering
189    /// fails if an explicitly selected baseline cannot be found.
190    pub fn baseline(mut self, baseline: impl AsRef<str>) -> Self {
191        self.baseline = Some(baseline.as_ref().to_string());
192        self
193    }
194
195    /// Selects the Criterion output root containing the baseline.
196    ///
197    /// Defaults to the candidate's Criterion output directory.
198    pub fn baseline_root(mut self, baseline_root: impl AsRef<Path>) -> Self {
199        self.baseline_root = baseline_root.as_ref().to_path_buf();
200        self
201    }
202
203    /// Selects a benchmark by its full id.
204    ///
205    /// If no benchmarks are selected explicitly, all benchmarks are rendered.
206    pub fn benchmark(mut self, entry: impl AsRef<str>) -> Self {
207        self.included_entries.push(entry.as_ref().to_string());
208        self
209    }
210
211    /// Selects benchmarks by their full ids.
212    ///
213    /// Repeated calls are additive. If no benchmarks are selected explicitly,
214    /// all benchmarks are rendered.
215    pub fn benchmarks(mut self, entries: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
216        self.included_entries
217            .extend(entries.into_iter().map(|entry| entry.as_ref().to_string()));
218        self
219    }
220
221    /// Sets the report title. Defaults to `Benchmarks`.
222    pub fn title(mut self, title: impl AsRef<str>) -> Self {
223        self.title = title.as_ref().to_string();
224        self
225    }
226
227    /// Controls whether the output is wrapped in a `<details>` element.
228    pub fn collapsible(mut self, collapsible: bool) -> Self {
229        self.collapsible = collapsible;
230        self
231    }
232
233    /// Configures the ratios used to select change indicators.
234    pub fn thresholds(mut self, thresholds: Thresholds) -> Self {
235        self.thresholds = thresholds;
236        self
237    }
238
239    /// Configures the emojis used for benchmark change classifications.
240    pub fn emojis(mut self, emojis: Emojis) -> Self {
241        self.emojis = emojis;
242        self
243    }
244
245    /// Sets the maximum number of improvements and regressions shown in the summary.
246    /// Defaults to `3`. A limit of `0` omits the summary.
247    pub fn summary_limit(mut self, limit: usize) -> Self {
248        self.summary_limit = limit;
249        self
250    }
251
252    /// Loads the configured results and renders them as markdown.
253    pub fn render(&self) -> Result<String> {
254        self.thresholds.validate()?;
255        let mut entries = discovery::discover_benchmarks(
256            &self.criterion_dir,
257            &self.candidate,
258            &self.baseline_root,
259            self.baseline.as_deref(),
260        )?;
261        if !self.included_entries.is_empty() {
262            entries.retain(|entry| self.included_entries.contains(&entry.full_id));
263        }
264        if entries.is_empty() {
265            anyhow::bail!(
266                "No benchmark results found in {}",
267                self.criterion_dir.display()
268            );
269        }
270        let summary =
271            markdown::compute_summary(&entries, self.thresholds, &self.emojis, self.summary_limit);
272        let body = markdown::format_table(
273            &entries,
274            &self.title,
275            self.collapsible,
276            self.thresholds,
277            &self.emojis,
278            &summary,
279        );
280        if !self.collapsible {
281            return Ok(body);
282        }
283        let headline = summary
284            .headline(&self.emojis)
285            .map(|headline| format!(" ({headline})"))
286            .unwrap_or_default();
287        Ok(format!(
288            "<details>\n<summary>{}{headline}</summary>\n\n{body}\n</details>\n",
289            self.title
290        ))
291    }
292}