Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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:?}");
    }
}