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}