1use semver::{Comparator, Op, Version, VersionReq};
9
10use crate::scoring::parse_version;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Bound {
15 pub version: Version,
17 pub inclusive: bool,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct VersionInterval {
24 pub lo: Option<Bound>,
26 pub hi: Option<Bound>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum RequestedVersion {
33 Concrete(Version),
35 Range(VersionInterval),
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MatchClass {
42 Satisfies,
44 NearMissPatch(u32),
46 NearMissMinor(u32),
48 Breaking,
50 Unknown,
52}
53
54#[must_use]
60pub fn parse_request(raw: &str) -> Option<RequestedVersion> {
61 if let Some(v) = parse_version(raw) {
63 return Some(RequestedVersion::Concrete(v));
69 }
70 let parsed = VersionReq::parse(raw.trim()).ok()?;
71 let interval = VersionInterval::from_req(&parsed)?;
72 if interval.is_empty() {
73 return None;
74 }
75 Some(RequestedVersion::Range(interval))
76}
77
78#[must_use]
81pub fn classify(requested: &RequestedVersion, declared: Option<&str>) -> MatchClass {
82 let Some(constraint) = declared else {
83 return MatchClass::Satisfies; };
85 let Ok(declared_req) = VersionReq::parse(constraint) else {
86 return MatchClass::Unknown;
87 };
88 match requested {
91 RequestedVersion::Concrete(v) => {
92 if declared_req.matches(v) {
93 return MatchClass::Satisfies;
94 }
95 }
96 RequestedVersion::Range(ri) => {
97 let Some(di) = VersionInterval::from_req(&declared_req) else {
98 return MatchClass::Unknown;
99 };
100 if ri.intersects(&di) {
101 return MatchClass::Satisfies;
102 }
103 }
104 }
105 let Some(di) = VersionInterval::from_req(&declared_req) else {
106 return MatchClass::Unknown;
107 };
108 let ri = match requested {
109 RequestedVersion::Concrete(v) => VersionInterval::point(v.clone()),
110 RequestedVersion::Range(r) => r.clone(),
111 };
112 let Some((a, b)) = gap_pair(&ri, &di) else {
116 return MatchClass::Unknown; };
118 mismatch_class(&a, &b)
119}
120
121fn representative(b: &Bound, is_upper: bool) -> Version {
124 if b.inclusive {
125 return b.version.clone();
126 }
127 let v = &b.version;
128 if is_upper {
129 if v.patch > 0 {
131 Version::new(v.major, v.minor, v.patch - 1)
132 } else if v.minor > 0 {
133 Version::new(v.major, v.minor - 1, u64::MAX)
134 } else if v.major > 0 {
135 Version::new(v.major - 1, u64::MAX, u64::MAX)
136 } else {
137 v.clone() }
139 } else {
140 Version::new(v.major, v.minor, v.patch + 1)
142 }
143}
144
145fn gap_pair(req: &VersionInterval, decl: &VersionInterval) -> Option<(Version, Version)> {
147 if let (Some(r), Some(d)) = (&req.hi, &decl.lo) {
149 if r.version <= d.version {
150 return Some((representative(r, true), representative(d, false)));
151 }
152 }
153 if let (Some(r), Some(d)) = (&req.lo, &decl.hi) {
155 if r.version >= d.version {
156 return Some((representative(r, false), representative(d, true)));
157 }
158 }
159 None
160}
161
162fn mismatch_class(a: &Version, b: &Version) -> MatchClass {
165 #[allow(clippy::cast_possible_truncation)]
166 fn dist(x: u64, y: u64) -> u32 {
167 x.abs_diff(y).min(u64::from(u32::MAX)) as u32
168 }
169 if a == b {
170 return MatchClass::NearMissPatch(1);
172 }
173 if a.major != b.major {
174 return MatchClass::Breaking;
175 }
176 if a.major == 0 {
177 if a.minor != b.minor || a.minor == 0 {
178 return MatchClass::Breaking; }
180 return MatchClass::NearMissMinor(dist(a.patch, b.patch).max(1));
181 }
182 if a.minor != b.minor {
183 return MatchClass::NearMissMinor(dist(a.minor, b.minor));
184 }
185 MatchClass::NearMissPatch(dist(a.patch, b.patch).max(1))
186}
187
188#[must_use]
190pub const fn class_rank(c: &MatchClass) -> (u8, u32) {
191 match c {
192 MatchClass::Satisfies => (0, 0),
193 MatchClass::NearMissPatch(d) => (1, *d),
194 MatchClass::NearMissMinor(d) => (2, *d),
195 MatchClass::Unknown => (3, 0),
196 MatchClass::Breaking => (4, 0),
197 }
198}
199
200impl VersionInterval {
201 #[must_use]
203 pub const fn unbounded() -> Self {
204 Self { lo: None, hi: None }
205 }
206
207 #[must_use]
209 pub fn point(v: Version) -> Self {
210 Self {
211 lo: Some(Bound {
212 version: v.clone(),
213 inclusive: true,
214 }),
215 hi: Some(Bound { version: v, inclusive: true }),
216 }
217 }
218
219 #[must_use]
222 pub fn from_req(req: &VersionReq) -> Option<Self> {
223 let mut iv = Self::unbounded();
224 for c in &req.comparators {
225 iv = iv.intersect(&comparator_interval(c)?);
226 }
227 Some(iv)
228 }
229
230 #[must_use]
232 pub fn contains(&self, v: &Version) -> bool {
233 if let Some(lo) = &self.lo {
234 if *v < lo.version || (*v == lo.version && !lo.inclusive) {
235 return false;
236 }
237 }
238 if let Some(hi) = &self.hi {
239 if *v > hi.version || (*v == hi.version && !hi.inclusive) {
240 return false;
241 }
242 }
243 true
244 }
245
246 #[must_use]
248 pub fn intersects(&self, other: &Self) -> bool {
249 !self.intersect(other).is_empty()
250 }
251
252 #[must_use]
254 pub fn is_empty(&self) -> bool {
255 match (&self.lo, &self.hi) {
256 (Some(lo), Some(hi)) => {
257 lo.version > hi.version
258 || (lo.version == hi.version && !(lo.inclusive && hi.inclusive))
259 }
260 _ => false,
261 }
262 }
263
264 fn intersect(&self, other: &Self) -> Self {
266 let lo = match (&self.lo, &other.lo) {
267 (Some(a), Some(b)) => {
268 Some(if (b.version.clone(), !b.inclusive) > (a.version.clone(), !a.inclusive) {
269 b.clone()
270 } else {
271 a.clone()
272 })
273 }
274 (Some(a), None) => Some(a.clone()),
275 (None, b) => b.clone(),
276 };
277 let hi = match (&self.hi, &other.hi) {
278 (Some(a), Some(b)) => {
279 Some(if (b.version.clone(), b.inclusive) < (a.version.clone(), a.inclusive) {
280 b.clone()
281 } else {
282 a.clone()
283 })
284 }
285 (Some(a), None) => Some(a.clone()),
286 (None, b) => b.clone(),
287 };
288 Self { lo, hi }
289 }
290}
291
292fn comparator_interval(c: &Comparator) -> Option<VersionInterval> {
295 let maj = c.major;
296 let min = c.minor;
297 let pat = c.patch;
298 let base = Version::new(maj, min.unwrap_or(0), pat.unwrap_or(0));
299 let lo_incl = |v: Version| Some(Bound { version: v, inclusive: true });
300 let hi_excl = |v: Version| Some(Bound { version: v, inclusive: false });
301 let next_major = Version::new(maj + 1, 0, 0);
302 let next_minor = |j: u64| Version::new(maj, j + 1, 0);
303 Some(match c.op {
304 Op::Exact => match (min, pat) {
305 (Some(_), Some(_)) => VersionInterval::point(base),
306 (Some(j), None) => VersionInterval {
307 lo: lo_incl(base),
308 hi: hi_excl(next_minor(j)),
309 },
310 (None, _) => VersionInterval {
311 lo: lo_incl(base),
312 hi: hi_excl(next_major),
313 },
314 },
315 Op::Greater => match (min, pat) {
316 (Some(_), Some(_)) => VersionInterval {
317 lo: Some(Bound {
318 version: base,
319 inclusive: false,
320 }),
321 hi: None,
322 },
323 (Some(j), None) => VersionInterval {
324 lo: lo_incl(next_minor(j)),
325 hi: None,
326 },
327 (None, _) => VersionInterval {
328 lo: lo_incl(next_major),
329 hi: None,
330 },
331 },
332 Op::GreaterEq => VersionInterval { lo: lo_incl(base), hi: None },
333 Op::Less => VersionInterval { lo: None, hi: hi_excl(base) },
334 Op::LessEq => match (min, pat) {
335 (Some(_), Some(_)) => VersionInterval {
336 lo: None,
337 hi: Some(Bound { version: base, inclusive: true }),
338 },
339 (Some(j), None) => VersionInterval {
340 lo: None,
341 hi: hi_excl(next_minor(j)),
342 },
343 (None, _) => VersionInterval {
344 lo: None,
345 hi: hi_excl(next_major),
346 },
347 },
348 Op::Tilde | Op::Wildcard => match (min, pat) {
350 (Some(j), _) => VersionInterval {
351 lo: lo_incl(base),
352 hi: hi_excl(next_minor(j)),
353 },
354 (None, _) => VersionInterval {
355 lo: lo_incl(base),
356 hi: hi_excl(next_major),
357 },
358 },
359 Op::Caret => {
360 let hi = if maj > 0 {
361 next_major
362 } else if min.unwrap_or(0) > 0 {
363 next_minor(min.unwrap_or(0))
364 } else if pat.is_some() {
365 Version::new(0, 0, pat.unwrap_or(0) + 1)
366 } else {
367 Version::new(1, 0, 0)
369 };
370 VersionInterval {
371 lo: lo_incl(base),
372 hi: hi_excl(hi),
373 }
374 }
375 _ => return None,
376 })
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use proptest::prelude::*;
383
384 fn v(s: &str) -> Version {
385 parse_version(s).unwrap()
386 }
387 fn req(s: &str) -> RequestedVersion {
388 parse_request(s).unwrap()
389 }
390
391 #[test]
392 fn parse_request_bare_is_concrete() {
393 assert_eq!(req("0.31"), RequestedVersion::Concrete(v("0.31")));
394 assert_eq!(req("v1.4.2"), RequestedVersion::Concrete(v("1.4.2")));
395 }
396
397 #[test]
398 fn parse_request_operators_are_ranges() {
399 for s in [">=0.23", "^1.2", "~1.4.2", ">=1.0, <2.0", "1.2.*"] {
400 assert!(matches!(parse_request(s), Some(RequestedVersion::Range(_))), "{s}");
401 }
402 }
403
404 #[test]
405 fn parse_request_rejects_garbage_and_empty_ranges() {
406 assert_eq!(parse_request("not-a-version"), None);
407 assert_eq!(parse_request(">=2.0, <1.0"), None);
409 }
410
411 #[test]
412 fn classify_concrete_against_range() {
413 assert_eq!(classify(&req("0.31"), Some(">=0.23")), MatchClass::Satisfies);
415 assert_eq!(classify(&req("0.10"), Some(">=0.23")), MatchClass::Breaking);
417 assert_eq!(
420 classify(&req("0.23.9"), Some(">=0.23.0, <0.23.5")),
421 MatchClass::NearMissMinor(5)
422 );
423 assert_eq!(classify(&req("1.6.0"), Some("^1.4")), MatchClass::Satisfies);
425 assert_eq!(classify(&req("1.6.0"), Some(">=1.4, <1.5")), MatchClass::NearMissMinor(2));
427 assert_eq!(classify(&req("1.4.5"), Some(">=1.4.0, <1.4.3")), MatchClass::NearMissPatch(3));
431 assert_eq!(classify(&req("2.0.0"), Some("^1.4")), MatchClass::Breaking);
433 assert_eq!(classify(&req("0.0.9"), Some("=0.0.3")), MatchClass::Breaking);
435 }
436
437 #[test]
438 fn classify_edge_cases() {
439 assert_eq!(classify(&req("0.31"), None), MatchClass::Satisfies);
441 assert_eq!(classify(&req("0.31"), Some("banana")), MatchClass::Unknown);
443 assert_eq!(classify(&req("1.5.0"), Some(">=1.4.0, <1.5.0")), MatchClass::NearMissMinor(1));
446 assert_eq!(classify(&req("^1.4"), Some(">=1.0, <2.0")), MatchClass::Satisfies);
448 assert_eq!(classify(&req(">=2.0"), Some("^1.4")), MatchClass::Breaking);
449 }
450
451 #[test]
452 fn class_rank_orders_best_first() {
453 let mut classes = vec![
454 MatchClass::Breaking,
455 MatchClass::NearMissMinor(2),
456 MatchClass::Satisfies,
457 MatchClass::Unknown,
458 MatchClass::NearMissPatch(1),
459 MatchClass::NearMissMinor(1),
460 ];
461 classes.sort_by_key(class_rank);
462 assert_eq!(
463 classes,
464 vec![
465 MatchClass::Satisfies,
466 MatchClass::NearMissPatch(1),
467 MatchClass::NearMissMinor(1),
468 MatchClass::NearMissMinor(2),
469 MatchClass::Unknown,
470 MatchClass::Breaking,
471 ]
472 );
473 }
474
475 proptest! {
476 #[test]
479 fn interval_agrees_with_matches_oracle(
480 major in 0u64..3, minor in 0u64..50, patch in 0u64..50,
481 req_major in 0u64..3, req_minor in 0u64..50, req_patch in 0u64..50,
482 op in prop::sample::select(vec!["=", ">", ">=", "<", "<=", "~", "^"]),
483 ) {
484 let candidate = Version::new(major, minor, patch);
485 let raw = format!("{op}{req_major}.{req_minor}.{req_patch}");
486 let parsed = VersionReq::parse(&raw).unwrap();
487 let interval = VersionInterval::from_req(&parsed).unwrap();
488 prop_assert_eq!(interval.contains(&candidate), parsed.matches(&candidate), "{}", raw);
489 }
490 }
491}