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