criterion_markdown/
lib.rs1use std::path::{Path, PathBuf};
30
31use anyhow::Result;
32
33mod discovery;
34mod markdown;
35mod model;
36
37#[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 pub fn improvement_ratio(mut self, ratio: f64) -> Self {
51 self.improvement_ratio = ratio;
52 self
53 }
54
55 pub fn strong_improvement_ratio(mut self, ratio: f64) -> Self {
57 self.strong_improvement_ratio = ratio;
58 self
59 }
60
61 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#[derive(Debug, Clone)]
100pub struct RenderOptions {
101 pub title: String,
103 pub collapsible: bool,
105 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#[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 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 pub fn candidate(mut self, candidate: impl AsRef<str>) -> Self {
154 self.candidate = candidate.as_ref().to_string();
155 self
156 }
157
158 pub fn baseline(mut self, baseline: impl AsRef<str>) -> Self {
164 self.baseline = Some(baseline.as_ref().to_string());
165 self
166 }
167
168 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 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 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 pub fn title(mut self, title: impl AsRef<str>) -> Self {
196 self.title = title.as_ref().to_string();
197 self
198 }
199
200 pub fn collapsible(mut self, collapsible: bool) -> Self {
202 self.collapsible = collapsible;
203 self
204 }
205
206 pub fn change_thresholds(mut self, thresholds: ChangeThresholds) -> Self {
208 self.change_thresholds = thresholds;
209 self
210 }
211
212 pub fn summary_limit(mut self, limit: usize) -> Self {
215 self.summary_limit = limit;
216 self
217 }
218
219 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
260pub 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
273pub 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}