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