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