Gunnar

nordisk/holger-egui

holger-egui-build-thing/src/lib.rs

//! # 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);
    }
}