use std::collections::{BTreeMap, BTreeSet};
use facett_graphview::{
Color, Decorations, DepEdge, DepGraphLayout, EdgeClass, GraphEdge, GraphModel, GraphNode,
NodeDecoration, Pos,
};
use crate::knowledge::query::KnowledgeView;
use crate::release::depdag::{CrateDepEdge, ReleaseDepDag};
use crate::release::registry::RegistryState;
const COL_GAP: f32 = 230.0;
const ROW_GAP: f32 = 74.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DepDagMode {
#[default]
Thin,
Deep,
}
impl DepDagMode {
pub fn as_str(self) -> &'static str {
match self {
DepDagMode::Thin => "thin",
DepDagMode::Deep => "deep",
}
}
}
fn last_seg(s: &str) -> &str {
s.rsplit("::").next().unwrap_or(s)
}
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(),
}
}
#[must_use]
pub fn deep_edges_from_view(view: &KnowledgeView, produced: &BTreeSet<String>) -> Vec<CrateDepEdge> {
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());
}
}
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 };
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()
}
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),
}
}
#[derive(Default)]
pub struct ReleaseDepDagPane {
view: Option<facett_graphview::DecoratedGraphView>,
pub show_transitive: bool,
pub mode: DepDagMode,
deep_edges: Vec<CrateDepEdge>,
built: bool,
node_count: usize,
direct_edges: usize,
transitive_edges: usize,
deep_edge_count: usize,
waves: Vec<Vec<String>>,
cycle: Vec<String>,
publishable: bool,
layout_json: serde_json::Value,
dag_json: serde_json::Value,
}
impl ReleaseDepDagPane {
pub fn node_count(&self) -> usize {
self.node_count
}
pub fn is_built(&self) -> bool {
self.built
}
pub fn deep_edge_count(&self) -> usize {
self.deep_edge_count
}
pub fn set_deep_edges(&mut self, edges: Vec<CrateDepEdge>) {
self.deep_edge_count = edges.len();
self.deep_edges = edges;
}
pub fn build(&mut self, dag: &ReleaseDepDag) {
let repos: Vec<String> = dag.nodes.iter().map(|n| n.name.clone()).collect();
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());
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 {
ring: in_cycle.then(|| Color::rgb(230, 70, 60)),
badge: Some(wave.map(|w| w.to_string()).unwrap_or_else(|| "—".to_string())),
badge_color: Some(fill),
scale: None,
},
);
}
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()
),
);
}
pub fn toggle_transitive(&mut self) {
self.show_transitive = !self.show_transitive;
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
DepDagMode::Thin => DepDagMode::Deep,
DepDagMode::Deep => DepDagMode::Thin,
};
}
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");
});
}
}
}
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,
"publishable": self.publishable,
"waves": self.waves,
"cycle": self.cycle,
"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);
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);
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() {
let symbols = vec![sym("base", "open"), sym("app", "run")];
let calls = vec![
call("app", "app::run", "base::open"), call("app", "app::run", "run"), ];
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() {
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(); let mut pane = ReleaseDepDagPane::default();
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");
}
}