crates/nornir-build-thing/src/elm.rs
//! The **build-thing UI component**, as the facett Elm contract's pure core:
//! all state in one serializable [`BuildThingModel`], every input a
//! [`BuildThingMsg`], mutation only through [`BuildThing::update`], side work as
//! data ([`BuildThingEffect`]). For holger's driving example the model is three
//! rows/cards — `embedded`, `minimal`, `full` — each showing its materialized
//! command, validity, live build status, and (once built) a provenance summary
//! (digest, artifact count, sealed?).
//!
//! # Why the egui `view` lives elsewhere
//!
//! The full facett contract adds `fn view(&self, ui: &mut egui::Ui) -> Vec<Msg>`
//! plus `facett_core::impl_facet_via_elm!(BuildThingView)`. That pulls egui, so
//! it CANNOT live in this deliberately dep-light crate. The egui view + the
//! `impl_facet_via_elm!` bridge are a thin **facett-side follow-up** (a
//! `facett-buildview` component) that renders this exact `BuildThingModel` and
//! emits these exact `BuildThingMsg`s — the pure core here is what it drives and
//! what the headless harness tests. Keeping the model/msg/update here means the
//! logic is tested without a GPU, exactly as `facett_core::harness` intends.
use serde::{Deserialize, Serialize};
use crate::surface::BuildSurface;
/// Live build status of one variant card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CardStatus {
/// Not built this session.
Idle,
/// A build was requested and is running.
Building,
/// Built successfully; carries the provenance summary.
Built {
/// The attestation digest (hex-16 prefix, enough to show).
digest: String,
/// How many artifacts the build produced.
artifacts: usize,
/// Whether the attestation was znippy-sealed.
sealed: bool,
},
/// The build failed.
Failed {
/// Human reason.
reason: String,
},
}
/// One variant row/card in the build-thing UI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VariantCard {
/// Variant handle (`embedded` / `minimal` / `full`).
pub name: String,
/// The materialized cargo command line (shown under the title).
pub command_line: String,
/// Whether the variant is valid to build.
pub valid: bool,
/// Why it's invalid (disables the Build button + shows the reason).
pub invalid_reason: Option<String>,
/// Live status.
pub status: CardStatus,
}
/// All observable state of the build-thing component (facett FC-1/FC-3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildThingModel {
/// The repo whose build space this is.
pub repo: String,
/// One card per declared variant (the three holger exes).
pub cards: Vec<VariantCard>,
/// The currently selected variant, if any.
pub selected: Option<String>,
/// A short activity log (newest last).
pub log: Vec<String>,
}
/// Every input the component accepts (facett FC-2) — the only way to mutate the
/// model is [`BuildThing::update`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildThingMsg {
/// Select a variant card.
Select(String),
/// Press the "Build" button for a variant.
Build(String),
/// The host reports a build finished successfully (fed back after running
/// the [`BuildThingEffect::RunBuild`] effect).
BuildSucceeded {
variant: String,
/// Attestation digest (hex; the card shows a short prefix).
digest: String,
artifacts: usize,
sealed: bool,
},
/// The host reports a build failed.
BuildFailed { variant: String, reason: String },
}
/// Side work as data (facett FC-8): the host runs the actual `cargo build`
/// (via [`crate::build_variant`]) and feeds the result back as a [`BuildThingMsg`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildThingEffect {
/// Run a build for this variant, then send `BuildSucceeded`/`BuildFailed`.
RunBuild(String),
}
/// The build-thing component: the Elm pure core (state + update).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildThing {
model: BuildThingModel,
}
impl BuildThing {
/// Build the component from a [`BuildSurface`] — one card per DECLARED
/// variant, whatever its kind (the three holger exes for the driving example,
/// and holger's `[[build.installer]]` too).
///
/// Reads [`BuildSurface::declared`], not `variants`: a card model built from the
/// cargo rows alone silently drops [`BuildThingMsg::Select`] for a container /
/// iso / installer name, so the picker could offer a row the model refused to
/// select.
pub fn from_surface(surface: &BuildSurface) -> Self {
let cards = surface
.declared
.iter()
.map(|v| VariantCard {
name: v.name.clone(),
command_line: v.command_line.clone(),
valid: v.valid,
invalid_reason: v.invalid_reason.clone(),
status: CardStatus::Idle,
})
.collect();
BuildThing {
model: BuildThingModel { repo: surface.repo.clone(), cards, selected: None, log: Vec::new() },
}
}
/// The component title (facett `Elm::title`).
pub fn title(&self) -> &str {
"Build"
}
/// Read the complete observable state (facett FC-3 `Elm::state`).
pub fn state(&self) -> &BuildThingModel {
&self.model
}
/// The `state_json` a viz deck / matrix reads.
pub fn state_json(&self) -> serde_json::Value {
serde_json::to_value(&self.model).unwrap_or(serde_json::Value::Null)
}
fn card_mut(&mut self, name: &str) -> Option<&mut VariantCard> {
self.model.cards.iter_mut().find(|c| c.name == name)
}
/// The single mutation path (facett FC-2/FC-8). Apply one message; return the
/// effects the host should run.
pub fn update(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
match msg {
BuildThingMsg::Select(name) => {
if self.model.cards.iter().any(|c| c.name == name) {
self.model.selected = Some(name);
}
Vec::new()
}
BuildThingMsg::Build(name) => {
// Guard: never emit a build effect for an invalid variant.
let valid = self.card_mut(&name).map(|c| c.valid).unwrap_or(false);
if !valid {
self.model.log.push(format!("refused: '{name}' is not valid to build"));
return Vec::new();
}
if let Some(c) = self.card_mut(&name) {
c.status = CardStatus::Building;
}
self.model.selected = Some(name.clone());
self.model.log.push(format!("building '{name}'…"));
vec![BuildThingEffect::RunBuild(name)]
}
BuildThingMsg::BuildSucceeded { variant, digest, artifacts, sealed } => {
let short = digest.chars().take(16).collect::<String>();
if let Some(c) = self.card_mut(&variant) {
c.status = CardStatus::Built { digest: short.clone(), artifacts, sealed };
}
self.model.log.push(format!(
"built '{variant}' — {artifacts} artifact(s), digest {short}…{}",
if sealed { ", znippy-sealed" } else { "" }
));
Vec::new()
}
BuildThingMsg::BuildFailed { variant, reason } => {
if let Some(c) = self.card_mut(&variant) {
c.status = CardStatus::Failed { reason: reason.clone() };
}
self.model.log.push(format!("build '{variant}' FAILED: {reason}"));
Vec::new()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::variant::BuildVariantSet;
const HOLGER: &str = r#"
[build]
offers = ["embedded", "minimal", "full", "served", "gui"]
exclusive = [["embedded", "minimal", "full"]]
[build.required]
"holger-full" = ["full"]
[[build.variant]]
name = "embedded"
package = "holger-embedded"
bin = "holger-embedded"
features = ["embedded"]
no_default_features = true
profile = "release"
[[build.variant]]
name = "minimal"
package = "holger-minimal"
bin = "holger-minimal"
features = ["minimal"]
no_default_features = true
profile = "release"
[[build.variant]]
name = "full"
package = "holger-full"
bin = "holger-full"
features = ["full"]
profile = "release"
"#;
fn component() -> BuildThing {
let set = BuildVariantSet::from_toml_str(HOLGER).unwrap();
BuildThing::from_surface(&BuildSurface::from_set("holger", &set))
}
#[test]
fn three_holger_exes_become_three_cards() {
let c = component();
assert_eq!(c.state().cards.len(), 3);
let names: Vec<&str> = c.state().cards.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["embedded", "minimal", "full"]);
assert!(c.state().cards.iter().all(|c| c.valid));
assert!(c.state().cards.iter().all(|c| c.status == CardStatus::Idle));
}
#[test]
fn build_emits_a_run_effect_and_marks_building() {
let mut c = component();
let effects = c.update(BuildThingMsg::Build("full".to_string()));
assert_eq!(effects, vec![BuildThingEffect::RunBuild("full".to_string())]);
let full = c.state().cards.iter().find(|c| c.name == "full").unwrap();
assert_eq!(full.status, CardStatus::Building);
assert_eq!(c.state().selected.as_deref(), Some("full"));
}
#[test]
fn success_feeds_back_a_provenance_summary() {
let mut c = component();
c.update(BuildThingMsg::Build("full".to_string()));
c.update(BuildThingMsg::BuildSucceeded {
variant: "full".to_string(),
digest: "abcdef0123456789ffff".to_string(),
artifacts: 1,
sealed: true,
});
let full = c.state().cards.iter().find(|c| c.name == "full").unwrap();
assert_eq!(
full.status,
CardStatus::Built { digest: "abcdef0123456789".to_string(), artifacts: 1, sealed: true }
);
}
#[test]
fn invalid_variant_refuses_to_build() {
let mut c = component();
// Force one card invalid, then press Build → no effect, status unchanged.
c.model.cards[2].valid = false;
c.model.cards[2].invalid_reason = Some("requires feature 'full'".to_string());
let effects = c.update(BuildThingMsg::Build("full".to_string()));
assert!(effects.is_empty(), "no build effect for an invalid variant");
assert_eq!(c.state().cards[2].status, CardStatus::Idle);
assert!(c.state().log.iter().any(|l| l.contains("refused")));
}
#[test]
fn model_state_json_round_trips() {
let mut c = component();
c.update(BuildThingMsg::Select("minimal".to_string()));
let json = c.state_json();
let back: BuildThingModel = serde_json::from_value(json).unwrap();
assert_eq!(&back, c.state());
assert_eq!(back.selected.as_deref(), Some("minimal"));
}
}