Skip to main content

fastskill_core/core/
dependencies.rs

1//! Dependency resolution and graph management
2
3use crate::core::version::{VersionConstraint, VersionError};
4use std::collections::{HashMap, HashSet, VecDeque};
5use thiserror::Error;
6
7/// A skill dependency with optional version constraint
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Dependency {
10    pub skill_id: String,
11    pub version_constraint: Option<VersionConstraint>,
12}
13
14impl Dependency {
15    /// Parse dependency from string format (e.g., "skill-id" or "skill-id@^1.2.3")
16    pub fn parse(dep_str: &str) -> Result<Self, DependencyError> {
17        if let Some(at_pos) = dep_str.find('@') {
18            let skill_id = dep_str[..at_pos].trim().to_string();
19            let constraint_str = dep_str[at_pos + 1..].trim();
20
21            if constraint_str.is_empty() {
22                return Err(DependencyError::InvalidFormat(
23                    "Empty version constraint after @".to_string(),
24                ));
25            }
26
27            let constraint =
28                VersionConstraint::parse(constraint_str).map_err(DependencyError::VersionError)?;
29
30            Ok(Dependency {
31                skill_id,
32                version_constraint: Some(constraint),
33            })
34        } else {
35            Ok(Dependency {
36                skill_id: dep_str.trim().to_string(),
37                version_constraint: None,
38            })
39        }
40    }
41
42    /// Parse multiple dependencies from a list of strings
43    pub fn parse_multiple(dep_strings: &[String]) -> Result<Vec<Self>, DependencyError> {
44        dep_strings.iter().map(|s| Self::parse(s)).collect()
45    }
46}
47
48/// Dependency graph errors
49#[derive(Debug, Error)]
50pub enum DependencyError {
51    #[error("Circular dependency detected: {0}")]
52    CircularDependency(String),
53
54    #[error("Dependency not found: {0}")]
55    NotFound(String),
56
57    #[error("Version error: {0}")]
58    VersionError(#[from] VersionError),
59
60    #[error("Invalid dependency format: {0}")]
61    InvalidFormat(String),
62}
63
64/// Dependency graph for managing skill dependencies
65pub struct DependencyGraph {
66    /// Adjacency list: skill_id -> list of dependencies
67    graph: HashMap<String, Vec<Dependency>>,
68    /// Reverse graph for finding dependents
69    reverse_graph: HashMap<String, Vec<String>>,
70}
71
72impl DependencyGraph {
73    /// Create a new empty dependency graph
74    pub fn new() -> Self {
75        Self {
76            graph: HashMap::new(),
77            reverse_graph: HashMap::new(),
78        }
79    }
80
81    /// Add a skill with its dependencies
82    pub fn add_skill(&mut self, skill_id: String, dependencies: Vec<Dependency>) {
83        // Update forward graph
84        self.graph.insert(skill_id.clone(), dependencies.clone());
85
86        // Update reverse graph
87        for dep in dependencies {
88            self.reverse_graph
89                .entry(dep.skill_id.clone())
90                .or_default()
91                .push(skill_id.clone());
92        }
93    }
94
95    /// Build graph from a list of (skill_id, dependencies) pairs
96    pub fn build_graph(skills: Vec<(String, Vec<Dependency>)>) -> Self {
97        let mut graph = Self::new();
98        for (skill_id, deps) in skills {
99            graph.add_skill(skill_id, deps);
100        }
101        graph
102    }
103
104    /// Detect circular dependencies
105    pub fn detect_cycles(&self) -> Result<(), DependencyError> {
106        let mut visited = HashSet::new();
107        let mut rec_stack = HashSet::new();
108
109        for skill_id in self.graph.keys() {
110            if !visited.contains(skill_id)
111                && self.dfs_cycle_detect(skill_id, &mut visited, &mut rec_stack)
112            {
113                return Err(DependencyError::CircularDependency(
114                    "Circular dependency detected in graph".to_string(),
115                ));
116            }
117        }
118
119        Ok(())
120    }
121
122    /// DFS for cycle detection (public interface)
123    fn dfs_cycle_detect(
124        &self,
125        skill_id: &str,
126        visited: &mut HashSet<String>,
127        rec_stack: &mut HashSet<String>,
128    ) -> bool {
129        self.dfs_cycle_detect_internal(skill_id, visited, rec_stack)
130    }
131
132    /// Internal DFS for cycle detection
133    fn dfs_cycle_detect_internal(
134        &self,
135        skill_id: &str,
136        visited: &mut HashSet<String>,
137        rec_stack: &mut HashSet<String>,
138    ) -> bool {
139        if rec_stack.contains(skill_id) {
140            return true; // Found a cycle
141        }
142
143        if visited.contains(skill_id) {
144            return false; // Already processed this node
145        }
146
147        visited.insert(skill_id.to_string());
148        rec_stack.insert(skill_id.to_string());
149
150        if let Some(deps) = self.graph.get(skill_id) {
151            for dep in deps {
152                // Only check dependencies that are in the graph
153                if !self.graph.contains_key(&dep.skill_id) {
154                    continue;
155                }
156
157                if self.dfs_cycle_detect_internal(&dep.skill_id, visited, rec_stack) {
158                    return true;
159                }
160            }
161        }
162
163        rec_stack.remove(skill_id);
164        false
165    }
166
167    /// Topological sort for installation order
168    /// Returns skills in dependency-first order (dependencies before dependents)
169    pub fn topological_sort(&self) -> Result<Vec<String>, DependencyError> {
170        // Check for cycles first using the existing method
171        self.detect_cycles()?;
172
173        // Calculate in-degree: number of dependencies each skill has (that are in the graph)
174        // For installation order, we want to install skills with no dependencies first
175        let mut in_degree: HashMap<String, usize> = HashMap::new();
176
177        // Initialize in-degree for all skills in the graph
178        for skill_id in self.graph.keys() {
179            // Count how many dependencies this skill has that are also in the graph
180            let dep_count = self
181                .graph
182                .get(skill_id)
183                .map(|deps| {
184                    deps.iter()
185                        .filter(|dep| self.graph.contains_key(&dep.skill_id))
186                        .count()
187                })
188                .unwrap_or(0);
189            in_degree.insert(skill_id.clone(), dep_count);
190        }
191
192        // Find all skills with no dependencies (in-degree 0)
193        let mut queue: VecDeque<String> = in_degree
194            .iter()
195            .filter(|(_, &degree)| degree == 0)
196            .map(|(skill_id, _)| skill_id.clone())
197            .collect();
198
199        let mut result = Vec::new();
200
201        // Process queue using Kahn's algorithm
202        while let Some(skill_id) = queue.pop_front() {
203            result.push(skill_id.clone());
204
205            // When we process a skill, we can now install skills that depend on it
206            // Use reverse graph to find dependents and reduce their in-degree
207            if let Some(dependents) = self.reverse_graph.get(&skill_id) {
208                for dependent in dependents {
209                    // Only process dependents that are in the graph
210                    if self.graph.contains_key(dependent) {
211                        if let Some(degree) = in_degree.get_mut(dependent) {
212                            *degree -= 1;
213                            if *degree == 0 {
214                                queue.push_back(dependent.clone());
215                            }
216                        }
217                    }
218                }
219            }
220        }
221
222        // Verify all skills were processed
223        if result.len() != self.graph.len() {
224            // This should not happen if cycle detection worked correctly
225            // But handle gracefully for external dependencies
226            return Err(DependencyError::CircularDependency(format!(
227                "Not all skills could be sorted ({} of {} processed)",
228                result.len(),
229                self.graph.len()
230            )));
231        }
232
233        Ok(result)
234    }
235
236    /// Get dependencies for a skill
237    pub fn get_dependencies(&self, skill_id: &str) -> Option<&Vec<Dependency>> {
238        self.graph.get(skill_id)
239    }
240
241    /// Get all skills that depend on a given skill
242    pub fn get_dependents(&self, skill_id: &str) -> Vec<String> {
243        self.reverse_graph
244            .get(skill_id)
245            .cloned()
246            .unwrap_or_default()
247    }
248}
249
250impl Default for DependencyGraph {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256#[cfg(test)]
257#[allow(clippy::unwrap_used)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_parse_dependency() {
263        let dep = Dependency::parse("my-skill").unwrap();
264        assert_eq!(dep.skill_id, "my-skill");
265        assert_eq!(dep.version_constraint, None);
266
267        let dep = Dependency::parse("my-skill@^1.2.3").unwrap();
268        assert_eq!(dep.skill_id, "my-skill");
269        assert!(dep.version_constraint.is_some());
270    }
271
272    #[test]
273    fn test_cycle_detection() {
274        let mut graph = DependencyGraph::new();
275        graph.add_skill(
276            "a".to_string(),
277            vec![Dependency {
278                skill_id: "b".to_string(),
279                version_constraint: None,
280            }],
281        );
282        graph.add_skill(
283            "b".to_string(),
284            vec![Dependency {
285                skill_id: "a".to_string(),
286                version_constraint: None,
287            }],
288        );
289
290        assert!(graph.detect_cycles().is_err());
291    }
292
293    #[test]
294    fn test_topological_sort() {
295        let mut graph = DependencyGraph::new();
296        // a depends on b, b depends on c, c has no dependencies
297        // Installation order should be: c, b, a
298        graph.add_skill(
299            "a".to_string(),
300            vec![Dependency {
301                skill_id: "b".to_string(),
302                version_constraint: None,
303            }],
304        );
305        graph.add_skill(
306            "b".to_string(),
307            vec![Dependency {
308                skill_id: "c".to_string(),
309                version_constraint: None,
310            }],
311        );
312        graph.add_skill("c".to_string(), vec![]);
313
314        let order = graph.topological_sort().unwrap();
315        // Verify all skills are present
316        assert_eq!(order.len(), 3);
317        assert!(order.contains(&"a".to_string()));
318        assert!(order.contains(&"b".to_string()));
319        assert!(order.contains(&"c".to_string()));
320
321        // Verify dependencies: c must come before b, b must come before a
322        let c_pos = order.iter().position(|x| x == "c").unwrap();
323        let b_pos = order.iter().position(|x| x == "b").unwrap();
324        let a_pos = order.iter().position(|x| x == "a").unwrap();
325
326        assert!(
327            c_pos < b_pos,
328            "c (pos {}) should come before b (pos {})",
329            c_pos,
330            b_pos
331        );
332        assert!(
333            b_pos < a_pos,
334            "b (pos {}) should come before a (pos {})",
335            b_pos,
336            a_pos
337        );
338    }
339
340    #[test]
341    fn test_topological_sort_branching() {
342        let mut graph = DependencyGraph::new();
343        // a depends on b and c, b depends on d
344        // Installation order: d, b, c, a (or d, c, b, a)
345        graph.add_skill(
346            "a".to_string(),
347            vec![
348                Dependency {
349                    skill_id: "b".to_string(),
350                    version_constraint: None,
351                },
352                Dependency {
353                    skill_id: "c".to_string(),
354                    version_constraint: None,
355                },
356            ],
357        );
358        graph.add_skill(
359            "b".to_string(),
360            vec![Dependency {
361                skill_id: "d".to_string(),
362                version_constraint: None,
363            }],
364        );
365        graph.add_skill("c".to_string(), vec![]);
366        graph.add_skill("d".to_string(), vec![]);
367
368        let order = graph.topological_sort().unwrap();
369        assert_eq!(order.len(), 4);
370
371        // d must come before b
372        let d_pos = order.iter().position(|x| x == "d").unwrap();
373        let b_pos = order.iter().position(|x| x == "b").unwrap();
374        let a_pos = order.iter().position(|x| x == "a").unwrap();
375
376        assert!(d_pos < b_pos, "d should come before b");
377        assert!(b_pos < a_pos, "b should come before a");
378        // c can be anywhere before a
379        let c_pos = order.iter().position(|x| x == "c").unwrap();
380        assert!(c_pos < a_pos, "c should come before a");
381    }
382
383    #[test]
384    fn test_topological_sort_multiple_roots() {
385        let mut graph = DependencyGraph::new();
386        // b and c have no dependencies, a depends on b
387        // Installation order: b and c first (any order), then a
388        graph.add_skill(
389            "a".to_string(),
390            vec![Dependency {
391                skill_id: "b".to_string(),
392                version_constraint: None,
393            }],
394        );
395        graph.add_skill("b".to_string(), vec![]);
396        graph.add_skill("c".to_string(), vec![]);
397
398        let order = graph.topological_sort().unwrap();
399        assert_eq!(order.len(), 3);
400
401        // b must come before a
402        let b_pos = order.iter().position(|x| x == "b").unwrap();
403        let a_pos = order.iter().position(|x| x == "a").unwrap();
404        assert!(b_pos < a_pos, "b should come before a");
405
406        // c can be anywhere
407        assert!(order.contains(&"c".to_string()));
408    }
409}