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() {
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        assert!(graph.detect_cycles().is_ok());
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    }
358
359    #[test]
360    fn test_circular_dependency_detection() {
361        let mut graph = DependencyGraph::new();
362
363        // A -> B -> C -> A (circular)
364        graph.add_dependency(
365            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
366            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
367        );
368        graph.add_dependency(
369            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
370            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
371        );
372        graph.add_dependency(
373            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
374            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
375        );
376
377        let result = graph.detect_cycles();
378        assert!(result.is_err());
379        let error_msg = result.unwrap_err().to_string();
380        assert!(error_msg.contains("Circular dependency"));
381        assert!(error_msg.contains("agent:A"));
382    }
383
384    #[test]
385    fn test_diamond_dependency() {
386        let mut graph = DependencyGraph::new();
387
388        // A -> B, A -> C, B -> D, C -> D (diamond)
389        graph.add_dependency(
390            DependencyNode::new(crate::core::ResourceType::Command, "A"),
391            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
392        );
393        graph.add_dependency(
394            DependencyNode::new(crate::core::ResourceType::Command, "A"),
395            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
396        );
397        graph.add_dependency(
398            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
399            DependencyNode::new(crate::core::ResourceType::Snippet, "D"),
400        );
401        graph.add_dependency(
402            DependencyNode::new(crate::core::ResourceType::Agent, "C"),
403            DependencyNode::new(crate::core::ResourceType::Snippet, "D"),
404        );
405
406        assert!(graph.detect_cycles().is_ok());
407
408        let order = graph.topological_order().unwrap();
409        assert_eq!(order.len(), 4);
410
411        // D should come before both B and C
412        let d_idx = order.iter().position(|n| n.name == "D").unwrap();
413        let b_idx = order.iter().position(|n| n.name == "B").unwrap();
414        let c_idx = order.iter().position(|n| n.name == "C").unwrap();
415        let a_idx = order.iter().position(|n| n.name == "A").unwrap();
416
417        assert!(d_idx < b_idx);
418        assert!(d_idx < c_idx);
419        assert!(b_idx < a_idx);
420        assert!(c_idx < a_idx);
421    }
422
423    #[test]
424    fn test_get_transitive_deps() {
425        let mut graph = DependencyGraph::new();
426
427        // A -> B -> C, A -> D
428        graph.add_dependency(
429            DependencyNode::new(crate::core::ResourceType::Command, "A"),
430            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
431        );
432        graph.add_dependency(
433            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
434            DependencyNode::new(crate::core::ResourceType::Snippet, "C"),
435        );
436        graph.add_dependency(
437            DependencyNode::new(crate::core::ResourceType::Command, "A"),
438            DependencyNode::new(crate::core::ResourceType::Snippet, "D"),
439        );
440
441        let deps = graph
442            .get_transitive_deps(&DependencyNode::new(crate::core::ResourceType::Command, "A"));
443        assert_eq!(deps.len(), 3);
444        assert!(deps.contains(&DependencyNode::new(crate::core::ResourceType::Agent, "B")));
445        assert!(deps.contains(&DependencyNode::new(crate::core::ResourceType::Snippet, "C")));
446        assert!(deps.contains(&DependencyNode::new(crate::core::ResourceType::Snippet, "D")));
447    }
448
449    #[test]
450    fn test_self_dependency() {
451        let mut graph = DependencyGraph::new();
452
453        // A -> A (self-dependency)
454        graph.add_dependency(
455            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
456            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
457        );
458
459        let result = graph.detect_cycles();
460        assert!(result.is_err());
461        assert!(result.unwrap_err().to_string().contains("Circular dependency"));
462    }
463
464    #[test]
465    fn test_empty_graph() {
466        let graph = DependencyGraph::new();
467        assert!(graph.is_empty());
468        assert_eq!(graph.node_count(), 0);
469        assert_eq!(graph.edge_count(), 0);
470        assert!(graph.detect_cycles().is_ok());
471        assert!(graph.topological_order().unwrap().is_empty());
472    }
473
474    #[test]
475    fn test_duplicate_edges() {
476        let mut graph = DependencyGraph::new();
477
478        // Add the same dependency twice
479        graph.add_dependency(
480            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
481            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
482        );
483        graph.add_dependency(
484            DependencyNode::new(crate::core::ResourceType::Agent, "A"),
485            DependencyNode::new(crate::core::ResourceType::Agent, "B"),
486        );
487
488        assert_eq!(graph.edge_count(), 1); // Should only have one edge
489        assert_eq!(graph.node_count(), 2);
490    }
491
492    #[test]
493    fn test_cross_source_no_false_cycle() {
494        let mut graph = DependencyGraph::new();
495
496        // Same resource name "helper" from two different sources should NOT create a cycle
497        // sourceA: A -> helper@sourceA
498        // sourceB: B -> helper@sourceB
499        graph.add_dependency(
500            DependencyNode::with_source(
501                crate::core::ResourceType::Agent,
502                "A",
503                Some("sourceA".to_string()),
504            ),
505            DependencyNode::with_source(
506                crate::core::ResourceType::Agent,
507                "helper",
508                Some("sourceA".to_string()),
509            ),
510        );
511        graph.add_dependency(
512            DependencyNode::with_source(
513                crate::core::ResourceType::Agent,
514                "B",
515                Some("sourceB".to_string()),
516            ),
517            DependencyNode::with_source(
518                crate::core::ResourceType::Agent,
519                "helper",
520                Some("sourceB".to_string()),
521            ),
522        );
523
524        // Should have 4 distinct nodes (A@sourceA, helper@sourceA, B@sourceB, helper@sourceB)
525        assert_eq!(graph.node_count(), 4);
526        assert_eq!(graph.edge_count(), 2);
527
528        // Should NOT detect a cycle
529        assert!(graph.detect_cycles().is_ok());
530
531        // Topological order should succeed
532        let order = graph.topological_order().unwrap();
533        assert_eq!(order.len(), 4);
534    }
535
536    #[test]
537    fn test_cross_source_real_cycle() {
538        let mut graph = DependencyGraph::new();
539
540        // Same source, real cycle: A -> B -> A (both from sourceX)
541        graph.add_dependency(
542            DependencyNode::with_source(
543                crate::core::ResourceType::Agent,
544                "A",
545                Some("sourceX".to_string()),
546            ),
547            DependencyNode::with_source(
548                crate::core::ResourceType::Agent,
549                "B",
550                Some("sourceX".to_string()),
551            ),
552        );
553        graph.add_dependency(
554            DependencyNode::with_source(
555                crate::core::ResourceType::Agent,
556                "B",
557                Some("sourceX".to_string()),
558            ),
559            DependencyNode::with_source(
560                crate::core::ResourceType::Agent,
561                "A",
562                Some("sourceX".to_string()),
563            ),
564        );
565
566        // Should detect a cycle
567        let result = graph.detect_cycles();
568        assert!(result.is_err());
569        assert!(result.unwrap_err().to_string().contains("Circular dependency"));
570    }
571}