agpm_cli/resolver/
dependency_graph.rs

1//! Dependency graph management for transitive dependency resolution.
2//!
3//! This module provides the graph data structure and algorithms needed to
4//! handle transitive dependencies, including cycle detection and topological
5//! ordering for correct installation order.
6
7use anyhow::{Result, anyhow};
8use petgraph::algo::toposort;
9use petgraph::graph::{DiGraph, NodeIndex};
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::fmt;
12
13/// Represents a dependency node in the graph.
14///
15/// Each node represents a unique resource that can be installed.
16/// Nodes are distinguished by name, resource type, and source to prevent
17/// false cycle detection when the same resource name appears in multiple sources.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct DependencyNode {
20    /// Resource type: Agent, Snippet, Command, etc.
21    pub resource_type: crate::core::ResourceType,
22    /// Dependency name as specified in the manifest.
23    pub name: String,
24    /// Source name (e.g., "community", "local"). None for direct path dependencies.
25    pub source: Option<String>,
26}
27
28impl DependencyNode {
29    /// Create a new dependency node without a source.
30    pub fn new(resource_type: crate::core::ResourceType, name: impl Into<String>) -> Self {
31        Self {
32            resource_type,
33            name: name.into(),
34            source: None,
35        }
36    }
37
38    /// Create a new dependency node with a source.
39    pub fn with_source(
40        resource_type: crate::core::ResourceType,
41        name: impl Into<String>,
42        source: Option<String>,
43    ) -> Self {
44        Self {
45            resource_type,
46            name: name.into(),
47            source,
48        }
49    }
50
51    /// Get a display name for this node.
52    pub fn display_name(&self) -> String {
53        if let Some(ref source) = self.source {
54            format!("{}/{}@{}", self.resource_type, self.name, source)
55        } else {
56            format!("{}/{}", self.resource_type, self.name)
57        }
58    }
59}
60
61impl fmt::Display for DependencyNode {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "{}", self.display_name())
64    }
65}
66
67/// Color states for cycle detection using DFS.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum Color {
70    /// Node has not been visited.
71    White,
72    /// Node is currently being visited (in the DFS stack).
73    Gray,
74    /// Node has been fully visited.
75    Black,
76}
77
78/// Dependency graph for managing transitive dependencies.
79///
80/// This graph tracks dependencies between resources and provides
81/// algorithms for cycle detection and topological ordering.
82pub struct DependencyGraph {
83    /// The underlying directed graph.
84    graph: DiGraph<DependencyNode, ()>,
85    /// Map from dependency nodes to their graph indices.
86    node_map: HashMap<DependencyNode, NodeIndex>,
87}
88
89impl DependencyGraph {
90    /// Create a new empty dependency graph.
91    pub fn new() -> Self {
92        Self {
93            graph: DiGraph::new(),
94            node_map: HashMap::new(),
95        }
96    }
97
98    /// Add a node to the graph if it doesn't already exist.
99    ///
100    /// Returns the node index in the graph.
101    fn ensure_node(&mut self, node: DependencyNode) -> NodeIndex {
102        if let Some(&index) = self.node_map.get(&node) {
103            index
104        } else {
105            let index = self.graph.add_node(node.clone());
106            self.node_map.insert(node, index);
107            index
108        }
109    }
110
111    /// Add a dependency relationship to the graph.
112    ///
113    /// `from` depends on `to`, meaning `to` must be installed before `from`.
114    pub fn add_dependency(&mut self, from: DependencyNode, to: DependencyNode) {
115        let from_idx = self.ensure_node(from);
116        let to_idx = self.ensure_node(to);
117
118        // Check if edge already exists to avoid duplicates
119        if !self.graph.contains_edge(from_idx, to_idx) {
120            self.graph.add_edge(from_idx, to_idx, ());
121        }
122    }
123
124    /// Detect cycles in the dependency graph using DFS with colors.
125    ///
126    /// Returns an error containing the cycle path if a cycle is detected.
127    pub fn detect_cycles(&self) -> Result<()> {
128        let mut colors: HashMap<NodeIndex, Color> = HashMap::new();
129        let mut path: Vec<DependencyNode> = Vec::new();
130
131        // Initialize all nodes as white
132        for node in self.graph.node_indices() {
133            colors.insert(node, Color::White);
134        }
135
136        // DFS from each white node
137        for node in self.graph.node_indices() {
138            if matches!(colors.get(&node), Some(Color::White))
139                && let Some(cycle) = self.dfs_visit(node, &mut colors, &mut path)
140            {
141                let cycle_str =
142                    cycle.iter().map(DependencyNode::display_name).collect::<Vec<_>>().join(" → ");
143                return Err(anyhow!("Circular dependency detected: {cycle_str}"));
144            }
145        }
146
147        Ok(())
148    }
149
150    /// DFS visit for cycle detection.
151    ///
152    /// Returns `Some(cycle_path)` if a cycle is detected, None otherwise.
153    fn dfs_visit(
154        &self,
155        node: NodeIndex,
156        colors: &mut HashMap<NodeIndex, Color>,
157        path: &mut Vec<DependencyNode>,
158    ) -> Option<Vec<DependencyNode>> {
159        colors.insert(node, Color::Gray);
160        path.push(self.graph[node].clone());
161
162        for neighbor in self.graph.neighbors(node) {
163            match colors.get(&neighbor) {
164                Some(Color::Gray) => {
165                    // Found a cycle - find where it starts in the path
166                    let cycle_start = path.iter().position(|n| *n == self.graph[neighbor]).unwrap();
167                    let mut cycle = path[cycle_start..].to_vec();
168                    // Add the node again to show the cycle closes
169                    cycle.push(self.graph[neighbor].clone());
170                    return Some(cycle);
171                }
172                Some(Color::White) => {
173                    if let Some(cycle) = self.dfs_visit(neighbor, colors, path) {
174                        return Some(cycle);
175                    }
176                }
177                _ => {}
178            }
179        }
180
181        path.pop();
182        colors.insert(node, Color::Black);
183        None
184    }
185
186    /// Get the topological order for installation.
187    ///
188    /// Returns nodes in an order where all dependencies come before their dependents.
189    /// This ensures that resources are installed in the correct order.
190    pub fn topological_order(&self) -> Result<Vec<DependencyNode>> {
191        // First check for cycles
192        self.detect_cycles()?;
193
194        // Perform topological sort
195        match toposort(&self.graph, None) {
196            Ok(indices) => {
197                let mut order = Vec::new();
198                // Reverse the order so dependencies come first
199                for idx in indices.into_iter().rev() {
200                    order.push(self.graph[idx].clone());
201                }
202                Ok(order)
203            }
204            Err(_) => {
205                // This shouldn't happen as we already checked for cycles
206                Err(anyhow!("Failed to determine installation order"))
207            }
208        }
209    }
210
211    /// Get all transitive dependencies for a given node.
212    ///
213    /// Returns a set of all nodes that the given node depends on,
214    /// directly or indirectly.
215    pub fn get_transitive_deps(&self, node: &DependencyNode) -> HashSet<DependencyNode> {
216        let mut deps = HashSet::new();
217        let mut queue = VecDeque::new();
218
219        // Find the node index
220        if let Some(&node_idx) = self.node_map.get(node) {
221            queue.push_back(node_idx);
222
223            while let Some(current) = queue.pop_front() {
224                // Add all neighbors (dependencies) to the queue
225                for neighbor in self.graph.neighbors(current) {
226                    let dep_node = &self.graph[neighbor];
227                    if deps.insert(dep_node.clone()) {
228                        // Only process if we haven't seen this node before
229                        queue.push_back(neighbor);
230                    }
231                }
232            }
233        }
234
235        deps
236    }
237
238    /// Get direct dependencies for a given node.
239    ///
240    /// Returns only the immediate dependencies, not transitive ones.
241    pub fn get_direct_deps(&self, node: &DependencyNode) -> Vec<DependencyNode> {
242        if let Some(&node_idx) = self.node_map.get(node) {
243            self.graph.neighbors(node_idx).map(|idx| self.graph[idx].clone()).collect()
244        } else {
245            Vec::new()
246        }
247    }
248
249    /// Check if the graph is empty.
250    pub fn is_empty(&self) -> bool {
251        self.graph.node_count() == 0
252    }
253
254    /// Get the total number of nodes in the graph.
255    pub fn node_count(&self) -> usize {
256        self.graph.node_count()
257    }
258
259    /// Get the total number of edges (dependencies) in the graph.
260    pub fn edge_count(&self) -> usize {
261        self.graph.edge_count()
262    }
263
264    /// Get all nodes in the graph.
265    pub fn nodes(&self) -> Vec<DependencyNode> {
266        self.graph.node_indices().map(|idx| self.graph[idx].clone()).collect()
267    }
268
269    /// Build a human-readable dependency tree representation.
270    ///
271    /// Returns a string showing the dependency hierarchy.
272    pub fn to_tree_string(&self, root: &DependencyNode) -> String {
273        let mut result = String::new();
274        let mut visited = HashSet::new();
275        self.build_tree_string(root, &mut result, "", true, &mut visited);
276        result
277    }
278
279    fn build_tree_string(
280        &self,
281        node: &DependencyNode,
282        result: &mut String,
283        prefix: &str,
284        is_last: bool,
285        visited: &mut HashSet<DependencyNode>,
286    ) {
287        let connector = if is_last {
288            "└── "
289        } else {
290            "├── "
291        };
292        result.push_str(&format!("{}{}{}\n", prefix, connector, node.display_name()));
293
294        if !visited.insert(node.clone()) {
295            // Already visited - indicate circular reference
296            let child_prefix = if is_last {
297                format!("{prefix}    ")
298            } else {
299                format!("{prefix}│   ")
300            };
301            result.push_str(&format!("{child_prefix}└── (circular reference)\n"));
302            return;
303        }
304
305        let deps = self.get_direct_deps(node);
306        let child_prefix = if is_last {
307            format!("{prefix}    ")
308        } else {
309            format!("{prefix}│   ")
310        };
311
312        for (i, dep) in deps.iter().enumerate() {
313            let is_last_child = i == deps.len() - 1;
314            self.build_tree_string(dep, result, &child_prefix, is_last_child, visited);
315        }
316    }
317}
318
319impl Default for DependencyGraph {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_simple_dependency_chain() {
331        let mut graph = DependencyGraph::new();
332
333        // A -> B -> C
334        graph.add_dependency(
335            DependencyNode::new(crate::core::ResourceType::Command, "A"),
336            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
337        );
338        graph.add_dependency(
339            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
340            DependencyNode::new(crate::core::ResourceType::Snippet, "C"),
341        );
342
343        assert!(graph.detect_cycles().is_ok());
344
345        let order = graph.topological_order().unwrap();
346        assert_eq!(order.len(), 3);
347
348        // C should come before B, and B before A
349        let c_idx = order.iter().position(|n| n.name == "C").unwrap();
350        let b_idx = order.iter().position(|n| n.name == "B").unwrap();
351        let a_idx = order.iter().position(|n| n.name == "A").unwrap();
352        assert!(c_idx < b_idx);
353        assert!(b_idx < a_idx);
354    }
355
356    #[test]
357    fn test_circular_dependency_detection() {
358        let mut graph = DependencyGraph::new();
359
360        // A -> B -> C -> A (circular)
361        graph.add_dependency(
362            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
363            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
364        );
365        graph.add_dependency(
366            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
367            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
368        );
369        graph.add_dependency(
370            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
371            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
372        );
373
374        let result = graph.detect_cycles();
375        assert!(result.is_err());
376        let error_msg = result.unwrap_err().to_string();
377        assert!(error_msg.contains("Circular dependency"));
378        assert!(error_msg.contains("agent/A"));
379    }
380
381    #[test]
382    fn test_diamond_dependency() {
383        let mut graph = DependencyGraph::new();
384
385        // A -> B, A -> C, B -> D, C -> D (diamond)
386        graph.add_dependency(
387            DependencyNode::new(crate::core::ResourceType::Command, "A"),
388            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
389        );
390        graph.add_dependency(
391            DependencyNode::new(crate::core::ResourceType::Command, "A"),
392            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
393        );
394        graph.add_dependency(
395            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
396            DependencyNode::new(crate::core::ResourceType::Snippet, "D"),
397        );
398        graph.add_dependency(
399            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
400            DependencyNode::new(crate::core::ResourceType::Snippet, "D"),
401        );
402
403        assert!(graph.detect_cycles().is_ok());
404
405        let order = graph.topological_order().unwrap();
406        assert_eq!(order.len(), 4);
407
408        // D should come before both B and C
409        let d_idx = order.iter().position(|n| n.name == "D").unwrap();
410        let b_idx = order.iter().position(|n| n.name == "B").unwrap();
411        let c_idx = order.iter().position(|n| n.name == "C").unwrap();
412        let a_idx = order.iter().position(|n| n.name == "A").unwrap();
413
414        assert!(d_idx < b_idx);
415        assert!(d_idx < c_idx);
416        assert!(b_idx < a_idx);
417        assert!(c_idx < a_idx);
418    }
419
420    #[test]
421    fn test_get_transitive_deps() {
422        let mut graph = DependencyGraph::new();
423
424        // A -> B -> C, A -> D
425        graph.add_dependency(
426            DependencyNode::new(crate::core::ResourceType::Command, "A"),
427            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
428        );
429        graph.add_dependency(
430            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
431            DependencyNode::new(crate::core::ResourceType::Snippet, "C"),
432        );
433        graph.add_dependency(
434            DependencyNode::new(crate::core::ResourceType::Command, "A"),
435            DependencyNode::new(crate::core::ResourceType::Snippet, "D"),
436        );
437
438        let deps = graph
439            .get_transitive_deps(&DependencyNode::new(crate::core::ResourceType::Command, "A"));
440        assert_eq!(deps.len(), 3);
441        assert!(deps.contains(&DependencyNode::new(crate::core::ResourceType::Agent, "B")));
442        assert!(deps.contains(&DependencyNode::new(crate::core::ResourceType::Snippet, "C")));
443        assert!(deps.contains(&DependencyNode::new(crate::core::ResourceType::Snippet, "D")));
444    }
445
446    #[test]
447    fn test_self_dependency() {
448        let mut graph = DependencyGraph::new();
449
450        // A -> A (self-dependency)
451        graph.add_dependency(
452            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
453            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
454        );
455
456        let result = graph.detect_cycles();
457        assert!(result.is_err());
458        assert!(result.unwrap_err().to_string().contains("Circular dependency"));
459    }
460
461    #[test]
462    fn test_empty_graph() {
463        let graph = DependencyGraph::new();
464        assert!(graph.is_empty());
465        assert_eq!(graph.node_count(), 0);
466        assert_eq!(graph.edge_count(), 0);
467        assert!(graph.detect_cycles().is_ok());
468        assert!(graph.topological_order().unwrap().is_empty());
469    }
470
471    #[test]
472    fn test_duplicate_edges() {
473        let mut graph = DependencyGraph::new();
474
475        // Add the same dependency twice
476        graph.add_dependency(
477            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
478            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
479        );
480        graph.add_dependency(
481            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
482            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
483        );
484
485        assert_eq!(graph.edge_count(), 1); // Should only have one edge
486        assert_eq!(graph.node_count(), 2);
487    }
488
489    #[test]
490    fn test_cross_source_no_false_cycle() {
491        let mut graph = DependencyGraph::new();
492
493        // Same resource name "helper" from two different sources should NOT create a cycle
494        // sourceA: A -> helper@sourceA
495        // sourceB: B -> helper@sourceB
496        graph.add_dependency(
497            DependencyNode::with_source(
498                crate::core::ResourceType::Agent,
499                "A",
500                Some("sourceA".to_string()),
501            ),
502            DependencyNode::with_source(
503                crate::core::ResourceType::Agent,
504                "helper",
505                Some("sourceA".to_string()),
506            ),
507        );
508        graph.add_dependency(
509            DependencyNode::with_source(
510                crate::core::ResourceType::Agent,
511                "B",
512                Some("sourceB".to_string()),
513            ),
514            DependencyNode::with_source(
515                crate::core::ResourceType::Agent,
516                "helper",
517                Some("sourceB".to_string()),
518            ),
519        );
520
521        // Should have 4 distinct nodes (A@sourceA, helper@sourceA, B@sourceB, helper@sourceB)
522        assert_eq!(graph.node_count(), 4);
523        assert_eq!(graph.edge_count(), 2);
524
525        // Should NOT detect a cycle
526        assert!(graph.detect_cycles().is_ok());
527
528        // Topological order should succeed
529        let order = graph.topological_order().unwrap();
530        assert_eq!(order.len(), 4);
531    }
532
533    #[test]
534    fn test_cross_source_real_cycle() {
535        let mut graph = DependencyGraph::new();
536
537        // Same source, real cycle: A -> B -> A (both from sourceX)
538        graph.add_dependency(
539            DependencyNode::with_source(
540                crate::core::ResourceType::Agent,
541                "A",
542                Some("sourceX".to_string()),
543            ),
544            DependencyNode::with_source(
545                crate::core::ResourceType::Agent,
546                "B",
547                Some("sourceX".to_string()),
548            ),
549        );
550        graph.add_dependency(
551            DependencyNode::with_source(
552                crate::core::ResourceType::Agent,
553                "B",
554                Some("sourceX".to_string()),
555            ),
556            DependencyNode::with_source(
557                crate::core::ResourceType::Agent,
558                "A",
559                Some("sourceX".to_string()),
560            ),
561        );
562
563        // Should detect a cycle
564        let result = graph.detect_cycles();
565        assert!(result.is_err());
566        assert!(result.unwrap_err().to_string().contains("Circular dependency"));
567    }
568}