nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **Release dependency-DAG** — the crate publish-order graph the release doctor
//! topo-sorts, assembled as a neutral, egui-free **graph model** (nodes = crates,
//! edges = crate→crate depends-on) annotated with each crate's **registry state**
//! (local-vs-published: will-publish / already-published / desync / unpublished).
//!
//! This is the *deep* half of the release dep-DAG UI (`nornir viz`): it rolls the
//! per-repo [`RepoGraph`] facts up to the CRATE granularity cargo actually
//! publishes at, reuses the release core's condensation math
//! ([`graph_math`](crate::release::graph_math)) for the publish **waves** + real
//! **cycles**, and folds in the [`registry`](crate::release::registry) check so a
//! desync (the local tree is BEHIND crates.io) is visible on the node. A *thin*
//! renderer (`facett-graphview`'s `DepGraphLayout` + `DecoratedGraphView` in
//! nornir's viz) maps it onto pixels.
//!
//! Pure + deterministic (BTree-ordered, offline): the registry probe is injected
//! as data (a `published` map), so the assembly + every wave/cycle/state fact is
//! golden-testable with no network.

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

use crate::release::doctor::RepoGraph;
use crate::release::graph_math::{self, Adj};
use crate::release::registry::{classify, RegistryState};

/// One crate node in the publish DAG.
#[derive(Debug, Clone, PartialEq)]
pub struct CrateNode {
    /// Crate name (the publish unit).
    pub name: String,
    /// The repo that produces it.
    pub repo: String,
    /// Local (workspace) version, if known.
    pub local_version: Option<String>,
    /// Max published (non-yanked) version on the registry, if probed.
    pub published_version: Option<String>,
    /// Local-vs-published verdict ([`RegistryState::Unknown`] until a `published`
    /// map is supplied at assembly).
    pub state: RegistryState,
    /// True when this crate sits on a real (crate-level) publish cycle — unshippable
    /// until the cycle is cut.
    pub in_cycle: bool,
    /// Publish wave index (antichain): 0 = publishable first (depends on nothing
    /// in-workspace). `None` for a crate stuck on a cycle.
    pub wave: Option<usize>,
}

/// One directed dependency edge `from → to` (`from` depends on `to`), justified by
/// the depended-on crate name(s).
#[derive(Debug, Clone, PartialEq)]
pub struct CrateDepEdge {
    pub from: String,
    pub to: String,
    /// The crate name(s) justifying the edge (for a crate dep, the dep itself).
    pub via: Vec<String>,
}

/// The assembled release dependency-DAG.
#[derive(Debug, Clone, Default)]
pub struct ReleaseDepDag {
    pub nodes: Vec<CrateNode>,
    pub edges: Vec<CrateDepEdge>,
    /// Crates on a real publish cycle (empty = a clean, publishable DAG).
    pub cycle: Vec<String>,
    /// Parallelizable publish waves (antichains), deps-first.
    pub waves: Vec<Vec<String>>,
}

/// The CRATE-level order graph: crate name → owning repo, and crate name → its
/// in-workspace order-gating dep crate names (filtered to crates produced
/// somewhere). Mirrors the doctor's private `crate_graph`, over the public
/// [`RepoGraph`] facts, so the DAG assembly reuses the exact granularity cargo
/// publishes at. Every produced crate is keyed (even with no deps).
#[must_use]
pub fn crate_adjacency(graphs: &[RepoGraph]) -> (BTreeMap<String, String>, Adj) {
    let produced: BTreeSet<String> =
        graphs.iter().flat_map(|g| g.crate_deps.keys().cloned()).collect();
    let mut owner: BTreeMap<String, String> = BTreeMap::new();
    let mut adj: Adj = BTreeMap::new();
    for g in graphs {
        for (c, deps) in &g.crate_deps {
            owner.entry(c.clone()).or_insert_with(|| g.repo.clone());
            let e = adj.entry(c.clone()).or_default();
            for d in deps {
                if d != c && produced.contains(d) {
                    e.insert(d.clone());
                }
            }
        }
    }
    (owner, adj)
}

impl ReleaseDepDag {
    /// Assemble the crate publish-DAG from the per-repo graphs, annotating each node
    /// with its registry state.
    ///
    /// * `local_versions` — crate name → local (workspace) version (`classify` needs
    ///   it to compare against the registry). Missing ⇒ state `Unknown`.
    /// * `published` — crate name → the max published version on the registry
    ///   (`None` inside the map ⇒ unpublished). A crate absent from the map is left
    ///   `Unknown` (the registry wasn't probed for it).
    ///
    /// The `published` map is data (not a live probe), so this is fully offline and
    /// deterministic; the viz supplies a real probe, tests supply a fixture.
    #[must_use]
    pub fn assemble(
        graphs: &[RepoGraph],
        local_versions: &BTreeMap<String, String>,
        published: &BTreeMap<String, Option<String>>,
    ) -> Self {
        let (owner, adj) = crate_adjacency(graphs);

        // Real crate-level publish cycles + parallelizable waves (reuse the release
        // core's condensation math — the same the doctor/stage read).
        let cycle_members: BTreeSet<String> =
            graph_math::cycles(&adj).into_iter().flatten().collect();
        let waves = graph_math::condense(&adj).waves();
        let mut wave_of: BTreeMap<String, usize> = BTreeMap::new();
        for (i, wave) in waves.iter().enumerate() {
            for c in wave {
                wave_of.entry(c.clone()).or_insert(i);
            }
        }

        let mut nodes: Vec<CrateNode> = owner
            .keys()
            .map(|name| {
                let local_version = local_versions.get(name).cloned();
                let published_version = published.get(name).cloned().flatten();
                let state = match local_versions.get(name) {
                    Some(local) if published.contains_key(name) => {
                        classify(local, published_version.as_deref())
                    }
                    _ => RegistryState::Unknown,
                };
                CrateNode {
                    name: name.clone(),
                    repo: owner.get(name).cloned().unwrap_or_default(),
                    local_version,
                    published_version,
                    state,
                    in_cycle: cycle_members.contains(name),
                    wave: wave_of.get(name).copied(),
                }
            })
            .collect();
        nodes.sort_by(|a, b| a.name.cmp(&b.name));

        let mut edges: Vec<CrateDepEdge> = Vec::new();
        for (from, deps) in &adj {
            for to in deps {
                edges.push(CrateDepEdge { from: from.clone(), to: to.clone(), via: vec![to.clone()] });
            }
        }
        edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));

        let cycle: Vec<String> = cycle_members.into_iter().collect();
        Self { nodes, edges, cycle, waves }
    }

    /// Node count.
    #[must_use]
    pub fn node_count(&self) -> usize {
        self.nodes.len()
    }
    /// Edge count.
    #[must_use]
    pub fn edge_count(&self) -> usize {
        self.edges.len()
    }
    /// True when the DAG has no real publish cycle (fully publishable).
    #[must_use]
    pub fn is_publishable(&self) -> bool {
        self.cycle.is_empty()
    }

    /// A topological publish order (dependencies first): the waves flattened. Empty
    /// only for an empty graph; crates on a cycle are omitted (they have no wave).
    #[must_use]
    pub fn topo_order(&self) -> Vec<String> {
        self.waves.iter().flatten().cloned().collect()
    }

    /// Per-state node tally (for the ribbon/legend + robot assertions).
    #[must_use]
    pub fn state_counts(&self) -> BTreeMap<&'static str, usize> {
        let mut m: BTreeMap<&'static str, usize> = BTreeMap::new();
        for n in &self.nodes {
            *m.entry(state_str(n.state)).or_default() += 1;
        }
        m
    }

    /// The robot-observable introspection blob (LAW 6): counts, waves, cycle, the
    /// per-state tally, the publish order, and every node's registry verdict.
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "node_count": self.node_count(),
            "edge_count": self.edge_count(),
            "publishable": self.is_publishable(),
            "cycle": self.cycle,
            "cycle_count": self.cycle.len(),
            "wave_count": self.waves.len(),
            "waves": self.waves,
            "state_counts": self.state_counts().into_iter().collect::<BTreeMap<_, _>>(),
            "topo_order": self.topo_order(),
            "nodes": self.nodes.iter().map(|n| serde_json::json!({
                "name": n.name,
                "repo": n.repo,
                "local": n.local_version,
                "published": n.published_version,
                "state": state_str(n.state),
                "in_cycle": n.in_cycle,
                "wave": n.wave,
            })).collect::<Vec<_>>(),
            "edges": self.edges.iter().map(|e| serde_json::json!({
                "from": e.from, "to": e.to, "via": e.via,
            })).collect::<Vec<_>>(),
        })
    }
}

/// A short, stable token for a [`RegistryState`] (it doesn't derive `Serialize`).
#[must_use]
pub fn state_str(s: RegistryState) -> &'static str {
    match s {
        RegistryState::WillPublish => "will_publish",
        RegistryState::AlreadyPublished => "already_published",
        RegistryState::Desync => "desync",
        RegistryState::Unpublished => "unpublished",
        RegistryState::Unknown => "unknown",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A 3-repo chain: `app`(repo A) → `lib`(repo B) → `base`(repo C). `app`
    /// depends on `lib`, `lib` on `base` — a clean crate DAG.
    fn chain_graphs() -> Vec<RepoGraph> {
        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();
        vec![a, b, c]
    }

    #[test]
    fn assembles_nodes_edges_and_publish_waves() {
        let graphs = chain_graphs();
        let dag = ReleaseDepDag::assemble(&graphs, &BTreeMap::new(), &BTreeMap::new());
        assert_eq!(dag.node_count(), 3, "app, lib, base");
        assert_eq!(dag.edge_count(), 2, "app→lib, lib→base");
        assert!(dag.is_publishable(), "a clean DAG has no cycle");
        // waves: base first (depends on nothing), then lib, then app.
        assert_eq!(dag.waves, vec![vec!["base".to_string()], vec!["lib".to_string()], vec!["app".to_string()]]);
        // topo order is deps-first.
        assert_eq!(dag.topo_order(), vec!["base", "lib", "app"]);
        // the owning repo is recorded per crate.
        let app = dag.nodes.iter().find(|n| n.name == "app").unwrap();
        assert_eq!(app.repo, "A");
        assert_eq!(app.wave, Some(2));
    }

    #[test]
    fn registry_state_annotates_will_publish_and_desync() {
        let graphs = chain_graphs();
        let local: BTreeMap<String, String> = [
            ("app".to_string(), "1.0.0".to_string()),
            ("lib".to_string(), "0.1.0".to_string()),
            ("base".to_string(), "2.0.0".to_string()),
        ]
        .into_iter()
        .collect();
        let published: BTreeMap<String, Option<String>> = [
            ("app".to_string(), Some("0.9.0".to_string())), // local ahead → will publish
            ("lib".to_string(), Some("0.2.0".to_string())), // local BEHIND → desync (the bug)
            ("base".to_string(), None),                     // unpublished → first publish
        ]
        .into_iter()
        .collect();
        let dag = ReleaseDepDag::assemble(&graphs, &local, &published);
        let by = |n: &str| dag.nodes.iter().find(|x| x.name == n).unwrap();
        assert_eq!(by("app").state, RegistryState::WillPublish);
        assert_eq!(by("lib").state, RegistryState::Desync);
        assert_eq!(by("base").state, RegistryState::Unpublished);

        let counts = dag.state_counts();
        assert_eq!(counts.get("will_publish"), Some(&1));
        assert_eq!(counts.get("desync"), Some(&1));
        assert_eq!(counts.get("unpublished"), Some(&1));

        let j = dag.state_json();
        assert_eq!(j["node_count"], 3);
        assert_eq!(j["publishable"], true);
        let lib = j["nodes"].as_array().unwrap().iter().find(|n| n["name"] == "lib").unwrap();
        assert_eq!(lib["state"], "desync");
        assert_eq!(lib["local"], "0.1.0");
        assert_eq!(lib["published"], "0.2.0");
    }

    #[test]
    fn a_real_cycle_is_flagged_unpublishable() {
        // app → lib → app (a genuine crate-level cycle).
        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("app".into());
        let dag = ReleaseDepDag::assemble(&[a, b], &BTreeMap::new(), &BTreeMap::new());
        assert!(!dag.is_publishable(), "a cycle blocks publish");
        assert_eq!(dag.cycle.len(), 2);
        // the SCC condenses to one super-node → both cycle crates share one wave.
        assert!(dag.nodes.iter().all(|n| n.in_cycle));
        assert_eq!(dag.waves, vec![vec!["app".to_string(), "lib".to_string()]]);
        assert!(dag.nodes.iter().all(|n| n.wave == Some(0)));
        assert_eq!(dag.state_json()["cycle_count"], 2);
    }

    #[test]
    fn unknown_state_without_a_registry_probe() {
        let dag = ReleaseDepDag::assemble(&chain_graphs(), &BTreeMap::new(), &BTreeMap::new());
        assert!(dag.nodes.iter().all(|n| n.state == RegistryState::Unknown));
        assert_eq!(dag.state_counts().get("unknown"), Some(&3));
    }
}