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/graph_pane.rs

//! **The build GRAPH pane** — what a variant compiles in, drawn.
//!
//! The plugin returns a [`BuildGraph`]: nodes, edges, roles. No SVG, no egui type
//! anywhere near the wasm boundary. This module is the other half — it maps that data
//! onto **`facett_graphpan::GraphModel`** and hands it to the SHARED
//! [`facett_graphpan::draw_graph`], which already owns pan, scroll-zoom,
//! click-to-select and downstream highlighting.
//!
//! There is no layout code here beyond assigning columns by node kind, and no renderer
//! at all: a second graph renderer in this constellation would be the fourth twin of
//! the session. What this file adds is the domain mapping — which kind gets which
//! colour, which edge is dashed — and the one thing the picture exists for:
//!
//! > **a `Reaches` edge is dashed and an `Embeds` edge is solid.**
//!
//! That single visual distinction is the fat/thin story. `holger full` embeds skade
//! (solid: it is *in the binary*); `holger mini` reaches it (dashed: it must be
//! *running*). Measured on 2026-07-22, the same distinction in dwarves is a 6.3 MB
//! thin binary against a 227 MB fat one — 35.9×. A table states that; a picture shows
//! it before you have read a word.
//!
//! Clicking a variant node returns the variant it [`selects`](nornir_build_thing::plugin::GraphNode::selects),
//! so the graph is not an illustration beside the menu — it *is* a way to pick a build.

use std::collections::HashMap;

use egui::{Color32, Pos2, Ui};
use facett_graphpan::{Decorations, GraphEdge, GraphModel, GraphNode, GraphView};
use nornir_build_thing::plugin::{BuildGraph, EdgeKind, NodeKind};

/// Which nodes the graph shows. **The filter is the interaction**: a build's gated
/// file list is long, and the finding is almost never "all of it" — it is "what does
/// this variant leave out".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Filter {
    /// The build itself: variant, steps, artifacts, embeds/reaches. No files.
    #[default]
    Build,
    /// The build plus every gated source file.
    Files,
    /// Only the files this variant GATES OUT — the code that is not in the binary.
    /// This is the view that finds a dead subsystem.
    Excluded,
}

impl Filter {
    /// Stable label.
    pub fn as_str(self) -> &'static str {
        match self {
            Filter::Build => "build",
            Filter::Files => "files",
            Filter::Excluded => "excluded",
        }
    }
    /// The tab caption.
    pub fn caption(self) -> &'static str {
        match self {
            Filter::Build => "Build",
            Filter::Files => "+ files",
            Filter::Excluded => "excluded only",
        }
    }
}

/// Categorical colour per node kind — one hue per *kind*, never per magnitude. Drawn
/// from the same family the other facett graph panes use so a reader moving between
/// them is not relearning a palette.
fn node_fill(kind: NodeKind) -> Color32 {
    match kind {
        NodeKind::Variant => Color32::from_rgb(0x15, 0x65, 0xc0),
        NodeKind::Step => Color32::from_rgb(0x37, 0x47, 0x4f),
        NodeKind::Artifact => Color32::from_rgb(0x00, 0x69, 0x5c),
        // The two that carry the whole point, deliberately far apart in hue.
        NodeKind::Embedded => Color32::from_rgb(0x6a, 0x1b, 0x9a),
        NodeKind::Reached => Color32::from_rgb(0x8d, 0x6e, 0x00),
        // A source file's own colour is neutral; its INCLUSION is what carries meaning
        // and that is painted by `file_fill` from the node's detail.
        NodeKind::SourceFile => Color32::from_rgb(0x45, 0x4a, 0x4d),
    }
}

/// A gated file's colour, by whether this variant actually compiles it.
///
/// Three distinguishable states, never two: **in the binary**, **excluded from the
/// binary**, and **not decided**. Painting "gated out" and "undecided" the same way
/// would be the conflation this whole layer exists to expose.
fn file_fill(detail: Option<&str>) -> Color32 {
    let d = detail.unwrap_or_default();
    if d.contains("[gated-out]") {
        Color32::from_rgb(0xc6, 0x28, 0x28)
    } else if d.contains("[compiled-in]") {
        Color32::from_rgb(0x00, 0x69, 0x5c)
    } else if d.contains("[undecided]") {
        Color32::from_rgb(0x6a, 0x1b, 0x9a)
    } else {
        Color32::from_rgb(0x45, 0x4a, 0x4d)
    }
}

/// True when a node's detail marks it excluded from this build.
fn is_excluded(detail: Option<&str>) -> bool {
    detail.unwrap_or_default().contains("[gated-out]")
}

fn edge_color(kind: EdgeKind) -> Color32 {
    match kind {
        EdgeKind::Plan => Color32::from_rgb(0x60, 0x6a, 0x70),
        EdgeKind::Produces => Color32::from_rgb(0x00, 0x69, 0x5c),
        EdgeKind::Embeds => Color32::from_rgb(0x6a, 0x1b, 0x9a),
        EdgeKind::Reaches => Color32::from_rgb(0x8d, 0x6e, 0x00),
        EdgeKind::Gates => Color32::from_rgb(0x60, 0x50, 0x70),
    }
}

/// Which column a kind sits in — the only "layout" this file does. Left to right is
/// the story order: *this variant* → *what it does* → *what comes out*, with the
/// composition siblings banked below the root where they belong to it visually.
fn column(kind: NodeKind) -> usize {
    match kind {
        NodeKind::Variant => 0,
        NodeKind::Embedded | NodeKind::Reached => 1,
        NodeKind::Step => 2,
        NodeKind::Artifact => 3,
        // Files bank furthest right: there are many, and they must not push the
        // build's own spine off the left of the view.
        NodeKind::SourceFile => 4,
    }
}

/// Map a [`BuildGraph`] onto the shared renderer's model, laying nodes out in columns
/// by kind and rows by order of appearance.
pub fn to_graph_model(g: &BuildGraph) -> GraphModel {
    to_graph_model_filtered(g, Filter::Files)
}

/// Map a [`BuildGraph`] onto the renderer's model, showing only the nodes `filter`
/// admits. Edges whose endpoint was filtered away are dropped WITH their node — a
/// filter that left an edge pointing at nothing would break the same invariant
/// `dangling_edges()` protects on the data side.
pub fn to_graph_model_filtered(g: &BuildGraph, filter: Filter) -> GraphModel {
    const COL_W: f32 = 210.0;
    const ROW_H: f32 = 64.0;

    let keep = |n: &nornir_build_thing::plugin::GraphNode| -> bool {
        match (filter, n.kind) {
            (Filter::Build, NodeKind::SourceFile) => false,
            (Filter::Excluded, NodeKind::SourceFile) => is_excluded(n.detail.as_deref()),
            // In `Excluded`, the build's own spine stays so the files have something
            // to hang from — an orphan file list is not a graph.
            _ => true,
        }
    };
    let kept: std::collections::BTreeSet<&str> =
        g.nodes.iter().filter(|n| keep(n)).map(|n| n.id.as_str()).collect();

    let mut per_column: HashMap<usize, usize> = HashMap::new();
    let nodes = g
        .nodes
        .iter()
        .filter(|n| keep(n))
        .map(|n| {
            let col = column(n.kind);
            let row = *per_column.entry(col).and_modify(|r| *r += 1).or_insert(0);
            GraphNode {
                id: n.id.clone(),
                label: n.label.clone(),
                fill: if n.kind == NodeKind::SourceFile {
                    file_fill(n.detail.as_deref())
                } else {
                    node_fill(n.kind)
                },
                stroke: if n.kind == NodeKind::SourceFile {
                    file_fill(n.detail.as_deref())
                } else {
                    node_fill(n.kind)
                },
                pos: Pos2::new(col as f32 * COL_W, row as f32 * ROW_H),
            }
        })
        .collect();

    let edges = g
        .edges
        .iter()
        .filter(|e| kept.contains(e.from.as_str()) && kept.contains(e.to.as_str()))
        .map(|e| GraphEdge {
            from: e.from.clone(),
            to: e.to.clone(),
            color: edge_color(e.kind),
            // The one distinction the picture exists for: a runtime dependency is
            // dashed, a compiled-in one is solid.
            dashed: e.kind.dashed(),
            label: e.label.clone(),
        })
        .collect();

    GraphModel { nodes, edges }
}

/// The graph pane's own state: the shared view (pan / zoom / selection) plus the
/// graph currently being shown.
#[derive(Default)]
pub struct BuildGraphPane {
    /// The graph the plugin returned.
    pub graph: BuildGraph,
    /// Pan / zoom / selection, owned by the shared renderer.
    pub view: GraphView,
    /// Which nodes to show — the interaction the owner asked for.
    pub filter: Filter,
    /// The variant a click asked to select, taken by the host each frame.
    pending_select: Option<String>,
}

impl BuildGraphPane {
    /// A pane over one graph.
    pub fn new(graph: BuildGraph) -> Self {
        BuildGraphPane { graph, ..Default::default() }
    }

    /// Show a different variant's graph (the menu pane selected something).
    pub fn set_graph(&mut self, graph: BuildGraph) {
        self.graph = graph;
    }

    /// Record a pending selection — the deck's test seam, standing in for the click
    /// `ui` would have recorded. `#[doc(hidden)]`: it is a seam, not API.
    #[doc(hidden)]
    pub fn pending_select_for_test(&mut self, variant: &str) {
        self.pending_select = Some(variant.to_string());
    }

    /// Take the variant a node click asked to select, if any. The host applies it —
    /// this pane never mutates the selection itself, so the menu stays the one owner.
    pub fn take_selection(&mut self) -> Option<String> {
        self.pending_select.take()
    }

    /// `(compiled_in, gated_out, undecided)` over the graph's source-file nodes.
    pub fn gated_counts(&self) -> (usize, usize, usize) {
        let mut t = (0, 0, 0);
        for n in self.graph.nodes.iter().filter(|n| n.kind == NodeKind::SourceFile) {
            let d = n.detail.as_deref().unwrap_or_default();
            if d.contains("[gated-out]") {
                t.1 += 1;
            } else if d.contains("[undecided]") {
                t.2 += 1;
            } else {
                t.0 += 1;
            }
        }
        t
    }

    /// Set the filter (the headless seam).
    pub fn set_filter(&mut self, f: Filter) {
        self.filter = f;
    }

    /// The pane title, for a deck.
    pub fn title(&self) -> String {
        if self.graph.root.is_empty() {
            "Build graph".to_string()
        } else {
            format!("Build graph — {}", self.graph.root)
        }
    }

    /// **Draw.** Delegates to the shared `facett_graphpan::draw_graph`, which owns
    /// pan, scroll-zoom, click-to-select and downstream highlighting. A clicked
    /// variant node records the build it selects.
    pub fn ui(&mut self, ui: &mut Ui) {
        if self.graph.nodes.is_empty() {
            ui.label(
                egui::RichText::new(
                    "no graph — this variant is not declared by the build thing (an empty \
                     graph is a missing declaration, not an empty build)",
                )
                .small(),
            );
            return;
        }
        // THE FILTER — the interaction. "Show me what this build leaves out" is the
        // question that finds a dead subsystem, and it is one click.
        let gated = self.gated_counts();
        ui.horizontal_wrapped(|ui| {
            for f in [Filter::Build, Filter::Files, Filter::Excluded] {
                if ui.selectable_label(self.filter == f, f.caption()).clicked() {
                    self.filter = f;
                }
            }
            if gated.2 > 0 {
                ui.label(
                    egui::RichText::new(format!(
                        "· {} in · {} EXCLUDED · {} undecided",
                        gated.0, gated.1, gated.2
                    ))
                    .small(),
                );
            } else {
                ui.label(
                    egui::RichText::new(format!("· {} in · {} EXCLUDED", gated.0, gated.1)).small(),
                );
            }
        });
        // The legend earns its space: it is the key to the one distinction that matters.
        ui.horizontal_wrapped(|ui| {
            ui.label(egui::RichText::new("━━ compiled IN (in the binary)").small().color(edge_color(EdgeKind::Embeds)));
            ui.label(egui::RichText::new("╌╌ reached over gRPC (must be RUNNING)").small().color(edge_color(EdgeKind::Reaches)));
        });
        if self.filter == Filter::Excluded && gated.1 == 0 {
            ui.label(
                egui::RichText::new(
                    "this variant gates NO file out — every gated file it knows about is \
                     compiled in",
                )
                .small(),
            );
        }
        let model = to_graph_model_filtered(&self.graph, self.filter);
        // Colours come from the LIVE egui visuals, not from constants here, so the
        // pane follows whatever theme facett-core's `look` presets put in force.
        let v = ui.visuals().clone();
        let response = facett_graphpan::draw_graph(
            ui,
            &model,
            &Decorations::default(),
            &mut self.view,
            v.extreme_bg_color,
            v.text_color(),
            v.selection.bg_fill,
            v.weak_text_color(),
        );
        if let Some(id) = response.clicked_node
            && let Some(sel) = self
                .graph
                .nodes
                .iter()
                .find(|n| n.id == id)
                .and_then(|n| n.selects.clone())
        {
            self.pending_select = Some(sel);
        }
    }

    /// The observable state (L4) — asserted by a robot rather than eyeballed.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "component": "BuildGraphPane",
            "repo": self.graph.repo,
            "root": self.graph.root,
            "node_count": self.graph.nodes.len(),
            "edge_count": self.graph.edges.len(),
            "embedded": self.graph.embedded(),
            "reached": self.graph.reached(),
            "dangling_edges": self.graph.dangling_edges(),
            "filter": self.filter.as_str(),
            "gated_compiled_in": self.gated_counts().0,
            "gated_out": self.gated_counts().1,
            "gated_undecided": self.gated_counts().2,
            "pending_select": self.pending_select,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_build_thing::plugin::{BuildGraph, GraphEdge as BgEdge, GraphNode as BgNode};

    fn fat() -> BuildGraph {
        BuildGraph {
            repo: "holger".into(),
            root: "full".into(),
            nodes: vec![
                BgNode {
                    id: "variant:full".into(),
                    label: "full".into(),
                    kind: NodeKind::Variant,
                    detail: Some("embeds skade".into()),
                    selects: Some("full".into()),
                },
                BgNode {
                    id: "embeds:skade".into(),
                    label: "skade".into(),
                    kind: NodeKind::Embedded,
                    detail: None,
                    selects: None,
                },
            ],
            edges: vec![BgEdge {
                from: "variant:full".into(),
                to: "embeds:skade".into(),
                kind: EdgeKind::Embeds,
                label: None,
            }],
        }
    }

    /// **The distinction the picture exists for.** A compiled-in sibling is a solid
    /// edge; one reached over gRPC is dashed. Everything else about the render is
    /// the shared renderer's business.
    #[test]
    fn embeds_is_solid_and_reaches_is_dashed() {
        let m = to_graph_model(&fat());
        assert_eq!(m.edges.len(), 1);
        assert!(!m.edges[0].dashed, "compiled IN is solid — it is in the binary");

        let mut thin = fat();
        thin.edges[0].kind = EdgeKind::Reaches;
        let m = to_graph_model(&thin);
        assert!(m.edges[0].dashed, "reached over gRPC is dashed — it must be RUNNING");
        assert_ne!(
            m.edges[0].color,
            edge_color(EdgeKind::Embeds),
            "and the two are not the same colour either"
        );
    }

    /// Nodes land in distinct columns by kind, so the mapping produces a layout the
    /// shared renderer can draw without every node stacking at the origin.
    #[test]
    fn nodes_are_laid_out_in_columns_by_kind() {
        let m = to_graph_model(&fat());
        let root = m.nodes.iter().find(|n| n.id == "variant:full").unwrap();
        let sib = m.nodes.iter().find(|n| n.id == "embeds:skade").unwrap();
        assert!(sib.pos.x > root.pos.x, "the sibling sits to the right of the variant");
        assert_ne!(root.fill, sib.fill, "kinds are categorically coloured");
    }

    /// A clicked variant node yields the build to select — the payoff for the graph
    /// being data rather than a picture.
    #[test]
    fn the_pane_reports_a_click_as_a_variant_to_select() {
        let mut p = BuildGraphPane::new(fat());
        assert_eq!(p.take_selection(), None, "nothing clicked yet");
        // Simulate what `ui` does with a click on the root node.
        p.pending_select = p.graph.nodes[0].selects.clone();
        assert_eq!(p.take_selection().as_deref(), Some("full"));
        assert_eq!(p.take_selection(), None, "taken once, then cleared");
        assert!(p.title().contains("full"));
    }

    /// An empty graph is a REPORTED state — a missing declaration, never an empty
    /// build silently drawn as nothing.
    #[test]
    fn an_empty_graph_states_that_it_is_a_missing_declaration() {
        let p = BuildGraphPane::default();
        let s = p.state_json();
        assert_eq!(s["node_count"], 0);
        assert_eq!(s["component"], "BuildGraphPane");
        assert!(s["dangling_edges"].as_array().unwrap().is_empty());
    }

    /// **The filter is the interaction.** `Build` hides the file layer, `Files` shows
    /// it, `Excluded` shows only what this variant leaves OUT — the view that finds a
    /// dead subsystem.
    #[test]
    fn the_filter_narrows_to_what_this_build_leaves_out() {
        use nornir_build_thing::plugin::{GraphEdge as BgEdge, GraphNode as BgNode};
        let mut g = fat();
        let file = |name: &str, state: &str| BgNode {
            id: format!("file:{name}"),
            label: name.into(),
            kind: NodeKind::SourceFile,
            detail: Some(format!("src/{name} — feature = \"x\" [{state}]")),
            selects: None,
        };
        g.nodes.push(file("in.rs", "compiled-in"));
        g.nodes.push(file("out.rs", "gated-out"));
        g.nodes.push(file("maybe.rs", "undecided"));
        for n in ["in.rs", "out.rs", "maybe.rs"] {
            g.edges.push(BgEdge {
                from: "variant:full".into(),
                to: format!("file:{n}"),
                kind: EdgeKind::Gates,
                label: None,
            });
        }
        let mut p = BuildGraphPane::new(g);
        assert_eq!(p.gated_counts(), (1, 1, 1), "three states, counted apart");

        // Build: no files at all.
        p.set_filter(Filter::Build);
        let m = to_graph_model_filtered(&p.graph, Filter::Build);
        assert_eq!(m.nodes.len(), 2, "variant + embedded sibling only");
        assert!(m.edges.iter().all(|e| !e.from.starts_with("file:") && !e.to.starts_with("file:")));

        // Files: all three.
        let m = to_graph_model_filtered(&p.graph, Filter::Files);
        assert_eq!(m.nodes.len(), 5);

        // Excluded: only the gated-out one, and the build spine it hangs from.
        let m = to_graph_model_filtered(&p.graph, Filter::Excluded);
        let files: Vec<&str> =
            m.nodes.iter().filter(|n| n.id.starts_with("file:")).map(|n| n.label.as_str()).collect();
        assert_eq!(files, vec!["out.rs"], "only what this build LEAVES OUT");
        // No edge may point at a node the filter removed.
        let ids: std::collections::BTreeSet<&str> = m.nodes.iter().map(|n| n.id.as_str()).collect();
        assert!(
            m.edges.iter().all(|e| ids.contains(e.from.as_str()) && ids.contains(e.to.as_str())),
            "a filter must not leave an edge pointing at nothing"
        );
    }

    /// The three inclusion states get three different colours. Painting "gated out"
    /// and "undecided" alike is the conflation this layer exists to expose.
    #[test]
    fn the_three_inclusion_states_are_three_colours() {
        let c_in = file_fill(Some("x [compiled-in]"));
        let c_out = file_fill(Some("x [gated-out]"));
        let c_und = file_fill(Some("x [undecided]"));
        assert_ne!(c_in, c_out);
        assert_ne!(c_out, c_und);
        assert_ne!(c_in, c_und);
        assert!(is_excluded(Some("x [gated-out]")));
        assert!(!is_excluded(Some("x [compiled-in]")));
        assert!(!is_excluded(None), "no detail is not an exclusion claim");
    }

    #[test]
    fn state_json_names_what_is_compiled_in_and_what_must_be_running() {
        let s = BuildGraphPane::new(fat()).state_json();
        assert_eq!(s["embedded"], serde_json::json!(["skade"]));
        assert_eq!(s["reached"], serde_json::json!([]));
        assert_eq!(s["root"], "full");
    }
}