dwarves-gui/src/build_graph.rs
//! **The build graph** — the ⚒ settings tree AS A GRAPH (owner, 2026-08-26: "byt").
//!
//! ```text
//! node a VARIANT (typ on the badge) · a FEATURE on it · a SETTING or VAL under a feature
//! edge CONTAINMENT (variant ⊃ feature ⊃ setting), one stroke
//! CONSUMPTION (`--build X`: a delivery variant consumes the artifact another
//! builds; an installer's `kärna` consumes a donor's kernel), double stroke,
//! labelled with the flag — the same DAG runa will run
//! ```
//!
//! A feature with children is a SUBGRAPH, folded by default and unfolded on click
//! ([`crate::dag_graph::DagPane::fold`] / `unfold`) — which is what removes the
//! "booting/complex" feel: the cold picture is the variants and what consumes what;
//! a click opens exactly one thing. The kernel kconfig picture is the unfolded subgraph
//! under `kärna` — same renderer, deeper — wearing the honesty label.
//!
//! This module LOWERS: the [`Menu`] (variants × their delivery columns) and each
//! variant's edda [`SettingsTree`] become one [`DagSpec`] for the ONE graph renderer
//! dwarves has. It decides nothing about what a variant is; the ids it mints are the
//! variant name for a variant node and `<variant>/<path>` for a node of its tree, so
//! a selected id maps back to the matrix pick and to the tree path without a lookup
//! table.
use std::collections::BTreeMap;
use facett_graphview::Color;
use nornir_build_thing::tree::{NodeKind, SettingsNode, SettingsTree, KERNEL_PATH};
use nornir_build_thing::variant::ArtifactKind;
use crate::build_scene::Menu;
use crate::dag_graph::{DagNode, DagSpec};
use crate::settings_tree::KERNEL_LABEL;
/// The separator between a variant and a path in a tree node's id.
pub const ID_SEP: char = '/';
/// A variant node's id — the variant's own name.
#[must_use]
pub fn variant_id(variant: &str) -> String {
variant.to_string()
}
/// A tree node's id — `<variant>/<path>`.
#[must_use]
pub fn node_id(variant: &str, path: &str) -> String {
format!("{variant}{ID_SEP}{path}")
}
/// Split an id back into `(variant, path)`; a variant node has no path.
#[must_use]
pub fn split_id(id: &str) -> (&str, Option<&str>) {
match id.split_once(ID_SEP) {
Some((v, p)) => (v, Some(p)),
None => (id, None),
}
}
/// **What the graph knows about one variant** beyond the menu: its tree, when the forge
/// answered one.
pub struct VariantTree<'a> {
/// The variant.
pub variant: &'a str,
/// Its tree.
pub tree: &'a SettingsTree,
}
/// **Lower the menu and the trees to the DAG spec.**
///
/// Variant nodes come from the menu: every row, and every delivery column that names a
/// variant of its own (`Target::variant`), with a consumption edge from the delivery
/// variant to the row it packs (`--build <row>`). Tree nodes come from `trees`: a
/// containment edge from the variant to each root, and from each node to its children.
/// The `kärna` node's donor edge becomes a consumption edge from the VARIANT to a node
/// for the donor's coordinate (`--build <repo>/<kernel>`), typ on the badge.
///
/// `folded` receives every node id that starts folded — every feature with children —
/// so the caller can seed the pane's fold state after loading the spec.
#[must_use]
pub fn lower(
menu: &Menu,
trees: &[VariantTree<'_>],
deps: Option<(&facett_graphpan::GraphModel, &facett_graphpan::Decorations)>,
folded: &mut Vec<String>,
) -> DagSpec {
let mut spec = DagSpec::default();
lower_menu_and_trees(menu, trees, &mut spec, folded);
if let Some((model, deco)) = deps {
lower_deps(&mut spec, trees, model, deco, folded);
}
spec
}
/// The separator between a variant and a dependency-picture node id:
/// `<variant>/deps:<feat:x | crate:y | and:y:i>`.
pub const DEPS_SEP: &str = "/deps:";
/// **The dependency picture, folded INTO the build graph** — no second graph pane.
///
/// The feature↔crate model (`facett_buildthing::galois_pane::GaloisPane::model`) is
/// already a graph in the same engine; its nodes are re-minted under the picked
/// variant (`<variant>/deps:<id>`) and every `feat:<f>` node hangs under the tree's
/// feature node `<variant>/<f>` — or under the variant itself when the tree has no
/// such feature (a cargo variant's `features` list). Its emphasis edges become
/// consumption strokes (the picture's own "this pulls that"). Every `feat:` node
/// starts folded: the cold graph shows a feature, and a click opens what it costs.
fn lower_deps(
spec: &mut DagSpec,
trees: &[VariantTree<'_>],
model: &facett_graphpan::GraphModel,
deco: &facett_graphpan::Decorations,
folded: &mut Vec<String>,
) {
let Some(vt) = trees.first() else { return };
let mint = |id: &str| format!("{}{DEPS_SEP}{id}", vt.variant);
let color = |c: egui::Color32| Color::rgba(c.r(), c.g(), c.b(), c.a());
for n in &model.nodes {
let d = deco.nodes.get(&n.id);
spec.nodes.push(DagNode {
id: mint(&n.id),
label: n.label.clone(),
fill: color(n.fill),
ring: d.and_then(|d| d.ring).map(color),
badge: d.and_then(|d| d.badge.clone()),
});
if let Some(f) = n.id.strip_prefix("feat:") {
let under = node_id(vt.variant, f);
let parent = if spec.nodes.iter().any(|x| x.id == under) {
under
} else {
variant_id(vt.variant)
};
spec.edges.push((parent, mint(&n.id)));
folded.push(mint(&n.id));
}
}
for e in &model.edges {
spec.edges.push((mint(&e.from), mint(&e.to)));
}
for e in &deco.edges {
spec.consumes.push((
mint(&e.from),
mint(&e.to),
e.label.clone().unwrap_or_default(),
));
}
}
fn lower_menu_and_trees(
menu: &Menu,
trees: &[VariantTree<'_>],
spec: &mut DagSpec,
folded: &mut Vec<String>,
) {
let spec_ref = spec;
let mut spec = DagSpec::default();
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
let mut push_variant = |spec: &mut DagSpec, name: &str, kind: ArtifactKind, declared: bool| {
if seen.insert(name.to_string(), ()).is_some() {
return;
}
spec.nodes.push(DagNode {
id: variant_id(name),
label: name.to_string(),
fill: variant_fill(kind, declared),
ring: None,
badge: Some(kind.as_str().to_string()),
});
};
for row in &menu.rows {
let own = row
.targets
.iter()
.find(|t| t.declared && t.variant.is_none())
.map(|t| t.kind)
.unwrap_or(ArtifactKind::Exe);
push_variant(&mut spec, &row.name, own, true);
for t in row.targets.iter().filter(|t| t.declared) {
if let Some(v) = &t.variant {
if v != &row.name {
push_variant(&mut spec, v, t.kind, true);
spec.consumes.push((
variant_id(v),
variant_id(&row.name),
format!("--build {}", row.name),
));
}
}
}
}
for vt in trees {
let vid = variant_id(vt.variant);
// A kernel variant's roots are spelled `kernel.<name>` — the prompt's and the
// view's spelling — so a node id is the same string everywhere. Since lane AK
// (2026-08-30) that spelling is EDDA's own (`SettingsTree::of_kernel` scopes
// the row's nodes), so nothing is added here: prefixing again would draw
// `orten-kernel/kernel.kernel.preset`.
let prefix: Option<&str> = None;
for root in &vt.tree.roots {
lower_node(&mut spec, &vid, vt.variant, root, prefix, folded);
}
// A kernel VARIANT is the kernel: its roots hang under a synthetic `kärna` node
// so the picture has the same shape whichever variant carries the feature.
if vt.tree.typ == ArtifactKind::Kernel {
let kid = node_id(vt.variant, KERNEL_PATH);
spec.nodes.push(DagNode {
id: kid.clone(),
label: KERNEL_LABEL.to_string(),
fill: feature_fill(),
ring: Some(Color::rgb(120, 200, 140)),
badge: Some(format!(
"{} symboler",
vt.tree.roots.iter().filter(|n| is_symbol(n)).count()
)),
});
spec.edges.push((vid.clone(), kid.clone()));
// Re-hang the roots under kärna: their containment edge from the variant
// becomes one from the kärna node.
for e in &mut spec.edges {
if e.0 == vid && e.1 != kid {
e.0 = kid.clone();
}
}
folded.push(kid);
}
}
*spec_ref = spec;
}
fn lower_node(
spec: &mut DagSpec,
parent_id: &str,
variant: &str,
n: &SettingsNode,
prefix: Option<&str>,
folded: &mut Vec<String>,
) {
let path = match prefix {
Some(p) => format!("{p}.{}", n.path),
None => n.path.clone(),
};
let id = node_id(variant, &path);
let is_kernel = n.path == KERNEL_PATH && n.edge.is_some();
let (label, fill, badge, ring) = match &n.kind {
NodeKind::Feature(f) if is_kernel => (
KERNEL_LABEL.to_string(),
feature_fill(),
Some(format!(
"{} symboler",
n.children.iter().filter(|c| is_symbol(c)).count()
)),
Some(Color::rgb(120, 200, 140)),
),
NodeKind::Feature(f) if f.feature.starts_with("CONFIG_") => {
(f.feature.clone(), symbol_fill(f.default), None, None)
}
NodeKind::Feature(_) => (
n.display_label().to_string(),
feature_fill(),
Some("feature".to_string()),
None,
),
NodeKind::Setting(_) => (
n.display_label().to_string(),
setting_fill(),
Some("setting".to_string()),
None,
),
NodeKind::Val(v) => (
n.display_label().to_string(),
val_fill(),
Some(format!("val · {}", v.default)),
None,
),
};
spec.nodes.push(DagNode {
id: id.clone(),
label,
fill,
ring,
badge,
});
spec.edges.push((parent_id.to_string(), id.clone()));
if let Some(e) = &n.edge {
// The donor's kernel is a node of its own — another repo's variant — and the
// VARIANT consumes it: the edge runa runs. Typ on the badge.
let did = e.coordinate.to_string();
if !spec.nodes.iter().any(|x| x.id == did) {
spec.nodes.push(DagNode {
id: did.clone(),
label: e.coordinate.clone(),
fill: variant_fill(e.typ, true),
ring: None,
badge: Some(e.typ.as_str().to_string()),
});
}
spec.consumes.push((
variant_id(variant),
did,
format!("--build {}", e.coordinate),
));
}
if !n.children.is_empty() {
folded.push(id.clone());
for c in &n.children {
lower_node(spec, &id, variant, c, prefix, folded);
}
}
}
fn is_symbol(n: &SettingsNode) -> bool {
matches!(&n.kind, NodeKind::Feature(f) if f.feature.starts_with("CONFIG_"))
}
fn variant_fill(kind: ArtifactKind, declared: bool) -> Color {
if !declared {
return Color::rgb(90, 90, 96);
}
match kind {
ArtifactKind::Kernel => Color::rgb(40, 110, 80),
ArtifactKind::Installer | ArtifactKind::Iso => Color::rgb(150, 90, 40),
ArtifactKind::Container | ArtifactKind::Vm => Color::rgb(90, 70, 140),
_ => Color::rgb(50, 80, 150),
}
}
fn feature_fill() -> Color {
Color::rgb(60, 120, 120)
}
fn setting_fill() -> Color {
Color::rgb(100, 100, 60)
}
fn val_fill() -> Color {
Color::rgb(120, 80, 110)
}
fn symbol_fill(on: bool) -> Color {
if on {
Color::rgb(50, 130, 90)
} else {
Color::rgb(110, 60, 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::build_scene::{Row, Target};
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::variant::BuildVariantSet;
const DECL: &str = r#"
[build]
base_suite = "ORIGINAL"
[[build.variant]]
name = "orten-server"
package = "orten"
bin = "orten"
role = "server"
default = true
suites = ["ORIGINAL"]
kind = "exe"
[[build.installer]]
name = "orten-installer"
variant = "orten-server"
kernel_donor = { repo = "tunnr", kernel = "tunnr-kernel" }
[[build.installer.options]]
name = "dhcp"
label = "DHCP"
feature = "dhcp"
params_when_off = ["static_ip"]
[[build.installer.params]]
name = "static_ip"
kind = "text"
"#;
fn menu() -> Menu {
let target = |name: &str, kind: ArtifactKind, variant: Option<&str>| Target {
name: name.to_string(),
kind,
pane: None,
declared: true,
settings: Vec::new(),
variant: variant.map(str::to_string),
};
Menu {
coordinate: Coordinate::parse("orten-build-thing:1.0.0").unwrap(),
how: "test".into(),
rows: vec![Row {
name: "orten-server".into(),
badges: Vec::new(),
targets: vec![
target("exe", ArtifactKind::Exe, None),
target(
"installer",
ArtifactKind::Installer,
Some("orten-installer"),
),
],
}],
}
}
/// **The graph IS the tree**: a node per variant, a consumption edge from the
/// delivery variant to the row it packs, containment from the installer to its
/// features, the `kärna` node with the donor as a consumption edge from the VARIANT
/// (the DAG runa runs), and every feature with children folded by default.
#[test]
fn the_menu_and_the_trees_lower_to_one_dag_with_two_edge_kinds() {
let set = BuildVariantSet::from_toml_str(DECL).unwrap();
let tree = SettingsTree::of_set(&set, "orten-installer").unwrap();
let mut folded = Vec::new();
let spec = lower(
&menu(),
&[VariantTree {
variant: "orten-installer",
tree: &tree,
}],
None,
&mut folded,
);
let ids: Vec<&str> = spec.nodes.iter().map(|n| n.id.as_str()).collect();
assert!(
ids.contains(&"orten-server") && ids.contains(&"orten-installer"),
"{ids:?}"
);
assert!(
ids.contains(&"orten-installer/dhcp")
&& ids.contains(&"orten-installer/dhcp.static_ip"),
"{ids:?}"
);
assert!(
ids.contains(&"orten-installer/kernel"),
"the kärna node: {ids:?}"
);
assert!(
ids.contains(&"tunnr/tunnr-kernel"),
"the donor's kernel is a node: {ids:?}"
);
assert!(spec.consumes.iter().any(|(f, t, l)| f == "orten-installer"
&& t == "orten-server"
&& l == "--build orten-server"));
assert!(spec.consumes.iter().any(|(f, t, l)| f == "orten-installer"
&& t == "tunnr/tunnr-kernel"
&& l == "--build tunnr/tunnr-kernel"));
assert!(spec.edges.contains(&(
"orten-installer".to_string(),
"orten-installer/dhcp".to_string()
)));
assert!(spec.edges.contains(&(
"orten-installer/dhcp".to_string(),
"orten-installer/dhcp.static_ip".to_string()
)));
assert!(
folded.contains(&"orten-installer/dhcp".to_string())
&& folded.contains(&"orten-installer/kernel".to_string())
);
assert!(
!folded.contains(&"orten-installer/dhcp.static_ip".to_string()),
"a leaf is not folded"
);
let k = spec
.nodes
.iter()
.find(|n| n.id == "orten-installer/kernel")
.unwrap();
assert_eq!(k.label, KERNEL_LABEL);
assert_eq!(
split_id("orten-installer/dhcp.static_ip"),
("orten-installer", Some("dhcp.static_ip"))
);
assert_eq!(split_id("orten-server"), ("orten-server", None));
// The spec lowers to the engine's model with both edge kinds present.
let (model, deco) = spec.lower();
assert_eq!(model.nodes.len(), spec.nodes.len());
assert_eq!(
deco.edges.len(),
2,
"one emphasis stroke per consumption edge"
);
assert!(
model.edges.iter().any(|e| e.dashed),
"the dashed base of a consumption edge"
);
}
}