1use std::fmt;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
18pub struct Date {
19 year: i32,
20 month: u8,
21 day: u8,
22}
23
24pub const MONTH_NAMES: [&str; 12] = [
25 "January",
26 "February",
27 "March",
28 "April",
29 "May",
30 "June",
31 "July",
32 "August",
33 "September",
34 "October",
35 "November",
36 "December",
37];
38
39#[cfg_attr(feature = "live", ravenrs_live::expose)]
40impl Date {
41 pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
43 (1..=12)
44 .contains(&month)
45 .then(|| days_in_month(year, month))
46 .filter(|&n| day >= 1 && day <= n)
47 .map(|_| Self { year, month, day })
48 }
49
50 pub fn year(self) -> i32 {
52 self.year
53 }
54
55 pub fn month(self) -> u8 {
57 self.month
58 }
59
60 pub fn day(self) -> u8 {
62 self.day
63 }
64
65 pub fn weekday(self) -> u8 {
67 const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
69 let y = if self.month < 3 { self.year - 1 } else { self.year };
70 let w = (y + y.div_euclid(4) - y.div_euclid(100)
71 + y.div_euclid(400)
72 + T[usize::from(self.month - 1)]
73 + i32::from(self.day))
74 .rem_euclid(7);
75 w as u8
76 }
77
78 pub fn first_of_month(self) -> Self {
80 Self { day: 1, ..self }
81 }
82
83 pub fn add_months(self, months: i32) -> Self {
85 let total = self.year * 12 + i32::from(self.month) - 1 + months;
86 let (year, month) = (total.div_euclid(12), (total.rem_euclid(12) + 1) as u8);
87 Self { year, month, day: self.day.min(days_in_month(year, month)) }
88 }
89
90 pub fn add_days(self, days: i32) -> Self {
92 Self::from_days(self.to_days() + i64::from(days))
93 }
94
95 pub fn to_days(self) -> i64 {
97 let y = i64::from(self.year) - i64::from(self.month <= 2);
99 let era = y.div_euclid(400);
100 let yoe = y - era * 400;
101 let m = i64::from(self.month);
102 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(self.day) - 1;
103 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
104 era * 146_097 + doe - 719_468
105 }
106
107 pub fn from_days(days: i64) -> Self {
109 let z = days + 719_468;
110 let era = z.div_euclid(146_097);
111 let doe = z - era * 146_097;
112 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
113 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
114 let mp = (5 * doy + 2) / 153;
115 let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
116 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
117 let year = (yoe + era * 400 + i64::from(month <= 2)) as i32;
118 Self { year, month, day }
119 }
120
121 pub fn month_title(self) -> String {
123 format!("{} {}", MONTH_NAMES[usize::from(self.month - 1)], self.year)
124 }
125
126 pub fn short(self) -> String {
128 format!("{} {}, {}", &MONTH_NAMES[usize::from(self.month - 1)][..3], self.day, self.year)
129 }
130}
131
132impl fmt::Display for Date {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
136 }
137}
138
139pub fn is_leap_year(year: i32) -> bool {
141 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
142}
143
144pub fn days_in_month(year: i32, month: u8) -> u8 {
146 match month {
147 2 if is_leap_year(year) => 29,
148 2 => 28,
149 4 | 6 | 9 | 11 => 30,
150 _ => 31,
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn validates_and_formats() {
160 assert!(Date::new(2026, 2, 29).is_none());
161 assert!(Date::new(2024, 2, 29).is_some());
162 let d = Date::new(2026, 10, 31).unwrap();
163 assert_eq!(d.to_string(), "2026-10-31");
164 assert_eq!(d.short(), "Oct 31, 2026");
165 assert_eq!(d.month_title(), "October 2026");
166 }
167
168 #[test]
169 fn weekdays_and_arithmetic() {
170 assert_eq!(Date::new(2026, 10, 31).unwrap().weekday(), 6, "a Saturday");
171 assert_eq!(Date::new(2000, 1, 1).unwrap().weekday(), 6);
172 assert_eq!(Date::new(2026, 1, 31).unwrap().add_months(1), Date::new(2026, 2, 28).unwrap());
173 assert_eq!(Date::new(2026, 12, 15).unwrap().add_months(1), Date::new(2027, 1, 15).unwrap());
174 assert_eq!(Date::new(2026, 3, 1).unwrap().add_days(-1), Date::new(2026, 2, 28).unwrap());
175 assert_eq!(Date::new(1970, 1, 1).unwrap().to_days(), 0);
176 for days in [-1_000_000_i64, -1, 0, 59, 60, 19_000, 2_000_000] {
177 assert_eq!(Date::from_days(days).to_days(), days);
178 }
179 }
180}