use std::cmp::Ordering;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionRange<V> {
Exact(V),
Minimum {
version: V,
inclusive: bool,
},
Maximum {
version: V,
inclusive: bool,
},
Bounded {
min: V,
min_inclusive: bool,
max: V,
max_inclusive: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BracketStyle {
Standard,
AllowReversed,
}
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,
}
}
}
#[allow(clippy::string_slice)]
pub fn parse_interval<V>(
s: &str,
style: BracketStyle,
parse_bound: impl Fn(&str) -> Option<V>,
) -> Option<VersionRange<V>> {
let s = s.trim();
let first = s.chars().next()?;
let min_inclusive = match (first, style) {
('[', _) => true,
('(', _) => false,
(']', BracketStyle::AllowReversed) => false,
_ => return None,
};
let last = s.chars().next_back()?;
let max_inclusive = match (last, style) {
(']', _) => true,
(')', _) => false,
('[', BracketStyle::AllowReversed) => false,
_ => 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 min = parse_bound_side(lo.trim(), &parse_bound);
let max = parse_bound_side(hi.trim(), &parse_bound);
match (min, max) {
(ParsedBound::Invalid, _) | (_, ParsedBound::Invalid) => None,
(ParsedBound::Value(min), ParsedBound::Value(max)) => Some(VersionRange::Bounded {
min,
min_inclusive,
max,
max_inclusive,
}),
(ParsedBound::Value(version), ParsedBound::Open) => Some(VersionRange::Minimum {
version,
inclusive: min_inclusive,
}),
(ParsedBound::Open, ParsedBound::Value(version)) => Some(VersionRange::Maximum {
version,
inclusive: max_inclusive,
}),
(ParsedBound::Open, ParsedBound::Open) => None,
}
} else {
let inner = inner.trim();
if inner.is_empty() || !min_inclusive || !max_inclusive {
return None;
}
parse_bound(inner).map(VersionRange::Exact)
}
}
fn satisfies_min<Q: ?Sized, V>(
v: &Q,
min: &V,
inclusive: bool,
cmp: &impl Fn(&Q, &V) -> Ordering,
) -> bool {
let ord = cmp(v, min);
if inclusive {
ord != Ordering::Less
} else {
ord == Ordering::Greater
}
}
fn satisfies_max<Q: ?Sized, V>(
v: &Q,
max: &V,
inclusive: bool,
cmp: &impl Fn(&Q, &V) -> Ordering,
) -> bool {
let ord = cmp(v, max);
if inclusive {
ord != Ordering::Greater
} else {
ord == Ordering::Less
}
}
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::Minimum { version, inclusive } => satisfies_min(v, version, *inclusive, &cmp),
VersionRange::Maximum { version, inclusive } => satisfies_max(v, version, *inclusive, &cmp),
VersionRange::Bounded {
min,
min_inclusive,
max,
max_inclusive,
} => {
satisfies_min(v, min, *min_inclusive, &cmp)
&& satisfies_max(v, max, *max_inclusive, &cmp)
}
}
}
#[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()))
}
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();
assert_eq!(
parse_interval("[1,not-a-number)", BracketStyle::Standard, parse_u32),
None
);
assert_eq!(
parse_interval("[not-a-number]", BracketStyle::Standard, parse_u32),
None
);
}
#[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));
}
}