1use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
24#[repr(transparent)]
25pub struct Date(i32);
26
27impl Date {
28 #[must_use]
32 pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Self> {
33 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
34 return None;
35 }
36 let max_day = days_in_month(year, month);
38 if day > max_day {
39 return None;
40 }
41 Some(Self(days_from_civil(year, month, day)))
42 }
43
44 #[inline]
46 #[must_use]
47 pub const fn from_days(days: i32) -> Self {
48 Self(days)
49 }
50
51 #[inline]
53 #[must_use]
54 pub const fn as_days(self) -> i32 {
55 self.0
56 }
57
58 #[must_use]
60 pub fn year(self) -> i32 {
61 civil_from_days(self.0).0
62 }
63
64 #[must_use]
66 pub fn month(self) -> u32 {
67 civil_from_days(self.0).1
68 }
69
70 #[must_use]
72 pub fn day(self) -> u32 {
73 civil_from_days(self.0).2
74 }
75
76 #[must_use]
78 pub fn to_ymd(self) -> (i32, u32, u32) {
79 civil_from_days(self.0)
80 }
81
82 #[must_use]
84 pub fn parse(s: &str) -> Option<Self> {
85 let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
87 (true, rest)
88 } else {
89 (false, s)
90 };
91
92 let parts: Vec<&str> = s.splitn(3, '-').collect();
93 if parts.len() != 3 {
94 return None;
95 }
96 let year: i32 = parts[0].parse().ok()?;
97 let month: u32 = parts[1].parse().ok()?;
98 let day: u32 = parts[2].parse().ok()?;
99 let year = if negative { -year } else { year };
100 Self::from_ymd(year, month, day)
101 }
102
103 #[must_use]
105 pub fn today() -> Self {
106 let ts = super::Timestamp::now();
107 ts.to_date()
108 }
109
110 #[must_use]
112 pub fn to_timestamp(self) -> super::Timestamp {
113 super::Timestamp::from_micros(self.0 as i64 * 86_400_000_000)
114 }
115
116 #[must_use]
121 pub fn add_duration(self, dur: &super::Duration) -> Self {
122 let (mut y, mut m, mut d) = self.to_ymd();
123
124 if dur.months() != 0 {
126 let total_months = y as i64 * 12 + (m as i64 - 1) + dur.months();
127 y = i32::try_from(total_months.div_euclid(12)).unwrap_or(if total_months < 0 {
128 i32::MIN
129 } else {
130 i32::MAX
131 });
132 #[allow(clippy::cast_possible_truncation)]
134 {
135 m = (total_months.rem_euclid(12) + 1) as u32;
136 }
137 let max_d = days_in_month(y, m);
139 if d > max_d {
140 d = max_d;
141 }
142 }
143
144 let days = days_from_civil(y, m, d) as i64 + dur.days();
146 Self(i32::try_from(days).unwrap_or(if days < 0 { i32::MIN } else { i32::MAX }))
147 }
148
149 #[must_use]
151 pub fn sub_duration(self, dur: &super::Duration) -> Self {
152 self.add_duration(&dur.neg())
153 }
154
155 #[must_use]
161 pub fn truncate(self, unit: &str) -> Option<Self> {
162 let (y, m, _d) = self.to_ymd();
163 match unit {
164 "year" => Self::from_ymd(y, 1, 1),
165 "month" => Self::from_ymd(y, m, 1),
166 "day" => Some(self),
167 _ => None,
168 }
169 }
170}
171
172impl fmt::Debug for Date {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 write!(f, "Date({})", self)
175 }
176}
177
178impl fmt::Display for Date {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 let (y, m, d) = civil_from_days(self.0);
181 if y < 0 {
182 write!(f, "-{:04}-{:02}-{:02}", -y, m, d)
183 } else {
184 write!(f, "{:04}-{:02}-{:02}", y, m, d)
185 }
186 }
187}
188
189pub(crate) fn days_from_civil(year: i32, month: u32, day: u32) -> i32 {
196 let y = if month <= 2 { year - 1 } else { year } as i64;
197 let era = y.div_euclid(400);
198 #[allow(clippy::cast_possible_truncation)]
200 let yoe = y.rem_euclid(400) as u32; let m = month;
202 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146097 + doe as i64 - 719468;
205 i32::try_from(days).unwrap_or(if days < 0 { i32::MIN } else { i32::MAX })
206}
207
208pub(crate) fn civil_from_days(days: i32) -> (i32, u32, u32) {
210 let z = days as i64 + 719468;
211 let era = z.div_euclid(146097);
212 #[allow(clippy::cast_possible_truncation)]
214 let doe = z.rem_euclid(146097) as u32; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
217 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 }; let y = if m <= 2 { y + 1 } else { y };
222 #[allow(clippy::cast_possible_truncation)]
224 let year = y as i32;
225 (year, m, d)
226}
227
228fn days_in_month(year: i32, month: u32) -> u32 {
230 match month {
231 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
232 4 | 6 | 9 | 11 => 30,
233 2 => {
234 if is_leap_year(year) {
235 29
236 } else {
237 28
238 }
239 }
240 _ => 0,
241 }
242}
243
244fn is_leap_year(year: i32) -> bool {
246 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_epoch() {
255 let d = Date::from_ymd(1970, 1, 1).unwrap();
256 assert_eq!(d.as_days(), 0);
257 assert_eq!(d.year(), 1970);
258 assert_eq!(d.month(), 1);
259 assert_eq!(d.day(), 1);
260 }
261
262 #[test]
263 fn test_known_dates() {
264 let d = Date::from_ymd(2024, 1, 1).unwrap();
266 assert_eq!(d.as_days(), 19723);
267 assert_eq!(d.to_string(), "2024-01-01");
268
269 let d = Date::from_ymd(2000, 3, 1).unwrap();
271 assert_eq!(d.year(), 2000);
272 assert_eq!(d.month(), 3);
273 assert_eq!(d.day(), 1);
274 }
275
276 #[test]
277 fn test_roundtrip() {
278 for days in [-100000, -1, 0, 1, 10000, 19723, 50000] {
279 let d = Date::from_days(days);
280 let (y, m, day) = d.to_ymd();
281 let d2 = Date::from_ymd(y, m, day).unwrap();
282 assert_eq!(d, d2, "roundtrip failed for days={days}");
283 }
284 }
285
286 #[test]
287 fn test_parse() {
288 let d = Date::parse("2024-03-15").unwrap();
289 assert_eq!(d.year(), 2024);
290 assert_eq!(d.month(), 3);
291 assert_eq!(d.day(), 15);
292
293 assert!(Date::parse("not-a-date").is_none());
294 assert!(Date::parse("2024-13-01").is_none()); assert!(Date::parse("2024-02-30").is_none()); }
297
298 #[test]
299 fn test_display() {
300 assert_eq!(
301 Date::from_ymd(2024, 1, 5).unwrap().to_string(),
302 "2024-01-05"
303 );
304 assert_eq!(
305 Date::from_ymd(100, 12, 31).unwrap().to_string(),
306 "0100-12-31"
307 );
308 }
309
310 #[test]
311 fn test_ordering() {
312 let d1 = Date::from_ymd(2024, 1, 1).unwrap();
313 let d2 = Date::from_ymd(2024, 6, 15).unwrap();
314 assert!(d1 < d2);
315 }
316
317 #[test]
318 fn test_leap_year() {
319 assert!(Date::from_ymd(2000, 2, 29).is_some()); assert!(Date::from_ymd(1900, 2, 29).is_none()); assert!(Date::from_ymd(2024, 2, 29).is_some()); assert!(Date::from_ymd(2023, 2, 29).is_none()); }
324
325 #[test]
326 fn test_to_timestamp() {
327 let d = Date::from_ymd(1970, 1, 2).unwrap();
328 assert_eq!(d.to_timestamp().as_micros(), 86_400_000_000);
329 }
330
331 #[test]
332 fn test_truncate() {
333 let d = Date::from_ymd(2024, 6, 15).unwrap();
334
335 let year = d.truncate("year").unwrap();
336 assert_eq!(year.to_string(), "2024-01-01");
337
338 let month = d.truncate("month").unwrap();
339 assert_eq!(month.to_string(), "2024-06-01");
340
341 let day = d.truncate("day").unwrap();
342 assert_eq!(day, d);
343
344 assert!(d.truncate("hour").is_none());
345 }
346
347 #[test]
348 fn test_negative_year() {
349 let d = Date::parse("-0001-01-01").unwrap();
350 assert_eq!(d.year(), -1);
351 assert_eq!(d.to_string(), "-0001-01-01");
352 }
353
354 #[test]
355 fn test_add_duration_months_clamps_day() {
356 use crate::types::Duration;
357 let d = Date::from_ymd(2025, 1, 31).unwrap();
359 let dur = Duration::from_months(1);
360 let result = d.add_duration(&dur);
361 assert_eq!(result.to_string(), "2025-02-28");
362 }
363
364 #[test]
365 fn test_add_duration_months_clamps_leap_year() {
366 use crate::types::Duration;
367 let d = Date::from_ymd(2024, 1, 31).unwrap();
369 let dur = Duration::from_months(1);
370 let result = d.add_duration(&dur);
371 assert_eq!(result.to_string(), "2024-02-29");
372 }
373
374 #[test]
375 fn test_add_duration_days() {
376 use crate::types::Duration;
377 let d = Date::from_ymd(2025, 3, 1).unwrap();
378 let dur = Duration::from_days(10);
379 let result = d.add_duration(&dur);
380 assert_eq!(result.to_string(), "2025-03-11");
381 }
382
383 #[test]
384 fn test_sub_duration() {
385 use crate::types::Duration;
386 let d = Date::from_ymd(2025, 3, 15).unwrap();
387 let dur = Duration::from_months(2);
388 let result = d.sub_duration(&dur);
389 assert_eq!(result.to_string(), "2025-01-15");
390 }
391}