Skip to main content

fasti/
date.rs

1//! Dates and their building blocks: [`Date`], [`Year`], [`Month`],
2//! [`Weekday`], and [`Ordinal`].
3//!
4//! [`Date`] is a newtype over [`u32`] counting days from 1901-01-01 (serial
5//! zero); supported range 1901-01-01..=2199-12-31, else [`TimeError`].
6
7use crate::{Period, TimeError};
8use core::fmt;
9use core::ops::{Add, Range, Sub};
10
11// ---- Range constants ----------------------------------------------------
12
13const 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
21/// `CUMULATIVE[i]` = days from 1901-01-01 to (1901 + i)-01-01; the entry
22/// at index `NUM_YEARS` is one past the last valid day.
23const 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
37/// 0-based day-of-year at the start of month `i + 1`, non-leap year;
38/// entry 12 is a sentinel.
39const MONTH_OFFSETS_NONLEAP: [u32; 13] =
40    [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
41
42/// As [`MONTH_OFFSETS_NONLEAP`], for a leap year.
43const MONTH_OFFSETS_LEAP: [u32; 13] = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366];
44
45// ---- Year ---------------------------------------------------------------
46
47/// A year in the range 1901..=2199.
48///
49/// ```
50/// use fasti::Year;
51/// let y = Year::new(2026)?;
52/// assert_eq!(y.get(), 2026);
53/// assert!(!y.is_leap());
54/// assert!(Year::new(1900).is_err());
55/// # Ok::<(), fasti::TimeError>(())
56/// ```
57#[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    /// The earliest supported year, 1901.
64    pub const MIN: Self = Self(EPOCH_YEAR);
65
66    /// The latest supported year, 2199.
67    pub const MAX: Self = Self(END_YEAR);
68
69    /// Construct a [`Year`], refusing values outside `1901..=2199`.
70    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    /// Construct a [`Year`] from a compile-time literal; an out-of-range
79    /// value is a compile error, not a runtime panic.
80    ///
81    /// ```
82    /// use fasti::Year;
83    /// const MLK_FEDERAL_FROM: Year = Year::literal(1986);
84    /// assert_eq!(MLK_FEDERAL_FROM.get(), 1986);
85    /// ```
86    ///
87    /// ```compile_fail
88    /// use fasti::Year;
89    /// // Compile error: argument out of range.
90    /// const BAD: Year = Year::literal(1800);
91    /// ```
92    #[must_use]
93    #[allow(clippy::panic)]
94    pub const fn literal(year: u16) -> Self {
95        match Self::new(year) {
96            Ok(y) => y,
97            // Reached only at const-eval time — a compile error, not a runtime panic.
98            Err(_) => panic!("Year::literal: argument must be in 1901..=2199"),
99        }
100    }
101
102    /// Return the underlying year as a [`u16`].
103    ///
104    /// ```
105    /// use fasti::Year;
106    /// assert_eq!(Year::new(2026)?.get(), 2026);
107    /// # Ok::<(), fasti::TimeError>(())
108    /// ```
109    #[must_use]
110    pub const fn get(self) -> u16 {
111        self.0
112    }
113
114    /// `true` iff this is a Gregorian leap year.
115    ///
116    /// ```
117    /// use fasti::Year;
118    /// assert!(Year::new(2000)?.is_leap());   // div by 400
119    /// assert!(!Year::new(2100)?.is_leap());  // div by 100 but not 400
120    /// assert!(Year::new(2024)?.is_leap());   // div by 4 only
121    /// assert!(!Year::new(2025)?.is_leap());
122    /// # Ok::<(), fasti::TimeError>(())
123    /// ```
124    #[must_use]
125    pub const fn is_leap(self) -> bool {
126        is_leap(self.0)
127    }
128
129    /// Number of days in the year (365 or 366).
130    ///
131    /// ```
132    /// use fasti::Year;
133    /// assert_eq!(Year::new(2024)?.length(), 366);
134    /// assert_eq!(Year::new(2025)?.length(), 365);
135    /// # Ok::<(), fasti::TimeError>(())
136    /// ```
137    #[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// ---- Month --------------------------------------------------------------
150
151/// A month of the year, discriminant 1..=12.
152#[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    /// January
157    Jan = 1,
158    /// February
159    Feb = 2,
160    /// March
161    Mar = 3,
162    /// April
163    Apr = 4,
164    /// May
165    May = 5,
166    /// June
167    Jun = 6,
168    /// July
169    Jul = 7,
170    /// August
171    Aug = 8,
172    /// September
173    Sep = 9,
174    /// October
175    Oct = 10,
176    /// November
177    Nov = 11,
178    /// December
179    Dec = 12,
180}
181
182impl Month {
183    /// Month number, `Jan => 1`, ..., `Dec => 12`.
184    ///
185    /// ```
186    /// use fasti::Month;
187    /// assert_eq!(Month::Jul.get(), 7);
188    /// ```
189    #[must_use]
190    pub const fn get(self) -> u8 {
191        self as u8
192    }
193
194    /// Construct a [`Month`] from a 1-based month number, refusing
195    /// anything outside `1..=12`.
196    ///
197    /// ```
198    /// use fasti::{Month, TimeError};
199    /// assert_eq!(Month::try_from_u8(7)?, Month::Jul);
200    /// assert_eq!(Month::try_from_u8(0), Err(TimeError::MonthOutOfRange));
201    /// assert_eq!(Month::try_from_u8(13), Err(TimeError::MonthOutOfRange));
202    /// # Ok::<(), fasti::TimeError>(())
203    /// ```
204    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    /// Number of days in this month for the given [`Year`], with February
223    /// returning 28 or 29 as appropriate.
224    ///
225    /// ```
226    /// use fasti::{Month, Year};
227    /// assert_eq!(Month::Feb.length(Year::new(2024)?), 29); // leap
228    /// assert_eq!(Month::Feb.length(Year::new(2025)?), 28);
229    /// assert_eq!(Month::Apr.length(Year::new(2025)?), 30);
230    /// # Ok::<(), fasti::TimeError>(())
231    /// ```
232    #[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// ---- Weekday ------------------------------------------------------------
269
270/// Day of the week. Discriminants follow ISO 8601: Monday = 1 .. Sunday = 7.
271#[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    /// Monday — ISO 1.
276    Mon = 1,
277    /// Tuesday — ISO 2.
278    Tue = 2,
279    /// Wednesday — ISO 3.
280    Wed = 3,
281    /// Thursday — ISO 4.
282    Thu = 4,
283    /// Friday — ISO 5.
284    Fri = 5,
285    /// Saturday — ISO 6.
286    Sat = 6,
287    /// Sunday — ISO 7.
288    Sun = 7,
289}
290
291impl Weekday {
292    /// The ISO 8601 weekday number: Monday = 1 .. Sunday = 7.
293    ///
294    /// ```
295    /// use fasti::Weekday;
296    /// assert_eq!(Weekday::Mon.get(), 1);
297    /// assert_eq!(Weekday::Sun.get(), 7);
298    /// ```
299    #[must_use]
300    pub const fn get(self) -> u8 {
301        self as u8
302    }
303
304    /// Construct a [`Weekday`] from an ISO weekday number (`1..=7`),
305    /// refusing anything outside that range.
306    ///
307    /// ```
308    /// use fasti::{Weekday, TimeError};
309    /// assert_eq!(Weekday::try_from_u8(1)?, Weekday::Mon);
310    /// assert_eq!(Weekday::try_from_u8(7)?, Weekday::Sun);
311    /// assert_eq!(Weekday::try_from_u8(0), Err(TimeError::WeekdayOutOfRange));
312    /// assert_eq!(Weekday::try_from_u8(8), Err(TimeError::WeekdayOutOfRange));
313    /// # Ok::<(), fasti::TimeError>(())
314    /// ```
315    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// ---- Ordinal ------------------------------------------------------------
345
346/// An ordinal position within a month for nth-weekday rules. "First" =
347/// first occurrence, "Fifth" = fifth (which may not exist in every month).
348#[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    /// 1st occurrence.
353    First = 1,
354    /// 2nd occurrence.
355    Second = 2,
356    /// 3rd occurrence.
357    Third = 3,
358    /// 4th occurrence.
359    Fourth = 4,
360    /// 5th occurrence (may not exist in all month/weekday pairs).
361    Fifth = 5,
362}
363
364impl Ordinal {
365    /// The underlying 1-based discriminant.
366    ///
367    /// ```
368    /// use fasti::Ordinal;
369    /// assert_eq!(Ordinal::Third.get(), 3);
370    /// ```
371    #[must_use]
372    pub const fn get(self) -> u8 {
373        self as u8
374    }
375
376    /// Construct an [`Ordinal`] from a 1-based value, refusing anything
377    /// outside `1..=5`.
378    ///
379    /// ```
380    /// use fasti::{Ordinal, TimeError};
381    /// assert_eq!(Ordinal::try_from_u8(3)?, Ordinal::Third);
382    /// assert_eq!(Ordinal::try_from_u8(0), Err(TimeError::OrdinalOutOfRange));
383    /// assert_eq!(Ordinal::try_from_u8(6), Err(TimeError::OrdinalOutOfRange));
384    /// # Ok::<(), fasti::TimeError>(())
385    /// ```
386    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// ---- Date ---------------------------------------------------------------
412
413/// A calendar date in the supported range 1901-01-01..=2199-12-31.
414///
415/// Internally a [`u32`] count of days since 1901-01-01 (inclusive).
416///
417/// ```
418/// use fasti::{Date, Month, Weekday};
419///
420/// let d = Date::from_ymd(2026, Month::Jul, 4)?;
421/// assert_eq!(d.year().get(), 2026);
422/// assert_eq!(d.month(), Month::Jul);
423/// assert_eq!(d.day(), 4);
424/// assert_eq!(d.weekday(), Weekday::Sat);
425/// # Ok::<(), fasti::TimeError>(())
426/// ```
427#[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    /// The earliest representable date, 1901-01-01.
434    pub const MIN: Self = Self(0);
435
436    /// The latest representable date, 2199-12-31.
437    pub const MAX: Self = Self(MAX_SERIAL);
438
439    /// Construct a [`Date`] from year, month, and day. Refuses
440    /// out-of-range years, zero days, and days exceeding the month length
441    /// (accounting for leap years).
442    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    /// Construct a [`Date`] from compile-time year, month, and day
462    /// literals; an invalid date is a compile error, not a runtime panic.
463    ///
464    /// ```
465    /// use fasti::{Date, Month};
466    /// const CARTER_FUNERAL: Date = Date::literal(2025, Month::Jan, 9);
467    /// assert_eq!(CARTER_FUNERAL.year().get(), 2025);
468    /// ```
469    ///
470    /// ```compile_fail
471    /// use fasti::{Date, Month};
472    /// // Compile error: Feb 30 does not exist.
473    /// const BAD: Date = Date::literal(2025, Month::Feb, 30);
474    /// ```
475    #[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            // Reached only at const-eval time — a compile error, not a runtime panic.
481            Err(_) => panic!("Date::literal: invalid year/month/day"),
482        }
483    }
484
485    /// Construct a [`Date`] from a serial day count relative to
486    /// 1901-01-01 (serial 0). Refuses values outside the supported range.
487    ///
488    /// ```
489    /// use fasti::{Date, Month};
490    /// assert_eq!(Date::from_serial(0)?, Date::from_ymd(1901, Month::Jan, 1)?);
491    /// # Ok::<(), fasti::TimeError>(())
492    /// ```
493    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    /// The underlying serial: days since 1901-01-01 inclusive (serial 0).
502    #[must_use]
503    pub const fn serial(self) -> u32 {
504        self.0
505    }
506
507    /// The [`Year`] component.
508    #[must_use]
509    pub const fn year(self) -> Year {
510        // A Gregorian cycle is 146 097 days over 400 years, so the
511        // serial names its own year index to within one (pinned
512        // exhaustively by `year_is_correct_for_every_serial`); the
513        // loops absorb the remainder. The upward loop needs no bounds
514        // check: `CUMULATIVE`'s final entry is one past the last valid
515        // day, so it never compares `<=` a valid serial. `serial * 400`
516        // peaks below 44 million — no overflow.
517        let serial = self.0;
518        // `serial * 400 / 146_097 < NUM_YEARS` for every valid serial, so
519        // the `u32 -> u16` narrowing is safe and the index in bounds.
520        #[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    /// Decompose into `(year, month, day-of-month)`.
532    ///
533    /// ```
534    /// use fasti::{Date, Month};
535    /// let d = Date::from_ymd(2026, Month::Jul, 4)?;
536    /// let (y, m, dom) = d.to_ymd();
537    /// assert_eq!((y.get(), m, dom), (2026, Month::Jul, 4));
538    /// # Ok::<(), fasti::TimeError>(())
539    /// ```
540    #[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        // `doy - offsets[m] + 1` is bounded 1..=31, so the `as u8` narrowing is safe.
569        #[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    /// The [`Month`] component.
575    ///
576    /// ```
577    /// use fasti::{Date, Month};
578    /// assert_eq!(Date::from_ymd(2026, Month::Jul, 4)?.month(), Month::Jul);
579    /// # Ok::<(), fasti::TimeError>(())
580    /// ```
581    #[must_use]
582    pub const fn month(self) -> Month {
583        let (_, m, _) = self.to_ymd();
584        m
585    }
586
587    /// The day-of-month component, `1..=31`.
588    ///
589    /// ```
590    /// use fasti::{Date, Month};
591    /// assert_eq!(Date::from_ymd(2026, Month::Jul, 4)?.day(), 4);
592    /// # Ok::<(), fasti::TimeError>(())
593    /// ```
594    #[must_use]
595    pub const fn day(self) -> u8 {
596        let (_, _, d) = self.to_ymd();
597        d
598    }
599
600    /// The 1-indexed day of the year, `1..=366`.
601    ///
602    /// ```
603    /// use fasti::{Date, Month};
604    /// assert_eq!(Date::from_ymd(2024, Month::Jan, 1)?.day_of_year(), 1);
605    /// assert_eq!(Date::from_ymd(2024, Month::Dec, 31)?.day_of_year(), 366); // leap
606    /// assert_eq!(Date::from_ymd(2025, Month::Dec, 31)?.day_of_year(), 365);
607    /// # Ok::<(), fasti::TimeError>(())
608    /// ```
609    #[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        // Result is 1..=366, so the `u32 -> u16` narrowing is safe.
614        #[allow(clippy::cast_possible_truncation)]
615        let doy = (self.0 - CUMULATIVE[year_idx] + 1) as u16;
616        doy
617    }
618
619    /// The day of the week.
620    ///
621    /// ```
622    /// use fasti::{Date, Month, Weekday};
623    /// assert_eq!(
624    ///     Date::from_ymd(2026, Month::Jul, 4)?.weekday(),
625    ///     Weekday::Sat,
626    /// );
627    /// # Ok::<(), fasti::TimeError>(())
628    /// ```
629    #[must_use]
630    pub const fn weekday(self) -> Weekday {
631        // Serial 0 (1901-01-01) is Tuesday, so `(serial + 1) % 7` gives 0..=6 keyed Mon..Sun.
632        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    /// Add `n` days, returning [`TimeError::DateOutOfRange`] if the result
644    /// would fall outside the supported range.
645    ///
646    /// ```
647    /// use fasti::{Date, Month, TimeError};
648    /// let d = Date::from_ymd(2026, Month::Feb, 28)?;
649    /// assert_eq!(d.add_days(1)?, Date::from_ymd(2026, Month::Mar, 1)?);
650    /// assert_eq!(Date::MAX.add_days(1), Err(TimeError::DateOutOfRange));
651    /// # Ok::<(), fasti::TimeError>(())
652    /// ```
653    pub const fn add_days(self, n: i32) -> Result<Self, TimeError> {
654        // Widen to `i64` so any `u32 + i32` sum fits and can be bounds-checked before narrowing.
655        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        // `target` is in `0..=MAX_SERIAL`, so the `i64 -> u32` narrowing is safe.
660        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
661        let serial = target as u32;
662        Ok(Self(serial))
663    }
664
665    /// Signed difference `self - other` in days. Returns a negative value
666    /// when `self` precedes `other`.
667    ///
668    /// ```
669    /// use fasti::{Date, Month};
670    /// let a = Date::from_ymd(2026, Month::Jan, 1)?;
671    /// let b = Date::from_ymd(2026, Month::Jan, 31)?;
672    /// assert_eq!(b.days_since(a), 30);
673    /// assert_eq!(a.days_since(b), -30);
674    /// # Ok::<(), fasti::TimeError>(())
675    /// ```
676    #[must_use]
677    pub const fn days_since(self, other: Self) -> i32 {
678        // Widen to `i64` to avoid `u32 - u32` underflow.
679        let diff = self.0 as i64 - other.0 as i64;
680        // Bounded by `|diff| <= MAX_SERIAL`; `i64 -> i32` is safe.
681        #[allow(clippy::cast_possible_truncation)]
682        let diff_i32 = diff as i32;
683        diff_i32
684    }
685
686    /// Add `n` calendar months, clamping the day-of-month to the new
687    /// month's length. Matches `QuantLib`'s `Date::advance` semantics.
688    /// Returns [`TimeError::DateOutOfRange`] if the result is out of range.
689    ///
690    /// ```
691    /// use fasti::{Date, Month};
692    /// let jan31 = Date::from_ymd(2026, Month::Jan, 31)?;
693    /// assert_eq!(jan31.add_months(1)?, Date::from_ymd(2026, Month::Feb, 28)?);
694    /// let feb28_2024 = Date::from_ymd(2024, Month::Feb, 28)?;
695    /// assert_eq!(feb28_2024.add_months(12)?, Date::from_ymd(2025, Month::Feb, 28)?);
696    /// let apr30 = Date::from_ymd(2026, Month::Apr, 30)?;
697    /// assert_eq!(apr30.add_months(-2)?, Date::from_ymd(2026, Month::Feb, 28)?);
698    /// # Ok::<(), fasti::TimeError>(())
699    /// ```
700    pub const fn add_months(self, n: i32) -> Result<Self, TimeError> {
701        let (year, month, day) = self.to_ymd();
702        // Zero-based month index in `i32` — all in-range (year, month) pairs fit.
703        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        // Euclidean div/rem stay correct if `new_total` is negative.
708        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        // `target_year_i32` is bounded to 1901..=2199, a `u16` range; narrowing is safe.
714        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
715        let new_year_u16 = target_year_i32 as u16;
716        // `new_month_idx` is bounded to 0..=11, a `u8` range.
717        #[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    /// Add `n` calendar years, clamping Feb 29 to Feb 28 when the
734    /// target year is not a leap year.
735    ///
736    /// ```
737    /// use fasti::{Date, Month};
738    /// let leap_day = Date::from_ymd(2024, Month::Feb, 29)?;
739    /// // 2025 is not a leap year — Feb 29 clamps to Feb 28.
740    /// assert_eq!(leap_day.add_years(1)?, Date::from_ymd(2025, Month::Feb, 28)?);
741    /// // 2028 is a leap year — Feb 29 preserved.
742    /// assert_eq!(leap_day.add_years(4)?, Date::from_ymd(2028, Month::Feb, 29)?);
743    /// # Ok::<(), fasti::TimeError>(())
744    /// ```
745    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    /// `self + period`, preserving end-of-month when `end_of_month`
753    /// is set and `self` is itself the last day of its month.
754    ///
755    /// This is the crate's one stepping rule: [`Calendar::advance`](crate::Calendar::advance)
756    /// rolls its result onto a business day, and
757    /// [`Generation::step`](crate::Generation::step) scales the tenor
758    /// before calling it. The flag is inert for `Days` and `Weeks`
759    /// periods, where end-of-month has no meaning. Semantics match
760    /// `QuantLib`'s `Date::advance`.
761    ///
762    /// ```
763    /// use fasti::{Date, Month, Period};
764    /// let feb_end = Date::from_ymd(2025, Month::Feb, 28)?;
765    /// assert_eq!(feb_end.advance(Period::Months(1), false)?, Date::from_ymd(2025, Month::Mar, 28)?);
766    /// assert_eq!(feb_end.advance(Period::Months(1), true)?, Date::from_ymd(2025, Month::Mar, 31)?);
767    /// # Ok::<(), fasti::TimeError>(())
768    /// ```
769    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    /// The first day of `self`'s month.
784    ///
785    /// ```
786    /// use fasti::{Date, Month};
787    /// let d = Date::from_ymd(2024, Month::Feb, 10)?;
788    /// assert_eq!(d.start_of_month(), Date::from_ymd(2024, Month::Feb, 1)?);
789    /// # Ok::<(), fasti::TimeError>(())
790    /// ```
791    #[must_use]
792    pub const fn start_of_month(self) -> Self {
793        Self(self.0 - (self.day() as u32 - 1))
794    }
795
796    /// `true` iff `self` is the first day of its month.
797    #[must_use]
798    pub const fn is_start_of_month(self) -> bool {
799        self.day() == 1
800    }
801
802    /// The first `weekday` on or after `self`.
803    ///
804    /// ```
805    /// use fasti::{Date, Month, Weekday};
806    /// let wed = Date::from_ymd(2025, Month::Jan, 1)?; // a Wednesday
807    /// assert_eq!(wed.next_weekday(Weekday::Wed)?, wed); // already Wednesday
808    /// assert_eq!(wed.next_weekday(Weekday::Mon)?, Date::from_ymd(2025, Month::Jan, 6)?);
809    /// # Ok::<(), fasti::TimeError>(())
810    /// ```
811    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    /// The `n`th `weekday` of `month` in `year` — "third Monday of
817    /// January", the shape `QuantLib` spells `Date::nthWeekday`.
818    ///
819    /// Returns [`TimeError::DayOutOfRange`] when that occurrence does
820    /// not exist, which only a fifth occurrence can fail to.
821    ///
822    /// ```
823    /// use fasti::{Date, Month, Ordinal, Weekday, Year};
824    /// // MLK Day 2026: third Monday of January.
825    /// assert_eq!(
826    ///     Date::nth_weekday(Ordinal::Third, Weekday::Mon, Month::Jan, Year::new(2026)?)?,
827    ///     Date::from_ymd(2026, Month::Jan, 19)?,
828    /// );
829    /// // February 2026 has only four Sundays.
830    /// assert!(Date::nth_weekday(Ordinal::Fifth, Weekday::Sun, Month::Feb, Year::new(2026)?).is_err());
831    /// # Ok::<(), fasti::TimeError>(())
832    /// ```
833    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    /// The last day of `self`'s month.
849    ///
850    /// ```
851    /// use fasti::{Date, Month};
852    /// let d = Date::from_ymd(2024, Month::Feb, 10)?;
853    /// assert_eq!(d.end_of_month(), Date::from_ymd(2024, Month::Feb, 29)?);
854    /// # Ok::<(), fasti::TimeError>(())
855    /// ```
856    #[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        // Serial arithmetic — month start plus (length - 1) — avoids an unreachable `from_ymd` error path.
861        let month_start = self.0 - (self.day() as u32 - 1);
862        Self(month_start + last as u32 - 1)
863    }
864
865    /// `true` iff `self` is the last day of its month.
866    ///
867    /// ```
868    /// use fasti::{Date, Month};
869    /// assert!(Date::from_ymd(2024, Month::Feb, 29)?.is_end_of_month()); // leap
870    /// assert!(Date::from_ymd(2025, Month::Feb, 28)?.is_end_of_month()); // non-leap
871    /// assert!(!Date::from_ymd(2025, Month::Feb, 27)?.is_end_of_month());
872    /// # Ok::<(), fasti::TimeError>(())
873    /// ```
874    #[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
881/// Date-aware operations on a half-open range `start..end`.
882///
883/// `fasti` spells every date interval — accrual periods, schedule
884/// periods, calendar queries — as a [`Range<Date>`](core::ops::Range)
885/// rather than a bespoke type; this trait is the vocabulary that goes
886/// with it.
887///
888/// ```
889/// use fasti::{Date, DateRange, Month};
890/// let jan = Date::from_ymd(2026, Month::Jan, 1)?..Date::from_ymd(2026, Month::Feb, 1)?;
891/// assert_eq!(jan.days(), 31);
892/// assert_eq!(jan.dates().count(), 31);
893/// # Ok::<(), fasti::TimeError>(())
894/// ```
895pub trait DateRange: Sized {
896    /// Elapsed days, signed by direction.
897    fn days(&self) -> i64;
898
899    /// The overlap with `other`, if the two share any days. Ranges
900    /// that merely touch at a boundary share none.
901    fn intersect(&self, other: &Self) -> Option<Self>;
902
903    /// Every date in the range, ascending; the end bound is excluded.
904    /// The iterator copies the bounds, so it outlives the range.
905    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        // Both bounds are valid dates, so every serial between them is too.
920        (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        // Decompose once instead of three separate year lookups.
927        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    /// Parse a strict ISO-8601 `YYYY-MM-DD` date — the exact format
936    /// [`Display`](fmt::Display) produces. Malformed strings return
937    /// [`TimeError::InvalidDateString`]; range errors match [`Date::from_ymd`].
938    ///
939    /// ```
940    /// use fasti::{Date, Month, TimeError};
941    /// let d: Date = "2026-07-04".parse()?;
942    /// assert_eq!(d, Date::from_ymd(2026, Month::Jul, 4)?);
943    /// // Round trip through Display.
944    /// assert_eq!("2026-07-04".parse::<Date>()?.to_string(), "2026-07-04");
945    /// // Malformed strings are rejected.
946    /// assert_eq!("2026-7-4".parse::<Date>(), Err(TimeError::InvalidDateString));
947    /// // Well-formed but nonexistent dates surface the range error.
948    /// assert_eq!("2026-02-30".parse::<Date>(), Err(TimeError::DayOutOfRange));
949    /// # Ok::<(), fasti::TimeError>(())
950    /// ```
951    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        // Both values are at most 99, so the u16 -> u8 narrowing is exact.
969        #[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
977/// Step a [`Date`] forward by a [`Period`]. Returns
978/// [`TimeError::DateOutOfRange`] for out-of-range results; `Months`/`Years`
979/// clamp the day-of-month (see [`Date::add_months`]).
980///
981/// ```
982/// use fasti::{Date, Month, Period};
983/// let d = Date::from_ymd(2026, Month::Jan, 15)?;
984/// assert_eq!((d + Period::Months(6))?, Date::from_ymd(2026, Month::Jul, 15)?);
985/// assert_eq!((d + Period::Years(1))?, Date::from_ymd(2027, Month::Jan, 15)?);
986/// assert_eq!((d + Period::Days(7))?, Date::from_ymd(2026, Month::Jan, 22)?);
987/// // Negative periods step backward.
988/// assert_eq!((d + (-Period::Months(1)))?, Date::from_ymd(2025, Month::Dec, 15)?);
989/// # Ok::<(), fasti::TimeError>(())
990/// ```
991impl 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
1007/// Step a [`Date`] backward by a [`Period`]. Uses [`Period::checked_neg`],
1008/// surfacing `i32::MIN` overflow as [`TimeError::DateOutOfRange`].
1009///
1010/// ```
1011/// use fasti::{Date, Month, Period};
1012/// let d = Date::from_ymd(2026, Month::Jul, 15)?;
1013/// assert_eq!((d - Period::Months(6))?, Date::from_ymd(2026, Month::Jan, 15)?);
1014/// # Ok::<(), fasti::TimeError>(())
1015/// ```
1016impl Sub<Period> for Date {
1017    type Output = Result<Self, TimeError>;
1018
1019    fn sub(self, period: Period) -> Self::Output {
1020        // `+` inside `Sub` is the deliberate factoring: delegate to `Add` after negating.
1021        #[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// ---- Tests --------------------------------------------------------------
1030
1031#[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        // 2000 is a leap year (divisible by 400).
1088        assert!(Date::from_ymd(2000, Month::Feb, 29).is_ok());
1089        // 2100 is NOT a leap year (divisible by 100, not 400).
1090        assert_eq!(
1091            Date::from_ymd(2100, Month::Feb, 29),
1092            Err(TimeError::DayOutOfRange)
1093        );
1094        // 2024 is a leap year (divisible by 4, not 100).
1095        assert!(Date::from_ymd(2024, Month::Feb, 29).is_ok());
1096        // 2026 is not a leap year.
1097        assert_eq!(
1098            Date::from_ymd(2026, Month::Feb, 29),
1099            Err(TimeError::DayOutOfRange)
1100        );
1101    }
1102
1103    #[test]
1104    fn known_weekdays() {
1105        // Anchors independently verifiable.
1106        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        // Exhaustive: walk the whole range once, tracking the expected
1131        // year incrementally, so `year`'s estimate-plus-correction is
1132        // pinned at every day — boundaries included — along with the
1133        // doc comment's claim that the 400-year-cycle estimate is off
1134        // by at most one year index (which also keeps the unclamped
1135        // table index in bounds).
1136        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, // leap
1165        );
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",    // not zero-padded
1231            "26-07-04",    // two-digit year
1232            "2026/07/04",  // wrong separator
1233            "2026-07-04T", // trailing content
1234            " 2026-07-04", // leading whitespace
1235            "2026-07-04 ", // trailing whitespace
1236            "+026-07-04",  // sign
1237            "2026-0a-04",  // non-digit
1238            "٢٠٢٦-07-04",  // non-ASCII digits
1239        ] {
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    // ---- property tests ------------------------------------------------
1274
1275    /// Strategy: uniformly sample a valid (year, month, day) in range.
1276    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        /// Every date's `Display` output parses back to the same date.
1394        #[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        /// `add_months(n)` then `add_months(-n)` round-trips exactly for day ≤ 28 (never clamped).
1402        #[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        /// `add_months(n)` equals `add_years(n/12)` then `add_months(n%12)` for day ≤ 28;
1419        /// the year range keeps intermediates in range.
1420        #[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        /// `add_months` never yields a day past the target month's length.
1438        #[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        /// `end_of_month` is idempotent.
1452        #[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        /// `date + Period::Days(n)` matches `date.add_days(n)`.
1460        #[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        /// `date + Period::Weeks(n)` matches `date.add_days(n * 7)`
1470        /// (modulo overflow on the multiplication).
1471        #[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        /// `date + Period::Months(n)` matches `date.add_months(n)`.
1481        #[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        /// `date + Period::Years(n)` matches `date.add_years(n)`.
1491        #[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        /// `(date - period)` equals `(date + (-period))` for every
1501        /// non-`i32::MIN` length.
1502        #[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    // ---- example-based tests for month/year arithmetic -----------------
1520
1521    #[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        // Leap year: Jan 31 2024 + 1M → Feb 29.
1529        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        // May 31 + 1M → Jun 30 (no May 31 + 1M = Jun 31).
1535        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    /// Clamp-on-add-months is not composable across end-of-month dates:
1543    /// `Jan 31 → Feb 28 → Mar 28` differs from `Jan 31 → Mar 31`.
1544    #[test]
1545    fn add_months_clamp_is_not_composable_across_eom() {
1546        let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1547        // Two single-month hops: 31 → 28 → 28. Day-of-month sticks at 28.
1548        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        // One two-month hop: 31 → 31 (March has 31 days).
1551        let single_hop = jan31.add_months(2).unwrap();
1552        assert_eq!(single_hop, Date::from_ymd(2026, Month::Mar, 31).unwrap());
1553        // The two paths disagree by 3 days.
1554        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        // Jan 15 2024 → Jan 31 2024.
1598        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        // Feb 10 2024 (leap) → Feb 29 2024.
1604        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        // Feb 10 2025 (non-leap) → Feb 28 2025.
1610        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        // Dec 31 2199 (max) is already EoM.
1616        assert_eq!(Date::MAX.end_of_month(), Date::MAX);
1617        // Jan 1 1901 (min) → Jan 31 1901.
1618        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        // Thu Jan 1 2026.
1668        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        // MLK Day: third Monday of January 2026.
1680        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        // Thanksgiving: fourth Thursday of November 2026.
1685        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        // Feb 2026 has four Sundays, not five.
1690        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        // Empty and reversed ranges are both empty.
1707        assert_eq!((jan.start..jan.start).dates().count(), 0);
1708        assert_eq!((jan.end..jan.start).dates().count(), 0);
1709    }
1710
1711    proptest! {
1712        /// Every date in a range is contained by it, and the count
1713        /// matches the day span.
1714        #[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        /// `nth_weekday` lands on the requested weekday and month.
1723        #[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        // Sub on a negative period steps forward.
1764        assert_eq!(
1765            (start - (-crate::Period::Months(6))).unwrap(),
1766            Date::from_ymd(2027, Month::Jan, 15).unwrap(),
1767        );
1768    }
1769}