xtask/src/guests.rs
//! `cargo xtask build-guests` — the build door for the product guests.
//!
//! | question | answer |
//! |---|---|
//! | what does it build? | every crate in [`GUEST_CRATES`], `--release --target wasm32-unknown-unknown` |
//! | where does cargo put it? | `<target>/wasm32-unknown-unknown/release/<crate>.wasm`, honouring `CARGO_TARGET_DIR` (the env var — cargo's config-file `target-dir` is not visible here, so a box that sets it there exports the variable before this verb) |
//! | who reads it? | the guest host's tests, and a deployment that signs and ships the module |
//!
//! A build that produced no module is a failure with the path it looked at,
//! never a green with nothing behind it. The shape is holger's
//! `cargo xtask build-wasm-plugins`.
use std::path::{Path, PathBuf};
pub const TARGET: &str = "wasm32-unknown-unknown";
// The gunnar guest is not here: it lives in the gunnar repository (crates/gunnar-monetize-guest)
// since 2026-09-25, built and signed from the same commit as gunnar-server. Holger's lives in
// holger (crates/holger-monetize-guest since 2026-09-27; monetize-impl's `holger-guest` row
// builds and signs it from ../holger), and this verb builds it from the sibling too
// ([`HOLGER_GUEST`]): monetize's own tests load it. Gunnar's is built from ../gunnar the same
// way ([`GUNNAR_GUEST`]), in its own workspace, `--locked`.
pub const GUEST_CRATES: &[&str] = &[
"monetize-guest-toy",
"monetize-guest-toy-vendor",
"monetize-guest-toy-cloud",
"monetize-guest-toy-signup",
"monetize-guest-toy-dns",
"monetize-guest-lake",
// docs/v2 §3: the skeletons must keep compiling to wasm32 against main
"monetize-example-product",
"monetize-example-vendor",
"monetize-example-cloud",
"monetize-example-dns",
"monetize-example-signup",
];
/// Holger's guest, built in the `../holger` sibling ([`holger_guest_steps`]): monetize-server's
/// `the_signed_holger_guest_*` and products/holger's `the_holger_guest_is_a_real_module` load it
/// from this verb's target and never skip (A28 H10).
pub const HOLGER_GUEST: &str = "holger-monetize-guest";
/// The cargo run that builds [`HOLGER_GUEST`]: (directory, `CARGO_TARGET_DIR`, args), into
/// `target` itself so the module lands where the tests look. The estate workspace carries its
/// sibling patches in its own manifest (holger B7, 2026-10-26), so nothing is written into the
/// holger checkout first (OVW-3: the gate's siblings are read-only). Not `--locked`: Holger's
/// estate lock follows the siblings (as `trials` builds it).
pub fn holger_guest_steps(repo_root: &Path, target: &Path) -> Vec<(PathBuf, PathBuf, Vec<String>)> {
let holger = repo_root.join("../holger");
let s = |v: &[&str]| v.iter().map(|a| a.to_string()).collect::<Vec<_>>();
// Holger v2.0 H1 (holger c804ffc9): the guest is a member of the ESTATE workspace
// (estate/Cargo.toml), not of holger's root, which is the open core.
vec![(
holger,
target.to_path_buf(),
s(&[
"build",
"--manifest-path",
"estate/Cargo.toml",
"--release",
"--target",
TARGET,
"-p",
HOLGER_GUEST,
]),
)]
}
/// Gunnar's guest, built in its own workspace in the `../gunnar` sibling
/// ([`gunnar_guest_steps`]): monetize-server's `the_signed_guests_answer_the_op_table` loads it
/// from this verb's target and never skips.
pub const GUNNAR_GUEST: &str = "gunnar-monetize-guest";
/// The cargo run that builds [`GUNNAR_GUEST`]: (directory, `CARGO_TARGET_DIR`, args), `--locked`
/// in its own workspace. The guest's manifest carries the estate's sibling patches its lock
/// records (gunnar G-B7, 2026-10-26), so nothing is written into the gunnar checkout first
/// (OVW-3: the gate's siblings are read-only).
pub fn gunnar_guest_steps(repo_root: &Path, target: &Path) -> Vec<(PathBuf, PathBuf, Vec<String>)> {
let gunnar = repo_root.join("../gunnar");
let s = |v: &[&str]| v.iter().map(|a| a.to_string()).collect::<Vec<_>>();
vec![(
gunnar.join("crates").join(GUNNAR_GUEST),
target.to_path_buf(),
s(&["build", "--locked", "--release", "--target", TARGET]),
)]
}
pub fn module_rel_path(crate_name: &str) -> PathBuf {
Path::new(TARGET)
.join("release")
.join(format!("{}.wasm", crate_name.replace('-', "_")))
}
pub fn target_dir(repo_root: &Path) -> PathBuf {
std::env::var("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| repo_root.join("target"))
}
pub fn run(repo_root: &Path) -> Result<Vec<PathBuf>, String> {
let target = target_dir(repo_root);
let mut args: Vec<String> = vec![
"build".into(),
"--release".into(),
"--target".into(),
TARGET.into(),
];
for c in GUEST_CRATES {
args.push("-p".into());
args.push((*c).into());
}
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let status = std::process::Command::new(cargo)
.args(&args)
.current_dir(repo_root)
.status()
.map_err(|e| format!("running cargo: {e}"))?;
if !status.success() {
return Err(format!("cargo build --target {TARGET} failed: {status}"));
}
let mut built = Vec::new();
let mut missing = Vec::new();
for c in GUEST_CRATES {
let p = target.join(module_rel_path(c));
match std::fs::metadata(&p) {
Ok(m) if m.len() > 0 => built.push(p),
Ok(_) => missing.push(format!("{} is empty", p.display())),
Err(e) => missing.push(format!("{} is missing: {e}", p.display())),
}
}
let holger = repo_root.join("../holger");
if !holger.join("Cargo.toml").is_file() {
return Err(format!("{HOLGER_GUEST}: no Holger checkout at {} (the Holger-guest tests load it; check holger out beside monetize)", holger.display()));
}
let gunnar = repo_root.join("../gunnar/crates").join(GUNNAR_GUEST);
if !gunnar.join("Cargo.toml").is_file() {
return Err(format!("{GUNNAR_GUEST}: no Gunnar checkout at {} (the op-table test loads it; check gunnar out beside monetize)", gunnar.display()));
}
for (dir, t, a) in holger_guest_steps(repo_root, &target)
.into_iter()
.chain(gunnar_guest_steps(repo_root, &target))
{
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let status = std::process::Command::new(cargo)
.args(&a)
.current_dir(&dir)
.env("CARGO_TARGET_DIR", &t)
.status()
.map_err(|e| format!("running cargo in {}: {e}", dir.display()))?;
if !status.success() {
return Err(format!(
"cargo {} in {} failed: {status}",
a.join(" "),
dir.display()
));
}
}
for guest in [HOLGER_GUEST, GUNNAR_GUEST] {
let p = target.join(module_rel_path(guest));
match std::fs::metadata(&p) {
Ok(m) if m.len() > 0 => built.push(p),
Ok(_) => missing.push(format!("{} is empty", p.display())),
Err(e) => missing.push(format!("{} is missing: {e}", p.display())),
}
}
if !missing.is_empty() {
return Err(format!(
"cargo build succeeded but produced no usable module:\n {}\nCheck that each crate declares `[lib] crate-type = [\"cdylib\"]`.",
missing.join("\n ")
));
}
Ok(built)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_holger_guest_is_built_in_the_holger_sibling_into_the_target_the_tests_read() {
// monetize-server's the_signed_holger_guest_* and products/holger's
// the_holger_guest_is_a_real_module load `<CARGO_TARGET_DIR>/wasm32-unknown-unknown/
// release/holger_monetize_guest.wasm` and never skip: 9 reds on every clean checkout
// until build-guests built it (A28 H10).
let steps = holger_guest_steps(Path::new("/c/monetize"), Path::new("/t"));
assert_eq!(steps.len(), 1, "{steps:?}");
let (dir, target, args) = &steps[0];
assert_eq!(dir, Path::new("/c/monetize/../holger"));
assert_eq!(
target,
Path::new("/t"),
"the module must land where the tests look"
);
// Holger v2.0 H1 (holger c804ffc9) moved the guest into the ESTATE workspace: `-p` at the
// root made cargo panic (features.rs:325, "did not find features for holger-monetize-guest").
assert_eq!(args.join(" "), "build --manifest-path estate/Cargo.toml --release --target wasm32-unknown-unknown -p holger-monetize-guest");
assert_eq!(
Path::new("/t").join(module_rel_path(HOLGER_GUEST)),
Path::new("/t/wasm32-unknown-unknown/release/holger_monetize_guest.wasm")
);
}
#[test]
fn the_gunnar_guest_is_built_locked_in_its_own_workspace_into_the_target_the_tests_read() {
// monetize-server's the_signed_guests_answer_the_op_table loads
// `<CARGO_TARGET_DIR>/wasm32-unknown-unknown/release/gunnar_monetize_guest.wasm` and never
// skips: it was red on every checkout until the guest was built by hand.
let steps = gunnar_guest_steps(Path::new("/c/monetize"), Path::new("/t"));
assert_eq!(steps.len(), 1, "{steps:?}");
let (dir, target, args) = &steps[0];
assert_eq!(
dir,
Path::new("/c/monetize/../gunnar/crates/gunnar-monetize-guest"),
"its own workspace and lock"
);
assert_eq!(
target,
Path::new("/t"),
"the module must land where the tests look"
);
assert_eq!(
args.join(" "),
"build --locked --release --target wasm32-unknown-unknown"
);
assert_eq!(
Path::new("/t").join(module_rel_path(GUNNAR_GUEST)),
Path::new("/t/wasm32-unknown-unknown/release/gunnar_monetize_guest.wasm")
);
}
/// ★ OVW-3 (2026-10-29): **no guest step writes into a sibling checkout.** The five signed
/// guest targets of monetize-server were red wherever holger and gunnar sit beside monetize
/// read-only (the gate's kit): each `cargo xtask siblings` wrote `.cargo/siblings.toml` into
/// the sibling. Since holger B7 and gunnar G-B7 (2026-10-26) the estate workspaces carry
/// their sibling patches in their own manifests and nothing includes that file, so the
/// guests build with no step of that kind, each into the target the tests read.
#[test]
fn no_guest_step_writes_into_a_sibling_checkout() {
let steps: Vec<_> = holger_guest_steps(Path::new("/c/monetize"), Path::new("/t"))
.into_iter()
.chain(gunnar_guest_steps(
Path::new("/c/monetize"),
Path::new("/t"),
))
.collect();
for (dir, target, args) in &steps {
assert!(
!args.iter().any(|a| a == "siblings"),
"{}: `cargo {}` writes .cargo/siblings.toml into the sibling",
dir.display(),
args.join(" ")
);
assert_eq!(
target,
Path::new("/t"),
"{}: `cargo {}` builds into the target the tests read",
dir.display(),
args.join(" ")
);
}
assert_eq!(steps.len(), 2, "one build per guest: {steps:?}");
}
}