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}