Skip to main content

fastskill_core/core/
dependency_resolver.rs

1//! Recursive dependency resolution for the install command
2//!
3//! Implements a breadth-first dependency graph traversal that:
4//! - Deduplicates skills by ID (first-encountered wins)
5//! - Enforces a configurable depth limit to prevent runaway chains
6//! - Detects circular dependencies and breaks cycles with a warning
7//! - Returns skills in topological (dependency-first) order
8
9use crate::core::manifest::{ManifestError, SkillEntry, SkillProjectToml};
10use std::collections::{HashSet, VecDeque};
11use std::path::Path;
12
13/// Error types specific to dependency resolution
14#[derive(Debug, thiserror::Error)]
15pub enum DependencyResolutionError {
16    #[error("Maximum dependency depth {0} exceeded")]
17    DepthLimitExceeded(u32),
18
19    #[error("Circular dependency detected: {0} -> {1}")]
20    CircularDependency(String, String),
21
22    #[error("Failed to load transitive dependencies for {skill}: {error}")]
23    TransitiveLoadError { skill: String, error: String },
24}
25
26/// A single item in the install queue, carrying its resolution context
27#[derive(Debug, Clone)]
28pub struct SkillInstallItem {
29    pub entry: SkillEntry,
30    /// Depth at which this skill was discovered (0 = direct dependency)
31    pub depth: u32,
32    /// ID of the skill that pulled this one in, if any
33    pub parent_skill: Option<String>,
34}
35
36/// Resolves a dependency graph for a set of root `SkillEntry` items.
37///
38/// Uses breadth-first traversal so that depth is counted from the root,
39/// matching the behaviour described in the spec (decision #5).
40#[derive(Debug, Clone)]
41pub struct DependencyResolver {
42    max_depth: u32,
43    visited_skills: HashSet<String>,
44    install_queue: VecDeque<SkillInstallItem>,
45}
46
47impl DependencyResolver {
48    /// Create a new resolver with the given depth limit.
49    pub fn new(max_depth: u32) -> Self {
50        Self {
51            max_depth,
52            visited_skills: HashSet::new(),
53            install_queue: VecDeque::new(),
54        }
55    }
56
57    /// Resolve the full dependency graph starting from `initial_entries`.
58    ///
59    /// After each skill is installed it is expected to live at
60    /// `<skills_dir>/<skill_id>/skill-project.toml`.  If that file is absent
61    /// the skill contributes zero transitive dependencies (graceful
62    /// degradation per the spec).
63    pub async fn resolve_dependencies(
64        &mut self,
65        initial_entries: Vec<SkillEntry>,
66        skills_dir: &Path,
67    ) -> Result<Vec<SkillInstallItem>, DependencyResolutionError> {
68        // Seed the queue with the direct (depth-0) dependencies
69        for entry in initial_entries {
70            let id = entry.id.clone();
71            if self.visited_skills.insert(id.clone()) {
72                self.install_queue.push_back(SkillInstallItem {
73                    entry,
74                    depth: 0,
75                    parent_skill: None,
76                });
77            }
78        }
79
80        let mut ordered: Vec<SkillInstallItem> = Vec::new();
81
82        while let Some(item) = self.install_queue.pop_front() {
83            let skill_id = item.entry.id.clone();
84            let current_depth = item.depth;
85
86            ordered.push(item);
87
88            // Do not recurse beyond max_depth
89            if current_depth >= self.max_depth {
90                if current_depth > 0 {
91                    // Informational – not a hard error per spec §4.6
92                    tracing::info!(
93                        "Dependency depth limit ({}) reached at skill '{}'; \
94                         transitive dependencies will not be resolved further",
95                        self.max_depth,
96                        skill_id
97                    );
98                }
99                continue;
100            }
101
102            // Attempt to load the transitive manifest for this skill
103            let transitive_manifest = skills_dir.join(&skill_id).join("skill-project.toml");
104            let transitive_entries = match self.load_transitive_dependencies(&transitive_manifest) {
105                Ok(entries) => entries,
106                Err(ManifestError::NotFound(_)) => {
107                    // Graceful degradation: skill has no manifest → zero deps
108                    tracing::debug!(
109                        "No skill-project.toml found for '{}'; \
110                             skipping transitive resolution",
111                        skill_id
112                    );
113                    continue;
114                }
115                Err(e) => {
116                    tracing::warn!(
117                        "Could not load transitive dependencies for '{}': {}",
118                        skill_id,
119                        e
120                    );
121                    continue;
122                }
123            };
124
125            for trans_entry in transitive_entries {
126                let trans_id = trans_entry.id.clone();
127
128                // Deduplication: first-encountered wins
129                // Also detect circular dependencies (when same skill appears at different depths)
130                if !self.visited_skills.insert(trans_id.clone()) {
131                    // Check if this is a circular dependency (skill already in the graph)
132                    // Emit warning per spec §4.6 "cycles are broken with warning message"
133                    tracing::warn!(
134                        "Circular dependency detected: {} -> {}; \
135                         skipping duplicate to break cycle",
136                        skill_id,
137                        trans_id
138                    );
139                    continue;
140                }
141
142                self.install_queue.push_back(SkillInstallItem {
143                    entry: trans_entry,
144                    depth: current_depth + 1,
145                    parent_skill: Some(skill_id.clone()),
146                });
147            }
148        }
149
150        Ok(ordered)
151    }
152
153    /// Load skill entries from a `skill-project.toml` at the given path.
154    ///
155    /// Returns `ManifestError::NotFound` when the file does not exist so the
156    /// caller can distinguish "no manifest" from "broken manifest".
157    fn load_transitive_dependencies(
158        &self,
159        manifest_path: &Path,
160    ) -> Result<Vec<SkillEntry>, ManifestError> {
161        let project = SkillProjectToml::load_from_file(manifest_path)?;
162        project.to_skill_entries().map_err(ManifestError::Parse)
163    }
164
165    /// Return the skills in dependency-first (topological) order.
166    ///
167    /// The BFS traversal already yields parents before children, so this is
168    /// a no-op structurally; the method exists as the public API surface
169    /// specified in §5.1 and can be extended later if needed.
170    pub fn topological_sort(&self, items: Vec<SkillInstallItem>) -> Vec<SkillInstallItem> {
171        // BFS order from resolve_dependencies is already topological:
172        // each skill appears after the skill that declared it as a dependency.
173        items
174    }
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used)]
179mod tests {
180    use super::*;
181    use crate::core::manifest::{SkillEntry, SkillSource};
182    use std::path::PathBuf;
183    use tempfile::TempDir;
184
185    fn make_local_entry(id: &str, path: &str) -> SkillEntry {
186        SkillEntry {
187            id: id.to_string(),
188            source: SkillSource::Local {
189                path: PathBuf::from(path),
190                editable: false,
191            },
192            version: "*".to_string(),
193            groups: vec![],
194            editable: false,
195        }
196    }
197
198    #[tokio::test]
199    async fn test_resolve_no_transitive_deps() {
200        let dir = TempDir::new().unwrap();
201        let mut resolver = DependencyResolver::new(5);
202
203        let entries = vec![make_local_entry("skill-a", "local/skill-a")];
204        let result = resolver
205            .resolve_dependencies(entries, dir.path())
206            .await
207            .unwrap();
208
209        assert_eq!(result.len(), 1);
210        assert_eq!(result[0].entry.id, "skill-a");
211        assert_eq!(result[0].depth, 0);
212        assert!(result[0].parent_skill.is_none());
213    }
214
215    #[tokio::test]
216    async fn test_resolve_deduplicates_same_id() {
217        let dir = TempDir::new().unwrap();
218        let mut resolver = DependencyResolver::new(5);
219
220        // Two entries with the same ID – only the first should survive
221        let entries = vec![
222            make_local_entry("skill-a", "local/skill-a"),
223            make_local_entry("skill-a", "local/skill-a-duplicate"),
224        ];
225        let result = resolver
226            .resolve_dependencies(entries, dir.path())
227            .await
228            .unwrap();
229
230        assert_eq!(result.len(), 1);
231        assert_eq!(result[0].entry.id, "skill-a");
232    }
233
234    #[tokio::test]
235    async fn test_resolve_with_transitive_manifest() {
236        let dir = TempDir::new().unwrap();
237
238        // Create skill-a with a skill-project.toml that depends on skill-b
239        let skill_a_dir = dir.path().join("skill-a");
240        std::fs::create_dir_all(&skill_a_dir).unwrap();
241        std::fs::write(
242            skill_a_dir.join("skill-project.toml"),
243            r#"[dependencies]
244skill-b = { source = "local", path = "local/skill-b" }
245"#,
246        )
247        .unwrap();
248
249        let mut resolver = DependencyResolver::new(5);
250        let entries = vec![make_local_entry("skill-a", "local/skill-a")];
251        let result = resolver
252            .resolve_dependencies(entries, dir.path())
253            .await
254            .unwrap();
255
256        assert_eq!(result.len(), 2);
257        assert_eq!(result[0].entry.id, "skill-a");
258        assert_eq!(result[0].depth, 0);
259        assert_eq!(result[1].entry.id, "skill-b");
260        assert_eq!(result[1].depth, 1);
261        assert_eq!(result[1].parent_skill.as_deref(), Some("skill-a"));
262    }
263
264    #[tokio::test]
265    async fn test_resolve_respects_depth_limit() {
266        let dir = TempDir::new().unwrap();
267
268        // skill-a → skill-b (at depth 1) → skill-c would be depth 2, but limit is 1
269        let skill_a_dir = dir.path().join("skill-a");
270        std::fs::create_dir_all(&skill_a_dir).unwrap();
271        std::fs::write(
272            skill_a_dir.join("skill-project.toml"),
273            r#"[dependencies]
274skill-b = { source = "local", path = "local/skill-b" }
275"#,
276        )
277        .unwrap();
278
279        let skill_b_dir = dir.path().join("skill-b");
280        std::fs::create_dir_all(&skill_b_dir).unwrap();
281        std::fs::write(
282            skill_b_dir.join("skill-project.toml"),
283            r#"[dependencies]
284skill-c = { source = "local", path = "local/skill-c" }
285"#,
286        )
287        .unwrap();
288
289        // max_depth = 1 → skill-b is included (depth 1), skill-c is NOT
290        let mut resolver = DependencyResolver::new(1);
291        let entries = vec![make_local_entry("skill-a", "local/skill-a")];
292        let result = resolver
293            .resolve_dependencies(entries, dir.path())
294            .await
295            .unwrap();
296
297        let ids: Vec<&str> = result.iter().map(|i| i.entry.id.as_str()).collect();
298        assert!(ids.contains(&"skill-a"), "skill-a must be present");
299        assert!(
300            ids.contains(&"skill-b"),
301            "skill-b must be present at depth 1"
302        );
303        assert!(
304            !ids.contains(&"skill-c"),
305            "skill-c must be excluded beyond depth limit"
306        );
307    }
308
309    #[tokio::test]
310    async fn test_resolve_missing_manifest_continues() {
311        // If an installed skill has no skill-project.toml the resolver must
312        // not error out – it should just contribute zero transitive deps.
313        let dir = TempDir::new().unwrap();
314        let mut resolver = DependencyResolver::new(5);
315
316        let entries = vec![make_local_entry(
317            "no-manifest-skill",
318            "local/no-manifest-skill",
319        )];
320        let result = resolver
321            .resolve_dependencies(entries, dir.path())
322            .await
323            .unwrap();
324
325        // Skill without manifest still appears – it just has no children
326        assert_eq!(result.len(), 1);
327        assert_eq!(result[0].entry.id, "no-manifest-skill");
328    }
329
330    #[tokio::test]
331    async fn test_topological_sort_preserves_order() {
332        let resolver = DependencyResolver::new(5);
333        let items = vec![
334            SkillInstallItem {
335                entry: make_local_entry("a", "local/a"),
336                depth: 0,
337                parent_skill: None,
338            },
339            SkillInstallItem {
340                entry: make_local_entry("b", "local/b"),
341                depth: 1,
342                parent_skill: Some("a".to_string()),
343            },
344        ];
345        let sorted = resolver.topological_sort(items.clone());
346        assert_eq!(sorted[0].entry.id, items[0].entry.id);
347        assert_eq!(sorted[1].entry.id, items[1].entry.id);
348    }
349}