1use std::fmt;
38use std::ops::{Add, Sub};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum DateError {
44 InvalidDate { year: i32, month: u32, day: u32 },
46 OutOfRange(String),
48}
49
50impl fmt::Display for DateError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::InvalidDate { year, month, day } => {
54 write!(f, "invalid date: {year:04}-{month:02}-{day:02}")
55 }
56 Self::OutOfRange(msg) => write!(f, "date out of range: {msg}"),
57 }
58 }
59}
60
61impl std::error::Error for DateError {}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum Weekday {
66 Monday,
67 Tuesday,
68 Wednesday,
69 Thursday,
70 Friday,
71 Saturday,
72 Sunday,
73}
74
75impl Weekday {
76 #[must_use]
78 pub fn number(self) -> u32 {
79 match self {
80 Self::Monday => 1,
81 Self::Tuesday => 2,
82 Self::Wednesday => 3,
83 Self::Thursday => 4,
84 Self::Friday => 5,
85 Self::Saturday => 6,
86 Self::Sunday => 7,
87 }
88 }
89
90 #[must_use]
93 pub fn is_weekend(self) -> bool {
94 matches!(self, Self::Saturday | Self::Sunday)
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum Unit {
101 Days,
102 Weeks,
103 Months,
104 Years,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct Period {
115 pub num: i32,
116 pub unit: Unit,
117}
118
119impl Period {
120 #[must_use]
122 pub fn days(n: i32) -> Self {
123 Self {
124 num: n,
125 unit: Unit::Days,
126 }
127 }
128
129 #[must_use]
131 pub fn weeks(n: i32) -> Self {
132 Self {
133 num: n,
134 unit: Unit::Weeks,
135 }
136 }
137
138 #[must_use]
140 pub fn months(n: i32) -> Self {
141 Self {
142 num: n,
143 unit: Unit::Months,
144 }
145 }
146
147 #[must_use]
149 pub fn years(n: i32) -> Self {
150 Self {
151 num: n,
152 unit: Unit::Years,
153 }
154 }
155}
156
157impl fmt::Display for Period {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 let suffix = match self.unit {
160 Unit::Days => 'D',
161 Unit::Weeks => 'W',
162 Unit::Months => 'M',
163 Unit::Years => 'Y',
164 };
165 write!(f, "{}{}", self.num, suffix)
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
174pub struct Date {
175 serial: i32,
176}
177
178impl Date {
179 pub fn new(year: i32, month: u32, day: u32) -> Result<Self, DateError> {
189 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
190 return Err(DateError::InvalidDate { year, month, day });
191 }
192 Ok(Self {
193 serial: days_from_civil(year, month, day),
194 })
195 }
196
197 #[must_use]
200 pub fn from_serial(serial: i32) -> Self {
201 Self { serial }
202 }
203
204 #[must_use]
209 pub fn serial(self) -> i32 {
210 self.serial
211 }
212
213 #[must_use]
215 pub fn year(self) -> i32 {
216 civil_from_days(self.serial).0
217 }
218
219 #[must_use]
221 pub fn month(self) -> u32 {
222 civil_from_days(self.serial).1
223 }
224
225 #[must_use]
227 pub fn day(self) -> u32 {
228 civil_from_days(self.serial).2
229 }
230
231 #[must_use]
233 pub fn ymd(self) -> (i32, u32, u32) {
234 civil_from_days(self.serial)
235 }
236
237 #[must_use]
239 pub fn weekday(self) -> Weekday {
240 match (self.serial + 3).rem_euclid(7) {
242 0 => Weekday::Monday,
243 1 => Weekday::Tuesday,
244 2 => Weekday::Wednesday,
245 3 => Weekday::Thursday,
246 4 => Weekday::Friday,
247 5 => Weekday::Saturday,
248 _ => Weekday::Sunday,
249 }
250 }
251
252 #[must_use]
255 pub fn is_weekend(self) -> bool {
256 self.weekday().is_weekend()
257 }
258
259 #[must_use]
261 pub fn is_leap_year(self) -> bool {
262 is_leap(self.year())
263 }
264
265 #[must_use]
267 pub fn add_days(self, n: i32) -> Self {
268 Self {
269 serial: self.serial + n,
270 }
271 }
272
273 #[must_use]
275 pub fn days_until(self, other: Self) -> i32 {
276 other.serial - self.serial
277 }
278
279 #[must_use]
281 pub fn end_of_month(self) -> Self {
282 let (y, m, _) = self.ymd();
283 Self {
284 serial: days_from_civil(y, m, days_in_month(y, m)),
285 }
286 }
287
288 #[must_use]
290 pub fn is_end_of_month(self) -> bool {
291 let (y, m, d) = self.ymd();
292 d == days_in_month(y, m)
293 }
294
295 #[must_use]
300 pub fn add_period(self, period: Period) -> Self {
301 match period.unit {
302 Unit::Days => self.add_days(period.num),
303 Unit::Weeks => self.add_days(period.num * 7),
304 Unit::Months => self.add_months(period.num),
305 Unit::Years => self.add_months(period.num * 12),
306 }
307 }
308
309 fn add_months(self, n: i32) -> Self {
311 let (y, m, d) = self.ymd();
312 let total = (i64::from(y) * 12 + i64::from(m) - 1) + i64::from(n);
314 let new_year = total.div_euclid(12) as i32;
315 let new_month = (total.rem_euclid(12) + 1) as u32;
316 let new_day = d.min(days_in_month(new_year, new_month));
317 Self {
318 serial: days_from_civil(new_year, new_month, new_day),
319 }
320 }
321}
322
323impl Add<Period> for Date {
324 type Output = Date;
325 fn add(self, period: Period) -> Date {
326 self.add_period(period)
327 }
328}
329
330impl Sub<Period> for Date {
331 type Output = Date;
332 fn sub(self, period: Period) -> Date {
333 self.add_period(Period {
334 num: -period.num,
335 unit: period.unit,
336 })
337 }
338}
339
340impl Sub<Date> for Date {
341 type Output = i32;
343 fn sub(self, rhs: Date) -> i32 {
344 self.serial - rhs.serial
345 }
346}
347
348impl fmt::Display for Date {
349 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351 let (y, m, d) = self.ymd();
352 write!(f, "{y:04}-{m:02}-{d:02}")
353 }
354}
355
356#[must_use]
358pub fn is_leap(year: i32) -> bool {
359 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
360}
361
362#[must_use]
365pub fn days_in_month(year: i32, month: u32) -> u32 {
366 match month {
367 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
368 4 | 6 | 9 | 11 => 30,
369 2 if is_leap(year) => 29,
370 2 => 28,
371 _ => 0,
372 }
373}
374
375fn days_from_civil(year: i32, month: u32, day: u32) -> i32 {
380 let y = i64::from(year) - i64::from(month <= 2);
381 let era = if y >= 0 { y } else { y - 399 } / 400;
382 let yoe = y - era * 400; let m = i64::from(month);
384 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
388}
389
390fn civil_from_days(serial: i32) -> (i32, u32, u32) {
393 let z = i64::from(serial) + 719468;
394 let era = if z >= 0 { z } else { z - 146096 } / 146097;
395 let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
398 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)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn epoch_serial_is_zero() {
411 assert_eq!(Date::new(1970, 1, 1).unwrap().serial(), 0);
412 assert_eq!(Date::new(1970, 1, 2).unwrap().serial(), 1);
413 assert_eq!(Date::new(1969, 12, 31).unwrap().serial(), -1);
414 }
415
416 #[test]
417 fn ymd_roundtrips_over_wide_range() {
418 let start = Date::new(1900, 1, 1).unwrap().serial();
421 let end = Date::new(2100, 12, 31).unwrap().serial();
422 for s in start..=end {
423 let (y, m, d) = civil_from_days(s);
424 assert_eq!(
425 days_from_civil(y, m, d),
426 s,
427 "roundtrip failed at serial {s}"
428 );
429 }
430 }
431
432 #[test]
433 fn leap_year_rules() {
434 assert!(is_leap(2000)); assert!(!is_leap(1900)); assert!(is_leap(2024));
437 assert!(!is_leap(2023));
438 assert!(Date::new(2024, 2, 29).unwrap().is_leap_year());
439 }
440
441 #[test]
442 fn days_in_month_handles_february() {
443 assert_eq!(days_in_month(2024, 2), 29);
444 assert_eq!(days_in_month(2023, 2), 28);
445 assert_eq!(days_in_month(2024, 4), 30);
446 assert_eq!(days_in_month(2024, 12), 31);
447 assert_eq!(days_in_month(2024, 13), 0);
448 }
449
450 #[test]
451 fn weekday_known_anchors() {
452 assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), Weekday::Thursday);
453 assert_eq!(Date::new(2000, 1, 1).unwrap().weekday(), Weekday::Saturday);
454 assert_eq!(Date::new(2024, 2, 29).unwrap().weekday(), Weekday::Thursday);
455 assert_eq!(Date::new(2025, 6, 5).unwrap().weekday(), Weekday::Thursday);
456 }
457
458 #[test]
459 fn weekend_detection() {
460 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()); }
464
465 #[test]
466 fn rejects_invalid_dates() {
467 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()); }
474
475 #[test]
476 fn day_arithmetic_and_difference() {
477 let a = Date::new(2024, 1, 1).unwrap();
478 let b = a.add_days(31);
479 assert_eq!(b, Date::new(2024, 2, 1).unwrap());
480 assert_eq!(a.days_until(b), 31);
481 assert_eq!(b - a, 31);
482 assert_eq!(a.add_days(-1), Date::new(2023, 12, 31).unwrap());
483 }
484
485 #[test]
486 fn add_months_clamps_end_of_month() {
487 let jan31 = Date::new(2021, 1, 31).unwrap();
488 assert_eq!(jan31 + Period::months(1), Date::new(2021, 2, 28).unwrap());
489
490 let jan31_leap = Date::new(2020, 1, 31).unwrap();
491 assert_eq!(
492 jan31_leap + Period::months(1),
493 Date::new(2020, 2, 29).unwrap()
494 );
495
496 assert_eq!(
498 Date::new(2024, 11, 30).unwrap() + Period::months(3),
499 Date::new(2025, 2, 28).unwrap()
500 );
501 }
502
503 #[test]
504 fn add_years_handles_leap_day() {
505 let leap = Date::new(2020, 2, 29).unwrap();
506 assert_eq!(leap + Period::years(1), Date::new(2021, 2, 28).unwrap());
507 assert_eq!(leap + Period::years(4), Date::new(2024, 2, 29).unwrap());
508 }
509
510 #[test]
511 fn subtract_period_moves_backward() {
512 let d = Date::new(2025, 3, 31).unwrap();
513 assert_eq!(d - Period::months(1), Date::new(2025, 2, 28).unwrap());
514 assert_eq!(d - Period::days(1), Date::new(2025, 3, 30).unwrap());
515 assert_eq!(d - Period::weeks(1), Date::new(2025, 3, 24).unwrap());
516 }
517
518 #[test]
519 fn add_period_weeks_and_days() {
520 let d = Date::new(2025, 1, 1).unwrap();
521 assert_eq!(d + Period::weeks(2), Date::new(2025, 1, 15).unwrap());
522 assert_eq!(d + Period::days(10), Date::new(2025, 1, 11).unwrap());
523 }
524
525 #[test]
526 fn end_of_month_helpers() {
527 let mid = Date::new(2024, 2, 15).unwrap();
528 assert_eq!(mid.end_of_month(), Date::new(2024, 2, 29).unwrap());
529 assert!(!mid.is_end_of_month());
530 assert!(Date::new(2024, 2, 29).unwrap().is_end_of_month());
531 assert!(Date::new(2025, 4, 30).unwrap().is_end_of_month());
532 }
533
534 #[test]
535 fn ordering_matches_calendar() {
536 let a = Date::new(2024, 1, 1).unwrap();
537 let b = Date::new(2024, 6, 1).unwrap();
538 let c = Date::new(2025, 1, 1).unwrap();
539 assert!(a < b);
540 assert!(b < c);
541 let mut v = vec![c, a, b];
542 v.sort();
543 assert_eq!(v, vec![a, b, c]);
544 }
545
546 #[test]
547 fn display_is_iso() {
548 assert_eq!(Date::new(2025, 6, 5).unwrap().to_string(), "2025-06-05");
549 assert_eq!(Date::new(999, 1, 9).unwrap().to_string(), "0999-01-09");
550 assert_eq!(Period::months(3).to_string(), "3M");
551 assert_eq!(Period::days(-5).to_string(), "-5D");
552 }
553
554 #[test]
555 fn serial_roundtrip_via_from_serial() {
556 let d = Date::new(2030, 7, 4).unwrap();
557 assert_eq!(Date::from_serial(d.serial()), d);
558 }
559}