agpm_cli/version/constraints/
resolver.rs

1//! Constraint resolver implementation for multi-dependency version resolution.
2
3use anyhow::Result;
4use semver::Version;
5use std::collections::HashMap;
6
7use super::{ConstraintSet, VersionConstraint};
8use crate::core::AgpmError;
9
10/// Manages version constraints for multiple dependencies and resolves them simultaneously.
11///
12/// `ConstraintResolver` coordinates version resolution across an entire dependency graph,
13/// ensuring that all constraints are satisfied and conflicts are detected. It maintains
14/// separate [`ConstraintSet`]s for each dependency and resolves them against available
15/// version catalogs.
16///
17/// # Multi-Dependency Resolution
18///
19/// Unlike [`ConstraintSet`] which manages constraints for a single dependency,
20/// `ConstraintResolver` handles multiple dependencies simultaneously:
21///
22/// - Each dependency gets its own constraint set
23/// - Constraints can be added incrementally
24/// - Resolution happens across the entire dependency graph
25/// - Missing dependencies are detected and reported
26///
27/// # Resolution Process
28///
29/// 1. **Collect constraints**: Gather all constraints for each dependency
30/// 2. **Validate availability**: Ensure versions exist for all dependencies
31/// 3. **Apply constraint sets**: Use each dependency's constraints to filter versions
32/// 4. **Select best matches**: Choose optimal versions for each dependency
33/// 5. **Return resolution map**: Provide final version selections
34///
35/// # Examples
36///
37/// ## Basic Multi-Dependency Resolution
38///
39/// ```rust,no_run
40/// use agpm_cli::version::constraints::ConstraintResolver;
41/// use semver::Version;
42/// use std::collections::HashMap;
43///
44/// let mut resolver = ConstraintResolver::new();
45///
46/// // Add constraints for multiple dependencies
47/// resolver.add_constraint("dep1", "^1.0.0")?;
48/// resolver.add_constraint("dep2", "~2.1.0")?;
49/// resolver.add_constraint("dep3", "main")?;
50///
51/// // Provide available versions for each dependency
52/// let mut available = HashMap::new();
53/// available.insert("dep1".to_string(), vec![Version::parse("1.5.0")?]);
54/// available.insert("dep2".to_string(), vec![Version::parse("2.1.3")?]);
55/// available.insert("dep3".to_string(), vec![Version::parse("3.0.0")?]);
56///
57/// // Resolve all dependencies
58/// let resolved = resolver.resolve(&available)?;
59/// assert_eq!(resolved.len(), 3);
60/// # Ok::<(), anyhow::Error>(())
61/// ```
62///
63/// ## Incremental Constraint Addition
64///
65/// ```rust,no_run
66/// use agpm_cli::version::constraints::ConstraintResolver;
67///
68/// let mut resolver = ConstraintResolver::new();
69///
70/// // Add multiple constraints for the same dependency
71/// resolver.add_constraint("my-dep", ">=1.0.0")?;
72/// resolver.add_constraint("my-dep", "<2.0.0")?;
73/// resolver.add_constraint("my-dep", "^1.5.0")?;
74///
75/// // All constraints will be combined into a single constraint set
76/// # Ok::<(), anyhow::Error>(())
77/// ```
78///
79/// # Error Conditions
80///
81/// The resolver reports several types of errors:
82///
83/// - **Missing dependencies**: A constraint exists but no versions are available
84/// - **Unsatisfiable constraints**: No available version meets all requirements
85/// - **Conflicting constraints**: Impossible constraint combinations
86///
87/// # Use Cases
88///
89/// This resolver is particularly useful for:
90/// - Package managers resolving dependency graphs
91/// - Build systems selecting compatible versions
92/// - Configuration management ensuring consistent environments
93/// - Update analysis determining safe upgrade paths
94pub struct ConstraintResolver {
95    constraints: HashMap<String, ConstraintSet>,
96}
97
98impl Default for ConstraintResolver {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl ConstraintResolver {
105    /// Creates a new constraint resolver
106    ///
107    /// # Returns
108    ///
109    /// Returns a new `ConstraintResolver` with empty constraint and resolution maps
110    #[must_use]
111    pub fn new() -> Self {
112        Self {
113            constraints: HashMap::new(),
114        }
115    }
116
117    /// Add a version constraint for a specific dependency.
118    ///
119    /// This method parses constraint string and adds it to the constraint set
120    /// for the named dependency. If this is the first constraint for the dependency,
121    /// a new constraint set is created. Multiple constraints for the same dependency
122    /// are combined into a single set with conflict detection.
123    ///
124    /// # Arguments
125    ///
126    /// * `dependency` - The name of the dependency to constrain
127    /// * `constraint` - The constraint string to parse and add (e.g., "^1.0.0", "latest")
128    ///
129    /// # Returns
130    ///
131    /// Returns `Ok(())` if the constraint was added successfully, or `Err` if:
132    /// - The constraint string is invalid
133    /// - The constraint conflicts with existing constraints for this dependency
134    ///
135    /// # Examples
136    ///
137    /// ```rust,no_run
138    /// use agpm_cli::version::constraints::ConstraintResolver;
139    ///
140    /// let mut resolver = ConstraintResolver::new();
141    ///
142    /// // Add constraints for different dependencies
143    /// resolver.add_constraint("web-framework", "^2.0.0")?;
144    /// resolver.add_constraint("database", "~1.5.0")?;
145    /// resolver.add_constraint("auth-lib", "main")?;
146    ///
147    /// // Add multiple constraints for the same dependency
148    /// resolver.add_constraint("api-client", ">=1.0.0")?;
149    /// resolver.add_constraint("api-client", "<2.0.0")?; // Compatible range
150    ///
151    /// // This would fail - conflicting exact versions
152    /// resolver.add_constraint("my-dep", "1.0.0")?;
153    /// let result = resolver.add_constraint("my-dep", "2.0.0");
154    /// assert!(result.is_err());
155    /// # Ok::<(), anyhow::Error>(())
156    /// ```
157    ///
158    /// # Constraint Combination
159    ///
160    /// When multiple constraints are added for the same dependency, they are
161    /// combined using AND logic. The final constraint set requires that all
162    /// individual constraints be satisfied simultaneously.
163    pub fn add_constraint(&mut self, dependency: &str, constraint: &str) -> Result<()> {
164        let parsed = VersionConstraint::parse(constraint)?;
165
166        self.constraints.entry(dependency.to_string()).or_default().add(parsed)?;
167
168        Ok(())
169    }
170
171    /// Resolve all dependency constraints and return the best version for each.
172    ///
173    /// This method performs the core resolution algorithm, taking all accumulated
174    /// constraints and finding the best matching version for each dependency from
175    /// the provided catalog of available versions.
176    ///
177    /// # Resolution Algorithm
178    ///
179    /// For each dependency with constraints:
180    /// 1. **Verify availability**: Check that versions exist for the dependency
181    /// 2. **Apply constraints**: Filter versions using the dependency's constraint set
182    /// 3. **Select best match**: Choose the highest compatible version
183    /// 4. **Handle prereleases**: Apply prerelease policies appropriately
184    ///
185    /// # Arguments
186    ///
187    /// * `available_versions` - Map from dependency names to lists of available versions
188    ///
189    /// # Returns
190    ///
191    /// Returns `Ok(HashMap<String, Version>)` with the resolved version for each
192    /// dependency, or `Err` if resolution fails.
193    ///
194    /// # Error Conditions
195    ///
196    /// - **Missing dependency**: Constraint exists but no versions are available
197    /// - **No satisfying version**: Available versions don't meet constraints
198    /// - **Internal errors**: Constraint set conflicts or parsing failures
199    ///
200    /// # Examples
201    ///
202    /// ```rust,no_run
203    /// use agpm_cli::version::constraints::ConstraintResolver;
204    /// use semver::Version;
205    /// use std::collections::HashMap;
206    ///
207    /// let mut resolver = ConstraintResolver::new();
208    /// resolver.add_constraint("web-server", "^1.0.0")?;
209    /// resolver.add_constraint("database", "~2.1.0")?;
210    ///
211    /// // Provide version catalog
212    /// let mut available = HashMap::new();
213    /// available.insert(
214    ///     "web-server".to_string(),
215    ///     vec![
216    ///         Version::parse("1.0.0")?,
217    ///         Version::parse("1.2.0")?,
218    ///         Version::parse("1.5.0")?, // Best match for ^1.0.0
219    ///         Version::parse("2.0.0")?, // Too new
220    ///     ],
221    /// );
222    /// available.insert(
223    ///     "database".to_string(),
224    ///     vec![
225    ///         Version::parse("2.1.0")?,
226    ///         Version::parse("2.1.3")?, // Best match for ~2.1.0
227    ///         Version::parse("2.2.0")?, // Too new
228    ///     ],
229    /// );
230    ///
231    /// // Resolve dependencies
232    /// let resolved = resolver.resolve(&available)?;
233    /// assert_eq!(resolved["web-server"], Version::parse("1.5.0")?);
234    /// assert_eq!(resolved["database"], Version::parse("2.1.3")?);
235    /// # Ok::<(), anyhow::Error>(())
236    /// ```
237    ///
238    /// ## Error Handling
239    ///
240    /// ```rust,no_run
241    /// use agpm_cli::version::constraints::ConstraintResolver;
242    /// use std::collections::HashMap;
243    ///
244    /// let mut resolver = ConstraintResolver::new();
245    /// resolver.add_constraint("missing-dep", "^1.0.0")?;
246    ///
247    /// let available = HashMap::new(); // No versions provided
248    ///
249    /// let result = resolver.resolve(&available);
250    /// assert!(result.is_err()); // Missing dependency error
251    /// # Ok::<(), anyhow::Error>(())
252    /// ```
253    ///
254    /// # Performance Considerations
255    ///
256    /// - Resolution is performed independently for each dependency
257    /// - Version filtering and sorting may be expensive for large version lists
258    /// - Consider pre-filtering available versions if catalogs are very large
259    pub fn resolve(
260        &self,
261        available_versions: &HashMap<String, Vec<Version>>,
262    ) -> Result<HashMap<String, Version>> {
263        let mut resolved = HashMap::new();
264
265        for (dep, constraint_set) in &self.constraints {
266            let versions = available_versions.get(dep).ok_or_else(|| AgpmError::Other {
267                message: format!("No versions available for dependency: {dep}"),
268            })?;
269
270            let best_match =
271                constraint_set.find_best_match(versions).ok_or_else(|| AgpmError::Other {
272                    message: format!("No version satisfies constraints for dependency: {dep}"),
273                })?;
274
275            resolved.insert(dep.clone(), best_match.clone());
276        }
277
278        Ok(resolved)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::collections::HashMap;
286
287    #[test]
288    fn test_constraint_resolver() {
289        let mut resolver = ConstraintResolver::new();
290
291        resolver.add_constraint("dep1", "^1.0.0").unwrap();
292        resolver.add_constraint("dep2", "~2.1.0").unwrap();
293
294        let mut available = HashMap::new();
295        available.insert(
296            "dep1".to_string(),
297            vec![
298                Version::parse("0.9.0").unwrap(),
299                Version::parse("1.0.0").unwrap(),
300                Version::parse("1.5.0").unwrap(),
301                Version::parse("2.0.0").unwrap(),
302            ],
303        );
304        available.insert(
305            "dep2".to_string(),
306            vec![
307                Version::parse("2.0.0").unwrap(),
308                Version::parse("2.1.0").unwrap(),
309                Version::parse("2.1.5").unwrap(),
310                Version::parse("2.2.0").unwrap(),
311            ],
312        );
313
314        let resolved = resolver.resolve(&available).unwrap();
315        assert_eq!(resolved.get("dep1"), Some(&Version::parse("1.5.0").unwrap()));
316        assert_eq!(resolved.get("dep2"), Some(&Version::parse("2.1.5").unwrap()));
317    }
318
319    #[test]
320    fn test_constraint_resolver_missing_dependency() {
321        let mut resolver = ConstraintResolver::new();
322        resolver.add_constraint("dep1", "^1.0.0").unwrap();
323
324        let available = HashMap::new(); // No versions available
325
326        let result = resolver.resolve(&available);
327        assert!(result.is_err());
328    }
329
330    #[test]
331    fn test_constraint_resolver_no_satisfying_version() {
332        let mut resolver = ConstraintResolver::new();
333        resolver.add_constraint("dep1", "^2.0.0").unwrap();
334
335        let mut available = HashMap::new();
336        available.insert(
337            "dep1".to_string(),
338            vec![Version::parse("1.0.0").unwrap()], // Only 1.x available, but we need 2.x
339        );
340
341        let result = resolver.resolve(&available);
342        assert!(result.is_err());
343    }
344
345    #[test]
346    fn test_constraint_resolver_add_constraint_error() {
347        let mut resolver = ConstraintResolver::new();
348
349        // Add a valid constraint first
350        resolver.add_constraint("dep1", "1.0.0").unwrap();
351
352        // Add conflicting constraint
353        let result = resolver.add_constraint("dep1", "2.0.0");
354        assert!(result.is_err());
355    }
356}