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

nordisk/edda

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