Skip to main content

waterui_cli/bench/
mod.rs

1//! `water bench` engine.
2//!
3//! Runs every `#[waterui::bench]` in a crate under `cargo nextest` in
4//! full-measurement mode (the `WATERUI_BENCH_*` environment selects the run
5//! shape), collects the JSON reports the benches write, and hands them to
6//! [`report`] for rendering. Budget evaluation happens inside
7//! `waterui-testing` while the benches run — a blown budget is a failed test,
8//! which surfaces here as a failed nextest run.
9
10pub 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
24/// Test-name prefix `#[waterui::bench]` expands to; the discovery contract
25/// between the macro and this runner.
26const BENCH_TEST_PREFIX: &str = "waterui_bench_";
27
28/// Resolves a path against the current directory without requiring it to exist.
29///
30/// `canonicalize` is unusable here: the directory is created after this point.
31fn 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/// One `water bench` invocation.
40#[derive(Debug, Clone)]
41pub struct BenchRunOptions {
42    /// Directory of the project or crate whose benches run.
43    pub path: PathBuf,
44    /// Substring narrowing which benches run, matched against the test name.
45    pub filter: Option<String>,
46    /// Frame-run shape exported to the benches.
47    pub config: BenchRunConfig,
48    /// Directory receiving the per-bench report JSON files; a temporary
49    /// directory is used when unset.
50    pub report_dir: Option<PathBuf>,
51    /// Budget caps exported to the benches; each is merged against the
52    /// attribute budgets with the tighter limit winning.
53    pub budget_caps: BenchBudgets,
54}
55
56/// Outcome of one bench suite run.
57#[derive(Debug)]
58pub struct BenchSuiteRun {
59    /// Collected reports, sorted by crate then bench name.
60    pub reports: Vec<BenchReport>,
61    /// Whether the underlying nextest run succeeded (budget violations and
62    /// panicking benches fail it).
63    pub nextest_succeeded: bool,
64}
65
66/// Runs the crate's benches under `cargo nextest` and collects their reports.
67///
68/// nextest inherits the terminal, so build and per-test progress stream
69/// directly to the user.
70///
71/// # Errors
72/// Returns an error when `cargo-nextest` is missing, the run cannot be
73/// spawned, or the collected reports cannot be read.
74pub async fn run_bench_suite(options: BenchRunOptions) -> Result<BenchSuiteRun> {
75    ensure_nextest_installed().await?;
76
77    // Held so a temporary report directory outlives collection.
78    let _temp_dir;
79    let report_dir = if let Some(dir) = &options.report_dir {
80        // Absolute, and deliberately so: this path is both created and read
81        // here, in the CLI's working directory, but it is handed to a nextest
82        // process whose working directory is the bench crate. A relative path
83        // therefore names two different directories on the two sides — the
84        // benches write their reports under the crate, and collection then
85        // finds nothing and reports the crate as having no benches at all.
86        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
147/// Fails fast with an install hint when `cargo-nextest` is unavailable.
148async 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
165/// Builds the nextest filter expression selecting bench tests, optionally
166/// narrowed by a user substring.
167fn 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
203/// Removes report files left by a previous run so stale benches are never
204/// aggregated into this run's output.
205async 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}