use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;
#[derive(Debug)]
pub struct BuildOutcome {
pub wasm_path: PathBuf,
pub duration_ms: u64,
pub wasm_size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Profile {
Debug,
Release,
}
impl Profile {
pub fn as_str(self) -> &'static str {
match self {
Profile::Debug => "debug",
Profile::Release => "release",
}
}
}
pub fn cargo_command(project_dir: &Path, profile: Profile) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("component")
.arg("build")
.arg("--target")
.arg("wasm32-wasip2")
.current_dir(project_dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if matches!(profile, Profile::Release) {
cmd.arg("--release");
}
cmd
}
pub fn run_build(project_dir: &Path, profile: Profile) -> anyhow::Result<BuildOutcome> {
let start = Instant::now();
let status = cargo_command(project_dir, profile).status()?;
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
if !status.success() {
anyhow::bail!("cargo component build failed (exit {status})");
}
let wasm_path = find_wasm_artifact(project_dir, profile)?;
let wasm_size = std::fs::metadata(&wasm_path)?.len();
Ok(BuildOutcome {
wasm_path,
duration_ms,
wasm_size,
})
}
fn cargo_target_dir(project_dir: &Path) -> anyhow::Result<PathBuf> {
let output = Command::new("cargo")
.arg("metadata")
.arg("--no-deps")
.arg("--format-version=1")
.current_dir(project_dir)
.stderr(Stdio::null())
.output()?;
if !output.status.success() {
return Ok(project_dir.join("target"));
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let target = json
.get("target_directory")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("cargo metadata missing target_directory"))?;
Ok(PathBuf::from(target))
}
fn project_crate_name(project_dir: &Path) -> Option<String> {
let raw = std::fs::read_to_string(project_dir.join("Cargo.toml")).ok()?;
let value: toml::Value = toml::from_str(&raw).ok()?;
value
.get("package")?
.get("name")?
.as_str()
.map(str::to_string)
}
fn select_wasm_artifact(mut candidates: Vec<PathBuf>, crate_name: Option<&str>) -> Option<PathBuf> {
candidates.sort();
if let Some(name) = crate_name {
let wanted = format!("{}.wasm", name.replace('-', "_"));
if let Some(hit) = candidates
.iter()
.find(|p| p.file_name().and_then(|s| s.to_str()) == Some(wanted.as_str()))
{
return Some(hit.clone());
}
}
candidates.into_iter().next()
}
pub fn find_wasm_artifact(project_dir: &Path, profile: Profile) -> anyhow::Result<PathBuf> {
let profile_name = profile.as_str();
let target_dir = cargo_target_dir(project_dir)?;
let crate_name = project_crate_name(project_dir);
let candidates_dirs = [
target_dir.join("wasm32-wasip2").join(profile_name),
target_dir.join("wasm32-wasip1").join(profile_name),
];
for dir in &candidates_dirs {
if !dir.exists() {
continue;
}
let candidates: Vec<_> = std::fs::read_dir(dir)?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("wasm"))
.collect();
if let Some(hit) = select_wasm_artifact(candidates, crate_name.as_deref()) {
return Ok(hit);
}
}
anyhow::bail!(
"no .wasm artifact under {}/wasm32-wasip2/{profile_name}/ or {}/wasm32-wasip1/{profile_name}/",
target_dir.display(),
target_dir.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_command_has_target_and_profile_flags() {
let tmp = tempfile::tempdir().unwrap();
let dbg = cargo_command(tmp.path(), Profile::Debug);
let args: Vec<_> = dbg
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
vec!["component", "build", "--target", "wasm32-wasip2"]
);
let rel = cargo_command(tmp.path(), Profile::Release);
let rel_args: Vec<_> = rel
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(rel_args.iter().any(|a| a == "--release"));
}
#[test]
fn find_wasm_artifact_returns_first_lexicographic() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("zeta.wasm"), b"z").unwrap();
std::fs::write(dir.join("alpha.wasm"), b"a").unwrap();
let got = find_wasm_artifact(tmp.path(), Profile::Debug).unwrap();
assert_eq!(got.file_name().unwrap(), "alpha.wasm");
}
#[test]
fn find_wasm_artifact_errors_when_dir_missing() {
let tmp = tempfile::tempdir().unwrap();
let err = find_wasm_artifact(tmp.path(), Profile::Debug).unwrap_err();
assert!(err.to_string().contains("no .wasm"));
}
#[test]
fn find_wasm_artifact_errors_when_no_wasm() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("target/wasm32-wasip2/release")).unwrap();
let err = find_wasm_artifact(tmp.path(), Profile::Release).unwrap_err();
assert!(err.to_string().contains("no .wasm"));
}
#[test]
fn select_wasm_prefers_crate_name_match() {
let candidates = vec![
PathBuf::from("/t/aaa.wasm"),
PathBuf::from("/t/my_ext.wasm"),
PathBuf::from("/t/zzz.wasm"),
];
let got = select_wasm_artifact(candidates, Some("my-ext")).unwrap();
assert_eq!(got.file_name().unwrap(), "my_ext.wasm");
}
#[test]
fn select_wasm_falls_back_to_first_lexicographic_without_match() {
let candidates = vec![PathBuf::from("/t/zzz.wasm"), PathBuf::from("/t/aaa.wasm")];
let got = select_wasm_artifact(candidates, Some("nomatch")).unwrap();
assert_eq!(got.file_name().unwrap(), "aaa.wasm");
}
#[test]
fn select_wasm_none_for_empty() {
assert!(select_wasm_artifact(vec![], Some("x")).is_none());
}
#[test]
fn find_wasm_artifact_prefers_matching_crate_name() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
b"[package]\nname = \"my-ext\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
let dir = tmp.path().join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("aaa.wasm"), b"a").unwrap();
std::fs::write(dir.join("my_ext.wasm"), b"m").unwrap();
std::fs::write(dir.join("zzz.wasm"), b"z").unwrap();
let got = find_wasm_artifact(tmp.path(), Profile::Debug).unwrap();
assert_eq!(
got.file_name().unwrap(),
"my_ext.wasm",
"should pick the artifact named after the crate, not the first lexicographic"
);
}
#[test]
fn find_wasm_artifact_falls_back_to_wasip1_output_dir() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("target/wasm32-wasip1/debug");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("demo.wasm"), b"\0asm").unwrap();
let got = find_wasm_artifact(tmp.path(), Profile::Debug).unwrap();
assert_eq!(got.file_name().unwrap(), "demo.wasm");
}
}