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