use std::fmt;
use super::ybf::Ybf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReqKind {
Tilde,
Exact,
Caret,
Wildcard,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Requirement {
pub(crate) kind: ReqKind,
pub(crate) base: Option<Ybf>,
}
impl Requirement {
pub(crate) fn parse(raw: &str) -> Result<Self, RangeError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(RangeError {
input: raw.to_string(),
reason: "empty requirement".to_string(),
});
}
if trimmed == "*" {
return Ok(Self {
kind: ReqKind::Wildcard,
base: None,
});
}
if let Some(rest) = trimmed.strip_prefix('~') {
let base = Ybf::parse(rest).map_err(|e| RangeError {
input: raw.to_string(),
reason: format!("tilde range: {e}"),
})?;
return Ok(Self {
kind: ReqKind::Tilde,
base: Some(base),
});
}
if let Some(rest) = trimmed.strip_prefix('=') {
let base = Ybf::parse(rest).map_err(|e| RangeError {
input: raw.to_string(),
reason: format!("exact range: {e}"),
})?;
return Ok(Self {
kind: ReqKind::Exact,
base: Some(base),
});
}
if let Some(rest) = trimmed.strip_prefix('^') {
let base = Ybf::parse(rest).map_err(|e| RangeError {
input: raw.to_string(),
reason: format!("caret range: {e}"),
})?;
return Ok(Self {
kind: ReqKind::Caret,
base: Some(base),
});
}
if let Ok(base) = Ybf::parse(trimmed) {
return Ok(Self {
kind: ReqKind::Caret,
base: Some(base),
});
}
Err(RangeError {
input: raw.to_string(),
reason: "unrecognized requirement shape".to_string(),
})
}
#[allow(dead_code)]
pub(crate) fn upper_exclusive(&self) -> Option<Ybf> {
match self.kind {
ReqKind::Tilde => self.base.map(|b| Ybf {
year: b.year,
break_: b.break_.saturating_add(1),
fix: 0,
}),
ReqKind::Caret => self.base.map(|b| Ybf {
year: b.year.saturating_add(1),
break_: 0,
fix: 0,
}),
ReqKind::Exact | ReqKind::Wildcard => None,
}
}
#[allow(dead_code)]
pub(crate) fn admits(&self, candidate: Ybf) -> bool {
match self.kind {
ReqKind::Wildcard => true,
ReqKind::Exact => self.base.map(|b| b == candidate).unwrap_or(false),
ReqKind::Tilde | ReqKind::Caret => match self.base {
Some(base) => {
candidate >= base
&& self
.upper_exclusive()
.is_some_and(|upper| candidate < upper)
}
None => false,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RangeError {
pub(crate) input: String,
pub(crate) reason: String,
}
impl fmt::Display for RangeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"invalid requirement {:?}: {}",
self.input, self.reason
)
}
}
impl std::error::Error for RangeError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_tilde() {
let r = Requirement::parse("~2026.1.0").unwrap();
assert_eq!(r.kind, ReqKind::Tilde);
assert_eq!(r.base, Some(Ybf::parse("2026.1.0").unwrap()));
}
#[test]
fn parses_exact() {
let r = Requirement::parse("=2026.1.0").unwrap();
assert_eq!(r.kind, ReqKind::Exact);
assert_eq!(r.base, Some(Ybf::parse("2026.1.0").unwrap()));
}
#[test]
fn parses_caret_explicit() {
let r = Requirement::parse("^2026.1.0").unwrap();
assert_eq!(r.kind, ReqKind::Caret);
}
#[test]
fn parses_caret_implicit_bare_version() {
let r = Requirement::parse("2026.1.0").unwrap();
assert_eq!(r.kind, ReqKind::Caret);
}
#[test]
fn parses_wildcard() {
let r = Requirement::parse("*").unwrap();
assert_eq!(r.kind, ReqKind::Wildcard);
assert!(r.base.is_none());
}
#[test]
fn rejects_empty() {
assert!(Requirement::parse("").is_err());
}
#[test]
fn rejects_garbage() {
assert!(Requirement::parse("latest").is_err());
assert!(Requirement::parse(">=2026.1.0, <2027.0.0").is_err());
}
#[test]
fn rejects_invalid_tilde_base() {
assert!(Requirement::parse("~2026.1.x").is_err());
}
#[test]
fn tilde_upper_bound_is_next_generation() {
let r = Requirement::parse("~2026.1.0").unwrap();
assert_eq!(r.upper_exclusive(), Some(Ybf::parse("2026.2.0").unwrap()));
}
#[test]
fn caret_upper_bound_is_next_year() {
let r = Requirement::parse("^2026.1.0").unwrap();
assert_eq!(r.upper_exclusive(), Some(Ybf::parse("2027.0.0").unwrap()));
}
#[test]
fn exact_and_wildcard_have_no_upper_bound() {
let exact = Requirement::parse("=2026.1.0").unwrap();
let wild = Requirement::parse("*").unwrap();
assert!(exact.upper_exclusive().is_none());
assert!(wild.upper_exclusive().is_none());
}
#[test]
fn tilde_admits_same_generation_fixes() {
let r = Requirement::parse("~2026.1.0").unwrap();
assert!(r.admits(Ybf::parse("2026.1.0").unwrap()));
assert!(r.admits(Ybf::parse("2026.1.5").unwrap()));
}
#[test]
fn tilde_refuses_next_generation() {
let r = Requirement::parse("~2026.1.0").unwrap();
assert!(!r.admits(Ybf::parse("2026.2.0").unwrap()));
assert!(!r.admits(Ybf::parse("2027.0.0").unwrap()));
}
#[test]
fn exact_admits_only_one_version() {
let r = Requirement::parse("=2026.1.0").unwrap();
assert!(r.admits(Ybf::parse("2026.1.0").unwrap()));
assert!(!r.admits(Ybf::parse("2026.1.1").unwrap()));
assert!(!r.admits(Ybf::parse("2026.2.0").unwrap()));
}
#[test]
fn caret_admits_next_generation_within_year() {
let r = Requirement::parse("^2026.1.0").unwrap();
assert!(r.admits(Ybf::parse("2026.2.0").unwrap()));
}
#[test]
fn caret_refuses_next_year() {
let r = Requirement::parse("^2026.1.0").unwrap();
assert!(!r.admits(Ybf::parse("2027.0.0").unwrap()));
}
}