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

nordisk/gunnar-ui

crates/gunnar-ui-recipe/src/variants.rs

//! **The build variants — every `[[build.variant]]` gunnar-ui declares, as Rust.**
//!
//! The model: variant → features → settings/sub-settings → typ. Each row is one
//! `(package × bin × features × profile × target)` point with its artifact kind
//! (the typ), its linkage and its role.
//!
//! # Where these rows come from
//!
//! Not from a design. Every row below is a build this repository ALREADY
//! performs, read off the doors that perform it:
//!
//! * `monetize-impl`'s `cargo xtask refresh-front --only console` — the door
//!   that ships the console, in Rust. It builds
//!   `-p gunnar-ui-server --features metered --target x86_64-unknown-linux-musl`
//!   and `trunk build --release --features metered` in `crates/gunnar-ui-web`,
//!   both words read off one declaration
//!   (`deploy/front/src/artefact.rs`). It replaced `deploy/deploy.sh`, which was
//!   the only door that shipped anything until 2026-09-18 and is now a stub —
//!   that script passed `--features` to cargo and NOTHING to trunk, which is the
//!   gap the `gunnar-ui-web-metered` row below had declared since it was written.
//! * `.github/workflows/ci.yml` — `cargo clippy -p gunnar-ui-web --target
//!   wasm32-unknown-unknown`, `cargo test -p gunnar-ui-server --features
//!   metered`, and the `console` job's `trunk build --release`.
//! * `README.md` — the two halves and the `metered` split, in the product's own
//!   words.
//!
//! New reasoning goes HERE, never into the emitted toml: `emit` overwrites it.

use std::collections::BTreeSet;

use nornir_build_thing::plugin::Role;
use nornir_build_thing::variant::{ArtifactKind, BuildVariant, Profile, ServiceShape};

/// **The product** (`cargo install gunnar-ui`): the console crate, a thin bin over the BFF.
pub const PRODUCT: &str = "gunnar-ui";
/// The BFF package — the axum server half.
pub const SERVER: &str = "gunnar-ui-server";
/// The console package — the Leptos/WASM browser half.
pub const WEB: &str = "gunnar-ui-web";

/// **The one triple the BFF is shipped as.** `deploy/deploy.sh`'s
/// `GUNNAR_UI_TRIPLE` default, and it is static-pie on purpose: the front is
/// Ubuntu with glibc 2.39 and a natively linked binary built on a newer
/// developer box lands there and dies with `version GLIBC_2.43 not found`,
/// AFTER the upload — on the far side of the wire, where nothing this
/// repository runs can catch it.
pub const MUSL: &str = "x86_64-unknown-linux-musl";
/// The browser target. `rust-toolchain.toml` carries it, so a fresh clone has it.
pub const WASM32: &str = "wasm32-unknown-unknown";

/// The suite member the BFF installs as — `gunnar_ui_server::lifecycle::UNIT`,
/// the name skidbladnir gives the unit and the dedicated system user.
pub const MEMBER: &str = "gunnar-ui";
/// The suite member the BFF speaks gRPC to.
const GUNNAR: &str = "gunnar";
/// The suite member the `metered` half speaks gRPC to for plans, quota and
/// payment. Compiled out entirely without the feature — gunnar's rule, quoted
/// verbatim in its `entitlement.proto`: "the community appliance does not
/// DECLINE to enforce plans, it does not CONTAIN the code".
const MONETIZE: &str = "monetize";

/// The paid half, in gunnar's own spelling. ONE word turns the metered build on
/// across every binary in the estate; a second spelling here would let the
/// console and the BFF disagree about whether this box sells anything.
pub const METERED: &str = "metered";

/// One row, spelled with only the fields gunnar-ui's declaration uses.
struct Row {
    name: &'static str,
    role: Role,
    is_default: bool,
    package: &'static str,
    bin: &'static str,
    kind: ArtifactKind,
    member: bool,
    features: &'static [&'static str],
    target: &'static str,
    reaches: &'static [&'static str],
    /// Where a browser row's bundle LANDS, relative to its package directory
    /// (trunk's `--dist`). `None` for a native row. See [`bundle_dist`].
    dist: Option<&'static str>,
}

impl Row {
    fn to_variant(&self) -> BuildVariant {
        BuildVariant {
            name: self.name.to_string(),
            package: Some(self.package.to_string()),
            bin: Some(self.bin.to_string()),
            features: self.features.iter().map(|f| (*f).to_string()).collect(),
            // NOT `no_default_features`. Both packages declare `default = []`,
            // so `--no-default-features` would be a flag that changes nothing
            // while claiming the row decided something — and neither door that
            // builds these passes it. The row describes the command that runs.
            no_default_features: false,
            profile: Profile::Release,
            // `""`: the host's (the product row is its `cargo install` line).
            target: (!self.target.is_empty()).then(|| self.target.to_string()),
            member: self.member.then(|| MEMBER.to_string()),
            // THIN everywhere: nothing here compiles a sibling in. The BFF
            // links gunnar's protos and reaches a running gunnar; the console
            // is a browser bundle that reaches the BFF. `embeds` stays empty,
            // which `validate_shape` holds thin rows to.
            shape: ServiceShape::ThinStandalone,
            // MVP on every row (lane GUN-PRODUCTS, 2026-10-30): the console's rows are in
            // Gunnar's one suite, the one `cargo xtask build --suite MVP` names in gunnar
            // and holger alike (`workspace_nordisk/.nornir/SUITE-MODEL.md`: one command
            // shape in every repo). It was ORIGINAL until gunnar's ORIGINAL became MVP.
            suites: vec!["MVP".to_string()],
            role: Some(self.role),
            is_default: self.is_default,
            kind: self.kind,
            reaches: self
                .reaches
                .iter()
                .map(|r| (*r).to_string())
                .collect::<BTreeSet<_>>(),
            ..BuildVariant::default()
        }
    }
}

/// **Every build variant gunnar-ui declares**, in declaration order.
///
/// Order matters: `BuildVariantSet.variants` is a `Vec`, the menu and the
/// per-role default groups iterate it, and the committed toml follows it.
fn rows() -> [Row; 5] {
    use ArtifactKind::{Exe, Service, Wasm};
    use Role::{Client, Server};
    [
        // ══ the product — `cargo install gunnar-ui` (lane GUN-PRODUCTS, 2026-10-30) ═══
        //
        // gunnar-ui — the console as Gunnar's product crate: the thin bin over the
        // BFF's library with the console bundle built in (`crates/gunnar-ui`). The row
        // IS its `cargo install` line: the defaults (AIRGAP, no extra), the host's
        // target. A client of gunnar, like the bundle; its program is the BFF's.
        Row {
            name: PRODUCT,
            role: Client,
            is_default: false,
            package: PRODUCT,
            bin: PRODUCT,
            kind: Exe,
            member: false,
            features: &[],
            target: "",
            reaches: &[GUNNAR],
            dist: None,
        },
        // ══ role: server — the BFF, the half a host runs ═══════════════════════
        //
        // Kind `service`, not `exe`: the binary installs ITSELF through
        // skidbladnir (`gunnar_ui_server::lifecycle`), which mints the unit and
        // the dedicated system user named by `member`. Until 2026-09-13 this
        // console was the last service in the suite that did not, and shipped a
        // hand-written unit a ~200 line bash script `cp`ed into place.
        //
        // gunnar-ui-server — the COMMUNITY BFF: no money code in the binary at
        // all. `deploy/deploy.sh` builds this one only when an operator sets
        // `GUNNAR_UI_FEATURES=` empty on purpose, and the far side refuses to
        // install it on a host whose env file says it sells
        // (`lifecycle::Refusal::ConfiguredToSellButCannot`).
        Row {
            name: SERVER,
            role: Server,
            is_default: false,
            package: SERVER,
            bin: SERVER,
            kind: Service,
            member: true,
            features: &[],
            target: MUSL,
            reaches: &[GUNNAR],
            dist: None,
        },
        // gunnar-ui-server-metered — THE SHIPPED BFF, and the default of the
        // server group because it is what `deploy/deploy.sh` builds with no
        // arguments and what gunnar.rs runs. `metered` compiles in the monetize
        // client, the signup routes and `/welcome` — the page a payment vendor
        // returns a customer to. MEASURED on the front 2026-09-12: the running
        // binary had none of them while `/etc/gunnar-ui.env` had been carrying
        // GUNNAR_UI_MONETIZE all along; `GET /welcome?session=…` answered 401,
        // and two live card payments taken 2026-09-11 were refunded. That is
        // why the default is the paid half and not the lean one.
        Row {
            name: "gunnar-ui-server-metered",
            role: Server,
            is_default: true,
            package: SERVER,
            bin: SERVER,
            kind: Service,
            member: true,
            features: &[METERED],
            target: MUSL,
            reaches: &[GUNNAR, MONETIZE],
            dist: None,
        },
        // ══ role: client — the console, the half a browser runs ════════════════
        //
        // `role = "client"` and NOT a new `role = "ui"`. The role axis already
        // has the word: `Role::Client` is "something a human or a script drives
        // AGAINST a server — a UI, a CLI", which is this bundle exactly. It is
        // not `Solo` (nothing is compiled in; without the BFF running the
        // console is a blank page) and not `Server` (it is deployed as files a
        // server hands out, it opens no port and owns no lifecycle).
        //
        // `linkage = "thin"` with `reaches = ["gunnar-ui"]`. The wire is
        // REST + SSE rather than gRPC, so this row uses `reaches` for its plain
        // meaning — a running owner is REQUIRED for this artifact to do
        // anything — and makes no claim about the protocol. Saying nothing
        // would have been worse: an omitted linkage reads as `fat`, a positive
        // claim of self-containment nobody made.
        //
        // ⚠ **`kind = "wasm"` describes the CARGO build point, and the bundle is
        // built by trunk.** `trunk build --release` runs this row's cargo build
        // and then three more things the model has no word for: wasm-bindgen at
        // the version `Trunk.toml` pins, `wasm-opt -Oz`, and the `index.html`
        // asset pipeline — and it writes CONTENT-HASHED names
        // (`gunnar-ui-web-<hash>_bg.wasm`), which is why no `[[build.wasm]]`
        // cell is declared here: `BindgenStage` DERIVES its output names from
        // the package name (`gunnar_ui_web_bg.wasm`), so a cell would name two
        // files trunk never writes. The absence is documented, tested
        // (`tests/toml_parity.rs`) and is the honest half of a real gap in the
        // shared vocabulary; a `TrunkStage` beside `BindgenStage` is the fix,
        // and minting one quietly here is not.
        //
        // gunnar-ui-web — the community console. The default of the client
        // group because it is what a bare `trunk build --release` produces,
        // which is the only console build any door in this repository performs.
        Row {
            name: WEB,
            role: Client,
            is_default: true,
            package: WEB,
            bin: WEB,
            kind: Wasm,
            member: false,
            features: &[],
            target: WASM32,
            reaches: &[MEMBER],
            dist: Some(DIST),
        },
        // gunnar-ui-web-metered — the PAID console: `pages::increase`,
        // `pages::account`'s subscription pane and `pages::subscription`, none
        // of which a community build contains.
        //
        // ⚠ WAS: "no door in this repository builds this row today" — and it
        // was true from the row's birth until 2026-09-18. `deploy/deploy.sh`
        // passed `--features "$FEATURES"` to the BFF and NOTHING to trunk, so
        // every metered deploy shipped a metered server and a COMMUNITY
        // bundle. MEASURED on the front that day: `gunnar-ui-server --version`
        // said `metered` and `/welcome` answered 200 (so `monetize.is_some()`,
        // so the `/increase` routes were registered), while the served
        // `gunnar-ui-web-b3d395abb37cdf9c_bg.wasm` contained neither
        // "More room" nor `/increase/offer`. An admin on `/usage/<ns>` had the
        // route and no button — the declaration was right and the door was
        // wrong, which is the case this row was written down to catch.
        //
        // Closed by two doors now: `xtask::build` drives trunk from THIS row's
        // `features`, and `deploy/deploy.sh` hands trunk the same
        // `$FEATURES` as cargo and then refuses to ship a bundle with no
        // `/increase/offer` in it. `index.html` still carries no
        // `data-cargo-features` and does not need one — the feature arrives on
        // trunk's command line.
        Row {
            name: "gunnar-ui-web-metered",
            role: Client,
            is_default: false,
            package: WEB,
            bin: WEB,
            kind: Wasm,
            member: false,
            features: &[METERED],
            target: WASM32,
            reaches: &[MEMBER],
            dist: Some(DIST_METERED),
        },
    ]
}

/// The community console's bundle directory — `Trunk.toml`'s own `dist`, and
/// the one CI measures (`.github/workflows/ci.yml`).
pub const DIST: &str = "dist";

/// **The PAID console's bundle directory.** gunnar's front medium embeds the
/// console from `gunnar-ui/crates/gunnar-ui-web/dist-metered` when it is built
/// `metered` (`gunnar/crates/gunnar-front/build.rs`), and until 2026-09-26 no
/// declared build wrote there: both wasm rows ran trunk into `Trunk.toml`'s
/// one `dist`, so the paid bundle overwrote the community one and reached
/// `dist-metered` only when somebody moved it by hand (MEASURED on loki that
/// day, for the prod front). The row now says where its bundle lands, and the
/// door hands trunk `--dist` from here. A separate directory also means the
/// two bundles never share a file: a community build can no longer be shipped
/// as the paid console by being the last one written.
pub const DIST_METERED: &str = "dist-metered";

/// **Where a browser row's bundle lands**, relative to its package directory
/// (the `--dist` its door hands trunk). `None` for a native row and for a name
/// this recipe does not declare.
///
/// Not a field of `BuildVariant`: the shared model has no word for trunk's
/// output yet (see the `gunnar-ui-web` row), so the answer lives in this
/// recipe, beside the rows, rather than in any door.
#[must_use]
pub fn bundle_dist(variant: &str) -> Option<&'static str> {
    rows().iter().find(|r| r.name == variant).and_then(|r| r.dist)
}

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

    /// The paid console has its own bundle directory, the one gunnar's front
    /// reads; the community console keeps trunk's default; no native row has one.
    #[test]
    fn each_console_row_names_its_own_bundle_dir() {
        assert_eq!(bundle_dist(WEB), Some(DIST));
        assert_eq!(bundle_dist("gunnar-ui-web-metered"), Some(DIST_METERED));
        assert_ne!(DIST, DIST_METERED, "two bundles, two directories");
        assert_eq!(bundle_dist(SERVER), None);
        assert_eq!(bundle_dist("gunnar-ui-server-metered"), None);
        assert_eq!(bundle_dist("no-such-row"), None);
        for r in rows() {
            assert_eq!(
                r.dist.is_some(),
                r.target.starts_with("wasm32"),
                "`{}`: a browser row names its bundle dir, a native row none",
                r.name
            );
        }
    }
}

/// The declared variants as nornir-build-thing's declaration type.
#[must_use]
pub fn variants() -> Vec<BuildVariant> {
    rows().iter().map(Row::to_variant).collect()
}

/// The browser package's directory, repository-relative: where its
/// `index.html` lives and where each bundle directory ([`bundle_dist`]) lands.
pub const WEB_DIR: &str = "crates/gunnar-ui-web";

/// **`[[build.web_app]]`** — each console bundle built through the Build
/// Thing's web-app step (nornir-build-thing `web_app`): the row's cargo build
/// in the pinned container, wasm-bindgen as a library at the version the lock
/// pins, the page assembled in Rust from `crates/gunnar-ui-web/index.html`.
/// One per browser row, claimed by the wasm row of the SAME name, landing in
/// that row's [`bundle_dist`] under [`WEB_DIR`]. Until 2026-09-28 only trunk
/// on the HOST wrote `dist-metered/`, and a box without trunk (dwarf, that
/// day) could not compose gunnar's metered front medium at all
/// (`[medium-no-console]`).
#[must_use]
pub fn web_apps() -> Vec<nornir_build_thing::web_app::WebAppBuild> {
    rows()
        .iter()
        .filter_map(|r| {
            r.dist.map(|d| nornir_build_thing::web_app::WebAppBuild {
                name: r.name.to_string(),
                out_dir: format!("{WEB_DIR}/{d}"),
                index: format!("{WEB_DIR}/index.html"),
            })
        })
        .collect()
}

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

    /// Every browser row has a web app, landing where gunnar's front reads it
    /// (`gunnar-ui/crates/gunnar-ui-web/dist-metered` for the paid console),
    /// and the recipe's set accepts each one.
    #[test]
    fn each_console_row_is_a_web_app_where_the_front_reads_it() {
        let apps = web_apps();
        let names: Vec<&str> = apps.iter().map(|a| a.name.as_str()).collect();
        assert_eq!(names, [WEB, "gunnar-ui-web-metered"]);
        let paid = apps.iter().find(|a| a.name == "gunnar-ui-web-metered").unwrap();
        assert_eq!(paid.out_dir, "crates/gunnar-ui-web/dist-metered");
        assert_eq!(paid.index, "crates/gunnar-ui-web/index.html");
        let set = crate::declared();
        for a in &apps {
            set.validate_web_app(a).unwrap();
        }
    }
}