use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Weekday {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
impl Weekday {
pub const ALL: [Weekday; 7] = [
Weekday::Sunday,
Weekday::Monday,
Weekday::Tuesday,
Weekday::Wednesday,
Weekday::Thursday,
Weekday::Friday,
Weekday::Saturday,
];
pub fn index(self) -> usize {
Weekday::ALL
.iter()
.position(|day| *day == self)
.unwrap_or(0)
}
pub fn days_from(self, start: Weekday) -> usize {
(7 + self.index() - start.index()) % 7
}
pub fn week_from(self) -> [Weekday; 7] {
let mut week = [Weekday::Sunday; 7];
for (offset, slot) in week.iter_mut().enumerate() {
*slot = Weekday::ALL[(self.index() + offset) % 7];
}
week
}
pub fn name(self) -> &'static str {
match self {
Weekday::Sunday => "Sunday",
Weekday::Monday => "Monday",
Weekday::Tuesday => "Tuesday",
Weekday::Wednesday => "Wednesday",
Weekday::Thursday => "Thursday",
Weekday::Friday => "Friday",
Weekday::Saturday => "Saturday",
}
}
pub fn short_name(self) -> &'static str {
&self.name()[..3]
}
pub fn min_name(self) -> &'static str {
&self.name()[..2]
}
}
const MONTH_NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
pub fn month_name(month: u32) -> &'static str {
month
.checked_sub(1)
.and_then(|index| MONTH_NAMES.get(index as usize))
.copied()
.unwrap_or("")
}
pub fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
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(year) => 29,
2 => 28,
_ => 0,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
year: i32,
month: u32,
day: u32,
}
impl Date {
pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return None;
}
Some(Self { year, month, day })
}
pub fn year(self) -> i32 {
self.year
}
pub fn month(self) -> u32 {
self.month
}
pub fn day(self) -> u32 {
self.day
}
pub fn to_days(self) -> i64 {
let year = self.year as i64 - i64::from(self.month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let month = self.month as i64;
let day_of_year =
(153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
pub fn from_days(days: i64) -> Self {
let days = days + 719_468;
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32;
let month = (month_prime + if month_prime < 10 { 3 } else { -9 }) as u32;
Self {
year: (year + i64::from(month <= 2)) as i32,
month,
day,
}
}
pub fn weekday(self) -> Weekday {
let index = (self.to_days() + 4).rem_euclid(7) as usize;
Weekday::ALL[index]
}
pub fn add_days(self, days: i64) -> Self {
Self::from_days(self.to_days() + days)
}
pub fn add_months(self, months: i32) -> Self {
let total = self.year as i64 * 12 + (self.month as i64 - 1) + months as i64;
let year = total.div_euclid(12) as i32;
let month = total.rem_euclid(12) as u32 + 1;
let day = self.day.min(days_in_month(year, month));
Self { year, month, day }
}
pub fn is_same_month(self, other: Date) -> bool {
self.year == other.year && self.month == other.month
}
pub fn first_of_month(self) -> Self {
Self {
year: self.year,
month: self.month,
day: 1,
}
}
pub fn month_name(self) -> &'static str {
month_name(self.month)
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> Date {
Date::new(year, month, day).expect("a date the test wrote by hand exists")
}
#[test]
fn month_name_is_empty_out_of_range_and_never_panics() {
assert_eq!(month_name(1), "January");
assert_eq!(month_name(12), "December");
assert_eq!(month_name(0), "");
assert_eq!(month_name(13), "");
assert_eq!(month_name(u32::MAX), "");
}
#[test]
fn a_day_that_does_not_exist_is_not_a_date() {
assert!(Date::new(2026, 2, 29).is_none());
assert!(Date::new(2024, 2, 29).is_some());
assert!(Date::new(2026, 13, 1).is_none());
assert!(Date::new(2026, 0, 1).is_none());
assert!(Date::new(2026, 4, 31).is_none());
assert!(Date::new(2026, 1, 0).is_none());
}
#[test]
fn leap_years_follow_the_gregorian_rule() {
assert!(is_leap_year(2000));
assert!(!is_leap_year(1900));
assert!(is_leap_year(2024));
assert!(!is_leap_year(2026));
assert_eq!(days_in_month(2000, 2), 29);
assert_eq!(days_in_month(1900, 2), 28);
}
#[test]
fn the_epoch_is_a_thursday() {
assert_eq!(date(1970, 1, 1).to_days(), 0);
assert_eq!(date(1970, 1, 1).weekday(), Weekday::Thursday);
}
#[test]
fn known_weekdays() {
assert_eq!(date(2026, 8, 20).weekday(), Weekday::Thursday);
assert_eq!(date(2000, 1, 1).weekday(), Weekday::Saturday);
assert_eq!(date(1900, 1, 1).weekday(), Weekday::Monday);
}
#[test]
fn round_trips_every_day_from_1800_to_2200() {
let start = date(1800, 1, 1).to_days();
let end = date(2200, 12, 31).to_days();
let mut expected = date(1800, 1, 1);
for days in start..=end {
let actual = Date::from_days(days);
assert_eq!(actual, expected, "day {days} disagrees with the walk");
assert_eq!(actual.to_days(), days);
expected = expected.add_days(1);
}
}
#[test]
fn days_before_the_epoch_are_negative() {
assert_eq!(date(1969, 12, 31).to_days(), -1);
assert_eq!(Date::from_days(-1), date(1969, 12, 31));
}
#[test]
fn add_months_clamps_and_does_not_round_trip() {
assert_eq!(date(2026, 1, 31).add_months(1), date(2026, 2, 28));
assert_eq!(
date(2026, 1, 31).add_months(1).add_months(-1),
date(2026, 1, 28)
);
assert_eq!(date(2024, 1, 31).add_months(1), date(2024, 2, 29));
assert_eq!(date(2026, 12, 15).add_months(1), date(2027, 1, 15));
}
#[test]
fn add_months_across_year_zero_uses_euclidean_division() {
assert_eq!(date(1, 1, 15).add_months(-13), date(-1, 12, 15));
assert_eq!(date(-1, 12, 15).add_months(13), date(1, 1, 15));
}
#[test]
fn a_week_starts_where_it_is_told_to() {
assert_eq!(Weekday::Sunday.days_from(Weekday::Sunday), 0);
assert_eq!(Weekday::Sunday.days_from(Weekday::Monday), 6);
assert_eq!(Weekday::Thursday.days_from(Weekday::Monday), 3);
assert_eq!(
Weekday::Monday.week_from(),
[
Weekday::Monday,
Weekday::Tuesday,
Weekday::Wednesday,
Weekday::Thursday,
Weekday::Friday,
Weekday::Saturday,
Weekday::Sunday,
]
);
}
#[test]
fn names_are_the_english_defaults() {
assert_eq!(month_name(8), "August");
assert_eq!(month_name(12), "December");
assert_eq!(Weekday::Wednesday.short_name(), "Wed");
assert_eq!(Weekday::Saturday.min_name(), "Sa");
assert_eq!(Weekday::Sunday.min_name(), "Su");
assert_eq!(date(2026, 8, 20).to_string(), "2026-08-20");
}
#[test]
fn ordering_is_chronological() {
assert!(date(2026, 1, 31) < date(2026, 2, 1));
assert!(date(2025, 12, 31) < date(2026, 1, 1));
assert_eq!(date(2026, 8, 1), date(2026, 8, 20).first_of_month());
assert!(date(2026, 8, 1).is_same_month(date(2026, 8, 31)));
assert!(!date(2026, 8, 1).is_same_month(date(2027, 8, 1)));
}
}