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.
106    ///
107    /// When unset, the renderer looks for Criterion's default `base` dataset.
108    pub baseline: Option<String>,
109}
110
111impl Default for RenderOptions {
112    fn default() -> Self {
113        Self {
114            title: "Benchmarks".to_string(),
115            collapsible: false,
116            baseline: None,
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: Option<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: None,
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.
159    ///
160    /// If not selected, the renderer uses Criterion's default `base` dataset
161    /// when it exists and otherwise omits comparison information. Rendering
162    /// fails if an explicitly selected baseline cannot be found.
163    pub fn baseline(mut self, baseline: impl AsRef<str>) -> Self {
164        self.baseline = Some(baseline.as_ref().to_string());
165        self
166    }
167
168    /// Selects the Criterion output root containing the baseline.
169    ///
170    /// Defaults to the candidate's Criterion output directory.
171    pub fn baseline_root(mut self, baseline_root: impl AsRef<Path>) -> Self {
172        self.baseline_root = baseline_root.as_ref().to_path_buf();
173        self
174    }
175
176    /// Selects a benchmark by its full id.
177    ///
178    /// If no benchmarks are selected explicitly, all benchmarks are rendered.
179    pub fn benchmark(mut self, entry: impl AsRef<str>) -> Self {
180        self.included_entries.push(entry.as_ref().to_string());
181        self
182    }
183
184    /// Selects benchmarks by their full ids.
185    ///
186    /// Repeated calls are additive. If no benchmarks are selected explicitly,
187    /// all benchmarks are rendered.
188    pub fn benchmarks(mut self, entries: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
189        self.included_entries
190            .extend(entries.into_iter().map(|entry| entry.as_ref().to_string()));
191        self
192    }
193
194    /// Sets the report title. Defaults to `Benchmarks`.
195    pub fn title(mut self, title: impl AsRef<str>) -> Self {
196        self.title = title.as_ref().to_string();
197        self
198    }
199
200    /// Controls whether the output is wrapped in a `<details>` element.
201    pub fn collapsible(mut self, collapsible: bool) -> Self {
202        self.collapsible = collapsible;
203        self
204    }
205
206    /// Configures the ratios used to select change indicators.
207    pub fn change_thresholds(mut self, thresholds: ChangeThresholds) -> Self {
208        self.change_thresholds = thresholds;
209        self
210    }
211
212    /// Sets the maximum number of improvements and regressions shown in the summary.
213    /// Defaults to `3`. A limit of `0` omits the summary.
214    pub fn summary_limit(mut self, limit: usize) -> Self {
215        self.summary_limit = limit;
216        self
217    }
218
219    /// Loads the configured results and renders them as markdown.
220    pub fn render(&self) -> Result<String> {
221        self.change_thresholds.validate()?;
222        let mut entries = discovery::discover_benchmarks(
223            &self.criterion_dir,
224            &self.candidate,
225            &self.baseline_root,
226            self.baseline.as_deref(),
227        )?;
228        if !self.included_entries.is_empty() {
229            entries.retain(|entry| self.included_entries.contains(&entry.full_id));
230        }
231        if entries.is_empty() {
232            anyhow::bail!(
233                "No benchmark results found in {}",
234                self.criterion_dir.display()
235            );
236        }
237        let summary =
238            markdown::compute_summary(&entries, self.change_thresholds, self.summary_limit);
239        let body = markdown::format_table(
240            &entries,
241            &self.title,
242            self.collapsible,
243            self.change_thresholds,
244            &summary,
245        );
246        if !self.collapsible {
247            return Ok(body);
248        }
249        let headline = summary
250            .headline()
251            .map(|headline| format!(" ({headline})"))
252            .unwrap_or_default();
253        Ok(format!(
254            "<details>\n<summary>{}{headline}</summary>\n\n{body}\n</details>\n",
255            self.title
256        ))
257    }
258}
259
260/// Reads all benchmark results from the given criterion output directory
261/// and renders a markdown table.
262///
263/// `allowlist` filters benchmarks by `full_id`.
264///
265/// If the iterator is empty, no filtering is applied.
266pub fn render(
267    criterion_dir: impl AsRef<Path>,
268    allowlist: impl IntoIterator<Item = impl AsRef<str>>,
269) -> Result<String> {
270    Renderer::new(criterion_dir).benchmarks(allowlist).render()
271}
272
273/// Like [`render`], but accepts additional [`RenderOptions`] to control output.
274pub fn render_with_options(
275    criterion_dir: impl AsRef<Path>,
276    allowlist: impl IntoIterator<Item = impl AsRef<str>>,
277    options: &RenderOptions,
278) -> Result<String> {
279    let mut renderer = Renderer::new(criterion_dir)
280        .benchmarks(allowlist)
281        .title(&options.title)
282        .collapsible(options.collapsible);
283    if let Some(baseline) = &options.baseline {
284        renderer = renderer.baseline(baseline);
285    }
286    renderer.render()
287}