1#![deny(warnings)]
11
12use std::collections::{HashMap, HashSet, VecDeque};
13
14use serde::Serialize;
15
16use crate::catalog::store::{CatalogStore, EdgeKind};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum AssetType {
26 Agent,
27 Skill,
28 Role,
29 Mcp,
30 Rule,
31 Unknown,
32}
33
34#[derive(Debug, Clone, Serialize)]
36pub struct DependencyNode {
37 pub id: String,
38 pub asset_type: AssetType,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum EdgeType {
45 DependsOn,
47 Contains,
49 References,
51 Configures,
53}
54
55impl EdgeType {
56 fn from_edge_kind(kind: &EdgeKind) -> Self {
57 match kind {
58 EdgeKind::AgentSkill => EdgeType::DependsOn,
59 EdgeKind::RoleAgent => EdgeType::Contains,
60 EdgeKind::AgentMcp => EdgeType::References,
61 EdgeKind::AgentRule => EdgeType::Configures,
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize)]
68pub struct DependencyEdge {
69 pub from: String,
70 pub to: String,
71 pub edge_type: EdgeType,
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct BlastRadius {
77 pub dependents: Vec<String>,
79}
80
81pub struct DependencyGraph {
83 pub nodes: HashMap<String, DependencyNode>,
84 pub edges: Vec<DependencyEdge>,
85}
86
87impl DependencyGraph {
92 pub fn build(store: &CatalogStore) -> Self {
94 let mut nodes: HashMap<String, DependencyNode> = HashMap::new();
95
96 for a in &store.agents {
98 nodes.insert(
99 a.id.clone(),
100 DependencyNode {
101 id: a.id.clone(),
102 asset_type: AssetType::Agent,
103 },
104 );
105 }
106 for s in &store.skills {
107 nodes.insert(
108 s.id.clone(),
109 DependencyNode {
110 id: s.id.clone(),
111 asset_type: AssetType::Skill,
112 },
113 );
114 }
115 for rid in store.roles.keys() {
116 nodes.insert(
117 rid.clone(),
118 DependencyNode {
119 id: rid.clone(),
120 asset_type: AssetType::Role,
121 },
122 );
123 }
124 for m in &store.mcp_refs {
125 nodes.insert(
126 m.id.clone(),
127 DependencyNode {
128 id: m.id.clone(),
129 asset_type: AssetType::Mcp,
130 },
131 );
132 }
133 for r in &store.rules {
134 nodes.insert(
135 r.id.clone(),
136 DependencyNode {
137 id: r.id.clone(),
138 asset_type: AssetType::Rule,
139 },
140 );
141 }
142
143 let raw_edges = store.dependency_edges();
144 let mut edges: Vec<DependencyEdge> = Vec::with_capacity(raw_edges.len());
145
146 for (from, to, kind) in &raw_edges {
147 nodes.entry(from.clone()).or_insert_with(|| DependencyNode {
150 id: from.clone(),
151 asset_type: AssetType::Unknown,
152 });
153 nodes.entry(to.clone()).or_insert_with(|| DependencyNode {
154 id: to.clone(),
155 asset_type: AssetType::Unknown,
156 });
157
158 edges.push(DependencyEdge {
159 from: from.clone(),
160 to: to.clone(),
161 edge_type: EdgeType::from_edge_kind(kind),
162 });
163 }
164
165 edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
167
168 DependencyGraph { nodes, edges }
169 }
170
171 pub fn upstream(&self, asset_id: &str) -> Vec<&DependencyEdge> {
177 let mut result: Vec<&DependencyEdge> =
178 self.edges.iter().filter(|e| e.from == asset_id).collect();
179 result.sort_by(|a, b| a.to.cmp(&b.to));
180 result
181 }
182
183 pub fn downstream(&self, asset_id: &str) -> Vec<&DependencyEdge> {
185 let mut result: Vec<&DependencyEdge> =
186 self.edges.iter().filter(|e| e.to == asset_id).collect();
187 result.sort_by(|a, b| a.from.cmp(&b.from));
188 result
189 }
190
191 pub fn upstream_ids(&self, asset_id: &str) -> Vec<String> {
193 let mut ids: Vec<String> = self
194 .upstream(asset_id)
195 .iter()
196 .map(|e| e.to.clone())
197 .collect();
198 ids.sort();
199 ids
200 }
201
202 pub fn downstream_ids(&self, asset_id: &str) -> Vec<String> {
204 let mut ids: Vec<String> = self
205 .downstream(asset_id)
206 .iter()
207 .map(|e| e.from.clone())
208 .collect();
209 ids.sort();
210 ids
211 }
212
213 pub fn blast_radius(&self, asset_id: &str) -> BlastRadius {
218 let mut visited: HashSet<String> = HashSet::new();
219 let mut queue: VecDeque<String> = VecDeque::new();
220 queue.push_back(asset_id.to_string());
221
222 while let Some(current) = queue.pop_front() {
223 for edge in self.downstream(¤t) {
224 if visited.insert(edge.from.clone()) {
225 queue.push_back(edge.from.clone());
226 }
227 }
228 }
229
230 visited.remove(asset_id);
232
233 let mut dependents: Vec<String> = visited.into_iter().collect();
234 dependents.sort();
235 BlastRadius { dependents }
236 }
237
238 pub fn find_cycles(&self) -> Vec<Vec<String>> {
248 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
250 for edge in &self.edges {
251 adj.entry(edge.from.as_str())
252 .or_default()
253 .push(edge.to.as_str());
254 }
255 for neighbors in adj.values_mut() {
257 neighbors.sort_unstable();
258 }
259
260 let mut all_nodes: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
261 all_nodes.sort_unstable();
262
263 let mut color: HashMap<&str, u8> = HashMap::new();
265 let mut path: Vec<String> = Vec::new();
266 let mut cycles: Vec<Vec<String>> = Vec::new();
267
268 for &start in &all_nodes {
269 if color.get(start).copied().unwrap_or(0) == 0 {
270 Self::dfs_cycles(start, &adj, &mut color, &mut path, &mut cycles);
271 }
272 }
273
274 for c in &mut cycles {
276 if let Some(min_pos) = c
278 .iter()
279 .enumerate()
280 .min_by_key(|(_, v)| v.as_str())
281 .map(|(i, _)| i)
282 {
283 c.rotate_left(min_pos);
284 }
285 }
286 cycles.sort();
287 cycles.dedup();
288 cycles
289 }
290
291 fn dfs_cycles<'a>(
292 node: &'a str,
293 adj: &HashMap<&'a str, Vec<&'a str>>,
294 color: &mut HashMap<&'a str, u8>,
295 path: &mut Vec<String>,
296 cycles: &mut Vec<Vec<String>>,
297 ) {
298 color.insert(node, 1); path.push(node.to_string());
300
301 if let Some(neighbors) = adj.get(node) {
302 for &neighbor in neighbors {
303 let c = color.get(neighbor).copied().unwrap_or(0);
304 if c == 1 {
305 if let Some(pos) = path.iter().position(|s| s == neighbor) {
307 let cycle: Vec<String> = path[pos..].to_vec();
308 cycles.push(cycle);
309 }
310 } else if c == 0 {
311 Self::dfs_cycles(neighbor, adj, color, path, cycles);
312 }
313 }
314 }
315
316 path.pop();
317 color.insert(node, 2); }
319
320 pub fn render_ascii_tree(&self, focus_id: &str, max_depth: usize) -> String {
329 let mut out = String::new();
330 let mut visited: HashSet<String> = HashSet::new();
331 Self::render_node(self, focus_id, 0, max_depth, &mut visited, &mut out);
332 out
333 }
334
335 fn render_node(
336 graph: &DependencyGraph,
337 node_id: &str,
338 depth: usize,
339 max_depth: usize,
340 visited: &mut HashSet<String>,
341 out: &mut String,
342 ) {
343 let prefix = " ".repeat(depth);
344 if visited.contains(node_id) {
345 out.push_str(&format!("{}{} [cycle]\n", prefix, node_id));
346 return;
347 }
348 out.push_str(&format!("{}{}\n", prefix, node_id));
349
350 if depth >= max_depth {
351 return;
352 }
353
354 visited.insert(node_id.to_string());
355
356 let mut children: Vec<String> = graph
358 .edges
359 .iter()
360 .filter(|e| e.from == node_id)
361 .map(|e| e.to.clone())
362 .collect();
363 children.sort();
364 children.dedup();
365
366 for child in &children {
367 Self::render_node(graph, child, depth + 1, max_depth, visited, out);
368 }
369
370 visited.remove(node_id);
371 }
372
373 pub fn to_adjacency_json(&self) -> serde_json::Value {
390 let mut node_ids: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
392 node_ids.sort_unstable();
393 let nodes_array: Vec<serde_json::Value> = node_ids
394 .iter()
395 .map(|id| {
396 let node = &self.nodes[*id];
397 serde_json::json!({
398 "id": node.id,
399 "asset_type": node.asset_type,
400 })
401 })
402 .collect();
403
404 let mut adj_map: HashMap<&str, Vec<serde_json::Value>> = HashMap::new();
406 for edge in &self.edges {
407 adj_map
408 .entry(edge.from.as_str())
409 .or_default()
410 .push(serde_json::json!({
411 "to": edge.to,
412 "edge_type": edge.edge_type,
413 }));
414 }
415 for v in adj_map.values_mut() {
417 v.sort_by(|a, b| {
418 a["to"]
419 .as_str()
420 .unwrap_or("")
421 .cmp(b["to"].as_str().unwrap_or(""))
422 });
423 }
424
425 let mut adj_keys: Vec<&str> = adj_map.keys().copied().collect();
427 adj_keys.sort_unstable();
428 let mut adjacency = serde_json::Map::new();
429 for key in adj_keys {
430 adjacency.insert(
431 key.to_string(),
432 serde_json::Value::Array(adj_map[key].clone()),
433 );
434 }
435
436 serde_json::json!({
437 "nodes": nodes_array,
438 "adjacency": adjacency,
439 })
440 }
441}
442
443#[cfg(test)]
448mod tests {
449 use super::*;
450
451 fn graph_from_edges(edges: Vec<(String, String, EdgeType)>) -> DependencyGraph {
457 let mut nodes: HashMap<String, DependencyNode> = HashMap::new();
458 let mut dep_edges: Vec<DependencyEdge> = Vec::new();
459
460 for (from, to, etype) in edges {
461 nodes.entry(from.clone()).or_insert_with(|| DependencyNode {
462 id: from.clone(),
463 asset_type: AssetType::Unknown,
464 });
465 nodes.entry(to.clone()).or_insert_with(|| DependencyNode {
466 id: to.clone(),
467 asset_type: AssetType::Unknown,
468 });
469 dep_edges.push(DependencyEdge {
470 from,
471 to,
472 edge_type: etype,
473 });
474 }
475 dep_edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
476 DependencyGraph {
477 nodes,
478 edges: dep_edges,
479 }
480 }
481
482 #[test]
487 fn upstream_downstream_are_inverses() {
488 let g = graph_from_edges(vec![
489 ("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
490 ("agent-a".into(), "skill-y".into(), EdgeType::DependsOn),
491 ("role-r".into(), "agent-a".into(), EdgeType::Contains),
492 ]);
493
494 let up = g.upstream_ids("agent-a");
496 assert_eq!(up, vec!["skill-x", "skill-y"]);
497
498 let down = g.downstream_ids("agent-a");
500 assert_eq!(down, vec!["role-r"]);
501
502 assert!(g.upstream_ids("skill-x").is_empty());
504
505 assert_eq!(g.downstream_ids("skill-x"), vec!["agent-a"]);
507 }
508
509 #[test]
510 fn blast_radius_transitive() {
511 let g = graph_from_edges(vec![
513 ("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
514 ("role-r".into(), "agent-a".into(), EdgeType::Contains),
515 ]);
516
517 let br = g.blast_radius("skill-x");
519 assert!(br.dependents.contains(&"agent-a".to_string()));
520 assert!(br.dependents.contains(&"role-r".to_string()));
521 assert!(!br.dependents.contains(&"skill-x".to_string()));
522 }
523
524 #[test]
525 fn blast_radius_direct_only() {
526 let g = graph_from_edges(vec![(
527 "agent-a".into(),
528 "skill-x".into(),
529 EdgeType::DependsOn,
530 )]);
531 let br = g.blast_radius("skill-x");
532 assert_eq!(br.dependents, vec!["agent-a"]);
533 }
534
535 #[test]
536 fn find_cycles_detects_simple_cycle() {
537 let g = graph_from_edges(vec![
539 ("a".into(), "b".into(), EdgeType::DependsOn),
540 ("b".into(), "c".into(), EdgeType::DependsOn),
541 ("c".into(), "a".into(), EdgeType::DependsOn),
542 ]);
543 let cycles = g.find_cycles();
544 assert!(!cycles.is_empty(), "expected at least one cycle");
545 let flat: HashSet<String> = cycles.iter().flatten().cloned().collect();
547 assert!(flat.contains("a"));
548 assert!(flat.contains("b"));
549 assert!(flat.contains("c"));
550 }
551
552 #[test]
553 fn find_cycles_dag_no_cycle() {
554 let g = graph_from_edges(vec![
555 ("role-r".into(), "agent-a".into(), EdgeType::Contains),
556 ("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
557 ]);
558 assert!(g.find_cycles().is_empty());
559 }
560
561 #[test]
562 fn render_ascii_tree_basic() {
563 let g = graph_from_edges(vec![
564 ("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
565 ("agent-a".into(), "skill-y".into(), EdgeType::DependsOn),
566 ]);
567 let tree = g.render_ascii_tree("agent-a", 5);
568 assert!(tree.contains("agent-a"));
569 assert!(tree.contains("skill-x"));
570 assert!(tree.contains("skill-y"));
571 }
572
573 #[test]
574 fn adjacency_json_structure() {
575 let g = graph_from_edges(vec![(
576 "agent-a".into(),
577 "skill-x".into(),
578 EdgeType::DependsOn,
579 )]);
580 let json = g.to_adjacency_json();
581 assert!(json["nodes"].is_array());
582 assert!(json["adjacency"].is_object());
583 let edges = &json["adjacency"]["agent-a"];
584 assert_eq!(edges[0]["to"].as_str().unwrap(), "skill-x");
585 assert_eq!(edges[0]["edge_type"].as_str().unwrap(), "depends-on");
586 }
587
588 use proptest::prelude::*;
593
594 fn arb_dag_edges(
599 n_agents: usize,
600 n_skills: usize,
601 n_roles: usize,
602 ) -> Vec<(String, String, EdgeType)> {
603 let agents: Vec<String> = (0..n_agents).map(|i| format!("agent-{i}")).collect();
604 let skills: Vec<String> = (0..n_skills).map(|i| format!("skill-{i}")).collect();
605 let roles: Vec<String> = (0..n_roles).map(|i| format!("role-{i}")).collect();
606
607 let mut edges = Vec::new();
608 for (ai, agent) in agents.iter().enumerate() {
610 for (si, skill) in skills.iter().enumerate() {
611 if si <= ai {
612 edges.push((agent.clone(), skill.clone(), EdgeType::DependsOn));
613 }
614 }
615 }
616 for (ri, role) in roles.iter().enumerate() {
618 for (ai, agent) in agents.iter().enumerate() {
619 if ai <= ri {
620 edges.push((role.clone(), agent.clone(), EdgeType::Contains));
621 }
622 }
623 }
624 edges
625 }
626
627 proptest! {
628 #![proptest_config(ProptestConfig::with_cases(256))]
629
630 #[test]
635 fn prop_upstream_downstream_inverses(
636 n_agents in 1usize..5,
637 n_skills in 1usize..5,
638 n_roles in 1usize..5,
639 ) {
640 let edges = arb_dag_edges(n_agents, n_skills, n_roles);
641 let g = graph_from_edges(edges.clone());
642
643 for (from, to, _) in &edges {
644 let up = g.upstream_ids(from);
645 let down = g.downstream_ids(to);
646 prop_assert!(up.contains(to),
647 "{to} should be in upstream({from})");
648 prop_assert!(down.contains(from),
649 "{from} should be in downstream({to})");
650 }
651 }
652
653 #[test]
656 fn prop_blast_radius_includes_direct_agents(
657 n_agents in 1usize..4,
658 n_skills in 1usize..4,
659 n_roles in 1usize..4,
660 ) {
661 let edges = arb_dag_edges(n_agents, n_skills, n_roles);
662 let g = graph_from_edges(edges);
663
664 for si in 0..n_skills {
665 let skill_id = format!("skill-{si}");
666 let br = g.blast_radius(&skill_id);
667 for ai in si..n_agents {
669 let agent_id = format!("agent-{ai}");
670 prop_assert!(
671 br.dependents.contains(&agent_id),
672 "blast_radius({skill_id}) missing {agent_id}"
673 );
674 }
675 }
676 }
677
678 #[test]
680 fn prop_cycle_detected_not_infinite(
681 n in 2usize..6,
682 ) {
683 let mut edges: Vec<(String, String, EdgeType)> = (0..n)
685 .map(|i| (
686 format!("node-{i}"),
687 format!("node-{}", (i + 1) % n),
688 EdgeType::DependsOn,
689 ))
690 .collect();
691 edges.push(("extra".into(), format!("node-{}", n - 1), EdgeType::DependsOn));
693 let g = graph_from_edges(edges);
694 let cycles = g.find_cycles();
695 prop_assert!(!cycles.is_empty(), "cycle must be detected");
696 }
697 }
698}