use std::cmp::Ordering;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Ybf {
pub(crate) year: u32,
pub(crate) break_: u32,
pub(crate) fix: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct YbfError {
pub(crate) input: String,
pub(crate) reason: String,
}
impl fmt::Display for YbfError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"invalid YBF version {:?}: {}",
self.input, self.reason
)
}
}
impl std::error::Error for YbfError {}
impl Ybf {
pub(crate) fn parse(input: &str) -> Result<Self, YbfError> {
let mut parts = input.split('.');
let reason = |msg: &str| YbfError {
input: input.to_string(),
reason: msg.to_string(),
};
let year = parse_component(parts.next(), "year", input)?;
let break_ = parse_component(parts.next(), "break", input)?;
let fix = parse_component(parts.next(), "fix", input)?;
if parts.next().is_some() {
return Err(reason("expected exactly 3 components"));
}
Ok(Self { year, break_, fix })
}
#[allow(dead_code)]
pub(crate) fn to_string_value(self) -> String {
format!("{}.{}.{}", self.year, self.break_, self.fix)
}
#[allow(dead_code)]
pub(crate) fn bump_fix(self) -> Self {
Self {
year: self.year,
break_: self.break_,
fix: self.fix.saturating_add(1),
}
}
#[allow(dead_code)]
pub(crate) fn bump_break(self) -> Self {
Self {
year: self.year,
break_: self.break_.saturating_add(1),
fix: 0,
}
}
#[allow(dead_code)]
pub(crate) fn bump_year(self) -> Self {
Self {
year: self.year.saturating_add(1),
break_: 0,
fix: 0,
}
}
}
impl Ord for Ybf {
fn cmp(&self, other: &Self) -> Ordering {
self.year
.cmp(&other.year)
.then(self.break_.cmp(&other.break_))
.then(self.fix.cmp(&other.fix))
}
}
impl PartialOrd for Ybf {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Display for Ybf {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}.{}.{}", self.year, self.break_, self.fix)
}
}
fn parse_component(raw: Option<&str>, name: &str, input: &str) -> Result<u32, YbfError> {
let raw = raw.ok_or_else(|| YbfError {
input: input.to_string(),
reason: format!("missing {name} component"),
})?;
if raw.is_empty() {
return Err(YbfError {
input: input.to_string(),
reason: format!("empty {name} component"),
});
}
if raw.len() > 1 && raw.starts_with('0') {
return Err(YbfError {
input: input.to_string(),
reason: format!("{name} has a leading zero"),
});
}
if !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(YbfError {
input: input.to_string(),
reason: format!("{name} is not a base-10 integer"),
});
}
raw.parse::<u32>().map_err(|_| YbfError {
input: input.to_string(),
reason: format!("{name} overflows u32"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_form() {
let v = Ybf::parse("2026.1.0").unwrap();
assert_eq!(
v,
Ybf {
year: 2026,
break_: 1,
fix: 0
}
);
}
#[test]
fn parses_zero_components() {
let v = Ybf::parse("2026.0.0").unwrap();
assert_eq!(
v,
Ybf {
year: 2026,
break_: 0,
fix: 0
}
);
}
#[test]
fn rejects_leading_v() {
assert!(Ybf::parse("v2026.1.0").is_err());
}
#[test]
fn rejects_leading_zeros() {
assert!(Ybf::parse("2026.01.0").is_err());
assert!(Ybf::parse("02026.1.0").is_err());
assert!(Ybf::parse("2026.1.00").is_err());
}
#[test]
fn rejects_too_few_components() {
assert!(Ybf::parse("2026.1").is_err());
assert!(Ybf::parse("2026").is_err());
}
#[test]
fn rejects_too_many_components() {
assert!(Ybf::parse("2026.1.0.0").is_err());
}
#[test]
fn rejects_empty_components() {
assert!(Ybf::parse("2026..0").is_err());
assert!(Ybf::parse("2026.1.").is_err());
assert!(Ybf::parse(".1.0").is_err());
}
#[test]
fn rejects_non_numeric() {
assert!(Ybf::parse("2026.1.x").is_err());
assert!(Ybf::parse("2026.a.0").is_err());
}
#[test]
fn rejects_prerelease_metadata() {
assert!(Ybf::parse("2026.1.0-alpha").is_err());
assert!(Ybf::parse("2026.1.0+build").is_err());
}
#[test]
fn bump_fix_increments_fix_only() {
let v = Ybf::parse("2026.1.3").unwrap();
assert_eq!(v.bump_fix(), Ybf::parse("2026.1.4").unwrap());
}
#[test]
fn bump_break_resets_fix() {
let v = Ybf::parse("2026.1.5").unwrap();
assert_eq!(v.bump_break(), Ybf::parse("2026.2.0").unwrap());
}
#[test]
fn bump_year_resets_break_and_fix() {
let v = Ybf::parse("2026.1.5").unwrap();
assert_eq!(v.bump_year(), Ybf::parse("2027.0.0").unwrap());
}
#[test]
fn ordering_is_lexicographic() {
let a = Ybf::parse("2026.1.0").unwrap();
let b = Ybf::parse("2026.1.1").unwrap();
let c = Ybf::parse("2026.2.0").unwrap();
let d = Ybf::parse("2027.0.0").unwrap();
assert!(a < b);
assert!(b < c);
assert!(c < d);
}
#[test]
fn display_is_canonical() {
let v = Ybf::parse("2026.1.0").unwrap();
assert_eq!(v.to_string(), "2026.1.0");
assert_eq!(v.to_string_value(), "2026.1.0");
}
}