use std::cmp::Ordering;
use std::ops::Bound;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionRange<V> {
Exact(V),
Interval(Interval<V>),
Empty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Interval<V> {
lower: Bound<V>,
upper: Bound<V>,
}
impl<V> Interval<V> {
pub const fn lower(&self) -> Bound<&V> {
match &self.lower {
Bound::Included(v) => Bound::Included(v),
Bound::Excluded(v) => Bound::Excluded(v),
Bound::Unbounded => Bound::Unbounded,
}
}
pub const fn upper(&self) -> Bound<&V> {
match &self.upper {
Bound::Included(v) => Bound::Included(v),
Bound::Excluded(v) => Bound::Excluded(v),
Bound::Unbounded => Bound::Unbounded,
}
}
}
impl<V> VersionRange<V> {
pub const fn upper_edge(&self) -> Bound<&V> {
match self {
Self::Exact(v) => Bound::Included(v),
Self::Interval(iv) => iv.upper(),
Self::Empty => Bound::Unbounded,
}
}
pub const fn lower_edge(&self) -> Bound<&V> {
match self {
Self::Exact(v) => Bound::Included(v),
Self::Interval(iv) => iv.lower(),
Self::Empty => Bound::Unbounded,
}
}
}
pub fn range_from_edges<V>(
lower: Bound<V>,
upper: Bound<V>,
cmp_bound: impl Fn(&V, &V) -> Ordering,
) -> Option<VersionRange<V>> {
let unsatisfiable = match (&lower, &upper) {
(Bound::Unbounded, Bound::Unbounded) => return None,
(Bound::Included(l) | Bound::Excluded(l), Bound::Included(u) | Bound::Excluded(u)) => {
let ord = cmp_bound(l, u);
ord == Ordering::Greater
|| (ord == Ordering::Equal
&& !matches!((&lower, &upper), (Bound::Included(_), Bound::Included(_))))
}
_ => false,
};
Some(if unsatisfiable {
VersionRange::Empty
} else {
VersionRange::Interval(Interval { lower, upper })
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BracketStyle {
Standard,
AllowReversed,
}
#[derive(Clone, Copy)]
enum Edge {
Inclusive,
Exclusive,
}
impl Edge {
fn wrap<V>(self, v: V) -> Bound<V> {
match self {
Self::Inclusive => Bound::Included(v),
Self::Exclusive => Bound::Excluded(v),
}
}
}
enum ParsedBound<V> {
Open,
Value(V),
Invalid,
}
fn parse_bound_side<V>(s: &str, parse_bound: &impl Fn(&str) -> Option<V>) -> ParsedBound<V> {
if s.is_empty() {
ParsedBound::Open
} else {
match parse_bound(s) {
Some(v) => ParsedBound::Value(v),
None => ParsedBound::Invalid,
}
}
}
#[expect(
clippy::string_slice,
reason = "first/last are chars() ends sliced at len_utf8, and the explicit length guard \
above prevents start > end, so the slice bound is always a char boundary"
)]
pub fn parse_interval<V>(
s: &str,
style: BracketStyle,
parse_bound: impl Fn(&str) -> Option<V>,
cmp_bound: impl Fn(&V, &V) -> Ordering,
) -> Option<VersionRange<V>> {
let s = s.trim();
let first = s.chars().next()?;
let lower_edge = match (first, style) {
('[', _) => Edge::Inclusive,
('(', _) => Edge::Exclusive,
(']', BracketStyle::AllowReversed) => Edge::Exclusive,
_ => return None,
};
let last = s.chars().next_back()?;
let upper_edge = match (last, style) {
(']', _) => Edge::Inclusive,
(')', _) => Edge::Exclusive,
('[', BracketStyle::AllowReversed) => Edge::Exclusive,
_ => return None,
};
if s.len() < first.len_utf8() + last.len_utf8() {
return None;
}
let inner = &s[first.len_utf8()..s.len() - last.len_utf8()];
if inner.contains(['[', ']', '(', ')']) {
return None;
}
if let Some((lo, hi)) = inner.split_once(',') {
if hi.contains(',') {
return None;
}
let lower = parse_bound_side(lo.trim(), &parse_bound);
let upper = parse_bound_side(hi.trim(), &parse_bound);
let wrap = |side: ParsedBound<V>, edge: Edge| match side {
ParsedBound::Invalid => None,
ParsedBound::Open => Some(Bound::Unbounded),
ParsedBound::Value(v) => Some(edge.wrap(v)),
};
range_from_edges(
wrap(lower, lower_edge)?,
wrap(upper, upper_edge)?,
cmp_bound,
)
} else {
let inner = inner.trim();
if inner.is_empty()
|| !matches!((lower_edge, upper_edge), (Edge::Inclusive, Edge::Inclusive))
{
return None;
}
parse_bound(inner).map(VersionRange::Exact)
}
}
pub fn contains<Q: ?Sized, V>(
v: &Q,
range: &VersionRange<V>,
cmp: impl Fn(&Q, &V) -> Ordering,
) -> bool {
match range {
VersionRange::Exact(target) => cmp(v, target) == Ordering::Equal,
VersionRange::Interval(iv) => {
admits_at_or_below(v, iv.lower(), &cmp) && admits_at_or_above(v, iv.upper(), &cmp)
}
VersionRange::Empty => false,
}
}
pub fn admits_at_or_above<Q: ?Sized, V: ?Sized>(
candidate: &Q,
upper_edge: Bound<&V>,
cmp: impl Fn(&Q, &V) -> Ordering,
) -> bool {
match upper_edge {
Bound::Unbounded => true,
Bound::Included(bound) => cmp(candidate, bound) != Ordering::Greater,
Bound::Excluded(bound) => cmp(candidate, bound) == Ordering::Less,
}
}
pub fn admits_at_or_below<Q: ?Sized, V: ?Sized>(
candidate: &Q,
lower_edge: Bound<&V>,
cmp: impl Fn(&Q, &V) -> Ordering,
) -> bool {
match lower_edge {
Bound::Unbounded => true,
Bound::Included(bound) => cmp(candidate, bound) != Ordering::Less,
Bound::Excluded(bound) => cmp(candidate, bound) == Ordering::Greater,
}
}
pub fn tighter_lower<V>(a: Bound<V>, b: Bound<V>, cmp: impl Fn(&V, &V) -> Ordering) -> Bound<V> {
match (a, b) {
(Bound::Unbounded, x) | (x, Bound::Unbounded) => x,
(Bound::Included(av), Bound::Included(bv)) => {
if cmp(&av, &bv) == Ordering::Less {
Bound::Included(bv)
} else {
Bound::Included(av)
}
}
(Bound::Excluded(av), Bound::Excluded(bv)) => {
if cmp(&av, &bv) == Ordering::Less {
Bound::Excluded(bv)
} else {
Bound::Excluded(av)
}
}
(Bound::Included(av), Bound::Excluded(bv)) => match cmp(&av, &bv) {
Ordering::Less | Ordering::Equal => Bound::Excluded(bv),
Ordering::Greater => Bound::Included(av),
},
(Bound::Excluded(av), Bound::Included(bv)) => match cmp(&av, &bv) {
Ordering::Greater | Ordering::Equal => Bound::Excluded(av),
Ordering::Less => Bound::Included(bv),
},
}
}
pub fn tighter_upper<V>(a: Bound<V>, b: Bound<V>, cmp: impl Fn(&V, &V) -> Ordering) -> Bound<V> {
match (a, b) {
(Bound::Unbounded, x) | (x, Bound::Unbounded) => x,
(Bound::Included(av), Bound::Included(bv)) => {
if cmp(&av, &bv) == Ordering::Greater {
Bound::Included(bv)
} else {
Bound::Included(av)
}
}
(Bound::Excluded(av), Bound::Excluded(bv)) => {
if cmp(&av, &bv) == Ordering::Greater {
Bound::Excluded(bv)
} else {
Bound::Excluded(av)
}
}
(Bound::Included(av), Bound::Excluded(bv)) => match cmp(&av, &bv) {
Ordering::Greater | Ordering::Equal => Bound::Excluded(bv),
Ordering::Less => Bound::Included(av),
},
(Bound::Excluded(av), Bound::Included(bv)) => match cmp(&av, &bv) {
Ordering::Less | Ordering::Equal => Bound::Excluded(av),
Ordering::Greater => Bound::Included(bv),
},
}
}
pub fn union_gap_excludes<M>(
members: &[M],
covers: impl Fn(&M) -> bool,
admits_at_or_above: impl Fn(&M) -> bool,
admits_at_or_below: impl Fn(&M) -> bool,
) -> bool {
if members.iter().any(&covers) {
return false;
}
let past_some_upper = members.iter().any(|m| !admits_at_or_above(m));
let before_some_lower = members.iter().any(|m| !admits_at_or_below(m));
past_some_upper && before_some_lower
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(s: &str, style: BracketStyle) -> Option<VersionRange<String>> {
parse_interval(s, style, |b| Some(b.to_string()), |a, b| a.cmp(b))
}
fn parses(s: &str, style: BracketStyle) -> bool {
parse_str(s, style).is_some()
}
fn str_cmp<S: AsRef<str>>(a: &str, b: &S) -> Ordering {
a.cmp(b.as_ref())
}
fn contains_str(v: &str, range: &VersionRange<String>) -> bool {
contains(v, range, str_cmp)
}
#[test]
fn test_satisfies_exact_pin() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
let range = parse_str("[1.0]", style).unwrap();
assert!(contains_str("1.0", &range));
assert!(!contains_str("1.0.1", &range));
}
}
#[test]
fn test_satisfies_bounded_exclusive_inclusive_mix() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
let range = parse_str("[1.0,2.0)", style).unwrap();
assert!(contains_str("1.5", &range));
assert!(!contains_str("2.0", &range));
}
}
#[test]
fn test_satisfies_open_ended_minimum_and_maximum() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
let min = parse_str("[1.5,)", style).unwrap();
assert!(contains_str("2.0", &min));
assert!(!contains_str("1.4", &min));
let max = parse_str("(,2.0]", style).unwrap();
assert!(contains_str("2.0", &max));
assert!(!contains_str("2.0.1", &max));
}
}
#[test]
fn test_satisfies_whitespace_inside_brackets() {
let range = parse_str("[ 1.0 , 2.0 )", BracketStyle::Standard).unwrap();
assert!(contains_str("1.5", &range));
}
#[test]
fn test_reversed_bracket_accepted_under_allow_reversed_only() {
assert!(parses("]1.2,1.5]", BracketStyle::AllowReversed));
assert!(!parses("]1.2,1.5]", BracketStyle::Standard));
assert!(parses("[1.1,2.0[", BracketStyle::AllowReversed));
assert!(!parses("[1.1,2.0[", BracketStyle::Standard));
}
#[test]
fn test_single_delimiter_is_rejected_not_panicking() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
assert!(!parses("[", style));
assert!(!parses("]", style));
}
}
#[test]
fn test_conformance_rejects_all_malformed_shapes() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
assert!(!parses("[[1.0,2.0)", style), "style={style:?}");
assert!(!parses("[1.0,2.0)]", style), "style={style:?}");
assert!(!parses("[1.0,2.0,3.0]", style), "style={style:?}");
assert!(!parses("(1.0)", style), "style={style:?}");
assert!(!parses("[1.0)", style), "style={style:?}");
assert!(!parses("(1.0]", style), "style={style:?}");
assert!(!parses("[]", style), "style={style:?}");
}
}
#[test]
fn test_conformance_accepts_well_formed_shapes() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
assert!(parses("[1.0]", style), "style={style:?}");
assert!(parses("[1.0,2.0)", style), "style={style:?}");
}
}
#[test]
fn test_unparseable_bound_rejects_whole_interval() {
let parse_u32 = |b: &str| b.parse::<u32>().ok();
let cmp_u32 = |a: &u32, b: &u32| a.cmp(b);
assert_eq!(
parse_interval(
"[1,not-a-number)",
BracketStyle::Standard,
parse_u32,
cmp_u32
),
None
);
assert_eq!(
parse_interval("[not-a-number]", BracketStyle::Standard, parse_u32, cmp_u32),
None
);
}
#[test]
fn test_bounded_range_min_greater_than_max_is_empty() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
assert_eq!(
parse_str("[5.0,3.0]", style),
Some(VersionRange::Empty),
"style={style:?}"
);
assert_eq!(
parse_str("(5.0,3.0)", style),
Some(VersionRange::Empty),
"style={style:?}"
);
}
}
#[test]
fn test_bounded_range_zero_width_exclusive_is_empty() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
for s in ["(3.0,3.0)", "[3.0,3.0)", "(3.0,3.0]"] {
assert_eq!(
parse_str(s, style),
Some(VersionRange::Empty),
"style={style:?}"
);
}
}
}
#[test]
fn test_empty_range_contains_nothing() {
let range: VersionRange<String> = VersionRange::Empty;
assert!(!contains_str("3.0", &range));
assert!(!contains_str("0.0", &range));
}
#[test]
fn test_bounded_range_zero_width_inclusive_is_a_valid_single_point() {
for style in [BracketStyle::Standard, BracketStyle::AllowReversed] {
let range = parse_str("[3.0,3.0]", style).unwrap();
assert!(contains_str("3.0", &range));
assert!(!contains_str("2.9", &range));
}
}
#[test]
fn test_contains_with_independent_candidate_and_bound_types() {
let range = parse_str("[1,10)", BracketStyle::Standard).unwrap();
let cmp = |a: &u32, b: &String| a.cmp(&b.parse::<u32>().unwrap());
assert!(contains(&5u32, &range, cmp));
assert!(!contains(&10u32, &range, cmp));
}
fn str_ord(a: &str, b: &str) -> Ordering {
a.cmp(b)
}
#[test]
fn test_admits_at_or_above_none_edge_is_always_true() {
assert!(admits_at_or_above("999", Bound::Unbounded, str_ord));
}
#[test]
fn test_admits_at_or_above_respects_inclusivity() {
let bound = "5";
assert!(admits_at_or_above("5", Bound::Included(bound), str_ord));
assert!(!admits_at_or_above("5", Bound::Excluded(bound), str_ord));
assert!(admits_at_or_above("4", Bound::Included(bound), str_ord));
assert!(!admits_at_or_above("6", Bound::Included(bound), str_ord));
}
#[test]
fn test_admits_at_or_below_none_edge_is_always_true() {
assert!(admits_at_or_below("0", Bound::Unbounded, str_ord));
}
#[test]
fn test_admits_at_or_below_respects_inclusivity() {
let bound = "5";
assert!(admits_at_or_below("5", Bound::Included(bound), str_ord));
assert!(!admits_at_or_below("5", Bound::Excluded(bound), str_ord));
assert!(admits_at_or_below("6", Bound::Included(bound), str_ord));
assert!(!admits_at_or_below("4", Bound::Included(bound), str_ord));
}
#[test]
fn test_tighter_lower_prefers_larger_value_and_exclusive_on_tie() {
let cmp = |a: &u32, b: &u32| a.cmp(b);
assert_eq!(
tighter_lower(Bound::Included(1), Bound::Included(2), cmp),
Bound::Included(2)
);
assert_eq!(
tighter_lower(Bound::Included(2), Bound::Excluded(2), cmp),
Bound::Excluded(2)
);
assert_eq!(
tighter_lower(Bound::Excluded(2), Bound::Included(2), cmp),
Bound::Excluded(2)
);
assert_eq!(
tighter_lower(Bound::Unbounded, Bound::Included(1), cmp),
Bound::Included(1)
);
assert_eq!(
tighter_lower(Bound::Included(1), Bound::Unbounded, cmp),
Bound::Included(1)
);
}
#[test]
fn test_tighter_upper_prefers_smaller_value_and_exclusive_on_tie() {
let cmp = |a: &u32, b: &u32| a.cmp(b);
assert_eq!(
tighter_upper(Bound::Included(2), Bound::Included(1), cmp),
Bound::Included(1)
);
assert_eq!(
tighter_upper(Bound::Included(2), Bound::Excluded(2), cmp),
Bound::Excluded(2)
);
assert_eq!(
tighter_upper(Bound::Excluded(2), Bound::Included(2), cmp),
Bound::Excluded(2)
);
assert_eq!(
tighter_upper(Bound::Unbounded, Bound::Included(1), cmp),
Bound::Included(1)
);
}
#[test]
fn test_union_gap_excludes_disjoint_members() {
let members: Vec<(i32, i32)> = vec![(0, 5), (5, 10)]; let excludes = |candidate: i32| {
union_gap_excludes(
&members,
|&(lo, hi)| candidate > lo && candidate < hi,
|&(_, hi)| candidate < hi,
|&(lo, _)| candidate > lo,
)
};
assert!(excludes(5));
assert!(!excludes(3));
assert!(!excludes(7));
assert!(!excludes(-1));
assert!(!excludes(11));
}
#[test]
fn test_union_gap_excludes_single_member_never_excludes() {
let members: Vec<(i32, i32)> = vec![(0, 5)];
assert!(!union_gap_excludes(
&members,
|&(lo, hi)| 7 > lo && 7 < hi,
|&(_, hi)| 7 < hi,
|&(lo, _)| 7 > lo,
));
}
#[test]
fn test_union_gap_excludes_unknown_member_contributes_nothing() {
#[derive(Clone, Copy)]
enum Member {
Bounded(i32, i32),
Unknown,
}
let members = [
Member::Bounded(0, 5),
Member::Unknown,
Member::Bounded(5, 10),
];
let covers = |m: &Member| matches!(m, Member::Bounded(lo, hi) if 5 > *lo && 5 < *hi);
let above = |m: &Member| match m {
Member::Bounded(_, hi) => 5 < *hi,
Member::Unknown => true,
};
let below = |m: &Member| match m {
Member::Bounded(lo, _) => 5 > *lo,
Member::Unknown => true,
};
assert!(union_gap_excludes(&members, covers, above, below));
}
#[test]
fn test_upper_edge_lower_edge_per_variant() {
let bounded = parse_str("[1.0,2.0)", BracketStyle::Standard).unwrap();
assert_eq!(bounded.upper_edge(), Bound::Excluded(&"2.0".to_string()));
assert_eq!(bounded.lower_edge(), Bound::Included(&"1.0".to_string()));
let min = parse_str("[1.5,)", BracketStyle::Standard).unwrap();
assert_eq!(min.upper_edge(), Bound::Unbounded);
assert_eq!(min.lower_edge(), Bound::Included(&"1.5".to_string()));
let max = parse_str("(,2.0]", BracketStyle::Standard).unwrap();
assert_eq!(max.upper_edge(), Bound::Included(&"2.0".to_string()));
assert_eq!(max.lower_edge(), Bound::Unbounded);
let exact = parse_str("[1.0]", BracketStyle::Standard).unwrap();
assert_eq!(exact.upper_edge(), Bound::Included(&"1.0".to_string()));
assert_eq!(exact.lower_edge(), Bound::Included(&"1.0".to_string()));
let empty: VersionRange<String> = VersionRange::Empty;
assert_eq!(empty.upper_edge(), Bound::Unbounded);
assert_eq!(empty.lower_edge(), Bound::Unbounded);
}
#[test]
fn test_range_from_edges_builds_expected_shapes() {
let cmp = |a: &String, b: &String| a.cmp(b);
let s = |v: &str| v.to_string();
let bounded =
range_from_edges(Bound::Included(s("1.0")), Bound::Excluded(s("2.0")), cmp).unwrap();
assert_eq!(bounded.lower_edge(), Bound::Included(&s("1.0")));
assert_eq!(bounded.upper_edge(), Bound::Excluded(&s("2.0")));
let min = range_from_edges(Bound::Excluded(s("1.5")), Bound::Unbounded, cmp).unwrap();
assert_eq!(min.lower_edge(), Bound::Excluded(&s("1.5")));
assert_eq!(min.upper_edge(), Bound::Unbounded);
let max = range_from_edges(Bound::Unbounded, Bound::Included(s("2.0")), cmp).unwrap();
assert_eq!(max.lower_edge(), Bound::Unbounded);
assert_eq!(max.upper_edge(), Bound::Included(&s("2.0")));
assert_eq!(
range_from_edges::<String>(Bound::Unbounded, Bound::Unbounded, cmp),
None
);
}
#[test]
fn test_range_from_edges_equal_edges_need_both_included() {
let cmp = |a: &u32, b: &u32| a.cmp(b);
for (lo, hi, empty) in [
(Bound::Included(3), Bound::Included(3), false),
(Bound::Included(3), Bound::Excluded(3), true),
(Bound::Excluded(3), Bound::Included(3), true),
(Bound::Excluded(3), Bound::Excluded(3), true),
] {
let range = range_from_edges(lo, hi, cmp).unwrap();
assert_eq!(range == VersionRange::Empty, empty, "{lo:?} {hi:?}");
}
}
#[test]
fn test_range_from_edges_inverted_edges_are_empty() {
let cmp = |a: &u32, b: &u32| a.cmp(b);
for (lo, hi) in [
(Bound::Included(4), Bound::Included(3)),
(Bound::Excluded(4), Bound::Included(3)),
(Bound::Included(4), Bound::Excluded(3)),
(Bound::Excluded(4), Bound::Excluded(3)),
] {
assert_eq!(range_from_edges(lo, hi, cmp), Some(VersionRange::Empty));
}
}
#[test]
fn test_parse_interval_open_ended_edges_and_contains() {
let min = parse_str("[1.0,)", BracketStyle::Standard).unwrap();
assert_eq!(min.lower_edge(), Bound::Included(&"1.0".to_string()));
assert_eq!(min.upper_edge(), Bound::Unbounded);
assert!(contains_str("1.0", &min));
assert!(!contains_str("0.9", &min));
let max = parse_str("(,2.0]", BracketStyle::Standard).unwrap();
assert_eq!(max.lower_edge(), Bound::Unbounded);
assert_eq!(max.upper_edge(), Bound::Included(&"2.0".to_string()));
assert!(contains_str("2.0", &max));
assert!(!contains_str("2.1", &max));
}
#[test]
fn test_range_from_edges_unsatisfiable_shapes_are_empty() {
let cmp = |a: &String, b: &String| a.cmp(b);
assert_eq!(
range_from_edges(
Bound::Included("5.0".to_string()),
Bound::Included("3.0".to_string()),
cmp
),
Some(VersionRange::Empty)
);
assert_eq!(
range_from_edges(
Bound::Excluded("3.0".to_string()),
Bound::Included("3.0".to_string()),
cmp
),
Some(VersionRange::Empty)
);
}
#[test]
fn test_union_gap_excludes_false_when_covered() {
let members: Vec<(i32, i32)> = vec![(0, 5), (4, 10)];
assert!(!union_gap_excludes(
&members,
|&(lo, hi)| 4 >= lo && 4 <= hi,
|&(_, hi)| 4 < hi,
|&(lo, _)| 4 > lo,
));
}
}