use std::collections::{BTreeMap, BTreeSet};
use crate::release::doctor::RepoGraph;
use crate::release::graph_math::{self, Adj};
use crate::release::registry::{classify, RegistryState};
#[derive(Debug, Clone, PartialEq)]
pub struct CrateNode {
pub name: String,
pub repo: String,
pub local_version: Option<String>,
pub published_version: Option<String>,
pub state: RegistryState,
pub in_cycle: bool,
pub wave: Option<usize>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CrateDepEdge {
pub from: String,
pub to: String,
pub via: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReleaseDepDag {
pub nodes: Vec<CrateNode>,
pub edges: Vec<CrateDepEdge>,
pub cycle: Vec<String>,
pub waves: Vec<Vec<String>>,
}
#[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 {
#[must_use]
pub fn assemble(
graphs: &[RepoGraph],
local_versions: &BTreeMap<String, String>,
published: &BTreeMap<String, Option<String>>,
) -> Self {
let (owner, adj) = crate_adjacency(graphs);
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 }
}
#[must_use]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
#[must_use]
pub fn edge_count(&self) -> usize {
self.edges.len()
}
#[must_use]
pub fn is_publishable(&self) -> bool {
self.cycle.is_empty()
}
#[must_use]
pub fn topo_order(&self) -> Vec<String> {
self.waves.iter().flatten().cloned().collect()
}
#[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
}
#[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<_>>(),
})
}
}
#[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::*;
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");
assert_eq!(dag.waves, vec![vec!["base".to_string()], vec!["lib".to_string()], vec!["app".to_string()]]);
assert_eq!(dag.topo_order(), vec!["base", "lib", "app"]);
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())), ("lib".to_string(), Some("0.2.0".to_string())), ("base".to_string(), None), ]
.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() {
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);
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));
}
}