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

nordisk/facett

facett-buildthing/src/lib.rs

//! **facett-buildthing** — the egui **build-thing** facet (design §f7).
//!
//! ONE facett-side [`BuildThing`] with TWO methods:
//!
//! * [`BuildThing::assemble`] — delegates to the dep-light leaf
//!   [`nornir_build_thing::assemble`] (f7): read the chosen variant, dispatch on its
//!   [`ArtifactKind`](nornir_build_thing::ArtifactKind), drive the matching builder,
//!   return ONE content-addressed [`Assembled`](nornir_build_thing::Assembled)
//!   receipt (output kind + attestation + build-info lineage).
//! * [`BuildThing::present_egui`] — draw the option widgets **from the DECLARATION
//!   DATA** (the [`BuildVariantSet`](nornir_build_thing::BuildVariantSet) value +
//!   its [`validate()`](nornir_build_thing::BuildVariantSet::validate) contract, NOT
//!   struct reflection): an `ArtifactKind` combo, a fat/thin shape toggle, a
//!   `Profile` dropdown, a target field, a features **checkbox grid that greys out
//!   illegal combos** (offers / exclusive / required, decided by `validate()`), a
//!   live valid/invalid banner, and a **Build** button that calls `assemble()` and
//!   shows the returned receipt (output kind + lineage rows).
//!
//! ## Dep-light rule
//! `nornir-build-thing` stays **egui-free**: `present_egui` (the egui view) lives
//! ONLY in this crate, which alone carries the egui edge. The facet
//! ([`BuildThingFacet`]) is a thin [`Facet`](facett_core::Facet) shell that just
//! calls `present_egui()` + `state_json()`.
//!
//! ## Reuse-first
//! This crate invents no build logic. It **wraps** the leaf's `BuildVariantSet` +
//! [`BuildSurface`](nornir_build_thing::BuildSurface) (the schema it walks) + the
//! existing [`nornir_build_thing::BuildThing`] Elm card model (its Model/Msg/update
//! drive the per-variant cards) + the `assemble()` seam. `present_egui` reads the
//! surface projection and `validate()`; it never reflects over Rust structs.

pub mod deck;
pub mod galois_pane;
pub mod graph_pane;
pub use deck::{deck_for, demo, demo_menu, empty_deck, BuildResult, BuildThingDeck, Msg, PANE_COUNT, PANE_GRAPH, PANE_RESULT, PANE_VARIANTS};
pub use galois_pane::{Action, CrateClass, FeatureAxis, GaloisPane};
pub use graph_pane::{to_graph_model, BuildGraphPane};

use egui::{Color32, RichText, Sense, Ui};
use serde::Serialize;

use nornir_build_thing::{
    assemble as leaf_assemble, Assemble, AssembleCtx, Assembled, ArtifactKind, BuildSurface,
    BuildThing as CardModel, BuildThingMsg as CardMsg, BuildVariant, BuildVariantSet, Builders,
    InMemBuildInfoSink, Profile, ServiceShape,
};
use std::path::PathBuf;

/// The full set of [`ArtifactKind`]s the kind combo offers (the declaration's kind
/// axis, enumerated once — the same discriminant `assemble` dispatches on).
pub const KINDS: [ArtifactKind; 6] = [
    ArtifactKind::Exe,
    ArtifactKind::Lib,
    ArtifactKind::Wasm,
    ArtifactKind::Service,
    ArtifactKind::Container,
    ArtifactKind::Vm,
];

/// Every [`ServiceShape`] the fat/thin toggle offers — including `not-applicable`.
///
/// It belongs on the toggle for the same reason it exists at all: a `lib` or a wasm
/// demo has no meaningful linkage, and if the control cannot express that, the only way
/// to say it is to leave the axis alone — which deserializes as the `fat` DEFAULT, i.e.
/// a positive claim of self-containment nobody made. An option a user cannot pick is an
/// option that gets encoded as silence.
pub const SHAPES: [ServiceShape; 4] = [
    ServiceShape::FatService,
    ServiceShape::ThinStandalone,
    ServiceShape::Mixed,
    ServiceShape::NotApplicable,
];

fn shape_label(s: ServiceShape) -> &'static str {
    match s {
        ServiceShape::FatService => "fat",
        ServiceShape::ThinStandalone => "thin",
        ServiceShape::Mixed => "mixed",
        // Spelled out, not "—": a dash beside three words reads as "unset", which is
        // the exact reading this value exists to make impossible.
        ServiceShape::NotApplicable => "n/a (no linkage)",
    }
}

const OK_GREEN: Color32 = Color32::from_rgb(120, 210, 150);
const ERR_RED: Color32 = facett_core::look::RED;
const DIM: Color32 = Color32::from_rgb(150, 150, 150);

/// The facett-side **build-thing**: the declaration + the live-edited candidate +
/// the reused card model + the `assemble()` seam. Two methods —
/// [`assemble`](Self::assemble) and [`present_egui`](Self::present_egui) — are the
/// whole surface; everything else is state the facet shell reads via
/// [`state_json`](Self::state_json).
pub struct BuildThing {
    repo: String,
    /// The build-context root (where a real `assemble` runs cargo). Defaults to `.`.
    repo_root: PathBuf,
    /// Where a produced binary/service lands (`output_of`'s exec dir).
    exec_dir: PathBuf,
    /// The declared build space — the schema every widget reads (offers / exclusive
    /// / required / variants), the SAME data `validate()` enforces.
    set: BuildVariantSet,
    /// The flat projection a UI walks without knowing cargo (the schema).
    surface: BuildSurface,
    /// The REUSED leaf Elm card model (one card per declared variant; Select /
    /// Build / BuildSucceeded / BuildFailed). Not twinned — driven verbatim.
    cards: CardModel,
    /// The live-edited candidate build point the option widgets mutate; a clone of
    /// the selected declared variant with the chosen axes applied. `validate()` runs
    /// against THIS every frame for the banner + the grey-out decisions.
    editing: BuildVariant,
    /// Scratch buffer for the target text field (synced to `editing.target`).
    target_edit: String,
    /// The injected builder seam `assemble()` drives (cargo / container / VM). The
    /// production default is [`Builders::new`] (cargo-only); a test injects a fake.
    builders: Box<dyn Assemble>,
    /// The last receipt `assemble()` produced (output kind + attestation + lineage).
    last: Option<Assembled>,
    /// The last assemble error, if any.
    error: Option<String>,
}

impl BuildThing {
    /// Build the thing from a repo name + its declared [`BuildVariantSet`]. The
    /// candidate starts as the first declared variant (or an empty exe).
    pub fn from_set(repo: impl Into<String>, set: BuildVariantSet) -> Self {
        let repo = repo.into();
        let surface = BuildSurface::from_set(repo.clone(), &set);
        let cards = CardModel::from_surface(&surface);
        let editing = set.variants.first().cloned().unwrap_or_default();
        let target_edit = editing.target.clone().unwrap_or_default();
        BuildThing {
            repo,
            repo_root: PathBuf::from("."),
            exec_dir: PathBuf::from("/usr/local/bin"),
            set,
            surface,
            cards,
            editing,
            target_edit,
            builders: Box::new(Builders::new()),
            last: None,
            error: None,
        }
    }

    /// Set the build-context root a real `assemble()` runs cargo in.
    pub fn with_repo_root(mut self, root: impl Into<PathBuf>) -> Self {
        self.repo_root = root.into();
        self
    }

    /// Inject the builder seam (a fake in tests; `Builders::with_image/with_vm` in
    /// production for container/VM kinds).
    pub fn with_builders(mut self, builders: Box<dyn Assemble>) -> Self {
        self.builders = builders;
        self
    }

    /// The pane title (the deck / discovery key).
    pub fn title(&self) -> &str {
        "Build"
    }

    /// The declared variant names, in declaration order.
    pub fn variant_names(&self) -> Vec<String> {
        self.surface.variant_names().iter().map(|s| s.to_string()).collect()
    }

    /// The candidate build point currently being edited.
    pub fn candidate(&self) -> &BuildVariant {
        &self.editing
    }

    /// The last receipt, if a build has run.
    pub fn last(&self) -> Option<&Assembled> {
        self.last.as_ref()
    }

    /// Select a declared variant — reset the candidate to its clone and drive the
    /// reused card model's `Select`.
    pub fn select(&mut self, name: &str) {
        if let Some(v) = self.set.get(name) {
            self.editing = v.clone();
            self.target_edit = self.editing.target.clone().unwrap_or_default();
        }
        let _ = self.cards.update(CardMsg::Select(name.to_string()));
    }

    /// Set the candidate's artifact kind (the combo choice).
    pub fn set_kind(&mut self, kind: ArtifactKind) {
        self.editing.kind = kind;
    }

    /// Set the candidate's fat/thin shape (the toggle choice).
    pub fn set_shape(&mut self, shape: ServiceShape) {
        self.editing.shape = shape;
    }

    /// Set the candidate's profile (the dropdown choice).
    pub fn set_profile(&mut self, profile: Profile) {
        self.editing.profile = profile;
    }

    /// Set the candidate's cross-compile target (the text field).
    pub fn set_target(&mut self, target: Option<String>) {
        self.target_edit = target.clone().unwrap_or_default();
        self.editing.target = target.filter(|t| !t.trim().is_empty());
    }

    /// Toggle a feature on the candidate (the checkbox). A no-op-safe flip.
    pub fn toggle_feature(&mut self, feature: &str) {
        if let Some(i) = self.editing.features.iter().position(|f| f == feature) {
            self.editing.features.remove(i);
        } else {
            self.editing.features.push(feature.to_string());
        }
    }

    /// The candidate's live validity (the declaration contract — `validate()`), as a
    /// stringly reason when invalid.
    pub fn validity(&self) -> Result<(), String> {
        self.set.validate(&self.editing).map_err(|e| e.to_string())
    }

    /// The offered features that are **illegal to add right now** — decided by
    /// `validate()`, NOT struct reflection: tentatively add each not-yet-enabled
    /// feature and keep the ones that make the candidate invalid (an exclusive /
    /// required / offers conflict). These are the greyed-out checkboxes.
    pub fn greyed_features(&self) -> Vec<String> {
        self.set
            .offers
            .iter()
            .filter(|f| {
                if self.editing.features.iter().any(|e| e == *f) {
                    return false; // already on — always legal to toggle off
                }
                let mut cand = self.editing.clone();
                cand.features.push((*f).clone());
                self.set.validate(&cand).is_err()
            })
            .cloned()
            .collect()
    }

    /// **f7 seam.** Delegate to the dep-light leaf [`nornir_build_thing::assemble`]:
    /// build the candidate through the injected builder, return the
    /// [`Assembled`] receipt, and feed the reused card model the outcome
    /// (`BuildSucceeded` / `BuildFailed`). The lineage + output kind are stashed for
    /// [`present_egui`] + [`state_json`].
    pub fn assemble(&mut self) -> anyhow::Result<Assembled> {
        let variant = self.editing.clone();
        let sink = InMemBuildInfoSink::new();
        let exec = self.exec_dir.clone();
        let ctx = AssembleCtx::new(&self.repo_root, &exec, &sink, self.builders.as_ref());
        match leaf_assemble(&self.set, &variant, &ctx) {
            Ok(assembled) => {
                let _ = self.cards.update(CardMsg::BuildSucceeded {
                    variant: variant.name.clone(),
                    digest: assembled.digest_hex(),
                    artifacts: assembled.artifact_content_ids().len(),
                    sealed: assembled.attestation.is_sealed(),
                });
                self.error = None;
                self.last = Some(assembled.clone());
                Ok(assembled)
            }
            Err(e) => {
                let reason = format!("{e:#}");
                let _ = self
                    .cards
                    .update(CardMsg::BuildFailed { variant: variant.name.clone(), reason: reason.clone() });
                self.error = Some(reason);
                Err(e)
            }
        }
    }

    /// **The egui view** — draw the option widgets FROM THE DECLARATION DATA and,
    /// when Build is pressed, run [`assemble`](Self::assemble). Mutates the candidate
    /// through the same setter methods the tests drive, so the UI and the robot share
    /// one mutation surface.
    pub fn present_egui(&mut self, ui: &mut Ui) {
        // Read-only decisions computed once this frame (owned locals so no `self`
        // borrow lingers into the widget closures below).
        let offers: Vec<String> = self.set.offers.iter().cloned().collect();
        let required_here: Vec<String> = self
            .editing
            .bin
            .as_deref()
            .and_then(|b| self.set.required.get(b))
            .cloned()
            .unwrap_or_default();
        let greyed = self.greyed_features();
        let validity = self.validity();
        let cmd = self.editing.command_line();
        let names = self.variant_names();

        // Actions deferred out of the widget closures (they need `&mut self` wholesale).
        let mut pick: Option<String> = None;
        let mut do_build = false;

        ui.heading(format!("🔨 build — {}", self.repo));
        ui.label(RichText::new("options drawn from the declared build space (validate()-gated)").color(DIM));
        ui.separator();

        // ── variant picker (drives the reused card model's Select) ──
        egui::ComboBox::from_label("variant")
            .selected_text(if self.editing.name.is_empty() { "—" } else { self.editing.name.as_str() })
            .show_ui(ui, |ui| {
                for n in &names {
                    if ui.selectable_label(&self.editing.name == n, n).clicked() {
                        pick = Some(n.clone());
                    }
                }
            });

        // ── ArtifactKind → ComboBox ──
        egui::ComboBox::from_label("kind")
            .selected_text(self.editing.kind.as_str())
            .show_ui(ui, |ui| {
                for k in KINDS {
                    ui.selectable_value(&mut self.editing.kind, k, k.as_str());
                }
            });

        // ── shape {fat/thin/mixed} → toggle ──
        ui.horizontal(|ui| {
            ui.label("shape:");
            for s in SHAPES {
                ui.selectable_value(&mut self.editing.shape, s, shape_label(s));
            }
        });

        // ── Profile → dropdown ──
        egui::ComboBox::from_label("profile")
            .selected_text(String::from(self.editing.profile.clone()))
            .show_ui(ui, |ui| {
                ui.selectable_value(&mut self.editing.profile, Profile::Dev, "dev");
                ui.selectable_value(&mut self.editing.profile, Profile::Release, "release");
            });

        // ── target → text field ──
        ui.horizontal(|ui| {
            ui.label("target:");
            if ui.text_edit_singleline(&mut self.target_edit).changed() {
                self.editing.target =
                    Some(self.target_edit.clone()).filter(|t| !t.trim().is_empty());
            }
        });

        // ── features → checkbox grid; illegal combos greyed via validate() ──
        ui.label("features:");
        for f in &offers {
            let enabled = self.editing.features.iter().any(|e| e == f);
            let blocked = !enabled && greyed.contains(f);
            let required = required_here.iter().any(|r| r == f);
            let mut on = enabled;
            let label = if required {
                RichText::new(format!("{f} (required)")).color(OK_GREEN)
            } else if blocked {
                RichText::new(f).color(DIM)
            } else {
                RichText::new(f)
            };
            let resp = ui.add_enabled(enabled || !blocked, egui::Checkbox::new(&mut on, label));
            if blocked {
                resp.on_hover_text("illegal here — an exclusive/required conflict (validate())");
            }
            if on != enabled {
                self.toggle_feature(f);
            }
        }

        ui.separator();

        // ── live valid / invalid banner ──
        match &validity {
            Ok(()) => {
                ui.label(RichText::new(format!("✓ valid — {cmd}")).color(OK_GREEN).monospace());
            }
            Err(reason) => {
                ui.label(RichText::new(format!("✗ invalid — {reason}")).color(ERR_RED));
            }
        }

        // ── Build button → assemble() ──
        let valid = validity.is_ok();
        if ui.add_enabled(valid, egui::Button::new("🔨 Build")).clicked() {
            do_build = true;
        }
        if let Some(err) = &self.error {
            ui.label(RichText::new(format!("build error: {err}")).color(ERR_RED));
        }

        // ── the returned Assembled receipt: output kind + build-info lineage ──
        if let Some(a) = &self.last {
            ui.separator();
            let digest = a.digest_hex();
            let short: String = digest.chars().take(16).collect();
            ui.label(
                RichText::new(format!(
                    "Assembled: {} · digest {short}… · {} artifact(s){}",
                    a.kind().as_str(),
                    a.artifact_content_ids().len(),
                    if a.attestation.is_sealed() { " · sealed" } else { "" },
                ))
                .color(OK_GREEN),
            );
            egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
                for row in &a.lineage {
                    let cid: String = row.content_id.chars().take(12).collect();
                    ui.label(
                        RichText::new(format!(
                            "{:>10} {:<8} {}={} {}",
                            row.kind, row.level, row.name, row.value, cid
                        ))
                        .monospace()
                        .size(11.0),
                    );
                }
            });
        }

        // Apply deferred whole-`self` actions.
        if let Some(n) = pick {
            self.select(&n);
        }
        if do_build {
            let _ = self.assemble();
        }

        // A hover-only a11y anchor so a robot can always find the pane by name.
        let _ = ui.interact(ui.max_rect(), ui.id().with("buildthing-pane"), Sense::hover());

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-buildthing::BuildThing::present_egui",
            "ui_render",
            !self.set.variants.is_empty(),
            &format!(
                "variants={} valid={} assembled={}",
                self.set.variants.len(),
                validity.is_ok(),
                self.last.is_some()
            ),
        );
    }

    /// The rich, headless-assertable observable state (FC-3 / LAW 6): the declared
    /// surface, the reused card model, the live candidate + its validity + the
    /// greyed (illegal) features, and the last receipt (output kind + lineage). A
    /// robot asserts the whole flow from this without a GPU.
    pub fn state_json(&self) -> serde_json::Value {
        let validity = self.validity();
        serde_json::json!({
            "component": "facett-buildthing::BuildThing",
            "repo": self.repo,
            "surface": self.surface.state_json(),
            "cards": self.cards.state_json(),
            "candidate": {
                "name": self.editing.name,
                "kind": self.editing.kind.as_str(),
                "shape": shape_label(self.editing.shape),
                "profile": String::from(self.editing.profile.clone()),
                "target": self.editing.target,
                "features": self.editing.features,
                "command_line": self.editing.command_line(),
            },
            "valid": validity.is_ok(),
            "invalid_reason": validity.err(),
            "greyed": self.greyed_features(),
            "last_assembled": self.last.as_ref().map(assembled_summary),
            "error": self.error,
        })
    }
}

/// A serde summary of a receipt — output kind + digest + the build-info lineage
/// rows (each carrying its `content_id`), the shape a viz deck / matrix reads.
#[derive(Debug, Clone, Serialize)]
struct AssembledSummary {
    output_kind: String,
    digest: String,
    sealed: bool,
    artifact_count: usize,
    lineage: Vec<serde_json::Value>,
}

fn assembled_summary(a: &Assembled) -> AssembledSummary {
    AssembledSummary {
        output_kind: a.kind().as_str().to_string(),
        digest: a.digest_hex(),
        sealed: a.attestation.is_sealed(),
        artifact_count: a.artifact_content_ids().len(),
        lineage: a
            .lineage
            .iter()
            .map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null))
            .collect(),
    }
}

/// The thin [`Facet`](facett_core::Facet) shell — it just calls
/// [`BuildThing::present_egui`] + [`BuildThing::state_json`]. This is the
/// facett facet/scene seam; a host deck mounts it like any other facet.
pub struct BuildThingFacet {
    /// The wrapped build-thing.
    pub thing: BuildThing,
}

impl BuildThingFacet {
    /// Wrap a [`BuildThing`] as a facet.
    pub fn new(thing: BuildThing) -> Self {
        BuildThingFacet { thing }
    }

    /// Build the facet directly from a repo name + declared set.
    pub fn from_set(repo: impl Into<String>, set: BuildVariantSet) -> Self {
        BuildThingFacet { thing: BuildThing::from_set(repo, set) }
    }
}

impl facett_core::Facet for BuildThingFacet {
    fn title(&self) -> &str {
        self.thing.title()
    }

    fn ui(&mut self, ui: &mut Ui) {
        self.thing.present_egui(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        self.thing.state_json()
    }

    fn caps(&self) -> facett_core::FacetCaps {
        facett_core::FacetCaps { themeable: true, selectable: true, ..facett_core::FacetCaps::NONE }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    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 = "full"
package = "holger-full"
bin = "holger-full"
features = ["full"]
profile = "release"
"#;

    #[test]
    fn greys_out_the_exclusive_siblings_via_validate() {
        let set = BuildVariantSet::from_toml_str(HOLGER).unwrap();
        let mut t = BuildThing::from_set("holger", set);
        t.select("embedded"); // features = ["embedded"]; exclusive group locks the rest
        let greyed = t.greyed_features();
        assert!(greyed.contains(&"minimal".to_string()), "minimal is exclusive with embedded → greyed: {greyed:?}");
        assert!(greyed.contains(&"full".to_string()), "full is exclusive with embedded → greyed: {greyed:?}");
        assert!(!greyed.contains(&"gui".to_string()), "gui is orthogonal → NOT greyed");
        assert!(t.validity().is_ok(), "embedded alone is a valid point");
    }
}