agpm_cli/version/constraints/
constraint_set.rs

1//! Constraint set implementation for managing multiple version constraints.
2
3use anyhow::Result;
4use semver::Version;
5
6use super::VersionConstraint;
7use crate::core::AgpmError;
8
9/// A collection of version constraints that must all be satisfied simultaneously.
10///
11/// `ConstraintSet` manages multiple [`VersionConstraint`]s for a single dependency,
12/// ensuring that all constraints are compatible and can be resolved together.
13/// It provides conflict detection, version matching, and best-match selection.
14///
15/// # Constraint Combination
16///
17/// When multiple constraints are added to a set, they create an intersection
18/// of requirements. For example:
19/// - `>=1.0.0` AND `<2.0.0` = versions in range `[1.0.0, 2.0.0)`
20/// - `^1.0.0` AND `~1.2.0` = versions compatible with both (e.g., `1.2.x`)
21///
22/// # Conflict Detection
23///
24/// The constraint set detects and prevents conflicting constraints:
25/// - Multiple exact versions: `1.0.0` AND `2.0.0` (impossible to satisfy)
26/// - Conflicting Git refs: `main` AND `develop` (can't be both branches)
27///
28/// # Resolution Strategy
29///
30/// When selecting from available versions, the set:
31/// 1. Filters versions that satisfy ALL constraints
32/// 2. Excludes prereleases unless explicitly allowed
33/// 3. Selects the highest remaining version
34///
35/// # Examples
36///
37/// ## Basic Usage
38///
39/// ```rust,no_run
40/// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
41/// use semver::Version;
42///
43/// let mut set = ConstraintSet::new();
44/// set.add(VersionConstraint::parse(">=1.0.0")?)?;
45/// set.add(VersionConstraint::parse("<2.0.0")?)?;
46///
47/// let version = Version::parse("1.5.0")?;
48/// assert!(set.satisfies(&version));
49///
50/// let version = Version::parse("2.0.0")?;
51/// assert!(!set.satisfies(&version)); // Outside range
52/// # Ok::<(), anyhow::Error>(())
53/// ```
54///
55/// ## Best Match Selection
56///
57/// ```rust,no_run
58/// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
59/// use semver::Version;
60///
61/// let mut set = ConstraintSet::new();
62/// set.add(VersionConstraint::parse("^1.0.0")?)?;
63///
64/// let versions = vec![
65///     Version::parse("0.9.0")?,  // Too old
66///     Version::parse("1.0.0")?,  // Matches
67///     Version::parse("1.5.0")?,  // Matches, higher
68///     Version::parse("2.0.0")?,  // Too new
69/// ];
70///
71/// let best = set.find_best_match(&versions).unwrap();
72/// assert_eq!(best, &Version::parse("1.5.0")?); // Highest compatible
73/// # Ok::<(), anyhow::Error>(())
74/// ```
75///
76/// ## Conflict Detection
77///
78/// ```rust,no_run
79/// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
80/// use semver::Version;
81///
82/// let mut set = ConstraintSet::new();
83/// set.add(VersionConstraint::parse("1.0.0")?)?; // Exact version
84///
85/// // This will fail - can't have two different exact versions
86/// let result = set.add(VersionConstraint::parse("2.0.0")?);
87/// assert!(result.is_err());
88/// # Ok::<(), anyhow::Error>(())
89/// ```
90#[derive(Debug, Clone)]
91pub struct ConstraintSet {
92    constraints: Vec<VersionConstraint>,
93}
94
95impl Default for ConstraintSet {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl ConstraintSet {
102    /// Creates a new empty constraint set
103    ///
104    /// # Returns
105    ///
106    /// Returns a new `ConstraintSet` with no constraints
107    #[must_use]
108    pub const fn new() -> Self {
109        Self {
110            constraints: Vec::new(),
111        }
112    }
113
114    /// Add a constraint to this set with conflict detection.
115    ///
116    /// This method adds a new constraint to the set after checking for conflicts
117    /// with existing constraints. If the new constraint would create an impossible
118    /// situation (like requiring two different exact versions), an error is returned.
119    ///
120    /// # Arguments
121    ///
122    /// * `constraint` - The [`VersionConstraint`] to add to this set
123    ///
124    /// # Returns
125    ///
126    /// Returns `Ok(())` if the constraint was added successfully, or `Err` if it
127    /// conflicts with existing constraints.
128    ///
129    /// # Conflict Detection
130    ///
131    /// Current conflict detection covers:
132    /// - **Exact version conflicts**: Different exact versions for the same dependency
133    /// - **Git ref conflicts**: Different Git references for the same dependency
134    ///
135    /// Future versions may add more sophisticated conflict detection for semantic
136    /// version ranges.
137    ///
138    /// # Examples
139    ///
140    /// ```rust,no_run
141    /// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
142    ///
143    /// let mut set = ConstraintSet::new();
144    ///
145    /// // These constraints are compatible
146    /// set.add(VersionConstraint::parse(">=1.0.0")?)?;
147    /// set.add(VersionConstraint::parse("<2.0.0")?)?;
148    ///
149    /// // This would conflict with exact versions
150    /// set.add(VersionConstraint::parse("1.5.0")?)?;
151    /// let result = set.add(VersionConstraint::parse("1.6.0")?);
152    /// assert!(result.is_err()); // Conflict: can't be both 1.5.0 AND 1.6.0
153    /// # Ok::<(), anyhow::Error>(())
154    /// ```
155    pub fn add(&mut self, constraint: VersionConstraint) -> Result<()> {
156        // Check for conflicting constraints
157        if self.has_conflict(&constraint) {
158            return Err(AgpmError::Other {
159                message: format!("Constraint {constraint} conflicts with existing constraints"),
160            }
161            .into());
162        }
163
164        self.constraints.push(constraint);
165        Ok(())
166    }
167
168    /// Check if a version satisfies all constraints in this set.
169    ///
170    /// This method tests whether a given version passes all the constraints
171    /// in this set. For a version to be acceptable, it must satisfy every
172    /// single constraint - this represents a logical AND operation.
173    ///
174    /// # Arguments
175    ///
176    /// * `version` - The semantic version to test against all constraints
177    ///
178    /// # Returns
179    ///
180    /// Returns `true` if the version satisfies ALL constraints, `false` if it
181    /// fails to satisfy any constraint.
182    ///
183    /// # Examples
184    ///
185    /// ```rust,no_run
186    /// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
187    /// use semver::Version;
188    ///
189    /// let mut set = ConstraintSet::new();
190    /// set.add(VersionConstraint::parse(">=1.0.0")?)?; // Must be at least 1.0.0
191    /// set.add(VersionConstraint::parse("<2.0.0")?)?;  // Must be less than 2.0.0
192    /// set.add(VersionConstraint::parse("^1.0.0")?)?;  // Must be compatible with 1.0.0
193    ///
194    /// assert!(set.satisfies(&Version::parse("1.5.0")?)); // Satisfies all three
195    /// assert!(!set.satisfies(&Version::parse("0.9.0")?)); // Fails >=1.0.0
196    /// assert!(!set.satisfies(&Version::parse("2.0.0")?)); // Fails <2.0.0
197    /// # Ok::<(), anyhow::Error>(())
198    /// ```
199    ///
200    /// # Performance Note
201    ///
202    /// This method short-circuits on the first constraint that fails, making it
203    /// efficient even with many constraints.
204    #[must_use]
205    pub fn satisfies(&self, version: &Version) -> bool {
206        self.constraints.iter().all(|c| c.matches(version))
207    }
208
209    /// Find the best matching version from a list of available versions.
210    ///
211    /// This method filters provided versions to find those that satisfy all
212    /// constraints, then selects the "best" match according to AGPM's resolution
213    /// strategy. The selection prioritizes newer versions while respecting prerelease
214    /// preferences.
215    ///
216    /// # Resolution Strategy
217    ///
218    /// 1. **Filter candidates**: Keep only versions that satisfy all constraints
219    /// 2. **Sort by version**: Order candidates from highest to lowest version
220    /// 3. **Apply prerelease policy**: Remove prereleases unless explicitly allowed
221    /// 4. **Select best**: Return the highest remaining version
222    ///
223    /// # Arguments
224    ///
225    /// * `versions` - Slice of available versions to choose from
226    ///
227    /// # Returns
228    ///
229    /// Returns `Some(&Version)` with the best matching version, or `None` if no
230    /// version satisfies all constraints.
231    ///
232    /// # Examples
233    ///
234    /// ```rust,no_run
235    /// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
236    /// use semver::Version;
237    ///
238    /// let mut set = ConstraintSet::new();
239    /// set.add(VersionConstraint::parse("^1.0.0")?)?;
240    ///
241    /// let versions = vec![
242    ///     Version::parse("0.9.0")?,    // Too old
243    ///     Version::parse("1.0.0")?,    // Compatible
244    ///     Version::parse("1.2.0")?,    // Compatible, newer
245    ///     Version::parse("1.5.0")?,    // Compatible, newest
246    ///     Version::parse("2.0.0")?,    // Too new
247    /// ];
248    ///
249    /// let best = set.find_best_match(&versions).unwrap();
250    /// assert_eq!(best, &Version::parse("1.5.0")?); // Highest compatible version
251    /// # Ok::<(), anyhow::Error>(())
252    /// ```
253    ///
254    /// ## Prerelease Handling
255    ///
256    /// ```rust,no_run
257    /// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
258    /// use semver::Version;
259    ///
260    /// let mut set = ConstraintSet::new();
261    /// set.add(VersionConstraint::parse("^1.0.0")?)?; // Doesn't allow prereleases
262    ///
263    /// let versions = vec![
264    ///     Version::parse("1.0.0")?,
265    ///     Version::parse("1.1.0-alpha.1")?,  // Prerelease
266    ///     Version::parse("1.1.0")?,           // Stable
267    /// ];
268    ///
269    /// let best = set.find_best_match(&versions).unwrap();
270    /// assert_eq!(best, &Version::parse("1.1.0")?); // Stable version preferred
271    /// # Ok::<(), anyhow::Error>(())
272    /// ```
273    #[must_use]
274    pub fn find_best_match<'a>(&self, versions: &'a [Version]) -> Option<&'a Version> {
275        let mut candidates: Vec<&Version> = versions.iter().filter(|v| self.satisfies(v)).collect();
276
277        // Sort by version (highest first) with deterministic tie-breaking
278        // Note: Version comparison itself is deterministic, but this protects against potential future issues
279        candidates.sort_by(|a, b| b.cmp(a));
280
281        // If we don't allow prereleases, filter them out
282        if !self.allows_prerelease() {
283            candidates.retain(|v| v.pre.is_empty());
284        }
285
286        candidates.first().copied()
287    }
288
289    /// Check if any constraint in this set allows prerelease versions.
290    ///
291    /// This method determines the prerelease policy for the entire constraint set.
292    /// If ANY constraint in the set allows prereleases, the entire set is considered
293    /// to allow prereleases. This ensures that explicit prerelease constraints
294    /// (like `latest-prerelease` or Git refs) are respected.
295    ///
296    /// # Returns
297    ///
298    /// Returns `true` if any constraint allows prereleases, `false` if all constraints
299    /// exclude prereleases.
300    ///
301    /// # Examples
302    ///
303    /// ```rust,no_run
304    /// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
305    ///
306    /// let mut stable_set = ConstraintSet::new();
307    /// stable_set.add(VersionConstraint::parse("^1.0.0")?)?;
308    /// stable_set.add(VersionConstraint::parse("~1.2.0")?)?;
309    /// assert!(!stable_set.allows_prerelease()); // All constraints exclude prereleases
310    ///
311    /// let mut prerelease_set = ConstraintSet::new();
312    /// prerelease_set.add(VersionConstraint::parse("^1.0.0")?)?;
313    /// prerelease_set.add(VersionConstraint::parse("main")?)?; // Git ref allows prereleases
314    /// assert!(prerelease_set.allows_prerelease()); // One constraint allows prereleases
315    /// # Ok::<(), anyhow::Error>(())
316    /// ```
317    ///
318    /// # Impact on Resolution
319    ///
320    /// This setting affects [`find_best_match`](Self::find_best_match) behavior:
321    /// - If `false`: Prerelease versions are filtered out before selection
322    /// - If `true`: Prerelease versions are included in selection
323    #[must_use]
324    pub fn allows_prerelease(&self) -> bool {
325        self.constraints.iter().any(VersionConstraint::allows_prerelease)
326    }
327
328    /// Check if a new constraint would conflict with existing constraints.
329    ///
330    /// This method performs conflict detection to prevent adding incompatible
331    /// constraints to the same set. It currently detects basic conflicts but
332    /// could be enhanced with more sophisticated analysis in the future.
333    ///
334    /// # Current Conflict Detection
335    ///
336    /// - **Exact version conflicts**: Two different exact versions (`1.0.0` vs `2.0.0`)
337    /// - **Git reference conflicts**: Two different Git refs (`main` vs `develop`)
338    ///
339    /// # Arguments
340    ///
341    /// * `new_constraint` - The constraint to test for conflicts
342    ///
343    /// # Returns
344    ///
345    /// Returns `true` if the constraint conflicts with existing ones, `false` if
346    /// it's compatible.
347    ///
348    /// # Future Enhancements
349    ///
350    /// Future versions could detect more sophisticated conflicts:
351    /// - Impossible version ranges (e.g., `>2.0.0` AND `<1.0.0`)
352    /// - Contradictory semver requirements
353    /// - Mixed version and Git reference constraints
354    ///
355    /// # Examples
356    ///
357    /// ```rust,no_run,ignore
358    /// use agpm_cli::version::constraints::{ConstraintSet, VersionConstraint};
359    ///
360    /// let mut set = ConstraintSet::new();
361    /// set.add(VersionConstraint::parse("1.0.0")?)?;
362    ///
363    /// // This would conflict (different exact versions)
364    /// let conflicting = VersionConstraint::parse("2.0.0")?;
365    /// assert!(set.has_conflict(&conflicting));
366    ///
367    /// // This would not conflict (same exact version)
368    /// let compatible = VersionConstraint::parse("1.0.0")?;
369    /// assert!(!set.has_conflict(&compatible));
370    /// # Ok::<(), anyhow::Error>(())
371    /// ```
372    fn has_conflict(&self, new_constraint: &VersionConstraint) -> bool {
373        // Simple conflict detection - can be enhanced
374        for existing in &self.constraints {
375            match (existing, new_constraint) {
376                (
377                    VersionConstraint::Exact {
378                        prefix: p1,
379                        version: v1,
380                    },
381                    VersionConstraint::Exact {
382                        prefix: p2,
383                        version: v2,
384                    },
385                ) => {
386                    // Different prefixes = different namespaces, no conflict
387                    if p1 != p2 {
388                        continue;
389                    }
390                    // Same prefix (or both None), conflict if different versions
391                    if v1 != v2 {
392                        return true;
393                    }
394                }
395                (VersionConstraint::GitRef(r1), VersionConstraint::GitRef(r2)) => {
396                    if r1 != r2 {
397                        return true;
398                    }
399                }
400                // For Requirement constraints, different prefixes = no conflict
401                (
402                    VersionConstraint::Exact {
403                        prefix: p1,
404                        ..
405                    }
406                    | VersionConstraint::Requirement {
407                        prefix: p1,
408                        ..
409                    },
410                    VersionConstraint::Requirement {
411                        prefix: p2,
412                        ..
413                    },
414                )
415                | (
416                    VersionConstraint::Requirement {
417                        prefix: p1,
418                        ..
419                    },
420                    VersionConstraint::Exact {
421                        prefix: p2,
422                        ..
423                    },
424                ) => {
425                    // Different prefixes = different namespaces, no conflict
426                    if p1 != p2 {
427                        // Continue to next pair
428                    }
429                    // Same prefix - could do more sophisticated conflict detection here
430                }
431                _ => {
432                    // More sophisticated conflict detection could be added here
433                }
434            }
435        }
436        false
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use semver::Version;
444
445    #[test]
446    fn test_constraint_set() {
447        let mut set = ConstraintSet::new();
448        set.add(VersionConstraint::parse(">=1.0.0").unwrap()).unwrap();
449        set.add(VersionConstraint::parse("<2.0.0").unwrap()).unwrap();
450
451        let v090 = Version::parse("0.9.0").unwrap();
452        let v100 = Version::parse("1.0.0").unwrap();
453        let v150 = Version::parse("1.5.0").unwrap();
454        let v200 = Version::parse("2.0.0").unwrap();
455
456        assert!(!set.satisfies(&v090));
457        assert!(set.satisfies(&v100));
458        assert!(set.satisfies(&v150));
459        assert!(!set.satisfies(&v200));
460    }
461
462    #[test]
463    fn test_find_best_match() {
464        let mut set = ConstraintSet::new();
465        set.add(VersionConstraint::parse("^1.0.0").unwrap()).unwrap();
466
467        let versions = vec![
468            Version::parse("0.9.0").unwrap(),
469            Version::parse("1.0.0").unwrap(),
470            Version::parse("1.2.0").unwrap(),
471            Version::parse("1.5.0").unwrap(),
472            Version::parse("2.0.0").unwrap(),
473        ];
474
475        let best = set.find_best_match(&versions).unwrap();
476        assert_eq!(best, &Version::parse("1.5.0").unwrap());
477    }
478
479    #[test]
480    fn test_constraint_conflicts() -> Result<()> {
481        let mut set = ConstraintSet::new();
482
483        // Add first exact version
484        set.add(VersionConstraint::Exact {
485            prefix: None,
486            version: Version::parse("1.0.0").unwrap(),
487        })
488        .unwrap();
489
490        // Try to add conflicting exact version
491        let result = set.add(VersionConstraint::Exact {
492            prefix: None,
493            version: Version::parse("2.0.0").unwrap(),
494        });
495        assert!(result.is_err());
496
497        // Adding the same version should be ok
498        let result = set.add(VersionConstraint::Exact {
499            prefix: None,
500            version: Version::parse("1.0.0").unwrap(),
501        });
502        result?;
503        Ok(())
504    }
505
506    #[test]
507    fn test_allows_prerelease() {
508        assert!(VersionConstraint::GitRef("main".to_string()).allows_prerelease());
509        assert!(VersionConstraint::GitRef("latest".to_string()).allows_prerelease()); // Git ref
510        assert!(
511            !VersionConstraint::Exact {
512                prefix: None,
513                version: Version::parse("1.0.0").unwrap()
514            }
515            .allows_prerelease()
516        );
517    }
518
519    #[test]
520    fn test_constraint_set_with_prereleases() {
521        let mut set = ConstraintSet::new();
522        set.add(VersionConstraint::GitRef("main".to_string())).unwrap();
523
524        let v100_pre = Version::parse("1.0.0-alpha.1").unwrap();
525        let v100 = Version::parse("1.0.0").unwrap();
526
527        assert!(set.allows_prerelease());
528
529        // Git refs don't match semver versions
530        let versions = vec![v100_pre.clone(), v100.clone()];
531        let best = set.find_best_match(&versions);
532        assert!(best.is_none()); // Git refs don't match semver
533    }
534
535    #[test]
536    fn test_constraint_set_no_matches() {
537        let mut set = ConstraintSet::new();
538        set.add(VersionConstraint::parse(">=2.0.0").unwrap()).unwrap();
539
540        let versions = vec![Version::parse("1.0.0").unwrap(), Version::parse("1.5.0").unwrap()];
541
542        let best = set.find_best_match(&versions);
543        assert!(best.is_none());
544    }
545
546    #[test]
547    fn test_constraint_set_git_ref_conflicts() -> Result<()> {
548        let mut set = ConstraintSet::new();
549
550        // Add first git ref
551        set.add(VersionConstraint::GitRef("main".to_string())).unwrap();
552
553        // Try to add conflicting git ref
554        let result = set.add(VersionConstraint::GitRef("develop".to_string()));
555        assert!(result.is_err());
556
557        // Adding the same ref should be ok
558        let result = set.add(VersionConstraint::GitRef("main".to_string()));
559        result?;
560        Ok(())
561    }
562
563    #[test]
564    fn test_constraint_set_prerelease_filtering() {
565        let mut set = ConstraintSet::new();
566        set.add(VersionConstraint::parse("^1.0.0").unwrap()).unwrap();
567
568        let versions = vec![
569            Version::parse("1.0.0-alpha.1").unwrap(),
570            Version::parse("1.0.0").unwrap(),
571            Version::parse("1.1.0-beta.1").unwrap(),
572            Version::parse("1.1.0").unwrap(),
573        ];
574
575        let best = set.find_best_match(&versions).unwrap();
576        assert_eq!(best, &Version::parse("1.1.0").unwrap()); // Should pick highest stable
577    }
578
579    #[test]
580    fn test_constraint_set_no_conflict_different_types() {
581        let mut set = ConstraintSet::new();
582
583        // These shouldn't conflict as they are different types
584        set.add(VersionConstraint::parse("^1.0.0").unwrap()).unwrap();
585        set.add(VersionConstraint::GitRef("main".to_string())).unwrap();
586
587        // Should have 2 constraints
588        assert_eq!(set.constraints.len(), 2);
589    }
590
591    // ========== Prefix Support Tests ==========
592
593    #[test]
594    fn test_prefixed_constraint_conflicts() -> Result<()> {
595        let mut set = ConstraintSet::new();
596
597        // Add prefixed constraint
598        set.add(VersionConstraint::parse("agents-^v1.0.0").unwrap()).unwrap();
599
600        // Different prefix should not conflict
601        let result = set.add(VersionConstraint::parse("snippets-^v1.0.0").unwrap());
602        result?;
603
604        // Same prefix but compatible constraints should not conflict
605        let result = set.add(VersionConstraint::parse("agents-~v1.2.0").unwrap());
606        result?;
607
608        // Different prefixes for Exact constraints
609        let mut exact_set = ConstraintSet::new();
610        exact_set.add(VersionConstraint::parse("agents-v1.0.0").unwrap()).unwrap();
611
612        // Different prefix, same version - should not conflict
613        let result = exact_set.add(VersionConstraint::parse("snippets-v1.0.0").unwrap());
614        result?;
615        Ok(())
616    }
617}