1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ConflictStrategy {
26 Priority,
28 HighestVersion,
30 Explicit,
32}
33
34#[derive(Debug, Clone)]
36pub struct ResolutionResult {
37 pub candidate: SkillCandidate,
38 pub source_name: String,
39}
40
41#[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
60pub struct PackageResolver {
62 sources_manager: Arc<SourcesManager>,
63 skill_index: HashMap<String, Vec<SkillCandidate>>,
64}
65
66impl PackageResolver {
67 pub fn new(sources_manager: Arc<SourcesManager>) -> Self {
69 Self {
70 sources_manager,
71 skill_index: HashMap::new(),
72 }
73 }
74
75 pub async fn build_index(&mut self) -> Result<(), ResolverError> {
77 self.skill_index.clear();
78
79 let all_skills = self
81 .sources_manager
82 .get_available_skills()
83 .await
84 .map_err(|e| ResolverError::SourceError(e.to_string()))?;
85
86 for skill_info in all_skills {
88 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 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 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 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 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 let selected = if constraint_filtered.len() == 1 {
184 constraint_filtered[0]
185 } else {
186 match strategy {
187 ConflictStrategy::Priority => {
188 constraint_filtered[0]
190 }
191 ConflictStrategy::HighestVersion => {
192 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 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 pub fn skill_exists(&self, skill_id: &str) -> bool {
227 self.skill_index.contains_key(skill_id)
228 }
229
230 pub fn list_skills(&self) -> Vec<String> {
232 self.skill_index.keys().cloned().collect()
233 }
234
235 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 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 let constraint = dep.version_constraint.as_ref();
281 let resolution = self.resolve_skill(&dep.skill_id, constraint, None, strategy)?;
282
283 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 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 }
344}