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