Skip to main content

whisker_cli/
probe.rs

1//! Run a tiny probe binary that includes the user's `whisker.rs` and
2//! emits the resulting `Config` as JSON on stdout.
3//!
4//! ## Why a probe binary?
5//!
6//! `whisker.rs` is regular Rust source — there's no parser we could
7//! point at the file. The user's `configure` function can do
8//! arbitrary computation (env lookups, conditional fields, etc.), so
9//! the only way to know what it *means* is to execute it.
10//!
11//! The probe is a one-file `cargo run` that:
12//!   1. `include!("path/to/whisker.rs")` — splices the user's
13//!      `configure` fn into our tiny `main`.
14//!   2. Calls `configure(&mut Config::default())`.
15//!   3. Serializes the populated config to JSON and prints it.
16//!
17//! Compile cost is single-digit seconds the first time (just
18//! `whisker-config` + `serde_json`), and the result is cached
19//! under `target/.whisker/config-cache.json`. The cache is
20//! invalidated by mtime: re-running the probe is a no-op unless
21//! `whisker.rs` was touched since the cache write.
22//!
23//! ## Why not include the umbrella `whisker` crate?
24//!
25//! `whisker-config` is intentionally a small, dependency-light
26//! crate so the probe build is cheap. Pulling in `whisker`
27//! (umbrella) would also pull `whisker-runtime`, `whisker-driver`,
28//! Lynx headers, etc. — turning the probe into a multi-minute build.
29//! The user crate's `whisker.rs` therefore writes
30//! `use whisker_config::Config` directly, not `use
31//! whisker::Config`.
32
33use anyhow::{Context, Result};
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use whisker_cng::{discover_plugins, DiscoveredPlugin};
37use whisker_config::Config;
38
39/// Run the probe and return the parsed config. Caches via mtime so
40/// the second call (and later) returns instantly until `whisker.rs`
41/// changes.
42///
43/// `crate_name` is used to name the probe binary (so the temp `target/`
44/// doesn't collide if the user happens to also have a probe-shaped
45/// crate of their own).
46pub fn run(whisker_rs: &Path, crate_dir: &Path, crate_name: &str) -> Result<Config> {
47    let user_manifest = crate_dir.join("Cargo.toml");
48    let cache = crate_dir.join("target/.whisker/config-cache.json");
49    if cache_is_fresh(&cache, &[whisker_rs, user_manifest.as_path()]) {
50        let json = std::fs::read_to_string(&cache)
51            .with_context(|| format!("read cache {}", cache.display()))?;
52        return serde_json::from_str(&json)
53            .with_context(|| format!("parse cached config {}", cache.display()));
54    }
55    // Discover the user app's Whisker CNG plugin deps so the probe
56    // can import each plugin's `Plugin` impl by name. Each gets
57    // added to the probe's Cargo.toml with `default-features = false`
58    // so the runtime-heavy parts of the plugin crate don't get
59    // built (the convention is plugin crates gate their runtime
60    // behind a `runtime` feature; the probe only needs the `cng`
61    // module).
62    let plugins = discover_plugins(&user_manifest, crate_name)
63        .with_context(|| format!("discover Whisker CNG plugins for `{crate_name}`"))?;
64
65    let probe_dir = crate_dir.join("target/.whisker/config-probe");
66    write_probe_project(&probe_dir, whisker_rs, crate_name, &plugins)?;
67    let json = run_cargo_probe(&probe_dir, crate_name)?;
68    if let Some(parent) = cache.parent() {
69        std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
70    }
71    std::fs::write(&cache, &json).with_context(|| format!("write cache {}", cache.display()))?;
72    serde_json::from_str(&json).with_context(|| "parse probe stdout as Config JSON")
73}
74
75fn cache_is_fresh(cache: &Path, sources: &[&Path]) -> bool {
76    let Ok(cache_mtime) = std::fs::metadata(cache).and_then(|m| m.modified()) else {
77        return false;
78    };
79    // Invalidate the cache when ANY of the watched sources is
80    // newer than the cache. Today that's `whisker.rs` plus the
81    // user crate's `Cargo.toml` (so adding/removing a plugin dep
82    // forces a probe rebuild). `SystemTime` already implements
83    // `PartialOrd` — direct comparison covers both the happy path
84    // and the post-`UNIX_EPOCH` clock-skew edge case that
85    // `duration_since` would `Err` on.
86    for source in sources {
87        let Ok(src_mtime) = std::fs::metadata(source).and_then(|m| m.modified()) else {
88            // Source missing → conservative: regenerate.
89            return false;
90        };
91        if src_mtime > cache_mtime {
92            return false;
93        }
94    }
95    true
96}
97
98/// Write the probe's `Cargo.toml` + `src/main.rs`. Both files are
99/// idempotent: we rewrite them on every cache-miss so an out-of-tree
100/// edit (or a probe-dir delete) self-heals.
101fn write_probe_project(
102    probe_dir: &Path,
103    whisker_rs: &Path,
104    crate_name: &str,
105    plugins: &[DiscoveredPlugin],
106) -> Result<()> {
107    let src_dir = probe_dir.join("src");
108    std::fs::create_dir_all(&src_dir).with_context(|| format!("mkdir {}", src_dir.display()))?;
109
110    let probe_crate_name = format!("__whisker_config_probe_{}", crate_name.replace('-', "_"));
111    let plugin_dep_lines = render_plugin_dep_lines(plugins);
112    let cargo_toml = format!(
113        r#"# Auto-generated by whisker-cli (do not edit).
114[package]
115name = "{probe_crate_name}"
116version = "0.0.0"
117edition = "2021"
118publish = false
119
120[dependencies]
121whisker-config = {whisker_config_dep}
122serde_json = "1"
123{plugin_dep_lines}
124[[bin]]
125name = "{probe_crate_name}"
126path = "src/main.rs"
127
128# Avoid leaking workspace inheritance: every published-config field
129# the parent workspace sets (rust-version, license, …) would have to
130# match here. Stand-alone keeps the probe immune to workspace churn.
131[workspace]
132"#,
133        whisker_config_dep = whisker_config_dep_spec(),
134    );
135    std::fs::write(probe_dir.join("Cargo.toml"), cargo_toml)
136        .with_context(|| format!("write {}/Cargo.toml", probe_dir.display()))?;
137
138    // `include!` takes an absolute path; we use display() so the path
139    // is a normal forward-slashed string (Windows isn't supported by
140    // the dev server, so display() is safe).
141    let main_rs = format!(
142        r#"// Auto-generated by whisker-cli (do not edit).
143//
144// This probe binary splices the user's `whisker.rs` into a `main`
145// that prints the resulting `Config` as JSON on stdout. The
146// host shell (`whisker run`) parses that JSON and projects the
147// fields it needs into a flat `whisker_dev_server::Config`.
148
149include!({whisker_rs:?});
150
151fn main() {{
152    let mut cfg = whisker_config::Config::default();
153    configure(&mut cfg);
154    let stdout = std::io::stdout();
155    serde_json::to_writer(stdout.lock(), &cfg).expect("serialize Config");
156}}
157"#,
158        whisker_rs = whisker_rs.to_string_lossy(),
159    );
160    std::fs::write(src_dir.join("main.rs"), main_rs)
161        .with_context(|| format!("write {}/src/main.rs", src_dir.display()))?;
162
163    Ok(())
164}
165
166fn run_cargo_probe(probe_dir: &Path, _crate_name: &str) -> Result<String> {
167    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
168    let out = Command::new(&cargo)
169        .arg("run")
170        .arg("--quiet")
171        .arg("--release")
172        .arg("--manifest-path")
173        .arg(probe_dir.join("Cargo.toml"))
174        .output()
175        .with_context(|| format!("spawn cargo run for probe at {}", probe_dir.display()))?;
176    if !out.status.success() {
177        anyhow::bail!(
178            "config probe build/run failed (exit {})\nstderr:\n{}",
179            out.status,
180            String::from_utf8_lossy(&out.stderr),
181        );
182    }
183    String::from_utf8(out.stdout).context("probe stdout not valid UTF-8")
184}
185
186/// Format each discovered Whisker CNG plugin crate as a probe
187/// `[dependencies]` line:
188///
189/// ```toml
190/// whisker-audio = { path = "...", default-features = false }
191/// ```
192///
193/// `default-features = false` is critical — plugin crates by
194/// convention put their heavyweight runtime behind a `runtime`
195/// feature so the probe build stays cheap. The probe only needs
196/// the `cng` module exposing `Plugin` + `Config` types.
197fn render_plugin_dep_lines(plugins: &[DiscoveredPlugin]) -> String {
198    if plugins.is_empty() {
199        return String::new();
200    }
201    // Dedup by source_crate name — multiple plugins can ship from
202    // one crate and we only need to list that crate once.
203    let mut seen = std::collections::BTreeSet::new();
204    let mut out = String::new();
205    for p in plugins {
206        if !seen.insert(p.source_crate.as_str()) {
207            continue;
208        }
209        out.push_str(&format!(
210            "{} = {{ path = \"{}\", default-features = false }}\n",
211            p.source_crate,
212            p.source_manifest_dir.display(),
213        ));
214    }
215    out
216}
217
218/// The `whisker-config` dependency spec the probe's `Cargo.toml`
219/// should use.
220///
221/// Two cases, distinguished by whether the local source dir exists:
222///
223///   * **In-workspace development** (this `whisker-cli` was built from
224///     a checkout of the Whisker monorepo): point the probe at the
225///     local `crates/whisker-config` source via `path` so edits to
226///     `whisker-config` are picked up without a publish/version bump.
227///   * **External users** (this `whisker-cli` was installed from
228///     crates.io): the local path doesn't exist, so depend on the
229///     published `whisker-config` whose version matches this
230///     `whisker-cli` build. `whisker-config` shares the workspace
231///     version with `whisker-cli`, so `CARGO_PKG_VERSION` is the
232///     correct, in-lockstep version to request from crates.io.
233fn whisker_config_dep_spec() -> String {
234    match in_workspace_config_path() {
235        Some(path) => format!("{{ path = {:?} }}", path.display().to_string()),
236        None => format!("\"{}\"", env!("CARGO_PKG_VERSION")),
237    }
238}
239
240/// The local `crates/whisker-config` source dir, if this `whisker-cli`
241/// was built from a monorepo checkout. `CARGO_MANIFEST_DIR` is baked
242/// at compile time: in-workspace it's `<workspace>/crates/whisker-cli`
243/// (sibling `whisker-config` exists); installed from crates.io it's
244/// the registry `src/.../whisker-cli-<v>` dir (no sibling
245/// `whisker-config` dir), so this returns `None`.
246fn in_workspace_config_path() -> Option<PathBuf> {
247    let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
248    let app_config = cli_dir.parent()?.join("whisker-config");
249    app_config.is_dir().then_some(app_config)
250}