use std::fmt::{Display, Formatter};
use std::str::FromStr;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)]
pub struct CalendarDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
impl CalendarDate {
pub fn new(year: i32, month: u32, day: u32) -> Self {
Self { year, month, day }
}
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 Self::is_leap_year(year) {
29
} else {
28
}
}
_ => 0,
}
}
pub fn day_of_week(self) -> u32 {
const OFFSETS: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let mut y = self.year;
if self.month < 3 {
y -= 1;
}
let sunday_based =
(y + y / 4 - y / 100 + y / 400 + OFFSETS[(self.month - 1) as usize] + self.day as i32)
.rem_euclid(7) as u32;
(sunday_based + 6) % 7
}
pub fn first_of_month(self) -> Self {
Self::new(self.year, self.month, 1)
}
pub fn prev_month(year: i32, month: u32) -> (i32, u32) {
if month == 1 {
(year - 1, 12)
} else {
(year, month - 1)
}
}
pub fn next_month(year: i32, month: u32) -> (i32, u32) {
if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
}
}
pub fn add_days(self, days: i32) -> Self {
let mut year = self.year;
let mut month = self.month;
let mut day = self.day as i32 + days;
while day < 1 {
let (py, pm) = Self::prev_month(year, month);
year = py;
month = pm;
day += Self::days_in_month(year, month) as i32;
}
while day > Self::days_in_month(year, month) as i32 {
day -= Self::days_in_month(year, month) as i32;
let (ny, nm) = Self::next_month(year, month);
year = ny;
month = nm;
}
Self::new(year, month, day as u32)
}
pub fn add_months(self, months: i32) -> Self {
let total = self.year * 12 + (self.month as i32 - 1) + months;
let year = total.div_euclid(12);
let month = (total.rem_euclid(12) + 1) as u32;
let day = self.day.min(Self::days_in_month(year, month));
Self::new(year, month, day)
}
pub fn month_name(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "",
}
}
pub fn today() -> Self {
#[cfg(target_arch = "wasm32")]
{
let now = web_sys::js_sys::Date::new_0();
Self::new(
now.get_full_year() as i32,
now.get_month() + 1,
now.get_date(),
)
}
#[cfg(not(target_arch = "wasm32"))]
{
Self::new(1970, 1, 1)
}
}
}
impl Display for CalendarDate {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarDateParseError;
impl Display for CalendarDateParseError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "expected an ISO-8601 date (YYYY-MM-DD)")
}
}
impl FromStr for CalendarDate {
type Err = CalendarDateParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.trim().splitn(3, '-');
let year = parts
.next()
.and_then(|v| v.parse::<i32>().ok())
.ok_or(CalendarDateParseError)?;
let month = parts
.next()
.and_then(|v| v.parse::<u32>().ok())
.ok_or(CalendarDateParseError)?;
let day = parts
.next()
.and_then(|v| v.parse::<u32>().ok())
.ok_or(CalendarDateParseError)?;
if !(1..=12).contains(&month) || day < 1 || day > Self::days_in_month(year, month) {
return Err(CalendarDateParseError);
}
Ok(Self::new(year, month, day))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leap_years() {
assert!(CalendarDate::is_leap_year(2024));
assert!(CalendarDate::is_leap_year(2000));
assert!(!CalendarDate::is_leap_year(1900));
assert!(!CalendarDate::is_leap_year(2026));
}
#[test]
fn month_lengths() {
assert_eq!(CalendarDate::days_in_month(2026, 6), 30);
assert_eq!(CalendarDate::days_in_month(2026, 7), 31);
assert_eq!(CalendarDate::days_in_month(2024, 2), 29);
assert_eq!(CalendarDate::days_in_month(2026, 2), 28);
}
#[test]
fn weekdays() {
assert_eq!(CalendarDate::new(2026, 6, 10).day_of_week(), 2);
assert_eq!(CalendarDate::new(2024, 2, 29).day_of_week(), 3);
assert_eq!(CalendarDate::new(2000, 1, 1).day_of_week(), 5);
}
#[test]
fn day_arithmetic_crosses_boundaries() {
let d = CalendarDate::new(2026, 1, 31).add_days(1);
assert_eq!(d, CalendarDate::new(2026, 2, 1));
let d = CalendarDate::new(2026, 1, 1).add_days(-1);
assert_eq!(d, CalendarDate::new(2025, 12, 31));
let d = CalendarDate::new(2024, 2, 28).add_days(7);
assert_eq!(d, CalendarDate::new(2024, 3, 6));
}
#[test]
fn month_arithmetic_clamps_days() {
let d = CalendarDate::new(2026, 1, 31).add_months(1);
assert_eq!(d, CalendarDate::new(2026, 2, 28));
let d = CalendarDate::new(2026, 1, 15).add_months(-2);
assert_eq!(d, CalendarDate::new(2025, 11, 15));
let d = CalendarDate::new(2026, 12, 5).add_months(1);
assert_eq!(d, CalendarDate::new(2027, 1, 5));
}
#[test]
fn iso_roundtrip() {
let d: CalendarDate = "2026-06-10".parse().unwrap();
assert_eq!(d, CalendarDate::new(2026, 6, 10));
assert_eq!(d.to_string(), "2026-06-10");
assert!("2026-13-01".parse::<CalendarDate>().is_err());
assert!("2026-02-29".parse::<CalendarDate>().is_err());
assert!("not-a-date".parse::<CalendarDate>().is_err());
}
}