1use crate::{Period, TimeError};
8use core::fmt;
9use core::ops::{Add, Range, Sub};
10
11const EPOCH_YEAR: u16 = 1901;
14const END_YEAR: u16 = 2199;
15const NUM_YEARS: u16 = END_YEAR - EPOCH_YEAR + 1;
16
17const fn is_leap(year: u16) -> bool {
18 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
19}
20
21const CUMULATIVE: [u32; NUM_YEARS as usize + 1] = {
24 let mut out = [0u32; NUM_YEARS as usize + 1];
25 let mut i: u16 = 0;
26 while i < NUM_YEARS {
27 let year = EPOCH_YEAR + i;
28 let len: u32 = if is_leap(year) { 366 } else { 365 };
29 out[i as usize + 1] = out[i as usize] + len;
30 i += 1;
31 }
32 out
33};
34
35const MAX_SERIAL: u32 = CUMULATIVE[NUM_YEARS as usize] - 1;
36
37const MONTH_OFFSETS_NONLEAP: [u32; 13] =
40 [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
41
42const MONTH_OFFSETS_LEAP: [u32; 13] = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366];
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59#[cfg_attr(feature = "serde", serde(transparent))]
60pub struct Year(u16);
61
62impl Year {
63 pub const MIN: Self = Self(EPOCH_YEAR);
65
66 pub const MAX: Self = Self(END_YEAR);
68
69 pub const fn new(year: u16) -> Result<Self, TimeError> {
71 if year < EPOCH_YEAR || year > END_YEAR {
72 Err(TimeError::YearOutOfRange)
73 } else {
74 Ok(Self(year))
75 }
76 }
77
78 #[must_use]
93 #[allow(clippy::panic)]
94 pub const fn literal(year: u16) -> Self {
95 match Self::new(year) {
96 Ok(y) => y,
97 Err(_) => panic!("Year::literal: argument must be in 1901..=2199"),
99 }
100 }
101
102 #[must_use]
110 pub const fn get(self) -> u16 {
111 self.0
112 }
113
114 #[must_use]
125 pub const fn is_leap(self) -> bool {
126 is_leap(self.0)
127 }
128
129 #[must_use]
138 pub const fn length(self) -> u16 {
139 if self.is_leap() { 366 } else { 365 }
140 }
141}
142
143impl fmt::Display for Year {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 write!(f, "{}", self.0)
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154#[repr(u8)]
155pub enum Month {
156 Jan = 1,
158 Feb = 2,
160 Mar = 3,
162 Apr = 4,
164 May = 5,
166 Jun = 6,
168 Jul = 7,
170 Aug = 8,
172 Sep = 9,
174 Oct = 10,
176 Nov = 11,
178 Dec = 12,
180}
181
182impl Month {
183 #[must_use]
190 pub const fn get(self) -> u8 {
191 self as u8
192 }
193
194 pub const fn try_from_u8(month: u8) -> Result<Self, TimeError> {
205 match month {
206 1 => Ok(Self::Jan),
207 2 => Ok(Self::Feb),
208 3 => Ok(Self::Mar),
209 4 => Ok(Self::Apr),
210 5 => Ok(Self::May),
211 6 => Ok(Self::Jun),
212 7 => Ok(Self::Jul),
213 8 => Ok(Self::Aug),
214 9 => Ok(Self::Sep),
215 10 => Ok(Self::Oct),
216 11 => Ok(Self::Nov),
217 12 => Ok(Self::Dec),
218 _ => Err(TimeError::MonthOutOfRange),
219 }
220 }
221
222 #[must_use]
233 pub const fn length(self, year: Year) -> u8 {
234 match self {
235 Self::Jan | Self::Mar | Self::May | Self::Jul | Self::Aug | Self::Oct | Self::Dec => 31,
236 Self::Apr | Self::Jun | Self::Sep | Self::Nov => 30,
237 Self::Feb => {
238 if year.is_leap() {
239 29
240 } else {
241 28
242 }
243 }
244 }
245 }
246}
247
248impl fmt::Display for Month {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 let name = match self {
251 Self::Jan => "Jan",
252 Self::Feb => "Feb",
253 Self::Mar => "Mar",
254 Self::Apr => "Apr",
255 Self::May => "May",
256 Self::Jun => "Jun",
257 Self::Jul => "Jul",
258 Self::Aug => "Aug",
259 Self::Sep => "Sep",
260 Self::Oct => "Oct",
261 Self::Nov => "Nov",
262 Self::Dec => "Dec",
263 };
264 f.write_str(name)
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
272#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
273#[repr(u8)]
274pub enum Weekday {
275 Mon = 1,
277 Tue = 2,
279 Wed = 3,
281 Thu = 4,
283 Fri = 5,
285 Sat = 6,
287 Sun = 7,
289}
290
291impl Weekday {
292 #[must_use]
300 pub const fn get(self) -> u8 {
301 self as u8
302 }
303
304 pub const fn try_from_u8(weekday: u8) -> Result<Self, TimeError> {
316 match weekday {
317 1 => Ok(Self::Mon),
318 2 => Ok(Self::Tue),
319 3 => Ok(Self::Wed),
320 4 => Ok(Self::Thu),
321 5 => Ok(Self::Fri),
322 6 => Ok(Self::Sat),
323 7 => Ok(Self::Sun),
324 _ => Err(TimeError::WeekdayOutOfRange),
325 }
326 }
327}
328
329impl fmt::Display for Weekday {
330 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331 let name = match self {
332 Self::Mon => "Mon",
333 Self::Tue => "Tue",
334 Self::Wed => "Wed",
335 Self::Thu => "Thu",
336 Self::Fri => "Fri",
337 Self::Sat => "Sat",
338 Self::Sun => "Sun",
339 };
340 f.write_str(name)
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
349#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
350#[repr(u8)]
351pub enum Ordinal {
352 First = 1,
354 Second = 2,
356 Third = 3,
358 Fourth = 4,
360 Fifth = 5,
362}
363
364impl Ordinal {
365 #[must_use]
372 pub const fn get(self) -> u8 {
373 self as u8
374 }
375
376 pub const fn try_from_u8(n: u8) -> Result<Self, TimeError> {
387 match n {
388 1 => Ok(Self::First),
389 2 => Ok(Self::Second),
390 3 => Ok(Self::Third),
391 4 => Ok(Self::Fourth),
392 5 => Ok(Self::Fifth),
393 _ => Err(TimeError::OrdinalOutOfRange),
394 }
395 }
396}
397
398impl fmt::Display for Ordinal {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 let name = match self {
401 Self::First => "First",
402 Self::Second => "Second",
403 Self::Third => "Third",
404 Self::Fourth => "Fourth",
405 Self::Fifth => "Fifth",
406 };
407 f.write_str(name)
408 }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
428#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
429#[cfg_attr(feature = "serde", serde(transparent))]
430pub struct Date(u32);
431
432impl Date {
433 pub const MIN: Self = Self(0);
435
436 pub const MAX: Self = Self(MAX_SERIAL);
438
439 pub const fn from_ymd(year: u16, month: Month, day: u8) -> Result<Self, TimeError> {
443 let y = match Year::new(year) {
444 Ok(y) => y,
445 Err(e) => return Err(e),
446 };
447 let len = month.length(y);
448 if day == 0 || day > len {
449 return Err(TimeError::DayOutOfRange);
450 }
451 let year_idx = (year - EPOCH_YEAR) as usize;
452 let year_start = CUMULATIVE[year_idx];
453 let month_offset = if y.is_leap() {
454 MONTH_OFFSETS_LEAP[(month.get() - 1) as usize]
455 } else {
456 MONTH_OFFSETS_NONLEAP[(month.get() - 1) as usize]
457 };
458 Ok(Self(year_start + month_offset + day as u32 - 1))
459 }
460
461 #[must_use]
476 #[allow(clippy::panic)]
477 pub const fn literal(year: u16, month: Month, day: u8) -> Self {
478 match Self::from_ymd(year, month, day) {
479 Ok(d) => d,
480 Err(_) => panic!("Date::literal: invalid year/month/day"),
482 }
483 }
484
485 pub const fn from_serial(serial: u32) -> Result<Self, TimeError> {
494 if serial > MAX_SERIAL {
495 Err(TimeError::DateOutOfRange)
496 } else {
497 Ok(Self(serial))
498 }
499 }
500
501 #[must_use]
503 pub const fn serial(self) -> u32 {
504 self.0
505 }
506
507 #[must_use]
509 pub const fn year(self) -> Year {
510 let serial = self.0;
512 let mut lo: u16 = 0;
513 let mut hi: u16 = NUM_YEARS;
514 while hi - lo > 1 {
515 let mid = lo + (hi - lo) / 2;
516 if CUMULATIVE[mid as usize] <= serial {
517 lo = mid;
518 } else {
519 hi = mid;
520 }
521 }
522 Year(EPOCH_YEAR + lo)
523 }
524
525 #[must_use]
535 pub const fn to_ymd(self) -> (Year, Month, u8) {
536 let y = self.year();
537 let year_idx = (y.0 - EPOCH_YEAR) as usize;
538 let doy = self.0 - CUMULATIVE[year_idx];
539 let offsets = if y.is_leap() {
540 &MONTH_OFFSETS_LEAP
541 } else {
542 &MONTH_OFFSETS_NONLEAP
543 };
544 let mut m: usize = 0;
545 while m + 1 < 13 && offsets[m + 1] <= doy {
546 m += 1;
547 }
548 let month = match m {
549 0 => Month::Jan,
550 1 => Month::Feb,
551 2 => Month::Mar,
552 3 => Month::Apr,
553 4 => Month::May,
554 5 => Month::Jun,
555 6 => Month::Jul,
556 7 => Month::Aug,
557 8 => Month::Sep,
558 9 => Month::Oct,
559 10 => Month::Nov,
560 _ => Month::Dec,
561 };
562 #[allow(clippy::cast_possible_truncation)]
564 let day_of_month = (doy - offsets[m] + 1) as u8;
565 (y, month, day_of_month)
566 }
567
568 #[must_use]
576 pub const fn month(self) -> Month {
577 let (_, m, _) = self.to_ymd();
578 m
579 }
580
581 #[must_use]
589 pub const fn day(self) -> u8 {
590 let (_, _, d) = self.to_ymd();
591 d
592 }
593
594 #[must_use]
604 pub const fn day_of_year(self) -> u16 {
605 let y = self.year();
606 let year_idx = (y.0 - EPOCH_YEAR) as usize;
607 #[allow(clippy::cast_possible_truncation)]
609 let doy = (self.0 - CUMULATIVE[year_idx] + 1) as u16;
610 doy
611 }
612
613 #[must_use]
624 pub const fn weekday(self) -> Weekday {
625 match (self.0 + 1) % 7 {
627 0 => Weekday::Mon,
628 1 => Weekday::Tue,
629 2 => Weekday::Wed,
630 3 => Weekday::Thu,
631 4 => Weekday::Fri,
632 5 => Weekday::Sat,
633 _ => Weekday::Sun,
634 }
635 }
636
637 pub const fn add_days(self, n: i32) -> Result<Self, TimeError> {
648 let target = self.0 as i64 + n as i64;
650 if target < 0 || target > MAX_SERIAL as i64 {
651 return Err(TimeError::DateOutOfRange);
652 }
653 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
655 let serial = target as u32;
656 Ok(Self(serial))
657 }
658
659 #[must_use]
671 pub const fn days_since(self, other: Self) -> i32 {
672 let diff = self.0 as i64 - other.0 as i64;
674 #[allow(clippy::cast_possible_truncation)]
676 let diff_i32 = diff as i32;
677 diff_i32
678 }
679
680 pub const fn add_months(self, n: i32) -> Result<Self, TimeError> {
695 let (year, month, day) = self.to_ymd();
696 let total_months = year.get() as i32 * 12 + (month.get() as i32 - 1);
698 let Some(new_total) = total_months.checked_add(n) else {
699 return Err(TimeError::DateOutOfRange);
700 };
701 let target_year_i32 = new_total.div_euclid(12);
703 let new_month_idx = new_total.rem_euclid(12);
704 if target_year_i32 < Year::MIN.get() as i32 || target_year_i32 > Year::MAX.get() as i32 {
705 return Err(TimeError::DateOutOfRange);
706 }
707 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
709 let new_year_u16 = target_year_i32 as u16;
710 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
712 let new_month = match Month::try_from_u8((new_month_idx as u8) + 1) {
713 Ok(found) => found,
714 Err(err) => return Err(err),
715 };
716 let target_year = match Year::new(new_year_u16) {
717 Ok(found) => found,
718 Err(err) => return Err(err),
719 };
720 let clamped_day = {
721 let len = new_month.length(target_year);
722 if day > len { len } else { day }
723 };
724 Self::from_ymd(new_year_u16, new_month, clamped_day)
725 }
726
727 pub const fn add_years(self, n: i32) -> Result<Self, TimeError> {
740 let Some(months) = n.checked_mul(12) else {
741 return Err(TimeError::DateOutOfRange);
742 };
743 self.add_months(months)
744 }
745
746 pub fn advance(self, period: Period, end_of_month: bool) -> Result<Self, TimeError> {
764 let stepped = (self + period)?;
765 Ok(
766 if end_of_month
767 && self.is_end_of_month()
768 && matches!(period, Period::Months(_) | Period::Years(_))
769 {
770 stepped.end_of_month()
771 } else {
772 stepped
773 },
774 )
775 }
776
777 #[must_use]
786 pub const fn start_of_month(self) -> Self {
787 Self(self.0 - (self.day() as u32 - 1))
788 }
789
790 #[must_use]
792 pub const fn is_start_of_month(self) -> bool {
793 self.day() == 1
794 }
795
796 pub fn next_weekday(self, weekday: Weekday) -> Result<Self, TimeError> {
806 let delta = (i32::from(weekday.get()) - i32::from(self.weekday().get())).rem_euclid(7);
807 self.add_days(delta)
808 }
809
810 pub fn nth_weekday(
828 n: Ordinal,
829 weekday: Weekday,
830 month: Month,
831 year: Year,
832 ) -> Result<Self, TimeError> {
833 let first = Self::from_ymd(year.get(), month, 1)?.next_weekday(weekday)?;
834 let nth = first.add_days(7 * (i32::from(n.get()) - 1))?;
835 if nth.month() == month {
836 Ok(nth)
837 } else {
838 Err(TimeError::DayOutOfRange)
839 }
840 }
841
842 #[must_use]
851 pub const fn end_of_month(self) -> Self {
852 let (year, month, _) = self.to_ymd();
853 let last = month.length(year);
854 let month_start = self.0 - (self.day() as u32 - 1);
856 Self(month_start + last as u32 - 1)
857 }
858
859 #[must_use]
869 pub const fn is_end_of_month(self) -> bool {
870 let (year, month, day) = self.to_ymd();
871 day == month.length(year)
872 }
873}
874
875pub trait DateRange: Sized {
890 fn days(&self) -> i64;
892
893 fn intersect(&self, other: &Self) -> Option<Self>;
896
897 fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<Self>;
900}
901
902impl DateRange for Range<Date> {
903 fn days(&self) -> i64 {
904 i64::from(self.end.days_since(self.start))
905 }
906
907 fn intersect(&self, other: &Self) -> Option<Self> {
908 let both = self.start.max(other.start)..self.end.min(other.end);
909 (both.start < both.end).then_some(both)
910 }
911
912 fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<> {
913 (self.start.serial()..self.end.serial()).filter_map(|s| Date::from_serial(s).ok())
915 }
916}
917
918impl fmt::Display for Date {
919 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
920 let (y, m, d) = self.to_ymd();
922 write!(f, "{:04}-{:02}-{:02}", y.get(), m.get(), d)
923 }
924}
925
926impl core::str::FromStr for Date {
927 type Err = TimeError;
928
929 fn from_str(s: &str) -> Result<Self, Self::Err> {
946 const fn digit(b: u8) -> Result<u16, TimeError> {
947 if b.is_ascii_digit() {
948 Ok((b - b'0') as u16)
949 } else {
950 Err(TimeError::InvalidDateString)
951 }
952 }
953 let [y3, y2, y1, y0, h1, m1, m0, h2, d1, d0] = s.as_bytes() else {
954 return Err(TimeError::InvalidDateString);
955 };
956 if *h1 != b'-' || *h2 != b'-' {
957 return Err(TimeError::InvalidDateString);
958 }
959 let year = 1000 * digit(*y3)? + 100 * digit(*y2)? + 10 * digit(*y1)? + digit(*y0)?;
960 let month_num = 10 * digit(*m1)? + digit(*m0)?;
961 let day = 10 * digit(*d1)? + digit(*d0)?;
962 #[allow(clippy::cast_possible_truncation)]
964 let month = Month::try_from_u8(month_num as u8)?;
965 #[allow(clippy::cast_possible_truncation)]
966 let day = day as u8;
967 Self::from_ymd(year, month, day)
968 }
969}
970
971impl Add<Period> for Date {
986 type Output = Result<Self, TimeError>;
987
988 fn add(self, period: Period) -> Self::Output {
989 match period {
990 Period::Days(n) => self.add_days(n),
991 Period::Weeks(n) => match n.checked_mul(7) {
992 Some(days) => self.add_days(days),
993 None => Err(TimeError::DateOutOfRange),
994 },
995 Period::Months(n) => self.add_months(n),
996 Period::Years(n) => self.add_years(n),
997 }
998 }
999}
1000
1001impl Sub<Period> for Date {
1011 type Output = Result<Self, TimeError>;
1012
1013 fn sub(self, period: Period) -> Self::Output {
1014 #[allow(clippy::suspicious_arithmetic_impl)]
1016 match period.checked_neg() {
1017 Some(neg) => self + neg,
1018 None => Err(TimeError::DateOutOfRange),
1019 }
1020 }
1021}
1022
1023#[cfg(test)]
1026#[allow(clippy::unwrap_used, clippy::expect_used)]
1027mod tests {
1028 extern crate alloc;
1029
1030 use super::*;
1031 use proptest::prelude::*;
1032
1033 #[test]
1034 fn epoch_is_1901_01_01_tuesday() {
1035 let d = Date::MIN;
1036 assert_eq!(d.serial(), 0);
1037 assert_eq!(d.year().get(), 1901);
1038 assert_eq!(d.month(), Month::Jan);
1039 assert_eq!(d.day(), 1);
1040 assert_eq!(d.weekday(), Weekday::Tue);
1041 }
1042
1043 #[test]
1044 fn max_is_2199_12_31() {
1045 let d = Date::MAX;
1046 assert_eq!(d.year().get(), 2199);
1047 assert_eq!(d.month(), Month::Dec);
1048 assert_eq!(d.day(), 31);
1049 }
1050
1051 #[test]
1052 fn from_ymd_rejects_out_of_range_year() {
1053 assert_eq!(
1054 Date::from_ymd(1900, Month::Jan, 1),
1055 Err(TimeError::YearOutOfRange)
1056 );
1057 assert_eq!(
1058 Date::from_ymd(2200, Month::Jan, 1),
1059 Err(TimeError::YearOutOfRange)
1060 );
1061 }
1062
1063 #[test]
1064 fn from_ymd_rejects_day_zero_and_overflow() {
1065 assert_eq!(
1066 Date::from_ymd(2026, Month::Jan, 0),
1067 Err(TimeError::DayOutOfRange)
1068 );
1069 assert_eq!(
1070 Date::from_ymd(2026, Month::Jan, 32),
1071 Err(TimeError::DayOutOfRange)
1072 );
1073 assert_eq!(
1074 Date::from_ymd(2026, Month::Apr, 31),
1075 Err(TimeError::DayOutOfRange)
1076 );
1077 }
1078
1079 #[test]
1080 fn february_leap_year_behavior() {
1081 assert!(Date::from_ymd(2000, Month::Feb, 29).is_ok());
1083 assert_eq!(
1085 Date::from_ymd(2100, Month::Feb, 29),
1086 Err(TimeError::DayOutOfRange)
1087 );
1088 assert!(Date::from_ymd(2024, Month::Feb, 29).is_ok());
1090 assert_eq!(
1092 Date::from_ymd(2026, Month::Feb, 29),
1093 Err(TimeError::DayOutOfRange)
1094 );
1095 }
1096
1097 #[test]
1098 fn known_weekdays() {
1099 assert_eq!(
1101 Date::from_ymd(1901, Month::Jan, 1).unwrap().weekday(),
1102 Weekday::Tue,
1103 );
1104 assert_eq!(
1105 Date::from_ymd(2000, Month::Jan, 1).unwrap().weekday(),
1106 Weekday::Sat,
1107 );
1108 assert_eq!(
1109 Date::from_ymd(2026, Month::Jul, 4).unwrap().weekday(),
1110 Weekday::Sat,
1111 );
1112 assert_eq!(
1113 Date::from_ymd(2021, Month::Jun, 19).unwrap().weekday(),
1114 Weekday::Sat,
1115 );
1116 assert_eq!(
1117 Date::from_ymd(2199, Month::Dec, 31).unwrap().weekday(),
1118 Weekday::Tue,
1119 );
1120 }
1121
1122 #[test]
1123 fn day_of_year_boundaries() {
1124 assert_eq!(
1125 Date::from_ymd(2024, Month::Jan, 1).unwrap().day_of_year(),
1126 1,
1127 );
1128 assert_eq!(
1129 Date::from_ymd(2024, Month::Dec, 31).unwrap().day_of_year(),
1130 366, );
1132 assert_eq!(
1133 Date::from_ymd(2025, Month::Dec, 31).unwrap().day_of_year(),
1134 365,
1135 );
1136 }
1137
1138 #[test]
1139 fn add_days_at_boundaries() {
1140 assert_eq!(Date::MIN.add_days(-1), Err(TimeError::DateOutOfRange));
1141 assert_eq!(Date::MAX.add_days(1), Err(TimeError::DateOutOfRange));
1142 let d = Date::from_ymd(2026, Month::Feb, 28).unwrap();
1143 assert_eq!(
1144 d.add_days(1).unwrap(),
1145 Date::from_ymd(2026, Month::Mar, 1).unwrap()
1146 );
1147 let leap = Date::from_ymd(2024, Month::Feb, 28).unwrap();
1148 assert_eq!(
1149 leap.add_days(1).unwrap(),
1150 Date::from_ymd(2024, Month::Feb, 29).unwrap()
1151 );
1152 }
1153
1154 #[test]
1155 fn display_is_iso_8601() {
1156 let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1157 assert_eq!(alloc::format!("{d}"), "2026-07-04");
1158 }
1159
1160 #[test]
1161 fn weekday_iso_numbering() {
1162 assert_eq!(Weekday::Mon.get(), 1);
1163 assert_eq!(Weekday::Sun.get(), 7);
1164 assert_eq!(Weekday::try_from_u8(1).unwrap(), Weekday::Mon);
1165 assert_eq!(Weekday::try_from_u8(7).unwrap(), Weekday::Sun);
1166 assert_eq!(Weekday::try_from_u8(0), Err(TimeError::WeekdayOutOfRange));
1167 assert_eq!(Weekday::try_from_u8(8), Err(TimeError::WeekdayOutOfRange));
1168 }
1169
1170 #[test]
1171 fn ordinal_display() {
1172 assert_eq!(alloc::format!("{}", Ordinal::First), "First");
1173 assert_eq!(alloc::format!("{}", Ordinal::Fifth), "Fifth");
1174 }
1175
1176 #[test]
1177 fn from_str_parses_display_output() {
1178 for (y, m, d) in [
1179 (1901u16, Month::Jan, 1u8),
1180 (2026, Month::Jul, 4),
1181 (2024, Month::Feb, 29),
1182 (2199, Month::Dec, 31),
1183 ] {
1184 let date = Date::from_ymd(y, m, d).unwrap();
1185 let parsed: Date = alloc::format!("{date}").parse().unwrap();
1186 assert_eq!(parsed, date);
1187 }
1188 }
1189
1190 #[test]
1191 fn from_str_rejects_malformed_strings() {
1192 for bad in [
1193 "",
1194 "2026",
1195 "2026-07",
1196 "2026-7-4", "26-07-04", "2026/07/04", "2026-07-04T", " 2026-07-04", "2026-07-04 ", "+026-07-04", "2026-0a-04", "٢٠٢٦-07-04", ] {
1206 assert_eq!(
1207 bad.parse::<Date>(),
1208 Err(TimeError::InvalidDateString),
1209 "{bad:?} should be rejected as malformed",
1210 );
1211 }
1212 }
1213
1214 #[test]
1215 fn from_str_surfaces_range_errors_for_well_formed_input() {
1216 assert_eq!("1900-12-31".parse::<Date>(), Err(TimeError::YearOutOfRange));
1217 assert_eq!("2200-01-01".parse::<Date>(), Err(TimeError::YearOutOfRange));
1218 assert_eq!(
1219 "2026-13-01".parse::<Date>(),
1220 Err(TimeError::MonthOutOfRange)
1221 );
1222 assert_eq!(
1223 "2026-00-01".parse::<Date>(),
1224 Err(TimeError::MonthOutOfRange)
1225 );
1226 assert_eq!("2026-02-30".parse::<Date>(), Err(TimeError::DayOutOfRange));
1227 assert_eq!("2026-01-00".parse::<Date>(), Err(TimeError::DayOutOfRange));
1228 }
1229
1230 #[test]
1231 fn to_ymd_matches_individual_accessors() {
1232 let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1233 let (y, m, dom) = d.to_ymd();
1234 assert_eq!(y, d.year());
1235 assert_eq!(m, d.month());
1236 assert_eq!(dom, d.day());
1237 }
1238
1239 fn any_ymd() -> impl Strategy<Value = (u16, Month, u8)> {
1243 (EPOCH_YEAR..=END_YEAR, 1u8..=12u8).prop_flat_map(|(y, m)| {
1244 let month = Month::try_from_u8(m).expect("1..=12");
1245 let year = Year::new(y).expect("in range");
1246 let max_day = month.length(year);
1247 (Just(y), Just(month), 1u8..=max_day)
1248 })
1249 }
1250
1251 proptest! {
1252 #[test]
1253 fn from_ymd_round_trips(
1254 (year, month, day) in any_ymd()
1255 ) {
1256 let d = Date::from_ymd(year, month, day).expect("valid ymd");
1257 prop_assert_eq!(d.year().get(), year);
1258 prop_assert_eq!(d.month(), month);
1259 prop_assert_eq!(d.day(), day);
1260 }
1261
1262 #[test]
1263 fn serial_round_trips(
1264 serial in 0u32..=MAX_SERIAL,
1265 ) {
1266 let d = Date::from_serial(serial).expect("in range");
1267 prop_assert_eq!(d.serial(), serial);
1268 let ymd = Date::from_ymd(d.year().get(), d.month(), d.day()).expect("valid");
1269 prop_assert_eq!(ymd.serial(), serial);
1270 }
1271
1272 #[test]
1273 fn weekday_advances_by_one_per_day(
1274 serial in 0u32..MAX_SERIAL,
1275 ) {
1276 let today = Date::from_serial(serial).expect("in range");
1277 let tomorrow = today.add_days(1).expect("in range");
1278 let expected = match today.weekday() {
1279 Weekday::Mon => Weekday::Tue,
1280 Weekday::Tue => Weekday::Wed,
1281 Weekday::Wed => Weekday::Thu,
1282 Weekday::Thu => Weekday::Fri,
1283 Weekday::Fri => Weekday::Sat,
1284 Weekday::Sat => Weekday::Sun,
1285 Weekday::Sun => Weekday::Mon,
1286 };
1287 prop_assert_eq!(tomorrow.weekday(), expected);
1288 }
1289
1290 #[test]
1291 fn add_days_is_inverse_of_days_since(
1292 a_serial in 0u32..=MAX_SERIAL,
1293 b_serial in 0u32..=MAX_SERIAL,
1294 ) {
1295 let a = Date::from_serial(a_serial).unwrap();
1296 let b = Date::from_serial(b_serial).unwrap();
1297 let diff = b.days_since(a);
1298 prop_assert_eq!(a.add_days(diff).unwrap(), b);
1299 }
1300
1301 #[test]
1302 fn day_of_year_is_consistent(
1303 (year, month, day) in any_ymd()
1304 ) {
1305 let d = Date::from_ymd(year, month, day).expect("valid");
1306 let year_start = Date::from_ymd(year, Month::Jan, 1).expect("valid");
1307 prop_assert_eq!(
1308 u16::try_from(d.days_since(year_start) + 1).unwrap(),
1309 d.day_of_year(),
1310 );
1311 }
1312
1313 #[test]
1314 fn month_try_from_u8_round_trips(m in 1u8..=12u8) {
1315 let parsed = Month::try_from_u8(m).expect("1..=12");
1316 prop_assert_eq!(parsed.get(), m);
1317 }
1318
1319 #[test]
1320 fn weekday_try_from_u8_round_trips(n in 1u8..=7u8) {
1321 let parsed = Weekday::try_from_u8(n).expect("1..=7");
1322 prop_assert_eq!(parsed.get(), n);
1323 }
1324
1325 #[test]
1326 fn ordinal_try_from_u8_round_trips(n in 1u8..=5u8) {
1327 let parsed = Ordinal::try_from_u8(n).expect("1..=5");
1328 prop_assert_eq!(parsed.get(), n);
1329 }
1330
1331 #[test]
1332 fn add_days_accepts_iff_result_in_range(
1333 serial in 0u32..=MAX_SERIAL,
1334 n in i32::MIN..=i32::MAX,
1335 ) {
1336 let d = Date::from_serial(serial).expect("in range");
1337 let result = d.add_days(n);
1338 let target = i64::from(serial) + i64::from(n);
1339 let in_range = (0..=i64::from(MAX_SERIAL)).contains(&target);
1340 prop_assert_eq!(result.is_ok(), in_range);
1341 if in_range {
1342 prop_assert_eq!(
1343 result.expect("in-range").serial(),
1344 u32::try_from(target).expect("fits in u32"),
1345 );
1346 } else {
1347 prop_assert_eq!(result, Err(TimeError::DateOutOfRange));
1348 }
1349 }
1350
1351 #[test]
1352 fn to_ymd_round_trips(serial in 0u32..=MAX_SERIAL) {
1353 let d = Date::from_serial(serial).expect("in range");
1354 let (y, m, dom) = d.to_ymd();
1355 let rebuilt = Date::from_ymd(y.get(), m, dom).expect("valid");
1356 prop_assert_eq!(rebuilt.serial(), serial);
1357 }
1358
1359 #[test]
1361 fn display_and_from_str_round_trip(serial in 0u32..=MAX_SERIAL) {
1362 let d = Date::from_serial(serial).expect("in range");
1363 let parsed: Date = alloc::format!("{d}").parse().expect("Display output is valid");
1364 prop_assert_eq!(parsed, d);
1365 }
1366
1367 #[test]
1369 fn add_months_round_trip_on_safe_days(
1370 year in 1910u16..=2190,
1371 month in 1u8..=12,
1372 day in 1u8..=28,
1373 n in -500i32..=500,
1374 ) {
1375 let parsed_month = Month::try_from_u8(month).expect("1..=12");
1376 let start = Date::from_ymd(year, parsed_month, day).expect("valid");
1377 if let Ok(stepped) = start.add_months(n)
1378 && let Ok(restored) = stepped.add_months(-n)
1379 {
1380 prop_assert_eq!(restored, start);
1381 }
1382 }
1383
1384 #[test]
1387 fn add_months_decomposes_into_years_plus_months(
1388 year in 1921u16..=2179,
1389 month in 1u8..=12,
1390 day in 1u8..=28,
1391 whole_years in -20i32..=20,
1392 extra_months in -11i32..=11,
1393 ) {
1394 let parsed_month = Month::try_from_u8(month).expect("1..=12");
1395 let start = Date::from_ymd(year, parsed_month, day).expect("valid");
1396 let direct = start.add_months(whole_years * 12 + extra_months);
1397 let stepped = start
1398 .add_years(whole_years)
1399 .and_then(|x| x.add_months(extra_months));
1400 prop_assert_eq!(direct, stepped);
1401 }
1402
1403 #[test]
1405 fn add_months_never_exceeds_target_month_length(
1406 serial in 0u32..=MAX_SERIAL,
1407 n in -200i32..=200,
1408 ) {
1409 let d = Date::from_serial(serial).expect("in range");
1410 if let Ok(out) = d.add_months(n) {
1411 let (y, m, dom) = out.to_ymd();
1412 prop_assert!(dom <= m.length(y));
1413 prop_assert!(dom >= 1);
1414 }
1415 }
1416
1417 #[test]
1419 fn end_of_month_is_idempotent(serial in 0u32..=MAX_SERIAL) {
1420 let d = Date::from_serial(serial).expect("in range");
1421 prop_assert_eq!(d.end_of_month(), d.end_of_month().end_of_month());
1422 prop_assert!(d.end_of_month().is_end_of_month());
1423 }
1424
1425 #[test]
1427 fn add_period_days_matches_add_days(
1428 serial in 0u32..=MAX_SERIAL,
1429 n in -10_000i32..=10_000,
1430 ) {
1431 let start = Date::from_serial(serial).expect("in range");
1432 prop_assert_eq!(start + crate::Period::Days(n), start.add_days(n));
1433 }
1434
1435 #[test]
1438 fn add_period_weeks_equals_add_days_times_seven(
1439 serial in 0u32..=MAX_SERIAL,
1440 n in (i32::MIN / 7)..=(i32::MAX / 7),
1441 ) {
1442 let start = Date::from_serial(serial).expect("in range");
1443 prop_assert_eq!(start + crate::Period::Weeks(n), start.add_days(n * 7));
1444 }
1445
1446 #[test]
1448 fn add_period_months_matches_add_months(
1449 serial in 0u32..=MAX_SERIAL,
1450 n in -200i32..=200,
1451 ) {
1452 let start = Date::from_serial(serial).expect("in range");
1453 prop_assert_eq!(start + crate::Period::Months(n), start.add_months(n));
1454 }
1455
1456 #[test]
1458 fn add_period_years_matches_add_years(
1459 serial in 0u32..=MAX_SERIAL,
1460 n in -100i32..=100,
1461 ) {
1462 let start = Date::from_serial(serial).expect("in range");
1463 prop_assert_eq!(start + crate::Period::Years(n), start.add_years(n));
1464 }
1465
1466 #[test]
1469 fn sub_period_equals_add_negated_period(
1470 serial in 0u32..=MAX_SERIAL,
1471 length in (i32::MIN + 1)..=i32::MAX,
1472 unit_idx in 0u8..=3,
1473 ) {
1474 let p = match unit_idx {
1475 0 => crate::Period::Days(length),
1476 1 => crate::Period::Weeks(length),
1477 2 => crate::Period::Months(length),
1478 _ => crate::Period::Years(length),
1479 };
1480 let start = Date::from_serial(serial).expect("in range");
1481 prop_assert_eq!(start - p, start + (-p));
1482 }
1483 }
1484
1485 #[test]
1488 fn add_months_clamps_to_target_month_length() {
1489 let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1490 assert_eq!(
1491 jan31.add_months(1).unwrap(),
1492 Date::from_ymd(2026, Month::Feb, 28).unwrap()
1493 );
1494 let jan31_leap = Date::from_ymd(2024, Month::Jan, 31).unwrap();
1496 assert_eq!(
1497 jan31_leap.add_months(1).unwrap(),
1498 Date::from_ymd(2024, Month::Feb, 29).unwrap()
1499 );
1500 let may31 = Date::from_ymd(2026, Month::May, 31).unwrap();
1502 assert_eq!(
1503 may31.add_months(1).unwrap(),
1504 Date::from_ymd(2026, Month::Jun, 30).unwrap()
1505 );
1506 }
1507
1508 #[test]
1511 fn add_months_clamp_is_not_composable_across_eom() {
1512 let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1513 let two_hops = jan31.add_months(1).unwrap().add_months(1).unwrap();
1515 assert_eq!(two_hops, Date::from_ymd(2026, Month::Mar, 28).unwrap());
1516 let single_hop = jan31.add_months(2).unwrap();
1518 assert_eq!(single_hop, Date::from_ymd(2026, Month::Mar, 31).unwrap());
1519 assert_ne!(two_hops, single_hop);
1521 }
1522
1523 #[test]
1524 fn add_months_crosses_year_boundaries() {
1525 let nov15 = Date::from_ymd(2026, Month::Nov, 15).unwrap();
1526 assert_eq!(
1527 nov15.add_months(3).unwrap(),
1528 Date::from_ymd(2027, Month::Feb, 15).unwrap()
1529 );
1530 assert_eq!(
1531 nov15.add_months(-11).unwrap(),
1532 Date::from_ymd(2025, Month::Dec, 15).unwrap()
1533 );
1534 }
1535
1536 #[test]
1537 fn add_months_zero_is_identity() {
1538 let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1539 assert_eq!(d.add_months(0).unwrap(), d);
1540 }
1541
1542 #[test]
1543 fn add_months_refuses_out_of_range_result() {
1544 assert_eq!(Date::MAX.add_months(1), Err(TimeError::DateOutOfRange));
1545 assert_eq!(Date::MIN.add_months(-1), Err(TimeError::DateOutOfRange));
1546 }
1547
1548 #[test]
1549 fn add_years_clamps_feb_29_in_non_leap_target() {
1550 let feb29 = Date::from_ymd(2024, Month::Feb, 29).unwrap();
1551 assert_eq!(
1552 feb29.add_years(1).unwrap(),
1553 Date::from_ymd(2025, Month::Feb, 28).unwrap()
1554 );
1555 assert_eq!(
1556 feb29.add_years(4).unwrap(),
1557 Date::from_ymd(2028, Month::Feb, 29).unwrap()
1558 );
1559 }
1560
1561 #[test]
1562 fn end_of_month_examples() {
1563 let d = Date::from_ymd(2024, Month::Jan, 15).unwrap();
1565 assert_eq!(
1566 d.end_of_month(),
1567 Date::from_ymd(2024, Month::Jan, 31).unwrap()
1568 );
1569 let d = Date::from_ymd(2024, Month::Feb, 10).unwrap();
1571 assert_eq!(
1572 d.end_of_month(),
1573 Date::from_ymd(2024, Month::Feb, 29).unwrap()
1574 );
1575 let d = Date::from_ymd(2025, Month::Feb, 10).unwrap();
1577 assert_eq!(
1578 d.end_of_month(),
1579 Date::from_ymd(2025, Month::Feb, 28).unwrap()
1580 );
1581 assert_eq!(Date::MAX.end_of_month(), Date::MAX);
1583 assert_eq!(
1585 Date::MIN.end_of_month(),
1586 Date::from_ymd(1901, Month::Jan, 31).unwrap()
1587 );
1588 }
1589
1590 #[test]
1591 fn is_end_of_month_examples() {
1592 assert!(
1593 Date::from_ymd(2024, Month::Feb, 29)
1594 .unwrap()
1595 .is_end_of_month()
1596 );
1597 assert!(
1598 !Date::from_ymd(2024, Month::Feb, 28)
1599 .unwrap()
1600 .is_end_of_month()
1601 );
1602 assert!(
1603 Date::from_ymd(2025, Month::Feb, 28)
1604 .unwrap()
1605 .is_end_of_month()
1606 );
1607 assert!(
1608 Date::from_ymd(2026, Month::Apr, 30)
1609 .unwrap()
1610 .is_end_of_month()
1611 );
1612 assert!(
1613 Date::from_ymd(2026, Month::May, 31)
1614 .unwrap()
1615 .is_end_of_month()
1616 );
1617 }
1618
1619 #[test]
1620 fn start_of_month_examples() {
1621 let d = Date::from_ymd(2024, Month::Feb, 29).unwrap();
1622 assert_eq!(
1623 d.start_of_month(),
1624 Date::from_ymd(2024, Month::Feb, 1).unwrap()
1625 );
1626 assert!(d.start_of_month().is_start_of_month());
1627 assert!(!d.is_start_of_month());
1628 assert_eq!(Date::MIN.start_of_month(), Date::MIN);
1629 }
1630
1631 #[test]
1632 fn next_weekday_is_the_identity_on_a_match() {
1633 let thu = Date::from_ymd(2026, Month::Jan, 1).unwrap();
1635 assert_eq!(thu.next_weekday(Weekday::Thu).unwrap(), thu);
1636 assert_eq!(
1637 thu.next_weekday(Weekday::Wed).unwrap(),
1638 Date::from_ymd(2026, Month::Jan, 7).unwrap(),
1639 );
1640 }
1641
1642 #[test]
1643 fn nth_weekday_examples() {
1644 let y = Year::new(2026).unwrap();
1645 assert_eq!(
1647 Date::nth_weekday(Ordinal::Third, Weekday::Mon, Month::Jan, y).unwrap(),
1648 Date::from_ymd(2026, Month::Jan, 19).unwrap(),
1649 );
1650 assert_eq!(
1652 Date::nth_weekday(Ordinal::Fourth, Weekday::Thu, Month::Nov, y).unwrap(),
1653 Date::from_ymd(2026, Month::Nov, 26).unwrap(),
1654 );
1655 assert_eq!(
1657 Date::nth_weekday(Ordinal::Fifth, Weekday::Sun, Month::Feb, y),
1658 Err(TimeError::DayOutOfRange),
1659 );
1660 }
1661
1662 #[test]
1663 fn date_range_dates_walks_both_ends() {
1664 let jan = Date::from_ymd(2026, Month::Jan, 1).unwrap()
1665 ..Date::from_ymd(2026, Month::Feb, 1).unwrap();
1666 assert_eq!(i64::try_from(jan.dates().count()).unwrap(), jan.days());
1667 assert_eq!(jan.dates().next(), Some(jan.start));
1668 assert_eq!(
1669 jan.dates().next_back(),
1670 Some(Date::from_ymd(2026, Month::Jan, 31).unwrap()),
1671 );
1672 assert_eq!((jan.start..jan.start).dates().count(), 0);
1674 assert_eq!((jan.end..jan.start).dates().count(), 0);
1675 }
1676
1677 proptest! {
1678 #[test]
1681 fn dates_agree_with_days(serial in 0u32..(MAX_SERIAL - 400), len in 0u32..400) {
1682 let start = Date::from_serial(serial).unwrap();
1683 let range = start..Date::from_serial(serial + len).unwrap();
1684 prop_assert_eq!(i64::try_from(range.dates().count()).unwrap(), range.days());
1685 prop_assert!(range.dates().all(|d| range.contains(&d)));
1686 }
1687
1688 #[test]
1690 fn nth_weekday_lands_where_asked(y in 1901u16..=2199, m in 1u8..=12, w in 1u8..=7, n in 1u8..=5) {
1691 let (month, weekday) = (Month::try_from_u8(m).unwrap(), Weekday::try_from_u8(w).unwrap());
1692 let ordinal = Ordinal::try_from_u8(n).unwrap();
1693 if let Ok(d) = Date::nth_weekday(ordinal, weekday, month, Year::new(y).unwrap()) {
1694 prop_assert_eq!(d.weekday(), weekday);
1695 prop_assert_eq!(d.month(), month);
1696 prop_assert!(d.day() > 7 * (n - 1) && d.day() <= 7 * n);
1697 }
1698 }
1699 }
1700
1701 #[test]
1702 fn add_period_dispatches_by_unit() {
1703 let start = Date::from_ymd(2026, Month::Jan, 15).unwrap();
1704 assert_eq!(
1705 (start + crate::Period::Days(1)).unwrap(),
1706 Date::from_ymd(2026, Month::Jan, 16).unwrap()
1707 );
1708 assert_eq!(
1709 (start + crate::Period::Weeks(2)).unwrap(),
1710 Date::from_ymd(2026, Month::Jan, 29).unwrap()
1711 );
1712 assert_eq!(
1713 (start + crate::Period::Months(3)).unwrap(),
1714 Date::from_ymd(2026, Month::Apr, 15).unwrap()
1715 );
1716 assert_eq!(
1717 (start + crate::Period::Years(1)).unwrap(),
1718 Date::from_ymd(2027, Month::Jan, 15).unwrap()
1719 );
1720 }
1721
1722 #[test]
1723 fn sub_period_steps_backward() {
1724 let start = Date::from_ymd(2026, Month::Jul, 15).unwrap();
1725 assert_eq!(
1726 (start - crate::Period::Months(6)).unwrap(),
1727 Date::from_ymd(2026, Month::Jan, 15).unwrap(),
1728 );
1729 assert_eq!(
1731 (start - (-crate::Period::Months(6))).unwrap(),
1732 Date::from_ymd(2027, Month::Jan, 15).unwrap(),
1733 );
1734 }
1735}