1use std::collections::{BTreeMap, BTreeSet, VecDeque};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DepEdge {
28 pub from: String,
29 pub to: String,
30 pub via: Vec<String>,
32}
33
34impl DepEdge {
35 pub fn new(from: impl Into<String>, to: impl Into<String>, via: &[&str]) -> Self {
37 Self { from: from.into(), to: to.into(), via: via.iter().map(|s| s.to_string()).collect() }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum EdgeClass {
44 Direct,
46 Transitive,
49}
50
51impl EdgeClass {
52 pub fn as_str(self) -> &'static str {
53 match self {
54 EdgeClass::Direct => "direct",
55 EdgeClass::Transitive => "transitive",
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
63pub struct LaidEdge {
64 pub from: String,
65 pub to: String,
66 pub class: EdgeClass,
67 pub via: Vec<String>,
69 pub hops: usize,
72}
73
74#[derive(Debug, Clone)]
76pub struct LaidNode {
77 pub repo: String,
78 pub col: usize,
79 pub row: usize,
80 pub collapsed: bool,
82}
83
84#[derive(Debug, Clone)]
86pub struct DepGraphLayout {
87 pub deep: bool,
89 pub nodes: Vec<LaidNode>,
90 pub edges: Vec<LaidEdge>,
91 collapsed: BTreeSet<String>,
92}
93
94impl DepGraphLayout {
95 pub fn build(repos: &[String], edges: &[DepEdge], deep: bool, collapsed: &BTreeSet<String>) -> Self {
104 let repo_set: BTreeSet<&str> = repos.iter().map(String::as_str).collect();
105 let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
106 let mut via_of: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
107 for e in edges {
108 if !repo_set.contains(e.from.as_str()) || !repo_set.contains(e.to.as_str()) {
109 continue;
110 }
111 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
112 via_of.insert((e.from.as_str(), e.to.as_str()), e.via.clone());
113 }
114
115 let roots: Vec<&str> = repos
117 .iter()
118 .map(String::as_str)
119 .filter(|r| !edges.iter().any(|e| e.to == **r && repo_set.contains(e.from.as_str())))
120 .collect();
121 let mut visible: BTreeSet<&str> = BTreeSet::new();
122 let mut q: VecDeque<&str> = VecDeque::new();
123 for r in &roots {
124 if visible.insert(r) {
125 q.push_back(r);
126 }
127 }
128 if visible.is_empty() {
129 for r in repos {
130 visible.insert(r.as_str());
131 q.push_back(r.as_str());
132 }
133 }
134 while let Some(cur) = q.pop_front() {
135 if collapsed.contains(cur) {
136 continue;
137 }
138 if let Some(children) = adj.get(cur) {
139 for &c in children {
140 if visible.insert(c) {
141 q.push_back(c);
142 }
143 }
144 }
145 }
146 for c in collapsed {
147 if repo_set.contains(c.as_str()) {
148 visible.insert(c.as_str());
149 }
150 }
151
152 let visible_repos: Vec<String> =
153 repos.iter().filter(|r| visible.contains(r.as_str())).cloned().collect();
154
155 let rank = longest_path_rank(&visible_repos, &adj);
156 let mut by_col: BTreeMap<usize, Vec<String>> = BTreeMap::new();
157 for r in &visible_repos {
158 by_col.entry(*rank.get(r.as_str()).unwrap_or(&0)).or_default().push(r.clone());
159 }
160 let mut nodes: Vec<LaidNode> = Vec::new();
161 for (&col, repos_in_col) in &by_col {
162 for (row, repo) in repos_in_col.iter().enumerate() {
163 nodes.push(LaidNode { repo: repo.clone(), col, row, collapsed: collapsed.contains(repo) });
164 }
165 }
166 nodes.sort_by(|a, b| a.repo.cmp(&b.repo));
167
168 let vis_set: BTreeSet<&str> = visible_repos.iter().map(String::as_str).collect();
169 let mut laid: Vec<LaidEdge> = Vec::new();
170 let mut direct_pairs: BTreeSet<(String, String)> = BTreeSet::new();
171 for e in edges {
172 if vis_set.contains(e.from.as_str()) && vis_set.contains(e.to.as_str()) {
173 if collapsed.contains(e.from.as_str()) {
174 continue;
175 }
176 direct_pairs.insert((e.from.clone(), e.to.clone()));
177 laid.push(LaidEdge {
178 from: e.from.clone(),
179 to: e.to.clone(),
180 class: EdgeClass::Direct,
181 via: via_of.get(&(e.from.as_str(), e.to.as_str())).cloned().unwrap_or_default(),
182 hops: 1,
183 });
184 }
185 }
186 if deep {
187 for from in &visible_repos {
188 if collapsed.contains(from) {
189 continue;
190 }
191 for (to, hops) in bfs_distances(from, &adj) {
192 if hops < 2 || !vis_set.contains(to.as_str()) {
193 continue;
194 }
195 if direct_pairs.contains(&(from.clone(), to.clone())) {
196 continue;
197 }
198 laid.push(LaidEdge {
199 from: from.clone(),
200 to: to.clone(),
201 class: EdgeClass::Transitive,
202 via: Vec::new(),
203 hops,
204 });
205 }
206 }
207 }
208
209 Self { deep, nodes, edges: laid, collapsed: collapsed.clone() }
210 }
211
212 pub fn transitive_closure(repo: &str, edges: &[DepEdge]) -> BTreeSet<String> {
215 let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
216 for e in edges {
217 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
218 }
219 let mut seen: BTreeSet<String> = BTreeSet::new();
220 let mut q: VecDeque<&str> = VecDeque::new();
221 q.push_back(repo);
222 while let Some(cur) = q.pop_front() {
223 if let Some(children) = adj.get(cur) {
224 for &c in children {
225 if seen.insert(c.to_string()) {
226 q.push_back(c);
227 }
228 }
229 }
230 }
231 seen.remove(repo);
232 seen
233 }
234
235 pub fn visible(&self) -> Vec<String> {
237 self.nodes.iter().map(|n| n.repo.clone()).collect()
238 }
239
240 pub fn position(&self, repo: &str) -> Option<(usize, usize)> {
242 self.nodes.iter().find(|n| n.repo == repo).map(|n| (n.col, n.row))
243 }
244
245 pub fn direct_edges(&self) -> usize {
246 self.edges.iter().filter(|e| e.class == EdgeClass::Direct).count()
247 }
248
249 pub fn transitive_edges(&self) -> usize {
250 self.edges.iter().filter(|e| e.class == EdgeClass::Transitive).count()
251 }
252
253 pub fn state_json(&self) -> serde_json::Value {
256 serde_json::json!({
257 "deep": self.deep,
258 "node_count": self.nodes.len(),
259 "direct_edges": self.direct_edges(),
260 "transitive_edges": self.transitive_edges(),
261 "collapsed": self.collapsed.iter().cloned().collect::<Vec<_>>(),
262 "nodes": self.nodes.iter().map(|n| serde_json::json!({
263 "repo": n.repo, "col": n.col, "row": n.row, "collapsed": n.collapsed,
264 })).collect::<Vec<_>>(),
265 "edges": self.edges.iter().map(|e| serde_json::json!({
266 "from": e.from, "to": e.to, "class": e.class.as_str(), "hops": e.hops, "via": e.via,
267 })).collect::<Vec<_>>(),
268 })
269 }
270}
271
272fn longest_path_rank<'a>(repos: &'a [String], adj: &BTreeMap<&'a str, Vec<&'a str>>) -> BTreeMap<String, usize> {
276 let mut memo: BTreeMap<String, usize> = BTreeMap::new();
277 fn depth<'a>(
278 r: &'a str,
279 adj: &BTreeMap<&'a str, Vec<&'a str>>,
280 memo: &mut BTreeMap<String, usize>,
281 stack: &mut BTreeSet<String>,
282 ) -> usize {
283 if let Some(&d) = memo.get(r) {
284 return d;
285 }
286 if !stack.insert(r.to_string()) {
287 return 0; }
289 let mut best = 0;
290 if let Some(children) = adj.get(r) {
291 for &c in children {
292 best = best.max(1 + depth(c, adj, memo, stack));
293 }
294 }
295 stack.remove(r);
296 memo.insert(r.to_string(), best);
297 best
298 }
299 let mut stack = BTreeSet::new();
300 for r in repos {
301 depth(r.as_str(), adj, &mut memo, &mut stack);
303 }
304 let max_depth = memo.values().copied().max().unwrap_or(0);
305 repos.iter().map(|r| (r.clone(), max_depth - memo.get(r).copied().unwrap_or(0))).collect()
306}
307
308fn bfs_distances<'a>(start: &str, adj: &BTreeMap<&'a str, Vec<&'a str>>) -> Vec<(String, usize)> {
310 let mut dist: BTreeMap<String, usize> = BTreeMap::new();
311 let mut q: VecDeque<(String, usize)> = VecDeque::new();
312 q.push_back((start.to_string(), 0));
313 dist.insert(start.to_string(), 0);
314 while let Some((cur, d)) = q.pop_front() {
315 if let Some(children) = adj.get(cur.as_str()) {
316 for &c in children {
317 if !dist.contains_key(c) {
318 dist.insert(c.to_string(), d + 1);
319 q.push_back((c.to_string(), d + 1));
320 }
321 }
322 }
323 }
324 dist.into_iter().filter(|(r, _)| r != start).collect()
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn abc() -> (Vec<String>, Vec<DepEdge>) {
333 let repos = vec!["A".to_string(), "B".to_string(), "C".to_string()];
334 let edges =
335 vec![DepEdge::new("A", "B", &["b_crate"]), DepEdge::new("B", "C", &["c_crate"]), DepEdge::new("A", "C", &["c_direct"])];
336 (repos, edges)
337 }
338
339 #[test]
340 fn transitive_closure_is_correct() {
341 let (_, edges) = abc();
342 assert_eq!(
343 DepGraphLayout::transitive_closure("A", &edges),
344 ["B", "C"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>()
345 );
346 assert_eq!(
347 DepGraphLayout::transitive_closure("B", &edges),
348 ["C"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>()
349 );
350 assert!(DepGraphLayout::transitive_closure("C", &edges).is_empty());
351 }
352
353 #[test]
354 fn direct_mode_classifies_every_edge_direct() {
355 let (repos, edges) = abc();
356 let lay = DepGraphLayout::build(&repos, &edges, false, &BTreeSet::new());
357 assert_eq!(lay.direct_edges(), 3);
358 assert_eq!(lay.transitive_edges(), 0);
359 for (f, t) in [("A", "B"), ("B", "C"), ("A", "C")] {
360 let e = lay.edges.iter().find(|e| e.from == f && e.to == t).expect("edge");
361 assert_eq!(e.class, EdgeClass::Direct);
362 assert_eq!(e.hops, 1);
363 }
364 let ab = lay.edges.iter().find(|e| e.from == "A" && e.to == "B").unwrap();
366 assert_eq!(ab.via, vec!["b_crate".to_string()]);
367 }
368
369 #[test]
370 fn deep_mode_synthesises_only_genuinely_transitive_edges() {
371 let repos = vec!["A".to_string(), "B".to_string(), "C".to_string()];
372 let edges = vec![DepEdge::new("A", "B", &["b"]), DepEdge::new("B", "C", &["c"])];
373 let lay = DepGraphLayout::build(&repos, &edges, true, &BTreeSet::new());
374 assert_eq!(lay.direct_edges(), 2);
375 assert_eq!(lay.transitive_edges(), 1);
376 let t = lay.edges.iter().find(|e| e.class == EdgeClass::Transitive).expect("a transitive edge");
377 assert_eq!((t.from.as_str(), t.to.as_str()), ("A", "C"));
378 assert_eq!(t.hops, 2);
379 }
380
381 #[test]
382 fn deep_mode_does_not_duplicate_an_existing_direct_edge() {
383 let (repos, edges) = abc();
384 let lay = DepGraphLayout::build(&repos, &edges, true, &BTreeSet::new());
385 let ac: Vec<&LaidEdge> = lay.edges.iter().filter(|e| e.from == "A" && e.to == "C").collect();
386 assert_eq!(ac.len(), 1);
387 assert_eq!(ac[0].class, EdgeClass::Direct);
388 }
389
390 #[test]
391 fn layout_places_deps_right_of_consumers() {
392 let (repos, edges) = abc();
393 let lay = DepGraphLayout::build(&repos, &edges, false, &BTreeSet::new());
394 let col = |r: &str| lay.position(r).unwrap().0;
395 assert!(col("A") < col("B"));
396 assert!(col("B") < col("C"));
397 assert!(col("A") < col("C"));
398 }
399
400 #[test]
401 fn state_json_exposes_positions_and_edge_classes() {
402 let (repos, edges) = abc();
403 let lay = DepGraphLayout::build(&repos, &edges, true, &BTreeSet::new());
404 let j = lay.state_json();
405 assert_eq!(j["deep"], serde_json::Value::Bool(true));
406 assert_eq!(j["node_count"], 3);
407 let nodes = j["nodes"].as_array().unwrap();
408 assert!(nodes.iter().all(|n| n["col"].is_number() && n["row"].is_number()));
409 let edges_j = j["edges"].as_array().unwrap();
410 assert!(edges_j.iter().all(|e| matches!(e["class"].as_str(), Some("direct") | Some("transitive"))));
411 assert_eq!(j["direct_edges"], 3);
412 assert_eq!(j["transitive_edges"], 0);
413 }
414
415 #[test]
416 fn collapsing_a_node_hides_its_exclusive_subtree() {
417 let repos = vec!["A".to_string(), "B".to_string(), "C".to_string()];
418 let edges = vec![DepEdge::new("A", "B", &["b"]), DepEdge::new("B", "C", &["c"])];
419 let collapsed: BTreeSet<String> = ["B".to_string()].into_iter().collect();
420 let lay = DepGraphLayout::build(&repos, &edges, false, &collapsed);
421 let vis = lay.visible();
422 assert!(vis.contains(&"A".to_string()));
423 assert!(vis.contains(&"B".to_string()));
424 assert!(!vis.contains(&"C".to_string()));
425 assert!(lay.edges.iter().any(|e| e.from == "A" && e.to == "B"));
426 assert!(!lay.edges.iter().any(|e| e.from == "B" && e.to == "C"));
427 }
428
429 #[test]
430 fn collapsing_keeps_a_node_with_an_alternate_open_path() {
431 let repos = ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect::<Vec<_>>();
432 let edges = vec![
433 DepEdge::new("A", "B", &["b"]),
434 DepEdge::new("A", "C", &["c"]),
435 DepEdge::new("B", "D", &["d"]),
436 DepEdge::new("C", "D", &["d"]),
437 ];
438 let collapsed: BTreeSet<String> = ["B".to_string()].into_iter().collect();
439 let lay = DepGraphLayout::build(&repos, &edges, false, &collapsed);
440 assert!(lay.visible().contains(&"D".to_string()));
441 assert!(!lay.edges.iter().any(|e| e.from == "B" && e.to == "D"));
442 assert!(lay.edges.iter().any(|e| e.from == "C" && e.to == "D"));
443 }
444}