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

nordisk/dwarves

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