1pub mod report;
11
12use std::path::{Path, PathBuf};
13use std::process::Stdio;
14
15use eyre::{Result, WrapErr as _, bail};
16
17use waterui_preview_protocol::bench::{
18 BENCH_MAX_CLIP_LAYERS_ENV, BENCH_MAX_GPU_SURFACE_LAYERS_ENV, BENCH_MAX_MEAN_US_ENV,
19 BENCH_MAX_P95_US_ENV, BENCH_MAX_REBUILD_RATIO_ENV, BENCH_MAX_SCENE_LAYERS_ENV,
20 BENCH_REPETITIONS_ENV, BENCH_REPORT_DIR_ENV, BENCH_SAMPLES_ENV, BENCH_WARMUPS_ENV,
21 BenchBudgets, BenchReport, BenchRunConfig,
22};
23
24const BENCH_TEST_PREFIX: &str = "waterui_bench_";
27
28fn absolute_path(path: &Path) -> Result<PathBuf> {
32 if path.is_absolute() {
33 return Ok(path.to_path_buf());
34 }
35 let cwd = std::env::current_dir().wrap_err("failed to resolve the current directory")?;
36 Ok(cwd.join(path))
37}
38
39#[derive(Debug, Clone)]
41pub struct BenchRunOptions {
42 pub path: PathBuf,
44 pub filter: Option<String>,
46 pub config: BenchRunConfig,
48 pub report_dir: Option<PathBuf>,
51 pub budget_caps: BenchBudgets,
54}
55
56#[derive(Debug)]
58pub struct BenchSuiteRun {
59 pub reports: Vec<BenchReport>,
61 pub nextest_succeeded: bool,
64}
65
66pub async fn run_bench_suite(options: BenchRunOptions) -> Result<BenchSuiteRun> {
75 ensure_nextest_installed().await?;
76
77 let _temp_dir;
79 let report_dir = if let Some(dir) = &options.report_dir {
80 let dir = absolute_path(dir)?;
87 smol::fs::create_dir_all(&dir)
88 .await
89 .wrap_err_with(|| format!("failed to create report directory {}", dir.display()))?;
90 clear_stale_reports(&dir).await?;
91 dir
92 } else {
93 let temp_dir = tempfile::Builder::new()
94 .prefix("waterui-bench-")
95 .tempdir()
96 .wrap_err("failed to create temporary bench report directory")?;
97 let path = temp_dir.path().to_path_buf();
98 _temp_dir = temp_dir;
99 path
100 };
101
102 let mut command = smol::process::Command::new("cargo");
103 command
104 .arg("nextest")
105 .arg("run")
106 .arg("--no-fail-fast")
107 .arg("-E")
108 .arg(nextest_filter_expression(options.filter.as_deref()))
109 .current_dir(&options.path)
110 .env(BENCH_WARMUPS_ENV, options.config.warmups.to_string())
111 .env(BENCH_SAMPLES_ENV, options.config.samples.to_string())
112 .env(
113 BENCH_REPETITIONS_ENV,
114 options.config.repetitions.to_string(),
115 )
116 .env(BENCH_REPORT_DIR_ENV, &report_dir)
117 .stdout(Stdio::inherit())
118 .stderr(Stdio::inherit())
119 .kill_on_drop(true);
120 apply_budget_cap_envs(&mut command, options.budget_caps);
121
122 let status = command
123 .status()
124 .await
125 .wrap_err("failed to run `cargo nextest`")?;
126
127 let reports = collect_reports(&report_dir).await?;
128 if reports.is_empty() && status.success() {
129 bail!(
130 "no `#[waterui::bench]` reports were produced under {}; \
131 the crate has no benches{}",
132 options.path.display(),
133 options
134 .filter
135 .as_deref()
136 .map(|filter| format!(" matching `{filter}`"))
137 .unwrap_or_default()
138 );
139 }
140
141 Ok(BenchSuiteRun {
142 reports,
143 nextest_succeeded: status.success(),
144 })
145}
146
147async fn ensure_nextest_installed() -> Result<()> {
149 let probe = smol::process::Command::new("cargo")
150 .arg("nextest")
151 .arg("--version")
152 .stdout(Stdio::null())
153 .stderr(Stdio::null())
154 .kill_on_drop(true)
155 .status()
156 .await;
157 match probe {
158 Ok(status) if status.success() => Ok(()),
159 _ => bail!(
160 "`water bench` requires cargo-nextest. Install it with: cargo install cargo-nextest --locked"
161 ),
162 }
163}
164
165fn nextest_filter_expression(filter: Option<&str>) -> String {
168 filter.map_or_else(
169 || format!("test({BENCH_TEST_PREFIX})"),
170 |filter| format!("test({BENCH_TEST_PREFIX}) & test({filter})"),
171 )
172}
173
174fn apply_budget_cap_envs(command: &mut smol::process::Command, caps: BenchBudgets) {
175 let mut set = |name: &str, value: Option<String>| {
176 if let Some(value) = value {
177 command.env(name, value);
178 }
179 };
180 set(BENCH_MAX_P95_US_ENV, caps.max_p95_us.map(|v| v.to_string()));
181 set(
182 BENCH_MAX_MEAN_US_ENV,
183 caps.max_mean_us.map(|v| v.to_string()),
184 );
185 set(
186 BENCH_MAX_REBUILD_RATIO_ENV,
187 caps.max_rebuild_ratio.map(|v| v.to_string()),
188 );
189 set(
190 BENCH_MAX_SCENE_LAYERS_ENV,
191 caps.max_scene_layers.map(|v| v.to_string()),
192 );
193 set(
194 BENCH_MAX_GPU_SURFACE_LAYERS_ENV,
195 caps.max_gpu_surface_layers.map(|v| v.to_string()),
196 );
197 set(
198 BENCH_MAX_CLIP_LAYERS_ENV,
199 caps.max_clip_layers.map(|v| v.to_string()),
200 );
201}
202
203async fn clear_stale_reports(dir: &Path) -> Result<()> {
206 for path in report_files(dir)? {
207 smol::fs::remove_file(&path)
208 .await
209 .wrap_err_with(|| format!("failed to remove stale bench report {}", path.display()))?;
210 }
211 Ok(())
212}
213
214async fn collect_reports(dir: &Path) -> Result<Vec<BenchReport>> {
215 let mut reports = Vec::new();
216 for path in report_files(dir)? {
217 let raw = smol::fs::read(&path)
218 .await
219 .wrap_err_with(|| format!("failed to read bench report {}", path.display()))?;
220 let report: BenchReport = serde_json::from_slice(&raw)
221 .wrap_err_with(|| format!("failed to parse bench report {}", path.display()))?;
222 reports.push(report);
223 }
224 reports.sort_by(|a, b| {
225 (a.crate_name.as_str(), a.bench_name.as_str())
226 .cmp(&(b.crate_name.as_str(), b.bench_name.as_str()))
227 });
228 Ok(reports)
229}
230
231fn report_files(dir: &Path) -> Result<Vec<PathBuf>> {
232 let entries = std::fs::read_dir(dir)
233 .wrap_err_with(|| format!("failed to list bench report directory {}", dir.display()))?;
234 let mut files = Vec::new();
235 for entry in entries {
236 let path = entry?.path();
237 if path
238 .extension()
239 .is_some_and(|extension| extension == "json")
240 {
241 files.push(path);
242 }
243 }
244 Ok(files)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::nextest_filter_expression;
250
251 #[test]
252 fn bench_filter_selects_prefix_only_by_default() {
253 assert_eq!(nextest_filter_expression(None), "test(waterui_bench_)");
254 }
255
256 #[test]
257 fn bench_filter_intersects_user_substring() {
258 assert_eq!(
259 nextest_filter_expression(Some("scroll")),
260 "test(waterui_bench_) & test(scroll)"
261 );
262 }
263}