Skip to main content

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}