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

nordisk/nornir

src/viz/release_depdag.rs

//! **Release dependency-DAG view** — the crate publish-order graph as a decorated,
//! pan/zoom dep-graph, rendered by the shared facett components
//! [`facett_graphview::DepGraphLayout`] (toposort/longest-path layering) +
//! [`facett_graphview::DecoratedGraphView`] (the interactive egui pane: rings,
//! badges, pan/drag + scroll-zoom, click-to-light-downstream).
//!
//! Thin adapter: the DAG is assembled from real release data by
//! [`nornir_release_core::depdag::ReleaseDepDag`] (nodes = crates, edges = depends-on,
//! annotated with per-crate registry state); this module maps it onto facett's
//! layout + view. Node **fill** = registry state (will-publish green / already-
//! published grey / desync red / unpublished blue / unknown grey), the **ring** =
//! on a real publish cycle (unshippable), the **badge** = the publish **wave**
//! index, and a **dashed** edge = a transitive-closure edge (the `show_transitive`
//! toggle). The column rank *is* the reverse publish order (deps to the right).

use std::collections::{BTreeMap, BTreeSet};

use facett_graphview::{
    Color, Decorations, DepEdge, DepGraphLayout, EdgeClass, GraphEdge, GraphModel, GraphNode,
    NodeDecoration, Pos,
};

use crate::knowledge::query::KnowledgeView;
use nornir_release_core::depdag::{CrateDepEdge, ReleaseDepDag};
use nornir_release_core::registry::RegistryState;

const COL_GAP: f32 = 230.0;
const ROW_GAP: f32 = 74.0;

/// Which crate-graph the dep-DAG pane draws.
///
/// * [`Thin`](DepDagMode::Thin) — the **cargo-metadata** graph: nodes = crates,
///   edges = the Cargo.toml `depends-on` order the release doctor topo-sorts.
///   Always available (parsed from manifests, offline).
/// * [`Deep`](DepDagMode::Deep) — the **symbol-derived** cross-crate graph: the
///   warehouse `call_edges` rolled up to crate→crate edges (a caller crate that
///   invokes a symbol another crate DEFINES). Needs a scanned warehouse; empty
///   when the workspace was never `deep_scan`ned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DepDagMode {
    #[default]
    Thin,
    Deep,
}

impl DepDagMode {
    /// Stable token for `state_json` + robot assertions.
    pub fn as_str(self) -> &'static str {
        match self {
            DepDagMode::Thin => "thin",
            DepDagMode::Deep => "deep",
        }
    }
}

/// Last `::`-separated segment of `s` (the bare identifier).
fn last_seg(s: &str) -> &str {
    s.rsplit("::").next().unwrap_or(s)
}

/// Best-effort calling crate for a `call_edges` row: the edge's `crate_name` when
/// the syn scan recorded one, else the first `::`-segment of the caller path (a
/// resolved SCIP moniker carries no crate column). Mirrors the same helper in
/// [`nornir_release_core::semantic_blast`].
fn caller_crate(crate_name: &str, caller_path: &str) -> String {
    if !crate_name.is_empty() {
        return crate_name.to_string();
    }
    match caller_path.split_once("::") {
        Some((head, _)) if !head.is_empty() => head.to_string(),
        _ => caller_path.to_string(),
    }
}

/// **The DEEP rollup** — turn a call-graph [`KnowledgeView`] into crate→crate
/// `depends-on` edges, restricted to the `produced` crate set (the DAG's nodes).
///
/// For every persisted `call_edges` row: resolve the CALLER's crate (from the
/// edge) and the CALLEE's DEFINING crate (map the bare callee identifier through
/// `symbol_facts`). When both are produced-in-workspace, distinct, and the callee
/// identifier resolves **unambiguously** to a single defining crate, emit a
/// `caller → callee` edge (caller depends-on callee). Ambiguous identifiers
/// (defined in >1 produced crate) are skipped this first cut — see
/// `.nornir/dag-ui-design.md`. Edges are de-duplicated; `via` collects the symbol
/// names that justify the edge (sorted, deduped), so the pane can label/inspect.
///
/// Pure + deterministic (BTree-ordered): the `view` is data, so this is fully
/// offline and golden-testable with a synthetic view fixture.
#[must_use]
pub fn deep_edges_from_view(
    view: &KnowledgeView,
    produced: &BTreeSet<String>,
) -> Vec<CrateDepEdge> {
    // bare symbol name → the produced crate(s) that DEFINE it.
    let mut def_crate: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
    for s in &view.symbols {
        if produced.contains(&s.crate_name) {
            def_crate
                .entry(s.item_name.as_str())
                .or_default()
                .insert(s.crate_name.as_str());
        }
    }

    // (from_crate, to_crate) → justifying symbol names.
    let mut acc: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
    for e in &view.calls {
        let from = caller_crate(&e.crate_name, &e.caller_path);
        if !produced.contains(&from) {
            continue;
        }
        let callee = last_seg(&e.callee_ident);
        let Some(defs) = def_crate.get(callee) else {
            continue;
        };
        // Only an UNAMBIGUOUS callee resolves to a crate→crate edge this first cut.
        if defs.len() != 1 {
            continue;
        }
        let to = *defs.iter().next().unwrap();
        if to == from.as_str() {
            continue;
        }
        acc.entry((from, to.to_string()))
            .or_default()
            .insert(callee.to_string());
    }

    acc.into_iter()
        .map(|((from, to), via)| CrateDepEdge {
            from,
            to,
            via: via.into_iter().collect(),
        })
        .collect()
}

/// Registry-state → chip fill colour (the "kind" colour DecoratedGraphView paints).
fn state_color(s: RegistryState) -> Color {
    match s {
        RegistryState::WillPublish => Color::rgb(90, 200, 140),
        RegistryState::AlreadyPublished => Color::rgb(120, 130, 140),
        RegistryState::Desync => Color::rgb(230, 90, 90),
        RegistryState::Unpublished => Color::rgb(90, 150, 230),
        RegistryState::Unknown => Color::rgb(110, 110, 120),
    }
}

/// The release dep-DAG pane: a lazily-(re)built [`DecoratedGraphView`] over the
/// facett layout, plus the display toggle + cached introspection.
#[derive(Default)]
pub struct ReleaseDepDagPane {
    view: Option<facett_graphview::DecoratedGraphView>,
    /// Synthesize transitive-closure edges (maps to `DepGraphLayout`'s `deep` arg).
    pub show_transitive: bool,
    /// THIN (cargo-metadata) vs DEEP (symbol-derived) edge set.
    pub mode: DepDagMode,
    /// The DEEP crate→crate edge set (from the warehouse rollup), cached so a
    /// mode/transitive flip rebuilds without re-reading the warehouse. Empty until
    /// [`set_deep_edges`](Self::set_deep_edges) supplies them.
    deep_edges: Vec<CrateDepEdge>,
    built: bool,
    node_count: usize,
    direct_edges: usize,
    transitive_edges: usize,
    /// Count of DEEP edges available (reported even while THIN is showing).
    deep_edge_count: usize,
    /// Why the DEEP edge set could not be read from the code room (the
    /// reader's refusal): an empty set is then NOT "no deep edges".
    deep_unavailable: Option<String>,
    waves: Vec<Vec<String>>,
    cycle: Vec<String>,
    publishable: bool,
    /// Cached `DepGraphLayout::state_json` of the ACTIVE mode's laid-out graph
    /// (the structure the robot reads).
    layout_json: serde_json::Value,
    /// Cached `ReleaseDepDag::state_json` (the registry-annotated model).
    dag_json: serde_json::Value,
}

impl ReleaseDepDagPane {
    pub fn node_count(&self) -> usize {
        self.node_count
    }
    pub fn is_built(&self) -> bool {
        self.built
    }
    /// Number of DEEP (symbol-derived) edges currently available.
    pub fn deep_edge_count(&self) -> usize {
        self.deep_edge_count
    }

    /// Supply the DEEP crate→crate edge set (from [`deep_edges_from_view`]). The
    /// caller rebuilds afterwards; the edges are cached so a mode flip is cheap.
    pub fn set_deep_edges(&mut self, edges: Vec<CrateDepEdge>) {
        self.deep_edge_count = edges.len();
        self.deep_edges = edges;
        self.deep_unavailable = None;
    }

    /// The DEEP edge set could not be read (`why` = the reader's refusal: nothing
    /// consumed, no lake …). The pane and `state_json` say so instead of showing
    /// zero edges.
    pub fn mark_deep_unavailable(&mut self, why: String) {
        self.deep_edges.clear();
        self.deep_edge_count = 0;
        self.deep_unavailable = Some(why);
    }

    /// (Re)build the facet from an assembled [`ReleaseDepDag`], honouring the current
    /// `mode` (THIN cargo graph vs DEEP symbol rollup) + `show_transitive` toggle.
    pub fn build(&mut self, dag: &ReleaseDepDag) {
        let repos: Vec<String> = dag.nodes.iter().map(|n| n.name.clone()).collect();
        // Active edge set: the cargo graph (THIN) or the symbol rollup (DEEP).
        let edge_src: &[CrateDepEdge] = match self.mode {
            DepDagMode::Thin => &dag.edges,
            DepDagMode::Deep => &self.deep_edges,
        };
        let edges: Vec<DepEdge> = edge_src
            .iter()
            .map(|e| DepEdge {
                from: e.from.clone(),
                to: e.to.clone(),
                via: e.via.clone(),
            })
            .collect();

        let layout = DepGraphLayout::build(&repos, &edges, self.show_transitive, &BTreeSet::new());

        // Node lookup for state/wave/cycle decoration.
        let by_name: std::collections::HashMap<&str, &nornir_release_core::depdag::CrateNode> =
            dag.nodes.iter().map(|n| (n.name.as_str(), n)).collect();

        let mut model = GraphModel::default();
        let mut deco_nodes: std::collections::HashMap<String, NodeDecoration> =
            std::collections::HashMap::new();
        for ln in &layout.nodes {
            let meta = by_name.get(ln.repo.as_str());
            let state = meta.map(|m| m.state).unwrap_or(RegistryState::Unknown);
            let fill = state_color(state);
            model.nodes.push(GraphNode {
                id: ln.repo.clone(),
                label: ln.repo.clone(),
                fill,
                stroke: Color::WHITE,
                pos: Pos::new(ln.col as f32 * COL_GAP, ln.row as f32 * ROW_GAP),
            });
            let in_cycle = meta.map(|m| m.in_cycle).unwrap_or(false);
            let wave = meta.and_then(|m| m.wave);
            deco_nodes.insert(
                ln.repo.clone(),
                NodeDecoration {
                    // A red ring flags an unshippable crate on a real publish cycle.
                    ring: in_cycle.then(|| Color::rgb(230, 70, 60)),
                    // The publish wave index as a corner badge (— when on a cycle).
                    badge: Some(
                        wave.map(|w| w.to_string())
                            .unwrap_or_else(|| "—".to_string()),
                    ),
                    badge_color: Some(fill),
                    scale: None,
                },
            );
        }

        // Base edges: dashed for a synthesised transitive-closure edge.
        for e in &layout.edges {
            model.edges.push(GraphEdge {
                from: e.from.clone(),
                to: e.to.clone(),
                color: if e.class == EdgeClass::Transitive {
                    Color::rgb(120, 120, 140)
                } else {
                    Color::rgb(180, 185, 200)
                },
                dashed: e.class == EdgeClass::Transitive,
                label: None,
            });
        }

        self.node_count = layout.nodes.len();
        self.direct_edges = layout.direct_edges();
        self.transitive_edges = layout.transitive_edges();
        self.waves = dag.waves.clone();
        self.cycle = dag.cycle.clone();
        self.publishable = dag.is_publishable();
        self.layout_json = layout.state_json();
        self.dag_json = dag.state_json();

        let decorations = Decorations {
            nodes: deco_nodes,
            edges: Vec::new(),
        };
        self.view = Some(
            facett_graphview::DecoratedGraphView::new("release-depdag")
                .with_model(model)
                .with_decorations(decorations),
        );
        self.built = true;

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "viz/release_depdag (facett-graphview)",
            "release_depdag_built",
            true,
            &format!(
                "mode={} nodes={} direct={} transitive={} deep_avail={} waves={} cycle={}",
                self.mode.as_str(),
                self.node_count,
                self.direct_edges,
                self.transitive_edges,
                self.deep_edge_count,
                self.waves.len(),
                self.cycle.len()
            ),
        );
    }

    /// Flip the transitive-closure display toggle (the caller rebuilds afterwards).
    pub fn toggle_transitive(&mut self) {
        self.show_transitive = !self.show_transitive;
    }

    /// Flip the THIN⇄DEEP edge-set toggle (the caller rebuilds afterwards).
    pub fn toggle_mode(&mut self) {
        self.mode = match self.mode {
            DepDagMode::Thin => DepDagMode::Deep,
            DepDagMode::Deep => DepDagMode::Thin,
        };
    }

    /// Render the decorated pane (or a placeholder when nothing is built yet).
    pub fn ui(&mut self, ui: &mut egui::Ui) {
        use facett_core::Facet;
        if self.mode == DepDagMode::Deep
            && let Some(why) = &self.deep_unavailable
        {
            ui.weak(format!("deep edges: {why}"));
        }
        match &mut self.view {
            Some(v) => v.ui(ui),
            None => {
                ui.centered_and_justified(|ui| {
                    ui.weak("no crate graph — local mode with a workspace checkout required");
                });
            }
        }
    }

    /// Robot-observable state — folded under `state_json["depdag"]`. `mode` is the
    /// active edge source (`"thin"` cargo graph / `"deep"` symbol rollup); the nested
    /// `layout` block is the facett `DepGraphLayout::state_json` of that active graph
    /// (positions + classified edges); `dag` is the registry-annotated model.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "mode": self.mode.as_str(),
            "show_transitive": self.show_transitive,
            "deep_edge_count": self.deep_edge_count,
            "deep_state": if self.deep_unavailable.is_some() { "unavailable" } else { "available" },
            "deep_note": self.deep_unavailable,
            "publishable": self.publishable,
            "waves": self.waves,
            "cycle": self.cycle,
            // The ACTIVE mode's laid-out graph (positions + classified edges). The
            // `mode` field says whether these are cargo (thin) or symbol (deep) edges.
            "layout": self.layout_json,
            "dag": self.dag_json,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::doctor::RepoGraph;
    use std::collections::BTreeMap;

    fn chain_dag() -> ReleaseDepDag {
        let mut a = RepoGraph {
            repo: "A".into(),
            ..Default::default()
        };
        a.crate_deps
            .entry("app".into())
            .or_default()
            .insert("lib".into());
        let mut b = RepoGraph {
            repo: "B".into(),
            ..Default::default()
        };
        b.crate_deps
            .entry("lib".into())
            .or_default()
            .insert("base".into());
        let mut c = RepoGraph {
            repo: "C".into(),
            ..Default::default()
        };
        c.crate_deps.entry("base".into()).or_default();
        ReleaseDepDag::assemble(&[a, b, c], &BTreeMap::new(), &BTreeMap::new())
    }

    #[test]
    fn build_lays_out_the_thin_crate_graph() {
        let dag = chain_dag();
        let mut pane = ReleaseDepDagPane::default();
        pane.build(&dag);
        assert!(pane.is_built());
        let j = pane.state_json();
        assert_eq!(j["mode"], "thin");
        assert_eq!(j["layout"]["node_count"], 3);
        assert_eq!(j["layout"]["direct_edges"], 2);
        assert_eq!(j["layout"]["transitive_edges"], 0);
        // waves[0] is the leaf crate published first.
        assert_eq!(j["waves"][0][0], "base");
        assert_eq!(j["publishable"], true);
    }

    #[test]
    fn transitive_toggle_synthesises_closure_edges() {
        let dag = chain_dag();
        let mut pane = ReleaseDepDagPane::default();
        pane.toggle_transitive();
        pane.build(&dag);
        let j = pane.state_json();
        assert_eq!(j["show_transitive"], true);
        // app → base is reachable in two hops → one synthesised transitive edge.
        assert_eq!(j["layout"]["transitive_edges"], 1);
    }

    #[test]
    fn registry_state_reaches_the_dag_block() {
        let mut a = RepoGraph {
            repo: "A".into(),
            ..Default::default()
        };
        a.crate_deps
            .entry("app".into())
            .or_default()
            .insert("lib".into());
        let mut b = RepoGraph {
            repo: "B".into(),
            ..Default::default()
        };
        b.crate_deps.entry("lib".into()).or_default();
        let local: BTreeMap<String, String> = [
            ("app".to_string(), "1.0.0".to_string()),
            ("lib".to_string(), "0.1.0".to_string()),
        ]
        .into_iter()
        .collect();
        let published: BTreeMap<String, Option<String>> = [
            ("app".to_string(), Some("0.9.0".to_string())),
            ("lib".to_string(), Some("0.2.0".to_string())),
        ]
        .into_iter()
        .collect();
        let dag = ReleaseDepDag::assemble(&[a, b], &local, &published);
        let mut pane = ReleaseDepDagPane::default();
        pane.build(&dag);
        let j = pane.state_json();
        let lib = j["dag"]["nodes"]
            .as_array()
            .unwrap()
            .iter()
            .find(|n| n["name"] == "lib")
            .unwrap();
        assert_eq!(
            lib["state"], "desync",
            "the behind-registry crate is a desync in the model"
        );
    }

    use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};

    fn sym(crate_name: &str, item_name: &str) -> SymbolRow {
        SymbolRow {
            crate_name: crate_name.into(),
            module_path: String::new(),
            item_kind: "fn".into(),
            item_name: item_name.into(),
            visibility: "pub".into(),
            file: "lib.rs".into(),
            line: 1,
            doc_lines: 0,
            signature: None,
        }
    }
    fn call(crate_name: &str, caller: &str, callee: &str) -> CallEdgeRow {
        CallEdgeRow {
            crate_name: crate_name.into(),
            caller_path: caller.into(),
            callee_ident: callee.into(),
            call_kind: "call".into(),
            file: "lib.rs".into(),
            line: 1,
        }
    }

    #[test]
    fn deep_rollup_derives_cross_crate_edges_from_call_graph() {
        // `base` defines `open`; `app` (crate) has a fn that calls `open`.
        let symbols = vec![sym("base", "open"), sym("app", "run")];
        let calls = vec![
            call("app", "app::run", "base::open"), // cross-crate → app → base
            call("app", "app::run", "run"),        // self-crate → skipped
        ];
        let view = KnowledgeView::new(symbols, calls);
        let produced: BTreeSet<String> = ["app".to_string(), "base".to_string()]
            .into_iter()
            .collect();
        let edges = deep_edges_from_view(&view, &produced);
        assert_eq!(edges.len(), 1, "one cross-crate edge: app → base");
        assert_eq!(edges[0].from, "app");
        assert_eq!(edges[0].to, "base");
        assert_eq!(edges[0].via, vec!["open".to_string()]);
    }

    #[test]
    fn deep_rollup_skips_ambiguous_callee() {
        // `helper` is defined in BOTH produced crates → ambiguous, no edge.
        let symbols = vec![sym("base", "helper"), sym("util", "helper")];
        let calls = vec![call("app", "app::run", "helper")];
        let view = KnowledgeView::new(symbols, calls);
        let produced: BTreeSet<String> =
            ["app".to_string(), "base".to_string(), "util".to_string()]
                .into_iter()
                .collect();
        assert!(deep_edges_from_view(&view, &produced).is_empty());
    }

    #[test]
    fn deep_mode_toggle_switches_the_active_edge_set() {
        let dag = chain_dag(); // thin: app → lib → base (2 direct edges)
        let mut pane = ReleaseDepDagPane::default();
        // A single symbol-derived edge that the cargo graph does NOT have.
        pane.set_deep_edges(vec![CrateDepEdge {
            from: "app".into(),
            to: "base".into(),
            via: vec!["open".into()],
        }]);
        pane.build(&dag);
        let j = pane.state_json();
        assert_eq!(j["mode"], "thin");
        assert_eq!(
            j["layout"]["direct_edges"], 2,
            "THIN shows the 2 cargo edges"
        );
        assert_eq!(j["deep_edge_count"], 1);

        pane.toggle_mode();
        pane.build(&dag);
        let j = pane.state_json();
        assert_eq!(j["mode"], "deep");
        assert_eq!(
            j["layout"]["node_count"], 3,
            "same crate nodes in both modes"
        );
        assert_eq!(
            j["layout"]["direct_edges"], 1,
            "DEEP shows the 1 symbol edge"
        );
    }
}