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;
518 #[allow(clippy::cast_possible_truncation)]
521 let mut idx = (serial * 400 / 146_097) as u16;
522 while CUMULATIVE[idx as usize + 1] <= serial {
523 idx += 1;
524 }
525 while CUMULATIVE[idx as usize] > serial {
526 idx -= 1;
527 }
528 Year(EPOCH_YEAR + idx)
529 }
530
531 #[must_use]
541 pub const fn to_ymd(self) -> (Year, Month, u8) {
542 let y = self.year();
543 let year_idx = (y.0 - EPOCH_YEAR) as usize;
544 let doy = self.0 - CUMULATIVE[year_idx];
545 let offsets = if y.is_leap() {
546 &MONTH_OFFSETS_LEAP
547 } else {
548 &MONTH_OFFSETS_NONLEAP
549 };
550 let mut m: usize = 0;
551 while m + 1 < 13 && offsets[m + 1] <= doy {
552 m += 1;
553 }
554 let month = match m {
555 0 => Month::Jan,
556 1 => Month::Feb,
557 2 => Month::Mar,
558 3 => Month::Apr,
559 4 => Month::May,
560 5 => Month::Jun,
561 6 => Month::Jul,
562 7 => Month::Aug,
563 8 => Month::Sep,
564 9 => Month::Oct,
565 10 => Month::Nov,
566 _ => Month::Dec,
567 };
568 #[allow(clippy::cast_possible_truncation)]
570 let day_of_month = (doy - offsets[m] + 1) as u8;
571 (y, month, day_of_month)
572 }
573
574 #[must_use]
582 pub const fn month(self) -> Month {
583 let (_, m, _) = self.to_ymd();
584 m
585 }
586
587 #[must_use]
595 pub const fn day(self) -> u8 {
596 let (_, _, d) = self.to_ymd();
597 d
598 }
599
600 #[must_use]
610 pub const fn day_of_year(self) -> u16 {
611 let y = self.year();
612 let year_idx = (y.0 - EPOCH_YEAR) as usize;
613 #[allow(clippy::cast_possible_truncation)]
615 let doy = (self.0 - CUMULATIVE[year_idx] + 1) as u16;
616 doy
617 }
618
619 #[must_use]
630 pub const fn weekday(self) -> Weekday {
631 match (self.0 + 1) % 7 {
633 0 => Weekday::Mon,
634 1 => Weekday::Tue,
635 2 => Weekday::Wed,
636 3 => Weekday::Thu,
637 4 => Weekday::Fri,
638 5 => Weekday::Sat,
639 _ => Weekday::Sun,
640 }
641 }
642
643 pub const fn add_days(self, n: i32) -> Result<Self, TimeError> {
654 let target = self.0 as i64 + n as i64;
656 if target < 0 || target > MAX_SERIAL as i64 {
657 return Err(TimeError::DateOutOfRange);
658 }
659 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
661 let serial = target as u32;
662 Ok(Self(serial))
663 }
664
665 #[must_use]
677 pub const fn days_since(self, other: Self) -> i32 {
678 let diff = self.0 as i64 - other.0 as i64;
680 #[allow(clippy::cast_possible_truncation)]
682 let diff_i32 = diff as i32;
683 diff_i32
684 }
685
686 pub const fn add_months(self, n: i32) -> Result<Self, TimeError> {
701 let (year, month, day) = self.to_ymd();
702 let total_months = year.get() as i32 * 12 + (month.get() as i32 - 1);
704 let Some(new_total) = total_months.checked_add(n) else {
705 return Err(TimeError::DateOutOfRange);
706 };
707 let target_year_i32 = new_total.div_euclid(12);
709 let new_month_idx = new_total.rem_euclid(12);
710 if target_year_i32 < Year::MIN.get() as i32 || target_year_i32 > Year::MAX.get() as i32 {
711 return Err(TimeError::DateOutOfRange);
712 }
713 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
715 let new_year_u16 = target_year_i32 as u16;
716 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
718 let new_month = match Month::try_from_u8((new_month_idx as u8) + 1) {
719 Ok(found) => found,
720 Err(err) => return Err(err),
721 };
722 let target_year = match Year::new(new_year_u16) {
723 Ok(found) => found,
724 Err(err) => return Err(err),
725 };
726 let clamped_day = {
727 let len = new_month.length(target_year);
728 if day > len { len } else { day }
729 };
730 Self::from_ymd(new_year_u16, new_month, clamped_day)
731 }
732
733 pub const fn add_years(self, n: i32) -> Result<Self, TimeError> {
746 let Some(months) = n.checked_mul(12) else {
747 return Err(TimeError::DateOutOfRange);
748 };
749 self.add_months(months)
750 }
751
752 pub fn advance(self, period: Period, end_of_month: bool) -> Result<Self, TimeError> {
770 let stepped = (self + period)?;
771 Ok(
772 if end_of_month
773 && self.is_end_of_month()
774 && matches!(period, Period::Months(_) | Period::Years(_))
775 {
776 stepped.end_of_month()
777 } else {
778 stepped
779 },
780 )
781 }
782
783 #[must_use]
792 pub const fn start_of_month(self) -> Self {
793 Self(self.0 - (self.day() as u32 - 1))
794 }
795
796 #[must_use]
798 pub const fn is_start_of_month(self) -> bool {
799 self.day() == 1
800 }
801
802 pub fn next_weekday(self, weekday: Weekday) -> Result<Self, TimeError> {
812 let delta = (i32::from(weekday.get()) - i32::from(self.weekday().get())).rem_euclid(7);
813 self.add_days(delta)
814 }
815
816 pub fn nth_weekday(
834 n: Ordinal,
835 weekday: Weekday,
836 month: Month,
837 year: Year,
838 ) -> Result<Self, TimeError> {
839 let first = Self::from_ymd(year.get(), month, 1)?.next_weekday(weekday)?;
840 let nth = first.add_days(7 * (i32::from(n.get()) - 1))?;
841 if nth.month() == month {
842 Ok(nth)
843 } else {
844 Err(TimeError::DayOutOfRange)
845 }
846 }
847
848 #[must_use]
857 pub const fn end_of_month(self) -> Self {
858 let (year, month, _) = self.to_ymd();
859 let last = month.length(year);
860 let month_start = self.0 - (self.day() as u32 - 1);
862 Self(month_start + last as u32 - 1)
863 }
864
865 #[must_use]
875 pub const fn is_end_of_month(self) -> bool {
876 let (year, month, day) = self.to_ymd();
877 day == month.length(year)
878 }
879}
880
881pub trait DateRange: Sized {
896 fn days(&self) -> i64;
898
899 fn intersect(&self, other: &Self) -> Option<Self>;
902
903 fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<Self>;
906}
907
908impl DateRange for Range<Date> {
909 fn days(&self) -> i64 {
910 i64::from(self.end.days_since(self.start))
911 }
912
913 fn intersect(&self, other: &Self) -> Option<Self> {
914 let both = self.start.max(other.start)..self.end.min(other.end);
915 (both.start < both.end).then_some(both)
916 }
917
918 fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<> {
919 (self.start.serial()..self.end.serial()).filter_map(|s| Date::from_serial(s).ok())
921 }
922}
923
924impl fmt::Display for Date {
925 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
926 let (y, m, d) = self.to_ymd();
928 write!(f, "{:04}-{:02}-{:02}", y.get(), m.get(), d)
929 }
930}
931
932impl core::str::FromStr for Date {
933 type Err = TimeError;
934
935 fn from_str(s: &str) -> Result<Self, Self::Err> {
952 const fn digit(b: u8) -> Result<u16, TimeError> {
953 if b.is_ascii_digit() {
954 Ok((b - b'0') as u16)
955 } else {
956 Err(TimeError::InvalidDateString)
957 }
958 }
959 let [y3, y2, y1, y0, h1, m1, m0, h2, d1, d0] = s.as_bytes() else {
960 return Err(TimeError::InvalidDateString);
961 };
962 if *h1 != b'-' || *h2 != b'-' {
963 return Err(TimeError::InvalidDateString);
964 }
965 let year = 1000 * digit(*y3)? + 100 * digit(*y2)? + 10 * digit(*y1)? + digit(*y0)?;
966 let month_num = 10 * digit(*m1)? + digit(*m0)?;
967 let day = 10 * digit(*d1)? + digit(*d0)?;
968 #[allow(clippy::cast_possible_truncation)]
970 let month = Month::try_from_u8(month_num as u8)?;
971 #[allow(clippy::cast_possible_truncation)]
972 let day = day as u8;
973 Self::from_ymd(year, month, day)
974 }
975}
976
977impl Add<Period> for Date {
992 type Output = Result<Self, TimeError>;
993
994 fn add(self, period: Period) -> Self::Output {
995 match period {
996 Period::Days(n) => self.add_days(n),
997 Period::Weeks(n) => match n.checked_mul(7) {
998 Some(days) => self.add_days(days),
999 None => Err(TimeError::DateOutOfRange),
1000 },
1001 Period::Months(n) => self.add_months(n),
1002 Period::Years(n) => self.add_years(n),
1003 }
1004 }
1005}
1006
1007impl Sub<Period> for Date {
1017 type Output = Result<Self, TimeError>;
1018
1019 fn sub(self, period: Period) -> Self::Output {
1020 #[allow(clippy::suspicious_arithmetic_impl)]
1022 match period.checked_neg() {
1023 Some(neg) => self + neg,
1024 None => Err(TimeError::DateOutOfRange),
1025 }
1026 }
1027}
1028
1029#[cfg(test)]
1032#[allow(clippy::unwrap_used, clippy::expect_used)]
1033mod tests {
1034 extern crate alloc;
1035
1036 use super::*;
1037 use proptest::prelude::*;
1038
1039 #[test]
1040 fn epoch_is_1901_01_01_tuesday() {
1041 let d = Date::MIN;
1042 assert_eq!(d.serial(), 0);
1043 assert_eq!(d.year().get(), 1901);
1044 assert_eq!(d.month(), Month::Jan);
1045 assert_eq!(d.day(), 1);
1046 assert_eq!(d.weekday(), Weekday::Tue);
1047 }
1048
1049 #[test]
1050 fn max_is_2199_12_31() {
1051 let d = Date::MAX;
1052 assert_eq!(d.year().get(), 2199);
1053 assert_eq!(d.month(), Month::Dec);
1054 assert_eq!(d.day(), 31);
1055 }
1056
1057 #[test]
1058 fn from_ymd_rejects_out_of_range_year() {
1059 assert_eq!(
1060 Date::from_ymd(1900, Month::Jan, 1),
1061 Err(TimeError::YearOutOfRange)
1062 );
1063 assert_eq!(
1064 Date::from_ymd(2200, Month::Jan, 1),
1065 Err(TimeError::YearOutOfRange)
1066 );
1067 }
1068
1069 #[test]
1070 fn from_ymd_rejects_day_zero_and_overflow() {
1071 assert_eq!(
1072 Date::from_ymd(2026, Month::Jan, 0),
1073 Err(TimeError::DayOutOfRange)
1074 );
1075 assert_eq!(
1076 Date::from_ymd(2026, Month::Jan, 32),
1077 Err(TimeError::DayOutOfRange)
1078 );
1079 assert_eq!(
1080 Date::from_ymd(2026, Month::Apr, 31),
1081 Err(TimeError::DayOutOfRange)
1082 );
1083 }
1084
1085 #[test]
1086 fn february_leap_year_behavior() {
1087 assert!(Date::from_ymd(2000, Month::Feb, 29).is_ok());
1089 assert_eq!(
1091 Date::from_ymd(2100, Month::Feb, 29),
1092 Err(TimeError::DayOutOfRange)
1093 );
1094 assert!(Date::from_ymd(2024, Month::Feb, 29).is_ok());
1096 assert_eq!(
1098 Date::from_ymd(2026, Month::Feb, 29),
1099 Err(TimeError::DayOutOfRange)
1100 );
1101 }
1102
1103 #[test]
1104 fn known_weekdays() {
1105 assert_eq!(
1107 Date::from_ymd(1901, Month::Jan, 1).unwrap().weekday(),
1108 Weekday::Tue,
1109 );
1110 assert_eq!(
1111 Date::from_ymd(2000, Month::Jan, 1).unwrap().weekday(),
1112 Weekday::Sat,
1113 );
1114 assert_eq!(
1115 Date::from_ymd(2026, Month::Jul, 4).unwrap().weekday(),
1116 Weekday::Sat,
1117 );
1118 assert_eq!(
1119 Date::from_ymd(2021, Month::Jun, 19).unwrap().weekday(),
1120 Weekday::Sat,
1121 );
1122 assert_eq!(
1123 Date::from_ymd(2199, Month::Dec, 31).unwrap().weekday(),
1124 Weekday::Tue,
1125 );
1126 }
1127
1128 #[test]
1129 fn year_is_correct_for_every_serial() {
1130 let mut expected: u16 = EPOCH_YEAR;
1137 let mut next_year_start: u32 = CUMULATIVE[1];
1138 for serial in 0..=MAX_SERIAL {
1139 if serial == next_year_start {
1140 expected += 1;
1141 next_year_start = CUMULATIVE[(expected - EPOCH_YEAR) as usize + 1];
1142 }
1143 assert_eq!(
1144 Date::from_serial(serial).unwrap().year().get(),
1145 expected,
1146 "serial {serial}",
1147 );
1148 let estimate = serial * 400 / 146_097;
1149 assert!(
1150 estimate.abs_diff(u32::from(expected - EPOCH_YEAR)) <= 1,
1151 "serial {serial}: estimate {estimate} not within one of the true index",
1152 );
1153 }
1154 }
1155
1156 #[test]
1157 fn day_of_year_boundaries() {
1158 assert_eq!(
1159 Date::from_ymd(2024, Month::Jan, 1).unwrap().day_of_year(),
1160 1,
1161 );
1162 assert_eq!(
1163 Date::from_ymd(2024, Month::Dec, 31).unwrap().day_of_year(),
1164 366, );
1166 assert_eq!(
1167 Date::from_ymd(2025, Month::Dec, 31).unwrap().day_of_year(),
1168 365,
1169 );
1170 }
1171
1172 #[test]
1173 fn add_days_at_boundaries() {
1174 assert_eq!(Date::MIN.add_days(-1), Err(TimeError::DateOutOfRange));
1175 assert_eq!(Date::MAX.add_days(1), Err(TimeError::DateOutOfRange));
1176 let d = Date::from_ymd(2026, Month::Feb, 28).unwrap();
1177 assert_eq!(
1178 d.add_days(1).unwrap(),
1179 Date::from_ymd(2026, Month::Mar, 1).unwrap()
1180 );
1181 let leap = Date::from_ymd(2024, Month::Feb, 28).unwrap();
1182 assert_eq!(
1183 leap.add_days(1).unwrap(),
1184 Date::from_ymd(2024, Month::Feb, 29).unwrap()
1185 );
1186 }
1187
1188 #[test]
1189 fn display_is_iso_8601() {
1190 let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1191 assert_eq!(alloc::format!("{d}"), "2026-07-04");
1192 }
1193
1194 #[test]
1195 fn weekday_iso_numbering() {
1196 assert_eq!(Weekday::Mon.get(), 1);
1197 assert_eq!(Weekday::Sun.get(), 7);
1198 assert_eq!(Weekday::try_from_u8(1).unwrap(), Weekday::Mon);
1199 assert_eq!(Weekday::try_from_u8(7).unwrap(), Weekday::Sun);
1200 assert_eq!(Weekday::try_from_u8(0), Err(TimeError::WeekdayOutOfRange));
1201 assert_eq!(Weekday::try_from_u8(8), Err(TimeError::WeekdayOutOfRange));
1202 }
1203
1204 #[test]
1205 fn ordinal_display() {
1206 assert_eq!(alloc::format!("{}", Ordinal::First), "First");
1207 assert_eq!(alloc::format!("{}", Ordinal::Fifth), "Fifth");
1208 }
1209
1210 #[test]
1211 fn from_str_parses_display_output() {
1212 for (y, m, d) in [
1213 (1901u16, Month::Jan, 1u8),
1214 (2026, Month::Jul, 4),
1215 (2024, Month::Feb, 29),
1216 (2199, Month::Dec, 31),
1217 ] {
1218 let date = Date::from_ymd(y, m, d).unwrap();
1219 let parsed: Date = alloc::format!("{date}").parse().unwrap();
1220 assert_eq!(parsed, date);
1221 }
1222 }
1223
1224 #[test]
1225 fn from_str_rejects_malformed_strings() {
1226 for bad in [
1227 "",
1228 "2026",
1229 "2026-07",
1230 "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", ] {
1240 assert_eq!(
1241 bad.parse::<Date>(),
1242 Err(TimeError::InvalidDateString),
1243 "{bad:?} should be rejected as malformed",
1244 );
1245 }
1246 }
1247
1248 #[test]
1249 fn from_str_surfaces_range_errors_for_well_formed_input() {
1250 assert_eq!("1900-12-31".parse::<Date>(), Err(TimeError::YearOutOfRange));
1251 assert_eq!("2200-01-01".parse::<Date>(), Err(TimeError::YearOutOfRange));
1252 assert_eq!(
1253 "2026-13-01".parse::<Date>(),
1254 Err(TimeError::MonthOutOfRange)
1255 );
1256 assert_eq!(
1257 "2026-00-01".parse::<Date>(),
1258 Err(TimeError::MonthOutOfRange)
1259 );
1260 assert_eq!("2026-02-30".parse::<Date>(), Err(TimeError::DayOutOfRange));
1261 assert_eq!("2026-01-00".parse::<Date>(), Err(TimeError::DayOutOfRange));
1262 }
1263
1264 #[test]
1265 fn to_ymd_matches_individual_accessors() {
1266 let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1267 let (y, m, dom) = d.to_ymd();
1268 assert_eq!(y, d.year());
1269 assert_eq!(m, d.month());
1270 assert_eq!(dom, d.day());
1271 }
1272
1273 fn any_ymd() -> impl Strategy<Value = (u16, Month, u8)> {
1277 (EPOCH_YEAR..=END_YEAR, 1u8..=12u8).prop_flat_map(|(y, m)| {
1278 let month = Month::try_from_u8(m).expect("1..=12");
1279 let year = Year::new(y).expect("in range");
1280 let max_day = month.length(year);
1281 (Just(y), Just(month), 1u8..=max_day)
1282 })
1283 }
1284
1285 proptest! {
1286 #[test]
1287 fn from_ymd_round_trips(
1288 (year, month, day) in any_ymd()
1289 ) {
1290 let d = Date::from_ymd(year, month, day).expect("valid ymd");
1291 prop_assert_eq!(d.year().get(), year);
1292 prop_assert_eq!(d.month(), month);
1293 prop_assert_eq!(d.day(), day);
1294 }
1295
1296 #[test]
1297 fn serial_round_trips(
1298 serial in 0u32..=MAX_SERIAL,
1299 ) {
1300 let d = Date::from_serial(serial).expect("in range");
1301 prop_assert_eq!(d.serial(), serial);
1302 let ymd = Date::from_ymd(d.year().get(), d.month(), d.day()).expect("valid");
1303 prop_assert_eq!(ymd.serial(), serial);
1304 }
1305
1306 #[test]
1307 fn weekday_advances_by_one_per_day(
1308 serial in 0u32..MAX_SERIAL,
1309 ) {
1310 let today = Date::from_serial(serial).expect("in range");
1311 let tomorrow = today.add_days(1).expect("in range");
1312 let expected = match today.weekday() {
1313 Weekday::Mon => Weekday::Tue,
1314 Weekday::Tue => Weekday::Wed,
1315 Weekday::Wed => Weekday::Thu,
1316 Weekday::Thu => Weekday::Fri,
1317 Weekday::Fri => Weekday::Sat,
1318 Weekday::Sat => Weekday::Sun,
1319 Weekday::Sun => Weekday::Mon,
1320 };
1321 prop_assert_eq!(tomorrow.weekday(), expected);
1322 }
1323
1324 #[test]
1325 fn add_days_is_inverse_of_days_since(
1326 a_serial in 0u32..=MAX_SERIAL,
1327 b_serial in 0u32..=MAX_SERIAL,
1328 ) {
1329 let a = Date::from_serial(a_serial).unwrap();
1330 let b = Date::from_serial(b_serial).unwrap();
1331 let diff = b.days_since(a);
1332 prop_assert_eq!(a.add_days(diff).unwrap(), b);
1333 }
1334
1335 #[test]
1336 fn day_of_year_is_consistent(
1337 (year, month, day) in any_ymd()
1338 ) {
1339 let d = Date::from_ymd(year, month, day).expect("valid");
1340 let year_start = Date::from_ymd(year, Month::Jan, 1).expect("valid");
1341 prop_assert_eq!(
1342 u16::try_from(d.days_since(year_start) + 1).unwrap(),
1343 d.day_of_year(),
1344 );
1345 }
1346
1347 #[test]
1348 fn month_try_from_u8_round_trips(m in 1u8..=12u8) {
1349 let parsed = Month::try_from_u8(m).expect("1..=12");
1350 prop_assert_eq!(parsed.get(), m);
1351 }
1352
1353 #[test]
1354 fn weekday_try_from_u8_round_trips(n in 1u8..=7u8) {
1355 let parsed = Weekday::try_from_u8(n).expect("1..=7");
1356 prop_assert_eq!(parsed.get(), n);
1357 }
1358
1359 #[test]
1360 fn ordinal_try_from_u8_round_trips(n in 1u8..=5u8) {
1361 let parsed = Ordinal::try_from_u8(n).expect("1..=5");
1362 prop_assert_eq!(parsed.get(), n);
1363 }
1364
1365 #[test]
1366 fn add_days_accepts_iff_result_in_range(
1367 serial in 0u32..=MAX_SERIAL,
1368 n in i32::MIN..=i32::MAX,
1369 ) {
1370 let d = Date::from_serial(serial).expect("in range");
1371 let result = d.add_days(n);
1372 let target = i64::from(serial) + i64::from(n);
1373 let in_range = (0..=i64::from(MAX_SERIAL)).contains(&target);
1374 prop_assert_eq!(result.is_ok(), in_range);
1375 if in_range {
1376 prop_assert_eq!(
1377 result.expect("in-range").serial(),
1378 u32::try_from(target).expect("fits in u32"),
1379 );
1380 } else {
1381 prop_assert_eq!(result, Err(TimeError::DateOutOfRange));
1382 }
1383 }
1384
1385 #[test]
1386 fn to_ymd_round_trips(serial in 0u32..=MAX_SERIAL) {
1387 let d = Date::from_serial(serial).expect("in range");
1388 let (y, m, dom) = d.to_ymd();
1389 let rebuilt = Date::from_ymd(y.get(), m, dom).expect("valid");
1390 prop_assert_eq!(rebuilt.serial(), serial);
1391 }
1392
1393 #[test]
1395 fn display_and_from_str_round_trip(serial in 0u32..=MAX_SERIAL) {
1396 let d = Date::from_serial(serial).expect("in range");
1397 let parsed: Date = alloc::format!("{d}").parse().expect("Display output is valid");
1398 prop_assert_eq!(parsed, d);
1399 }
1400
1401 #[test]
1403 fn add_months_round_trip_on_safe_days(
1404 year in 1910u16..=2190,
1405 month in 1u8..=12,
1406 day in 1u8..=28,
1407 n in -500i32..=500,
1408 ) {
1409 let parsed_month = Month::try_from_u8(month).expect("1..=12");
1410 let start = Date::from_ymd(year, parsed_month, day).expect("valid");
1411 if let Ok(stepped) = start.add_months(n)
1412 && let Ok(restored) = stepped.add_months(-n)
1413 {
1414 prop_assert_eq!(restored, start);
1415 }
1416 }
1417
1418 #[test]
1421 fn add_months_decomposes_into_years_plus_months(
1422 year in 1921u16..=2179,
1423 month in 1u8..=12,
1424 day in 1u8..=28,
1425 whole_years in -20i32..=20,
1426 extra_months in -11i32..=11,
1427 ) {
1428 let parsed_month = Month::try_from_u8(month).expect("1..=12");
1429 let start = Date::from_ymd(year, parsed_month, day).expect("valid");
1430 let direct = start.add_months(whole_years * 12 + extra_months);
1431 let stepped = start
1432 .add_years(whole_years)
1433 .and_then(|x| x.add_months(extra_months));
1434 prop_assert_eq!(direct, stepped);
1435 }
1436
1437 #[test]
1439 fn add_months_never_exceeds_target_month_length(
1440 serial in 0u32..=MAX_SERIAL,
1441 n in -200i32..=200,
1442 ) {
1443 let d = Date::from_serial(serial).expect("in range");
1444 if let Ok(out) = d.add_months(n) {
1445 let (y, m, dom) = out.to_ymd();
1446 prop_assert!(dom <= m.length(y));
1447 prop_assert!(dom >= 1);
1448 }
1449 }
1450
1451 #[test]
1453 fn end_of_month_is_idempotent(serial in 0u32..=MAX_SERIAL) {
1454 let d = Date::from_serial(serial).expect("in range");
1455 prop_assert_eq!(d.end_of_month(), d.end_of_month().end_of_month());
1456 prop_assert!(d.end_of_month().is_end_of_month());
1457 }
1458
1459 #[test]
1461 fn add_period_days_matches_add_days(
1462 serial in 0u32..=MAX_SERIAL,
1463 n in -10_000i32..=10_000,
1464 ) {
1465 let start = Date::from_serial(serial).expect("in range");
1466 prop_assert_eq!(start + crate::Period::Days(n), start.add_days(n));
1467 }
1468
1469 #[test]
1472 fn add_period_weeks_equals_add_days_times_seven(
1473 serial in 0u32..=MAX_SERIAL,
1474 n in (i32::MIN / 7)..=(i32::MAX / 7),
1475 ) {
1476 let start = Date::from_serial(serial).expect("in range");
1477 prop_assert_eq!(start + crate::Period::Weeks(n), start.add_days(n * 7));
1478 }
1479
1480 #[test]
1482 fn add_period_months_matches_add_months(
1483 serial in 0u32..=MAX_SERIAL,
1484 n in -200i32..=200,
1485 ) {
1486 let start = Date::from_serial(serial).expect("in range");
1487 prop_assert_eq!(start + crate::Period::Months(n), start.add_months(n));
1488 }
1489
1490 #[test]
1492 fn add_period_years_matches_add_years(
1493 serial in 0u32..=MAX_SERIAL,
1494 n in -100i32..=100,
1495 ) {
1496 let start = Date::from_serial(serial).expect("in range");
1497 prop_assert_eq!(start + crate::Period::Years(n), start.add_years(n));
1498 }
1499
1500 #[test]
1503 fn sub_period_equals_add_negated_period(
1504 serial in 0u32..=MAX_SERIAL,
1505 length in (i32::MIN + 1)..=i32::MAX,
1506 unit_idx in 0u8..=3,
1507 ) {
1508 let p = match unit_idx {
1509 0 => crate::Period::Days(length),
1510 1 => crate::Period::Weeks(length),
1511 2 => crate::Period::Months(length),
1512 _ => crate::Period::Years(length),
1513 };
1514 let start = Date::from_serial(serial).expect("in range");
1515 prop_assert_eq!(start - p, start + (-p));
1516 }
1517 }
1518
1519 #[test]
1522 fn add_months_clamps_to_target_month_length() {
1523 let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1524 assert_eq!(
1525 jan31.add_months(1).unwrap(),
1526 Date::from_ymd(2026, Month::Feb, 28).unwrap()
1527 );
1528 let jan31_leap = Date::from_ymd(2024, Month::Jan, 31).unwrap();
1530 assert_eq!(
1531 jan31_leap.add_months(1).unwrap(),
1532 Date::from_ymd(2024, Month::Feb, 29).unwrap()
1533 );
1534 let may31 = Date::from_ymd(2026, Month::May, 31).unwrap();
1536 assert_eq!(
1537 may31.add_months(1).unwrap(),
1538 Date::from_ymd(2026, Month::Jun, 30).unwrap()
1539 );
1540 }
1541
1542 #[test]
1545 fn add_months_clamp_is_not_composable_across_eom() {
1546 let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1547 let two_hops = jan31.add_months(1).unwrap().add_months(1).unwrap();
1549 assert_eq!(two_hops, Date::from_ymd(2026, Month::Mar, 28).unwrap());
1550 let single_hop = jan31.add_months(2).unwrap();
1552 assert_eq!(single_hop, Date::from_ymd(2026, Month::Mar, 31).unwrap());
1553 assert_ne!(two_hops, single_hop);
1555 }
1556
1557 #[test]
1558 fn add_months_crosses_year_boundaries() {
1559 let nov15 = Date::from_ymd(2026, Month::Nov, 15).unwrap();
1560 assert_eq!(
1561 nov15.add_months(3).unwrap(),
1562 Date::from_ymd(2027, Month::Feb, 15).unwrap()
1563 );
1564 assert_eq!(
1565 nov15.add_months(-11).unwrap(),
1566 Date::from_ymd(2025, Month::Dec, 15).unwrap()
1567 );
1568 }
1569
1570 #[test]
1571 fn add_months_zero_is_identity() {
1572 let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1573 assert_eq!(d.add_months(0).unwrap(), d);
1574 }
1575
1576 #[test]
1577 fn add_months_refuses_out_of_range_result() {
1578 assert_eq!(Date::MAX.add_months(1), Err(TimeError::DateOutOfRange));
1579 assert_eq!(Date::MIN.add_months(-1), Err(TimeError::DateOutOfRange));
1580 }
1581
1582 #[test]
1583 fn add_years_clamps_feb_29_in_non_leap_target() {
1584 let feb29 = Date::from_ymd(2024, Month::Feb, 29).unwrap();
1585 assert_eq!(
1586 feb29.add_years(1).unwrap(),
1587 Date::from_ymd(2025, Month::Feb, 28).unwrap()
1588 );
1589 assert_eq!(
1590 feb29.add_years(4).unwrap(),
1591 Date::from_ymd(2028, Month::Feb, 29).unwrap()
1592 );
1593 }
1594
1595 #[test]
1596 fn end_of_month_examples() {
1597 let d = Date::from_ymd(2024, Month::Jan, 15).unwrap();
1599 assert_eq!(
1600 d.end_of_month(),
1601 Date::from_ymd(2024, Month::Jan, 31).unwrap()
1602 );
1603 let d = Date::from_ymd(2024, Month::Feb, 10).unwrap();
1605 assert_eq!(
1606 d.end_of_month(),
1607 Date::from_ymd(2024, Month::Feb, 29).unwrap()
1608 );
1609 let d = Date::from_ymd(2025, Month::Feb, 10).unwrap();
1611 assert_eq!(
1612 d.end_of_month(),
1613 Date::from_ymd(2025, Month::Feb, 28).unwrap()
1614 );
1615 assert_eq!(Date::MAX.end_of_month(), Date::MAX);
1617 assert_eq!(
1619 Date::MIN.end_of_month(),
1620 Date::from_ymd(1901, Month::Jan, 31).unwrap()
1621 );
1622 }
1623
1624 #[test]
1625 fn is_end_of_month_examples() {
1626 assert!(
1627 Date::from_ymd(2024, Month::Feb, 29)
1628 .unwrap()
1629 .is_end_of_month()
1630 );
1631 assert!(
1632 !Date::from_ymd(2024, Month::Feb, 28)
1633 .unwrap()
1634 .is_end_of_month()
1635 );
1636 assert!(
1637 Date::from_ymd(2025, Month::Feb, 28)
1638 .unwrap()
1639 .is_end_of_month()
1640 );
1641 assert!(
1642 Date::from_ymd(2026, Month::Apr, 30)
1643 .unwrap()
1644 .is_end_of_month()
1645 );
1646 assert!(
1647 Date::from_ymd(2026, Month::May, 31)
1648 .unwrap()
1649 .is_end_of_month()
1650 );
1651 }
1652
1653 #[test]
1654 fn start_of_month_examples() {
1655 let d = Date::from_ymd(2024, Month::Feb, 29).unwrap();
1656 assert_eq!(
1657 d.start_of_month(),
1658 Date::from_ymd(2024, Month::Feb, 1).unwrap()
1659 );
1660 assert!(d.start_of_month().is_start_of_month());
1661 assert!(!d.is_start_of_month());
1662 assert_eq!(Date::MIN.start_of_month(), Date::MIN);
1663 }
1664
1665 #[test]
1666 fn next_weekday_is_the_identity_on_a_match() {
1667 let thu = Date::from_ymd(2026, Month::Jan, 1).unwrap();
1669 assert_eq!(thu.next_weekday(Weekday::Thu).unwrap(), thu);
1670 assert_eq!(
1671 thu.next_weekday(Weekday::Wed).unwrap(),
1672 Date::from_ymd(2026, Month::Jan, 7).unwrap(),
1673 );
1674 }
1675
1676 #[test]
1677 fn nth_weekday_examples() {
1678 let y = Year::new(2026).unwrap();
1679 assert_eq!(
1681 Date::nth_weekday(Ordinal::Third, Weekday::Mon, Month::Jan, y).unwrap(),
1682 Date::from_ymd(2026, Month::Jan, 19).unwrap(),
1683 );
1684 assert_eq!(
1686 Date::nth_weekday(Ordinal::Fourth, Weekday::Thu, Month::Nov, y).unwrap(),
1687 Date::from_ymd(2026, Month::Nov, 26).unwrap(),
1688 );
1689 assert_eq!(
1691 Date::nth_weekday(Ordinal::Fifth, Weekday::Sun, Month::Feb, y),
1692 Err(TimeError::DayOutOfRange),
1693 );
1694 }
1695
1696 #[test]
1697 fn date_range_dates_walks_both_ends() {
1698 let jan = Date::from_ymd(2026, Month::Jan, 1).unwrap()
1699 ..Date::from_ymd(2026, Month::Feb, 1).unwrap();
1700 assert_eq!(i64::try_from(jan.dates().count()).unwrap(), jan.days());
1701 assert_eq!(jan.dates().next(), Some(jan.start));
1702 assert_eq!(
1703 jan.dates().next_back(),
1704 Some(Date::from_ymd(2026, Month::Jan, 31).unwrap()),
1705 );
1706 assert_eq!((jan.start..jan.start).dates().count(), 0);
1708 assert_eq!((jan.end..jan.start).dates().count(), 0);
1709 }
1710
1711 proptest! {
1712 #[test]
1715 fn dates_agree_with_days(serial in 0u32..(MAX_SERIAL - 400), len in 0u32..400) {
1716 let start = Date::from_serial(serial).unwrap();
1717 let range = start..Date::from_serial(serial + len).unwrap();
1718 prop_assert_eq!(i64::try_from(range.dates().count()).unwrap(), range.days());
1719 prop_assert!(range.dates().all(|d| range.contains(&d)));
1720 }
1721
1722 #[test]
1724 fn nth_weekday_lands_where_asked(y in 1901u16..=2199, m in 1u8..=12, w in 1u8..=7, n in 1u8..=5) {
1725 let (month, weekday) = (Month::try_from_u8(m).unwrap(), Weekday::try_from_u8(w).unwrap());
1726 let ordinal = Ordinal::try_from_u8(n).unwrap();
1727 if let Ok(d) = Date::nth_weekday(ordinal, weekday, month, Year::new(y).unwrap()) {
1728 prop_assert_eq!(d.weekday(), weekday);
1729 prop_assert_eq!(d.month(), month);
1730 prop_assert!(d.day() > 7 * (n - 1) && d.day() <= 7 * n);
1731 }
1732 }
1733 }
1734
1735 #[test]
1736 fn add_period_dispatches_by_unit() {
1737 let start = Date::from_ymd(2026, Month::Jan, 15).unwrap();
1738 assert_eq!(
1739 (start + crate::Period::Days(1)).unwrap(),
1740 Date::from_ymd(2026, Month::Jan, 16).unwrap()
1741 );
1742 assert_eq!(
1743 (start + crate::Period::Weeks(2)).unwrap(),
1744 Date::from_ymd(2026, Month::Jan, 29).unwrap()
1745 );
1746 assert_eq!(
1747 (start + crate::Period::Months(3)).unwrap(),
1748 Date::from_ymd(2026, Month::Apr, 15).unwrap()
1749 );
1750 assert_eq!(
1751 (start + crate::Period::Years(1)).unwrap(),
1752 Date::from_ymd(2027, Month::Jan, 15).unwrap()
1753 );
1754 }
1755
1756 #[test]
1757 fn sub_period_steps_backward() {
1758 let start = Date::from_ymd(2026, Month::Jul, 15).unwrap();
1759 assert_eq!(
1760 (start - crate::Period::Months(6)).unwrap(),
1761 Date::from_ymd(2026, Month::Jan, 15).unwrap(),
1762 );
1763 assert_eq!(
1765 (start - (-crate::Period::Months(6))).unwrap(),
1766 Date::from_ymd(2027, Month::Jan, 15).unwrap(),
1767 );
1768 }
1769}