nornir 0.5.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **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
//! [`crate::release::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::BTreeSet;

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

use crate::release::depdag::ReleaseDepDag;
use crate::release::registry::RegistryState;

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

/// 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,
    built: bool,
    node_count: usize,
    direct_edges: usize,
    transitive_edges: usize,
    waves: Vec<Vec<String>>,
    cycle: Vec<String>,
    publishable: bool,
    /// Cached `DepGraphLayout::state_json` (the laid-out structure the robot reads).
    thin_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
    }

    /// (Re)build the facet from an assembled [`ReleaseDepDag`], honouring the current
    /// `show_transitive` toggle.
    pub fn build(&mut self, dag: &ReleaseDepDag) {
        let repos: Vec<String> = dag.nodes.iter().map(|n| n.name.clone()).collect();
        let edges: Vec<DepEdge> = dag
            .edges
            .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, &crate::release::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.thin_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!(
                "nodes={} direct={} transitive={} waves={} cycle={}",
                self.node_count,
                self.direct_edges,
                self.transitive_edges,
                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;
    }

    /// 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;
        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"]`. The nested
    /// `thin` block is the facett `DepGraphLayout::state_json` (positions + classified
    /// edges); `dag` is the registry-annotated model.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "mode": "thin",
            "show_transitive": self.show_transitive,
            "publishable": self.publishable,
            "waves": self.waves,
            "cycle": self.cycle,
            "thin": self.thin_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["thin"]["node_count"], 3);
        assert_eq!(j["thin"]["direct_edges"], 2);
        assert_eq!(j["thin"]["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["thin"]["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");
    }
}