Skip to main content

vfa_tui/federation/
dep_graph.rs

1//! Dependency graph builder and query engine (Tasks 7.12 / 7.13).
2//!
3//! Builds an in-memory directed graph from catalog data and exposes:
4//! - upstream / downstream traversal
5//! - blast-radius analysis
6//! - cycle detection
7//! - ASCII-tree rendering
8//! - JSON adjacency-list export
9
10#![deny(warnings)]
11
12use std::collections::{HashMap, HashSet, VecDeque};
13
14use serde::Serialize;
15
16use crate::catalog::store::{CatalogStore, EdgeKind};
17
18// ---------------------------------------------------------------------------
19// Public types
20// ---------------------------------------------------------------------------
21
22/// Asset category used to annotate graph nodes.
23#[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/// A node in the dependency graph.
35#[derive(Debug, Clone, Serialize)]
36pub struct DependencyNode {
37    pub id: String,
38    pub asset_type: AssetType,
39}
40
41/// The logical relationship that an edge encodes.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum EdgeType {
45    /// Agent → Skill via `companion_skills`.
46    DependsOn,
47    /// Role → Agent via role membership.
48    Contains,
49    /// Agent → MCP reference.
50    References,
51    /// Agent → Rule (shared harness).
52    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/// A directed edge between two assets.
67#[derive(Debug, Clone, Serialize)]
68pub struct DependencyEdge {
69    pub from: String,
70    pub to: String,
71    pub edge_type: EdgeType,
72}
73
74/// What breaks when an asset is removed.
75#[derive(Debug, Clone, Default)]
76pub struct BlastRadius {
77    /// Direct and transitive dependents of the asset.
78    pub dependents: Vec<String>,
79}
80
81/// Built dependency graph.
82pub struct DependencyGraph {
83    pub nodes: HashMap<String, DependencyNode>,
84    pub edges: Vec<DependencyEdge>,
85}
86
87// ---------------------------------------------------------------------------
88// Construction
89// ---------------------------------------------------------------------------
90
91impl DependencyGraph {
92    /// Build the graph from a loaded [`CatalogStore`].
93    pub fn build(store: &CatalogStore) -> Self {
94        let mut nodes: HashMap<String, DependencyNode> = HashMap::new();
95
96        // Seed node type from known collections
97        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            // Ensure both endpoints have a node entry even if not in the named
148            // collections (defensive).
149            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        // Deterministic ordering
166        edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
167
168        DependencyGraph { nodes, edges }
169    }
170
171    // -----------------------------------------------------------------------
172    // Traversal
173    // -----------------------------------------------------------------------
174
175    /// All edges that originate *from* `asset_id` — i.e. what `asset_id` depends on.
176    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    /// All edges that point *to* `asset_id` — i.e. what depends on `asset_id`.
184    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    /// Convenience: sorted list of IDs that `asset_id` depends on (upstream nodes).
192    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    /// Convenience: sorted list of IDs that depend on `asset_id` (downstream nodes).
203    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    /// Transitive blast-radius: which assets break if `asset_id` is removed?
214    ///
215    /// Walks backwards through the graph (BFS over `downstream` edges) and
216    /// returns every transitive dependent, sorted and deduplicated.
217    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(&current) {
224                if visited.insert(edge.from.clone()) {
225                    queue.push_back(edge.from.clone());
226                }
227            }
228        }
229
230        // Exclude the queried asset itself from the result set
231        visited.remove(asset_id);
232
233        let mut dependents: Vec<String> = visited.into_iter().collect();
234        dependents.sort();
235        BlastRadius { dependents }
236    }
237
238    // -----------------------------------------------------------------------
239    // Cycle detection
240    // -----------------------------------------------------------------------
241
242    /// Detect all cycles in the graph using iterative DFS.
243    ///
244    /// Returns a list of cycles where each cycle is represented as an ordered
245    /// list of node IDs forming the cycle path.  The result is deterministically
246    /// sorted so tests can make stable assertions.
247    pub fn find_cycles(&self) -> Vec<Vec<String>> {
248        // Build adjacency list
249        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        // Sort each adjacency list for determinism
256        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        // Standard DFS with three-color marking (white=0, grey=1, black=2)
264        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        // Deduplicate and sort for determinism
275        for c in &mut cycles {
276            // Normalize: rotate so the lexicographically smallest element is first
277            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); // grey — in current path
299        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                    // Found a back-edge → cycle
306                    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); // black — fully processed
318    }
319
320    // -----------------------------------------------------------------------
321    // Rendering
322    // -----------------------------------------------------------------------
323
324    /// Render an ASCII dependency tree rooted at `focus_id`.
325    ///
326    /// Each level is indented by 2 spaces per depth.  Nodes already visited
327    /// in the current path are marked `[cycle]` to avoid infinite output.
328    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        // Children = nodes this asset depends on (upstream direction in visual tree)
357        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    // -----------------------------------------------------------------------
374    // JSON export
375    // -----------------------------------------------------------------------
376
377    /// Export graph as a JSON adjacency list suitable for `--report dependencies`.
378    ///
379    /// Shape:
380    /// ```json
381    /// {
382    ///   "nodes": [ { "id": "...", "asset_type": "..." }, ... ],
383    ///   "adjacency": {
384    ///     "<id>": [ { "to": "...", "edge_type": "depends-on" }, ... ],
385    ///     ...
386    ///   }
387    /// }
388    /// ```
389    pub fn to_adjacency_json(&self) -> serde_json::Value {
390        // Sorted nodes
391        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        // Build adjacency map: from_id → sorted list of {to, edge_type}
405        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        // Sort each list by "to"
416        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        // Build sorted adjacency object
426        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// ---------------------------------------------------------------------------
444// Tests
445// ---------------------------------------------------------------------------
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    // -----------------------------------------------------------------------
452    // Helpers to build minimal graphs without a real CatalogStore
453    // -----------------------------------------------------------------------
454
455    /// Build a DependencyGraph directly from a list of edges for unit tests.
456    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    // -----------------------------------------------------------------------
483    // Basic correctness tests
484    // -----------------------------------------------------------------------
485
486    #[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        // upstream of agent-a → {skill-x, skill-y}
495        let up = g.upstream_ids("agent-a");
496        assert_eq!(up, vec!["skill-x", "skill-y"]);
497
498        // downstream of agent-a → {role-r}
499        let down = g.downstream_ids("agent-a");
500        assert_eq!(down, vec!["role-r"]);
501
502        // upstream of skill-x → nothing
503        assert!(g.upstream_ids("skill-x").is_empty());
504
505        // downstream of skill-x → {agent-a}
506        assert_eq!(g.downstream_ids("skill-x"), vec!["agent-a"]);
507    }
508
509    #[test]
510    fn blast_radius_transitive() {
511        // role-r → agent-a → skill-x
512        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        // Removing skill-x should implicate agent-a and role-r
518        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        // a → b → c → a
538        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        // All nodes in the graph should be part of a cycle
546        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    // -----------------------------------------------------------------------
589    // Property tests (proptest) — Property 15
590    // -----------------------------------------------------------------------
591
592    use proptest::prelude::*;
593
594    /// Generate a random acyclic graph topology.
595    ///
596    /// `n_agents`, `n_skills`, `n_roles` are node counts; edges are drawn
597    /// from a fixed seed strategy to keep the graph a DAG.
598    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        // Each agent depends on skills whose index ≤ agent index (keeps DAG)
609        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        // Each role contains agents whose index ≤ role index
617        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        /// Property 15a — upstream/downstream are consistent inverses.
631        ///
632        /// For every edge (a → b) in the graph:
633        ///   b ∈ upstream_ids(a)  AND  a ∈ downstream_ids(b)
634        #[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        /// Property 15b — blast_radius of a skill includes exactly the agents
654        /// referencing it (and their roles transitively).
655        #[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                // Every agent whose index ≥ si depends on this skill
668                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        /// Property 15c — injecting a cycle is reliably detected.
679        #[test]
680        fn prop_cycle_detected_not_infinite(
681            n in 2usize..6,
682        ) {
683            // Build a chain a0 → a1 → ... → a(n-1) → a0  (a cycle)
684            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            // Add a DAG tail that does NOT participate in the cycle
692            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}