deps_core/interval.rs
1//! Shared bracket-interval version-range grammar (#821).
2//!
3//! Maven, Gradle, and NuGet all express a single version range as a bracket interval
4//! (`[1.0,2.0)`, `[1.0]`, `[1.5,)`, `(,2.0]`); Gradle additionally accepts a reversed-bracket
5//! exclusive notation Maven/NuGet do not have (`]1.2,1.5]` for an exclusive lower bound,
6//! `[1.1,2.0[` for an exclusive upper bound), selected via [`crate::interval::BracketStyle`].
7//! This grammar used to be implemented twice: once (hardened, with three rejection guards for
8//! malformed input) in `deps-maven`, reused by `deps-gradle`, and once (independently, missing
9//! all three guards) in `deps-nuget` — which let `deps-nuget` silently accept malformed ranges
10//! (`[[1.0,2.0)`, `[1.0,2.0,3.0]`, `(1.0)`, `[]`) that Maven/Gradle correctly rejected. This
11//! module is the single implementation both grammars route through.
12//!
13//! What stays ecosystem-specific, and deliberately does **not** live here, is bound *parsing*
14//! and *comparison*: Maven's bounds carry qualifier precedence (`alpha < beta < milestone < rc
15//! < snapshot < release < sp`), NuGet's carry SemVer2 prerelease precedence with
16//! case-insensitive labels, and both normalize a missing trailing segment as zero.
17//! [`crate::interval::VersionRange`] is therefore generic over the ecosystem's own bound type
18//! `V`; [`crate::interval::parse_interval`] takes a `parse_bound` closure to turn a bound
19//! substring into `V`, and [`crate::interval::contains`] takes a `cmp` closure to compare a
20//! candidate against a bound. See each item's own doc for the exact signature.
21
22use std::cmp::Ordering;
23use std::ops::Bound;
24
25/// A single parsed bracket interval, e.g. `[1.0,2.0)` or `[1.0]`.
26///
27/// Generic over the ecosystem-specific bound type `V` (e.g. a raw version string for
28/// Maven/Gradle, or an already-parsed `ParsedVersion` for NuGet).
29///
30/// Every non-exact, satisfiable shape is a single [`Interval`], whose fields are private:
31/// [`range_from_edges`] is the only constructor, so an inverted or zero-width-exclusive
32/// interval is unrepresentable — it collapses to [`VersionRange::Empty`] instead.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum VersionRange<V> {
35 /// `[1.0]` — matches only that exact version.
36 Exact(V),
37 /// `[1.0,2.0)`, `[1.5,)`, `(,2.0]` — at least one bound present and the bounds admit
38 /// some version.
39 Interval(Interval<V>),
40 /// A syntactically well-formed bounded interval that can never be satisfied by any
41 /// version: `min > max` (`[5.0,3.0]`), or `min == max` with either bound exclusive
42 /// (`(3.0,3.0)`, `[3.0,3.0)`) — see #1595. Distinct from [`parse_interval`] returning
43 /// `None` (malformed syntax): a malformed member invalidates a whole union for callers
44 /// like Maven's, while `Empty` is a well-formed member that legitimately contributes
45 /// nothing — [`contains`] reports `false` for every candidate, so a requirement made up
46 /// entirely of `Empty` members is correctly reported as unsatisfiable rather than
47 /// undecidable, and an `Empty` member alongside valid ones in a union does not corrupt
48 /// gap detection over the valid members with a fabricated edge.
49 Empty,
50}
51
52/// The two edges of a satisfiable, non-exact [`VersionRange::Interval`].
53///
54/// Invariant: at least one edge is bounded; if both are bounded, `lower <= upper` and
55/// `lower == upper` only when both are `Included` — ordered per the comparator passed to
56/// [`range_from_edges`], the only constructor. A consumer comparing with a different ordering
57/// can still observe `lower > upper`.
58///
59/// # Examples
60///
61/// Constructing through [`range_from_edges`] and reading the edges back:
62///
63/// ```
64/// use deps_core::interval::{VersionRange, range_from_edges};
65/// use std::ops::Bound;
66///
67/// let range = range_from_edges(Bound::Included(1), Bound::Excluded(2), |a: &u32, b: &u32| a.cmp(b));
68/// let Some(VersionRange::Interval(iv)) = range else { unreachable!() };
69/// assert_eq!(iv.lower(), Bound::Included(&1));
70/// assert_eq!(iv.upper(), Bound::Excluded(&2));
71/// ```
72///
73/// The fields are private, so an interval cannot be built directly:
74///
75/// ```compile_fail,E0451
76/// use deps_core::interval::Interval;
77/// use std::ops::Bound;
78///
79/// let _ = Interval { lower: Bound::Included(2), upper: Bound::Included(1) };
80/// ```
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Interval<V> {
83 lower: Bound<V>,
84 upper: Bound<V>,
85}
86
87impl<V> Interval<V> {
88 /// The lower edge — `Unbounded` for an open-ended-below interval.
89 pub const fn lower(&self) -> Bound<&V> {
90 match &self.lower {
91 Bound::Included(v) => Bound::Included(v),
92 Bound::Excluded(v) => Bound::Excluded(v),
93 Bound::Unbounded => Bound::Unbounded,
94 }
95 }
96
97 /// The upper edge — `Unbounded` for an open-ended-above interval.
98 pub const fn upper(&self) -> Bound<&V> {
99 match &self.upper {
100 Bound::Included(v) => Bound::Included(v),
101 Bound::Excluded(v) => Bound::Excluded(v),
102 Bound::Unbounded => Bound::Unbounded,
103 }
104 }
105}
106
107impl<V> VersionRange<V> {
108 /// This range's upper edge as a [`Bound`] — `Unbounded` for an open-ended-above interval
109 /// and `Empty` (admits nothing).
110 ///
111 /// Shared edge accessor for union-gap detection (`deps-maven`'s disjoint-range-union check,
112 /// `deps-composer`'s OR-alternation check, #1610): both derive a member's edges through this
113 /// method instead of independently re-matching `VersionRange`'s variants.
114 ///
115 /// # Examples
116 ///
117 /// ```
118 /// use deps_core::interval::{range_from_edges, VersionRange};
119 /// use std::ops::Bound;
120 ///
121 /// let cmp = |a: &String, b: &String| a.cmp(b);
122 /// let bounded = range_from_edges(
123 /// Bound::Included("1.0".to_string()),
124 /// Bound::Excluded("2.0".to_string()),
125 /// cmp,
126 /// )
127 /// .unwrap();
128 /// assert_eq!(bounded.upper_edge(), Bound::Excluded(&"2.0".to_string()));
129 ///
130 /// let minimum = range_from_edges(Bound::Included("1.5".to_string()), Bound::Unbounded, cmp).unwrap();
131 /// assert_eq!(minimum.upper_edge(), Bound::Unbounded);
132 /// ```
133 pub const fn upper_edge(&self) -> Bound<&V> {
134 match self {
135 Self::Exact(v) => Bound::Included(v),
136 Self::Interval(iv) => iv.upper(),
137 Self::Empty => Bound::Unbounded,
138 }
139 }
140
141 /// This range's lower edge as a [`Bound`] — `Unbounded` for an open-ended-below interval
142 /// and `Empty` (admits nothing). See [`Self::upper_edge`].
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use deps_core::interval::{range_from_edges, VersionRange};
148 /// use std::ops::Bound;
149 ///
150 /// let cmp = |a: &String, b: &String| a.cmp(b);
151 /// let bounded = range_from_edges(
152 /// Bound::Included("1.0".to_string()),
153 /// Bound::Excluded("2.0".to_string()),
154 /// cmp,
155 /// )
156 /// .unwrap();
157 /// assert_eq!(bounded.lower_edge(), Bound::Included(&"1.0".to_string()));
158 ///
159 /// let maximum = range_from_edges(Bound::Unbounded, Bound::Excluded("2.0".to_string()), cmp).unwrap();
160 /// assert_eq!(maximum.lower_edge(), Bound::Unbounded);
161 /// ```
162 pub const fn lower_edge(&self) -> Bound<&V> {
163 match self {
164 Self::Exact(v) => Bound::Included(v),
165 Self::Interval(iv) => iv.lower(),
166 Self::Empty => Bound::Unbounded,
167 }
168 }
169}
170
171/// Builds a [`VersionRange`] from a lower/upper edge pair — the only constructor of [`Interval`].
172///
173/// Used by [`parse_interval`] for bracket syntax and directly by callers that derive edges
174/// independently (Composer's per-`||`-branch bound, AND-intersected clause by clause, #1610).
175///
176/// Applies the unsatisfiable-shape detection of #1595: `lower > upper`, or `lower == upper`
177/// with either edge exclusive, collapses to [`VersionRange::Empty`].
178///
179/// Returns `None` only when both edges are absent: "no constraint at all" (open on both sides)
180/// has no `VersionRange` shape to express it as — bracket syntax has no such form either (see
181/// [`parse_interval`]'s `(Unbounded, Unbounded)` case) — so the caller decides what an
182/// edge-less bound means for its own use case.
183///
184/// # Examples
185///
186/// ```
187/// use deps_core::interval::{VersionRange, range_from_edges};
188/// use std::ops::Bound;
189///
190/// let cmp = |a: &String, b: &String| a.cmp(b);
191/// let range = range_from_edges(
192/// Bound::Included("1.0".to_string()),
193/// Bound::Excluded("2.0".to_string()),
194/// cmp,
195/// )
196/// .unwrap();
197/// assert_eq!(range.lower_edge(), Bound::Included(&"1.0".to_string()));
198/// assert_eq!(range.upper_edge(), Bound::Excluded(&"2.0".to_string()));
199///
200/// // Inverted bounds collapse to `Empty`.
201/// assert_eq!(
202/// range_from_edges(Bound::Included("3.0".to_string()), Bound::Included("2.0".to_string()), cmp),
203/// Some(VersionRange::Empty)
204/// );
205///
206/// // Both edges absent has no `VersionRange` shape.
207/// assert_eq!(
208/// range_from_edges::<String>(Bound::Unbounded, Bound::Unbounded, cmp),
209/// None
210/// );
211/// ```
212pub fn range_from_edges<V>(
213 lower: Bound<V>,
214 upper: Bound<V>,
215 cmp_bound: impl Fn(&V, &V) -> Ordering,
216) -> Option<VersionRange<V>> {
217 let unsatisfiable = match (&lower, &upper) {
218 (Bound::Unbounded, Bound::Unbounded) => return None,
219 (Bound::Included(l) | Bound::Excluded(l), Bound::Included(u) | Bound::Excluded(u)) => {
220 let ord = cmp_bound(l, u);
221 ord == Ordering::Greater
222 || (ord == Ordering::Equal
223 && !matches!((&lower, &upper), (Bound::Included(_), Bound::Included(_))))
224 }
225 _ => false,
226 };
227 Some(if unsatisfiable {
228 VersionRange::Empty
229 } else {
230 VersionRange::Interval(Interval { lower, upper })
231 })
232}
233
234/// Selects the delimiter grammar [`parse_interval`] accepts.
235///
236/// `Standard` is Maven's/NuGet's grammar: `[`/`]` are inclusive, `(`/`)` are exclusive, and no
237/// character serves as both an opener and a closer. `AllowReversed` adds Gradle's
238/// reversed-bracket exclusive notation on top: a leading `]` or trailing `[` is also accepted
239/// as an exclusive bound (`]1.2,1.5]`, `[1.1,2.0[`).
240///
241/// **Exhaustive** (issue #769): a 2-variant grammar selector fixed by `parse_interval`'s call
242/// sites — a third grammar would change the calling convention there too, not slot into an
243/// existing wildcard arm.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum BracketStyle {
246 /// Maven's/NuGet's grammar: `[`/`]` inclusive, `(`/`)` exclusive only.
247 Standard,
248 /// `Standard` plus Gradle's reversed-bracket exclusive notation (`]`/`[`).
249 AllowReversed,
250}
251
252/// Whether a bracket delimiter includes or excludes its bound.
253#[derive(Clone, Copy)]
254enum Edge {
255 Inclusive,
256 Exclusive,
257}
258
259impl Edge {
260 fn wrap<V>(self, v: V) -> Bound<V> {
261 match self {
262 Self::Inclusive => Bound::Included(v),
263 Self::Exclusive => Bound::Excluded(v),
264 }
265 }
266}
267
268/// Outcome of parsing one side of a bracket interval's bound, distinguishing "no bound"
269/// (an open-ended range) from a parse failure (reject the whole interval) — a plain
270/// `Option<Option<V>>` cannot express this three-way split without clippy flagging it.
271enum ParsedBound<V> {
272 /// The bound substring was empty — an open-ended minimum/maximum.
273 Open,
274 /// `parse_bound` accepted the bound substring.
275 Value(V),
276 /// `parse_bound` rejected a non-empty bound substring.
277 Invalid,
278}
279
280/// Parses `s` as a bound, distinguishing "no bound" (empty string) from a parse failure.
281fn parse_bound_side<V>(s: &str, parse_bound: &impl Fn(&str) -> Option<V>) -> ParsedBound<V> {
282 if s.is_empty() {
283 ParsedBound::Open
284 } else {
285 match parse_bound(s) {
286 Some(v) => ParsedBound::Value(v),
287 None => ParsedBound::Invalid,
288 }
289 }
290}
291
292/// Parses one bracketed interval under the given [`BracketStyle`], turning each bound
293/// substring into `V` via `parse_bound`.
294///
295/// Returns `None` for anything that isn't a well-formed `[`/`(`/`]` ... `]`/`)`/`[` interval:
296/// unbalanced delimiters, a single character that cannot serve as both delimiters, empty
297/// bounds on both sides, a stray bracket character nested inside the bounds (e.g.
298/// `[[1.0,2.0)`, `[1.0,2.0)]`), a third comma-separated component (`[1.0,2.0,3.0]`), a
299/// no-comma body whose delimiters aren't the matching inclusive pair `[...]` (`[1.0)`,
300/// `(1.0]`, `(1.0)` — neither grammar has a reversed-bracket exact-pin form), or a bound
301/// substring `parse_bound` itself rejects. Callers treat an unparseable interval as
302/// satisfying nothing rather than panicking.
303///
304/// Returns `Some(VersionRange::Empty)` — not `None` — for a bounded range that parses
305/// syntactically but can never be satisfied: `min > max` per `cmp_bound` (`[5.0,3.0]`), or
306/// `min == max` with either bound exclusive (`(3.0,3.0)`, `[3.0,3.0)`) — see #1595 and
307/// [`VersionRange::Empty`]'s own doc for why this must not be conflated with the malformed-
308/// syntax `None` case.
309///
310/// # Examples
311///
312/// ```
313/// use deps_core::interval::{BracketStyle, VersionRange, parse_interval};
314/// use std::ops::Bound;
315///
316/// let cmp = |a: &String, b: &String| a.cmp(b);
317///
318/// let range = parse_interval("[1.0,2.0)", BracketStyle::Standard, |b| Some(b.to_string()), cmp);
319/// let range = range.unwrap();
320/// assert_eq!(range.lower_edge(), Bound::Included(&"1.0".to_string()));
321/// assert_eq!(range.upper_edge(), Bound::Excluded(&"2.0".to_string()));
322///
323/// // Malformed shapes are rejected, not silently accepted.
324/// assert_eq!(parse_interval::<String>("[1.0,2.0,3.0]", BracketStyle::Standard, |b| Some(b.to_string()), cmp), None);
325/// assert_eq!(parse_interval::<String>("(1.0)", BracketStyle::Standard, |b| Some(b.to_string()), cmp), None);
326///
327/// // An inverted or zero-width-exclusive bounded range parses to `Empty`, not `None`.
328/// assert_eq!(parse_interval::<String>("[5.0,3.0]", BracketStyle::Standard, |b| Some(b.to_string()), cmp), Some(VersionRange::Empty));
329/// assert_eq!(parse_interval::<String>("(3.0,3.0)", BracketStyle::Standard, |b| Some(b.to_string()), cmp), Some(VersionRange::Empty));
330///
331/// // A zero-width range with both bounds inclusive is a valid single-point match.
332/// assert!(parse_interval::<String>("[3.0,3.0]", BracketStyle::Standard, |b| Some(b.to_string()), cmp).is_some_and(|r| r != VersionRange::Empty));
333/// ```
334#[expect(
335 clippy::string_slice,
336 reason = "first/last are chars() ends sliced at len_utf8, and the explicit length guard \
337 above prevents start > end, so the slice bound is always a char boundary"
338)]
339pub fn parse_interval<V>(
340 s: &str,
341 style: BracketStyle,
342 parse_bound: impl Fn(&str) -> Option<V>,
343 cmp_bound: impl Fn(&V, &V) -> Ordering,
344) -> Option<VersionRange<V>> {
345 let s = s.trim();
346 let first = s.chars().next()?;
347 let lower_edge = match (first, style) {
348 ('[', _) => Edge::Inclusive,
349 ('(', _) => Edge::Exclusive,
350 (']', BracketStyle::AllowReversed) => Edge::Exclusive,
351 _ => return None,
352 };
353 let last = s.chars().next_back()?;
354 let upper_edge = match (last, style) {
355 (']', _) => Edge::Inclusive,
356 (')', _) => Edge::Exclusive,
357 ('[', BracketStyle::AllowReversed) => Edge::Exclusive,
358 _ => return None,
359 };
360
361 // A single character cannot be both delimiters; without this the slice below
362 // would have start > end (AllowReversed makes `[` and `]` valid on both sides).
363 if s.len() < first.len_utf8() + last.len_utf8() {
364 return None;
365 }
366
367 let inner = &s[first.len_utf8()..s.len() - last.len_utf8()];
368
369 if inner.contains(['[', ']', '(', ')']) {
370 return None;
371 }
372
373 if let Some((lo, hi)) = inner.split_once(',') {
374 if hi.contains(',') {
375 return None;
376 }
377 let lower = parse_bound_side(lo.trim(), &parse_bound);
378 let upper = parse_bound_side(hi.trim(), &parse_bound);
379 let wrap = |side: ParsedBound<V>, edge: Edge| match side {
380 ParsedBound::Invalid => None,
381 ParsedBound::Open => Some(Bound::Unbounded),
382 ParsedBound::Value(v) => Some(edge.wrap(v)),
383 };
384 range_from_edges(
385 wrap(lower, lower_edge)?,
386 wrap(upper, upper_edge)?,
387 cmp_bound,
388 )
389 } else {
390 let inner = inner.trim();
391 if inner.is_empty()
392 || !matches!((lower_edge, upper_edge), (Edge::Inclusive, Edge::Inclusive))
393 {
394 return None;
395 }
396 parse_bound(inner).map(VersionRange::Exact)
397 }
398}
399
400/// Whether `v` falls inside the parsed interval `range`, comparing via `cmp`.
401///
402/// `Q` (the candidate's type) and `V` (the range's bound type) are independent: a caller may
403/// compare a raw `&str` candidate against `VersionRange<String>` bounds without allocating
404/// (Maven/Gradle), or a pre-parsed candidate against pre-parsed bounds of the same type
405/// (NuGet).
406///
407/// # Examples
408///
409/// ```
410/// use deps_core::interval::{BracketStyle, contains, parse_interval};
411///
412/// let range = parse_interval("[1.0,2.0)", BracketStyle::Standard, |b| Some(b.to_string()), |a, b| a.cmp(b)).unwrap();
413/// let cmp = |a: &str, b: &String| a.cmp(b.as_str());
414/// assert!(contains("1.5", &range, cmp));
415/// assert!(!contains("2.0", &range, cmp));
416/// ```
417pub fn contains<Q: ?Sized, V>(
418 v: &Q,
419 range: &VersionRange<V>,
420 cmp: impl Fn(&Q, &V) -> Ordering,
421) -> bool {
422 match range {
423 VersionRange::Exact(target) => cmp(v, target) == Ordering::Equal,
424 VersionRange::Interval(iv) => {
425 admits_at_or_below(v, iv.lower(), &cmp) && admits_at_or_above(v, iv.upper(), &cmp)
426 }
427 // A well-formed but unsatisfiable range admits nothing — see `VersionRange::Empty`.
428 VersionRange::Empty => false,
429 }
430}
431
432/// Whether an upper-bounded member admits some value at or above `candidate`.
433///
434/// `upper_edge` is a [`Bound`], `Unbounded` for open-ended-above. `Unbounded` always answers
435/// `true`: "no known upper edge" means "assume unbounded", the safe default for a member whose
436/// shape [`union_gap_excludes`]'s caller could not characterize (mirrors `deps-maven`'s own
437/// `upper_edge`/`lower_edge` wildcard-arm convention of contributing no edge for an
438/// unrecognized/degenerate shape rather than guessing one).
439pub fn admits_at_or_above<Q: ?Sized, V: ?Sized>(
440 candidate: &Q,
441 upper_edge: Bound<&V>,
442 cmp: impl Fn(&Q, &V) -> Ordering,
443) -> bool {
444 match upper_edge {
445 Bound::Unbounded => true,
446 Bound::Included(bound) => cmp(candidate, bound) != Ordering::Greater,
447 Bound::Excluded(bound) => cmp(candidate, bound) == Ordering::Less,
448 }
449}
450
451/// Whether a lower-bounded member admits some value at or below `candidate`.
452///
453/// `lower_edge` is a [`Bound`], `Unbounded` for open-ended-below. See
454/// [`admits_at_or_above`]'s doc for why `Unbounded` always answers `true`.
455pub fn admits_at_or_below<Q: ?Sized, V: ?Sized>(
456 candidate: &Q,
457 lower_edge: Bound<&V>,
458 cmp: impl Fn(&Q, &V) -> Ordering,
459) -> bool {
460 match lower_edge {
461 Bound::Unbounded => true,
462 Bound::Included(bound) => cmp(candidate, bound) != Ordering::Less,
463 Bound::Excluded(bound) => cmp(candidate, bound) == Ordering::Greater,
464 }
465}
466
467/// Keeps the tighter (larger) of two optional lower bounds under AND-intersection.
468///
469/// At equal value, an exclusive edge is tighter than an inclusive one (it excludes the
470/// boundary the inclusive edge would admit); `Unbounded` never wins over a real bound. Generic
471/// AND-intersection primitive for a caller building a bound up from independent AND-clauses
472/// (`deps-composer`'s own clause-by-clause fold, #1610/#1625) — kept here, next to
473/// [`range_from_edges`], so the next ecosystem that AND-folds clauses into a [`VersionRange`]
474/// does not have to re-derive this tie-breaking rule itself.
475///
476/// # Examples
477///
478/// ```
479/// use deps_core::interval::tighter_lower;
480/// use std::ops::Bound;
481///
482/// let cmp = |a: &u32, b: &u32| a.cmp(b);
483///
484/// // The larger lower bound is tighter.
485/// assert_eq!(
486/// tighter_lower(Bound::Included(1), Bound::Included(2), cmp),
487/// Bound::Included(2)
488/// );
489///
490/// // At equal value, exclusive is tighter than inclusive.
491/// assert_eq!(
492/// tighter_lower(Bound::Included(2), Bound::Excluded(2), cmp),
493/// Bound::Excluded(2)
494/// );
495///
496/// // `Unbounded` never wins over a real bound.
497/// assert_eq!(
498/// tighter_lower(Bound::Unbounded, Bound::Included(1), cmp),
499/// Bound::Included(1)
500/// );
501/// ```
502pub fn tighter_lower<V>(a: Bound<V>, b: Bound<V>, cmp: impl Fn(&V, &V) -> Ordering) -> Bound<V> {
503 match (a, b) {
504 (Bound::Unbounded, x) | (x, Bound::Unbounded) => x,
505 (Bound::Included(av), Bound::Included(bv)) => {
506 if cmp(&av, &bv) == Ordering::Less {
507 Bound::Included(bv)
508 } else {
509 Bound::Included(av)
510 }
511 }
512 (Bound::Excluded(av), Bound::Excluded(bv)) => {
513 if cmp(&av, &bv) == Ordering::Less {
514 Bound::Excluded(bv)
515 } else {
516 Bound::Excluded(av)
517 }
518 }
519 (Bound::Included(av), Bound::Excluded(bv)) => match cmp(&av, &bv) {
520 Ordering::Less | Ordering::Equal => Bound::Excluded(bv),
521 Ordering::Greater => Bound::Included(av),
522 },
523 (Bound::Excluded(av), Bound::Included(bv)) => match cmp(&av, &bv) {
524 Ordering::Greater | Ordering::Equal => Bound::Excluded(av),
525 Ordering::Less => Bound::Included(bv),
526 },
527 }
528}
529
530/// Keeps the tighter (smaller) of two optional upper bounds under AND-intersection — mirrors
531/// [`tighter_lower`], with the tie-break and "smaller wins" direction inverted.
532///
533/// # Examples
534///
535/// ```
536/// use deps_core::interval::tighter_upper;
537/// use std::ops::Bound;
538///
539/// let cmp = |a: &u32, b: &u32| a.cmp(b);
540///
541/// // The smaller upper bound is tighter.
542/// assert_eq!(
543/// tighter_upper(Bound::Included(2), Bound::Included(1), cmp),
544/// Bound::Included(1)
545/// );
546///
547/// // At equal value, exclusive is tighter than inclusive.
548/// assert_eq!(
549/// tighter_upper(Bound::Included(2), Bound::Excluded(2), cmp),
550/// Bound::Excluded(2)
551/// );
552/// ```
553pub fn tighter_upper<V>(a: Bound<V>, b: Bound<V>, cmp: impl Fn(&V, &V) -> Ordering) -> Bound<V> {
554 match (a, b) {
555 (Bound::Unbounded, x) | (x, Bound::Unbounded) => x,
556 (Bound::Included(av), Bound::Included(bv)) => {
557 if cmp(&av, &bv) == Ordering::Greater {
558 Bound::Included(bv)
559 } else {
560 Bound::Included(av)
561 }
562 }
563 (Bound::Excluded(av), Bound::Excluded(bv)) => {
564 if cmp(&av, &bv) == Ordering::Greater {
565 Bound::Excluded(bv)
566 } else {
567 Bound::Excluded(av)
568 }
569 }
570 (Bound::Included(av), Bound::Excluded(bv)) => match cmp(&av, &bv) {
571 Ordering::Greater | Ordering::Equal => Bound::Excluded(bv),
572 Ordering::Less => Bound::Included(av),
573 },
574 (Bound::Excluded(av), Bound::Included(bv)) => match cmp(&av, &bv) {
575 Ordering::Less | Ordering::Equal => Bound::Excluded(av),
576 Ordering::Greater => Bound::Included(bv),
577 },
578 }
579}
580
581/// Whether a candidate not covered by any union member is explicitly excluded by the shape.
582///
583/// "The shape" means: sitting past one member's admitted span and before another's, with
584/// nothing in between (#1601, generalizing Maven's disjoint-range-union gap detection from
585/// #1590/#1594 to any union representation).
586///
587/// This is deliberately representation-agnostic — `M` may be a bracket interval
588/// (`deps-maven`/`deps-composer`, whose members expose literal string edges via
589/// [`admits_at_or_above`]/[`admits_at_or_below`]) or an opaque range object with no exposed
590/// bound values (`deps-npm`'s `node_semver::Range`, whose members answer the same two
591/// questions by probing `Range::allows_any` instead). Only the answers to three yes/no
592/// questions per member are needed: does it cover `candidate` already, does it admit
593/// something at or above `candidate`, and does it admit something at or below `candidate`.
594///
595/// A member that cannot answer these precisely for some clause shape should just answer
596/// `true` for the two `admits_at_or_*` questions (see [`admits_at_or_above`]'s doc) — that
597/// only means a real gap through that member goes undetected, never that a covered candidate
598/// is misreported as excluded.
599///
600/// # Examples
601///
602/// ```
603/// use deps_core::interval::union_gap_excludes;
604///
605/// // Two disjoint half-lines with a punctured point at 5, mirroring a Maven
606/// // `(,5),(5,)`-style union or a Composer/npm `||`-alternation with the same shape.
607/// let members = [(0, 5), (5, 10)]; // (lower, upper), both bounds exclusive
608/// let excludes = |candidate: i32| {
609/// union_gap_excludes(
610/// &members,
611/// |&(lo, hi)| candidate > lo && candidate < hi,
612/// |&(_, hi)| candidate < hi,
613/// |&(lo, _)| candidate > lo,
614/// )
615/// };
616/// assert!(excludes(5));
617/// assert!(!excludes(3));
618/// assert!(!excludes(7));
619/// assert!(!excludes(-1));
620/// assert!(!excludes(11));
621/// ```
622pub fn union_gap_excludes<M>(
623 members: &[M],
624 covers: impl Fn(&M) -> bool,
625 admits_at_or_above: impl Fn(&M) -> bool,
626 admits_at_or_below: impl Fn(&M) -> bool,
627) -> bool {
628 if members.iter().any(&covers) {
629 return false;
630 }
631 let past_some_upper = members.iter().any(|m| !admits_at_or_above(m));
632 let before_some_lower = members.iter().any(|m| !admits_at_or_below(m));
633 past_some_upper && before_some_lower
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 fn parse_str(s: &str, style: BracketStyle) -> Option<VersionRange<String>> {
641 parse_interval(s, style, |b| Some(b.to_string()), |a, b| a.cmp(b))
642 }
643
644 fn parses(s: &str, style: BracketStyle) -> bool {
645 parse_str(s, style).is_some()
646 }
647
648 fn str_cmp<S: AsRef<str>>(a: &str, b: &S) -> Ordering {
649 a.cmp(b.as_ref())
650 }
651
652 fn contains_str(v: &str, range: &VersionRange<String>) -> bool {
653 contains(v, range, str_cmp)
654 }
655
656 #[test]
657 fn test_satisfies_exact_pin() {
658 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
659 let range = parse_str("[1.0]", style).unwrap();
660 assert!(contains_str("1.0", &range));
661 assert!(!contains_str("1.0.1", &range));
662 }
663 }
664
665 #[test]
666 fn test_satisfies_bounded_exclusive_inclusive_mix() {
667 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
668 let range = parse_str("[1.0,2.0)", style).unwrap();
669 assert!(contains_str("1.5", &range));
670 assert!(!contains_str("2.0", &range));
671 }
672 }
673
674 #[test]
675 fn test_satisfies_open_ended_minimum_and_maximum() {
676 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
677 let min = parse_str("[1.5,)", style).unwrap();
678 assert!(contains_str("2.0", &min));
679 assert!(!contains_str("1.4", &min));
680
681 let max = parse_str("(,2.0]", style).unwrap();
682 assert!(contains_str("2.0", &max));
683 assert!(!contains_str("2.0.1", &max));
684 }
685 }
686
687 #[test]
688 fn test_satisfies_whitespace_inside_brackets() {
689 let range = parse_str("[ 1.0 , 2.0 )", BracketStyle::Standard).unwrap();
690 assert!(contains_str("1.5", &range));
691 }
692
693 #[test]
694 fn test_reversed_bracket_accepted_under_allow_reversed_only() {
695 assert!(parses("]1.2,1.5]", BracketStyle::AllowReversed));
696 assert!(!parses("]1.2,1.5]", BracketStyle::Standard));
697 assert!(parses("[1.1,2.0[", BracketStyle::AllowReversed));
698 assert!(!parses("[1.1,2.0[", BracketStyle::Standard));
699 }
700
701 #[test]
702 fn test_single_delimiter_is_rejected_not_panicking() {
703 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
704 assert!(!parses("[", style));
705 assert!(!parses("]", style));
706 }
707 }
708
709 /// #821 conformance: the malformed shapes NuGet's independent, less-hardened
710 /// implementation used to silently accept — this is the shared, ecosystem-agnostic
711 /// grammar every ecosystem (Maven, Gradle, NuGet) now routes through, so a shape rejected
712 /// here is rejected everywhere.
713 #[test]
714 fn test_conformance_rejects_all_malformed_shapes() {
715 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
716 // Nested/stray bracket characters.
717 assert!(!parses("[[1.0,2.0)", style), "style={style:?}");
718 assert!(!parses("[1.0,2.0)]", style), "style={style:?}");
719 // A third comma-separated component.
720 assert!(!parses("[1.0,2.0,3.0]", style), "style={style:?}");
721 // A no-comma body is only a valid exact pin as the matching inclusive pair
722 // `[...]` — neither grammar has a reversed-bracket exact-pin form.
723 assert!(!parses("(1.0)", style), "style={style:?}");
724 assert!(!parses("[1.0)", style), "style={style:?}");
725 assert!(!parses("(1.0]", style), "style={style:?}");
726 // Empty exact pin.
727 assert!(!parses("[]", style), "style={style:?}");
728 }
729 }
730
731 #[test]
732 fn test_conformance_accepts_well_formed_shapes() {
733 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
734 assert!(parses("[1.0]", style), "style={style:?}");
735 assert!(parses("[1.0,2.0)", style), "style={style:?}");
736 }
737 }
738
739 /// A bound `parse_bound` itself rejects must reject the whole interval, not fall back to
740 /// treating it as an open bound.
741 #[test]
742 fn test_unparseable_bound_rejects_whole_interval() {
743 let parse_u32 = |b: &str| b.parse::<u32>().ok();
744 let cmp_u32 = |a: &u32, b: &u32| a.cmp(b);
745 assert_eq!(
746 parse_interval(
747 "[1,not-a-number)",
748 BracketStyle::Standard,
749 parse_u32,
750 cmp_u32
751 ),
752 None
753 );
754 assert_eq!(
755 parse_interval("[not-a-number]", BracketStyle::Standard, parse_u32, cmp_u32),
756 None
757 );
758 }
759
760 /// #1595: a bounded range that can never be satisfied — inverted bounds, or a
761 /// zero-width range with either bound exclusive — parses to `Empty`, not `None`: it is
762 /// well-formed syntax, just an unsatisfiable one (see `VersionRange::Empty`'s doc for why
763 /// this must stay distinct from a malformed-syntax rejection).
764 #[test]
765 fn test_bounded_range_min_greater_than_max_is_empty() {
766 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
767 assert_eq!(
768 parse_str("[5.0,3.0]", style),
769 Some(VersionRange::Empty),
770 "style={style:?}"
771 );
772 assert_eq!(
773 parse_str("(5.0,3.0)", style),
774 Some(VersionRange::Empty),
775 "style={style:?}"
776 );
777 }
778 }
779
780 #[test]
781 fn test_bounded_range_zero_width_exclusive_is_empty() {
782 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
783 for s in ["(3.0,3.0)", "[3.0,3.0)", "(3.0,3.0]"] {
784 assert_eq!(
785 parse_str(s, style),
786 Some(VersionRange::Empty),
787 "style={style:?}"
788 );
789 }
790 }
791 }
792
793 /// An `Empty` range admits no candidate, unlike an unparseable `None` interval which the
794 /// caller must handle separately (e.g. Maven's whole-union rejection).
795 #[test]
796 fn test_empty_range_contains_nothing() {
797 let range: VersionRange<String> = VersionRange::Empty;
798 assert!(!contains_str("3.0", &range));
799 assert!(!contains_str("0.0", &range));
800 }
801
802 #[test]
803 fn test_bounded_range_zero_width_inclusive_is_a_valid_single_point() {
804 for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
805 let range = parse_str("[3.0,3.0]", style).unwrap();
806 assert!(contains_str("3.0", &range));
807 assert!(!contains_str("2.9", &range));
808 }
809 }
810
811 #[test]
812 fn test_contains_with_independent_candidate_and_bound_types() {
813 // The candidate type `Q` need not match the bound type `V` — here a `u32` candidate
814 // is compared against `String`-typed bounds via a custom `cmp`.
815 let range = parse_str("[1,10)", BracketStyle::Standard).unwrap();
816 let cmp = |a: &u32, b: &String| a.cmp(&b.parse::<u32>().unwrap());
817 assert!(contains(&5u32, &range, cmp));
818 assert!(!contains(&10u32, &range, cmp));
819 }
820
821 fn str_ord(a: &str, b: &str) -> Ordering {
822 a.cmp(b)
823 }
824
825 #[test]
826 fn test_admits_at_or_above_none_edge_is_always_true() {
827 assert!(admits_at_or_above("999", Bound::Unbounded, str_ord));
828 }
829
830 #[test]
831 fn test_admits_at_or_above_respects_inclusivity() {
832 let bound = "5";
833 assert!(admits_at_or_above("5", Bound::Included(bound), str_ord));
834 assert!(!admits_at_or_above("5", Bound::Excluded(bound), str_ord));
835 assert!(admits_at_or_above("4", Bound::Included(bound), str_ord));
836 assert!(!admits_at_or_above("6", Bound::Included(bound), str_ord));
837 }
838
839 #[test]
840 fn test_admits_at_or_below_none_edge_is_always_true() {
841 assert!(admits_at_or_below("0", Bound::Unbounded, str_ord));
842 }
843
844 #[test]
845 fn test_admits_at_or_below_respects_inclusivity() {
846 let bound = "5";
847 assert!(admits_at_or_below("5", Bound::Included(bound), str_ord));
848 assert!(!admits_at_or_below("5", Bound::Excluded(bound), str_ord));
849 assert!(admits_at_or_below("6", Bound::Included(bound), str_ord));
850 assert!(!admits_at_or_below("4", Bound::Included(bound), str_ord));
851 }
852
853 #[test]
854 fn test_tighter_lower_prefers_larger_value_and_exclusive_on_tie() {
855 let cmp = |a: &u32, b: &u32| a.cmp(b);
856 assert_eq!(
857 tighter_lower(Bound::Included(1), Bound::Included(2), cmp),
858 Bound::Included(2)
859 );
860 assert_eq!(
861 tighter_lower(Bound::Included(2), Bound::Excluded(2), cmp),
862 Bound::Excluded(2)
863 );
864 assert_eq!(
865 tighter_lower(Bound::Excluded(2), Bound::Included(2), cmp),
866 Bound::Excluded(2)
867 );
868 assert_eq!(
869 tighter_lower(Bound::Unbounded, Bound::Included(1), cmp),
870 Bound::Included(1)
871 );
872 assert_eq!(
873 tighter_lower(Bound::Included(1), Bound::Unbounded, cmp),
874 Bound::Included(1)
875 );
876 }
877
878 #[test]
879 fn test_tighter_upper_prefers_smaller_value_and_exclusive_on_tie() {
880 let cmp = |a: &u32, b: &u32| a.cmp(b);
881 assert_eq!(
882 tighter_upper(Bound::Included(2), Bound::Included(1), cmp),
883 Bound::Included(1)
884 );
885 assert_eq!(
886 tighter_upper(Bound::Included(2), Bound::Excluded(2), cmp),
887 Bound::Excluded(2)
888 );
889 assert_eq!(
890 tighter_upper(Bound::Excluded(2), Bound::Included(2), cmp),
891 Bound::Excluded(2)
892 );
893 assert_eq!(
894 tighter_upper(Bound::Unbounded, Bound::Included(1), cmp),
895 Bound::Included(1)
896 );
897 }
898
899 /// Mirrors Maven's #1590 disjoint-range-union gap test, but through the
900 /// representation-agnostic union primitive instead of `deps-maven`'s own edge extraction.
901 #[test]
902 fn test_union_gap_excludes_disjoint_members() {
903 let members: Vec<(i32, i32)> = vec![(0, 5), (5, 10)]; // exclusive both ends
904 let excludes = |candidate: i32| {
905 union_gap_excludes(
906 &members,
907 |&(lo, hi)| candidate > lo && candidate < hi,
908 |&(_, hi)| candidate < hi,
909 |&(lo, _)| candidate > lo,
910 )
911 };
912 assert!(excludes(5));
913 assert!(!excludes(3));
914 assert!(!excludes(7));
915 assert!(!excludes(-1));
916 assert!(!excludes(11));
917 }
918
919 /// A single member alone has no "other side" to sandwich a candidate against, so it can
920 /// never trigger a gap exclusion by itself, even if it is entirely below or above the
921 /// candidate.
922 #[test]
923 fn test_union_gap_excludes_single_member_never_excludes() {
924 let members: Vec<(i32, i32)> = vec![(0, 5)];
925 assert!(!union_gap_excludes(
926 &members,
927 |&(lo, hi)| 7 > lo && 7 < hi,
928 |&(_, hi)| 7 < hi,
929 |&(lo, _)| 7 > lo,
930 ));
931 }
932
933 /// A member reporting "unknown" (always answering `true` for both admits-questions, per
934 /// [`admits_at_or_above`]'s doc) never itself contributes a false gap, even sitting
935 /// between two other real members.
936 #[test]
937 fn test_union_gap_excludes_unknown_member_contributes_nothing() {
938 #[derive(Clone, Copy)]
939 enum Member {
940 Bounded(i32, i32),
941 Unknown,
942 }
943 let members = [
944 Member::Bounded(0, 5),
945 Member::Unknown,
946 Member::Bounded(5, 10),
947 ];
948 let covers = |m: &Member| matches!(m, Member::Bounded(lo, hi) if 5 > *lo && 5 < *hi);
949 let above = |m: &Member| match m {
950 Member::Bounded(_, hi) => 5 < *hi,
951 Member::Unknown => true,
952 };
953 let below = |m: &Member| match m {
954 Member::Bounded(lo, _) => 5 > *lo,
955 Member::Unknown => true,
956 };
957 assert!(union_gap_excludes(&members, covers, above, below));
958 }
959
960 #[test]
961 fn test_upper_edge_lower_edge_per_variant() {
962 let bounded = parse_str("[1.0,2.0)", BracketStyle::Standard).unwrap();
963 assert_eq!(bounded.upper_edge(), Bound::Excluded(&"2.0".to_string()));
964 assert_eq!(bounded.lower_edge(), Bound::Included(&"1.0".to_string()));
965
966 let min = parse_str("[1.5,)", BracketStyle::Standard).unwrap();
967 assert_eq!(min.upper_edge(), Bound::Unbounded);
968 assert_eq!(min.lower_edge(), Bound::Included(&"1.5".to_string()));
969
970 let max = parse_str("(,2.0]", BracketStyle::Standard).unwrap();
971 assert_eq!(max.upper_edge(), Bound::Included(&"2.0".to_string()));
972 assert_eq!(max.lower_edge(), Bound::Unbounded);
973
974 let exact = parse_str("[1.0]", BracketStyle::Standard).unwrap();
975 assert_eq!(exact.upper_edge(), Bound::Included(&"1.0".to_string()));
976 assert_eq!(exact.lower_edge(), Bound::Included(&"1.0".to_string()));
977
978 let empty: VersionRange<String> = VersionRange::Empty;
979 assert_eq!(empty.upper_edge(), Bound::Unbounded);
980 assert_eq!(empty.lower_edge(), Bound::Unbounded);
981 }
982
983 #[test]
984 fn test_range_from_edges_builds_expected_shapes() {
985 let cmp = |a: &String, b: &String| a.cmp(b);
986 let s = |v: &str| v.to_string();
987
988 let bounded =
989 range_from_edges(Bound::Included(s("1.0")), Bound::Excluded(s("2.0")), cmp).unwrap();
990 assert_eq!(bounded.lower_edge(), Bound::Included(&s("1.0")));
991 assert_eq!(bounded.upper_edge(), Bound::Excluded(&s("2.0")));
992
993 let min = range_from_edges(Bound::Excluded(s("1.5")), Bound::Unbounded, cmp).unwrap();
994 assert_eq!(min.lower_edge(), Bound::Excluded(&s("1.5")));
995 assert_eq!(min.upper_edge(), Bound::Unbounded);
996
997 let max = range_from_edges(Bound::Unbounded, Bound::Included(s("2.0")), cmp).unwrap();
998 assert_eq!(max.lower_edge(), Bound::Unbounded);
999 assert_eq!(max.upper_edge(), Bound::Included(&s("2.0")));
1000
1001 assert_eq!(
1002 range_from_edges::<String>(Bound::Unbounded, Bound::Unbounded, cmp),
1003 None
1004 );
1005 }
1006
1007 #[test]
1008 fn test_range_from_edges_equal_edges_need_both_included() {
1009 let cmp = |a: &u32, b: &u32| a.cmp(b);
1010 for (lo, hi, empty) in [
1011 (Bound::Included(3), Bound::Included(3), false),
1012 (Bound::Included(3), Bound::Excluded(3), true),
1013 (Bound::Excluded(3), Bound::Included(3), true),
1014 (Bound::Excluded(3), Bound::Excluded(3), true),
1015 ] {
1016 let range = range_from_edges(lo, hi, cmp).unwrap();
1017 assert_eq!(range == VersionRange::Empty, empty, "{lo:?} {hi:?}");
1018 }
1019 }
1020
1021 #[test]
1022 fn test_range_from_edges_inverted_edges_are_empty() {
1023 let cmp = |a: &u32, b: &u32| a.cmp(b);
1024 for (lo, hi) in [
1025 (Bound::Included(4), Bound::Included(3)),
1026 (Bound::Excluded(4), Bound::Included(3)),
1027 (Bound::Included(4), Bound::Excluded(3)),
1028 (Bound::Excluded(4), Bound::Excluded(3)),
1029 ] {
1030 assert_eq!(range_from_edges(lo, hi, cmp), Some(VersionRange::Empty));
1031 }
1032 }
1033
1034 #[test]
1035 fn test_parse_interval_open_ended_edges_and_contains() {
1036 let min = parse_str("[1.0,)", BracketStyle::Standard).unwrap();
1037 assert_eq!(min.lower_edge(), Bound::Included(&"1.0".to_string()));
1038 assert_eq!(min.upper_edge(), Bound::Unbounded);
1039 assert!(contains_str("1.0", &min));
1040 assert!(!contains_str("0.9", &min));
1041
1042 let max = parse_str("(,2.0]", BracketStyle::Standard).unwrap();
1043 assert_eq!(max.lower_edge(), Bound::Unbounded);
1044 assert_eq!(max.upper_edge(), Bound::Included(&"2.0".to_string()));
1045 assert!(contains_str("2.0", &max));
1046 assert!(!contains_str("2.1", &max));
1047 }
1048
1049 /// #1595 parity: `range_from_edges` must apply the same unsatisfiable-shape detection
1050 /// `parse_interval` does for a syntactic bracket interval.
1051 #[test]
1052 fn test_range_from_edges_unsatisfiable_shapes_are_empty() {
1053 let cmp = |a: &String, b: &String| a.cmp(b);
1054 assert_eq!(
1055 range_from_edges(
1056 Bound::Included("5.0".to_string()),
1057 Bound::Included("3.0".to_string()),
1058 cmp
1059 ),
1060 Some(VersionRange::Empty)
1061 );
1062 assert_eq!(
1063 range_from_edges(
1064 Bound::Excluded("3.0".to_string()),
1065 Bound::Included("3.0".to_string()),
1066 cmp
1067 ),
1068 Some(VersionRange::Empty)
1069 );
1070 }
1071
1072 /// A candidate covered by any member is never excluded, regardless of how many other
1073 /// members would otherwise box it in.
1074 #[test]
1075 fn test_union_gap_excludes_false_when_covered() {
1076 let members: Vec<(i32, i32)> = vec![(0, 5), (4, 10)];
1077 assert!(!union_gap_excludes(
1078 &members,
1079 |&(lo, hi)| 4 >= lo && 4 <= hi,
1080 |&(_, hi)| 4 < hi,
1081 |&(lo, _)| 4 > lo,
1082 ));
1083 }
1084}