crates/nornir-build-thing/src/plugin.rs
//! **A build thing is FUNCTIONALITY, not a table.**
//!
//! The declarations started as data files (`<repo>/.nornir/build-variants.toml`) and
//! the obvious next step was to make them better data — a JSON the app emits. That is
//! wrong, and the reason is visible in this crate's own module list: `analyze`,
//! `assemble`, `container`, `image_build`, `provenance`, `selftest`, `feature_axis`.
//! Those are **verbs**. A pane's INVALID chip is per-app validity *logic*. None of it
//! serializes.
//!
//! So an app's build thing is a [`BuildThing`] implementation — code — living in a
//! crate the app depends on. The app compiles it in, so the app genuinely describes
//! **and validates** itself, and a missing build thing is a compile error rather than
//! a file nobody noticed was absent.
//!
//! # Why the forge loads WASM
//!
//! You cannot load functionality at runtime as data. If dwarves `dep:`ed every
//! buildspec crate it could only ever know the apps that existed at its own compile
//! time; adding an app would mean rebuilding the forge. So the same crate compiles to
//! `wasm32` as well as native — **one implementation, two link targets** — and the
//! forge loads the `.wasm` and *calls* it.
//!
//! That the two targets are one source is load-bearing, not incidental: a native impl
//! and a wasm impl that could drift would rot exactly the way the TOML did. The
//! buildspec crates therefore have no `cfg`-forked logic, and a test builds both.
//!
//! # Why a core module and no WASI
//!
//! The component model (`wasm32-wasip2` + WIT) is the more fashionable answer and was
//! considered. This uses a **core `wasm32-unknown-unknown` module** with the narrow
//! ABI in [`abi`], for one decisive reason: the security requirement is *the sandbox
//! is granted nothing*. A wasip2 component links WASI and you must then carefully
//! **deny** each capability — filesystem, sockets, clock, environment — and a mistake
//! is silent. A `wasm32-unknown-unknown` module has no imports to deny; it cannot
//! *name* a syscall. The guarantee is structural instead of configured.
//!
//! The ABI is hand-rolled but cannot drift: the guest side is a macro
//! ([`export_build_thing!`]) and the host side is [`PluginHandle`], and both come from
//! this file. The upgrade to the component model is a swap of this one module if WIT
//! ever buys something the plugins actually need (rich types across the boundary;
//! today everything crossing it is one JSON string).
//!
//! # Why the forge never executes the artifact
//!
//! The point of all this is that dwarves can pull a built binary from a registry and
//! ask it what it is. It must **not** do that by running it:
//!
//! * **It frequently cannot work.** An aarch64 / musl / wasm artifact will not run on
//! an x86 forge — and cross-built variants are exactly the ones you most need to
//! identify remotely. An interrogation method that fails on cross-compilation fails
//! at its job.
//! * **It is remote code execution from a registry**, on the host that holds the SSH
//! deploy keys and the publish rights.
//!
//! Instead the artifact carries its build thing's **wasm module** in a linker section
//! ([`SECTION`]), which is read with an object parser and executed in a sandbox with
//! no capabilities. dwarves runs the artifact's *build thing*, never the artifact —
//! so it works for a binary this host could never execute, and a hostile artifact gets
//! a wasm sandbox rather than the forge's process.
//!
//! If the forge must ever genuinely RUN a pulled artifact (a smoke test, or
//! characterising something with no section), that goes in modgunn's OCI or tunnr's
//! KVM. Never bare on the forge host.
//!
//! # The bijection laws are part of this contract
//!
//! The owner, 2026-08-26: *"viktigt att bijektion används för buildthings — det
//! måste vara i traitsen till och med."* A build thing is ONE declaration worn
//! by many surfaces — the `[build]` TOML, the [`BuildVariantSet`] struct, the
//! [`BuildMenu`] a pane renders, the `--opt` flags a CLI takes, the kconfig
//! fragment a kernel decision commits, the receipt a built artifact carries —
//! and each pair must be **mutually recoverable**, because a surface with no
//! way back is a surface that can drift with nothing to say so. That is not a
//! style preference here: every worst bug this design exists to end (the TOML
//! nobody noticed was stale, the twin list nine tabs behind, the "server" that
//! was not the server) is a surface that had silently stopped mapping back to
//! its declaration.
//!
//! The laws are therefore trait-level, not per-impl virtue: **conformance to
//! [`crate::laws`] is part of implementing [`BuildThing`]**. Every
//! implementation's own test suite calls
//! [`laws::assert_bijective`](crate::laws::assert_bijective) on its build
//! thing — dwarves' TOML wrapper, holger's, gunnar's, knut's — and a failure
//! names the law and the inverse that broke. The two deliberate asymmetries
//! (the declaration TEXT has many legal spellings of one struct; a receipt
//! forgets its settings on the way back to its recipe) are stated exactly, as
//! one-way, in [`crate::laws`]' module docs — an honest one-way arrow is part
//! of the contract too, so nobody "fixes" it into a lying bijection.
use serde::{Deserialize, Serialize};
use crate::variant::BuildVariantSet;
/// The linker section an artifact carries its build thing's wasm module in. Read with
/// an object parser; **never** by executing the artifact.
pub const SECTION: &str = ".nordisk.buildthing";
/// The plugin ABI version. Bumped on any change to [`abi`]'s call convention; the host
/// refuses a module that reports a different one rather than reading garbage.
pub const ABI_VERSION: u32 = 1;
// ── what crosses the boundary ────────────────────────────────────────────────
/// Who a build thing belongs to. The first thing a host asks a freshly-loaded module.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
/// The ABI the module was built against.
pub abi: u32,
/// The repo / suite member.
pub repo: String,
/// The crate the build thing is implemented in — the source of truth's name.
pub source_crate: String,
/// One line about what this app is, for a list.
pub summary: String,
}
/// A build the caller is asking about: a declared variant by name, or an ad-hoc
/// feature combination the app should judge.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildRequest {
/// A declared variant name, when the caller is asking about one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
/// An ad-hoc feature set, when the caller is asking "could I build THIS?".
/// Cargo-flavoured; a non-cargo build thing reads
/// [`selector`](Self::selector) instead.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
/// The build thing's OWN selector, opaque to the forge — echoed back from
/// [`VariantRef::selector`]. Empty for the cargo path, which uses the fields above.
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub selector: serde_json::Value,
/// The target triple in play (`None` = host).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
impl BuildRequest {
/// Ask about a declared variant.
pub fn variant(name: impl Into<String>) -> Self {
BuildRequest {
variant: Some(name.into()),
..Default::default()
}
}
/// Ask about an ad-hoc feature combination.
pub fn features<I: IntoIterator<Item = S>, S: Into<String>>(f: I) -> Self {
BuildRequest {
features: f.into_iter().map(Into::into).collect(),
..Default::default()
}
}
}
/// **Which subtree of which variant** — the argument of
/// [`BuildThing::options`] over the wasm ABI. `path = ""` is the whole tree.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OptionsQuery {
/// The declared variant.
pub variant: String,
/// A node path inside its tree (`kernel`, `dhcp.static_ip`), or empty.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
/// An app's judgement. `reasons` is never empty when `!ok` — a refusal that cannot say
/// why is the failure mode this whole session has been about.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
/// Whether the app accepts this.
pub ok: bool,
/// Why not (or, when `ok`, any notes worth surfacing). Each entry is one sentence.
#[serde(default)]
pub reasons: Vec<String>,
}
impl Verdict {
/// Accepted, with no notes.
pub fn ok() -> Self {
Verdict {
ok: true,
reasons: Vec::new(),
}
}
/// Accepted, with something worth saying.
pub fn ok_with(note: impl Into<String>) -> Self {
Verdict {
ok: true,
reasons: vec![note.into()],
}
}
/// Refused, with the reason.
pub fn refuse(why: impl Into<String>) -> Self {
Verdict {
ok: false,
reasons: vec![why.into()],
}
}
/// Fold another verdict in: `ok` is the AND, reasons concatenate.
pub fn and(mut self, other: Verdict) -> Self {
self.ok &= other.ok;
self.reasons.extend(other.reasons);
self
}
/// The one-line rendering.
pub fn line(&self) -> String {
if self.reasons.is_empty() {
return if self.ok {
"ok".into()
} else {
"REFUSED (no reason given)".into()
};
}
format!(
"{} — {}",
if self.ok { "ok" } else { "REFUSED" },
self.reasons.join("; ")
)
}
}
/// What a host knows about a built artifact when it asks the app to judge it. Passed
/// IN, because the sandbox can read nothing for itself.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactFacts {
/// The variant it claims to be.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
/// File name (not a path — the sandbox has no filesystem and no business with one).
#[serde(default)]
pub file_name: String,
/// Size in bytes.
#[serde(default)]
pub bytes: u64,
/// SHA-256, lowercase hex.
#[serde(default)]
pub sha256: String,
/// Crate names found in the artifact's dependency closure — what the host resolved,
/// handed over so the app can apply its OWN rules to it (holger's airgap tiers
/// forbid an outbound HTTP client; only holger knows that).
#[serde(default)]
pub linked_crates: Vec<String>,
}
/// **What a variant IS to the product** — the axis a menu groups by.
///
/// A build space is not a flat vector. holger ships three *servers* and two *clients*
/// from one tree, and "which of these five do I want" is unanswerable until you know
/// which of the two questions you are asking. The pane renders one section per role.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// A long-running daemon the product IS — holger's `embedded` / `mini` / `full`.
Server,
/// Something a human or a script drives AGAINST a server — a UI, a CLI.
Client,
/// **UI and server in ONE binary.** You start it and it puts the UI up with the
/// server running *inside itself*: nothing else to install, nothing to connect
/// to, no port to configure, no second process to keep alive.
///
/// # Why this is its own role and not a flavour of the other two
///
/// It is **not a [`Client`](Role::Client)**: a client drives a server it does
/// not contain, so its defining question — *which server am I pointed at* — is
/// a runtime decision the operator makes. A solo build has no answer to that
/// question because there is no remote to point at. Filing it under `Client`
/// puts a self-contained appliance in the same menu section as a thin UI that
/// is useless without a running daemon, which is precisely the choice the role
/// axis exists to make legible.
///
/// It is **not a [`Server`](Role::Server)**: a server is headless and is
/// deployed — systemd unit, a port, a service account, a lifecycle somebody
/// else owns. A solo build opens a window on a desktop. Filing it under
/// `Server` means a deploy step can pick it up and try to run a GUI under a
/// system user with no display.
///
/// The two mistakes are symmetric and both are silent, which is the argument:
/// the distinction is not stylistic, it changes what a consumer of the menu is
/// allowed to do with the artifact.
///
/// # What it implies
///
/// * [`linkage`](crate::variant::BuildVariant::shape) is **fat** — a solo build
/// that reaches a sibling over the wire is not solo, it is a mixed client.
/// * It is the artifact you hand somebody who has nothing installed.
/// * It is normally the SAME source as the `<x>-ui` client, built with the
/// server compiled in — a distinct `[[bin]]` name off one `main.rs`, never a
/// forked copy of the UI (LAW 5, and the reason
/// [`check_bins`](crate::variant::BuildVariantSet::check_bins) exists: two
/// shapes of one source MUST NOT emit one file name).
///
/// holger's `solo` (`holger-solo`) is the reference: it is a rename of the old
/// `full` variant, not a new thing.
Solo,
/// A library, an SDK, a shared component: built, published, never run.
Library,
/// Tooling that supports the build itself and is not shipped as the product.
Tool,
}
impl Default for Role {
fn default() -> Self {
Role::Server
}
}
impl Role {
/// Stable lowercase label.
pub fn as_str(self) -> &'static str {
match self {
Role::Server => "server",
Role::Client => "client",
Role::Solo => "solo",
Role::Library => "library",
Role::Tool => "tool",
}
}
}
/// **Which build system actually builds a variant.**
///
/// Today the constellation is Rust and every variant is [`Ecosystem::Cargo`]; this
/// field exists because it is one word now and a migration later. It is deliberately
/// the ONLY concession to multi-ecosystem support — there is no factory, no per-
/// ecosystem registry and no second implementation, because there is no second
/// producer and no second consumer. The other concession is that a
/// [`PlanStep`] is a generic `program + args`, not a cargo invocation, and that
/// [`BuildRequest::selector`] is opaque JSON rather than a typed feature list.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Ecosystem {
/// A Rust crate built by cargo. The only one implemented.
Cargo,
}
impl Default for Ecosystem {
fn default() -> Self {
Ecosystem::Cargo
}
}
impl Ecosystem {
/// Stable lowercase label.
pub fn as_str(self) -> &'static str {
match self {
Ecosystem::Cargo => "cargo",
}
}
}
/// **How a variant talks to its siblings** — a declared axis, not a consequence of a
/// feature list you have to reverse-engineer.
///
/// Two builds of the same product can be completely different things to deploy: a FAT
/// dwarves compiles nornir + skade IN and opens the warehouse in-process, needing no
/// server at runtime; a THIN one is built without nornir and reaches the same verbs
/// over gRPC, needing a server up. A caller must see that BEFORE building, because the
/// runtime requirements are not comparable.
///
/// This is a projection of [`ServiceShape`](crate::variant::ServiceShape), which the
/// declaration already carries alongside `embeds` / `reaches` — **not a second field to
/// keep in sync**. `embeds` says WHICH siblings; linkage says HOW.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Linkage {
/// Siblings compiled IN; self-contained at runtime.
Fat,
/// No siblings compiled in; reaches them over gRPC. Needs them reachable.
Thin,
/// Some embedded, some reached.
Mixed,
/// **The axis does not apply** — a library, or a build with no sibling edge at
/// all. Distinct from an UNDECLARED linkage, which reads as [`Fat`](Self::Fat)
/// and so quietly claims self-containment nobody checked.
#[serde(rename = "not-applicable")]
NotApplicable,
}
impl From<crate::variant::ServiceShape> for Linkage {
fn from(s: crate::variant::ServiceShape) -> Self {
match s {
crate::variant::ServiceShape::FatService => Linkage::Fat,
crate::variant::ServiceShape::ThinStandalone => Linkage::Thin,
crate::variant::ServiceShape::Mixed => Linkage::Mixed,
crate::variant::ServiceShape::NotApplicable => Linkage::NotApplicable,
}
}
}
impl Linkage {
/// Stable lowercase label.
pub fn as_str(self) -> &'static str {
match self {
Linkage::Fat => "fat",
Linkage::Thin => "thin",
Linkage::Mixed => "mixed",
Linkage::NotApplicable => "not-applicable",
}
}
/// The sentence a pane shows beside the variant — what this choice COSTS at
/// runtime, which is the thing a caller actually needs to know.
pub fn runtime_line(self, embeds: &[String], reaches: &[String]) -> String {
match self {
Linkage::Fat if embeds.is_empty() => {
"self-contained — nothing else needs to be running".to_string()
}
Linkage::Fat => format!(
"embeds {} — self-contained, nothing else needs to be running",
embeds.join(" + ")
),
Linkage::Thin if reaches.is_empty() => {
"standalone — compiles in no siblings".to_string()
}
Linkage::Thin => format!(
"NEEDS {} RUNNING — reached over gRPC, not compiled in",
reaches.join(" + ").to_uppercase()
),
Linkage::Mixed => format!(
"embeds {}; needs {} running",
if embeds.is_empty() {
"nothing".into()
} else {
embeds.join(" + ")
},
if reaches.is_empty() {
"nothing".into()
} else {
reaches.join(" + ")
}
),
Linkage::NotApplicable => {
"fat/thin does not apply — this artifact is not deployed on its own".to_string()
}
}
}
}
/// One menu entry, ecosystem-neutral: an id, what it IS, who builds it, and an
/// **opaque selector** the owning build thing understands and the forge does not.
///
/// The forge never reads `selector`. It passes it back when asking for a plan, and the
/// build thing turns it into steps. That is the line that keeps dwarves from needing
/// to know cargo — even though, today, cargo is the only thing on the other side.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VariantRef {
/// The variant handle.
pub id: String,
/// What it is to the product.
pub role: Role,
/// Which build system builds it.
pub ecosystem: Ecosystem,
/// How it talks to its siblings — fat (compiled in) or thin (over gRPC).
#[serde(default = "default_linkage")]
pub linkage: Linkage,
/// What it needs at runtime, in one sentence.
#[serde(default)]
pub runtime: String,
/// **The normal case for this role.** Exactly one per role. A menu of five with no
/// indication of which is normal makes every caller guess.
#[serde(default)]
pub is_default: bool,
/// The build thing's own description of this build point. Opaque to the forge.
#[serde(default)]
pub selector: serde_json::Value,
}
fn default_linkage() -> Linkage {
Linkage::Fat
}
/// One `(suite, role)` group — a section of the menu.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleGroup {
/// What these variants are.
pub role: Role,
/// **Which DELIVERY this section belongs to** — `None` for a repo that ships one
/// unnamed product (see [`crate::variant::BuildVariant::suites`]).
///
/// A menu section is a `(suite, role)` group, not a role: a repo shipping both a
/// full product and a carved-out MVP has two server sections, each with its own
/// normal case. Flattening them to one role would force the two deliveries to
/// share a default — the declaration bug the suite axis removes. A variant that
/// ships in two deliveries appears in two sections, which is what it does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suite: Option<String>,
/// One line about this role in THIS product.
pub summary: String,
/// The variant names in the group, in menu order.
pub variants: Vec<String>,
/// **The normal case.** `None` only when the role has no variants; a role with
/// variants and no default is a declaration bug, caught by
/// [`BuildMenu::check_defaults`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_variant: Option<String>,
}
/// **The build menu**: the declaration, grouped by [`Role`]. This is what
/// [`BuildThing::menu`] returns and what a pane renders — never a flat list, because a
/// flat list cannot answer "am I choosing a server or a client".
///
/// (No `PartialEq`: it carries a whole [`BuildVariantSet`], which is compared through
/// its serialized form rather than structurally — see `menu_covers_every_variant`.)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildMenu {
/// The repo this menu belongs to.
pub repo: String,
/// The revision it is pinned to (see [`BuildThing::provenance`]).
pub rev: String,
/// The role sections, in menu order.
pub roles: Vec<RoleGroup>,
/// Every entry, ecosystem-neutral — what a pane renders and what the forge passes
/// back when it asks for a plan.
#[serde(default)]
pub entries: Vec<VariantRef>,
/// The cargo declaration behind the menu. `Some` for a cargo build thing (all of
/// them, today); a build thing for another ecosystem would leave it `None` and the
/// cargo-specific views — the FEATURE AXIS above all — must then report
/// NOT APPLICABLE rather than "0 of 0", which would be the vacuous-green bug again
/// by category error.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cargo_set: Option<BuildVariantSet>,
}
impl BuildMenu {
/// The variant names in one role.
pub fn in_role(&self, role: Role) -> Vec<&str> {
self.roles
.iter()
.filter(|g| g.role == role)
.flat_map(|g| g.variants.iter().map(|s| s.as_str()))
.collect()
}
/// Which role a variant belongs to.
pub fn role_of(&self, variant: &str) -> Option<Role> {
self.roles
.iter()
.find(|g| g.variants.iter().any(|v| v == variant))
.map(|g| g.role)
}
/// Every variant named by some role — the oracle that no variant is unreachable
/// from the menu.
pub fn menu_variants(&self) -> Vec<&str> {
self.roles
.iter()
.flat_map(|g| g.variants.iter().map(|s| s.as_str()))
.collect()
}
/// **Exactly one default per non-empty `(suite, role)` section**, or the reasons
/// it is not so.
///
/// fat-vs-thin is not a preference: fat is the normal case and thin is the
/// exception, and a build thing that does not say which is which pushes the guess
/// onto every caller. Empty result = well-formed.
///
/// This is the MENU-side twin of
/// [`BuildVariantSet::check_defaults`](crate::variant::BuildVariantSet::check_defaults):
/// the same rule over the rendered sections rather than over the declaration. The
/// two cannot contradict, because the sections a default [`BuildThing::roles`]
/// hands it come from [`BuildVariantSet::groups`](crate::variant::BuildVariantSet::groups)
/// — the one grouping writer — and because
/// [`VariantRef::is_default`] is now true when the DECLARATION marks it, not only
/// when the section elected it. Before that, a declaration marking two defaults in
/// one role produced one flagged entry, so this checker saw exactly one and went
/// green on a file the set-level checker rejected.
///
/// A section names its suite in the message only when the menu carries one.
pub fn check_defaults(&self) -> Vec<String> {
let mut problems = Vec::new();
for g in &self.roles {
if g.variants.is_empty() {
continue;
}
let marked: Vec<&str> = self
.entries
.iter()
.filter(|e| e.is_default && g.variants.iter().any(|v| v == &e.id))
.map(|e| e.id.as_str())
.collect();
let label = match &g.suite {
Some(s) => format!("suite '{}' role '{}'", s, g.role.as_str()),
None => format!("role '{}'", g.role.as_str()),
};
match marked.len() {
1 => {
if g.default_variant.as_deref() != Some(marked[0]) {
problems.push(format!(
"{} marks '{}' as default but the group names {:?}",
label, marked[0], g.default_variant
));
}
}
0 => problems.push(format!(
"{} has {} variant(s) and NO default — the caller has to guess \
which is the normal case",
label,
g.variants.len()
)),
n => problems.push(format!(
"{label} marks {n} defaults ({}) — exactly one is the normal case",
marked.join(", ")
)),
}
}
problems
}
/// The default variant for a role, if declared.
pub fn default_for(&self, role: Role) -> Option<&str> {
self.roles
.iter()
.find(|g| g.role == role)?
.default_variant
.as_deref()
}
/// The cargo declaration, or an empty one. A caller that needs the cargo axes must
/// go through [`cargo_set`](Self::cargo_set) and handle `None` — this is the
/// convenience for the cargo-only present.
pub fn set(&self) -> BuildVariantSet {
self.cargo_set.clone().unwrap_or_default()
}
}
// ── the rev pin ──────────────────────────────────────────────────────────────
/// **A build thing's identity: `(repo, rev)`.**
///
/// A build thing is only true for the revision it was built from — features get
/// renamed, variants come and go, an airgap rule changes which crates it forbids. A
/// build thing without a rev is the `build-variants.toml` rot again, wearing a
/// compiled wrapper: something that claims to describe a tree it may no longer match.
///
/// The forge therefore **refuses to rebuild** when the checkout's rev does not match
/// the build thing's ([`RevMatch::refuse_rebuild`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpecProvenance {
/// The repo.
pub repo: String,
/// The git revision this build thing describes. Empty means UNKNOWN, which is a
/// reported state and never treated as "matches anything".
pub rev: String,
/// Whether the working tree was dirty when the build thing was built. A dirty
/// build thing describes something that was never committed anywhere.
pub dirty: bool,
/// The buildspec crate.
pub source_crate: String,
}
impl SpecProvenance {
/// Compare against the revision a checkout is actually at.
pub fn check(&self, checkout_rev: &str) -> RevMatch {
if self.rev.is_empty() || checkout_rev.is_empty() {
return RevMatch::Unknown {
spec_rev: self.rev.clone(),
checkout_rev: checkout_rev.to_string(),
};
}
if self.rev == checkout_rev {
if self.dirty {
return RevMatch::MatchesButDirty {
rev: self.rev.clone(),
};
}
return RevMatch::Matches {
rev: self.rev.clone(),
};
}
RevMatch::Mismatch {
spec_rev: self.rev.clone(),
checkout_rev: checkout_rev.to_string(),
}
}
}
/// The verdict of pinning a build thing against a checkout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RevMatch {
/// The build thing describes exactly this tree.
Matches {
/// The agreed revision.
rev: String,
},
/// Same commit, but the build thing was built from a dirty tree — so it describes
/// something that was never committed and cannot be fetched by anyone else.
MatchesButDirty {
/// The agreed revision.
rev: String,
},
/// The build thing describes a different revision than the tree in hand.
Mismatch {
/// What the build thing says.
spec_rev: String,
/// What the checkout is at.
checkout_rev: String,
},
/// One side does not know its revision. **Not** treated as a match.
Unknown {
/// What the build thing says (possibly empty).
spec_rev: String,
/// What the checkout says (possibly empty).
checkout_rev: String,
},
}
impl RevMatch {
/// **Whether the forge must refuse to rebuild.** Only an exact clean match is
/// allowed to proceed silently; a dirty match proceeds with a warning; anything
/// else is refused, because rebuilding tree B with tree A's recipe produces an
/// artifact that matches neither.
pub fn refuse_rebuild(&self) -> bool {
matches!(self, RevMatch::Mismatch { .. } | RevMatch::Unknown { .. })
}
/// Stable label.
pub fn label(&self) -> &'static str {
match self {
RevMatch::Matches { .. } => "matches",
RevMatch::MatchesButDirty { .. } => "matches-dirty",
RevMatch::Mismatch { .. } => "mismatch",
RevMatch::Unknown { .. } => "unknown",
}
}
/// The sentence a pane prints, and the refusal reason.
pub fn line(&self) -> String {
match self {
RevMatch::Matches { rev } => {
format!("build thing is pinned to this tree ({})", short(rev))
}
RevMatch::MatchesButDirty { rev } => format!(
"build thing matches {} but was built from a DIRTY tree — it describes something \
never committed",
short(rev)
),
RevMatch::Mismatch {
spec_rev,
checkout_rev,
} => format!(
"REFUSING to rebuild: the build thing describes {} and this tree is at {} — \
features get renamed and variants come and go, so its recipe is not this tree's",
short(spec_rev),
short(checkout_rev)
),
RevMatch::Unknown {
spec_rev,
checkout_rev,
} => format!(
"REFUSING to rebuild: a revision is UNKNOWN (build thing '{}', checkout '{}') — \
an unpinned build thing is the TOML rot in a compiled wrapper",
short(spec_rev),
short(checkout_rev)
),
}
}
}
fn short(rev: &str) -> String {
if rev.is_empty() {
return "<unknown>".to_string();
}
rev.chars().take(12).collect()
}
// ── plan as data ─────────────────────────────────────────────────────────────
/// One step of a [`BuildPlan`] — a program and its argv. **Data.** The build thing
/// returns it; the forge decides whether, where and how to run it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanStep {
/// A short label for the step, for a log line and a DAG node.
pub name: String,
/// The program (`cargo`, …). Never a shell.
pub program: String,
/// The argv, already materialized.
pub args: Vec<String>,
/// Extra environment for this step only.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<(String, String)>,
}
/// **What the build thing says should happen — and nothing it did.**
///
/// The build thing never performs an action: it returns a plan and the forge executes
/// it. That is what lets a plan be inspected, diffed, dry-run, cached and gated before
/// anything touches the disk — and it is *why the sandbox needs no capabilities at
/// all*. A build thing that could run a command would need a process capability; one
/// that returns a list of commands needs nothing.
///
/// When a build genuinely must react to output, the forge runs step *n*, hands the
/// result back, and asks for step *n+1* — a step loop, still with no raw capability.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildPlan {
/// The variant this plan builds.
pub variant: String,
/// The steps, in order.
pub steps: Vec<PlanStep>,
/// Environment applied to every step.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<(String, String)>,
/// The artifacts the plan is expected to produce, relative to the repo root. The
/// forge checks these EXIST afterwards — which is how "cargo exited 0 and produced
/// nothing" stops counting as success.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expected_artifacts: Vec<String>,
/// Why this plan is what it is, for a human reading a dry run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
impl BuildPlan {
/// The plan as shell-ish lines for a dry run. Display only — the forge executes
/// the argv, never this string.
pub fn preview(&self) -> String {
let mut s = format!("plan for '{}':\n", self.variant);
for (i, st) in self.steps.iter().enumerate() {
s.push_str(&format!(
" {}. {} — {} {}\n",
i + 1,
st.name,
st.program,
st.args.join(" ")
));
}
for a in &self.expected_artifacts {
s.push_str(&format!(" expect: {a}\n"));
}
s
}
}
/// Everything needed to fetch the source and rebuild this artifact — the payload that
/// makes `pull → identify → rebuild → compare digests` possible.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RebuildRecipe {
/// The repo to fetch.
pub repo: String,
/// Its git remote, when the app knows it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
/// The revision the artifact was built from, when the app was told at build time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
/// The declared variant to rebuild.
pub variant: String,
/// The exact cargo argv, materialized by the app.
pub cargo_args: Vec<String>,
/// The toolchain the artifact was built with, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub toolchain: Option<String>,
}
// ── the trait ────────────────────────────────────────────────────────────────
/// **An application's build thing.** Implemented once, in a crate the app depends on;
/// compiled into the app natively AND to `wasm32` as the plugin the forge loads.
///
/// The methods are verbs deliberately: [`validate`](BuildThing::validate) and
/// [`judge_artifact`](BuildThing::judge_artifact) are where an app's own rules live,
/// and they are the reason this is code rather than a JSON file. `holger` knows that
/// its airgap tiers must link no HTTP client; `korp` knows `spark41` needs `iceberg`.
/// The generic validator in [`BuildVariantSet`] cannot know either.
///
/// Implementations must be **pure**: no filesystem, no network, no clock. The wasm
/// sandbox grants none of those (it cannot even name them), so an impl that reaches
/// for one compiles native and traps in the plugin — the drift this design exists to
/// prevent. Everything an impl needs is passed in.
pub trait BuildThing {
/// Who this belongs to.
fn manifest(&self) -> Manifest;
/// The declared build space — every variant with its axes.
fn variants(&self) -> BuildVariantSet;
/// **Which variants are which** — the role axis.
///
/// The default READS the declaration: a `[[build.variant]]` may carry
/// `role = "server" | "client" | "solo" | "library" | "tool"` and `default = true`, and
/// those group the menu and name each group's normal case. Only when NO variant
/// declares a role does it fall back to one `Server` group — honest for a
/// single-role product, wrong for anything that also ships a client, which is
/// why the declaration is worth making. A build thing with a richer notion of
/// its own roles still overrides.
fn roles(&self) -> Vec<RoleGroup> {
let set = self.variants();
if set.variants.iter().all(|v| v.role.is_none()) && !set.declares_suites() {
let names: Vec<String> = set.names().iter().map(|s| s.to_string()).collect();
return vec![RoleGroup {
role: Role::Server,
suite: None,
summary: "every declared variant (this build thing declares no role axis)"
.to_string(),
default_variant: names.first().cloned(),
variants: names,
}];
}
// One section per `(suite, role)` group, in declaration order. The grouping
// comes from `BuildVariantSet::groups` — the SAME writer `check_defaults`
// uses — so the menu and the guard cannot disagree about what a section is.
set.groups()
.into_iter()
.map(|(suite, _role_label, members)| {
let role = members.first().and_then(|v| v.role).unwrap_or_default();
let suite = (suite != crate::variant::UNSUITED).then(|| suite.to_string());
RoleGroup {
summary: match &suite {
Some(s) => format!(
"{} declared {} variant(s) in delivery '{}'",
members.len(),
role.as_str(),
s
),
None => format!("{} declared {} variant(s)", members.len(), role.as_str()),
},
role,
suite,
// `None` when the repo marked none — `check_defaults` then says so
// by name instead of the menu quietly electing the first entry.
default_variant: members
.iter()
.find(|v| v.is_default)
.map(|v| v.name.clone()),
variants: members.iter().map(|v| v.name.clone()).collect(),
}
})
.collect()
}
/// **The menu** a pane renders: the declaration grouped by role and pinned to a
/// revision. Not usually overridden — it composes [`variants`](BuildThing::variants),
/// [`roles`](BuildThing::roles) and [`provenance`](BuildThing::provenance).
fn menu(&self) -> BuildMenu {
let set = self.variants();
let roles = self.roles();
let entries = set
.variants
.iter()
.map(|v| {
let group = roles
.iter()
.find(|g| g.variants.iter().any(|n| n == &v.name));
let linkage = Linkage::from(v.shape);
let embeds: Vec<String> = v.embeds.iter().cloned().collect();
let reaches: Vec<String> = v.reaches.iter().cloned().collect();
VariantRef {
id: v.name.clone(),
role: group.map(|g| g.role).unwrap_or_default(),
ecosystem: Ecosystem::Cargo,
linkage,
runtime: linkage.runtime_line(&embeds, &reaches),
// The DECLARATION marking it, or the section electing it. Reading
// only the section made a two-default declaration render as one
// flagged entry, so `BuildMenu::check_defaults` went green on a
// file `BuildVariantSet::check_defaults` rejects — two checkers
// for one rule, contradicting. The `||` keeps an overriding
// `roles()` (which names its defaults in the group, not in the
// declaration) working, and stops the menu from hiding a
// double-default.
is_default: v.is_default
|| group
.and_then(|g| g.default_variant.as_deref())
.map(|d| d == v.name)
.unwrap_or(false),
selector: serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
}
})
.collect();
BuildMenu {
repo: self.manifest().repo,
rev: self.provenance().rev,
roles,
entries,
cargo_set: Some(set),
}
}
/// **The rev this build thing is true for.** The default asks the host via
/// [`host::git_rev`], so a build thing compiled into an app reports the app's own
/// tree — and a plugin loaded from an artifact reports what it was built from.
fn provenance(&self) -> SpecProvenance {
let (rev, dirty) = host::git_rev();
SpecProvenance {
repo: self.manifest().repo,
rev,
dirty,
source_crate: self.manifest().source_crate,
}
}
/// **What should happen to build `variant` — as DATA.** The build thing performs
/// nothing; the forge executes the returned steps. The default materializes the
/// declared cargo argv, which is right for a plain cargo variant; a product with a
/// codegen step, a wasm post-process or a packaging step overrides.
fn plan(&self, variant: &str) -> Option<BuildPlan> {
let set = self.variants();
let v = set.get(variant)?;
let mut env = Vec::new();
if let Some(cfg) = &v.cfg {
env.push(("RUSTFLAGS".to_string(), format!("--cfg {cfg}")));
}
Some(BuildPlan {
variant: variant.to_string(),
steps: vec![PlanStep {
name: format!("build-{variant}"),
program: "cargo".to_string(),
args: v.cargo_args(),
env: Vec::new(),
}],
env,
// Through `artifact_rel_path`, not a second copy of the convention:
// a plan that expects a path the build does not write is a plan that
// reports a missing artifact for a reason nobody can find.
expected_artifacts: v
.artifact_rel_path()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default(),
rationale: Some(format!(
"the declared variant '{variant}' materializes to exactly one cargo invocation"
)),
})
}
/// **The app's own judgement** on a requested build. The default applies the
/// generic set rules; override to add the rules only this app knows.
fn validate(&self, req: &BuildRequest) -> Verdict {
let set = self.variants();
if let Some(name) = &req.variant {
return match set.get(name) {
None => Verdict::refuse(format!(
"no variant '{name}'; declared: {}",
set.names().join(", ")
)),
Some(v) => match set.validate(v) {
Ok(()) => Verdict::ok(),
Err(e) => Verdict::refuse(e.to_string()),
},
};
}
let probe = crate::variant::BuildVariant {
name: "<ad-hoc>".to_string(),
features: req.features.clone(),
target: req.target.clone(),
..Default::default()
};
match set.validate(&probe) {
Ok(()) => Verdict::ok(),
Err(e) => Verdict::refuse(e.to_string()),
}
}
/// **The app's judgement on a BUILT artifact** — the check only this app can make.
/// Default: accept, saying so honestly. An app with a real invariant (holger's
/// zero-egress airgap tiers) overrides.
fn judge_artifact(&self, facts: &ArtifactFacts) -> Verdict {
let _ = facts;
Verdict::ok_with("this build thing declares no artifact-level invariant")
}
/// How to rebuild a declared variant from source — the payload behind
/// `pull → identify → rebuild → compare`.
fn rebuild_recipe(&self, variant: &str) -> Option<RebuildRecipe> {
let set = self.variants();
let v = set.get(variant)?;
Some(RebuildRecipe {
repo: self.manifest().repo,
remote: None,
rev: None,
variant: variant.to_string(),
cargo_args: v.cargo_args(),
toolchain: None,
})
}
/// **What building `variant` IS, as a graph.** variant → plan steps → expected
/// artifacts, plus the `embeds` / `reaches` edges that make fat-vs-thin visible.
///
/// The default composes [`plan`](BuildThing::plan) and the declaration, which is
/// right for every cargo build thing; a product with a genuinely different shape
/// (a multi-stage image chain) overrides.
fn graph(&self, variant: &str) -> BuildGraph {
let set = self.variants();
let mut g = BuildGraph {
repo: self.manifest().repo,
root: variant.to_string(),
..Default::default()
};
let Some(v) = set.get(variant) else { return g };
let linkage = Linkage::from(v.shape);
let embeds: Vec<String> = v.embeds.iter().cloned().collect();
let reaches: Vec<String> = v.reaches.iter().cloned().collect();
let root_id = format!("variant:{variant}");
g.nodes.push(GraphNode {
id: root_id.clone(),
label: variant.to_string(),
kind: NodeKind::Variant,
detail: Some(linkage.runtime_line(&embeds, &reaches)),
selects: Some(variant.to_string()),
});
// The plan DAG: variant → step → step, and each step → what it produces.
if let Some(plan) = self.plan(variant) {
let mut prev = root_id.clone();
for (i, st) in plan.steps.iter().enumerate() {
let id = format!("step:{variant}:{i}");
g.nodes.push(GraphNode {
id: id.clone(),
label: st.name.clone(),
kind: NodeKind::Step,
detail: Some(format!("{} {}", st.program, st.args.join(" "))),
selects: None,
});
g.edges.push(GraphEdge {
from: prev.clone(),
to: id.clone(),
kind: EdgeKind::Plan,
label: None,
});
prev = id;
}
// Artifacts hang off the LAST step — the one whose completion produces them.
for a in &plan.expected_artifacts {
let id = format!("artifact:{a}");
g.nodes.push(GraphNode {
id: id.clone(),
label: a.rsplit('/').next().unwrap_or(a).to_string(),
kind: NodeKind::Artifact,
detail: Some(a.clone()),
selects: None,
});
g.edges.push(GraphEdge {
from: prev.clone(),
to: id,
kind: EdgeKind::Produces,
label: None,
});
}
}
// The composition edges — the whole point of drawing this at all.
for e in &embeds {
let id = format!("embeds:{e}");
g.nodes.push(GraphNode {
id: id.clone(),
label: e.clone(),
kind: NodeKind::Embedded,
// A CLAIM, and labelled as one. On 2026-07-22 `dwarves`, `dwarves-server`
// and `dwarves-thin` all declared `embeds = ["nornir","holger","skade"]`
// and carried ZERO nornir symbols — only `dwarves-full` genuinely embeds.
// Rendering a declaration as fact would have drawn three lies, so the node
// says DECLARED and a host that has measured the artifact overwrites it.
detail: Some(format!(
"{e} is DECLARED compiled-in — unverified until the built artifact's \
symbols say so"
)),
selects: None,
});
g.edges.push(GraphEdge {
from: root_id.clone(),
to: id,
kind: EdgeKind::Embeds,
label: Some("compiled in".to_string()),
});
}
for r in &reaches {
let id = format!("reaches:{r}");
g.nodes.push(GraphNode {
id: id.clone(),
label: r.clone(),
kind: NodeKind::Reached,
detail: Some(format!("{r} must be RUNNING — reached over gRPC")),
selects: None,
});
g.edges.push(GraphEdge {
from: root_id.clone(),
to: id,
kind: EdgeKind::Reaches,
label: Some("over gRPC".to_string()),
});
}
g
}
/// **The settings tree of `variant`, from `path` down** — feature ▸ setting
/// ▸ val, paths as ids ([`crate::tree`]). `None` when the variant is not
/// declared or the path is not in its tree. The default DERIVES the tree
/// from [`variants`](BuildThing::variants) (options → features with their
/// conditional params as children; `kernel_donor` → the `kernel` edge with
/// the kernel model's rows); an impl whose answer is dynamic — a kernel
/// tree read from fetched source — overrides for that subtree and asks the
/// host through [`host::fetch`].
fn options(&self, variant: &str, path: &str) -> Option<crate::tree::SettingsTree> {
crate::tree::SettingsTree::of_set(&self.variants(), variant)?.subtree(path)
}
/// **The judge** — may this feature go on/off, and what follows? The
/// default is [`SettingsTree::try_toggle`](crate::tree::SettingsTree::try_toggle):
/// a plain toggle for a plain feature, and the kernel model's own cascade
/// and contract refusal for a `kernel.*` symbol — which is exactly what a
/// kernel donor's build thing must answer for its symbols.
fn try_toggle(&self, req: &crate::tree::ToggleRequest) -> crate::tree::ToggleDecision {
match self.options(&req.variant, "") {
Some(tree) => tree.try_toggle(req),
None => crate::tree::ToggleDecision {
path: req.path.clone(),
on: req.on,
verdict: Verdict::refuse(format!(
"no variant '{}'; declared: {}",
req.variant,
self.variants().names().join(", ")
)),
cascade: Vec::new(),
refuses: Vec::new(),
activates: Vec::new(),
},
}
}
/// **The verbs a typ bears** — RUN / BOOT / UNPACK. The default is the
/// table in [`crate::verbs`]; an impl refines (a kernel build thing that
/// also ships an UNPACK-able archive).
fn actions(&self, kind: crate::variant::ArtifactKind) -> Vec<crate::verbs::Action> {
crate::verbs::actions_for(kind)
}
/// The human `--describe-build` report.
fn describe(&self) -> String {
let m = self.manifest();
crate::spec::describe(&m.repo, &m.source_crate, &self.variants())
}
}
// ── the imports: THE PERMISSION LIST ─────────────────────────────────────────
/// **What a build thing may ask the host for — and therefore everything it can do.**
///
/// This is not a sandbox bolted onto an interface; it *is* the interface. There is no
/// filesystem, no socket, no clock, no process spawn, and no way to name one: a build
/// thing cannot shell out or open a connection, it can only ask dwarves a question,
/// and dwarves decides what to answer.
///
/// Three facts, chosen because a build thing genuinely cannot compute them and cannot
/// be trusted to fetch them:
///
/// | import | why it is safe |
/// |---|---|
/// | [`git_rev`] | the host reads the revision; the plugin cannot run `git` |
/// | [`read_manifest`] | the host resolves and reads ONE manifest, refusing traversal; the plugin has no path handle |
/// | [`log`] | a line of text out, nothing in |
///
/// Natively these are real implementations; on `wasm32` they are `extern` declarations
/// the host binds — and a module importing anything *outside* this namespace is
/// refused at load, by name.
pub mod host {
/// The import namespace. The host binds exactly these and refuses a module that
/// imports from any other module name.
pub const NAMESPACE: &str = "nordisk";
/// Import name: the revision + dirty flag of the tree the host is looking at.
pub const IMPORT_GIT_REV: &str = "git_rev";
/// Import name: read one manifest by repo-relative path.
pub const IMPORT_READ_MANIFEST: &str = "read_manifest";
/// Import name: emit a log line.
pub const IMPORT_LOG: &str = "log";
/// Import name: fetch the bytes behind a coordinate (`repo/variant`) — the ONE
/// narrow way a build thing reaches an artifact or a source it needs to
/// answer `options("kernel")`. The host may refuse; a refusal is a named
/// error, never a silent empty.
pub const IMPORT_FETCH: &str = "fetch";
/// Every import the host will bind. **This slice is the permission list**: the
/// loader allows exactly these and nothing else.
pub const IMPORTS: &[&str] = &[IMPORT_GIT_REV, IMPORT_READ_MANIFEST, IMPORT_LOG, IMPORT_FETCH];
/// The first byte of a `fetch` reply: fulfilled in a CONTAINER (the
/// declared toolchain image) — the reference fulfilment; the bytes follow.
pub const FETCH_OK: u8 = 0;
/// The first byte of a `fetch` reply: a UTF-8 refusal reason follows.
pub const FETCH_REFUSED: u8 = 1;
/// The first byte of a `fetch` reply: fulfilled on the HOST by a NAMED
/// exception (a fixture, a host path the operator chose) — the bytes
/// follow. Distinct from [`FETCH_OK`] so a host fulfilment can never pass
/// as the container one: the module, the log and the receipt all see it.
pub const FETCH_OK_HOST_EXCEPTION: u8 = 2;
/// Where a fulfilled `fetch` came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FetchOrigin {
/// The declared toolchain container (`nordisk-cargo-toolchain` via
/// draupnir / skidbladnir) — the default and the reference.
Container,
/// The host, by a named exception — never the default.
HostException,
}
/// A fulfilled fetch: the bytes and where they came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fetched {
/// The bytes.
pub bytes: Vec<u8>,
/// Container (reference) or host (named exception).
pub origin: FetchOrigin,
}
/// **The host declined a fetch** — and said why.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchRefused {
/// The coordinate asked for.
pub coordinate: String,
/// The host's reason.
pub reason: String,
}
impl std::fmt::Display for FetchRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "fetch('{}') refused by the host: {}", self.coordinate, self.reason)
}
}
impl std::error::Error for FetchRefused {}
/// **Fetch the bytes behind `coordinate`** (`repo/variant`, or whatever the
/// host agreed to serve under that name).
///
/// **The contract.** The reference fulfilment is a CONTAINER: the host
/// resolves the coordinate inside the declared toolchain image
/// (`nordisk-cargo-toolchain`, run through draupnir / skidbladnir — see
/// `plugin_host::ContainerFetcher`), never host cargo, host git or a host
/// path. A host-side fulfilment exists only as a NAMED exception
/// (`plugin_host::HostPathFetcher`, or a test fixture) and is marked on the
/// wire ([`FETCH_OK_HOST_EXCEPTION`]) so the module gets
/// [`FetchOrigin::HostException`] and the host log carries the exception's
/// reason — it cannot be fulfilled silently on the host. A host that bound
/// no fetcher REFUSES with a reason. Native: answered from
/// [`NativeFacts::fetchables`] (a fixture — a host exception by definition).
/// Either way an absent or declined coordinate is `Err(FetchRefused)` with
/// the reason — a build thing that needs the kernel source to answer must
/// say it could not, not answer from nothing.
pub fn fetch(coordinate: &str) -> Result<Fetched, FetchRefused> {
let decode = |payload: Vec<u8>| -> Result<Fetched, FetchRefused> {
match payload.split_first() {
Some((&FETCH_OK, rest)) => Ok(Fetched { bytes: rest.to_vec(), origin: FetchOrigin::Container }),
Some((&FETCH_OK_HOST_EXCEPTION, rest)) => {
Ok(Fetched { bytes: rest.to_vec(), origin: FetchOrigin::HostException })
}
Some((&FETCH_REFUSED, rest)) => Err(FetchRefused {
coordinate: coordinate.to_string(),
reason: String::from_utf8_lossy(rest).into_owned(),
}),
_ => Err(FetchRefused {
coordinate: coordinate.to_string(),
reason: "the host answered nothing (no fetch capability bound, or it could \
not allocate the reply)"
.to_string(),
}),
}
};
#[cfg(target_arch = "wasm32")]
{
let bytes = coordinate.as_bytes();
let packed = unsafe { wasm_imports::fetch(bytes.as_ptr() as i32, bytes.len() as i32) };
let (ptr, len) = crate::plugin::abi::unpack(packed);
decode(read_host_bytes(ptr, len))
}
#[cfg(not(target_arch = "wasm32"))]
{
match native_facts().fetchables.get(coordinate) {
Some(bytes) => {
decode(std::iter::once(FETCH_OK_HOST_EXCEPTION).chain(bytes.iter().copied()).collect())
}
None => Err(FetchRefused {
coordinate: coordinate.to_string(),
reason: "the host holds nothing under that coordinate".to_string(),
}),
}
}
}
/// The revision the host is looking at, and whether its tree is dirty.
///
/// Native: shells nothing — the caller supplies it through
/// [`set_native_facts`]. wasm: the host answers.
pub fn git_rev() -> (String, bool) {
#[cfg(target_arch = "wasm32")]
{
let packed = unsafe { wasm_imports::git_rev() };
let (ptr, len) = crate::plugin::abi::unpack(packed);
let s = read_host_string(ptr, len);
match s.split_once('\u{1}') {
Some((rev, d)) => (rev.to_string(), d == "1"),
None => (s, false),
}
}
#[cfg(not(target_arch = "wasm32"))]
{
native_facts().rev_and_dirty
}
}
/// Read one manifest, by path relative to the repo root. Returns an empty string
/// when the host declines (missing, or an attempted escape).
pub fn read_manifest(path: &str) -> String {
#[cfg(target_arch = "wasm32")]
{
let bytes = path.as_bytes();
let packed =
unsafe { wasm_imports::read_manifest(bytes.as_ptr() as i32, bytes.len() as i32) };
let (ptr, len) = crate::plugin::abi::unpack(packed);
read_host_string(ptr, len)
}
#[cfg(not(target_arch = "wasm32"))]
{
native_facts()
.manifests
.get(path)
.cloned()
.unwrap_or_default()
}
}
/// Emit a line for the host's log.
pub fn log(msg: &str) {
#[cfg(target_arch = "wasm32")]
{
let b = msg.as_bytes();
unsafe { wasm_imports::log(b.as_ptr() as i32, b.len() as i32) }
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = msg;
}
}
#[cfg(target_arch = "wasm32")]
fn read_host_string(ptr: u32, len: u32) -> String {
String::from_utf8_lossy(&read_host_bytes(ptr, len)).into_owned()
}
#[cfg(target_arch = "wasm32")]
fn read_host_bytes(ptr: u32, len: u32) -> Vec<u8> {
if ptr == 0 || len == 0 {
return Vec::new();
}
unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize).to_vec() }
}
#[cfg(target_arch = "wasm32")]
mod wasm_imports {
#[link(wasm_import_module = "nordisk")]
unsafe extern "C" {
pub fn git_rev() -> i64;
pub fn read_manifest(ptr: i32, len: i32) -> i64;
pub fn log(ptr: i32, len: i32);
pub fn fetch(ptr: i32, len: i32) -> i64;
}
}
/// The facts a NATIVE build thing sees. On wasm these come from the host over the
/// imports; natively the embedding app supplies them once at startup, so the same
/// impl runs both ways with no `cfg` fork in the build thing itself.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Default)]
pub struct NativeFacts {
/// `(rev, dirty)`.
pub rev_and_dirty: (String, bool),
/// Manifests by repo-relative path.
pub manifests: std::collections::BTreeMap<String, String>,
/// Bytes the host will serve to [`fetch`], by coordinate — a FIXTURE,
/// answered as [`FetchOrigin::HostException`]. Absent ⇒ refused.
pub fetchables: std::collections::BTreeMap<String, Vec<u8>>,
}
#[cfg(not(target_arch = "wasm32"))]
static NATIVE: std::sync::OnceLock<std::sync::RwLock<NativeFacts>> = std::sync::OnceLock::new();
#[cfg(not(target_arch = "wasm32"))]
fn cell() -> &'static std::sync::RwLock<NativeFacts> {
NATIVE.get_or_init(|| std::sync::RwLock::new(NativeFacts::default()))
}
/// Supply the facts a native build thing will see (the app does this at startup;
/// a test does it to pin a revision).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_native_facts(facts: NativeFacts) {
if let Ok(mut g) = cell().write() {
*g = facts;
}
}
/// The facts currently in force natively.
#[cfg(not(target_arch = "wasm32"))]
pub fn native_facts() -> NativeFacts {
cell().read().map(|g| g.clone()).unwrap_or_default()
}
}
// ── the wasm ABI ─────────────────────────────────────────────────────────────
/// **The plugin call convention.** Both halves live here so they cannot drift: the
/// guest exports are generated by [`export_build_thing!`], and the host's caller is
/// [`crate::plugin_host::PluginHandle`].
///
/// A core `wasm32-unknown-unknown` module exporting:
///
/// | export | signature | meaning |
/// |---|---|---|
/// | `memory` | — | linear memory (the only shared surface) |
/// | `bt_abi_version` | `() -> i32` | [`ABI_VERSION`]; a mismatch is refused, not guessed at |
/// | `bt_alloc` | `(i32) -> i32` | reserve `len` bytes for the host to write an argument into |
/// | `bt_dealloc` | `(i32, i32)` | release a buffer |
/// | `bt_call` | `(op, ptr, len) -> i64` | the one entry point |
///
/// `bt_call` returns the reply as a packed `(ptr << 32) | len`; the reply is always
/// UTF-8 JSON, and the host frees it with `bt_dealloc`. One entry point rather than
/// one export per verb keeps the surface that must be kept in sync to a single
/// signature.
pub mod abi {
/// `bt_call` op: [`super::Manifest`].
pub const OP_MANIFEST: u32 = 1;
/// `bt_call` op: the [`super::BuildVariantSet`] declaration.
pub const OP_VARIANTS: u32 = 2;
/// `bt_call` op: [`super::BuildRequest`] in, [`super::Verdict`] out.
pub const OP_VALIDATE: u32 = 3;
/// `bt_call` op: [`super::ArtifactFacts`] in, [`super::Verdict`] out.
pub const OP_JUDGE_ARTIFACT: u32 = 4;
/// `bt_call` op: a variant name (bare JSON string) in, `Option<RebuildRecipe>` out.
pub const OP_REBUILD_RECIPE: u32 = 5;
/// `bt_call` op: the human describe text, as a JSON string.
pub const OP_DESCRIBE: u32 = 6;
/// `bt_call` op: the role-grouped [`super::BuildMenu`].
pub const OP_MENU: u32 = 7;
/// `bt_call` op: [`super::SpecProvenance`] — the `(repo, rev)` pin.
pub const OP_PROVENANCE: u32 = 8;
/// `bt_call` op: a variant name (bare JSON string) in, `Option<BuildPlan>` out.
pub const OP_PLAN: u32 = 9;
/// `bt_call` op: a variant name (bare JSON string) in, [`super::BuildGraph`] out.
pub const OP_GRAPH: u32 = 10;
/// `bt_call` op: [`super::OptionsQuery`] in, `Option<SettingsTree>` out.
pub const OP_OPTIONS: u32 = 11;
/// `bt_call` op: [`ToggleRequest`](crate::tree::ToggleRequest) in,
/// [`ToggleDecision`](crate::tree::ToggleDecision) out.
pub const OP_TRY_TOGGLE: u32 = 12;
/// `bt_call` op: an [`ArtifactKind`](crate::variant::ArtifactKind) (JSON string)
/// in, `Vec<Action>` out.
pub const OP_ACTIONS: u32 = 13;
/// Export name: the ABI probe.
pub const EXPORT_ABI_VERSION: &str = "bt_abi_version";
/// Export name: allocate.
pub const EXPORT_ALLOC: &str = "bt_alloc";
/// Export name: deallocate.
pub const EXPORT_DEALLOC: &str = "bt_dealloc";
/// Export name: the single entry point.
pub const EXPORT_CALL: &str = "bt_call";
/// Export name: linear memory.
pub const EXPORT_MEMORY: &str = "memory";
/// Pack a reply buffer into `bt_call`'s return value.
pub fn pack(ptr: u32, len: u32) -> i64 {
(((ptr as u64) << 32) | (len as u64)) as i64
}
/// Unpack `bt_call`'s return value into `(ptr, len)`.
pub fn unpack(v: i64) -> (u32, u32) {
let v = v as u64;
((v >> 32) as u32, (v & 0xffff_ffff) as u32)
}
}
/// **Generate the wasm exports for a [`BuildThing`].** One line in a buildspec crate:
///
/// ```ignore
/// nornir_build_thing::export_build_thing!(HolgerBuildThing);
/// ```
///
/// Emits `bt_abi_version` / `bt_alloc` / `bt_dealloc` / `bt_call`, each `#[no_mangle]`
/// and `extern "C"`, under `#[cfg(target_arch = "wasm32")]` — so the same crate is a
/// plain library natively and a plugin when built for wasm, from one source.
///
/// The type must implement [`BuildThing`] + [`Default`].
#[macro_export]
macro_rules! export_build_thing {
($ty:ty) => {
#[cfg(target_arch = "wasm32")]
mod __build_thing_abi {
use super::*;
#[unsafe(no_mangle)]
pub extern "C" fn bt_abi_version() -> i32 {
$crate::plugin::ABI_VERSION as i32
}
#[unsafe(no_mangle)]
pub extern "C" fn bt_alloc(len: i32) -> i32 {
let mut buf: Vec<u8> = Vec::with_capacity(len.max(0) as usize);
let ptr = buf.as_mut_ptr();
core::mem::forget(buf);
ptr as i32
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn bt_dealloc(ptr: i32, len: i32) {
if ptr != 0 {
drop(Vec::from_raw_parts(ptr as *mut u8, 0, len.max(0) as usize));
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn bt_call(op: i32, ptr: i32, len: i32) -> i64 {
let arg: &str = if ptr == 0 || len <= 0 {
""
} else {
let bytes = core::slice::from_raw_parts(ptr as *const u8, len as usize);
core::str::from_utf8(bytes).unwrap_or("")
};
let bt = <$ty>::default();
let json = $crate::plugin::dispatch(&bt, op as u32, arg);
let bytes = json.into_bytes();
let out_len = bytes.len() as u32;
let mut boxed = bytes.into_boxed_slice();
let out_ptr = boxed.as_mut_ptr() as u32;
core::mem::forget(boxed);
$crate::plugin::abi::pack(out_ptr, out_len)
}
}
};
}
/// **The guest-side dispatcher** — the body behind `bt_call`, shared by every plugin
/// so no buildspec crate hand-writes op decoding. Public because the
/// [`export_build_thing!`] macro expands to a call to it; not usually called directly.
///
/// Every reply is JSON. A malformed argument is answered with a refusing [`Verdict`],
/// never a trap: a plugin that panics tells the host nothing.
pub fn dispatch<T: BuildThing + ?Sized>(bt: &T, op: u32, arg: &str) -> String {
fn enc<S: Serialize>(v: &S) -> String {
serde_json::to_string(v).unwrap_or_else(|e| {
serde_json::to_string(&Verdict::refuse(format!(
"plugin could not encode a reply: {e}"
)))
.unwrap_or_else(|_| "{\"ok\":false,\"reasons\":[\"encode failed\"]}".to_string())
})
}
match op {
abi::OP_MANIFEST => enc(&bt.manifest()),
abi::OP_VARIANTS => enc(&bt.variants()),
abi::OP_VALIDATE => match serde_json::from_str::<BuildRequest>(arg) {
Ok(req) => enc(&bt.validate(&req)),
Err(e) => enc(&Verdict::refuse(format!("unreadable BuildRequest: {e}"))),
},
abi::OP_JUDGE_ARTIFACT => match serde_json::from_str::<ArtifactFacts>(arg) {
Ok(f) => enc(&bt.judge_artifact(&f)),
Err(e) => enc(&Verdict::refuse(format!("unreadable ArtifactFacts: {e}"))),
},
abi::OP_REBUILD_RECIPE => match serde_json::from_str::<String>(arg) {
Ok(name) => enc(&bt.rebuild_recipe(&name)),
Err(e) => enc(&Verdict::refuse(format!("unreadable variant name: {e}"))),
},
abi::OP_DESCRIBE => enc(&bt.describe()),
abi::OP_MENU => enc(&bt.menu()),
abi::OP_PROVENANCE => enc(&bt.provenance()),
abi::OP_PLAN => match serde_json::from_str::<String>(arg) {
Ok(name) => enc(&bt.plan(&name)),
Err(e) => enc(&Verdict::refuse(format!("unreadable variant name: {e}"))),
},
abi::OP_GRAPH => match serde_json::from_str::<String>(arg) {
Ok(name) => enc(&bt.graph(&name)),
Err(e) => enc(&Verdict::refuse(format!("unreadable variant name: {e}"))),
},
abi::OP_OPTIONS => match serde_json::from_str::<OptionsQuery>(arg) {
Ok(q) => enc(&bt.options(&q.variant, &q.path)),
Err(e) => enc(&Verdict::refuse(format!("unreadable OptionsQuery: {e}"))),
},
abi::OP_TRY_TOGGLE => match serde_json::from_str::<crate::tree::ToggleRequest>(arg) {
Ok(req) => enc(&bt.try_toggle(&req)),
Err(e) => enc(&Verdict::refuse(format!("unreadable ToggleRequest: {e}"))),
},
abi::OP_ACTIONS => match serde_json::from_str::<crate::variant::ArtifactKind>(arg) {
Ok(kind) => enc(&bt.actions(kind)),
Err(e) => enc(&Verdict::refuse(format!("unreadable ArtifactKind: {e}"))),
},
other => enc(&Verdict::refuse(format!("unknown build-thing op {other}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Toy;
impl BuildThing for Toy {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: "toy".into(),
source_crate: "nornir-buildspec-toy".into(),
summary: "a toy".into(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet::from_toml_str(
r#"
[build]
offers = ["a", "b"]
exclusive = [["a", "b"]]
[[build.variant]]
name = "only-a"
features = ["a"]
"#,
)
.unwrap()
}
/// The app's OWN rule — the thing that does not serialize.
fn judge_artifact(&self, facts: &ArtifactFacts) -> Verdict {
if facts.linked_crates.iter().any(|c| c == "reqwest") {
return Verdict::refuse("a toy artifact must not link reqwest");
}
Verdict::ok()
}
}
#[test]
fn the_default_validate_applies_the_declared_rules() {
let t = Toy;
assert!(t.validate(&BuildRequest::variant("only-a")).ok);
let v = t.validate(&BuildRequest::variant("ghost"));
assert!(!v.ok);
assert!(v.reasons[0].contains("no variant 'ghost'"), "{v:?}");
// An ad-hoc combo is judged too — the "could I build THIS?" question.
let v = t.validate(&BuildRequest::features(["a", "b"]));
assert!(!v.ok, "a and b are mutually exclusive");
assert!(v.reasons[0].contains("mutually exclusive"), "{v:?}");
let v = t.validate(&BuildRequest::features(["nope"]));
assert!(!v.ok && v.reasons[0].contains("not offered"), "{v:?}");
}
/// **The reason this is code.** `judge_artifact` is a rule only the app knows;
/// there is no field in any JSON schema that expresses it.
#[test]
fn an_apps_own_artifact_rule_runs() {
let t = Toy;
let clean = ArtifactFacts {
linked_crates: vec!["serde".into(), "hyper".into()],
..Default::default()
};
assert!(t.judge_artifact(&clean).ok);
let egressing = ArtifactFacts {
linked_crates: vec!["reqwest".into()],
..Default::default()
};
let v = t.judge_artifact(&egressing);
assert!(!v.ok);
assert!(v.line().contains("must not link reqwest"), "{}", v.line());
}
/// The dispatcher is the guest half of the ABI; exercising it natively is
/// exercising exactly the code the wasm export runs.
#[test]
fn dispatch_answers_every_op_in_json() {
let t = Toy;
let m: Manifest = serde_json::from_str(&dispatch(&t, abi::OP_MANIFEST, "")).unwrap();
assert_eq!(m.repo, "toy");
assert_eq!(m.abi, ABI_VERSION);
let set: BuildVariantSet =
serde_json::from_str(&dispatch(&t, abi::OP_VARIANTS, "")).unwrap();
assert_eq!(set.names(), vec!["only-a"]);
let req = serde_json::to_string(&BuildRequest::variant("only-a")).unwrap();
let v: Verdict = serde_json::from_str(&dispatch(&t, abi::OP_VALIDATE, &req)).unwrap();
assert!(v.ok);
let facts = serde_json::to_string(&ArtifactFacts {
linked_crates: vec!["reqwest".into()],
..Default::default()
})
.unwrap();
let v: Verdict =
serde_json::from_str(&dispatch(&t, abi::OP_JUDGE_ARTIFACT, &facts)).unwrap();
assert!(!v.ok);
let name = serde_json::to_string("only-a").unwrap();
let r: Option<RebuildRecipe> =
serde_json::from_str(&dispatch(&t, abi::OP_REBUILD_RECIPE, &name)).unwrap();
assert_eq!(r.unwrap().cargo_args, vec!["build", "--features", "a"]);
let d: String = serde_json::from_str(&dispatch(&t, abi::OP_DESCRIBE, "")).unwrap();
assert!(d.contains("nornir-buildspec-toy"));
}
/// The three tree ops answer over the same dispatcher — the tree, the
/// judge and the verbs — and a refusal of `fetch` is a NAMED error natively.
#[test]
fn the_tree_ops_dispatch_and_fetch_refuses_by_name() {
let t = Toy;
let q = serde_json::to_string(&OptionsQuery { variant: "only-a".into(), path: String::new() }).unwrap();
let tree: Option<crate::tree::SettingsTree> =
serde_json::from_str(&dispatch(&t, abi::OP_OPTIONS, &q)).unwrap();
let tree = tree.expect("a declared variant has a tree");
assert_eq!(tree.variant, "only-a");
assert!(tree.paths().is_empty(), "a variant with no `open` axis has nothing to switch: {:?}", tree.paths());
let q = serde_json::to_string(&OptionsQuery { variant: "ghost".into(), path: String::new() }).unwrap();
let none: Option<crate::tree::SettingsTree> =
serde_json::from_str(&dispatch(&t, abi::OP_OPTIONS, &q)).unwrap();
assert!(none.is_none());
let req = serde_json::to_string(&crate::tree::ToggleRequest::new("only-a", "a", false)).unwrap();
let d: crate::tree::ToggleDecision =
serde_json::from_str(&dispatch(&t, abi::OP_TRY_TOGGLE, &req)).unwrap();
assert!(!d.verdict.ok && d.verdict.line().contains("not a setting"), "an always-on feature is not a toggle: {d:?}");
let req = serde_json::to_string(&crate::tree::ToggleRequest::new("ghost", "a", false)).unwrap();
let d: crate::tree::ToggleDecision =
serde_json::from_str(&dispatch(&t, abi::OP_TRY_TOGGLE, &req)).unwrap();
assert!(!d.verdict.ok && d.verdict.line().contains("no variant 'ghost'"), "{d:?}");
let kind = serde_json::to_string(&crate::variant::ArtifactKind::Kernel).unwrap();
let a: Vec<crate::verbs::Action> = serde_json::from_str(&dispatch(&t, abi::OP_ACTIONS, &kind)).unwrap();
assert_eq!(a, vec![crate::verbs::Action::Boot]);
let e = host::fetch("tunnr/tunnr-kernel").unwrap_err();
assert_eq!(e.coordinate, "tunnr/tunnr-kernel");
assert!(e.to_string().contains("refused by the host"), "{e}");
host::set_native_facts(host::NativeFacts {
fetchables: [("tunnr/tunnr-kernel".to_string(), b"bzImage".to_vec())].into_iter().collect(),
..Default::default()
});
let got = host::fetch("tunnr/tunnr-kernel").unwrap();
assert_eq!(got.bytes, b"bzImage");
assert_eq!(got.origin, host::FetchOrigin::HostException, "a fixture is a host exception, never the container");
host::set_native_facts(host::NativeFacts::default());
}
/// A plugin must never trap on bad input — a module that panics tells the host
/// nothing, and the host cannot tell "hostile" from "buggy" from "old".
#[test]
fn a_malformed_argument_is_refused_not_trapped() {
let t = Toy;
let v: Verdict = serde_json::from_str(&dispatch(&t, abi::OP_VALIDATE, "not json")).unwrap();
assert!(!v.ok);
assert!(v.reasons[0].contains("unreadable BuildRequest"), "{v:?}");
let v: Verdict = serde_json::from_str(&dispatch(&t, 999, "")).unwrap();
assert!(!v.ok);
assert!(v.reasons[0].contains("unknown build-thing op 999"), "{v:?}");
}
#[test]
fn the_abi_pack_round_trips() {
for (p, l) in [
(0u32, 0u32),
(1, 1),
(0xdead_beef, 0x1234),
(u32::MAX, u32::MAX),
] {
assert_eq!(abi::unpack(abi::pack(p, l)), (p, l));
}
}
#[test]
fn a_refusal_can_never_be_silent() {
let v = Verdict {
ok: false,
reasons: Vec::new(),
};
assert!(
v.line().contains("no reason given"),
"a refusal with no reason must SAY it has none: {}",
v.line()
);
assert_eq!(
Verdict::ok().and(Verdict::refuse("x")).ok,
false,
"and() is the AND"
);
}
// ───────────────── the SUITE axis, as the menu renders it ───────────────────
/// A build thing whose declaration ships TWO deliveries out of one crate graph —
/// the holger shape, minus holger.
struct TwoDeliveries(&'static str);
impl BuildThing for TwoDeliveries {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: "two".into(),
source_crate: "nornir-buildspec-two".into(),
summary: "two deliveries".into(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet::from_toml_str(self.0).unwrap()
}
}
/// **A menu section is a `(suite, role)` group, not a role.** Two deliveries each
/// get their own server section with their own normal case, and the shared atom
/// appears in both client sections — because it ships in both.
#[test]
fn the_menu_gives_each_delivery_its_own_section() {
let m = TwoDeliveries(
r#"
[build]
base_suite = "full"
[[build.variant]]
name = "holger-full"
role = "server"
suites = ["full"]
default = true
[[build.variant]]
name = "holger-mvp"
role = "server"
suites = ["mvp"]
default = true
[[build.variant]]
name = "agent-online"
role = "client"
suites = ["full", "mvp"]
default = true
"#,
)
.menu();
let sections: Vec<(Option<&str>, &str, Vec<&str>, Option<&str>)> = m
.roles
.iter()
.map(|g| {
(
g.suite.as_deref(),
g.role.as_str(),
g.variants.iter().map(|s| s.as_str()).collect(),
g.default_variant.as_deref(),
)
})
.collect();
assert_eq!(
sections,
vec![
(
Some("full"),
"server",
vec!["holger-full"],
Some("holger-full")
),
(
Some("mvp"),
"server",
vec!["holger-mvp"],
Some("holger-mvp")
),
(
Some("full"),
"client",
vec!["agent-online"],
Some("agent-online")
),
(
Some("mvp"),
"client",
vec!["agent-online"],
Some("agent-online")
),
],
"four sections; the shared atom is in two of them"
);
assert!(m.check_defaults().is_empty(), "{:?}", m.check_defaults());
// One ENTRY per declared variant — the shared atom is listed twice in the
// menu but is one build point, not two.
assert_eq!(m.entries.len(), 3);
}
/// RED-WHEN-BROKEN: a delivery with variants and NO default is named BY SUITE in
/// the menu-level message, not just by role — otherwise the reader is told
/// "role 'server' has no default" about a repo whose other server section is fine.
#[test]
fn the_menu_names_the_defaultless_delivery() {
let m = TwoDeliveries(
r#"
[build]
base_suite = "full"
[[build.variant]]
name = "holger-full"
role = "server"
suites = ["full"]
default = true
[[build.variant]]
name = "holger-mvp"
role = "server"
suites = ["mvp"]
"#,
)
.menu();
let problems = m.check_defaults();
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("suite 'mvp'"), "{problems:?}");
assert!(problems[0].contains("NO default"), "{problems:?}");
assert!(!problems[0].contains("suite 'full'"), "{problems:?}");
}
/// ★ The two `check_defaults` implementations must not CONTRADICT. A declaration
/// marking two defaults in one role is rejected by
/// [`BuildVariantSet::check_defaults`]; the menu used to render only the section's
/// elected default as `is_default`, so the menu-level checker counted ONE and went
/// green on the very same file.
#[test]
fn a_double_default_cannot_be_green_in_the_menu_and_red_in_the_set() {
let t = TwoDeliveries(
r#"
[build]
[[build.variant]]
name = "a"
role = "server"
default = true
[[build.variant]]
name = "b"
role = "server"
default = true
"#,
);
let set = t.variants();
let m = t.menu();
// The set-level rule says two.
let set_problems = set.check_defaults();
assert_eq!(set_problems.len(), 1, "{set_problems:?}");
assert!(
set_problems[0].contains("marks 2 defaults"),
"{set_problems:?}"
);
// RED-WHEN-BROKEN: the section elects exactly ONE — reading only that is what
// made the menu-level checker blind.
assert_eq!(m.roles[0].default_variant.as_deref(), Some("a"));
assert_eq!(
m.roles[0]
.variants
.iter()
.filter(|v| m.roles[0].default_variant.as_deref() == Some(v.as_str()))
.count(),
1,
"the section names one — the old `is_default` derivation stopped here"
);
// The entries now carry what the DECLARATION said, so the menu agrees.
assert_eq!(m.entries.iter().filter(|e| e.is_default).count(), 2);
let menu_problems = m.check_defaults();
assert_eq!(menu_problems.len(), 1, "{menu_problems:?}");
assert!(
menu_problems[0].contains("marks 2 defaults"),
"{menu_problems:?}"
);
}
/// The suite-LESS menu is unchanged: no section names a suite, and a build thing
/// with no role axis still gets its single all-variants section.
#[test]
fn a_suiteless_menu_names_no_delivery() {
let m = Toy.menu();
assert!(m.roles.iter().all(|g| g.suite.is_none()), "{:?}", m.roles);
assert_eq!(m.roles.len(), 1);
assert_eq!(m.roles[0].variants, vec!["only-a"]);
assert_eq!(m.roles[0].default_variant.as_deref(), Some("only-a"));
assert!(m.check_defaults().is_empty(), "{:?}", m.check_defaults());
// The message shape for a suite-less repo must not grow a `suite '…'` prefix.
let bare = TwoDeliveries(
"[build]\n[[build.variant]]\nname = \"a\"\nrole = \"server\"\n\
[[build.variant]]\nname = \"b\"\nrole = \"server\"\n",
)
.menu();
let problems = bare.check_defaults();
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].starts_with("role 'server'"), "{problems:?}");
assert!(!problems[0].contains("suite"), "{problems:?}");
}
// ── the solo role ────────────────────────────────────────────────────────
/// `role = "solo"` parses, round-trips and labels itself.
#[test]
fn solo_is_a_declarable_role() {
assert_eq!(Role::Solo.as_str(), "solo");
let r: Role = serde_json::from_str("\"solo\"").expect("parse");
assert_eq!(r, Role::Solo);
assert_eq!(serde_json::to_string(&Role::Solo).expect("ser"), "\"solo\"");
// It is NOT quietly aliased onto an existing role — the whole point.
assert_ne!(Role::Solo, Role::Server);
assert_ne!(Role::Solo, Role::Client);
}
/// ★ **The reason `Solo` had to be a role and not a note in a comment.**
///
/// `check_defaults` demands exactly one default PER GROUP. Before `Solo`
/// existed, a repo's UI client and its self-contained solo build had to share
/// the `client` group — so declaring both as normal was a hard error, and the
/// repo had to pick one and leave the other with no declared normal case. RED
/// first with the two crammed into `client`, GREEN once solo is its own group.
#[test]
fn solo_needs_its_own_group_to_hold_its_own_default() {
// RED: two defaults in one `client` group — the shape a repo was forced into.
let crammed = TwoDeliveries(
"[build]\n\
[[build.variant]]\nname = \"korp-ui\"\nrole = \"client\"\ndefault = true\n\
[[build.variant]]\nname = \"korp-solo\"\nrole = \"client\"\ndefault = true\n",
)
.menu();
let problems = crammed.check_defaults();
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("marks 2 defaults"), "{problems:?}");
assert!(problems[0].contains("korp-ui, korp-solo"), "{problems:?}");
// GREEN: solo is its own group, and each names its own normal case.
let split = TwoDeliveries(
"[build]\n\
[[build.variant]]\nname = \"korp-ui\"\nrole = \"client\"\ndefault = true\n\
[[build.variant]]\nname = \"korp-solo\"\nrole = \"solo\"\ndefault = true\n",
)
.menu();
assert!(
split.check_defaults().is_empty(),
"{:?}",
split.check_defaults()
);
assert_eq!(split.roles.len(), 2, "{:?}", split.roles);
assert_eq!(split.in_role(Role::Solo), vec!["korp-solo"]);
assert_eq!(split.in_role(Role::Client), vec!["korp-ui"]);
assert_eq!(split.role_of("korp-solo"), Some(Role::Solo));
}
}
// ── the graph: what a build IS, as a picture's worth of data ─────────────────
/// What a [`GraphNode`] stands for. A picture answers "what does this variant
/// compile in" far better than a table, and the node kind is what lets a renderer
/// colour that answer categorically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NodeKind {
/// The variant being built — the root of the picture.
Variant,
/// One step of its plan (a program + argv).
Step,
/// A file the plan is expected to produce.
Artifact,
/// A sibling **compiled IN** (a fat build's `embeds`).
Embedded,
/// A sibling **reached over gRPC** (a thin build's `reaches`) — needs it running.
Reached,
/// A source file (or cargo target) carrying a `cfg` gate — see
/// [`crate::gating`]. Its `detail` names the gate and whether THIS variant
/// compiles it in, gates it out, or leaves it undecided.
SourceFile,
}
impl NodeKind {
/// Stable label.
pub fn as_str(self) -> &'static str {
match self {
NodeKind::Variant => "variant",
NodeKind::Step => "step",
NodeKind::Artifact => "artifact",
NodeKind::Embedded => "embedded",
NodeKind::Reached => "reached",
NodeKind::SourceFile => "source-file",
}
}
}
/// What an edge means. A renderer draws `Reaches` differently (dashed — it is a
/// runtime dependency, not a compile-time one), which is the whole fat/thin story
/// in one visual distinction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EdgeKind {
/// variant → step, or step → step: the plan's order.
Plan,
/// step → artifact: what the step produces.
Produces,
/// variant → sibling **compiled in**. Solid: it is in the binary.
Embeds,
/// variant → sibling **reached over gRPC**. Dashed: it must be RUNNING.
Reaches,
/// variant → a source file its feature set gates. The layer that answers "what
/// code is actually IN this build" — see [`crate::gating`].
Gates,
}
impl EdgeKind {
/// Stable label.
pub fn as_str(self) -> &'static str {
match self {
EdgeKind::Plan => "plan",
EdgeKind::Produces => "produces",
EdgeKind::Embeds => "embeds",
EdgeKind::Reaches => "reaches",
EdgeKind::Gates => "gates",
}
}
/// Whether a renderer should dash it — a runtime edge, not a compile-time one.
pub fn dashed(self) -> bool {
matches!(self, EdgeKind::Reaches)
}
}
/// One node of a [`BuildGraph`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphNode {
/// Stable id, unique within the graph — what a click returns.
pub id: String,
/// What to draw on it.
pub label: String,
/// What it stands for.
pub kind: NodeKind,
/// A second line: the argv for a step, the runtime cost for a variant, the
/// expected path for an artifact. Optional; a renderer may show it on hover.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
/// For a [`NodeKind::Variant`] node: the variant id to select when it is clicked.
/// This is why the graph is data — a picture you can build from.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selects: Option<String>,
}
/// One edge of a [`BuildGraph`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphEdge {
/// Source node id.
pub from: String,
/// Target node id.
pub to: String,
/// What it means.
pub kind: EdgeKind,
/// An optional mid-edge label.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
/// **What a build IS, as a graph.** Returned by [`BuildThing::graph`].
///
/// # Why data and not SVG
///
/// SVG was the other option and is the wrong one. facett renders egui natively, so
/// an SVG would mean shipping a parser and a rasteriser, and it would throw away
/// hit-testing, hover, selection, zoom and live status — everything that makes the
/// picture worth having. **A node the user can click to build is worth more than a
/// picture of one.** The plugin describes; `facett-graphpan` lays out and paints.
///
/// It is plain serde with no egui type anywhere near it, because it crosses the wasm
/// boundary — the plugin *cannot* draw, and should not be able to.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildGraph {
/// The repo this graph belongs to.
pub repo: String,
/// The variant at its root (empty for a whole-menu overview).
pub root: String,
/// The nodes.
pub nodes: Vec<GraphNode>,
/// The edges.
pub edges: Vec<GraphEdge>,
}
impl BuildGraph {
/// Node ids, in order.
pub fn node_ids(&self) -> Vec<&str> {
self.nodes.iter().map(|n| n.id.as_str()).collect()
}
/// Edges of one kind.
pub fn edges_of(&self, kind: EdgeKind) -> Vec<&GraphEdge> {
self.edges.iter().filter(|e| e.kind == kind).collect()
}
/// The siblings this build **compiles in** — the fat story, in one call.
pub fn embedded(&self) -> Vec<&str> {
self.nodes
.iter()
.filter(|n| n.kind == NodeKind::Embedded)
.map(|n| n.label.as_str())
.collect()
}
/// The siblings this build **needs running** — the thin story.
pub fn reached(&self) -> Vec<&str> {
self.nodes
.iter()
.filter(|n| n.kind == NodeKind::Reached)
.map(|n| n.label.as_str())
.collect()
}
/// Every edge points at a declared node — the oracle that a renderer can lay this
/// out without inventing a node to hang a dangling edge from.
pub fn dangling_edges(&self) -> Vec<String> {
let ids: std::collections::BTreeSet<&str> = self.node_ids().into_iter().collect();
self.edges
.iter()
.filter(|e| !ids.contains(e.from.as_str()) || !ids.contains(e.to.as_str()))
.map(|e| format!("{} -{}-> {}", e.from, e.kind.as_str(), e.to))
.collect()
}
}