crates/gunnar-recipe/src/atom.rs
//! **The two axes of gunnar's build thing: the atom you build, and the column
//! you deliver it in.**
//!
//! The scene picks one row and one column and presses BUILD. This module is what
//! it picks from, and — more importantly — what it is *not allowed* to pick. The
//! ledger of legal assemblies lives in [`Atom::assembles`], as a `const fn`, so a
//! variant naming an illegal pair is a **compile error** rather than a build that
//! discovers the problem after four minutes of `mkisofs`.
//!
//! # Why an enum and not a string
//!
//! `.nornir/build-variants.toml` named its atoms in strings. A typo there is a
//! runtime error at best and a silently-skipped build point at worst — the
//! failure that made `tally_bins` report a bin nobody built. An enum cannot be
//! mistyped and cannot grow a sixth member without every `match` in the crate
//! being revisited.
use nornir_build_thing::contract::PaneKind;
use nornir_build_thing::plugin::Role;
use nornir_build_thing::variant::ArtifactKind;
/// A **delivery** (Swedish *utgåva*) an atom ships in — the `suites` axis of
/// [`nornir_build_thing::variant::BuildVariant`].
///
/// gunnar has one delivery, and it is named out loud rather than left empty: an
/// unnamed delivery is indistinguishable from a delivery nobody declared. It is MVP
/// since lane GUN-PRODUCTS (2026-10-30, Holger's shape): the three products and the
/// estate's builds. It was ORIGINAL before.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Suite {
/// gunnar's one delivery: the products (`gunnar`, `gunnar-ui`, `gunnar-cli`) and the
/// estate's builds (the appliance, metered, the front, the media).
Mvp,
}
impl Suite {
/// The exact string the declaration carries.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Suite::Mvp => "MVP",
}
}
}
/// Every delivery gunnar declares, in declaration order.
pub const SUITES: &[Suite] = &[Suite::Mvp];
/// **What gunnar can be built as** — one row of the build-thing matrix.
///
/// The order of this enum is the order the rows are shown in and the order the
/// work is done in; see [`Atom::priority`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Atom {
/// **The service, and the highest priority thing in this repository.**
/// Worktree-free, spawns nothing, meant to be PID 1 with no operating system
/// under it. Everything else here is a client
/// of it.
GunnarServer,
/// **The command-line tool** (`gunnar-cli`): git with the encrypted
/// repositories, and the control plane over gRPC against
/// [`GunnarServer`](Atom::GunnarServer). Thin: useless without a server or a
/// remote to point at. (The client-only `gunnar` git binary and the solo
/// atom are retired since GUN-PRODUCTS: both are this program with features.)
GunnarCli,
/// **The web console** (`gunnar-ui`, the gunnar-ui repository): the bundle and
/// its server, a client of [`GunnarServer`](Atom::GunnarServer).
GunnarUi,
/// **vm1, the estate's FRONT machine** — `gunnar-front`, the FRONT medium's
/// PID 1 (owner 2026-09-25: "twin/main gunnar share ISO, but NOT the
/// ui/monetize!! BUILD an iso for them using build thing!!!").
///
/// main and twin boot the gunnar medium ([`GunnarServer`](Atom::GunnarServer));
/// vm1 boots ITS OWN medium, whose one program is this: the installer's
/// bootstrap listener presenting kind `front`, the estate's WireGuard relay,
/// the twin's certification, the signed clock, and the front's keys offered
/// to the installer. A **server** that a machine can BE — so, unlike the
/// clock daemon, it takes the installer column.
GunnarFront,
}
/// Every atom gunnar declares, in priority order.
///
/// `gunnar-server` first and `gunnar` second is the ordering from the
/// 2026-08-06 design, not an alphabetisation that happens to agree with it.
pub const ATOMS: &[Atom] = &[
Atom::GunnarServer,
Atom::GunnarCli,
Atom::GunnarUi,
Atom::GunnarFront,
];
/// Whether an atom has a build point today, and — when it does not — why not.
///
/// # Why "planned" is a value and not an absence
///
/// `gunnar-ui` is in the design's list of five and has no crate in the workspace.
/// Leaving it out of [`ATOMS`] would make the recipe disagree with the design
/// with nothing to point at; leaving it in with no variants and no explanation makes
/// an empty row look like a bug in the scene. Saying it out loud is the same
/// move `ServiceShape::NotApplicable` makes in `nornir-build-thing`: a reader can
/// tell "declared and inapplicable" from "nobody declared it".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Availability {
/// The atom has at least one declared variant and can be built now.
Buildable,
/// The atom is part of the product and has no build point yet. Carries the
/// reason, which a scene can show instead of an empty row.
Planned(&'static str),
}
/// Whether an `(atom, column)` pair is a thing that can exist.
///
/// [`Assembly::Refused`] carries the argument, because a refusal a caller cannot
/// read is indistinguishable from a bug in the table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assembly {
/// The pair is a legal assembly. Whether anybody has *declared* it is a
/// separate question — see [`crate::variants::variant`].
Valid,
/// The pair is not a thing, for this reason.
Refused(&'static str),
}
impl Atom {
/// The exact atom name — the string a receipt's `atom` field carries and the
/// string the scene shows.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Atom::GunnarServer => "gunnar-server",
Atom::GunnarCli => "gunnar-cli",
Atom::GunnarUi => "gunnar-ui",
Atom::GunnarFront => "gunnar-front",
}
}
/// Parse an atom back from the name a receipt or a coordinate carries.
///
/// Refuses anything it does not know rather than guessing. A near-miss that
/// resolved to *some* atom is the same class of failure `Coordinate::parse`
/// refuses an empty version for.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
ATOMS.iter().copied().find(|a| a.as_str() == s)
}
/// **Build order.** Lower goes first. `gunnar-server` is 0 because everything
/// else in this repository is a client of it, so a server that does not build
/// makes every other row's result meaningless.
#[must_use]
pub const fn priority(self) -> u8 {
match self {
Atom::GunnarServer => 0,
Atom::GunnarCli => 2,
Atom::GunnarUi => 3,
// Last: the front medium is built from the same server library the
// appliance is, so a red server row explains it first. (The front
// host's clock daemon, `gunnar-clock`, was 5 until lane L10
// 2026-10-21: the crate moved to edda estate-clock.)
Atom::GunnarFront => 6,
}
}
/// What this atom IS to the product — the axis a menu groups by.
#[must_use]
pub const fn role(self) -> Role {
match self {
Atom::GunnarServer | Atom::GunnarFront => Role::Server,
Atom::GunnarCli | Atom::GunnarUi => Role::Client,
}
}
/// Whether this atom can be built today.
#[must_use]
pub const fn availability(self) -> Availability {
// Every atom has a build point since GUN-PRODUCTS: the console's row builds
// the gunnar-ui checkout's `gunnar-ui` crate. `Planned` stays in the vocabulary
// for the next atom declared before its crate exists.
let _ = self;
Availability::Buildable
}
/// **The ledger of legal assemblies.** `const fn`, which is what makes an
/// illegal variant a compile error (see [`crate::variants`]).
///
/// Two rules do all the work here:
///
/// * **Only something that can be PID 1 gets an ISO.** An ISO boots a
/// machine. A git client on a machine with nothing to talk to is a boot
/// that has nowhere to go, so the client atoms are refused both ISO
/// columns.
/// * **Everything else is legal in every column**, including `rpm`, which is
/// legal everywhere and built nowhere ([`Column::Rpm`]).
#[must_use]
pub const fn assembles(self, column: Column) -> Assembly {
match (self, column) {
// The two self-contained shapes: a server, and a server with its
// client compiled in. Both can be the only thing on a machine.
(Atom::GunnarServer, _) => Assembly::Valid,
// The front's clock: a daemon, but a daemon FOR a host somebody else
// installed. It keeps that host's `CLOCK_REALTIME` and signs time for
// the appliance; it is not something a machine can BE, so it takes
// the three non-boot columns and is refused the two ISO ones for the
// same reason the clients are.
// vm1: a machine CAN be the front — it is its whole program, PID 1
// on the front medium. An executable (the payload) and the installer
// that lays it; no live ISO, container or rpm is declared or claimed.
(Atom::GunnarFront, Column::Exe | Column::IsoInstaller) => Assembly::Valid,
(Atom::GunnarFront, Column::IsoLive | Column::Container | Column::Rpm) => {
Assembly::Refused(
"gunnar-front is vm1's PID 1 and is delivered as one medium: the \
installer ISO that lays it (`gunnar-front-installer-open`). A live \
front would lose its keys at every boot, and a container or rpm \
would need an OS the front does not have.",
)
}
// The clients. Native, in a CI image, or in a package — never a
// bootable image.
(Atom::GunnarCli | Atom::GunnarUi, Column::Exe | Column::Container | Column::Rpm) => {
Assembly::Valid
}
(Atom::GunnarCli | Atom::GunnarUi, Column::IsoInstaller | Column::IsoLive) => {
Assembly::Refused(
"an ISO boots a machine, and a git client is not something a \
machine can be: there would be no service on it to talk to. \
Boot gunnar-server instead.",
)
}
}
}
/// Whether `(self, column)` is a legal assembly. Derived from
/// [`assembles`](Atom::assembles) so the ledger has exactly one writer.
#[must_use]
pub const fn supports(self, column: Column) -> bool {
matches!(self.assembles(column), Assembly::Valid)
}
}
/// **How an atom is delivered** — one column of the build-thing matrix.
///
/// The columns are fixed by the 2026-08-06 design and are the same five for
/// every repo in the constellation. A repo does not get to invent a column; it
/// gets to declare which variants it fills.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum Column {
/// A native executable. **The default, and never written out** — the same
/// choice `nornir_build_thing::variant::BuildVariant::kind` makes, where an
/// `exe` variant carries no `kind` key at all. A default that is spelled out
/// in half the declarations and omitted in the other half reads as two
/// different things.
#[default]
Exe,
/// An OCI image.
Container,
/// A bootable ISO that **installs** onto a disk and reboots off it.
IsoInstaller,
/// A bootable ISO that **is** the running system — nothing is written to a
/// disk. gunnar's existing appliance ISO is this one, not the installer:
/// its boot parameters end in `serve --no-grpc`, so it comes up serving.
IsoLive,
/// An RPM package.
///
/// **The lowest priority in the universe.** The column exists so that
/// "gunnar has no rpm" is a fact the recipe states rather than a gap a
/// reader has to infer, and so nothing has to be redesigned the day somebody
/// wants one. Nobody builds it: [`crate::variants::VARIANTS`] contains no `Rpm`
/// variant, and [`Column::priority`] puts it behind every column that will ever
/// be added.
Rpm,
}
/// Every column, in priority order.
pub const COLUMNS: &[Column] = &[
Column::Exe,
Column::Container,
Column::IsoInstaller,
Column::IsoLive,
Column::Rpm,
];
impl Column {
/// The exact column name — the string a receipt's `target` field carries.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Column::Exe => "exe",
Column::Container => "container",
Column::IsoInstaller => "iso-installer",
Column::IsoLive => "iso-live",
Column::Rpm => "rpm",
}
}
/// Parse a column back from a receipt's `target`.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
COLUMNS.iter().copied().find(|c| c.as_str() == s)
}
/// **Build order.** Lower goes first.
///
/// `Rpm` is `u8::MAX` rather than `4`. That is deliberate: a numbering that
/// merely counts leaves `rpm` one place behind whatever exists today, so the
/// sixth column would silently overtake it. `u8::MAX` says *last, always*.
#[must_use]
pub const fn priority(self) -> u8 {
match self {
Column::Exe => 0,
Column::Container => 1,
Column::IsoInstaller => 2,
Column::IsoLive => 3,
Column::Rpm => u8::MAX,
}
}
/// The artifact kind this column produces, when the contract has one for it.
///
/// `None` for [`Rpm`](Column::Rpm): `ArtifactKind` has no package arm, and
/// inventing one here — or borrowing `Lib` because it is also "not a thing
/// you run" — would be a second opinion about a type this crate does not
/// own.
#[must_use]
pub const fn artifact_kind(self) -> Option<ArtifactKind> {
match self {
Column::Exe => Some(ArtifactKind::Exe),
Column::Container => Some(ArtifactKind::Container),
Column::IsoInstaller | Column::IsoLive => Some(ArtifactKind::Iso),
Column::Rpm => None,
}
}
/// The panel the scene opens for this column.
///
/// The mapping itself is `PaneKind::for_artifact` in the contract — the whole
/// switch lives there and is not repeated here. This only says which artifact
/// kind the column produces. An rpm gets [`PaneKind::None`] because there is
/// nothing to run: an rpm is installed, not executed.
#[must_use]
pub const fn pane(self) -> PaneKind {
match self.artifact_kind() {
Some(kind) => PaneKind::for_artifact(kind),
None => PaneKind::None,
}
}
/// Whether an artifact kind is a legal narrowing of this column.
///
/// A variant may be more specific than its column — `gunnar-service` is an `exe`
/// that is a `Service`, so the scene gives it start/stop/log rather than an
/// argv box. It may not be something *else*: an `exe` variant producing an ISO
/// is a mislabelled variant, and this is `const fn` so it is a compile error.
#[must_use]
pub const fn admits(self, kind: ArtifactKind) -> bool {
matches!(
(self, kind),
(Column::Exe, ArtifactKind::Exe | ArtifactKind::Service)
| (Column::Container, ArtifactKind::Container)
| (Column::IsoInstaller | Column::IsoLive, ArtifactKind::Iso)
// A per-site installer ISO is a narrowing of the installer
// column, not of the live one: a live ISO IS the appliance, an
// installer PRODUCES one, and `ArtifactKind` split the two on
// 2026-08-13 so a build record can tell them apart.
| (Column::IsoInstaller, ArtifactKind::Installer)
)
}
}