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