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        // Largest `idx` with `CUMULATIVE[idx] <= serial`; `lo`/`hi` are `u16` so the final add needs no cast.
511        let serial = self.0;
512        let mut lo: u16 = 0;
513        let mut hi: u16 = NUM_YEARS;
514        while hi - lo > 1 {
515            let mid = lo + (hi - lo) / 2;
516            if CUMULATIVE[mid as usize] <= serial {
517                lo = mid;
518            } else {
519                hi = mid;
520            }
521        }
522        Year(EPOCH_YEAR + lo)
523    }
524
525    /// Decompose into `(year, month, day-of-month)`.
526    ///
527    /// ```
528    /// use fasti::{Date, Month};
529    /// let d = Date::from_ymd(2026, Month::Jul, 4)?;
530    /// let (y, m, dom) = d.to_ymd();
531    /// assert_eq!((y.get(), m, dom), (2026, Month::Jul, 4));
532    /// # Ok::<(), fasti::TimeError>(())
533    /// ```
534    #[must_use]
535    pub const fn to_ymd(self) -> (Year, Month, u8) {
536        let y = self.year();
537        let year_idx = (y.0 - EPOCH_YEAR) as usize;
538        let doy = self.0 - CUMULATIVE[year_idx];
539        let offsets = if y.is_leap() {
540            &MONTH_OFFSETS_LEAP
541        } else {
542            &MONTH_OFFSETS_NONLEAP
543        };
544        let mut m: usize = 0;
545        while m + 1 < 13 && offsets[m + 1] <= doy {
546            m += 1;
547        }
548        let month = match m {
549            0 => Month::Jan,
550            1 => Month::Feb,
551            2 => Month::Mar,
552            3 => Month::Apr,
553            4 => Month::May,
554            5 => Month::Jun,
555            6 => Month::Jul,
556            7 => Month::Aug,
557            8 => Month::Sep,
558            9 => Month::Oct,
559            10 => Month::Nov,
560            _ => Month::Dec,
561        };
562        // `doy - offsets[m] + 1` is bounded 1..=31, so the `as u8` narrowing is safe.
563        #[allow(clippy::cast_possible_truncation)]
564        let day_of_month = (doy - offsets[m] + 1) as u8;
565        (y, month, day_of_month)
566    }
567
568    /// The [`Month`] component.
569    ///
570    /// ```
571    /// use fasti::{Date, Month};
572    /// assert_eq!(Date::from_ymd(2026, Month::Jul, 4)?.month(), Month::Jul);
573    /// # Ok::<(), fasti::TimeError>(())
574    /// ```
575    #[must_use]
576    pub const fn month(self) -> Month {
577        let (_, m, _) = self.to_ymd();
578        m
579    }
580
581    /// The day-of-month component, `1..=31`.
582    ///
583    /// ```
584    /// use fasti::{Date, Month};
585    /// assert_eq!(Date::from_ymd(2026, Month::Jul, 4)?.day(), 4);
586    /// # Ok::<(), fasti::TimeError>(())
587    /// ```
588    #[must_use]
589    pub const fn day(self) -> u8 {
590        let (_, _, d) = self.to_ymd();
591        d
592    }
593
594    /// The 1-indexed day of the year, `1..=366`.
595    ///
596    /// ```
597    /// use fasti::{Date, Month};
598    /// assert_eq!(Date::from_ymd(2024, Month::Jan, 1)?.day_of_year(), 1);
599    /// assert_eq!(Date::from_ymd(2024, Month::Dec, 31)?.day_of_year(), 366); // leap
600    /// assert_eq!(Date::from_ymd(2025, Month::Dec, 31)?.day_of_year(), 365);
601    /// # Ok::<(), fasti::TimeError>(())
602    /// ```
603    #[must_use]
604    pub const fn day_of_year(self) -> u16 {
605        let y = self.year();
606        let year_idx = (y.0 - EPOCH_YEAR) as usize;
607        // Result is 1..=366, so the `u32 -> u16` narrowing is safe.
608        #[allow(clippy::cast_possible_truncation)]
609        let doy = (self.0 - CUMULATIVE[year_idx] + 1) as u16;
610        doy
611    }
612
613    /// The day of the week.
614    ///
615    /// ```
616    /// use fasti::{Date, Month, Weekday};
617    /// assert_eq!(
618    ///     Date::from_ymd(2026, Month::Jul, 4)?.weekday(),
619    ///     Weekday::Sat,
620    /// );
621    /// # Ok::<(), fasti::TimeError>(())
622    /// ```
623    #[must_use]
624    pub const fn weekday(self) -> Weekday {
625        // Serial 0 (1901-01-01) is Tuesday, so `(serial + 1) % 7` gives 0..=6 keyed Mon..Sun.
626        match (self.0 + 1) % 7 {
627            0 => Weekday::Mon,
628            1 => Weekday::Tue,
629            2 => Weekday::Wed,
630            3 => Weekday::Thu,
631            4 => Weekday::Fri,
632            5 => Weekday::Sat,
633            _ => Weekday::Sun,
634        }
635    }
636
637    /// Add `n` days, returning [`TimeError::DateOutOfRange`] if the result
638    /// would fall outside the supported range.
639    ///
640    /// ```
641    /// use fasti::{Date, Month, TimeError};
642    /// let d = Date::from_ymd(2026, Month::Feb, 28)?;
643    /// assert_eq!(d.add_days(1)?, Date::from_ymd(2026, Month::Mar, 1)?);
644    /// assert_eq!(Date::MAX.add_days(1), Err(TimeError::DateOutOfRange));
645    /// # Ok::<(), fasti::TimeError>(())
646    /// ```
647    pub const fn add_days(self, n: i32) -> Result<Self, TimeError> {
648        // Widen to `i64` so any `u32 + i32` sum fits and can be bounds-checked before narrowing.
649        let target = self.0 as i64 + n as i64;
650        if target < 0 || target > MAX_SERIAL as i64 {
651            return Err(TimeError::DateOutOfRange);
652        }
653        // `target` is in `0..=MAX_SERIAL`, so the `i64 -> u32` narrowing is safe.
654        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
655        let serial = target as u32;
656        Ok(Self(serial))
657    }
658
659    /// Signed difference `self - other` in days. Returns a negative value
660    /// when `self` precedes `other`.
661    ///
662    /// ```
663    /// use fasti::{Date, Month};
664    /// let a = Date::from_ymd(2026, Month::Jan, 1)?;
665    /// let b = Date::from_ymd(2026, Month::Jan, 31)?;
666    /// assert_eq!(b.days_since(a), 30);
667    /// assert_eq!(a.days_since(b), -30);
668    /// # Ok::<(), fasti::TimeError>(())
669    /// ```
670    #[must_use]
671    pub const fn days_since(self, other: Self) -> i32 {
672        // Widen to `i64` to avoid `u32 - u32` underflow.
673        let diff = self.0 as i64 - other.0 as i64;
674        // Bounded by `|diff| <= MAX_SERIAL`; `i64 -> i32` is safe.
675        #[allow(clippy::cast_possible_truncation)]
676        let diff_i32 = diff as i32;
677        diff_i32
678    }
679
680    /// Add `n` calendar months, clamping the day-of-month to the new
681    /// month's length. Matches `QuantLib`'s `Date::advance` semantics.
682    /// Returns [`TimeError::DateOutOfRange`] if the result is out of range.
683    ///
684    /// ```
685    /// use fasti::{Date, Month};
686    /// let jan31 = Date::from_ymd(2026, Month::Jan, 31)?;
687    /// assert_eq!(jan31.add_months(1)?, Date::from_ymd(2026, Month::Feb, 28)?);
688    /// let feb28_2024 = Date::from_ymd(2024, Month::Feb, 28)?;
689    /// assert_eq!(feb28_2024.add_months(12)?, Date::from_ymd(2025, Month::Feb, 28)?);
690    /// let apr30 = Date::from_ymd(2026, Month::Apr, 30)?;
691    /// assert_eq!(apr30.add_months(-2)?, Date::from_ymd(2026, Month::Feb, 28)?);
692    /// # Ok::<(), fasti::TimeError>(())
693    /// ```
694    pub const fn add_months(self, n: i32) -> Result<Self, TimeError> {
695        let (year, month, day) = self.to_ymd();
696        // Zero-based month index in `i32` — all in-range (year, month) pairs fit.
697        let total_months = year.get() as i32 * 12 + (month.get() as i32 - 1);
698        let Some(new_total) = total_months.checked_add(n) else {
699            return Err(TimeError::DateOutOfRange);
700        };
701        // Euclidean div/rem stay correct if `new_total` is negative.
702        let target_year_i32 = new_total.div_euclid(12);
703        let new_month_idx = new_total.rem_euclid(12);
704        if target_year_i32 < Year::MIN.get() as i32 || target_year_i32 > Year::MAX.get() as i32 {
705            return Err(TimeError::DateOutOfRange);
706        }
707        // `target_year_i32` is bounded to 1901..=2199, a `u16` range; narrowing is safe.
708        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
709        let new_year_u16 = target_year_i32 as u16;
710        // `new_month_idx` is bounded to 0..=11, a `u8` range.
711        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
712        let new_month = match Month::try_from_u8((new_month_idx as u8) + 1) {
713            Ok(found) => found,
714            Err(err) => return Err(err),
715        };
716        let target_year = match Year::new(new_year_u16) {
717            Ok(found) => found,
718            Err(err) => return Err(err),
719        };
720        let clamped_day = {
721            let len = new_month.length(target_year);
722            if day > len { len } else { day }
723        };
724        Self::from_ymd(new_year_u16, new_month, clamped_day)
725    }
726
727    /// Add `n` calendar years, clamping Feb 29 to Feb 28 when the
728    /// target year is not a leap year.
729    ///
730    /// ```
731    /// use fasti::{Date, Month};
732    /// let leap_day = Date::from_ymd(2024, Month::Feb, 29)?;
733    /// // 2025 is not a leap year — Feb 29 clamps to Feb 28.
734    /// assert_eq!(leap_day.add_years(1)?, Date::from_ymd(2025, Month::Feb, 28)?);
735    /// // 2028 is a leap year — Feb 29 preserved.
736    /// assert_eq!(leap_day.add_years(4)?, Date::from_ymd(2028, Month::Feb, 29)?);
737    /// # Ok::<(), fasti::TimeError>(())
738    /// ```
739    pub const fn add_years(self, n: i32) -> Result<Self, TimeError> {
740        let Some(months) = n.checked_mul(12) else {
741            return Err(TimeError::DateOutOfRange);
742        };
743        self.add_months(months)
744    }
745
746    /// `self + period`, preserving end-of-month when `end_of_month`
747    /// is set and `self` is itself the last day of its month.
748    ///
749    /// This is the crate's one stepping rule: [`Calendar::advance`](crate::Calendar::advance)
750    /// rolls its result onto a business day, and
751    /// [`Generation::step`](crate::Generation::step) scales the tenor
752    /// before calling it. The flag is inert for `Days` and `Weeks`
753    /// periods, where end-of-month has no meaning. Semantics match
754    /// `QuantLib`'s `Date::advance`.
755    ///
756    /// ```
757    /// use fasti::{Date, Month, Period};
758    /// let feb_end = Date::from_ymd(2025, Month::Feb, 28)?;
759    /// assert_eq!(feb_end.advance(Period::Months(1), false)?, Date::from_ymd(2025, Month::Mar, 28)?);
760    /// assert_eq!(feb_end.advance(Period::Months(1), true)?, Date::from_ymd(2025, Month::Mar, 31)?);
761    /// # Ok::<(), fasti::TimeError>(())
762    /// ```
763    pub fn advance(self, period: Period, end_of_month: bool) -> Result<Self, TimeError> {
764        let stepped = (self + period)?;
765        Ok(
766            if end_of_month
767                && self.is_end_of_month()
768                && matches!(period, Period::Months(_) | Period::Years(_))
769            {
770                stepped.end_of_month()
771            } else {
772                stepped
773            },
774        )
775    }
776
777    /// The first day of `self`'s month.
778    ///
779    /// ```
780    /// use fasti::{Date, Month};
781    /// let d = Date::from_ymd(2024, Month::Feb, 10)?;
782    /// assert_eq!(d.start_of_month(), Date::from_ymd(2024, Month::Feb, 1)?);
783    /// # Ok::<(), fasti::TimeError>(())
784    /// ```
785    #[must_use]
786    pub const fn start_of_month(self) -> Self {
787        Self(self.0 - (self.day() as u32 - 1))
788    }
789
790    /// `true` iff `self` is the first day of its month.
791    #[must_use]
792    pub const fn is_start_of_month(self) -> bool {
793        self.day() == 1
794    }
795
796    /// The first `weekday` on or after `self`.
797    ///
798    /// ```
799    /// use fasti::{Date, Month, Weekday};
800    /// let wed = Date::from_ymd(2025, Month::Jan, 1)?; // a Wednesday
801    /// assert_eq!(wed.next_weekday(Weekday::Wed)?, wed); // already Wednesday
802    /// assert_eq!(wed.next_weekday(Weekday::Mon)?, Date::from_ymd(2025, Month::Jan, 6)?);
803    /// # Ok::<(), fasti::TimeError>(())
804    /// ```
805    pub fn next_weekday(self, weekday: Weekday) -> Result<Self, TimeError> {
806        let delta = (i32::from(weekday.get()) - i32::from(self.weekday().get())).rem_euclid(7);
807        self.add_days(delta)
808    }
809
810    /// The `n`th `weekday` of `month` in `year` — "third Monday of
811    /// January", the shape `QuantLib` spells `Date::nthWeekday`.
812    ///
813    /// Returns [`TimeError::DayOutOfRange`] when that occurrence does
814    /// not exist, which only a fifth occurrence can fail to.
815    ///
816    /// ```
817    /// use fasti::{Date, Month, Ordinal, Weekday, Year};
818    /// // MLK Day 2026: third Monday of January.
819    /// assert_eq!(
820    ///     Date::nth_weekday(Ordinal::Third, Weekday::Mon, Month::Jan, Year::new(2026)?)?,
821    ///     Date::from_ymd(2026, Month::Jan, 19)?,
822    /// );
823    /// // February 2026 has only four Sundays.
824    /// assert!(Date::nth_weekday(Ordinal::Fifth, Weekday::Sun, Month::Feb, Year::new(2026)?).is_err());
825    /// # Ok::<(), fasti::TimeError>(())
826    /// ```
827    pub fn nth_weekday(
828        n: Ordinal,
829        weekday: Weekday,
830        month: Month,
831        year: Year,
832    ) -> Result<Self, TimeError> {
833        let first = Self::from_ymd(year.get(), month, 1)?.next_weekday(weekday)?;
834        let nth = first.add_days(7 * (i32::from(n.get()) - 1))?;
835        if nth.month() == month {
836            Ok(nth)
837        } else {
838            Err(TimeError::DayOutOfRange)
839        }
840    }
841
842    /// The last day of `self`'s month.
843    ///
844    /// ```
845    /// use fasti::{Date, Month};
846    /// let d = Date::from_ymd(2024, Month::Feb, 10)?;
847    /// assert_eq!(d.end_of_month(), Date::from_ymd(2024, Month::Feb, 29)?);
848    /// # Ok::<(), fasti::TimeError>(())
849    /// ```
850    #[must_use]
851    pub const fn end_of_month(self) -> Self {
852        let (year, month, _) = self.to_ymd();
853        let last = month.length(year);
854        // Serial arithmetic — month start plus (length - 1) — avoids an unreachable `from_ymd` error path.
855        let month_start = self.0 - (self.day() as u32 - 1);
856        Self(month_start + last as u32 - 1)
857    }
858
859    /// `true` iff `self` is the last day of its month.
860    ///
861    /// ```
862    /// use fasti::{Date, Month};
863    /// assert!(Date::from_ymd(2024, Month::Feb, 29)?.is_end_of_month()); // leap
864    /// assert!(Date::from_ymd(2025, Month::Feb, 28)?.is_end_of_month()); // non-leap
865    /// assert!(!Date::from_ymd(2025, Month::Feb, 27)?.is_end_of_month());
866    /// # Ok::<(), fasti::TimeError>(())
867    /// ```
868    #[must_use]
869    pub const fn is_end_of_month(self) -> bool {
870        let (year, month, day) = self.to_ymd();
871        day == month.length(year)
872    }
873}
874
875/// Date-aware operations on a half-open range `start..end`.
876///
877/// `fasti` spells every date interval — accrual periods, schedule
878/// periods, calendar queries — as a [`Range<Date>`](core::ops::Range)
879/// rather than a bespoke type; this trait is the vocabulary that goes
880/// with it.
881///
882/// ```
883/// use fasti::{Date, DateRange, Month};
884/// let jan = Date::from_ymd(2026, Month::Jan, 1)?..Date::from_ymd(2026, Month::Feb, 1)?;
885/// assert_eq!(jan.days(), 31);
886/// assert_eq!(jan.dates().count(), 31);
887/// # Ok::<(), fasti::TimeError>(())
888/// ```
889pub trait DateRange: Sized {
890    /// Elapsed days, signed by direction.
891    fn days(&self) -> i64;
892
893    /// The overlap with `other`, if the two share any days. Ranges
894    /// that merely touch at a boundary share none.
895    fn intersect(&self, other: &Self) -> Option<Self>;
896
897    /// Every date in the range, ascending; the end bound is excluded.
898    /// The iterator copies the bounds, so it outlives the range.
899    fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<Self>;
900}
901
902impl DateRange for Range<Date> {
903    fn days(&self) -> i64 {
904        i64::from(self.end.days_since(self.start))
905    }
906
907    fn intersect(&self, other: &Self) -> Option<Self> {
908        let both = self.start.max(other.start)..self.end.min(other.end);
909        (both.start < both.end).then_some(both)
910    }
911
912    fn dates(&self) -> impl DoubleEndedIterator<Item = Date> + use<> {
913        // Both bounds are valid dates, so every serial between them is too.
914        (self.start.serial()..self.end.serial()).filter_map(|s| Date::from_serial(s).ok())
915    }
916}
917
918impl fmt::Display for Date {
919    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
920        // Decompose once instead of three separate year lookups.
921        let (y, m, d) = self.to_ymd();
922        write!(f, "{:04}-{:02}-{:02}", y.get(), m.get(), d)
923    }
924}
925
926impl core::str::FromStr for Date {
927    type Err = TimeError;
928
929    /// Parse a strict ISO-8601 `YYYY-MM-DD` date — the exact format
930    /// [`Display`](fmt::Display) produces. Malformed strings return
931    /// [`TimeError::InvalidDateString`]; range errors match [`Date::from_ymd`].
932    ///
933    /// ```
934    /// use fasti::{Date, Month, TimeError};
935    /// let d: Date = "2026-07-04".parse()?;
936    /// assert_eq!(d, Date::from_ymd(2026, Month::Jul, 4)?);
937    /// // Round trip through Display.
938    /// assert_eq!("2026-07-04".parse::<Date>()?.to_string(), "2026-07-04");
939    /// // Malformed strings are rejected.
940    /// assert_eq!("2026-7-4".parse::<Date>(), Err(TimeError::InvalidDateString));
941    /// // Well-formed but nonexistent dates surface the range error.
942    /// assert_eq!("2026-02-30".parse::<Date>(), Err(TimeError::DayOutOfRange));
943    /// # Ok::<(), fasti::TimeError>(())
944    /// ```
945    fn from_str(s: &str) -> Result<Self, Self::Err> {
946        const fn digit(b: u8) -> Result<u16, TimeError> {
947            if b.is_ascii_digit() {
948                Ok((b - b'0') as u16)
949            } else {
950                Err(TimeError::InvalidDateString)
951            }
952        }
953        let [y3, y2, y1, y0, h1, m1, m0, h2, d1, d0] = s.as_bytes() else {
954            return Err(TimeError::InvalidDateString);
955        };
956        if *h1 != b'-' || *h2 != b'-' {
957            return Err(TimeError::InvalidDateString);
958        }
959        let year = 1000 * digit(*y3)? + 100 * digit(*y2)? + 10 * digit(*y1)? + digit(*y0)?;
960        let month_num = 10 * digit(*m1)? + digit(*m0)?;
961        let day = 10 * digit(*d1)? + digit(*d0)?;
962        // Both values are at most 99, so the u16 -> u8 narrowing is exact.
963        #[allow(clippy::cast_possible_truncation)]
964        let month = Month::try_from_u8(month_num as u8)?;
965        #[allow(clippy::cast_possible_truncation)]
966        let day = day as u8;
967        Self::from_ymd(year, month, day)
968    }
969}
970
971/// Step a [`Date`] forward by a [`Period`]. Returns
972/// [`TimeError::DateOutOfRange`] for out-of-range results; `Months`/`Years`
973/// clamp the day-of-month (see [`Date::add_months`]).
974///
975/// ```
976/// use fasti::{Date, Month, Period};
977/// let d = Date::from_ymd(2026, Month::Jan, 15)?;
978/// assert_eq!((d + Period::Months(6))?, Date::from_ymd(2026, Month::Jul, 15)?);
979/// assert_eq!((d + Period::Years(1))?, Date::from_ymd(2027, Month::Jan, 15)?);
980/// assert_eq!((d + Period::Days(7))?, Date::from_ymd(2026, Month::Jan, 22)?);
981/// // Negative periods step backward.
982/// assert_eq!((d + (-Period::Months(1)))?, Date::from_ymd(2025, Month::Dec, 15)?);
983/// # Ok::<(), fasti::TimeError>(())
984/// ```
985impl Add<Period> for Date {
986    type Output = Result<Self, TimeError>;
987
988    fn add(self, period: Period) -> Self::Output {
989        match period {
990            Period::Days(n) => self.add_days(n),
991            Period::Weeks(n) => match n.checked_mul(7) {
992                Some(days) => self.add_days(days),
993                None => Err(TimeError::DateOutOfRange),
994            },
995            Period::Months(n) => self.add_months(n),
996            Period::Years(n) => self.add_years(n),
997        }
998    }
999}
1000
1001/// Step a [`Date`] backward by a [`Period`]. Uses [`Period::checked_neg`],
1002/// surfacing `i32::MIN` overflow as [`TimeError::DateOutOfRange`].
1003///
1004/// ```
1005/// use fasti::{Date, Month, Period};
1006/// let d = Date::from_ymd(2026, Month::Jul, 15)?;
1007/// assert_eq!((d - Period::Months(6))?, Date::from_ymd(2026, Month::Jan, 15)?);
1008/// # Ok::<(), fasti::TimeError>(())
1009/// ```
1010impl Sub<Period> for Date {
1011    type Output = Result<Self, TimeError>;
1012
1013    fn sub(self, period: Period) -> Self::Output {
1014        // `+` inside `Sub` is the deliberate factoring: delegate to `Add` after negating.
1015        #[allow(clippy::suspicious_arithmetic_impl)]
1016        match period.checked_neg() {
1017            Some(neg) => self + neg,
1018            None => Err(TimeError::DateOutOfRange),
1019        }
1020    }
1021}
1022
1023// ---- Tests --------------------------------------------------------------
1024
1025#[cfg(test)]
1026#[allow(clippy::unwrap_used, clippy::expect_used)]
1027mod tests {
1028    extern crate alloc;
1029
1030    use super::*;
1031    use proptest::prelude::*;
1032
1033    #[test]
1034    fn epoch_is_1901_01_01_tuesday() {
1035        let d = Date::MIN;
1036        assert_eq!(d.serial(), 0);
1037        assert_eq!(d.year().get(), 1901);
1038        assert_eq!(d.month(), Month::Jan);
1039        assert_eq!(d.day(), 1);
1040        assert_eq!(d.weekday(), Weekday::Tue);
1041    }
1042
1043    #[test]
1044    fn max_is_2199_12_31() {
1045        let d = Date::MAX;
1046        assert_eq!(d.year().get(), 2199);
1047        assert_eq!(d.month(), Month::Dec);
1048        assert_eq!(d.day(), 31);
1049    }
1050
1051    #[test]
1052    fn from_ymd_rejects_out_of_range_year() {
1053        assert_eq!(
1054            Date::from_ymd(1900, Month::Jan, 1),
1055            Err(TimeError::YearOutOfRange)
1056        );
1057        assert_eq!(
1058            Date::from_ymd(2200, Month::Jan, 1),
1059            Err(TimeError::YearOutOfRange)
1060        );
1061    }
1062
1063    #[test]
1064    fn from_ymd_rejects_day_zero_and_overflow() {
1065        assert_eq!(
1066            Date::from_ymd(2026, Month::Jan, 0),
1067            Err(TimeError::DayOutOfRange)
1068        );
1069        assert_eq!(
1070            Date::from_ymd(2026, Month::Jan, 32),
1071            Err(TimeError::DayOutOfRange)
1072        );
1073        assert_eq!(
1074            Date::from_ymd(2026, Month::Apr, 31),
1075            Err(TimeError::DayOutOfRange)
1076        );
1077    }
1078
1079    #[test]
1080    fn february_leap_year_behavior() {
1081        // 2000 is a leap year (divisible by 400).
1082        assert!(Date::from_ymd(2000, Month::Feb, 29).is_ok());
1083        // 2100 is NOT a leap year (divisible by 100, not 400).
1084        assert_eq!(
1085            Date::from_ymd(2100, Month::Feb, 29),
1086            Err(TimeError::DayOutOfRange)
1087        );
1088        // 2024 is a leap year (divisible by 4, not 100).
1089        assert!(Date::from_ymd(2024, Month::Feb, 29).is_ok());
1090        // 2026 is not a leap year.
1091        assert_eq!(
1092            Date::from_ymd(2026, Month::Feb, 29),
1093            Err(TimeError::DayOutOfRange)
1094        );
1095    }
1096
1097    #[test]
1098    fn known_weekdays() {
1099        // Anchors independently verifiable.
1100        assert_eq!(
1101            Date::from_ymd(1901, Month::Jan, 1).unwrap().weekday(),
1102            Weekday::Tue,
1103        );
1104        assert_eq!(
1105            Date::from_ymd(2000, Month::Jan, 1).unwrap().weekday(),
1106            Weekday::Sat,
1107        );
1108        assert_eq!(
1109            Date::from_ymd(2026, Month::Jul, 4).unwrap().weekday(),
1110            Weekday::Sat,
1111        );
1112        assert_eq!(
1113            Date::from_ymd(2021, Month::Jun, 19).unwrap().weekday(),
1114            Weekday::Sat,
1115        );
1116        assert_eq!(
1117            Date::from_ymd(2199, Month::Dec, 31).unwrap().weekday(),
1118            Weekday::Tue,
1119        );
1120    }
1121
1122    #[test]
1123    fn day_of_year_boundaries() {
1124        assert_eq!(
1125            Date::from_ymd(2024, Month::Jan, 1).unwrap().day_of_year(),
1126            1,
1127        );
1128        assert_eq!(
1129            Date::from_ymd(2024, Month::Dec, 31).unwrap().day_of_year(),
1130            366, // leap
1131        );
1132        assert_eq!(
1133            Date::from_ymd(2025, Month::Dec, 31).unwrap().day_of_year(),
1134            365,
1135        );
1136    }
1137
1138    #[test]
1139    fn add_days_at_boundaries() {
1140        assert_eq!(Date::MIN.add_days(-1), Err(TimeError::DateOutOfRange));
1141        assert_eq!(Date::MAX.add_days(1), Err(TimeError::DateOutOfRange));
1142        let d = Date::from_ymd(2026, Month::Feb, 28).unwrap();
1143        assert_eq!(
1144            d.add_days(1).unwrap(),
1145            Date::from_ymd(2026, Month::Mar, 1).unwrap()
1146        );
1147        let leap = Date::from_ymd(2024, Month::Feb, 28).unwrap();
1148        assert_eq!(
1149            leap.add_days(1).unwrap(),
1150            Date::from_ymd(2024, Month::Feb, 29).unwrap()
1151        );
1152    }
1153
1154    #[test]
1155    fn display_is_iso_8601() {
1156        let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1157        assert_eq!(alloc::format!("{d}"), "2026-07-04");
1158    }
1159
1160    #[test]
1161    fn weekday_iso_numbering() {
1162        assert_eq!(Weekday::Mon.get(), 1);
1163        assert_eq!(Weekday::Sun.get(), 7);
1164        assert_eq!(Weekday::try_from_u8(1).unwrap(), Weekday::Mon);
1165        assert_eq!(Weekday::try_from_u8(7).unwrap(), Weekday::Sun);
1166        assert_eq!(Weekday::try_from_u8(0), Err(TimeError::WeekdayOutOfRange));
1167        assert_eq!(Weekday::try_from_u8(8), Err(TimeError::WeekdayOutOfRange));
1168    }
1169
1170    #[test]
1171    fn ordinal_display() {
1172        assert_eq!(alloc::format!("{}", Ordinal::First), "First");
1173        assert_eq!(alloc::format!("{}", Ordinal::Fifth), "Fifth");
1174    }
1175
1176    #[test]
1177    fn from_str_parses_display_output() {
1178        for (y, m, d) in [
1179            (1901u16, Month::Jan, 1u8),
1180            (2026, Month::Jul, 4),
1181            (2024, Month::Feb, 29),
1182            (2199, Month::Dec, 31),
1183        ] {
1184            let date = Date::from_ymd(y, m, d).unwrap();
1185            let parsed: Date = alloc::format!("{date}").parse().unwrap();
1186            assert_eq!(parsed, date);
1187        }
1188    }
1189
1190    #[test]
1191    fn from_str_rejects_malformed_strings() {
1192        for bad in [
1193            "",
1194            "2026",
1195            "2026-07",
1196            "2026-7-4",    // not zero-padded
1197            "26-07-04",    // two-digit year
1198            "2026/07/04",  // wrong separator
1199            "2026-07-04T", // trailing content
1200            " 2026-07-04", // leading whitespace
1201            "2026-07-04 ", // trailing whitespace
1202            "+026-07-04",  // sign
1203            "2026-0a-04",  // non-digit
1204            "٢٠٢٦-07-04",  // non-ASCII digits
1205        ] {
1206            assert_eq!(
1207                bad.parse::<Date>(),
1208                Err(TimeError::InvalidDateString),
1209                "{bad:?} should be rejected as malformed",
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn from_str_surfaces_range_errors_for_well_formed_input() {
1216        assert_eq!("1900-12-31".parse::<Date>(), Err(TimeError::YearOutOfRange));
1217        assert_eq!("2200-01-01".parse::<Date>(), Err(TimeError::YearOutOfRange));
1218        assert_eq!(
1219            "2026-13-01".parse::<Date>(),
1220            Err(TimeError::MonthOutOfRange)
1221        );
1222        assert_eq!(
1223            "2026-00-01".parse::<Date>(),
1224            Err(TimeError::MonthOutOfRange)
1225        );
1226        assert_eq!("2026-02-30".parse::<Date>(), Err(TimeError::DayOutOfRange));
1227        assert_eq!("2026-01-00".parse::<Date>(), Err(TimeError::DayOutOfRange));
1228    }
1229
1230    #[test]
1231    fn to_ymd_matches_individual_accessors() {
1232        let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1233        let (y, m, dom) = d.to_ymd();
1234        assert_eq!(y, d.year());
1235        assert_eq!(m, d.month());
1236        assert_eq!(dom, d.day());
1237    }
1238
1239    // ---- property tests ------------------------------------------------
1240
1241    /// Strategy: uniformly sample a valid (year, month, day) in range.
1242    fn any_ymd() -> impl Strategy<Value = (u16, Month, u8)> {
1243        (EPOCH_YEAR..=END_YEAR, 1u8..=12u8).prop_flat_map(|(y, m)| {
1244            let month = Month::try_from_u8(m).expect("1..=12");
1245            let year = Year::new(y).expect("in range");
1246            let max_day = month.length(year);
1247            (Just(y), Just(month), 1u8..=max_day)
1248        })
1249    }
1250
1251    proptest! {
1252        #[test]
1253        fn from_ymd_round_trips(
1254            (year, month, day) in any_ymd()
1255        ) {
1256            let d = Date::from_ymd(year, month, day).expect("valid ymd");
1257            prop_assert_eq!(d.year().get(), year);
1258            prop_assert_eq!(d.month(), month);
1259            prop_assert_eq!(d.day(), day);
1260        }
1261
1262        #[test]
1263        fn serial_round_trips(
1264            serial in 0u32..=MAX_SERIAL,
1265        ) {
1266            let d = Date::from_serial(serial).expect("in range");
1267            prop_assert_eq!(d.serial(), serial);
1268            let ymd = Date::from_ymd(d.year().get(), d.month(), d.day()).expect("valid");
1269            prop_assert_eq!(ymd.serial(), serial);
1270        }
1271
1272        #[test]
1273        fn weekday_advances_by_one_per_day(
1274            serial in 0u32..MAX_SERIAL,
1275        ) {
1276            let today = Date::from_serial(serial).expect("in range");
1277            let tomorrow = today.add_days(1).expect("in range");
1278            let expected = match today.weekday() {
1279                Weekday::Mon => Weekday::Tue,
1280                Weekday::Tue => Weekday::Wed,
1281                Weekday::Wed => Weekday::Thu,
1282                Weekday::Thu => Weekday::Fri,
1283                Weekday::Fri => Weekday::Sat,
1284                Weekday::Sat => Weekday::Sun,
1285                Weekday::Sun => Weekday::Mon,
1286            };
1287            prop_assert_eq!(tomorrow.weekday(), expected);
1288        }
1289
1290        #[test]
1291        fn add_days_is_inverse_of_days_since(
1292            a_serial in 0u32..=MAX_SERIAL,
1293            b_serial in 0u32..=MAX_SERIAL,
1294        ) {
1295            let a = Date::from_serial(a_serial).unwrap();
1296            let b = Date::from_serial(b_serial).unwrap();
1297            let diff = b.days_since(a);
1298            prop_assert_eq!(a.add_days(diff).unwrap(), b);
1299        }
1300
1301        #[test]
1302        fn day_of_year_is_consistent(
1303            (year, month, day) in any_ymd()
1304        ) {
1305            let d = Date::from_ymd(year, month, day).expect("valid");
1306            let year_start = Date::from_ymd(year, Month::Jan, 1).expect("valid");
1307            prop_assert_eq!(
1308                u16::try_from(d.days_since(year_start) + 1).unwrap(),
1309                d.day_of_year(),
1310            );
1311        }
1312
1313        #[test]
1314        fn month_try_from_u8_round_trips(m in 1u8..=12u8) {
1315            let parsed = Month::try_from_u8(m).expect("1..=12");
1316            prop_assert_eq!(parsed.get(), m);
1317        }
1318
1319        #[test]
1320        fn weekday_try_from_u8_round_trips(n in 1u8..=7u8) {
1321            let parsed = Weekday::try_from_u8(n).expect("1..=7");
1322            prop_assert_eq!(parsed.get(), n);
1323        }
1324
1325        #[test]
1326        fn ordinal_try_from_u8_round_trips(n in 1u8..=5u8) {
1327            let parsed = Ordinal::try_from_u8(n).expect("1..=5");
1328            prop_assert_eq!(parsed.get(), n);
1329        }
1330
1331        #[test]
1332        fn add_days_accepts_iff_result_in_range(
1333            serial in 0u32..=MAX_SERIAL,
1334            n in i32::MIN..=i32::MAX,
1335        ) {
1336            let d = Date::from_serial(serial).expect("in range");
1337            let result = d.add_days(n);
1338            let target = i64::from(serial) + i64::from(n);
1339            let in_range = (0..=i64::from(MAX_SERIAL)).contains(&target);
1340            prop_assert_eq!(result.is_ok(), in_range);
1341            if in_range {
1342                prop_assert_eq!(
1343                    result.expect("in-range").serial(),
1344                    u32::try_from(target).expect("fits in u32"),
1345                );
1346            } else {
1347                prop_assert_eq!(result, Err(TimeError::DateOutOfRange));
1348            }
1349        }
1350
1351        #[test]
1352        fn to_ymd_round_trips(serial in 0u32..=MAX_SERIAL) {
1353            let d = Date::from_serial(serial).expect("in range");
1354            let (y, m, dom) = d.to_ymd();
1355            let rebuilt = Date::from_ymd(y.get(), m, dom).expect("valid");
1356            prop_assert_eq!(rebuilt.serial(), serial);
1357        }
1358
1359        /// Every date's `Display` output parses back to the same date.
1360        #[test]
1361        fn display_and_from_str_round_trip(serial in 0u32..=MAX_SERIAL) {
1362            let d = Date::from_serial(serial).expect("in range");
1363            let parsed: Date = alloc::format!("{d}").parse().expect("Display output is valid");
1364            prop_assert_eq!(parsed, d);
1365        }
1366
1367        /// `add_months(n)` then `add_months(-n)` round-trips exactly for day ≤ 28 (never clamped).
1368        #[test]
1369        fn add_months_round_trip_on_safe_days(
1370            year in 1910u16..=2190,
1371            month in 1u8..=12,
1372            day in 1u8..=28,
1373            n in -500i32..=500,
1374        ) {
1375            let parsed_month = Month::try_from_u8(month).expect("1..=12");
1376            let start = Date::from_ymd(year, parsed_month, day).expect("valid");
1377            if let Ok(stepped) = start.add_months(n)
1378                && let Ok(restored) = stepped.add_months(-n)
1379            {
1380                prop_assert_eq!(restored, start);
1381            }
1382        }
1383
1384        /// `add_months(n)` equals `add_years(n/12)` then `add_months(n%12)` for day ≤ 28;
1385        /// the year range keeps intermediates in range.
1386        #[test]
1387        fn add_months_decomposes_into_years_plus_months(
1388            year in 1921u16..=2179,
1389            month in 1u8..=12,
1390            day in 1u8..=28,
1391            whole_years in -20i32..=20,
1392            extra_months in -11i32..=11,
1393        ) {
1394            let parsed_month = Month::try_from_u8(month).expect("1..=12");
1395            let start = Date::from_ymd(year, parsed_month, day).expect("valid");
1396            let direct = start.add_months(whole_years * 12 + extra_months);
1397            let stepped = start
1398                .add_years(whole_years)
1399                .and_then(|x| x.add_months(extra_months));
1400            prop_assert_eq!(direct, stepped);
1401        }
1402
1403        /// `add_months` never yields a day past the target month's length.
1404        #[test]
1405        fn add_months_never_exceeds_target_month_length(
1406            serial in 0u32..=MAX_SERIAL,
1407            n in -200i32..=200,
1408        ) {
1409            let d = Date::from_serial(serial).expect("in range");
1410            if let Ok(out) = d.add_months(n) {
1411                let (y, m, dom) = out.to_ymd();
1412                prop_assert!(dom <= m.length(y));
1413                prop_assert!(dom >= 1);
1414            }
1415        }
1416
1417        /// `end_of_month` is idempotent.
1418        #[test]
1419        fn end_of_month_is_idempotent(serial in 0u32..=MAX_SERIAL) {
1420            let d = Date::from_serial(serial).expect("in range");
1421            prop_assert_eq!(d.end_of_month(), d.end_of_month().end_of_month());
1422            prop_assert!(d.end_of_month().is_end_of_month());
1423        }
1424
1425        /// `date + Period::Days(n)` matches `date.add_days(n)`.
1426        #[test]
1427        fn add_period_days_matches_add_days(
1428            serial in 0u32..=MAX_SERIAL,
1429            n in -10_000i32..=10_000,
1430        ) {
1431            let start = Date::from_serial(serial).expect("in range");
1432            prop_assert_eq!(start + crate::Period::Days(n), start.add_days(n));
1433        }
1434
1435        /// `date + Period::Weeks(n)` matches `date.add_days(n * 7)`
1436        /// (modulo overflow on the multiplication).
1437        #[test]
1438        fn add_period_weeks_equals_add_days_times_seven(
1439            serial in 0u32..=MAX_SERIAL,
1440            n in (i32::MIN / 7)..=(i32::MAX / 7),
1441        ) {
1442            let start = Date::from_serial(serial).expect("in range");
1443            prop_assert_eq!(start + crate::Period::Weeks(n), start.add_days(n * 7));
1444        }
1445
1446        /// `date + Period::Months(n)` matches `date.add_months(n)`.
1447        #[test]
1448        fn add_period_months_matches_add_months(
1449            serial in 0u32..=MAX_SERIAL,
1450            n in -200i32..=200,
1451        ) {
1452            let start = Date::from_serial(serial).expect("in range");
1453            prop_assert_eq!(start + crate::Period::Months(n), start.add_months(n));
1454        }
1455
1456        /// `date + Period::Years(n)` matches `date.add_years(n)`.
1457        #[test]
1458        fn add_period_years_matches_add_years(
1459            serial in 0u32..=MAX_SERIAL,
1460            n in -100i32..=100,
1461        ) {
1462            let start = Date::from_serial(serial).expect("in range");
1463            prop_assert_eq!(start + crate::Period::Years(n), start.add_years(n));
1464        }
1465
1466        /// `(date - period)` equals `(date + (-period))` for every
1467        /// non-`i32::MIN` length.
1468        #[test]
1469        fn sub_period_equals_add_negated_period(
1470            serial in 0u32..=MAX_SERIAL,
1471            length in (i32::MIN + 1)..=i32::MAX,
1472            unit_idx in 0u8..=3,
1473        ) {
1474            let p = match unit_idx {
1475                0 => crate::Period::Days(length),
1476                1 => crate::Period::Weeks(length),
1477                2 => crate::Period::Months(length),
1478                _ => crate::Period::Years(length),
1479            };
1480            let start = Date::from_serial(serial).expect("in range");
1481            prop_assert_eq!(start - p, start + (-p));
1482        }
1483    }
1484
1485    // ---- example-based tests for month/year arithmetic -----------------
1486
1487    #[test]
1488    fn add_months_clamps_to_target_month_length() {
1489        let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1490        assert_eq!(
1491            jan31.add_months(1).unwrap(),
1492            Date::from_ymd(2026, Month::Feb, 28).unwrap()
1493        );
1494        // Leap year: Jan 31 2024 + 1M → Feb 29.
1495        let jan31_leap = Date::from_ymd(2024, Month::Jan, 31).unwrap();
1496        assert_eq!(
1497            jan31_leap.add_months(1).unwrap(),
1498            Date::from_ymd(2024, Month::Feb, 29).unwrap()
1499        );
1500        // May 31 + 1M → Jun 30 (no May 31 + 1M = Jun 31).
1501        let may31 = Date::from_ymd(2026, Month::May, 31).unwrap();
1502        assert_eq!(
1503            may31.add_months(1).unwrap(),
1504            Date::from_ymd(2026, Month::Jun, 30).unwrap()
1505        );
1506    }
1507
1508    /// Clamp-on-add-months is not composable across end-of-month dates:
1509    /// `Jan 31 → Feb 28 → Mar 28` differs from `Jan 31 → Mar 31`.
1510    #[test]
1511    fn add_months_clamp_is_not_composable_across_eom() {
1512        let jan31 = Date::from_ymd(2026, Month::Jan, 31).unwrap();
1513        // Two single-month hops: 31 → 28 → 28. Day-of-month sticks at 28.
1514        let two_hops = jan31.add_months(1).unwrap().add_months(1).unwrap();
1515        assert_eq!(two_hops, Date::from_ymd(2026, Month::Mar, 28).unwrap());
1516        // One two-month hop: 31 → 31 (March has 31 days).
1517        let single_hop = jan31.add_months(2).unwrap();
1518        assert_eq!(single_hop, Date::from_ymd(2026, Month::Mar, 31).unwrap());
1519        // The two paths disagree by 3 days.
1520        assert_ne!(two_hops, single_hop);
1521    }
1522
1523    #[test]
1524    fn add_months_crosses_year_boundaries() {
1525        let nov15 = Date::from_ymd(2026, Month::Nov, 15).unwrap();
1526        assert_eq!(
1527            nov15.add_months(3).unwrap(),
1528            Date::from_ymd(2027, Month::Feb, 15).unwrap()
1529        );
1530        assert_eq!(
1531            nov15.add_months(-11).unwrap(),
1532            Date::from_ymd(2025, Month::Dec, 15).unwrap()
1533        );
1534    }
1535
1536    #[test]
1537    fn add_months_zero_is_identity() {
1538        let d = Date::from_ymd(2026, Month::Jul, 4).unwrap();
1539        assert_eq!(d.add_months(0).unwrap(), d);
1540    }
1541
1542    #[test]
1543    fn add_months_refuses_out_of_range_result() {
1544        assert_eq!(Date::MAX.add_months(1), Err(TimeError::DateOutOfRange));
1545        assert_eq!(Date::MIN.add_months(-1), Err(TimeError::DateOutOfRange));
1546    }
1547
1548    #[test]
1549    fn add_years_clamps_feb_29_in_non_leap_target() {
1550        let feb29 = Date::from_ymd(2024, Month::Feb, 29).unwrap();
1551        assert_eq!(
1552            feb29.add_years(1).unwrap(),
1553            Date::from_ymd(2025, Month::Feb, 28).unwrap()
1554        );
1555        assert_eq!(
1556            feb29.add_years(4).unwrap(),
1557            Date::from_ymd(2028, Month::Feb, 29).unwrap()
1558        );
1559    }
1560
1561    #[test]
1562    fn end_of_month_examples() {
1563        // Jan 15 2024 → Jan 31 2024.
1564        let d = Date::from_ymd(2024, Month::Jan, 15).unwrap();
1565        assert_eq!(
1566            d.end_of_month(),
1567            Date::from_ymd(2024, Month::Jan, 31).unwrap()
1568        );
1569        // Feb 10 2024 (leap) → Feb 29 2024.
1570        let d = Date::from_ymd(2024, Month::Feb, 10).unwrap();
1571        assert_eq!(
1572            d.end_of_month(),
1573            Date::from_ymd(2024, Month::Feb, 29).unwrap()
1574        );
1575        // Feb 10 2025 (non-leap) → Feb 28 2025.
1576        let d = Date::from_ymd(2025, Month::Feb, 10).unwrap();
1577        assert_eq!(
1578            d.end_of_month(),
1579            Date::from_ymd(2025, Month::Feb, 28).unwrap()
1580        );
1581        // Dec 31 2199 (max) is already EoM.
1582        assert_eq!(Date::MAX.end_of_month(), Date::MAX);
1583        // Jan 1 1901 (min) → Jan 31 1901.
1584        assert_eq!(
1585            Date::MIN.end_of_month(),
1586            Date::from_ymd(1901, Month::Jan, 31).unwrap()
1587        );
1588    }
1589
1590    #[test]
1591    fn is_end_of_month_examples() {
1592        assert!(
1593            Date::from_ymd(2024, Month::Feb, 29)
1594                .unwrap()
1595                .is_end_of_month()
1596        );
1597        assert!(
1598            !Date::from_ymd(2024, Month::Feb, 28)
1599                .unwrap()
1600                .is_end_of_month()
1601        );
1602        assert!(
1603            Date::from_ymd(2025, Month::Feb, 28)
1604                .unwrap()
1605                .is_end_of_month()
1606        );
1607        assert!(
1608            Date::from_ymd(2026, Month::Apr, 30)
1609                .unwrap()
1610                .is_end_of_month()
1611        );
1612        assert!(
1613            Date::from_ymd(2026, Month::May, 31)
1614                .unwrap()
1615                .is_end_of_month()
1616        );
1617    }
1618
1619    #[test]
1620    fn start_of_month_examples() {
1621        let d = Date::from_ymd(2024, Month::Feb, 29).unwrap();
1622        assert_eq!(
1623            d.start_of_month(),
1624            Date::from_ymd(2024, Month::Feb, 1).unwrap()
1625        );
1626        assert!(d.start_of_month().is_start_of_month());
1627        assert!(!d.is_start_of_month());
1628        assert_eq!(Date::MIN.start_of_month(), Date::MIN);
1629    }
1630
1631    #[test]
1632    fn next_weekday_is_the_identity_on_a_match() {
1633        // Thu Jan 1 2026.
1634        let thu = Date::from_ymd(2026, Month::Jan, 1).unwrap();
1635        assert_eq!(thu.next_weekday(Weekday::Thu).unwrap(), thu);
1636        assert_eq!(
1637            thu.next_weekday(Weekday::Wed).unwrap(),
1638            Date::from_ymd(2026, Month::Jan, 7).unwrap(),
1639        );
1640    }
1641
1642    #[test]
1643    fn nth_weekday_examples() {
1644        let y = Year::new(2026).unwrap();
1645        // MLK Day: third Monday of January 2026.
1646        assert_eq!(
1647            Date::nth_weekday(Ordinal::Third, Weekday::Mon, Month::Jan, y).unwrap(),
1648            Date::from_ymd(2026, Month::Jan, 19).unwrap(),
1649        );
1650        // Thanksgiving: fourth Thursday of November 2026.
1651        assert_eq!(
1652            Date::nth_weekday(Ordinal::Fourth, Weekday::Thu, Month::Nov, y).unwrap(),
1653            Date::from_ymd(2026, Month::Nov, 26).unwrap(),
1654        );
1655        // Feb 2026 has four Sundays, not five.
1656        assert_eq!(
1657            Date::nth_weekday(Ordinal::Fifth, Weekday::Sun, Month::Feb, y),
1658            Err(TimeError::DayOutOfRange),
1659        );
1660    }
1661
1662    #[test]
1663    fn date_range_dates_walks_both_ends() {
1664        let jan = Date::from_ymd(2026, Month::Jan, 1).unwrap()
1665            ..Date::from_ymd(2026, Month::Feb, 1).unwrap();
1666        assert_eq!(i64::try_from(jan.dates().count()).unwrap(), jan.days());
1667        assert_eq!(jan.dates().next(), Some(jan.start));
1668        assert_eq!(
1669            jan.dates().next_back(),
1670            Some(Date::from_ymd(2026, Month::Jan, 31).unwrap()),
1671        );
1672        // Empty and reversed ranges are both empty.
1673        assert_eq!((jan.start..jan.start).dates().count(), 0);
1674        assert_eq!((jan.end..jan.start).dates().count(), 0);
1675    }
1676
1677    proptest! {
1678        /// Every date in a range is contained by it, and the count
1679        /// matches the day span.
1680        #[test]
1681        fn dates_agree_with_days(serial in 0u32..(MAX_SERIAL - 400), len in 0u32..400) {
1682            let start = Date::from_serial(serial).unwrap();
1683            let range = start..Date::from_serial(serial + len).unwrap();
1684            prop_assert_eq!(i64::try_from(range.dates().count()).unwrap(), range.days());
1685            prop_assert!(range.dates().all(|d| range.contains(&d)));
1686        }
1687
1688        /// `nth_weekday` lands on the requested weekday and month.
1689        #[test]
1690        fn nth_weekday_lands_where_asked(y in 1901u16..=2199, m in 1u8..=12, w in 1u8..=7, n in 1u8..=5) {
1691            let (month, weekday) = (Month::try_from_u8(m).unwrap(), Weekday::try_from_u8(w).unwrap());
1692            let ordinal = Ordinal::try_from_u8(n).unwrap();
1693            if let Ok(d) = Date::nth_weekday(ordinal, weekday, month, Year::new(y).unwrap()) {
1694                prop_assert_eq!(d.weekday(), weekday);
1695                prop_assert_eq!(d.month(), month);
1696                prop_assert!(d.day() > 7 * (n - 1) && d.day() <= 7 * n);
1697            }
1698        }
1699    }
1700
1701    #[test]
1702    fn add_period_dispatches_by_unit() {
1703        let start = Date::from_ymd(2026, Month::Jan, 15).unwrap();
1704        assert_eq!(
1705            (start + crate::Period::Days(1)).unwrap(),
1706            Date::from_ymd(2026, Month::Jan, 16).unwrap()
1707        );
1708        assert_eq!(
1709            (start + crate::Period::Weeks(2)).unwrap(),
1710            Date::from_ymd(2026, Month::Jan, 29).unwrap()
1711        );
1712        assert_eq!(
1713            (start + crate::Period::Months(3)).unwrap(),
1714            Date::from_ymd(2026, Month::Apr, 15).unwrap()
1715        );
1716        assert_eq!(
1717            (start + crate::Period::Years(1)).unwrap(),
1718            Date::from_ymd(2027, Month::Jan, 15).unwrap()
1719        );
1720    }
1721
1722    #[test]
1723    fn sub_period_steps_backward() {
1724        let start = Date::from_ymd(2026, Month::Jul, 15).unwrap();
1725        assert_eq!(
1726            (start - crate::Period::Months(6)).unwrap(),
1727            Date::from_ymd(2026, Month::Jan, 15).unwrap(),
1728        );
1729        // Sub on a negative period steps forward.
1730        assert_eq!(
1731            (start - (-crate::Period::Months(6))).unwrap(),
1732            Date::from_ymd(2027, Month::Jan, 15).unwrap(),
1733        );
1734    }
1735}