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

nordisk/edda

crates/nornir-build-thing/src/surface.rs

//! The **build-thing surface** — the data-driven view-model a UI renders to
//! drive building a [`BuildVariant`](crate::variant::BuildVariant) (task #36).
//!
//! The [`crate::variant`] descriptor is *data*; this turns a [`BuildVariantSet`]
//! into a flat, serde-serializable surface any presentation renders without
//! knowing cargo: each declared variant with its option axes (features / profile
//! / target), its materialized cargo command, its up-front **validity** (with a
//! reason when invalid), and the **Build action** the "Build" button invokes.
//! For holger's driving example that is three rows — `embedded`, `minimal`,
//! `full`.
//!
//! Pure data: no egui — it builds in a lean context and is unit-testable. The
//! stateful [`crate::elm`] component wraps this for an interactive UI.

use serde::Serialize;

use crate::container::{ContainerBuild, ImageFormat};
use crate::iso::IsoBuild;
use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
use crate::vm::VmBuild;

/// The action a UI's "Build" control invokes for a variant: `nornir build
/// --variant <name>`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BuildAction {
    /// The verb (always `"build"`).
    pub verb: &'static str,
    /// The variant handle to pass to the materialize→build path.
    pub variant: String,
    /// The `nornir` CLI line equivalent to pressing the button.
    pub cli: String,
}

/// The **LAUNCH axis** — what STARTING (not building) a built artifact means,
/// declared per [`ArtifactKind`]. The sibling of [`BuildAction`]: `BuildAction`
/// says how to BUILD a cell; `LaunchAction` says how to START the thing once
/// built. Distinct verbs, distinct axes — a cell can be built AND launched.
///
/// This is a **declaration**, consumed by a future UI / the dwarves forge to know
/// a cell supports launch — it is **NOT an executor**. Nothing is spawned here.
/// The real run lives in a future consumer, kind-aware:
/// * `Container` → a future bollard podman/docker run of the built image;
/// * `Vm` → a future [`tunnr_vm::boot_test`] boot on a serial console;
/// * `Exe` → a plain exec of the built binary.
///
/// [`tunnr_vm::boot_test`]: https://git.gunnar.rs/nordisk
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LaunchAction {
    /// `Container` ⇒ deploy: a future bollard podman/docker RUN of the built image.
    Deploy,
    /// `Vm` / iso ⇒ boot on a serial console: a future `tunnr_vm::boot_test`.
    BootConsole,
    /// `Exe` ⇒ run the built binary directly.
    Run,
    /// `Service` ⇒ start the installed service unit.
    Start,
    /// `Wasm` ⇒ serve the pages bundle to a browser.
    Serve,
    /// `Lib` ⇒ nothing to launch (a library is linked into a consumer, never started).
    None,
}

impl LaunchAction {
    /// Map an [`ArtifactKind`] to the launch it supports — the kind-aware axis.
    pub fn for_kind(kind: ArtifactKind) -> Self {
        match kind {
            ArtifactKind::Container => LaunchAction::Deploy,
            ArtifactKind::Vm => LaunchAction::BootConsole,
            ArtifactKind::Iso => LaunchAction::BootConsole,
            ArtifactKind::Installer => LaunchAction::BootConsole,
            ArtifactKind::Exe => LaunchAction::Run,
            ArtifactKind::Service => LaunchAction::Start,
            ArtifactKind::Wasm => LaunchAction::Serve,
            ArtifactKind::Lib => LaunchAction::None,
            // A bare bzImage launches nothing by itself — it is CONSUMED by the
            // vm/iso kinds, whose declarations own the boot consoles.
            ArtifactKind::Kernel => LaunchAction::None,
            // A produced file set is INSTALLED, not launched. There is no
            // `Install` action here on purpose: installing a wheel is `pip`'s job
            // on a machine this forge does not own, and offering a button that
            // could only ever mean "install it somewhere unspecified" is the
            // affordance-that-cannot-work shape the `Serve`/`Start` split exists
            // to avoid.
            ArtifactKind::Package => LaunchAction::None,
        }
    }

    /// Stable label (matches the snake_case serde form).
    pub fn as_str(&self) -> &'static str {
        match self {
            LaunchAction::Deploy => "deploy",
            LaunchAction::BootConsole => "boot_console",
            LaunchAction::Run => "run",
            LaunchAction::Start => "start",
            LaunchAction::Serve => "serve",
            LaunchAction::None => "none",
        }
    }

    /// Whether this kind can be launched at all (everything but a [`Lib`](ArtifactKind::Lib)).
    pub fn is_launchable(&self) -> bool {
        !matches!(self, LaunchAction::None)
    }
}

/// **SEAM (data only): the launch-OUTPUT stream a launched artifact produces.**
///
/// This is a typed placeholder that NAMES — and only names — the hand-off point
/// for a SEPARATE, future logs framework. There is no streaming, no rendering, no
/// logs dependency, and no executor here: this crate never attaches a stream. A
/// deployed container / run exe / started service will emit [`Logs`](Self::Logs)
/// (stdout/stderr); a booted vm/iso will emit a [`Serial`](Self::Serial) console.
/// The future logs framework reads THIS declaration to know which stream to
/// attach; wiring it up is explicitly out of scope for this phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LaunchOutput {
    /// A stdout/stderr log stream (a deployed container, a run exe / started service,
    /// a served pages host).
    Logs,
    /// A serial console (a booted vm / iso).
    Serial,
    /// No launch output (a non-launchable lib).
    None,
}

impl LaunchOutput {
    /// The output-stream kind a given [`LaunchAction`] hands the future logs
    /// framework — the kind-aware seam mapping.
    pub fn for_action(action: LaunchAction) -> Self {
        match action {
            LaunchAction::BootConsole => LaunchOutput::Serial,
            LaunchAction::Deploy
            | LaunchAction::Run
            | LaunchAction::Start
            | LaunchAction::Serve => LaunchOutput::Logs,
            LaunchAction::None => LaunchOutput::None,
        }
    }

    /// Stable label (matches the snake_case serde form).
    pub fn as_str(&self) -> &'static str {
        match self {
            LaunchOutput::Logs => "logs",
            LaunchOutput::Serial => "serial",
            LaunchOutput::None => "none",
        }
    }
}

/// What a UI's "Launch"/"Start" control DECLARES for an artifact — the launch-axis
/// analog of [`BuildAction`]. Kind-aware and purely declarative: it carries the
/// [`LaunchAction`] the cell supports, the CLI line a future launch control would
/// invoke, and the [`LaunchOutput`] SEAM naming the stream a future logs framework
/// will attach. No process is spawned by constructing this.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LaunchDecl {
    /// The kind-aware launch action this artifact supports.
    pub action: LaunchAction,
    /// Whether the artifact can be launched at all (`false` for a lib).
    pub launchable: bool,
    /// The `nornir` CLI line a launch control would invoke (`None` for a lib).
    pub cli: Option<String>,
    /// **SEAM** — the launch-output stream kind the future logs framework attaches.
    /// Declaration only; nothing is streamed here.
    pub output: LaunchOutput,
}

impl LaunchDecl {
    /// Declare the launch axis for an artifact of `kind` named `name`.
    pub fn for_kind(kind: ArtifactKind, name: &str) -> Self {
        let action = LaunchAction::for_kind(kind);
        let launchable = action.is_launchable();
        LaunchDecl {
            action,
            launchable,
            cli: launchable.then(|| format!("nornir launch {name}")),
            output: LaunchOutput::for_action(action),
        }
    }
}

/// One variant, projected for display + a build control.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct VariantView {
    /// The variant handle.
    pub name: String,
    /// The artifact kind this variant produces (`exe` / `lib` / `wasm` /
    /// `service` / `container` / `vm`) — the output-KIND axis, so a UI can group
    /// or colour every declared artifact by kind uniformly.
    pub kind: String,
    /// `-p <package>`, if any.
    pub package: Option<String>,
    /// `--bin <bin>`, if any.
    pub bin: Option<String>,
    /// Enabled features (an option axis).
    pub features: Vec<String>,
    /// Whether `--no-default-features` is set.
    pub no_default_features: bool,
    /// Canonical profile name (`dev` / `release` / custom).
    pub profile: String,
    /// Cross-compile target triple (an option axis), if any.
    pub target: Option<String>,
    /// The materialized cargo argv.
    pub cargo_args: Vec<String>,
    /// The materialized command line, ready to display.
    pub command_line: String,
    /// Whether this variant is valid for the owning set.
    pub valid: bool,
    /// The rejection reason when `valid == false`.
    pub invalid_reason: Option<String>,
    /// What the "Build" button invokes.
    pub build_action: BuildAction,
    /// What the "Launch" control declares — the kind-aware LAUNCH axis (an `Exe`
    /// runs, a `Service` starts, a `Wasm` serves, a `Lib` is not launchable).
    /// Declaration only; no executor here.
    pub launch: LaunchDecl,
}

impl VariantView {
    fn of(set: &BuildVariantSet, v: &BuildVariant) -> Self {
        let (valid, invalid_reason) = match set.validate(v) {
            Ok(()) => (true, None),
            Err(e) => (false, Some(e.to_string())),
        };
        VariantView {
            name: v.name.clone(),
            kind: v.kind.as_str().to_string(),
            package: v.package.clone(),
            bin: v.bin.clone(),
            features: v.features.clone(),
            no_default_features: v.no_default_features,
            profile: String::from(v.profile.clone()),
            target: v.target.clone(),
            cargo_args: v.cargo_args(),
            command_line: v.command_line(),
            valid,
            invalid_reason,
            build_action: BuildAction {
                verb: "build",
                variant: v.name.clone(),
                cli: format!("nornir build --variant {}", v.name),
            },
            launch: LaunchDecl::for_kind(v.kind, &v.name),
        }
    }

    /// Project ONE [`Declared`] row — whatever collection it was declared in.
    ///
    /// A `[[build.variant]]` row projects exactly as [`of`](Self::of) does. A sugar
    /// row (`[[build.container]]` / `[[build.vm]]` / `[[build.iso]]` /
    /// `[[build.installer]]`) projects through its OWN validator, because a
    /// synthesized `BuildVariant { name, kind }` would be judged by
    /// [`BuildVariantSet::validate_kind`]'s cargo rules and, e.g., fail a container
    /// row for having no `container_ref` — a validity answer from the wrong
    /// question. The rich payload stays in [`ContainerView`] / [`VmView`] /
    /// [`IsoView`]; this is only the unified ROW.
    ///
    /// The branch keys on `d.variant`, not on the payload discriminant: since
    /// 2026-08-23 a PAIRED declaration (a pointer variant plus its sugar cell) is
    /// one row carrying BOTH, and its cargo row is still what a picker offers.
    fn of_declared(set: &BuildVariantSet, d: &crate::variant::Declared<'_>) -> Self {
        use crate::variant::DeclaredPayload as P;
        if let Some(v) = d.variant {
            return Self::of(set, v);
        }
        let (valid, invalid_reason) = {
            let r = match d.payload {
                P::Variant(_) => unreachable!("handled above"),
                P::Container(c) => set.validate_container(c),
                P::Vm(v) => set.validate_vm(v),
                P::Iso(i) => set.validate_iso(i),
                P::Installer(i) => set.validate_installer(i),
                P::Kernel(k) => set.validate_kernel(k),
                P::Package(p) => set.validate_package(p),
            };
            match r {
                Ok(()) => (true, None),
                Err(e) => (false, Some(e.to_string())),
            }
        };
        let command_line = match d.payload {
            P::Variant(_) => unreachable!("handled above"),
            P::Container(c) => c.command_line("docker"),
            P::Vm(v) => v.command_line("qemu-system-x86_64"),
            P::Iso(i) => i.command_line(&format!("{}.iso", i.name)),
            P::Installer(i) => i.installer_iso().command_line(&format!("{}.iso", i.name)),
            P::Kernel(k) => k.command_line("podman"),
            // What a package build RUNS is the format's own tool inside the
            // pinned image — `python -m build` for pip. Naming the image and not
            // just the tool because the image IS the pin: the same command in two
            // images is two different builds.
            P::Package(p) => format!(
                "{} ({} in {})",
                p.format.tool(),
                p.distribution,
                p.image.as_deref().unwrap_or("<no pinned image>")
            ),
        };
        VariantView {
            name: d.name.to_string(),
            kind: d.kind.as_str().to_string(),
            package: None,
            bin: None,
            features: Vec::new(),
            no_default_features: false,
            profile: String::from(crate::variant::Profile::Release),
            target: None,
            cargo_args: Vec::new(),
            command_line,
            valid,
            invalid_reason,
            build_action: BuildAction {
                verb: "build",
                variant: d.name.to_string(),
                cli: format!("nornir build --variant {}", d.name),
            },
            launch: LaunchDecl::for_kind(d.kind, d.name),
        }
    }
}

/// One image/appliance build, projected for display + a build control — the
/// container-target sibling of [`VariantView`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ContainerView {
    /// The image build handle.
    pub name: String,
    /// The artifact kind — always `container` (the output-KIND axis, uniform with
    /// [`VariantView::kind`]).
    pub kind: String,
    /// Base image (`FROM`).
    pub base: String,
    /// Output format (`oci` / `docker` / `appliance`).
    pub format: String,
    /// Image tags applied (`-t`).
    pub tags: Vec<String>,
    /// Cargo build variants this image chains as layers (must be built first).
    pub variant_deps: Vec<String>,
    /// Number of payload layers.
    pub layer_count: usize,
    /// The materialized Containerfile (Dockerfile syntax).
    pub containerfile: String,
    /// The materialized image-builder command line (docker/podman/buildah).
    pub command_line: String,
    /// Content-addressed image-spec id (SHA-256 over the Containerfile + tags).
    pub content_id: String,
    /// Whether the descriptor is valid for the owning set.
    pub valid: bool,
    /// The rejection reason when `valid == false`.
    pub invalid_reason: Option<String>,
    /// What the "Build image" button invokes.
    pub build_action: BuildAction,
    /// What the "Launch" control declares — always [`LaunchAction::Deploy`] (a
    /// future bollard podman/docker run of the built image). Declaration only.
    pub launch: LaunchDecl,
}

impl ContainerView {
    fn of(set: &BuildVariantSet, c: &ContainerBuild) -> Self {
        let (valid, invalid_reason) = match set.validate_container(c) {
            Ok(()) => (true, None),
            Err(e) => (false, Some(e.to_string())),
        };
        let format = match &c.format {
            ImageFormat::Oci => "oci",
            ImageFormat::Docker => "docker",
            ImageFormat::Appliance => "appliance",
        }
        .to_string();
        ContainerView {
            name: c.name.clone(),
            kind: c.artifact_kind().as_str().to_string(),
            base: c.base.clone(),
            format,
            tags: c.tags.clone(),
            variant_deps: c.variant_deps().iter().map(|s| s.to_string()).collect(),
            layer_count: c.layers.len(),
            containerfile: c.containerfile(),
            command_line: c.command_line("docker"),
            content_id: c.content_id(),
            valid,
            invalid_reason,
            build_action: BuildAction {
                verb: "build",
                variant: c.name.clone(),
                cli: format!("nornir build --image {}", c.name),
            },
            launch: LaunchDecl::for_kind(c.artifact_kind(), &c.name),
        }
    }
}

/// One VM/appliance boot, projected for display + a build/boot control — the
/// bootable sibling of [`VariantView`] and [`ContainerView`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct VmView {
    /// The VM build handle.
    pub name: String,
    /// The artifact kind — always `vm` (the output-KIND axis, uniform with
    /// [`VariantView::kind`]).
    pub kind: String,
    /// Kernel image path (`-kernel`).
    pub kernel: String,
    /// Rootfs/initramfs path (`-initrd`), or empty for a disk boot.
    pub rootfs: String,
    /// Disk image path (`-drive`), or empty for a kernel+rootfs boot.
    pub disk: String,
    /// Guest memory (MiB).
    pub mem_mb: u32,
    /// Guest vCPU count.
    pub cores: u32,
    /// Boot flavor (`qemu` / `microvm` / `appliance`).
    pub format: String,
    /// The kernel command line (`boot_params` joined).
    pub cmdline: String,
    /// Cargo build variant this VM boots (`from_variant`), if any.
    pub variant_deps: Vec<String>,
    /// The materialized QEMU command line.
    pub command_line: String,
    /// Content-addressed boot-spec id (SHA-256 over the QEMU argv + format).
    pub content_id: String,
    /// Whether the descriptor is valid for the owning set.
    pub valid: bool,
    /// The rejection reason when `valid == false`.
    pub invalid_reason: Option<String>,
    /// What the "Boot VM" button invokes.
    pub build_action: BuildAction,
    /// What the "Launch" control declares — always [`LaunchAction::BootConsole`]
    /// (a future `tunnr_vm::boot_test` boot on a serial console). Declaration only.
    pub launch: LaunchDecl,
}

impl VmView {
    fn of(set: &BuildVariantSet, vm: &VmBuild) -> Self {
        let (valid, invalid_reason) = match set.validate_vm(vm) {
            Ok(()) => (true, None),
            Err(e) => (false, Some(e.to_string())),
        };
        VmView {
            name: vm.name.clone(),
            kind: vm.artifact_kind().as_str().to_string(),
            kernel: vm.kernel.clone(),
            rootfs: vm.rootfs.clone(),
            disk: vm.disk.clone(),
            mem_mb: vm.mem_mb,
            cores: vm.cores,
            format: vm.format.as_str().to_string(),
            cmdline: vm.cmdline(),
            variant_deps: vm.variant_deps().iter().map(|s| s.to_string()).collect(),
            command_line: vm.command_line("qemu-system-x86_64"),
            content_id: vm.content_id(),
            valid,
            invalid_reason,
            build_action: BuildAction {
                verb: "build",
                variant: vm.name.clone(),
                cli: format!("nornir build --vm {}", vm.name),
            },
            launch: LaunchDecl::for_kind(vm.artifact_kind(), &vm.name),
        }
    }
}

/// One bootable-ISO build, projected for display + a build/boot control — the ISO
/// sibling of [`VariantView`], [`ContainerView`] and [`VmView`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IsoView {
    /// The ISO build handle.
    pub name: String,
    /// The artifact kind — always `iso` (the output-KIND axis, uniform with
    /// [`VariantView::kind`]).
    pub kind: String,
    /// Kernel image placed as the El-Torito/EFI boot payload (empty for a GRUB ISO).
    pub kernel: String,
    /// Rootfs/initramfs the boot payload loads (empty when the rootfs is on the ISO).
    pub rootfs: String,
    /// Staging directory whose contents are mastered onto the ISO (empty if none).
    pub staging: String,
    /// The ISO-9660 volume id (`-V`).
    pub volume_id: String,
    /// ISO flavor (`bios` / `uefi` / `hybrid`).
    pub format: String,
    /// The kernel command line (`boot_params` joined).
    pub cmdline: String,
    /// Cargo build variant this ISO masters (`from_variant`), if any.
    pub variant_deps: Vec<String>,
    /// The materialized `xorriso` mastering command line.
    pub command_line: String,
    /// Content-addressed ISO-spec id (SHA-256 over the mastering argv + format).
    pub content_id: String,
    /// Whether the descriptor is valid for the owning set.
    pub valid: bool,
    /// The rejection reason when `valid == false`.
    pub invalid_reason: Option<String>,
    /// What the "Master ISO" button invokes.
    pub build_action: BuildAction,
    /// What the "Launch" control declares — always [`LaunchAction::BootConsole`]
    /// (a future `tunnr_vm::boot_test` firmware boot off the ISO on a serial
    /// console). Declaration only.
    pub launch: LaunchDecl,
}

impl IsoView {
    fn of(set: &BuildVariantSet, iso: &IsoBuild) -> Self {
        let (valid, invalid_reason) = match set.validate_iso(iso) {
            Ok(()) => (true, None),
            Err(e) => (false, Some(e.to_string())),
        };
        let out = format!("{}.iso", iso.name);
        IsoView {
            name: iso.name.clone(),
            kind: iso.artifact_kind().as_str().to_string(),
            kernel: iso.kernel.clone(),
            rootfs: iso.rootfs.clone(),
            staging: iso.staging.clone(),
            volume_id: iso.volume_id.clone(),
            format: iso.format.as_str().to_string(),
            cmdline: iso.cmdline(),
            variant_deps: iso.variant_deps().iter().map(|s| s.to_string()).collect(),
            command_line: iso.command_line(&out),
            content_id: iso.content_id(),
            valid,
            invalid_reason,
            build_action: BuildAction {
                verb: "build",
                variant: iso.name.clone(),
                cli: format!("nornir build --iso {}", iso.name),
            },
            launch: LaunchDecl::for_kind(iso.artifact_kind(), &iso.name),
        }
    }
}

/// A `containerfile = "…"` cell whose file is NOT in the checkout must project as
/// invalid, with the reason — that is what stops `--list` advertising a target
/// that cannot build. `from_set` (filesystem-free) still calls it valid, which is
/// exactly why the root-aware constructor exists.
#[cfg(test)]
#[test]
fn a_missing_containerfile_projects_as_invalid_with_a_reason() {
    let set = BuildVariantSet::from_toml_str(
        r#"
[build]
[[build.container]]
name = "spark-iceberg"
containerfile = "containers/spark-iceberg/Containerfile"
tags = ["localhost/nordisk-spark-iceberg:4.1.2"]
"#,
    )
    .unwrap();
    let root = tempfile::tempdir().unwrap();

    let blind = BuildSurface::from_set("korp", &set);
    assert!(blind.containers[0].valid, "shape-only projection cannot see the filesystem");

    let honest = BuildSurface::from_set_in("korp", &set, root.path());
    assert!(!honest.containers[0].valid, "the Containerfile is not in this checkout");
    let why = honest.containers[0].invalid_reason.as_deref().unwrap();
    assert!(why.contains("does not exist"), "{why}");
    // And the projected command line names the real file, not a stdin pipe.
    assert!(
        honest.containers[0].command_line.contains("-f containers/spark-iceberg/Containerfile"),
        "{}",
        honest.containers[0].command_line
    );

    // Materialize it: the same declaration projects as buildable.
    std::fs::create_dir_all(root.path().join("containers/spark-iceberg")).unwrap();
    std::fs::write(
        root.path().join("containers/spark-iceberg/Containerfile"),
        "FROM docker.io/apache/spark:4.1.2\n",
    )
    .unwrap();
    assert!(BuildSurface::from_set_in("korp", &set, root.path()).containers[0].valid);
}

/// The whole build surface a UI renders: the option-space axes + every declared
/// variant projected for display and a Build action.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BuildSurface {
    /// The repo whose build space this is.
    pub repo: String,
    /// The feature universe the repo offers (empty = unconstrained).
    pub offers: Vec<String>,
    /// Mutually-exclusive feature groups.
    pub exclusive: Vec<Vec<String>>,
    /// Per-bin required features.
    pub required: std::collections::BTreeMap<String, Vec<String>>,
    /// **Every declared buildable, projected — the build list.** One row per
    /// declared variant WHATEVER its kind: the `[[build.variant]]` rows plus the
    /// `[[build.container]]` / `[[build.vm]]` / `[[build.iso]]` /
    /// `[[build.installer]]` sugar rows, in declaration order.
    ///
    /// This is the field a picker populates from. Projected from the ONE
    /// enumeration [`BuildVariantSet::declarations`], so a UI structurally cannot
    /// be missing a kind — before it existed, `facett`'s variant ComboBox read
    /// `variants` and therefore could not list an installer at all.
    pub declared: Vec<VariantView>,
    /// The `[[build.variant]]` (cargo) rows only, projected.
    ///
    /// ⚠ Kept for the callers that specifically want the cargo rows. For "what can
    /// this repo build", use [`declared`](Self::declared).
    pub variants: Vec<VariantView>,
    /// Every declared image/appliance build, projected (the container kind).
    pub containers: Vec<ContainerView>,
    /// Every declared VM/appliance boot, projected (the vm kind).
    pub vms: Vec<VmView>,
    /// Every declared bootable-ISO build, projected (the iso kind).
    pub isos: Vec<IsoView>,
}

impl BuildSurface {
    /// Build the surface from a repo name + its [`BuildVariantSet`].
    pub fn from_set(repo: impl Into<String>, set: &BuildVariantSet) -> Self {
        BuildSurface {
            repo: repo.into(),
            offers: set.offers.iter().cloned().collect(),
            exclusive: set.exclusive.clone(),
            required: set.required.clone(),
            declared: set
                .declarations()
                .iter()
                .map(|d| VariantView::of_declared(set, d))
                .collect(),
            variants: set.variants.iter().map(|v| VariantView::of(set, v)).collect(),
            containers: set.containers.iter().map(|c| ContainerView::of(set, c)).collect(),
            vms: set.vms.iter().map(|vm| VmView::of(set, vm)).collect(),
            isos: set.isos.iter().map(|iso| IsoView::of(set, iso)).collect(),
        }
    }

    /// [`from_set`](Self::from_set) against a real checkout, so a container cell's
    /// `valid` flag reflects the FILESYSTEM as well as the shape.
    ///
    /// The difference is only visible for a cell declaring
    /// `containerfile = "…"`: `from_set` cannot see whether that file is in the
    /// checkout, so it would project the cell as buildable and let `--list`
    /// advertise a target that dies the moment the engine is asked for it. Any
    /// caller that already holds the repo root should prefer this constructor;
    /// `from_set` stays for the filesystem-free callers (a build-thing wasm plugin
    /// projecting a set it can only see as data).
    ///
    /// Synthesis-mode cells are byte-identical between the two — they consult no
    /// disk at all.
    pub fn from_set_in(
        repo: impl Into<String>,
        set: &BuildVariantSet,
        repo_root: &std::path::Path,
    ) -> Self {
        let mut s = Self::from_set(repo, set);
        for (view, c) in s.containers.iter_mut().zip(set.containers.iter()) {
            if view.valid {
                if let Err(e) = c.validate_in(repo_root) {
                    view.valid = false;
                    view.invalid_reason = Some(e.to_string());
                }
            }
        }
        // The unified rows carry the SAME filesystem verdict — a `--list` that reads
        // `declared` must not advertise a cell the `containers` projection refused.
        for c in set.containers.iter() {
            if let Err(e) = c.validate_in(repo_root) {
                if let Some(row) = s.declared.iter_mut().find(|r| r.name == c.name) {
                    if row.valid {
                        row.valid = false;
                        row.invalid_reason = Some(e.to_string());
                    }
                }
            }
        }
        s
    }

    /// **Every declared name, in declaration order — the build list.** The unified
    /// picker list: cargo variants AND container / vm / iso / installer rows.
    pub fn declared_names(&self) -> Vec<&str> {
        self.declared.iter().map(|d| d.name.as_str()).collect()
    }

    /// The declared bootable-ISO build names, in declaration order.
    pub fn iso_names(&self) -> Vec<&str> {
        self.isos.iter().map(|iso| iso.name.as_str()).collect()
    }

    /// The declared image/appliance build names, in declaration order.
    pub fn container_names(&self) -> Vec<&str> {
        self.containers.iter().map(|c| c.name.as_str()).collect()
    }

    /// The declared VM/appliance boot names, in declaration order.
    pub fn vm_names(&self) -> Vec<&str> {
        self.vms.iter().map(|vm| vm.name.as_str()).collect()
    }

    /// The declared variant names, in declaration order.
    pub fn variant_names(&self) -> Vec<&str> {
        self.variants.iter().map(|v| v.name.as_str()).collect()
    }

    /// The **LAUNCH axis**, flattened across every declared artifact (variants +
    /// containers + vms) as `(name, kind, LaunchDecl)`, in that order. The
    /// per-kind declaration a future UI / the dwarves forge reads to know which
    /// cells support a start and what stream each hands the future logs framework.
    /// Purely declarative — no executor.
    pub fn launch_axis(&self) -> Vec<(&str, &str, &LaunchDecl)> {
        let mut out: Vec<(&str, &str, &LaunchDecl)> = self
            .variants
            .iter()
            .map(|v| (v.name.as_str(), v.kind.as_str(), &v.launch))
            .collect();
        out.extend(
            self.containers
                .iter()
                .map(|c| (c.name.as_str(), c.kind.as_str(), &c.launch)),
        );
        out.extend(
            self.vms
                .iter()
                .map(|vm| (vm.name.as_str(), vm.kind.as_str(), &vm.launch)),
        );
        out.extend(
            self.isos
                .iter()
                .map(|iso| (iso.name.as_str(), iso.kind.as_str(), &iso.launch)),
        );
        out
    }

    /// The `state_json` surface — the shape a viz pane / MCP tool reads.
    pub fn state_json(&self) -> serde_json::Value {
        // The LAUNCH axis, flattened as a per-artifact declaration list so a
        // consumer reads "what can be STARTED" without walking three collections.
        let launch: Vec<serde_json::Value> = self
            .launch_axis()
            .into_iter()
            .map(|(name, kind, decl)| {
                serde_json::json!({
                    "name": name,
                    "kind": kind,
                    "action": decl.action,
                    "launchable": decl.launchable,
                    "cli": decl.cli,
                    "output": decl.output,
                })
            })
            .collect();
        serde_json::json!({
            "surface": "build",
            "repo": self.repo,
            "offers": self.offers,
            "exclusive": self.exclusive,
            // `variant_count` is a wire-ish key robots already point at, so the KEY
            // stays and the VALUE is corrected: under the vocabulary ruling a
            // declared container / iso / installer IS a variant, and counting only
            // the cargo rows under-reported every repo that declares one.
            "variant_count": self.declared.len(),
            "valid_count": self.declared.iter().filter(|v| v.valid).count(),
            "declared": self.declared,
            "cargo_variant_count": self.variants.len(),
            "variants": self.variants,
            "container_count": self.containers.len(),
            "containers": self.containers,
            "vm_count": self.vms.len(),
            "vms": self.vms,
            "iso_count": self.isos.len(),
            "isos": self.isos,
            // The LAUNCH axis — per-kind declaration of what STARTING each artifact
            // means (a container deploys, a vm boots to a serial console, an exe
            // runs). Declaration for a future UI / dwarves; NOT an executor.
            "launch": launch,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const HOLGER: &str = r#"
[build]
offers = ["embedded", "minimal", "full", "served", "gui"]
exclusive = [["embedded", "minimal", "full"]]

[build.required]
"holger-full" = ["full"]

[[build.variant]]
name = "embedded"
package = "holger-embedded"
bin = "holger-embedded"
features = ["embedded"]
no_default_features = true
profile = "release"

[[build.variant]]
name = "full"
package = "holger-full"
bin = "holger-full"
features = ["full", "served", "gui"]
profile = "release"
"#;

    fn holger() -> BuildVariantSet {
        BuildVariantSet::from_toml_str(HOLGER).unwrap()
    }

    #[test]
    fn surface_enumerates_variants_with_commands_and_actions() {
        let surface = BuildSurface::from_set("holger", &holger());
        assert_eq!(surface.repo, "holger");
        assert_eq!(surface.variant_names(), vec!["embedded", "full"]);
        assert!(surface.offers.contains(&"full".to_string()));

        let full = surface.variants.iter().find(|v| v.name == "full").unwrap();
        assert!(full.valid, "full satisfies holger-full's required 'full' feature");
        assert!(full.command_line.contains("cargo build -p holger-full"));
        assert_eq!(full.build_action.cli, "nornir build --variant full");
    }

    #[test]
    fn surface_flags_an_invalid_variant_with_a_reason() {
        let mut set = holger();
        set.variants.push(BuildVariant {
            name: "full-broken".to_string(),
            package: Some("holger-full".to_string()),
            bin: Some("holger-full".to_string()),
            features: vec![], // missing required "full"
            ..Default::default()
        });
        let surface = BuildSurface::from_set("holger", &set);
        let broken = surface.variants.iter().find(|v| v.name == "full-broken").unwrap();
        assert!(!broken.valid);
        assert!(broken.invalid_reason.as_deref().unwrap().contains("requires feature 'full'"));
    }

    #[test]
    fn state_json_exposes_the_surface_shape() {
        let surface = BuildSurface::from_set("holger", &holger());
        let j = surface.state_json();
        assert_eq!(j["surface"], "build");
        assert_eq!(j["variant_count"], 2);
        assert_eq!(j["valid_count"], 2);
        assert_eq!(j["variants"][0]["name"], "embedded");
        // a pure-cargo repo declares no images.
        assert_eq!(j["container_count"], 0);
    }

    #[test]
    fn launch_action_maps_per_artifact_kind() {
        // The kind → launch mapping is exhaustive and distinct per kind.
        assert_eq!(LaunchAction::for_kind(ArtifactKind::Exe), LaunchAction::Run);
        assert_eq!(LaunchAction::for_kind(ArtifactKind::Lib), LaunchAction::None);
        assert_eq!(LaunchAction::for_kind(ArtifactKind::Wasm), LaunchAction::Serve);
        assert_eq!(LaunchAction::for_kind(ArtifactKind::Service), LaunchAction::Start);
        assert_eq!(LaunchAction::for_kind(ArtifactKind::Container), LaunchAction::Deploy);
        assert_eq!(LaunchAction::for_kind(ArtifactKind::Vm), LaunchAction::BootConsole);

        // A lib is the one non-launchable kind; everything else launches.
        assert!(!LaunchAction::None.is_launchable());
        assert!(LaunchAction::Run.is_launchable());

        // The LOG/SERIAL seam is kind-aware: a booted vm streams a serial console,
        // a deployed container/run exe streams logs, a lib streams nothing.
        assert_eq!(LaunchOutput::for_action(LaunchAction::BootConsole), LaunchOutput::Serial);
        assert_eq!(LaunchOutput::for_action(LaunchAction::Deploy), LaunchOutput::Logs);
        assert_eq!(LaunchOutput::for_action(LaunchAction::Run), LaunchOutput::Logs);
        assert_eq!(LaunchOutput::for_action(LaunchAction::None), LaunchOutput::None);
    }

    #[test]
    fn state_json_carries_the_per_kind_launch_action() {
        // holger's two variants are plain executables → the RUN launch, a logs seam.
        let surface = BuildSurface::from_set("holger", &holger());
        let j = surface.state_json();

        // Each VariantView carries its launch declaration inline.
        assert_eq!(j["variants"][0]["launch"]["action"], "run");
        assert_eq!(j["variants"][0]["launch"]["launchable"], true);
        assert_eq!(j["variants"][0]["launch"]["output"], "logs");
        assert_eq!(j["variants"][0]["launch"]["cli"], "nornir launch embedded");

        // The flattened top-level launch axis names every artifact + its action.
        let launch = j["launch"].as_array().expect("launch axis array");
        assert_eq!(launch.len(), 2);
        assert_eq!(launch[0]["name"], "embedded");
        assert_eq!(launch[0]["kind"], "exe");
        assert_eq!(launch[0]["action"], "run");
        assert_eq!(launch[0]["output"], "logs");
    }

    #[test]
    fn state_json_launch_axis_spans_container_and_vm_kinds() {
        // A repo declaring an image AND a vm shows the deploy/boot-console launches.
        let set = BuildVariantSet::from_toml_str(WITH_IMAGE).unwrap();
        let surface = BuildSurface::from_set("tunnr", &set);
        let img = &surface.containers[0];
        assert_eq!(img.launch.action, LaunchAction::Deploy);
        assert_eq!(img.launch.output, LaunchOutput::Logs);
        assert_eq!(img.launch.cli.as_deref(), Some("nornir launch tunnr-appliance"));

        let vmset = BuildVariantSet::from_toml_str(WITH_VM).unwrap();
        let vmsurface = BuildSurface::from_set("holger", &vmset);
        let vm = &vmsurface.vms[0];
        assert_eq!(vm.launch.action, LaunchAction::BootConsole);
        assert_eq!(vm.launch.output, LaunchOutput::Serial, "a booted vm hands the logs seam a SERIAL console");

        let j = vmsurface.state_json();
        let vm_launch = j["launch"]
            .as_array()
            .unwrap()
            .iter()
            .find(|e| e["kind"] == "vm")
            .expect("vm in launch axis");
        assert_eq!(vm_launch["action"], "boot_console");
        assert_eq!(vm_launch["output"], "serial");
    }

    const WITH_IMAGE: &str = r#"
[build]
offers = ["full"]

[[build.variant]]
name = "full"
package = "tunnr-full"
bin = "tunnr"
features = ["full"]
profile = "release"

[[build.container]]
name = "tunnr-appliance"
base = "debian:bookworm-slim"
entrypoint = ["/usr/local/bin/tunnr"]
tags = ["tunnr-appliance:0.1"]

[[build.container.layers]]
from_variant = "full"
dest = "/usr/local/bin/tunnr"
"#;

    #[test]
    fn surface_projects_container_builds() {
        let set = BuildVariantSet::from_toml_str(WITH_IMAGE).unwrap();
        set.validate_all().expect("cargo + image targets validate");
        let surface = BuildSurface::from_set("tunnr", &set);

        assert_eq!(surface.container_names(), vec!["tunnr-appliance"]);
        let img = &surface.containers[0];
        assert!(img.valid, "the chained cargo variant 'full' is declared");
        assert_eq!(img.base, "debian:bookworm-slim");
        assert_eq!(img.variant_deps, vec!["full"]);
        assert_eq!(img.layer_count, 1);
        assert!(img.containerfile.contains("FROM debian:bookworm-slim"));
        // Same set through the root-aware constructor: a synthesis cell touches no
        // disk, so the projection is byte-identical to `from_set`.
        let root = tempfile::tempdir().unwrap();
        assert_eq!(
            BuildSurface::from_set_in("tunnr", &set, root.path()),
            BuildSurface::from_set("tunnr", &set),
            "from_set_in only differs for a `containerfile = ...` cell"
        );
        assert!(img.command_line.contains("docker build -t tunnr-appliance:0.1"));
        assert_eq!(img.content_id.len(), 64);
        assert_eq!(img.build_action.cli, "nornir build --image tunnr-appliance");

        let j = surface.state_json();
        assert_eq!(j["container_count"], 1);
        assert_eq!(j["containers"][0]["name"], "tunnr-appliance");
    }

    const WITH_VM: &str = r#"
[build]
offers = ["full"]

[[build.variant]]
name = "full"
package = "holger-full"
bin = "holger-full"
features = ["full"]
profile = "release"

[[build.vm]]
name = "holger-appliance"
kernel = "/boot/vmlinuz"
rootfs = "artifacts/rootfs.cpio.gz"
mem_mb = 512
cores = 2
boot_params = ["console=ttyS0", "panic=-1"]
format = "qemu"
from_variant = "full"
"#;

    #[test]
    fn surface_projects_vm_builds() {
        let set = BuildVariantSet::from_toml_str(WITH_VM).unwrap();
        set.validate_all().expect("cargo + vm targets validate");
        let surface = BuildSurface::from_set("holger", &set);

        assert_eq!(surface.vm_names(), vec!["holger-appliance"]);
        let vm = &surface.vms[0];
        assert!(vm.valid, "the chained cargo variant 'full' is declared");
        assert_eq!(vm.kernel, "/boot/vmlinuz");
        assert_eq!(vm.mem_mb, 512);
        assert_eq!(vm.cores, 2);
        assert_eq!(vm.variant_deps, vec!["full"]);
        assert_eq!(vm.cmdline, "console=ttyS0 panic=-1");
        assert!(vm.command_line.contains("-kernel /boot/vmlinuz"));
        assert_eq!(vm.content_id.len(), 64);
        assert_eq!(vm.build_action.cli, "nornir build --vm holger-appliance");

        let j = surface.state_json();
        assert_eq!(j["vm_count"], 1);
        assert_eq!(j["vms"][0]["name"], "holger-appliance");
    }

    #[test]
    fn surface_flags_a_vm_booting_a_missing_variant() {
        let toml = r#"
[build]
[[build.vm]]
name = "orphan-vm"
kernel = "/boot/vmlinuz"
rootfs = "rootfs.cpio.gz"
from_variant = "ghost"
"#;
        let set = BuildVariantSet::from_toml_str(toml).unwrap();
        let surface = BuildSurface::from_set("x", &set);
        let vm = &surface.vms[0];
        assert!(!vm.valid);
        assert!(vm.invalid_reason.as_deref().unwrap().contains("not declared"));
    }

    #[test]
    fn surface_flags_an_image_chaining_a_missing_variant() {
        // Same image but the cargo 'full' variant is absent → invalid with reason.
        let toml = r#"
[build]
[[build.container]]
name = "orphan"
base = "scratch"
[[build.container.layers]]
from_variant = "ghost"
dest = "/app"
"#;
        let set = BuildVariantSet::from_toml_str(toml).unwrap();
        let surface = BuildSurface::from_set("x", &set);
        let img = &surface.containers[0];
        assert!(!img.valid);
        assert!(img.invalid_reason.as_deref().unwrap().contains("not declared"));
    }
}