use core::fmt;
use core::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ReleaseDate {
year: u16,
month: u8,
day: u8,
}
impl ReleaseDate {
pub const V2024_11_05: Self = Self::new(2024, 11, 5);
pub const V2025_03_26: Self = Self::new(2025, 3, 26);
pub const V2025_06_18: Self = Self::new(2025, 6, 18);
pub const V2025_11_25: Self = Self::new(2025, 11, 25);
pub const V2026_07_28: Self = Self::new(2026, 7, 28);
const fn new(year: u16, month: u8, day: u8) -> Self {
Self { year, month, day }
}
#[must_use]
pub const fn year(self) -> u16 {
self.year
}
#[must_use]
pub const fn month(self) -> u8 {
self.month
}
#[must_use]
pub const fn day(self) -> u8 {
self.day
}
#[must_use]
pub fn is_at_or_after_first_revision(self) -> bool {
self >= Self::V2024_11_05
}
}
impl fmt::Display for ReleaseDate {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let (year, month, day) = (self.year, self.month, self.day);
write!(formatter, "{year:04}-{month:02}-{day:02}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ParseReleaseDateError {
#[error("a release date is exactly 10 bytes (`YYYY-MM-DD`); this one is {found}")]
WrongLength {
found: usize,
},
#[error("expected `-` at byte {index}; found {found:#04x}")]
NotASeparator {
index: usize,
found: u8,
},
#[error("expected an ASCII digit at byte {index}; found {found:#04x}")]
NotADigit {
index: usize,
found: u8,
},
#[error("month {month} is out of range 1..=12")]
MonthOutOfRange {
month: u16,
},
#[error("day {day} is out of range 1..={last} for {year:04}-{month:02}")]
DayOutOfRange {
year: u16,
month: u8,
day: u16,
last: u8,
},
#[error("{date} is a well-formed date but predates the first revision, 2024-11-05")]
BeforeFirstRevision {
date: ReleaseDate,
},
}
impl FromStr for ReleaseDate {
type Err = ParseReleaseDateError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let bytes = text.as_bytes();
if bytes.len() != 10 {
return Err(ParseReleaseDateError::WrongLength { found: bytes.len() });
}
for index in [4, 7] {
match bytes.get(index) {
Some(b'-') => {}
Some(&found) => return Err(ParseReleaseDateError::NotASeparator { index, found }),
None => {
return Err(ParseReleaseDateError::WrongLength { found: bytes.len() });
}
}
}
let year = number(bytes, 0, 4)?;
let month = number(bytes, 5, 2)?;
let day = number(bytes, 8, 2)?;
if !(1..=12).contains(&month) {
return Err(ParseReleaseDateError::MonthOutOfRange { month });
}
let month = u8::try_from(month).unwrap_or(0);
let last = days_in_month(year, month).unwrap_or(0);
if day < 1 || day > u16::from(last) {
return Err(ParseReleaseDateError::DayOutOfRange {
year,
month,
day,
last,
});
}
let day = u8::try_from(day).unwrap_or(0);
Ok(Self::new(year, month, day))
}
}
fn number(bytes: &[u8], start: usize, width: usize) -> Result<u16, ParseReleaseDateError> {
let mut total: u16 = 0;
for index in start..start + width {
let byte = *bytes
.get(index)
.ok_or(ParseReleaseDateError::WrongLength { found: bytes.len() })?;
let digit = byte
.checked_sub(b'0')
.filter(|digit| *digit <= 9)
.ok_or(ParseReleaseDateError::NotADigit { index, found: byte })?;
total = total * 10 + u16::from(digit);
}
Ok(total)
}
const fn days_in_month(year: u16, month: u8) -> Option<u8> {
Some(match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => return None,
})
}
const fn is_leap_year(year: u16) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use super::{ParseReleaseDateError, ReleaseDate, days_in_month, is_leap_year, number};
type Case = (&'static str, fn(&ParseReleaseDateError) -> bool);
#[test]
fn derived_ordering_agrees_in_both_directions() {
assert!(ReleaseDate::V2024_11_05 < ReleaseDate::V2026_07_28);
assert!(ReleaseDate::V2026_07_28 > ReleaseDate::V2024_11_05);
assert_eq!(ReleaseDate::V2025_06_18, ReleaseDate::V2025_06_18,);
assert!(ReleaseDate::V2025_03_26 < ReleaseDate::V2025_06_18);
}
#[test]
fn field_order_is_chronological() {
assert!(ReleaseDate::V2025_03_26 < ReleaseDate::V2025_11_25);
assert!(ReleaseDate::V2025_11_25 > ReleaseDate::V2025_06_18);
assert!(ReleaseDate::V2025_06_18 < ReleaseDate::new(2025, 6, 19));
}
#[test]
fn parses_each_published_revision() {
let cases = [
("2024-11-05", ReleaseDate::V2024_11_05),
("2025-03-26", ReleaseDate::V2025_03_26),
("2025-06-18", ReleaseDate::V2025_06_18),
("2025-11-25", ReleaseDate::V2025_11_25),
("2026-07-28", ReleaseDate::V2026_07_28),
];
for (text, expected) in cases {
assert_eq!(text.parse::<ReleaseDate>().ok(), Some(expected), "{text}");
}
}
#[test]
fn round_trips_through_its_display_form() {
for date in [
ReleaseDate::V2024_11_05,
ReleaseDate::V2025_03_26,
ReleaseDate::V2025_06_18,
ReleaseDate::V2025_11_25,
ReleaseDate::V2026_07_28,
] {
assert_eq!(date.to_string().parse::<ReleaseDate>(), Ok(date));
}
}
#[test]
fn rejects_malformed_dates() {
let cases: [Case; 9] = [
("", |e| {
matches!(e, ParseReleaseDateError::WrongLength { found: 0 })
}),
("2025-03", |e| {
matches!(e, ParseReleaseDateError::WrongLength { found: 7 })
}),
("2025-03-26T00:00:00Z", |e| {
matches!(e, ParseReleaseDateError::WrongLength { found: 20 })
}),
(" 2025-03-26", |e| {
matches!(e, ParseReleaseDateError::WrongLength { found: 11 })
}),
("+025-03-26", |e| {
matches!(
e,
ParseReleaseDateError::NotADigit {
index: 0,
found: b'+'
}
)
}),
("2€-03-26", |e| {
matches!(e, ParseReleaseDateError::NotADigit { index: 1, .. })
}),
("2025/03/26", |e| {
matches!(
e,
ParseReleaseDateError::NotASeparator {
index: 4,
found: b'/'
}
)
}),
("2025-03/26", |e| {
matches!(
e,
ParseReleaseDateError::NotASeparator {
index: 7,
found: b'/'
}
)
}),
("2025-13-26", |e| {
matches!(e, ParseReleaseDateError::MonthOutOfRange { month: 13 })
}),
];
for (text, matches_expected) in cases {
let parsed = text.parse::<ReleaseDate>();
assert!(parsed.is_err(), "{text:?} should not parse");
let Err(error) = parsed else {
continue;
};
assert!(matches_expected(&error), "{text:?} gave {error}");
}
}
#[test]
fn rejects_days_the_month_does_not_have() {
assert!("2025-04-31".parse::<ReleaseDate>().is_err());
assert!("2025-06-31".parse::<ReleaseDate>().is_err());
assert!("2025-02-29".parse::<ReleaseDate>().is_err());
assert!("2025-04-30".parse::<ReleaseDate>().is_ok());
assert!("2025-02-28".parse::<ReleaseDate>().is_ok());
assert!("2000-02-29".parse::<ReleaseDate>().is_ok());
assert!("1900-02-29".parse::<ReleaseDate>().is_err());
assert!("2024-02-29".parse::<ReleaseDate>().is_ok());
}
#[test]
fn reports_day_zero_rather_than_accepting_it() {
let error = "2025-06-00".parse::<ReleaseDate>().unwrap_err();
assert!(
matches!(
error,
ParseReleaseDateError::DayOutOfRange {
year: 2025,
month: 6,
day: 0,
last: 30
}
),
"got {error}"
);
}
#[test]
fn every_month_length_is_correct() {
let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for (index, &last) in expected.iter().enumerate() {
let month = u8::try_from(index + 1).unwrap_or(0);
assert_eq!(days_in_month(2025, month), Some(last), "month {month}");
}
assert_eq!(days_in_month(2024, 2), Some(29));
assert_eq!(days_in_month(1900, 2), Some(28));
assert_eq!(days_in_month(2000, 2), Some(29));
assert_eq!(days_in_month(2025, 0), None);
assert_eq!(days_in_month(2025, 13), None);
}
#[test]
fn leap_year_rule_follows_the_gregorian_exceptions() {
assert!(is_leap_year(2000));
assert!(!is_leap_year(1900));
assert!(is_leap_year(2024));
assert!(!is_leap_year(2025));
}
#[test]
fn number_does_not_panic_past_the_end() {
assert!(number(b"12345", 0, 9).is_err());
assert!(number(b"", 0, 1).is_err());
assert_eq!(number(b"2025-03-26", 0, 4), Ok(2025));
}
#[test]
fn components_are_readable() {
let date = ReleaseDate::V2025_11_25;
assert_eq!(date.year(), 2025);
assert_eq!(date.month(), 11);
assert_eq!(date.day(), 25);
assert!(date.is_at_or_after_first_revision());
assert!(!ReleaseDate::new(2020, 1, 1).is_at_or_after_first_revision());
}
}