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

nordisk/gunnar

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