Skip to main content

fastskill_core/core/
resolver.rs

1//! Package resolver for unified skill resolution across multiple sources
2
3use crate::core::dependencies::Dependency;
4use crate::core::sources::{SourceConfig, SourcesManager};
5use crate::core::version::{VersionConstraint, VersionError};
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8use thiserror::Error;
9
10/// Skill candidate from a source
11#[derive(Debug, Clone)]
12pub struct SkillCandidate {
13    pub id: String,
14    pub name: String,
15    pub version: String,
16    pub description: String,
17    pub source_name: String,
18    pub source_config: SourceConfig,
19    pub download_url: Option<String>,
20    pub commit_hash: Option<String>,
21}
22
23/// Conflict resolution strategy
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ConflictStrategy {
26    /// Use highest priority source
27    Priority,
28    /// Use highest version
29    HighestVersion,
30    /// Require explicit source specification
31    Explicit,
32}
33
34/// Resolution result
35#[derive(Debug, Clone)]
36pub struct ResolutionResult {
37    pub candidate: SkillCandidate,
38    pub source_name: String,
39}
40
41/// Package resolver errors
42#[derive(Debug, Error)]
43pub enum ResolverError {
44    #[error("Skill not found: {0}")]
45    NotFound(String),
46
47    #[error("Version constraint not satisfied: {0}")]
48    ConstraintNotSatisfied(String),
49
50    #[error("Multiple candidates found, source specification required")]
51    MultipleCandidates,
52
53    #[error("Version error: {0}")]
54    VersionError(#[from] VersionError),
55
56    #[error("Source error: {0}")]
57    SourceError(String),
58}
59
60/// Package resolver for unified skill resolution
61pub struct PackageResolver {
62    sources_manager: Arc<SourcesManager>,
63    skill_index: HashMap<String, Vec<SkillCandidate>>,
64}
65
66impl PackageResolver {
67    /// Create a new package resolver
68    pub fn new(sources_manager: Arc<SourcesManager>) -> Self {
69        Self {
70            sources_manager,
71            skill_index: HashMap::new(),
72        }
73    }
74
75    /// Build the unified skill index from all sources
76    pub async fn build_index(&mut self) -> Result<(), ResolverError> {
77        self.skill_index.clear();
78
79        // Get all skills from all sources (already sorted by priority)
80        let all_skills = self
81            .sources_manager
82            .get_available_skills()
83            .await
84            .map_err(|e| ResolverError::SourceError(e.to_string()))?;
85
86        // Group skills by ID
87        for skill_info in all_skills {
88            // Get source config for this skill
89            let source_def = self
90                .sources_manager
91                .get_source(&skill_info.source_name)
92                .ok_or_else(|| {
93                    ResolverError::SourceError(format!(
94                        "Source '{}' not found",
95                        skill_info.source_name
96                    ))
97                })?;
98
99            let candidate = SkillCandidate {
100                id: skill_info.id.clone(),
101                name: skill_info.name.clone(),
102                version: skill_info.version.unwrap_or_else(|| "1.0.0".to_string()),
103                description: skill_info.description.clone(),
104                source_name: skill_info.source_name.clone(),
105                source_config: source_def.source.clone(),
106                download_url: None,
107                commit_hash: None,
108            };
109
110            self.skill_index
111                .entry(skill_info.id)
112                .or_default()
113                .push(candidate);
114        }
115
116        // Sort candidates by source priority (lower priority number = higher priority)
117        for candidates in self.skill_index.values_mut() {
118            candidates.sort_by(|a, b| {
119                let a_priority = self
120                    .sources_manager
121                    .get_source(&a.source_name)
122                    .map(|s| s.priority)
123                    .unwrap_or(u32::MAX);
124                let b_priority = self
125                    .sources_manager
126                    .get_source(&b.source_name)
127                    .map(|s| s.priority)
128                    .unwrap_or(u32::MAX);
129                a_priority.cmp(&b_priority)
130            });
131        }
132
133        Ok(())
134    }
135
136    /// Resolve a skill by ID
137    pub fn resolve_skill(
138        &self,
139        skill_id: &str,
140        version_constraint: Option<&VersionConstraint>,
141        source_name: Option<&str>,
142        strategy: ConflictStrategy,
143    ) -> Result<ResolutionResult, ResolverError> {
144        let candidates = self
145            .skill_index
146            .get(skill_id)
147            .ok_or_else(|| ResolverError::NotFound(skill_id.to_string()))?;
148
149        // Filter by source if specified
150        let filtered_candidates: Vec<&SkillCandidate> = if let Some(source) = source_name {
151            candidates
152                .iter()
153                .filter(|c| c.source_name == source)
154                .collect()
155        } else {
156            candidates.iter().collect()
157        };
158
159        if filtered_candidates.is_empty() {
160            return Err(ResolverError::NotFound(skill_id.to_string()));
161        }
162
163        // Filter by version constraint if specified
164        let constraint_filtered: Vec<&SkillCandidate> = if let Some(constraint) = version_constraint
165        {
166            filtered_candidates
167                .iter()
168                .filter(|c| constraint.satisfies(&c.version).unwrap_or(false))
169                .copied()
170                .collect()
171        } else {
172            filtered_candidates
173        };
174
175        if constraint_filtered.is_empty() {
176            return Err(ResolverError::ConstraintNotSatisfied(format!(
177                "No version satisfies constraint for skill '{}'",
178                skill_id
179            )));
180        }
181
182        // Resolve conflict if multiple candidates
183        let selected = if constraint_filtered.len() == 1 {
184            constraint_filtered[0]
185        } else {
186            match strategy {
187                ConflictStrategy::Priority => {
188                    // Already sorted by priority, take first
189                    constraint_filtered[0]
190                }
191                ConflictStrategy::HighestVersion => {
192                    // Find highest version
193                    constraint_filtered
194                        .iter()
195                        .max_by(|a, b| {
196                            crate::core::version::compare_versions(&a.version, &b.version)
197                                .unwrap_or(std::cmp::Ordering::Equal)
198                        })
199                        .ok_or_else(|| {
200                            ResolverError::ConstraintNotSatisfied(
201                                "No candidates available after filtering".to_string(),
202                            )
203                        })?
204                }
205                ConflictStrategy::Explicit => {
206                    return Err(ResolverError::MultipleCandidates);
207                }
208            }
209        };
210
211        Ok(ResolutionResult {
212            candidate: selected.clone(),
213            source_name: selected.source_name.clone(),
214        })
215    }
216
217    /// Get all available versions of a skill
218    pub fn get_available_versions(&self, skill_id: &str) -> Vec<&SkillCandidate> {
219        self.skill_index
220            .get(skill_id)
221            .map(|candidates| candidates.iter().collect())
222            .unwrap_or_default()
223    }
224
225    /// Check if a skill exists in any source
226    pub fn skill_exists(&self, skill_id: &str) -> bool {
227        self.skill_index.contains_key(skill_id)
228    }
229
230    /// Get all skill IDs available
231    pub fn list_skills(&self) -> Vec<String> {
232        self.skill_index.keys().cloned().collect()
233    }
234
235    /// Resolve dependencies for a skill
236    ///
237    /// Note: This method resolves dependencies across sources (finding which source
238    /// provides each dependency). For graph structure management and topological sorting,
239    /// see `DependencyGraph` in the `dependencies` module. The two serve different purposes:
240    /// - PackageResolver: Source resolution (which source has the skill/version)
241    /// - DependencyGraph: Graph structure and installation order
242    pub fn resolve_dependencies(
243        &self,
244        skill_id: &str,
245        dependencies: &[Dependency],
246        strategy: ConflictStrategy,
247    ) -> Result<Vec<ResolutionResult>, ResolverError> {
248        let mut resolved = Vec::new();
249        let mut visited = HashSet::new();
250        let mut resolution_map = HashMap::new();
251
252        self.resolve_dependencies_recursive(
253            skill_id,
254            dependencies,
255            &mut resolved,
256            &mut visited,
257            &mut resolution_map,
258            strategy,
259        )?;
260
261        Ok(resolved)
262    }
263
264    /// Recursively resolve dependencies
265    fn resolve_dependencies_recursive(
266        &self,
267        _skill_id: &str,
268        dependencies: &[Dependency],
269        resolved: &mut Vec<ResolutionResult>,
270        visited: &mut HashSet<String>,
271        resolution_map: &mut HashMap<String, ResolutionResult>,
272        strategy: ConflictStrategy,
273    ) -> Result<(), ResolverError> {
274        for dep in dependencies {
275            if visited.contains(&dep.skill_id) {
276                continue;
277            }
278
279            // Resolve the dependency
280            let constraint = dep.version_constraint.as_ref();
281            let resolution = self.resolve_skill(&dep.skill_id, constraint, None, strategy)?;
282
283            // Check for version conflicts
284            if let Some(existing) = resolution_map.get(&dep.skill_id) {
285                if existing.candidate.version != resolution.candidate.version {
286                    return Err(ResolverError::ConstraintNotSatisfied(format!(
287                        "Version conflict for '{}': {} vs {}",
288                        dep.skill_id, existing.candidate.version, resolution.candidate.version
289                    )));
290                }
291            } else {
292                resolution_map.insert(dep.skill_id.clone(), resolution.clone());
293            }
294
295            visited.insert(dep.skill_id.clone());
296            resolved.push(resolution);
297        }
298
299        Ok(())
300    }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used)]
305mod tests {
306    use super::*;
307    use crate::core::sources::{SourceConfig, SourcesManager};
308    use std::path::PathBuf;
309    use tempfile::NamedTempFile;
310
311    #[tokio::test]
312    async fn test_resolver_priority() {
313        let temp_file = NamedTempFile::new().unwrap();
314        let config_path = temp_file.path().to_path_buf();
315
316        let mut sources_manager = SourcesManager::new(config_path);
317        sources_manager.load().unwrap();
318
319        // Add sources with different priorities
320        sources_manager
321            .add_source_with_priority(
322                "high-priority".to_string(),
323                SourceConfig::Local {
324                    path: PathBuf::from("./test"),
325                },
326                1,
327            )
328            .unwrap();
329
330        sources_manager
331            .add_source_with_priority(
332                "low-priority".to_string(),
333                SourceConfig::Local {
334                    path: PathBuf::from("./test2"),
335                },
336                2,
337            )
338            .unwrap();
339
340        let _resolver = PackageResolver::new(Arc::new(sources_manager));
341        // Note: build_index would need actual skills to test fully
342        // This is a basic structure test
343    }
344}