use std::fmt;
use std::ops::{Add, Sub};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DateError {
InvalidDate { year: i32, month: u32, day: u32 },
OutOfRange(String),
}
impl fmt::Display for DateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidDate { year, month, day } => {
write!(f, "invalid date: {year:04}-{month:02}-{day:02}")
}
Self::OutOfRange(msg) => write!(f, "date out of range: {msg}"),
}
}
}
impl std::error::Error for DateError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
impl Weekday {
#[must_use]
pub fn number(self) -> u32 {
match self {
Self::Monday => 1,
Self::Tuesday => 2,
Self::Wednesday => 3,
Self::Thursday => 4,
Self::Friday => 5,
Self::Saturday => 6,
Self::Sunday => 7,
}
}
#[must_use]
pub fn is_weekend(self) -> bool {
matches!(self, Self::Saturday | Self::Sunday)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Unit {
Days,
Weeks,
Months,
Years,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Period {
pub num: i32,
pub unit: Unit,
}
impl Period {
#[must_use]
pub fn days(n: i32) -> Self {
Self {
num: n,
unit: Unit::Days,
}
}
#[must_use]
pub fn weeks(n: i32) -> Self {
Self {
num: n,
unit: Unit::Weeks,
}
}
#[must_use]
pub fn months(n: i32) -> Self {
Self {
num: n,
unit: Unit::Months,
}
}
#[must_use]
pub fn years(n: i32) -> Self {
Self {
num: n,
unit: Unit::Years,
}
}
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let suffix = match self.unit {
Unit::Days => 'D',
Unit::Weeks => 'W',
Unit::Months => 'M',
Unit::Years => 'Y',
};
write!(f, "{}{}", self.num, suffix)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
serial: i32,
}
impl Date {
pub fn new(year: i32, month: u32, day: u32) -> Result<Self, DateError> {
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return Err(DateError::InvalidDate { year, month, day });
}
Ok(Self {
serial: days_from_civil(year, month, day),
})
}
#[must_use]
pub fn from_serial(serial: i32) -> Self {
Self { serial }
}
#[must_use]
pub fn serial(self) -> i32 {
self.serial
}
#[must_use]
pub fn year(self) -> i32 {
civil_from_days(self.serial).0
}
#[must_use]
pub fn month(self) -> u32 {
civil_from_days(self.serial).1
}
#[must_use]
pub fn day(self) -> u32 {
civil_from_days(self.serial).2
}
#[must_use]
pub fn ymd(self) -> (i32, u32, u32) {
civil_from_days(self.serial)
}
#[must_use]
pub fn weekday(self) -> Weekday {
match (self.serial + 3).rem_euclid(7) {
0 => Weekday::Monday,
1 => Weekday::Tuesday,
2 => Weekday::Wednesday,
3 => Weekday::Thursday,
4 => Weekday::Friday,
5 => Weekday::Saturday,
_ => Weekday::Sunday,
}
}
#[must_use]
pub fn is_weekend(self) -> bool {
self.weekday().is_weekend()
}
#[must_use]
pub fn is_leap_year(self) -> bool {
is_leap(self.year())
}
#[must_use]
pub fn add_days(self, n: i32) -> Self {
Self {
serial: self.serial + n,
}
}
#[must_use]
pub fn days_until(self, other: Self) -> i32 {
other.serial - self.serial
}
#[must_use]
pub fn end_of_month(self) -> Self {
let (y, m, _) = self.ymd();
Self {
serial: days_from_civil(y, m, days_in_month(y, m)),
}
}
#[must_use]
pub fn is_end_of_month(self) -> bool {
let (y, m, d) = self.ymd();
d == days_in_month(y, m)
}
#[must_use]
pub fn add_period(self, period: Period) -> Self {
match period.unit {
Unit::Days => self.add_days(period.num),
Unit::Weeks => self.add_days(period.num * 7),
Unit::Months => self.add_months(period.num),
Unit::Years => self.add_months(period.num * 12),
}
}
fn add_months(self, n: i32) -> Self {
let (y, m, d) = self.ymd();
let total = (i64::from(y) * 12 + i64::from(m) - 1) + i64::from(n);
let new_year = total.div_euclid(12) as i32;
let new_month = (total.rem_euclid(12) + 1) as u32;
let new_day = d.min(days_in_month(new_year, new_month));
Self {
serial: days_from_civil(new_year, new_month, new_day),
}
}
}
impl Add<Period> for Date {
type Output = Date;
fn add(self, period: Period) -> Date {
self.add_period(period)
}
}
impl Sub<Period> for Date {
type Output = Date;
fn sub(self, period: Period) -> Date {
self.add_period(Period {
num: -period.num,
unit: period.unit,
})
}
}
impl Sub<Date> for Date {
type Output = i32;
fn sub(self, rhs: Date) -> i32 {
self.serial - rhs.serial
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (y, m, d) = self.ymd();
write!(f, "{y:04}-{m:02}-{d:02}")
}
}
#[must_use]
pub fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[must_use]
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
fn days_from_civil(year: i32, month: u32, day: u32) -> i32 {
let y = i64::from(year) - i64::from(month <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let m = i64::from(month);
let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + i64::from(day) - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; (era * 146097 + doe - 719468) as i32
}
fn civil_from_days(serial: i32) -> (i32, u32, u32) {
let z = i64::from(serial) + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; ((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_serial_is_zero() {
assert_eq!(Date::new(1970, 1, 1).unwrap().serial(), 0);
assert_eq!(Date::new(1970, 1, 2).unwrap().serial(), 1);
assert_eq!(Date::new(1969, 12, 31).unwrap().serial(), -1);
}
#[test]
fn ymd_roundtrips_over_wide_range() {
let start = Date::new(1900, 1, 1).unwrap().serial();
let end = Date::new(2100, 12, 31).unwrap().serial();
for s in start..=end {
let (y, m, d) = civil_from_days(s);
assert_eq!(
days_from_civil(y, m, d),
s,
"roundtrip failed at serial {s}"
);
}
}
#[test]
fn leap_year_rules() {
assert!(is_leap(2000)); assert!(!is_leap(1900)); assert!(is_leap(2024));
assert!(!is_leap(2023));
assert!(Date::new(2024, 2, 29).unwrap().is_leap_year());
}
#[test]
fn days_in_month_handles_february() {
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2024, 4), 30);
assert_eq!(days_in_month(2024, 12), 31);
assert_eq!(days_in_month(2024, 13), 0);
}
#[test]
fn weekday_known_anchors() {
assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), Weekday::Thursday);
assert_eq!(Date::new(2000, 1, 1).unwrap().weekday(), Weekday::Saturday);
assert_eq!(Date::new(2024, 2, 29).unwrap().weekday(), Weekday::Thursday);
assert_eq!(Date::new(2025, 6, 5).unwrap().weekday(), Weekday::Thursday);
}
#[test]
fn weekend_detection() {
assert!(Date::new(2000, 1, 1).unwrap().is_weekend()); assert!(Date::new(2000, 1, 2).unwrap().is_weekend()); assert!(!Date::new(2000, 1, 3).unwrap().is_weekend()); }
#[test]
fn rejects_invalid_dates() {
assert!(Date::new(2023, 2, 29).is_err()); assert!(Date::new(2024, 0, 1).is_err()); assert!(Date::new(2024, 13, 1).is_err()); assert!(Date::new(2024, 1, 0).is_err()); assert!(Date::new(2024, 4, 31).is_err()); assert!(Date::new(2024, 2, 29).is_ok()); }
#[test]
fn day_arithmetic_and_difference() {
let a = Date::new(2024, 1, 1).unwrap();
let b = a.add_days(31);
assert_eq!(b, Date::new(2024, 2, 1).unwrap());
assert_eq!(a.days_until(b), 31);
assert_eq!(b - a, 31);
assert_eq!(a.add_days(-1), Date::new(2023, 12, 31).unwrap());
}
#[test]
fn add_months_clamps_end_of_month() {
let jan31 = Date::new(2021, 1, 31).unwrap();
assert_eq!(jan31 + Period::months(1), Date::new(2021, 2, 28).unwrap());
let jan31_leap = Date::new(2020, 1, 31).unwrap();
assert_eq!(
jan31_leap + Period::months(1),
Date::new(2020, 2, 29).unwrap()
);
assert_eq!(
Date::new(2024, 11, 30).unwrap() + Period::months(3),
Date::new(2025, 2, 28).unwrap()
);
}
#[test]
fn add_years_handles_leap_day() {
let leap = Date::new(2020, 2, 29).unwrap();
assert_eq!(leap + Period::years(1), Date::new(2021, 2, 28).unwrap());
assert_eq!(leap + Period::years(4), Date::new(2024, 2, 29).unwrap());
}
#[test]
fn subtract_period_moves_backward() {
let d = Date::new(2025, 3, 31).unwrap();
assert_eq!(d - Period::months(1), Date::new(2025, 2, 28).unwrap());
assert_eq!(d - Period::days(1), Date::new(2025, 3, 30).unwrap());
assert_eq!(d - Period::weeks(1), Date::new(2025, 3, 24).unwrap());
}
#[test]
fn add_period_weeks_and_days() {
let d = Date::new(2025, 1, 1).unwrap();
assert_eq!(d + Period::weeks(2), Date::new(2025, 1, 15).unwrap());
assert_eq!(d + Period::days(10), Date::new(2025, 1, 11).unwrap());
}
#[test]
fn end_of_month_helpers() {
let mid = Date::new(2024, 2, 15).unwrap();
assert_eq!(mid.end_of_month(), Date::new(2024, 2, 29).unwrap());
assert!(!mid.is_end_of_month());
assert!(Date::new(2024, 2, 29).unwrap().is_end_of_month());
assert!(Date::new(2025, 4, 30).unwrap().is_end_of_month());
}
#[test]
fn ordering_matches_calendar() {
let a = Date::new(2024, 1, 1).unwrap();
let b = Date::new(2024, 6, 1).unwrap();
let c = Date::new(2025, 1, 1).unwrap();
assert!(a < b);
assert!(b < c);
let mut v = vec![c, a, b];
v.sort();
assert_eq!(v, vec![a, b, c]);
}
#[test]
fn display_is_iso() {
assert_eq!(Date::new(2025, 6, 5).unwrap().to_string(), "2025-06-05");
assert_eq!(Date::new(999, 1, 9).unwrap().to_string(), "0999-01-09");
assert_eq!(Period::months(3).to_string(), "3M");
assert_eq!(Period::days(-5).to_string(), "-5D");
}
#[test]
fn serial_roundtrip_via_from_serial() {
let d = Date::new(2030, 7, 4).unwrap();
assert_eq!(Date::from_serial(d.serial()), d);
}
}