use crate::{Period, TimeError};
use core::fmt;
use core::ops::{Add, Range, Sub};
const EPOCH_YEAR: u16 = 1901;
const END_YEAR: u16 = 2199;
const NUM_YEARS: u16 = END_YEAR - EPOCH_YEAR + 1;
const fn is_leap(year: u16) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
const CUMULATIVE: [u32; NUM_YEARS as usize + 1] = {
let mut out = [0u32; NUM_YEARS as usize + 1];
let mut i: u16 = 0;
while i < NUM_YEARS {
let year = EPOCH_YEAR + i;
let len: u32 = if is_leap(year) { 366 } else { 365 };
out[i as usize + 1] = out[i as usize] + len;
i += 1;
}
out
};
const MAX_SERIAL: u32 = CUMULATIVE[NUM_YEARS as usize] - 1;
const MONTH_OFFSETS_NONLEAP: [u32; 13] =
[0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
const MONTH_OFFSETS_LEAP: [u32; 13] = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Year(u16);
impl Year {
pub const MIN: Self = Self(EPOCH_YEAR);
pub const MAX: Self = Self(END_YEAR);
pub const fn new(year: u16) -> Result<Self, TimeError> {
if year < EPOCH_YEAR || year > END_YEAR {
Err(TimeError::YearOutOfRange)
} else {
Ok(Self(year))
}
}
#[must_use]
#[allow(clippy::panic)]
pub const fn literal(year: u16) -> Self {
match Self::new(year) {
Ok(y) => y,
Err(_) => panic!("Year::literal: argument must be in 1901..=2199"),
}
}
#[must_use]
pub const fn get(self) -> u16 {
self.0
}
#[must_use]
pub const fn is_leap(self) -> bool {
is_leap(self.0)
}
#[must_use]
pub const fn length(self) -> u16 {
if self.is_leap() { 366 } else { 365 }
}
}
impl fmt::Display for Year {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum Month {
Jan = 1,
Feb = 2,
Mar = 3,
Apr = 4,
May = 5,
Jun = 6,
Jul = 7,
Aug = 8,
Sep = 9,
Oct = 10,
Nov = 11,
Dec = 12,
}
impl Month {
#[must_use]
pub const fn get(self) -> u8 {
self as u8
}
pub const fn try_from_u8(month: u8) -> Result<Self, TimeError> {
match month {
1 => Ok(Self::Jan),
2 => Ok(Self::Feb),
3 => Ok(Self::Mar),
4 => Ok(Self::Apr),
5 => Ok(Self::May),
6 => Ok(Self::Jun),
7 => Ok(Self::Jul),
8 => Ok(Self::Aug),
9 => Ok(Self::Sep),
10 => Ok(Self::Oct),
11 => Ok(Self::Nov),
12 => Ok(Self::Dec),
_ => Err(TimeError::MonthOutOfRange),
}
}
#[must_use]
pub const fn length(self, year: Year) -> u8 {
match self {
Self::Jan | Self::Mar | Self::May | Self::Jul | Self::Aug | Self::Oct | Self::Dec => 31,
Self::Apr | Self::Jun | Self::Sep | Self::Nov => 30,
Self::Feb => {
if year.is_leap() {
29
} else {
28
}
}
}
}
}
impl fmt::Display for Month {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Jan => "Jan",
Self::Feb => "Feb",
Self::Mar => "Mar",
Self::Apr => "Apr",
Self::May => "May",
Self::Jun => "Jun",
Self::Jul => "Jul",
Self::Aug => "Aug",
Self::Sep => "Sep",
Self::Oct => "Oct",
Self::Nov => "Nov",
Self::Dec => "Dec",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum Weekday {
Mon = 1,
Tue = 2,
Wed = 3,
Thu = 4,
Fri = 5,
Sat = 6,
Sun = 7,
}
impl Weekday {
#[must_use]
pub const fn get(self) -> u8 {
self as u8
}
pub const fn try_from_u8(weekday: u8) -> Result<Self, TimeError> {
match weekday {
1 => Ok(Self::Mon),
2 => Ok(Self::Tue),
3 => Ok(Self::Wed),
4 => Ok(Self::Thu),
5 => Ok(Self::Fri),
6 => Ok(Self::Sat),
7 => Ok(Self::Sun),
_ => Err(TimeError::WeekdayOutOfRange),
}
}
}
impl fmt::Display for Weekday {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Mon => "Mon",
Self::Tue => "Tue",
Self::Wed => "Wed",
Self::Thu => "Thu",
Self::Fri => "Fri",
Self::Sat => "Sat",
Self::Sun => "Sun",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum Ordinal {
First = 1,
Second = 2,
Third = 3,
Fourth = 4,
Fifth = 5,
}
impl Ordinal {
#[must_use]
pub const fn get(self) -> u8 {
self as u8
}
pub const fn try_from_u8(n: u8) -> Result<Self, TimeError> {
match n {
1 => Ok(Self::First),
2 => Ok(Self::Second),
3 => Ok(Self::Third),
4 => Ok(Self::Fourth),
5 => Ok(Self::Fifth),
_ => Err(TimeError::OrdinalOutOfRange),
}
}
}
impl fmt::Display for Ordinal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::First => "First",
Self::Second => "Second",
Self::Third => "Third",
Self::Fourth => "Fourth",
Self::Fifth => "Fifth",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Date(u32);
impl Date {
pub const MIN: Self = Self(0);
pub const MAX: Self = Self(MAX_SERIAL);
pub const fn from_ymd(year: u16, month: Month, day: u8) -> Result<Self, TimeError> {
let y = match Year::new(year) {
Ok(y) => y,
Err(e) => return Err(e),
};
let len = month.length(y);
if day == 0 || day > len {
return Err(TimeError::DayOutOfRange);
}
let year_idx = (year - EPOCH_YEAR) as usize;
let year_start = CUMULATIVE[year_idx];
let month_offset = if y.is_leap() {
MONTH_OFFSETS_LEAP[(month.get() - 1) as usize]
} else {
MONTH_OFFSETS_NONLEAP[(month.get() - 1) as usize]
};
Ok(Self(year_start + month_offset + day as u32 - 1))
}
#[must_use]
#[allow(clippy::panic)]
pub const fn literal(year: u16, month: Month, day: u8) -> Self {
match Self::from_ymd(year, month, day) {
Ok(d) => d,
Err(_) => panic!("Date::literal: invalid year/month/day"),
}
}
pub const fn from_serial(serial: u32) -> Result<Self, TimeError> {
if serial > MAX_SERIAL {
Err(TimeError::DateOutOfRange)
} else {
Ok(Self(serial))
}
}
#[must_use]
pub const fn serial(self) -> u32 {
self.0
}
#[must_use]
pub const fn year(self) -> Year {
let serial = self.0;
#[allow(clippy::cast_possible_truncation)]
let mut idx = (serial * 400 / 146_097) as u16;
while CUMULATIVE[idx as usize + 1] <= serial {
idx += 1;
}
while CUMULATIVE[idx as usize] > serial {
idx -= 1;
}
Year(EPOCH_YEAR + idx)
}
#[must_use]
pub const fn to_ymd(self) -> (Year, Month, u8) {
let y = self.year();
let year_idx = (y.0 - EPOCH_YEAR) as usize;
let doy = self.0 - CUMULATIVE[year_idx];
let offsets = if y.is_leap() {
&MONTH_OFFSETS_LEAP
} else {
&MONTH_OFFSETS_NONLEAP
};
let mut m: usize = 0;
while m + 1 < 13 && offsets[m + 1] <= doy {
m += 1;
}
let month = match m {
0 => Month::Jan,
1 => Month::Feb,
2 => Month::Mar,
3 => Month::Apr,
4 => Month::May,
5 => Month::Jun,
6 => Month::Jul,
7 => Month::Aug,
8 => Month::Sep,
9 => Month::Oct,
10 => Month::Nov,
_ => Month::Dec,
};
#[allow(clippy::cast_possible_truncation)]
let day_of_month = (doy - offsets[m] + 1) as u8;
(y, month, day_of_month)
}
#[must_use]
pub const fn month(self) -> Month {
let (_, m, _) = self.to_ymd();
m
}
#[must_use]
pub const fn day(self) -> u8 {
let (_, _, d) = self.to_ymd();
d
}
#[must_use]
pub const fn day_of_year(self) -> u16 {
let y = self.year();
let year_idx = (y.0 - EPOCH_YEAR) as usize;
#[allow(clippy::cast_possible_truncation)]
let doy = (self.0 - CUMULATIVE[year_idx] + 1) as u16;
doy
}
#[must_use]
pub const fn weekday(self) -> Weekday {
match (self.0 + 1) % 7 {
0 => Weekday::Mon,
1 => Weekday::Tue,
2 => Weekday::Wed,
3 => Weekday::Thu,
4 => Weekday::Fri,
5 => Weekday::Sat,
_ => Weekday::Sun,
}
}
pub const fn add_days(self, n: i32) -> Result<Self, TimeError> {
let target = self.0 as i64 + n as i64;
if target < 0 || target > MAX_SERIAL as i64 {
return Err(TimeError::DateOutOfRange);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let serial = target as u32;
Ok(Self(serial))
}
#[must_use]
pub const fn days_since(self, other: Self) -> i32 {
let diff = self.0 as i64 - other.0 as i64;
#[allow(clippy::cast_possible_truncation)]
let diff_i32 = diff as i32;
diff_i32
}
pub const fn add_months(self, n: i32) -> Result<Self, TimeError> {
let (year, month, day) = self.to_ymd();
let total_months = year.get() as i32 * 12 + (month.get() as i32 - 1);
let Some(new_total) = total_months.checked_add(n) else {
return Err(TimeError::DateOutOfRange);
};
let target_year_i32 = new_total.div_euclid(12);
let new_month_idx = new_total.rem_euclid(12);
if target_year_i32 < Year::MIN.get() as i32 || target_year_i32 > Year::MAX.get() as i32 {
return Err(TimeError::DateOutOfRange);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_year_u16 = target_year_i32 as u16;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_month = match Month::try_from_u8((new_month_idx as u8) + 1) {
Ok(found) => found,
Err(err) => return Err(err),
};
let target_year = match Year::new(new_year_u16) {
Ok(found) => found,
Err(err) => return Err(err),
};
let clamped_day = {
let len = new_month.length(target_year);
if day > len { len } else { day }
};
Self::from_ymd(new_year_u16, new_month, clamped_day)
}
pub const fn add_years(self, n: i32) -> Result<Self, TimeError> {
let Some(months) = n.checked_mul(12) else {
return Err(TimeError::DateOutOfRange);
};
self.add_months(months)
}
pub fn advance(self, period: Period, end_of_month: bool) -> Result<Self, TimeError> {
let stepped = (self + period)?;
Ok(
if end_of_month
&& self.is_end_of_month()
&& matches!(period, Period::Months(_) | Period::Years(_))
{
stepped.end_of_month()
} else {
stepped
},
)
}
#[must_use]
pub const fn start_of_month(self) -> Self {
Self(self.0 - (self.day() as u32 - 1))
}
#[must_use]
pub const fn is_start_of_month(self) -> bool {
self.day() == 1
}
pub fn next_weekday(self, weekday: Weekday) -> Result<Self, TimeError> {
let delta = (i32::from(weekday.get()) - i32::from(self.weekday().get())).rem_euclid(7);
self.add_days(delta)
}
pub fn nth_weekday(
n: Ordinal,
weekday: Weekday,
month: Month,
year: Year,
) -> Result<Self, TimeError> {
let first = Self::from_ymd(year.get(), month, 1)?.next_weekday(weekday)?;
let nth = first.add_days(7 * (i32::from(n.get()) - 1))?;
if nth.month() == month {
Ok(nth)
} else {
Err(TimeError::DayOutOfRange)
}
}
#[must_use]
pub const fn end_of_month(self) -> Self {
let (year, month, _) = self.to_ymd();
let last = month.length(year);
let month_start = self.0 - (self.day() as u32 - 1);
Self(month_start + last as u32 - 1)
}
#[must_use]
pub const fn is_end_of_month(self) -> bool {
let (year, month, day) = self.to_ymd();
day == month.length(year)
}
}
pub trait DateRange: Sized {
fn days(&self) -> i64;
fn intersect(&self, other: &Self) -> Option<Self>;
fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<Self>;
}
impl DateRange for Range<Date> {
fn days(&self) -> i64 {
i64::from(self.end.days_since(self.start))
}
fn intersect(&self, other: &Self) -> Option<Self> {
let both = self.start.max(other.start)..self.end.min(other.end);
(both.start < both.end).then_some(both)
}
fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<> {
(self.start.serial()..self.end.serial()).filter_map(|s| Date::from_serial(s).ok())
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (y, m, d) = self.to_ymd();
write!(f, "{:04}-{:02}-{:02}", y.get(), m.get(), d)
}
}
impl core::str::FromStr for Date {
type Err = TimeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
const fn digit(b: u8) -> Result<u16, TimeError> {
if b.is_ascii_digit() {
Ok((b - b'0') as u16)
} else {
Err(TimeError::InvalidDateString)
}
}
let [y3, y2, y1, y0, h1, m1, m0, h2, d1, d0] = s.as_bytes() else {
return Err(TimeError::InvalidDateString);
};
if *h1 != b'-' || *h2 != b'-' {
return Err(TimeError::InvalidDateString);
}
let year = 1000 * digit(*y3)? + 100 * digit(*y2)? + 10 * digit(*y1)? + digit(*y0)?;
let month_num = 10 * digit(*m1)? + digit(*m0)?;
let day = 10 * digit(*d1)? + digit(*d0)?;
#[allow(clippy::cast_possible_truncation)]
let month = Month::try_from_u8(month_num as u8)?;
#[allow(clippy::cast_possible_truncation)]
let day = day as u8;
Self::from_ymd(year, month, day)
}
}
impl Add<Period> for Date {
type Output = Result<Self, TimeError>;
fn add(self, period: Period) -> Self::Output {
match period {
Period::Days(n) => self.add_days(n),
Period::Weeks(n) => match n.checked_mul(7) {
Some(days) => self.add_days(days),
None => Err(TimeError::DateOutOfRange),
},
Period::Months(n) => self.add_months(n),
Period::Years(n) => self.add_years(n),
}
}
}
impl Sub<Period> for Date {
type Output = Result<Self, TimeError>;
fn sub(self, period: Period) -> Self::Output {
#[allow(clippy::suspicious_arithmetic_impl)]
match period.checked_neg() {
Some(neg) => self + neg,
None => Err(TimeError::DateOutOfRange),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
extern crate alloc;
use super::*;
use proptest::prelude::*;
#[test]
fn epoch_is_1901_01_01_tuesday() {
let d = Date::MIN;
assert_eq!(d.serial(), 0);
assert_eq!(d.year().get(), 1901);
assert_eq!(d.month(), Month::Jan);
assert_eq!(d.day(), 1);
assert_eq!(d.weekday(), Weekday::Tue);
}
#[test]
fn max_is_2199_12_31() {
let d = Date::MAX;
assert_eq!(d.year().get(), 2199);
assert_eq!(d.month(), Month::Dec);
assert_eq!(d.day(), 31);
}
#[test]
fn from_ymd_rejects_out_of_range_year() {
assert_eq!(
Date::from_ymd(1900, Month::Jan, 1),
Err(TimeError::YearOutOfRange)
);
assert_eq!(
Date::from_ymd(2200, Month::Jan, 1),
Err(TimeError::YearOutOfRange)
);
}
#[test]
fn from_ymd_rejects_day_zero_and_overflow() {
assert_eq!(
Date::from_ymd(2026, Month::Jan, 0),
Err(TimeError::DayOutOfRange)
);
assert_eq!(
Date::from_ymd(2026, Month::Jan, 32),
Err(TimeError::DayOutOfRange)
);
assert_eq!(
Date::from_ymd(2026, Month::Apr, 31),
Err(TimeError::DayOutOfRange)
);
}
#[test]
fn february_leap_year_behavior() {
assert!(Date::from_ymd(2000, Month::Feb, 29).is_ok());
assert_eq!(
Date::from_ymd(2100, Month::Feb, 29),
Err(TimeError::DayOutOfRange)
);
assert!(Date::from_ymd(2024, Month::Feb, 29).is_ok());
assert_eq!(
Date::from_ymd(2026, Month::Feb, 29),
Err(TimeError::DayOutOfRange)
);
}
#[test]
fn known_weekdays() {
assert_eq!(
Date::from_ymd(1901, Month::Jan, 1).unwrap().weekday(),
Weekday::Tue,
);
assert_eq!(
Date::from_ymd(2000, Month::Jan, 1).unwrap().weekday(),
Weekday::Sat,
);
assert_eq!(
Date::from_ymd(2026, Month::Jul, 4).unwrap().weekday(),
Weekday::Sat,
);
assert_eq!(
Date::from_ymd(2021, Month::Jun, 19).unwrap().weekday(),
Weekday::Sat,
);
assert_eq!(
Date::from_ymd(2199, Month::Dec, 31).unwrap().weekday(),
Weekday::Tue,
);
}
#[test]
fn year_is_correct_for_every_serial() {
let mut expected: u16 = EPOCH_YEAR;
let mut next_year_start: u32 = CUMULATIVE[1];
for serial in 0..=MAX_SERIAL {
if serial == next_year_start {
expected += 1;
next_year_start = CUMULATIVE[(expected - EPOCH_YEAR) as usize + 1];
}
assert_eq!(
Date::from_serial(serial).unwrap().year().get(),
expected,
"serial {serial}",
);
let estimate = serial * 400 / 146_097;
assert!(
estimate.abs_diff(u32::from(expected - EPOCH_YEAR)) <= 1,
"serial {serial}: estimate {estimate} not within one of the true index",
);
}
}
#[test]
fn day_of_year_boundaries() {
assert_eq!(
Date::from_ymd(2024, Month::Jan, 1).unwrap().day_of_year(),
1,
);
assert_eq!(
Date::from_ymd(2024, Month::Dec, 31).unwrap().day_of_year(),
366, );
assert_eq!(
Date::from_ymd(2025, Month::Dec, 31).unwrap().day_of_year(),
365,
);
}
#[test]
fn add_days_at_boundaries() {
assert_eq!(Date::MIN.add_days(-1), Err(TimeError::DateOutOfRange));
assert_eq!(Date::MAX.add_days(1), Err(TimeError::DateOutOfRange));
let d = Date::from_ymd(2026, Month::Feb, 28).unwrap();
assert_eq!(
d.add_days(1).unwrap(),
Date::from_ymd(2026, Month::Mar, 1).unwrap()
);
let leap = Date::from_ymd(2024, Month::Feb, 28).unwrap();
assert_eq!(
leap.add_days(1).unwrap(),
Date::from_ymd(2024, Month::Feb, 29).unwrap()
);
}
#[test]
fn display_is_iso_8601() {
let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
assert_eq!(alloc::format!("{d}"), "2026-07-04");
}
#[test]
fn weekday_iso_numbering() {
assert_eq!(Weekday::Mon.get(), 1);
assert_eq!(Weekday::Sun.get(), 7);
assert_eq!(Weekday::try_from_u8(1).unwrap(), Weekday::Mon);
assert_eq!(Weekday::try_from_u8(7).unwrap(), Weekday::Sun);
assert_eq!(Weekday::try_from_u8(0), Err(TimeError::WeekdayOutOfRange));
assert_eq!(Weekday::try_from_u8(8), Err(TimeError::WeekdayOutOfRange));
}
#[test]
fn ordinal_display() {
assert_eq!(alloc::format!("{}", Ordinal::First), "First");
assert_eq!(alloc::format!("{}", Ordinal::Fifth), "Fifth");
}
#[test]
fn from_str_parses_display_output() {
for (y, m, d) in [
(1901u16, Month::Jan, 1u8),
(2026, Month::Jul, 4),
(2024, Month::Feb, 29),
(2199, Month::Dec, 31),
] {
let date = Date::from_ymd(y, m, d).unwrap();
let parsed: Date = alloc::format!("{date}").parse().unwrap();
assert_eq!(parsed, date);
}
}
#[test]
fn from_str_rejects_malformed_strings() {
for bad in [
"",
"2026",
"2026-07",
"2026-7-4", "26-07-04", "2026/07/04", "2026-07-04T", " 2026-07-04", "2026-07-04 ", "+026-07-04", "2026-0a-04", "٢٠٢٦-07-04", ] {
assert_eq!(
bad.parse::<Date>(),
Err(TimeError::InvalidDateString),
"{bad:?} should be rejected as malformed",
);
}
}
#[test]
fn from_str_surfaces_range_errors_for_well_formed_input() {
assert_eq!("1900-12-31".parse::<Date>(), Err(TimeError::YearOutOfRange));
assert_eq!("2200-01-01".parse::<Date>(), Err(TimeError::YearOutOfRange));
assert_eq!(
"2026-13-01".parse::<Date>(),
Err(TimeError::MonthOutOfRange)
);
assert_eq!(
"2026-00-01".parse::<Date>(),
Err(TimeError::MonthOutOfRange)
);
assert_eq!("2026-02-30".parse::<Date>(), Err(TimeError::DayOutOfRange));
assert_eq!("2026-01-00".parse::<Date>(), Err(TimeError::DayOutOfRange));
}
#[test]
fn to_ymd_matches_individual_accessors() {
let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
let (y, m, dom) = d.to_ymd();
assert_eq!(y, d.year());
assert_eq!(m, d.month());
assert_eq!(dom, d.day());
}
fn any_ymd() -> impl Strategy<Value = (u16, Month, u8)> {
(EPOCH_YEAR..=END_YEAR, 1u8..=12u8).prop_flat_map(|(y, m)| {
let month = Month::try_from_u8(m).expect("1..=12");
let year = Year::new(y).expect("in range");
let max_day = month.length(year);
(Just(y), Just(month), 1u8..=max_day)
})
}
proptest! {
#[test]
fn from_ymd_round_trips(
(year, month, day) in any_ymd()
) {
let d = Date::from_ymd(year, month, day).expect("valid ymd");
prop_assert_eq!(d.year().get(), year);
prop_assert_eq!(d.month(), month);
prop_assert_eq!(d.day(), day);
}
#[test]
fn serial_round_trips(
serial in 0u32..=MAX_SERIAL,
) {
let d = Date::from_serial(serial).expect("in range");
prop_assert_eq!(d.serial(), serial);
let ymd = Date::from_ymd(d.year().get(), d.month(), d.day()).expect("valid");
prop_assert_eq!(ymd.serial(), serial);
}
#[test]
fn weekday_advances_by_one_per_day(
serial in 0u32..MAX_SERIAL,
) {
let today = Date::from_serial(serial).expect("in range");
let tomorrow = today.add_days(1).expect("in range");
let expected = match today.weekday() {
Weekday::Mon => Weekday::Tue,
Weekday::Tue => Weekday::Wed,
Weekday::Wed => Weekday::Thu,
Weekday::Thu => Weekday::Fri,
Weekday::Fri => Weekday::Sat,
Weekday::Sat => Weekday::Sun,
Weekday::Sun => Weekday::Mon,
};
prop_assert_eq!(tomorrow.weekday(), expected);
}
#[test]
fn add_days_is_inverse_of_days_since(
a_serial in 0u32..=MAX_SERIAL,
b_serial in 0u32..=MAX_SERIAL,
) {
let a = Date::from_serial(a_serial).unwrap();
let b = Date::from_serial(b_serial).unwrap();
let diff = b.days_since(a);
prop_assert_eq!(a.add_days(diff).unwrap(), b);
}
#[test]
fn day_of_year_is_consistent(
(year, month, day) in any_ymd()
) {
let d = Date::from_ymd(year, month, day).expect("valid");
let year_start = Date::from_ymd(year, Month::Jan, 1).expect("valid");
prop_assert_eq!(
u16::try_from(d.days_since(year_start) + 1).unwrap(),
d.day_of_year(),
);
}
#[test]
fn month_try_from_u8_round_trips(m in 1u8..=12u8) {
let parsed = Month::try_from_u8(m).expect("1..=12");
prop_assert_eq!(parsed.get(), m);
}
#[test]
fn weekday_try_from_u8_round_trips(n in 1u8..=7u8) {
let parsed = Weekday::try_from_u8(n).expect("1..=7");
prop_assert_eq!(parsed.get(), n);
}
#[test]
fn ordinal_try_from_u8_round_trips(n in 1u8..=5u8) {
let parsed = Ordinal::try_from_u8(n).expect("1..=5");
prop_assert_eq!(parsed.get(), n);
}
#[test]
fn add_days_accepts_iff_result_in_range(
serial in 0u32..=MAX_SERIAL,
n in i32::MIN..=i32::MAX,
) {
let d = Date::from_serial(serial).expect("in range");
let result = d.add_days(n);
let target = i64::from(serial) + i64::from(n);
let in_range = (0..=i64::from(MAX_SERIAL)).contains(&target);
prop_assert_eq!(result.is_ok(), in_range);
if in_range {
prop_assert_eq!(
result.expect("in-range").serial(),
u32::try_from(target).expect("fits in u32"),
);
} else {
prop_assert_eq!(result, Err(TimeError::DateOutOfRange));
}
}
#[test]
fn to_ymd_round_trips(serial in 0u32..=MAX_SERIAL) {
let d = Date::from_serial(serial).expect("in range");
let (y, m, dom) = d.to_ymd();
let rebuilt = Date::from_ymd(y.get(), m, dom).expect("valid");
prop_assert_eq!(rebuilt.serial(), serial);
}
#[test]
fn display_and_from_str_round_trip(serial in 0u32..=MAX_SERIAL) {
let d = Date::from_serial(serial).expect("in range");
let parsed: Date = alloc::format!("{d}").parse().expect("Display output is valid");
prop_assert_eq!(parsed, d);
}
#[test]
fn add_months_round_trip_on_safe_days(
year in 1910u16..=2190,
month in 1u8..=12,
day in 1u8..=28,
n in -500i32..=500,
) {
let parsed_month = Month::try_from_u8(month).expect("1..=12");
let start = Date::from_ymd(year, parsed_month, day).expect("valid");
if let Ok(stepped) = start.add_months(n)
&& let Ok(restored) = stepped.add_months(-n)
{
prop_assert_eq!(restored, start);
}
}
#[test]
fn add_months_decomposes_into_years_plus_months(
year in 1921u16..=2179,
month in 1u8..=12,
day in 1u8..=28,
whole_years in -20i32..=20,
extra_months in -11i32..=11,
) {
let parsed_month = Month::try_from_u8(month).expect("1..=12");
let start = Date::from_ymd(year, parsed_month, day).expect("valid");
let direct = start.add_months(whole_years * 12 + extra_months);
let stepped = start
.add_years(whole_years)
.and_then(|x| x.add_months(extra_months));
prop_assert_eq!(direct, stepped);
}
#[test]
fn add_months_never_exceeds_target_month_length(
serial in 0u32..=MAX_SERIAL,
n in -200i32..=200,
) {
let d = Date::from_serial(serial).expect("in range");
if let Ok(out) = d.add_months(n) {
let (y, m, dom) = out.to_ymd();
prop_assert!(dom <= m.length(y));
prop_assert!(dom >= 1);
}
}
#[test]
fn end_of_month_is_idempotent(serial in 0u32..=MAX_SERIAL) {
let d = Date::from_serial(serial).expect("in range");
prop_assert_eq!(d.end_of_month(), d.end_of_month().end_of_month());
prop_assert!(d.end_of_month().is_end_of_month());
}
#[test]
fn add_period_days_matches_add_days(
serial in 0u32..=MAX_SERIAL,
n in -10_000i32..=10_000,
) {
let start = Date::from_serial(serial).expect("in range");
prop_assert_eq!(start + crate::Period::Days(n), start.add_days(n));
}
#[test]
fn add_period_weeks_equals_add_days_times_seven(
serial in 0u32..=MAX_SERIAL,
n in (i32::MIN / 7)..=(i32::MAX / 7),
) {
let start = Date::from_serial(serial).expect("in range");
prop_assert_eq!(start + crate::Period::Weeks(n), start.add_days(n * 7));
}
#[test]
fn add_period_months_matches_add_months(
serial in 0u32..=MAX_SERIAL,
n in -200i32..=200,
) {
let start = Date::from_serial(serial).expect("in range");
prop_assert_eq!(start + crate::Period::Months(n), start.add_months(n));
}
#[test]
fn add_period_years_matches_add_years(
serial in 0u32..=MAX_SERIAL,
n in -100i32..=100,
) {
let start = Date::from_serial(serial).expect("in range");
prop_assert_eq!(start + crate::Period::Years(n), start.add_years(n));
}
#[test]
fn sub_period_equals_add_negated_period(
serial in 0u32..=MAX_SERIAL,
length in (i32::MIN + 1)..=i32::MAX,
unit_idx in 0u8..=3,
) {
let p = match unit_idx {
0 => crate::Period::Days(length),
1 => crate::Period::Weeks(length),
2 => crate::Period::Months(length),
_ => crate::Period::Years(length),
};
let start = Date::from_serial(serial).expect("in range");
prop_assert_eq!(start - p, start + (-p));
}
}
#[test]
fn add_months_clamps_to_target_month_length() {
let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
assert_eq!(
jan31.add_months(1).unwrap(),
Date::from_ymd(2026, Month::Feb, 28).unwrap()
);
let jan31_leap = Date::from_ymd(2024, Month::Jan, 31).unwrap();
assert_eq!(
jan31_leap.add_months(1).unwrap(),
Date::from_ymd(2024, Month::Feb, 29).unwrap()
);
let may31 = Date::from_ymd(2026, Month::May, 31).unwrap();
assert_eq!(
may31.add_months(1).unwrap(),
Date::from_ymd(2026, Month::Jun, 30).unwrap()
);
}
#[test]
fn add_months_clamp_is_not_composable_across_eom() {
let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
let two_hops = jan31.add_months(1).unwrap().add_months(1).unwrap();
assert_eq!(two_hops, Date::from_ymd(2026, Month::Mar, 28).unwrap());
let single_hop = jan31.add_months(2).unwrap();
assert_eq!(single_hop, Date::from_ymd(2026, Month::Mar, 31).unwrap());
assert_ne!(two_hops, single_hop);
}
#[test]
fn add_months_crosses_year_boundaries() {
let nov15 = Date::from_ymd(2026, Month::Nov, 15).unwrap();
assert_eq!(
nov15.add_months(3).unwrap(),
Date::from_ymd(2027, Month::Feb, 15).unwrap()
);
assert_eq!(
nov15.add_months(-11).unwrap(),
Date::from_ymd(2025, Month::Dec, 15).unwrap()
);
}
#[test]
fn add_months_zero_is_identity() {
let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
assert_eq!(d.add_months(0).unwrap(), d);
}
#[test]
fn add_months_refuses_out_of_range_result() {
assert_eq!(Date::MAX.add_months(1), Err(TimeError::DateOutOfRange));
assert_eq!(Date::MIN.add_months(-1), Err(TimeError::DateOutOfRange));
}
#[test]
fn add_years_clamps_feb_29_in_non_leap_target() {
let feb29 = Date::from_ymd(2024, Month::Feb, 29).unwrap();
assert_eq!(
feb29.add_years(1).unwrap(),
Date::from_ymd(2025, Month::Feb, 28).unwrap()
);
assert_eq!(
feb29.add_years(4).unwrap(),
Date::from_ymd(2028, Month::Feb, 29).unwrap()
);
}
#[test]
fn end_of_month_examples() {
let d = Date::from_ymd(2024, Month::Jan, 15).unwrap();
assert_eq!(
d.end_of_month(),
Date::from_ymd(2024, Month::Jan, 31).unwrap()
);
let d = Date::from_ymd(2024, Month::Feb, 10).unwrap();
assert_eq!(
d.end_of_month(),
Date::from_ymd(2024, Month::Feb, 29).unwrap()
);
let d = Date::from_ymd(2025, Month::Feb, 10).unwrap();
assert_eq!(
d.end_of_month(),
Date::from_ymd(2025, Month::Feb, 28).unwrap()
);
assert_eq!(Date::MAX.end_of_month(), Date::MAX);
assert_eq!(
Date::MIN.end_of_month(),
Date::from_ymd(1901, Month::Jan, 31).unwrap()
);
}
#[test]
fn is_end_of_month_examples() {
assert!(
Date::from_ymd(2024, Month::Feb, 29)
.unwrap()
.is_end_of_month()
);
assert!(
!Date::from_ymd(2024, Month::Feb, 28)
.unwrap()
.is_end_of_month()
);
assert!(
Date::from_ymd(2025, Month::Feb, 28)
.unwrap()
.is_end_of_month()
);
assert!(
Date::from_ymd(2026, Month::Apr, 30)
.unwrap()
.is_end_of_month()
);
assert!(
Date::from_ymd(2026, Month::May, 31)
.unwrap()
.is_end_of_month()
);
}
#[test]
fn start_of_month_examples() {
let d = Date::from_ymd(2024, Month::Feb, 29).unwrap();
assert_eq!(
d.start_of_month(),
Date::from_ymd(2024, Month::Feb, 1).unwrap()
);
assert!(d.start_of_month().is_start_of_month());
assert!(!d.is_start_of_month());
assert_eq!(Date::MIN.start_of_month(), Date::MIN);
}
#[test]
fn next_weekday_is_the_identity_on_a_match() {
let thu = Date::from_ymd(2026, Month::Jan, 1).unwrap();
assert_eq!(thu.next_weekday(Weekday::Thu).unwrap(), thu);
assert_eq!(
thu.next_weekday(Weekday::Wed).unwrap(),
Date::from_ymd(2026, Month::Jan, 7).unwrap(),
);
}
#[test]
fn nth_weekday_examples() {
let y = Year::new(2026).unwrap();
assert_eq!(
Date::nth_weekday(Ordinal::Third, Weekday::Mon, Month::Jan, y).unwrap(),
Date::from_ymd(2026, Month::Jan, 19).unwrap(),
);
assert_eq!(
Date::nth_weekday(Ordinal::Fourth, Weekday::Thu, Month::Nov, y).unwrap(),
Date::from_ymd(2026, Month::Nov, 26).unwrap(),
);
assert_eq!(
Date::nth_weekday(Ordinal::Fifth, Weekday::Sun, Month::Feb, y),
Err(TimeError::DayOutOfRange),
);
}
#[test]
fn date_range_dates_walks_both_ends() {
let jan = Date::from_ymd(2026, Month::Jan, 1).unwrap()
..Date::from_ymd(2026, Month::Feb, 1).unwrap();
assert_eq!(i64::try_from(jan.dates().count()).unwrap(), jan.days());
assert_eq!(jan.dates().next(), Some(jan.start));
assert_eq!(
jan.dates().next_back(),
Some(Date::from_ymd(2026, Month::Jan, 31).unwrap()),
);
assert_eq!((jan.start..jan.start).dates().count(), 0);
assert_eq!((jan.end..jan.start).dates().count(), 0);
}
proptest! {
#[test]
fn dates_agree_with_days(serial in 0u32..(MAX_SERIAL - 400), len in 0u32..400) {
let start = Date::from_serial(serial).unwrap();
let range = start..Date::from_serial(serial + len).unwrap();
prop_assert_eq!(i64::try_from(range.dates().count()).unwrap(), range.days());
prop_assert!(range.dates().all(|d| range.contains(&d)));
}
#[test]
fn nth_weekday_lands_where_asked(y in 1901u16..=2199, m in 1u8..=12, w in 1u8..=7, n in 1u8..=5) {
let (month, weekday) = (Month::try_from_u8(m).unwrap(), Weekday::try_from_u8(w).unwrap());
let ordinal = Ordinal::try_from_u8(n).unwrap();
if let Ok(d) = Date::nth_weekday(ordinal, weekday, month, Year::new(y).unwrap()) {
prop_assert_eq!(d.weekday(), weekday);
prop_assert_eq!(d.month(), month);
prop_assert!(d.day() > 7 * (n - 1) && d.day() <= 7 * n);
}
}
}
#[test]
fn add_period_dispatches_by_unit() {
let start = Date::from_ymd(2026, Month::Jan, 15).unwrap();
assert_eq!(
(start + crate::Period::Days(1)).unwrap(),
Date::from_ymd(2026, Month::Jan, 16).unwrap()
);
assert_eq!(
(start + crate::Period::Weeks(2)).unwrap(),
Date::from_ymd(2026, Month::Jan, 29).unwrap()
);
assert_eq!(
(start + crate::Period::Months(3)).unwrap(),
Date::from_ymd(2026, Month::Apr, 15).unwrap()
);
assert_eq!(
(start + crate::Period::Years(1)).unwrap(),
Date::from_ymd(2027, Month::Jan, 15).unwrap()
);
}
#[test]
fn sub_period_steps_backward() {
let start = Date::from_ymd(2026, Month::Jul, 15).unwrap();
assert_eq!(
(start - crate::Period::Months(6)).unwrap(),
Date::from_ymd(2026, Month::Jan, 15).unwrap(),
);
assert_eq!(
(start - (-crate::Period::Months(6))).unwrap(),
Date::from_ymd(2027, Month::Jan, 15).unwrap(),
);
}
}