use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use whisker_cng::{discover_plugins, DiscoveredPlugin};
use whisker_config::Config;
pub fn run(whisker_rs: &Path, crate_dir: &Path, crate_name: &str) -> Result<Config> {
let user_manifest = crate_dir.join("Cargo.toml");
let cache = crate_dir.join("target/.whisker/config-cache.json");
if cache_is_fresh(&cache, &[whisker_rs, user_manifest.as_path()]) {
let json = std::fs::read_to_string(&cache)
.with_context(|| format!("read cache {}", cache.display()))?;
return serde_json::from_str(&json)
.with_context(|| format!("parse cached config {}", cache.display()));
}
let plugins = discover_plugins(&user_manifest, crate_name)
.with_context(|| format!("discover Whisker CNG plugins for `{crate_name}`"))?;
let probe_dir = crate_dir.join("target/.whisker/config-probe");
write_probe_project(&probe_dir, whisker_rs, crate_name, &plugins)?;
let json = run_cargo_probe(&probe_dir, crate_name)?;
if let Some(parent) = cache.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
std::fs::write(&cache, &json).with_context(|| format!("write cache {}", cache.display()))?;
serde_json::from_str(&json).with_context(|| "parse probe stdout as Config JSON")
}
fn cache_is_fresh(cache: &Path, sources: &[&Path]) -> bool {
let Ok(cache_mtime) = std::fs::metadata(cache).and_then(|m| m.modified()) else {
return false;
};
for source in sources {
let Ok(src_mtime) = std::fs::metadata(source).and_then(|m| m.modified()) else {
return false;
};
if src_mtime > cache_mtime {
return false;
}
}
true
}
fn write_probe_project(
probe_dir: &Path,
whisker_rs: &Path,
crate_name: &str,
plugins: &[DiscoveredPlugin],
) -> Result<()> {
let src_dir = probe_dir.join("src");
std::fs::create_dir_all(&src_dir).with_context(|| format!("mkdir {}", src_dir.display()))?;
let probe_crate_name = format!("__whisker_config_probe_{}", crate_name.replace('-', "_"));
let plugin_dep_lines = render_plugin_dep_lines(plugins);
let cargo_toml = format!(
r#"# Auto-generated by whisker-cli (do not edit).
[package]
name = "{probe_crate_name}"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
whisker-config = {whisker_config_dep}
serde_json = "1"
{plugin_dep_lines}
[[bin]]
name = "{probe_crate_name}"
path = "src/main.rs"
# Avoid leaking workspace inheritance: every published-config field
# the parent workspace sets (rust-version, license, …) would have to
# match here. Stand-alone keeps the probe immune to workspace churn.
[workspace]
"#,
whisker_config_dep = whisker_config_dep_spec(),
);
std::fs::write(probe_dir.join("Cargo.toml"), cargo_toml)
.with_context(|| format!("write {}/Cargo.toml", probe_dir.display()))?;
let main_rs = format!(
r#"// Auto-generated by whisker-cli (do not edit).
//
// This probe binary splices the user's `whisker.rs` into a `main`
// that prints the resulting `Config` as JSON on stdout. The
// host shell (`whisker run`) parses that JSON and projects the
// fields it needs into a flat `whisker_dev_server::Config`.
include!({whisker_rs:?});
fn main() {{
let mut cfg = whisker_config::Config::default();
configure(&mut cfg);
let stdout = std::io::stdout();
serde_json::to_writer(stdout.lock(), &cfg).expect("serialize Config");
}}
"#,
whisker_rs = whisker_rs.to_string_lossy(),
);
std::fs::write(src_dir.join("main.rs"), main_rs)
.with_context(|| format!("write {}/src/main.rs", src_dir.display()))?;
Ok(())
}
fn run_cargo_probe(probe_dir: &Path, _crate_name: &str) -> Result<String> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let out = Command::new(&cargo)
.arg("run")
.arg("--quiet")
.arg("--release")
.arg("--manifest-path")
.arg(probe_dir.join("Cargo.toml"))
.output()
.with_context(|| format!("spawn cargo run for probe at {}", probe_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"config probe build/run failed (exit {})\nstderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
}
String::from_utf8(out.stdout).context("probe stdout not valid UTF-8")
}
fn render_plugin_dep_lines(plugins: &[DiscoveredPlugin]) -> String {
if plugins.is_empty() {
return String::new();
}
let mut seen = std::collections::BTreeSet::new();
let mut out = String::new();
for p in plugins {
if !seen.insert(p.source_crate.as_str()) {
continue;
}
out.push_str(&format!(
"{} = {{ path = \"{}\", default-features = false }}\n",
p.source_crate,
p.source_manifest_dir.display(),
));
}
out
}
fn whisker_config_dep_spec() -> String {
match in_workspace_config_path() {
Some(path) => format!("{{ path = {:?} }}", path.display().to_string()),
None => format!("\"{}\"", env!("CARGO_PKG_VERSION")),
}
}
fn in_workspace_config_path() -> Option<PathBuf> {
let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let app_config = cli_dir.parent()?.join("whisker-config");
app_config.is_dir().then_some(app_config)
}