//! # holger-egui-build-thing — the four egui rungs, declared in Rust
//!
//! THE LAW: the code is the source. [`HolgerEguiBuildThing`] is the ONE
//! declaration of what this repository can be built into;
//! `.nornir/build-variants.toml` is EMITTED from it
//! (`cargo run -p holger-egui-build-thing -- emit`) and never read back as
//! authority. `tests/toml_parity.rs` holds the committed file to the emission.
//!
//! ## Where this came from
//!
//! Until 2026-09-16 these four rungs were `ui` / `ui-embedded` / `ui-mini` /
//! `ui-mikro` in holger's own recipe, and the rules that judge them lived in
//! holger too. `server/ui` became this repository, so the rungs and the rules came
//! with it: holger keeps no rule naming a variant it no longer declares, because
//! such a rule fails OPEN — `links_no_http_client` answers `false` and every
//! artifact sails through un-judged.
//!
//! ## ⚠ ONE DECLARED FACT CHANGED IN THE MOVE, and it is Rickard's to reverse
//!
//! holger declared `ui-mini` in BOTH deliveries (`suites = ["ORIGINAL", "MVP"]`).
//! Here it is **MVP-only**. The reason is mechanical, not editorial:
//! `BuildVariantSet::check_defaults` requires exactly one `default = true` per
//! `(suite, role)` group, and `is_default` is a single bool on the row — it cannot
//! say "the normal case of MVP but not of ORIGINAL". With `ui-mini` in both
//! groups, either it is the default of both (making the six-read-tab rung
//! ORIGINAL's normal case, which `ui` is) or MVP has no normal case at all.
//! Tagging it MVP-only keeps both groups honest: ORIGINAL's rungs are `ui` ⭐,
//! `ui-embedded` and `ui-mikro`; MVP's single rung is `ui-mini` ⭐.
//!
//! Vocabulary: variant → features → settings/sub-settings → typ.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::Result;
use nornir_build_thing::emit;
use nornir_build_thing::plugin::{
ArtifactFacts, BuildPlan, BuildRequest, BuildThing, Manifest, RebuildRecipe, Role, Verdict,
ABI_VERSION,
};
use nornir_build_thing::variant::{
ArtifactKind, BuildVariant, BuildVariantSet, Profile, ServiceShape,
};
/// The rules the declaration cannot carry — the thin-client and no-egress
/// invariants, moved here from holger with the rungs they judge.
pub mod rules;
/// The repo / suite member this declares.
pub const REPO: &str = "holger-egui";
/// This crate's name, recorded in the manifest so a reader knows where the source is.
pub const SOURCE_CRATE: &str = "holger-egui-build-thing";
/// The egui client crate every rung builds — this repository's own package.
pub const UI_PACKAGE: &str = "holger-server-ui";
/// The full delivery.
const ORIGINAL: &str = "ORIGINAL";
/// The Secure Update Gateway delivery, whose ui atom this repo ships.
const MVP: &str = "MVP";
/// holger-egui's build thing.
#[derive(Debug, Clone, Copy, Default)]
pub struct HolgerEguiBuildThing;
impl BuildThing for HolgerEguiBuildThing {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: REPO.to_string(),
source_crate: SOURCE_CRATE.to_string(),
summary: "the egui operator client for holger — four rungs from one source \
tree (full · embedded · mini · mikro), every one THIN except the \
embedded exception asked for by name"
.to_string(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet {
base_suite: Some(ORIGINAL.to_string()),
offers: offers(),
required: required(),
variants: rungs(),
..BuildVariantSet::default()
}
}
// The rules that are NOT data, delegated to [`rules`]. They must be answered by
// THIS type, because this type is what the wasm shim exports: the trait's own
// defaults would tell a forge that this repo "declares no artifact-level
// invariant", which is an OK verdict for a thin client carrying the storage
// engine.
fn plan(&self, variant: &str) -> Option<BuildPlan> {
rules::plan(variant)
}
fn validate(&self, req: &BuildRequest) -> Verdict {
rules::validate(req)
}
fn judge_artifact(&self, facts: &ArtifactFacts) -> Verdict {
rules::judge_artifact(facts)
}
fn rebuild_recipe(&self, variant: &str) -> Option<RebuildRecipe> {
rules::rebuild_recipe(variant)
}
}
/// One rung, spelled with only the fields this declaration uses.
struct Rung {
name: &'static str,
suites: &'static [&'static str],
is_default: bool,
bin: &'static str,
linkage: ServiceShape,
features: &'static [&'static str],
}
/// **The four rungs, in ladder order.** One source tree, four binaries, each
/// selecting exactly the panes it can drive — `cargo build --all-targets` SILENTLY
/// SKIPS a `required-features` target, so a rung that is not declared is a rung
/// nothing ever builds. That is how the `gui` bin went unbuilt for weeks.
const RUNGS: &[Rung] = &[
// ui ⭐ — the client a human runs. THIN: every pane works over gRPC against a
// RUNNING holger (guarded by tests/thin_client.rs).
Rung {
name: "ui",
suites: &[ORIGINAL],
is_default: true,
bin: "holger-server-ui",
linkage: ServiceShape::ThinStandalone,
features: &["gui"],
},
// ui-embedded — the ONE rung allowed to embed (demo + warehouse-scan + bench),
// asked for by name. FAT, and truthfully so: it is the only artifact here that
// compiles skade in. (Schema gap, recorded: `embeds` is not permitted on an
// `exe`, so the edge is stated in the linkage and not as a composition.)
Rung {
name: "ui-embedded",
suites: &[ORIGINAL],
is_default: false,
bin: "holger-server-ui-embedded",
linkage: ServiceShape::FatService,
features: &["gui-embedded"],
},
// ui-mini ⭐ — MVP's ui atom: six read tabs, no outbound HTTP client linked.
// MVP-only here; see the module doc for why a dual-suite row cannot carry a
// per-delivery default.
Rung {
name: "ui-mini",
suites: &[MVP],
is_default: true,
bin: "holger-server-ui-mini",
linkage: ServiceShape::ThinStandalone,
features: &["gui-mini"],
},
// ui-mikro — the leanest rung: three tabs, no about-card. Kept DECLARED but out
// of MVP: one `suites` edit away the day super-small matters (Rickard,
// 2026-08-01: "mikro means SUPER mega small, and that is not a requirement").
Rung {
name: "ui-mikro",
suites: &[ORIGINAL],
is_default: false,
bin: "holger-server-ui-mikro",
linkage: ServiceShape::ThinStandalone,
features: &["gui-mikro"],
},
];
impl Rung {
fn to_variant(&self) -> BuildVariant {
BuildVariant {
name: self.name.to_string(),
package: Some(UI_PACKAGE.to_string()),
bin: Some(self.bin.to_string()),
features: self.features.iter().map(|f| (*f).to_string()).collect(),
no_default_features: true,
profile: Profile::Release,
shape: self.linkage.clone(),
role: Some(Role::Client),
suites: self.suites.iter().map(|s| (*s).to_string()).collect(),
is_default: self.is_default,
kind: ArtifactKind::Exe,
// Every rung needs a holger RUNNING; none embeds a nordisk sibling.
// `ui-embedded` embeds holger's own core, which is not a sibling edge.
reaches: ["holger".to_string()].into_iter().collect(),
..BuildVariant::default()
}
}
}
fn rungs() -> Vec<BuildVariant> {
RUNGS.iter().map(Rung::to_variant).collect()
}
/// `[build] offers` — this repository's real feature universe, read off its own
/// manifest: the four TIER features, the building block, the removable panes and
/// the two surface markers, plus the three a build turns on but no rung names.
fn offers() -> BTreeSet<String> {
[
// the four TIER features — a [[bin]] selects exactly one
"gui", "gui-embedded", "gui-mini", "gui-mikro",
// the building block every tier links
"gui-core",
// the removable PANES, each owning the leaf deps only it needs
"about-card", "signin", "helix", "tools", "bench", "warehouse-scan", "demo",
// the surface markers the tier features imply
"mini", "mikro",
// declared, and named by no rung: the container pane, the testmatrix
// emitter and the robot-UI remote mount are turned on by a harness, not by
// a shipped artifact.
"containers", "testmatrix", "robot-remote",
]
.into_iter()
.map(str::to_string)
.collect()
}
/// `[build.required]` — the manifest's `required-features`, mirrored so a bad
/// rung/feature combo fails with a sentence instead of a raw cargo error.
fn required() -> BTreeMap<String, Vec<String>> {
RUNGS
.iter()
.map(|r| {
(
r.bin.to_string(),
r.features.iter().map(|f| (*f).to_string()).collect(),
)
})
.collect()
}
/// The header every emission starts with — what the file is, and what it is not.
pub const EMITTED_HEADER: &str = "\
# holger-egui — .nornir/build-variants.toml — EMITTED, NOT SOURCE.
#
# Generated by `cargo run -p holger-egui-build-thing -- emit` from
# holger-egui-build-thing (src/lib.rs for the rungs and the feature universe,
# src/rules.rs for the thin-client and no-egress rules). The code is the one
# source; this file DESCRIBES the build for readers and for the tools that
# discover a repo by this path. Editing it changes NOTHING: re-emit after
# changing the code. Do not edit by hand.
#
# Model: variant -> features -> settings/sub-settings -> typ. Four rungs from one
# source tree; `ui` is ORIGINAL's normal case and `ui-mini` is MVP's ui atom.
# The reasoning lives beside the rows in the Rust, never here: a comment written
# in this file is deleted by the next `emit`.";
/// The set this crate declares — the one source.
#[must_use]
pub fn declared() -> BuildVariantSet {
HolgerEguiBuildThing.variants()
}
/// The emission — what `.nornir/build-variants.toml` must contain.
pub fn emitted_toml() -> Result<String> {
emit::emitted_toml(EMITTED_HEADER, &HolgerEguiBuildThing)
}
/// Regenerate `<repo_root>/.nornir/build-variants.toml` from the code.
pub fn write_emitted(repo_root: &Path) -> Result<PathBuf> {
emit::write_emitted(EMITTED_HEADER, &HolgerEguiBuildThing, repo_root)
}
/// This repo's root, from the crate's manifest directory.
#[must_use]
pub fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("holger-egui-build-thing sits one level under the repo root")
.to_path_buf()
}
// The wasm32 plugin ABI over `HolgerEguiBuildThing` — one type, one definition, so
// the module the forge loads and the native doors cannot answer differently.
nornir_build_thing::export_build_thing!(HolgerEguiBuildThing);
// The trait, re-exported so a caller (this crate's own bin included) needs one
// dependency to ask the recipe a question and act on the answer. Aliased because
// `BuildThing` is already in this module's scope from the `use` above, and
// `BuildVariantSet` is not re-exported for the same reason.
pub use nornir_build_thing::plugin::BuildThing as BuildThingTrait;
#[cfg(test)]
mod tests {
use super::*;
/// The ladder is declared, and each rung names the feature its `[[bin]]`
/// requires. Moved from holger's rules.rs (2026-09-16).
#[test]
fn the_ui_tiers_mirror_their_required_features() {
let set = declared();
let ladder = [
("ui", "holger-server-ui", "gui"),
("ui-embedded", "holger-server-ui-embedded", "gui-embedded"),
("ui-mini", "holger-server-ui-mini", "gui-mini"),
("ui-mikro", "holger-server-ui-mikro", "gui-mikro"),
];
for (variant, bin, feature) in ladder {
let v = set.get(variant).unwrap_or_else(|| panic!("no variant '{variant}'"));
assert_eq!(v.package.as_deref(), Some(UI_PACKAGE), "{variant}");
assert_eq!(v.bin.as_deref(), Some(bin), "{variant}");
assert_eq!(v.features, vec![feature.to_string()], "{variant}");
// The mirror of the manifest's `required-features`.
assert_eq!(
set.required.get(bin).map(|v| v.as_slice()),
Some([feature.to_string()].as_slice()),
"{bin}"
);
assert!(set.offers.contains(feature), "'{feature}' must be offered");
assert!(set.validate(v).is_ok(), "{variant}: {:?}", set.validate(v).err());
}
assert_eq!(rules::ui_tier_features(), ["gui", "gui-embedded", "gui-mini", "gui-mikro"]);
}
/// Exactly one normal case per delivery — the constraint that decided the
/// MVP-only tagging of `ui-mini` (see the module doc).
#[test]
fn each_delivery_names_exactly_one_normal_case() {
let set = declared();
assert!(set.check_defaults().is_empty(), "{:?}", set.check_defaults());
assert_eq!(set.names(), vec!["ui", "ui-embedded", "ui-mini", "ui-mikro"]);
let mvp: Vec<&str> = set
.variants
.iter()
.filter(|v| v.suites.iter().any(|s| s == "MVP"))
.map(|v| v.name.as_str())
.collect();
assert_eq!(mvp, ["ui-mini"], "MVP's ui atom, and only it, carries the MVP tag");
}
#[test]
fn the_build_thing_obeys_the_bijection_laws() {
nornir_build_thing::laws::assert_bijective(&HolgerEguiBuildThing);
}
}