use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{ForkError, Result};
const DEFAULT_TARGET: &str = "wasm32v1-none";
const DEFAULT_PROFILE: &str = "release";
pub fn workspace_wasm(crate_name: &str) -> Result<Vec<u8>> {
workspace_wasm_in(None, crate_name, DEFAULT_TARGET, DEFAULT_PROFILE)
}
pub fn workspace_wasm_with(crate_name: &str, target: &str, profile: &str) -> Result<Vec<u8>> {
workspace_wasm_in(None, crate_name, target, profile)
}
pub fn workspace_wasm_in(
manifest_dir: Option<&Path>,
crate_name: &str,
target: &str,
profile: &str,
) -> Result<Vec<u8>> {
let metadata = read_metadata(manifest_dir)?;
let target_dir = extract_target_dir(&metadata)?;
require_workspace_member(&metadata, crate_name)?;
invoke_cargo_build(manifest_dir, crate_name, target, profile)?;
let wasm_path = target_dir
.join(target)
.join(profile)
.join(format!("{}.wasm", crate_name.replace('-', "_")));
std::fs::read(&wasm_path).map_err(|e| {
ForkError::Workspace(format!(
"expected wasm output at {} after a successful build, but read failed: {}",
wasm_path.display(),
e
))
})
}
fn cargo_bin() -> OsString {
std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}
fn read_metadata(manifest_dir: Option<&Path>) -> Result<serde_json::Value> {
let mut cmd = Command::new(cargo_bin());
cmd.args(["metadata", "--no-deps", "--format-version=1"]);
if let Some(dir) = manifest_dir {
cmd.current_dir(dir);
}
let output = cmd
.output()
.map_err(|e| ForkError::Workspace(format!("failed to spawn `cargo metadata`: {e}")))?;
if !output.status.success() {
return Err(ForkError::Workspace(format!(
"`cargo metadata` failed (exit {}):\n{}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| ForkError::Workspace(format!("parsing cargo metadata JSON: {e}")))
}
fn extract_target_dir(metadata: &serde_json::Value) -> Result<PathBuf> {
metadata["target_directory"]
.as_str()
.map(PathBuf::from)
.ok_or_else(|| {
ForkError::Workspace("cargo metadata response missing `target_directory`".into())
})
}
fn require_workspace_member(metadata: &serde_json::Value, crate_name: &str) -> Result<()> {
let workspace_member_ids: std::collections::HashSet<&str> = metadata["workspace_members"]
.as_array()
.ok_or_else(|| {
ForkError::Workspace("cargo metadata response missing `workspace_members`".into())
})?
.iter()
.filter_map(|m| m.as_str())
.collect();
let packages = metadata["packages"]
.as_array()
.ok_or_else(|| ForkError::Workspace("cargo metadata response missing `packages`".into()))?;
let workspace_names: Vec<&str> = packages
.iter()
.filter(|pkg| {
pkg["id"]
.as_str()
.map(|id| workspace_member_ids.contains(id))
.unwrap_or(false)
})
.filter_map(|pkg| pkg["name"].as_str())
.collect();
if workspace_names.contains(&crate_name) {
return Ok(());
}
Err(ForkError::Workspace(format!(
"{crate_name} is not a workspace member. cargo metadata listed: [{}]",
workspace_names.join(", ")
)))
}
fn invoke_cargo_build(
manifest_dir: Option<&Path>,
crate_name: &str,
target: &str,
profile: &str,
) -> Result<()> {
let mut cmd = Command::new(cargo_bin());
cmd.args(["build", "-p", crate_name, "--target", target]);
match profile {
"release" => {
cmd.arg("--release");
}
"dev" => {}
other => {
cmd.args(["--profile", other]);
}
}
if let Some(dir) = manifest_dir {
cmd.current_dir(dir);
}
let status = cmd
.status()
.map_err(|e| ForkError::Workspace(format!("failed to spawn `cargo build`: {e}")))?;
if !status.success() {
return Err(ForkError::Workspace(format!(
"`cargo build -p {crate_name} --target {target}` failed (exit {status}). \
cargo's stderr was already written to this process's stderr."
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn metadata_with(members: &[(&str, &str)]) -> serde_json::Value {
let workspace_members: Vec<serde_json::Value> = members
.iter()
.map(|(id, _)| serde_json::json!(id))
.collect();
let packages: Vec<serde_json::Value> = members
.iter()
.map(|(id, name)| serde_json::json!({ "id": id, "name": name }))
.collect();
serde_json::json!({
"workspace_members": workspace_members,
"packages": packages,
})
}
#[test]
fn member_check_accepts_old_cargo_id_format() {
let metadata = metadata_with(&[
("foo 0.1.0 (path+file:///tmp/foo)", "foo"),
("bar-baz 0.2.0 (path+file:///tmp/bar-baz)", "bar-baz"),
]);
assert!(require_workspace_member(&metadata, "foo").is_ok());
assert!(require_workspace_member(&metadata, "bar-baz").is_ok());
}
#[test]
fn member_check_accepts_new_cargo_id_format_with_name() {
let metadata =
metadata_with(&[("path+file:///tmp/aliased-dir#real-name@0.1.0", "real-name")]);
assert!(require_workspace_member(&metadata, "real-name").is_ok());
}
#[test]
fn member_check_accepts_new_cargo_id_format_name_dropped() {
let metadata = metadata_with(&[(
"path+file:///tmp/sfork-smoke/demo_contract#0.0.1",
"demo_contract",
)]);
assert!(require_workspace_member(&metadata, "demo_contract").is_ok());
}
#[test]
fn member_check_rejects_unknown_member() {
let metadata = metadata_with(&[("path+file:///tmp/foo#foo@0.1.0", "foo")]);
let err = require_workspace_member(&metadata, "bar").unwrap_err();
assert!(err.to_string().contains("bar is not a workspace member"));
assert!(err.to_string().contains("foo"));
}
#[test]
fn member_check_does_not_match_substring_of_member_name() {
let metadata = metadata_with(&[("path+file:///tmp/foobar#foobar@0.1.0", "foobar")]);
assert!(require_workspace_member(&metadata, "foo").is_err());
}
#[test]
fn member_check_errors_when_workspace_members_missing() {
let metadata = serde_json::json!({});
let err = require_workspace_member(&metadata, "foo").unwrap_err();
assert!(err.to_string().contains("missing `workspace_members`"));
}
#[test]
fn member_check_errors_when_packages_missing() {
let metadata = serde_json::json!({
"workspace_members": ["path+file:///tmp/foo#foo@0.1.0"],
});
let err = require_workspace_member(&metadata, "foo").unwrap_err();
assert!(err.to_string().contains("missing `packages`"));
}
#[test]
fn member_check_ignores_non_workspace_packages() {
let metadata = serde_json::json!({
"workspace_members": ["path+file:///tmp/local#local@0.1.0"],
"packages": [
{ "id": "path+file:///tmp/local#local@0.1.0", "name": "local" },
{ "id": "registry+https://crates.io#serde@1.0.0", "name": "serde" },
]
});
assert!(require_workspace_member(&metadata, "local").is_ok());
assert!(require_workspace_member(&metadata, "serde").is_err());
}
#[test]
fn target_dir_extracted_from_metadata() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target"
});
assert_eq!(
extract_target_dir(&metadata).unwrap(),
PathBuf::from("/tmp/target")
);
}
#[test]
fn target_dir_errors_on_missing() {
let metadata = serde_json::json!({});
let err = extract_target_dir(&metadata).unwrap_err();
assert!(err.to_string().contains("missing `target_directory`"));
}
}