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

nordisk/gunnar

crates/gunnar-build-thing/tests/wasm_plugin.rs

//! **One source, two link targets, one answer.** The same [`GunnarBuildThing`]
//! is called natively and — compiled to `wasm32-unknown-unknown` and loaded
//! through edda's generic plugin host, with NO gunnar dependency on the host
//! side, which is the forge's situation — over the ABI, and the two must
//! answer IDENTICALLY. If they did not, the ⚒ pane (which reads the wasm) and
//! the CLI (which links natively through the build scripts) could disagree
//! about the same variant. Template: edda
//! `crates/nornir-buildspec-holger/tests/tree_native_equals_wasm.rs`.
//!
//! # How the module is built here, and the road that is refused
//!
//! The module is built by HOST cargo from inside the test, exactly as the
//! template does it. The container road — dwarves `build_thing::code`
//! (`f5e0854`), the recipe crate to wasm32 INSIDE the declared
//! `nordisk-cargo-toolchain` — is the one every forge build takes; it is
//! refused by name for gunnar today because the declared image
//! (`[build.toolchain]`, 1.97.1) declares and carries no
//! `wasm32-unknown-unknown` target, and the `dwarves` binary on this box
//! predates the code road. When the image grows the target this test still
//! proves the same identity; what changes is who compiles the module.
//!
//! # Two tiers
//!
//! * always: `manifest`, `variants`, `menu` — the 0.1.9 ABI;
//! * `options` / `try_toggle` / `actions` over
//!   every node of every tree (ops 11–13, 0.1.10 — see `tests/tree_laws.rs`
//!   for why that is gated).

use std::path::{Path, PathBuf};
use std::process::Command;

use gunnar_build_thing::GunnarBuildThing;
use nornir_build_thing::plugin::BuildThing;
use nornir_build_thing::plugin_host::{HostFacts, Limits, PluginHandle};

/// Build this crate's own wasm32 module and hand back its path, or `None`
/// with the reason printed — a box without the target skips LOUDLY, never
/// green by omission.
fn build_the_plugin() -> Option<PathBuf> {
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let workspace = manifest.parent()?.parent()?.to_path_buf();
    // ASK CARGO where its target directory is, and then BUILD INTO THAT ANSWER,
    // so the place the module is written and the place it is looked for cannot
    // be two different opinions. See [`target_dir`].
    let target_dir = target_dir(&workspace);
    let out = Command::new(env!("CARGO"))
        .current_dir(&workspace)
        .args([
            "build",
            "-p",
            "gunnar-build-thing",
            "--lib",
            "--release",
            "--target",
            "wasm32-unknown-unknown",
            "--target-dir",
        ])
        .arg(&target_dir)
        .output()
        .ok()?;
    if !out.status.success() {
        eprintln!(
            "SKIP wasm_plugin: could not build the wasm32 module (is the target installed? \
             `rustup target add wasm32-unknown-unknown`)\n{}",
            String::from_utf8_lossy(&out.stderr)
        );
        return None;
    }
    let wasm = target_dir.join("wasm32-unknown-unknown/release/gunnar_build_thing.wasm");
    assert!(
        wasm.is_file(),
        "the wasm32 build reported success but produced no module at {} — is the crate \
         still a cdylib?",
        wasm.display()
    );
    Some(wasm)
}

/// **The cargo target directory — ASKED OF CARGO, never re-derived.**
///
/// RED on this box, 2026-09-10, and it cost a whole workspace test run: this
/// read `CARGO_TARGET_DIR` and fell back to `<workspace>/target`, and both were
/// wrong. The target directory here is set in `~/.cargo/config.toml`:
///
/// ```toml
/// [build]
/// target-dir = "/home/alice/cargo-target"
/// ```
///
/// A `config.toml` setting does not appear in the process environment, so
/// `CARGO_TARGET_DIR` was unset, the fallback pointed at `<workspace>/target`,
/// and the module — really written to
/// `/home/alice/cargo-target/wasm32-unknown-unknown/release/` — was declared
/// missing:
///
/// ```text
/// the wasm32 build reported success but produced no module at
/// /home/alice/git/gunnar/target/wasm32-unknown-unknown/release/gunnar_build_thing.wasm
/// — is the crate still a cdylib?
/// ```
///
/// The message even named the wrong suspect: the crate was still a `cdylib` and
/// the build had genuinely succeeded. `cargo test --workspace` stops at the
/// first failing target, so these two failures also hid every test after them.
///
/// `cargo metadata` is the only thing that knows the answer, because it applies
/// the whole precedence chain — `--target-dir`, then `CARGO_TARGET_DIR`, then
/// `build.target-dir` from every `config.toml` on the walk up, then the default.
/// Re-implementing that chain here would be a fifth opinion about a fact cargo
/// will simply state. The env var and `<workspace>/target` remain as fallbacks
/// for a box where `cargo metadata` cannot run at all.
///
/// The parse is deliberately not a JSON dependency: `target_directory` is one
/// string in a document this crate reads for one field, and `dev-dependencies`
/// here is one line on purpose (see `Cargo.toml` — this crate's unit count is
/// load-bearing). A path containing `"` or `\` would defeat it; on the
/// platforms gunnar builds for, a target directory is an ordinary path.
fn target_dir(workspace: &std::path::Path) -> PathBuf {
    let asked = Command::new(env!("CARGO"))
        .current_dir(workspace)
        .args(["metadata", "--format-version", "1", "--no-deps"])
        .output()
        .ok()
        .filter(|out| out.status.success())
        .and_then(|out| {
            let text = String::from_utf8_lossy(&out.stdout).into_owned();
            let key = "\"target_directory\":\"";
            let start = text.find(key)? + key.len();
            let rest = &text[start..];
            let end = rest.find('"')?;
            Some(PathBuf::from(&rest[..end]))
        });
    asked
        .or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
        .unwrap_or_else(|| workspace.join("target"))
}

fn load(wasm: &Path) -> PluginHandle {
    PluginHandle::load_file_with(wasm, Limits::default(), HostFacts::at_rev("test", false))
        .expect("the module loads through the generic host")
}

/// The 0.1.9 ABI: who this is, what it declares, what the pane draws.
#[test]
fn manifest_variants_and_menu_answer_the_same_natively_and_over_wasm() {
    let Some(wasm) = build_the_plugin() else {
        return;
    };
    let native = GunnarBuildThing;
    let mut plugin = load(&wasm);

    assert_eq!(
        plugin.manifest(),
        &native.manifest(),
        "manifest differs native vs wasm"
    );
    let over_wasm = plugin.variants().expect("variants over the ABI");
    let natively = native.variants();
    assert_eq!(over_wasm, natively, "variants() differs native vs wasm");
    assert!(
        natively.variants.len() >= 8,
        "gunnar declares eight rows; a vacuous identity over none proves nothing"
    );
    // `BuildMenu` wears no `PartialEq` at 0.1.9, so the menu is compared
    // through what it is made of: the set it folds back to and the variant
    // names in the order the pane draws them.
    let menu_wasm = plugin.menu().expect("menu over the ABI");
    let menu_native = native.menu();
    assert_eq!(
        menu_wasm.set(),
        menu_native.set(),
        "menu().set() differs native vs wasm"
    );
    assert_eq!(
        menu_wasm.menu_variants(),
        menu_native.menu_variants(),
        "menu_variants() differs native vs wasm"
    );
    eprintln!(
        "native == wasm over {} rows, {} containers, {} isos, {} installers ({})",
        natively.variants.len(),
        natively.containers.len(),
        natively.isos.len(),
        natively.installers.len(),
        wasm.display()
    );
}

/// The tree ops — ops 11–13, 0.1.10.
#[test]
fn options_try_toggle_and_actions_answer_the_same_natively_and_over_wasm() {
    use nornir_build_thing::tree::{SettingsTree, ToggleRequest};
    use nornir_build_thing::variant::ArtifactKind;

    let Some(wasm) = build_the_plugin() else {
        return;
    };
    let native = GunnarBuildThing;
    let mut plugin = load(&wasm);

    let set = native.variants();
    let forest = SettingsTree::forest(&set);
    assert!(
        !forest.is_empty(),
        "gunnar declares variants, so it has trees"
    );
    let mut compared = 0usize;
    for tree in &forest {
        let over_wasm = plugin
            .options(&tree.variant, "")
            .expect("options over the ABI");
        assert_eq!(
            over_wasm.as_ref(),
            Some(tree),
            "options({}) differs native vs wasm",
            tree.variant
        );
        assert_eq!(native.options(&tree.variant, ""), over_wasm);
        for node in tree.walk() {
            let sub_native = native.options(&tree.variant, &node.path);
            let sub_wasm = plugin.options(&tree.variant, &node.path).unwrap();
            assert_eq!(
                sub_native, sub_wasm,
                "options({}, {}) differs",
                tree.variant, node.path
            );
            for on in [true, false] {
                let req = ToggleRequest::new(&tree.variant, &node.path, on);
                let d_native = native.try_toggle(&req);
                let d_wasm = plugin.try_toggle(&req).unwrap();
                assert_eq!(
                    d_native, d_wasm,
                    "try_toggle({}, {}, {on}) differs",
                    tree.variant, node.path
                );
                compared += 1;
            }
        }
    }
    assert!(
        compared > 0,
        "nothing was compared — a vacuous identity proves nothing"
    );

    assert_eq!(
        native.options("ghost", ""),
        plugin.options("ghost", "").unwrap()
    );
    let req = ToggleRequest::new("ghost", "x", false);
    assert_eq!(native.try_toggle(&req), plugin.try_toggle(&req).unwrap());

    for kind in [
        ArtifactKind::Exe,
        ArtifactKind::Lib,
        ArtifactKind::Wasm,
        ArtifactKind::Container,
        ArtifactKind::Vm,
        ArtifactKind::Iso,
        ArtifactKind::Installer,
        ArtifactKind::Service,
        ArtifactKind::Kernel,
    ] {
        assert_eq!(
            native.actions(kind),
            plugin.actions(kind).unwrap(),
            "actions({kind:?}) differs"
        );
    }
    eprintln!(
        "native == wasm over {} trees, {compared} toggles, 9 kinds",
        forest.len()
    );
}