1use crate::core::manifest::{ManifestError, SkillEntry, SkillProjectToml};
10use std::collections::{HashSet, VecDeque};
11use std::path::Path;
12
13#[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#[derive(Debug, Clone)]
28pub struct SkillInstallItem {
29 pub entry: SkillEntry,
30 pub depth: u32,
32 pub parent_skill: Option<String>,
34}
35
36#[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 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 pub async fn resolve_dependencies(
64 &mut self,
65 initial_entries: Vec<SkillEntry>,
66 skills_dir: &Path,
67 ) -> Result<Vec<SkillInstallItem>, DependencyResolutionError> {
68 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 if current_depth >= self.max_depth {
90 if current_depth > 0 {
91 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 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 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 if !self.visited_skills.insert(trans_id.clone()) {
131 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 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 pub fn topological_sort(&self, items: Vec<SkillInstallItem>) -> Vec<SkillInstallItem> {
171 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 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 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 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 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 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 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}