Skip to main content

regit_daycount/
date.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Gregorian-calendar date primitive.
5//!
6//! A [`Date`] is a `(year, month, day)` triple under the proleptic Gregorian
7//! calendar — the calendar in use everywhere day-count fractions and holiday
8//! tables apply. It is the only date type this crate uses; every day-count
9//! fraction, date-roll convention, and calendar lookup operates on it.
10//!
11//! The representation is a `Copy`, three-field struct: a signed 32-bit year
12//! and two `u8` fields. This is intentionally cheap to pass by value, cheap
13//! to compare, and cheap to hash; the crate moves dates around freely.
14//!
15//! All arithmetic is grounded in **Howard Hinnant's `days_from_civil` /
16//! `civil_from_days`** algorithm — a public-domain, branchless, integer-only
17//! conversion between a `(year, month, day)` triple and a signed count of
18//! days from the civil epoch 1970-01-01 — transcribed here directly. The
19//! original derivation is at
20//! <http://howardhinnant.github.io/date_algorithms.html>. The algorithm is
21//! `no_std`-clean, allocation-free, and exact for every Gregorian date the
22//! crate accepts.
23//!
24//! # References
25//!
26//! - ISO 8601, *Date and time — Representations for information
27//!   interchange*, §3.4.1 (calendar date) and §3.4.2 (proleptic Gregorian
28//!   calendar).
29//! - Howard E. Hinnant, *chrono-Compatible Low-Level Date Algorithms*,
30//!   <http://howardhinnant.github.io/date_algorithms.html> (public domain).
31
32use crate::errors::ValidationError;
33
34// ─── Weekday ─────────────────────────────────────────────────────────────────
35
36/// A day of the week.
37///
38/// The variants are listed in the conventional Monday-first order used by
39/// ISO 8601 (`Monday = 1`, `Sunday = 7`). The discriminants are not part of
40/// the public API; callers should match on the variant name.
41///
42/// # Examples
43///
44/// ```
45/// use regit_daycount::{Date, Weekday};
46///
47/// // 2026-05-23 is a Saturday.
48/// assert_eq!(Date::ymd(2026, 5, 23).unwrap().day_of_week(), Weekday::Sat);
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum Weekday {
52    /// Monday.
53    Mon,
54    /// Tuesday.
55    Tue,
56    /// Wednesday.
57    Wed,
58    /// Thursday.
59    Thu,
60    /// Friday.
61    Fri,
62    /// Saturday.
63    Sat,
64    /// Sunday.
65    Sun,
66}
67
68// ─── Date ────────────────────────────────────────────────────────────────────
69
70/// A Gregorian-calendar date — `(year, month, day)`.
71///
72/// A `Date` is created by [`Date::ymd`] (validating) or
73/// [`Date::ymd_unchecked`] (caller asserts validity); the field layout is
74/// private so the invariant that the triple names a real date cannot be
75/// violated by direct construction. The type is `Copy` and allocates
76/// nothing.
77///
78/// `PartialOrd` / `Ord` follow the natural chronological order — equivalent
79/// to lexicographic order on `(year, month, day)`, which is the same thing
80/// for Gregorian dates.
81///
82/// The accepted year range is `1583..=9999`: the Gregorian calendar took
83/// effect in October 1582, and from 1583 onward every day-count and
84/// holiday-calendar rule this crate implements is defined uniformly.
85///
86/// # Examples
87///
88/// ```
89/// use regit_daycount::Date;
90///
91/// let d = Date::ymd(2026, 5, 23).unwrap();
92/// assert_eq!(d.year(),  2026);
93/// assert_eq!(d.month(), 5);
94/// assert_eq!(d.day(),   23);
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
97pub struct Date {
98    /// The year, as a signed integer (proleptic Gregorian; year 0 exists).
99    year: i32,
100    /// The month, `1..=12`.
101    month: u8,
102    /// The day of the month, `1..=31` (further constrained by `month` and
103    /// `year` — see [`Date::ymd`]).
104    day: u8,
105}
106
107impl Date {
108    /// Smallest accepted year (1583 — the first full year after the
109    /// Gregorian reform of October 1582).
110    pub const MIN_YEAR: i32 = 1583;
111    /// Largest accepted year (9999 — keeps the year an `i16`-fittable
112    /// value and matches every published holiday-calendar horizon).
113    pub const MAX_YEAR: i32 = 9999;
114
115    // ─── Construction ────────────────────────────────────────────────────
116
117    /// Constructs a validated `Date` from a `(year, month, day)` triple.
118    ///
119    /// Validation, in order: the year is in `MIN_YEAR..=MAX_YEAR`, the
120    /// month is in `1..=12`, and the day is in `1..=days_in_month(year,
121    /// month)` — which accounts for leap years.
122    ///
123    /// # Errors
124    ///
125    /// - [`ValidationError::OutOfRange`] with `what = "year < 1583"` or
126    ///   `what = "year > 9999"` if the year is outside the supported
127    ///   range.
128    /// - [`ValidationError::InvalidDate`] with `rule = "month-out-of-range"`
129    ///   if the month is not in `1..=12`.
130    /// - [`ValidationError::InvalidDate`] with `rule = "day-out-of-range"`
131    ///   if the day is not in `1..=days_in_month(year, month)`.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use regit_daycount::{Date, ValidationError};
137    ///
138    /// // A valid date.
139    /// assert!(Date::ymd(2026, 5, 23).is_ok());
140    ///
141    /// // 2025 is not a leap year — 29 February is rejected.
142    /// assert_eq!(
143    ///     Date::ymd(2025, 2, 29),
144    ///     Err(ValidationError::InvalidDate { rule: "day-out-of-range" }),
145    /// );
146    /// ```
147    pub fn ymd(year: i32, month: u8, day: u8) -> Result<Self, ValidationError> {
148        if year < Self::MIN_YEAR {
149            return Err(ValidationError::OutOfRange {
150                what: "year < 1583",
151            });
152        }
153        if year > Self::MAX_YEAR {
154            return Err(ValidationError::OutOfRange {
155                what: "year > 9999",
156            });
157        }
158        if !(1..=12).contains(&month) {
159            return Err(ValidationError::InvalidDate {
160                rule: "month-out-of-range",
161            });
162        }
163        let dim = Self::days_in_month(year, month);
164        if !(1..=dim).contains(&day) {
165            return Err(ValidationError::InvalidDate {
166                rule: "day-out-of-range",
167            });
168        }
169        Ok(Self { year, month, day })
170    }
171
172    /// Constructs a `Date` without validating the triple.
173    ///
174    /// The caller asserts that `(year, month, day)` names a real Gregorian
175    /// date inside the supported year range. This exists for `const`-context
176    /// construction and for reconstructing a `Date` from fields validated
177    /// earlier; prefer [`Date::ymd`] for any untrusted input.
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use regit_daycount::Date;
183    ///
184    /// let d = Date::ymd_unchecked(2026, 5, 23);
185    /// assert_eq!(d.year(), 2026);
186    /// ```
187    #[must_use]
188    pub const fn ymd_unchecked(year: i32, month: u8, day: u8) -> Self {
189        Self { year, month, day }
190    }
191
192    // ─── Trivial accessors ───────────────────────────────────────────────
193
194    /// Returns the year.
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use regit_daycount::Date;
200    ///
201    /// assert_eq!(Date::ymd_unchecked(2026, 5, 23).year(), 2026);
202    /// ```
203    #[must_use]
204    #[inline]
205    pub const fn year(&self) -> i32 {
206        self.year
207    }
208
209    /// Returns the month, `1..=12`.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use regit_daycount::Date;
215    ///
216    /// assert_eq!(Date::ymd_unchecked(2026, 5, 23).month(), 5);
217    /// ```
218    #[must_use]
219    #[inline]
220    pub const fn month(&self) -> u8 {
221        self.month
222    }
223
224    /// Returns the day of the month, `1..=31`.
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use regit_daycount::Date;
230    ///
231    /// assert_eq!(Date::ymd_unchecked(2026, 5, 23).day(), 23);
232    /// ```
233    #[must_use]
234    #[inline]
235    pub const fn day(&self) -> u8 {
236        self.day
237    }
238
239    // ─── Calendar rules ──────────────────────────────────────────────────
240
241    /// Returns `true` if `year` is a Gregorian leap year.
242    ///
243    /// A year is a leap year if it is divisible by 4 **and** either not
244    /// divisible by 100 **or** divisible by 400. Hence 1900 is not a leap
245    /// year, 2000 is, and 2100 is not.
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use regit_daycount::Date;
251    ///
252    /// assert!( Date::is_leap_year(2000));
253    /// assert!( Date::is_leap_year(2024));
254    /// assert!(!Date::is_leap_year(1900));
255    /// assert!(!Date::is_leap_year(2025));
256    /// ```
257    #[must_use]
258    #[inline]
259    pub const fn is_leap_year(year: i32) -> bool {
260        (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
261    }
262
263    /// Returns the number of days in `(year, month)`.
264    ///
265    /// February takes 29 days in a leap year and 28 otherwise; every other
266    /// month is the conventional 30 or 31. If `month` is not in `1..=12`,
267    /// returns `0` (the caller is expected to validate the month first;
268    /// [`Date::ymd`] does).
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use regit_daycount::Date;
274    ///
275    /// assert_eq!(Date::days_in_month(2024, 2), 29);
276    /// assert_eq!(Date::days_in_month(2025, 2), 28);
277    /// assert_eq!(Date::days_in_month(2026, 4), 30);
278    /// assert_eq!(Date::days_in_month(2026, 7), 31);
279    /// ```
280    #[must_use]
281    #[inline]
282    pub const fn days_in_month(year: i32, month: u8) -> u8 {
283        match month {
284            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
285            4 | 6 | 9 | 11 => 30,
286            2 => {
287                if Self::is_leap_year(year) {
288                    29
289                } else {
290                    28
291                }
292            }
293            _ => 0,
294        }
295    }
296
297    // ─── Hinnant epoch conversions ───────────────────────────────────────
298
299    /// Days from the civil epoch 1970-01-01 to `(year, month, day)`.
300    ///
301    /// Transcribed verbatim (in `i64`) from Howard Hinnant's
302    /// `days_from_civil` — public-domain, branchless, integer-only — at
303    /// <http://howardhinnant.github.io/date_algorithms.html>. The output is
304    /// negative for dates before 1970-01-01 and zero on that date itself.
305    ///
306    /// The arithmetic is performed in `i64` so that adding a 32-bit day
307    /// offset cannot overflow even at the extremes of the supported year
308    /// range.
309    //
310    // The cast lints are silenced for the algorithm body: `yoe ∈ [0, 399]`
311    // and `doe ∈ [0, 146_096]` are bounds established by the algorithm
312    // itself, so the `as u32` / `as i64` casts are exact, not narrowing.
313    #[inline]
314    #[allow(
315        clippy::cast_possible_truncation,
316        clippy::cast_sign_loss,
317        clippy::cast_lossless
318    )]
319    const fn to_civil_days(year: i32, month: u8, day: u8) -> i64 {
320        // Hinnant: y' = y - (m <= 2); era = floor(y' / 400)
321        let y = year as i64 - if month <= 2 { 1 } else { 0 };
322        let era = if y >= 0 { y } else { y - 399 } / 400;
323        let yoe = (y - era * 400) as u32; // [0, 399]
324        let m = month as u32;
325        // doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1
326        let m_shift = if m > 2 { m - 3 } else { m + 9 };
327        let doy = (153 * m_shift + 2) / 5 + (day as u32) - 1; // [0, 365]
328        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
329        era * 146_097 + doe as i64 - 719_468
330    }
331
332    /// Inverse of [`Self::to_civil_days`].
333    ///
334    /// Transcribed verbatim from Howard Hinnant's `civil_from_days` —
335    /// public-domain, branchless, integer-only — at
336    /// <http://howardhinnant.github.io/date_algorithms.html>.
337    ///
338    /// The output triple always satisfies `is_leap_year(year)`'s rule for
339    /// its `(month, day)`; if it falls outside this crate's supported year
340    /// range, [`Self::ymd_unchecked`] wraps it without re-validating —
341    /// arithmetic that escapes the window is the caller's responsibility
342    /// to bound.
343    //
344    // The cast lints are silenced for the algorithm body: `doe ∈
345    // [0, 146_096]`, `mp ∈ [0, 11]`, `d ∈ [1, 31]`, and `year` fits in
346    // `i32` for any input within roughly ±5 million civil days of the
347    // 1970 epoch — bounds established by the algorithm itself, so the
348    // narrowing casts are exact in the inputs the crate accepts.
349    #[inline]
350    #[allow(
351        clippy::cast_possible_truncation,
352        clippy::cast_sign_loss,
353        clippy::cast_lossless
354    )]
355    const fn from_civil_days(z: i64) -> Self {
356        let z = z + 719_468;
357        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
358        let doe = (z - era * 146_097) as u32; // [0, 146096]
359        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
360        let y = yoe as i64 + era * 400;
361        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
362        let mp = (5 * doy + 2) / 153; // [0, 11]
363        let d = (doy - (153 * mp + 2) / 5 + 1) as u8; // [1, 31]
364        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8; // [1, 12]
365        let year = (y + if m <= 2 { 1 } else { 0 }) as i32;
366        Self {
367            year,
368            month: m,
369            day: d,
370        }
371    }
372
373    // ─── Day-of-week / arithmetic ────────────────────────────────────────
374
375    /// Returns the day of the week for `self`.
376    ///
377    /// Computed by reducing the Hinnant civil-day count modulo 7 (the civil
378    /// epoch 1970-01-01 was a Thursday).
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use regit_daycount::{Date, Weekday};
384    ///
385    /// assert_eq!(Date::ymd(2026, 5, 23).unwrap().day_of_week(), Weekday::Sat);
386    /// assert_eq!(Date::ymd(2000, 1,  1).unwrap().day_of_week(), Weekday::Sat);
387    /// assert_eq!(Date::ymd(2024, 12, 25).unwrap().day_of_week(), Weekday::Wed);
388    /// ```
389    #[must_use]
390    pub fn day_of_week(self) -> Weekday {
391        // 1970-01-01 is a Thursday. Reduce the signed civil-day count to a
392        // non-negative weekday index using Euclidean modulo, which is
393        // defined directly on `i64` without needing a manual shift.
394        let z = Self::to_civil_days(self.year, self.month, self.day);
395        // Thursday is index 3 in (Mon, Tue, Wed, Thu, Fri, Sat, Sun).
396        let idx = (z + 3).rem_euclid(7);
397        match idx {
398            0 => Weekday::Mon,
399            1 => Weekday::Tue,
400            2 => Weekday::Wed,
401            3 => Weekday::Thu,
402            4 => Weekday::Fri,
403            5 => Weekday::Sat,
404            _ => Weekday::Sun,
405        }
406    }
407
408    /// Returns `self` advanced by `days` calendar days (negative goes back).
409    ///
410    /// Implemented by converting `self` to its Hinnant civil-day count,
411    /// adding `days`, and converting back. The intermediate arithmetic is
412    /// `i64`, so a 32-bit `days` cannot overflow. The result is constructed
413    /// via [`Self::ymd_unchecked`] and is *not* re-validated against the
414    /// `[1583, 9999]` window — arithmetic that escapes that window is the
415    /// caller's responsibility.
416    ///
417    /// # Examples
418    ///
419    /// ```
420    /// use regit_daycount::Date;
421    ///
422    /// // Plain forward step into the next month.
423    /// assert_eq!(
424    ///     Date::ymd(2026, 1, 1).unwrap().add_days(31),
425    ///     Date::ymd(2026, 2, 1).unwrap(),
426    /// );
427    ///
428    /// // Leap-year boundary.
429    /// assert_eq!(
430    ///     Date::ymd(2024, 2, 28).unwrap().add_days(1),
431    ///     Date::ymd(2024, 2, 29).unwrap(),
432    /// );
433    /// ```
434    #[must_use]
435    pub fn add_days(self, days: i32) -> Self {
436        let z = Self::to_civil_days(self.year, self.month, self.day) + i64::from(days);
437        Self::from_civil_days(z)
438    }
439
440    /// Returns `self` advanced by `months` months, clamping the day of the
441    /// month to the last day of the resulting month when the original day
442    /// does not exist there.
443    ///
444    /// The rule is: compute the new year and month from `(self.year * 12 +
445    /// self.month - 1) + months`, then set the day to `min(self.day,
446    /// days_in_month(new_year, new_month))`. So `2026-01-31 + 1 month =
447    /// 2026-02-28` (not a leap year) and `2024-01-31 + 1 month =
448    /// 2024-02-29` (leap year).
449    ///
450    /// The result is constructed via [`Self::ymd_unchecked`] and is *not*
451    /// re-validated against the `[1583, 9999]` window.
452    ///
453    /// # Examples
454    ///
455    /// ```
456    /// use regit_daycount::Date;
457    ///
458    /// assert_eq!(
459    ///     Date::ymd(2026, 1, 31).unwrap().add_months_eom_aware(1),
460    ///     Date::ymd(2026, 2, 28).unwrap(),
461    /// );
462    /// assert_eq!(
463    ///     Date::ymd(2024, 1, 31).unwrap().add_months_eom_aware(1),
464    ///     Date::ymd(2024, 2, 29).unwrap(),
465    /// );
466    /// ```
467    #[must_use]
468    pub fn add_months_eom_aware(self, months: i32) -> Self {
469        // Convert (year, month) to a zero-based month index, add `months`,
470        // and decompose. Performed in `i64` to avoid overflow on the
471        // extremes of the supported year range. `new_year_i64` is at most
472        // ~`MAX_YEAR + |months|/12`, which fits in `i32` for any reasonable
473        // offset; `new_month_i64` is in `1..=12`.
474        let total = i64::from(self.year) * 12 + (i64::from(self.month) - 1) + i64::from(months);
475        let new_year_i64 = total.div_euclid(12);
476        let new_month_i64 = total.rem_euclid(12) + 1;
477        let new_year = i32::try_from(new_year_i64).unwrap_or(self.year);
478        let new_month = u8::try_from(new_month_i64).unwrap_or(self.month);
479        let dim = Self::days_in_month(new_year, new_month);
480        let new_day = if self.day < dim { self.day } else { dim };
481        Self::ymd_unchecked(new_year, new_month, new_day)
482    }
483
484    /// Returns the date of the `n`-th occurrence of `weekday` in
485    /// `(year, month)`.
486    ///
487    /// `n = 1` is the first occurrence, `n = 5` is the fifth (if it
488    /// exists). Computed by finding the first occurrence of `weekday` in
489    /// the month and adding `(n - 1) * 7` days.
490    ///
491    /// # Errors
492    ///
493    /// - [`ValidationError::OutOfRange`] with `what = "n must be 1..=5"`
494    ///   if `n` is not in `1..=5`.
495    /// - [`ValidationError::InvalidDate`] with `rule = "month-out-of-range"`
496    ///   if `month` is not in `1..=12`.
497    /// - [`ValidationError::OutOfRange`] with `what = "year < 1583"` /
498    ///   `"year > 9999"` if the year is outside the supported range.
499    /// - [`ValidationError::OutOfRange`] with `what = "nth weekday does not
500    ///   exist in this month"` if the computed date would fall after the
501    ///   last day of the month (e.g. the 5th Monday of a 28-day February).
502    ///
503    /// # Examples
504    ///
505    /// ```
506    /// use regit_daycount::{Date, Weekday};
507    ///
508    /// // The 3rd Friday of June 2026 is 2026-06-19.
509    /// assert_eq!(
510    ///     Date::nth_weekday_of_month(2026, 6, 3, Weekday::Fri).unwrap(),
511    ///     Date::ymd(2026, 6, 19).unwrap(),
512    /// );
513    /// ```
514    pub fn nth_weekday_of_month(
515        year: i32,
516        month: u8,
517        n: u8,
518        weekday: Weekday,
519    ) -> Result<Self, ValidationError> {
520        if !(1..=5).contains(&n) {
521            return Err(ValidationError::OutOfRange {
522                what: "n must be 1..=5",
523            });
524        }
525        // Validate (year, month, 1) — that pins the month and year.
526        let first = Self::ymd(year, month, 1)?;
527        let first_wd = first.day_of_week();
528        // Offset from `first` to the first occurrence of `weekday`.
529        let offset = (weekday_index(weekday) + 7 - weekday_index(first_wd)) % 7;
530        let day_in_month = 1 + offset + (i32::from(n) - 1) * 7;
531        let dim = i32::from(Self::days_in_month(year, month));
532        if day_in_month > dim {
533            return Err(ValidationError::OutOfRange {
534                what: "nth weekday does not exist in this month",
535            });
536        }
537        // `day_in_month ∈ [1, 31]` here — bounded by the `> dim` check
538        // above and a per-month maximum of 31 days.
539        let day = u8::try_from(day_in_month).unwrap_or(1);
540        Ok(Self { year, month, day })
541    }
542
543    /// Returns the date of Easter Sunday in the (Western, Gregorian) year
544    /// `year`.
545    ///
546    /// Implemented by the **Anonymous Gregorian** / **Computus** algorithm
547    /// (Meeus / Butcher form): a closed-form integer-only computation that
548    /// is exact for every Gregorian year. See Jean Meeus, *Astronomical
549    /// Algorithms* (2nd ed., 1998), §8 — "The date of Easter".
550    ///
551    /// The TARGET2 holiday rule derives Good Friday and Easter Monday from
552    /// this date.
553    ///
554    /// # Examples
555    ///
556    /// ```
557    /// use regit_daycount::Date;
558    ///
559    /// // Easter Sunday 2026 falls on 5 April.
560    /// assert_eq!(Date::easter_sunday(2026), Date::ymd(2026, 4, 5).unwrap());
561    /// ```
562    //
563    // The Meeus / Butcher / Anonymous-Gregorian variables `a..m` are the
564    // standard one-letter names used in Meeus §8; preserving them keeps the
565    // implementation auditable line-by-line against the published algorithm.
566    // The casts at the bottom narrow values bounded by the algorithm to
567    // `month ∈ {3, 4}` and `day ∈ [1, 31]`.
568    #[must_use]
569    #[allow(
570        clippy::many_single_char_names,
571        clippy::cast_possible_truncation,
572        clippy::cast_sign_loss
573    )]
574    pub fn easter_sunday(year: i32) -> Self {
575        // Meeus / Butcher / Anonymous Gregorian. Variable names follow the
576        // standard presentation in Meeus §8.
577        let y = year;
578        let a = y % 19;
579        let b = y / 100;
580        let c = y % 100;
581        let d = b / 4;
582        let e = b % 4;
583        let f = (b + 8) / 25;
584        let g = (b - f + 1) / 3;
585        let h = (19 * a + b - d - g + 15) % 30;
586        let i = c / 4;
587        let k = c % 4;
588        let l = (32 + 2 * e + 2 * i - h - k) % 7;
589        let m = (a + 11 * h + 22 * l) / 451;
590        let month_num = (h + l - 7 * m + 114) / 31; // 3 = March, 4 = April
591        let day_num = ((h + l - 7 * m + 114) % 31) + 1;
592        Self {
593            year: y,
594            month: month_num as u8,
595            day: day_num as u8,
596        }
597    }
598
599    /// Returns the signed number of days from `self` to `other`.
600    ///
601    /// The convention is end-exclusive, start-inclusive: a one-day interval
602    /// `[2026-01-01, 2026-01-02)` returns `1`. Computed by subtracting the
603    /// two Hinnant civil-day counts; the result is `i32` and fits comfortably
604    /// for any pair of dates in the supported year range (the full window
605    /// 1583–9999 spans roughly `3.1 * 10⁶` days, well inside `i32`).
606    ///
607    /// # Examples
608    ///
609    /// ```
610    /// use regit_daycount::Date;
611    ///
612    /// let a = Date::ymd(2026, 1, 1).unwrap();
613    /// let b = Date::ymd(2026, 1, 2).unwrap();
614    /// assert_eq!(a.days_between(b),  1);
615    /// assert_eq!(b.days_between(a), -1);
616    /// assert_eq!(a.days_between(a),  0);
617    /// ```
618    //
619    // The difference of two civil-day counts for dates in `[1583, 9999]`
620    // is bounded by ~3.1 × 10⁶, which fits in `i32`. `try_from` is used
621    // defensively; on the (impossible-in-range) overflow path it falls back
622    // to `i32::MAX`.
623    #[must_use]
624    pub fn days_between(self, other: Self) -> i32 {
625        let a = Self::to_civil_days(self.year, self.month, self.day);
626        let b = Self::to_civil_days(other.year, other.month, other.day);
627        i32::try_from(b - a).unwrap_or(i32::MAX)
628    }
629}
630
631// ─── Helpers ─────────────────────────────────────────────────────────────────
632
633/// Numeric index for a `Weekday`, Monday = 0.
634#[inline]
635const fn weekday_index(w: Weekday) -> i32 {
636    match w {
637        Weekday::Mon => 0,
638        Weekday::Tue => 1,
639        Weekday::Wed => 2,
640        Weekday::Thu => 3,
641        Weekday::Fri => 4,
642        Weekday::Sat => 5,
643        Weekday::Sun => 6,
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    // ─── is_leap_year ────────────────────────────────────────────────────
652
653    #[test]
654    fn leap_year_known_anchors() {
655        assert!(!Date::is_leap_year(1900));
656        assert!(Date::is_leap_year(2000));
657        assert!(Date::is_leap_year(2004));
658        assert!(Date::is_leap_year(2020));
659        assert!(Date::is_leap_year(2024));
660        assert!(!Date::is_leap_year(2025));
661        assert!(!Date::is_leap_year(2026));
662        assert!(!Date::is_leap_year(2100));
663        assert!(Date::is_leap_year(2400));
664    }
665
666    // ─── days_in_month ───────────────────────────────────────────────────
667
668    #[test]
669    fn days_in_month_non_leap() {
670        let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
671        for (m, &d) in (1..=12).zip(expected.iter()) {
672            assert_eq!(Date::days_in_month(2025, m), d, "month {m} non-leap");
673        }
674    }
675
676    #[test]
677    fn days_in_month_leap() {
678        let expected = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
679        for (m, &d) in (1..=12).zip(expected.iter()) {
680            assert_eq!(Date::days_in_month(2024, m), d, "month {m} leap");
681        }
682    }
683
684    #[test]
685    fn days_in_month_invalid_month_is_zero() {
686        assert_eq!(Date::days_in_month(2026, 0), 0);
687        assert_eq!(Date::days_in_month(2026, 13), 0);
688    }
689
690    // ─── ymd validation ──────────────────────────────────────────────────
691
692    #[test]
693    fn ymd_accepts_boundary_years() {
694        assert!(Date::ymd(1583, 1, 1).is_ok());
695        assert!(Date::ymd(9999, 12, 31).is_ok());
696    }
697
698    #[test]
699    fn ymd_rejects_year_below_min() {
700        assert_eq!(
701            Date::ymd(1582, 1, 1),
702            Err(ValidationError::OutOfRange {
703                what: "year < 1583",
704            })
705        );
706    }
707
708    #[test]
709    fn ymd_rejects_year_above_max() {
710        assert_eq!(
711            Date::ymd(10_000, 1, 1),
712            Err(ValidationError::OutOfRange {
713                what: "year > 9999",
714            })
715        );
716    }
717
718    #[test]
719    fn ymd_rejects_month_zero_and_thirteen() {
720        assert_eq!(
721            Date::ymd(2026, 0, 1),
722            Err(ValidationError::InvalidDate {
723                rule: "month-out-of-range",
724            })
725        );
726        assert_eq!(
727            Date::ymd(2026, 13, 1),
728            Err(ValidationError::InvalidDate {
729                rule: "month-out-of-range",
730            })
731        );
732    }
733
734    #[test]
735    fn ymd_rejects_day_zero_and_overflow() {
736        assert_eq!(
737            Date::ymd(2026, 1, 0),
738            Err(ValidationError::InvalidDate {
739                rule: "day-out-of-range",
740            })
741        );
742        assert_eq!(
743            Date::ymd(2026, 1, 32),
744            Err(ValidationError::InvalidDate {
745                rule: "day-out-of-range",
746            })
747        );
748        // 30 April does not exist.
749        assert_eq!(
750            Date::ymd(2026, 4, 31),
751            Err(ValidationError::InvalidDate {
752                rule: "day-out-of-range",
753            })
754        );
755    }
756
757    #[test]
758    fn ymd_rejects_feb_29_in_non_leap() {
759        assert_eq!(
760            Date::ymd(2025, 2, 29),
761            Err(ValidationError::InvalidDate {
762                rule: "day-out-of-range",
763            })
764        );
765        // ... and accepts it in a leap year.
766        assert!(Date::ymd(2024, 2, 29).is_ok());
767    }
768
769    // ─── day_of_week ─────────────────────────────────────────────────────
770
771    #[test]
772    fn day_of_week_named_dates() {
773        // Hand-verified against published calendars.
774        assert_eq!(
775            Date::ymd(1900, 1, 1).unwrap().day_of_week(),
776            Weekday::Mon,
777            "1900-01-01"
778        );
779        assert_eq!(
780            Date::ymd(1999, 12, 31).unwrap().day_of_week(),
781            Weekday::Fri,
782            "1999-12-31"
783        );
784        assert_eq!(
785            Date::ymd(2000, 1, 1).unwrap().day_of_week(),
786            Weekday::Sat,
787            "2000-01-01"
788        );
789        assert_eq!(
790            Date::ymd(2020, 2, 29).unwrap().day_of_week(),
791            Weekday::Sat,
792            "2020-02-29"
793        );
794        assert_eq!(
795            Date::ymd(2024, 12, 25).unwrap().day_of_week(),
796            Weekday::Wed,
797            "2024-12-25"
798        );
799        assert_eq!(
800            Date::ymd(2026, 1, 1).unwrap().day_of_week(),
801            Weekday::Thu,
802            "2026-01-01"
803        );
804        assert_eq!(
805            Date::ymd(2026, 5, 23).unwrap().day_of_week(),
806            Weekday::Sat,
807            "2026-05-23"
808        );
809        assert_eq!(
810            Date::ymd(2026, 12, 31).unwrap().day_of_week(),
811            Weekday::Thu,
812            "2026-12-31"
813        );
814        assert_eq!(
815            Date::ymd(2038, 1, 19).unwrap().day_of_week(),
816            Weekday::Tue,
817            "2038-01-19"
818        );
819        assert_eq!(
820            Date::ymd(1969, 7, 20).unwrap().day_of_week(),
821            Weekday::Sun,
822            "1969-07-20 (Apollo 11 Moon landing)"
823        );
824    }
825
826    #[test]
827    fn day_of_week_cycles_correctly() {
828        // Seven consecutive days must produce all seven weekdays once.
829        let mut d = Date::ymd(2026, 1, 5).unwrap(); // Monday
830        let expected = [
831            Weekday::Mon,
832            Weekday::Tue,
833            Weekday::Wed,
834            Weekday::Thu,
835            Weekday::Fri,
836            Weekday::Sat,
837            Weekday::Sun,
838        ];
839        for &e in &expected {
840            assert_eq!(d.day_of_week(), e);
841            d = d.add_days(1);
842        }
843    }
844
845    // ─── add_days ────────────────────────────────────────────────────────
846
847    #[test]
848    fn add_days_into_next_month() {
849        assert_eq!(
850            Date::ymd(2026, 1, 1).unwrap().add_days(31),
851            Date::ymd(2026, 2, 1).unwrap()
852        );
853    }
854
855    #[test]
856    fn add_days_leap_boundary() {
857        assert_eq!(
858            Date::ymd(2024, 2, 28).unwrap().add_days(1),
859            Date::ymd(2024, 2, 29).unwrap()
860        );
861        assert_eq!(
862            Date::ymd(2025, 2, 28).unwrap().add_days(1),
863            Date::ymd(2025, 3, 1).unwrap()
864        );
865    }
866
867    #[test]
868    fn add_days_year_boundary() {
869        assert_eq!(
870            Date::ymd(2026, 12, 31).unwrap().add_days(1),
871            Date::ymd(2027, 1, 1).unwrap()
872        );
873    }
874
875    #[test]
876    fn add_days_round_trip_small_grid() {
877        // Manual deterministic round-trip grid over a wide span of offsets.
878        let anchor = Date::ymd(2026, 5, 23).unwrap();
879        for &n in &[
880            -10_000, -3_653, -366, -365, -100, -7, -1, 0, 1, 7, 100, 365, 366, 3_653, 10_000,
881        ] {
882            assert_eq!(anchor.add_days(n).add_days(-n), anchor, "offset {n}");
883        }
884    }
885
886    #[test]
887    fn add_days_round_trip_proptest() {
888        use proptest::prelude::*;
889        let anchor = Date::ymd(2026, 5, 23).unwrap();
890        proptest!(|(n in -10_000i32..10_000)| {
891            prop_assert_eq!(anchor.add_days(n).add_days(-n), anchor);
892        });
893    }
894
895    // ─── add_months_eom_aware ────────────────────────────────────────────
896
897    #[test]
898    fn add_months_eom_aware_non_leap_clamp() {
899        assert_eq!(
900            Date::ymd(2026, 1, 31).unwrap().add_months_eom_aware(1),
901            Date::ymd(2026, 2, 28).unwrap()
902        );
903    }
904
905    #[test]
906    fn add_months_eom_aware_leap_keeps_29() {
907        assert_eq!(
908            Date::ymd(2024, 1, 31).unwrap().add_months_eom_aware(1),
909            Date::ymd(2024, 2, 29).unwrap()
910        );
911    }
912
913    #[test]
914    fn add_months_eom_aware_year_rollover() {
915        assert_eq!(
916            Date::ymd(2026, 12, 31).unwrap().add_months_eom_aware(1),
917            Date::ymd(2027, 1, 31).unwrap()
918        );
919    }
920
921    #[test]
922    fn add_months_eom_aware_negative_rollover() {
923        // -1 month from 2026-03-31 lands on 2026-02-28 (clamped).
924        assert_eq!(
925            Date::ymd(2026, 3, 31).unwrap().add_months_eom_aware(-1),
926            Date::ymd(2026, 2, 28).unwrap()
927        );
928        // -1 month from 2024-03-31 lands on 2024-02-29 (clamped, leap).
929        assert_eq!(
930            Date::ymd(2024, 3, 31).unwrap().add_months_eom_aware(-1),
931            Date::ymd(2024, 2, 29).unwrap()
932        );
933        // -12 months goes back exactly one year.
934        assert_eq!(
935            Date::ymd(2026, 5, 15).unwrap().add_months_eom_aware(-12),
936            Date::ymd(2025, 5, 15).unwrap()
937        );
938    }
939
940    // ─── nth_weekday_of_month ────────────────────────────────────────────
941
942    #[test]
943    fn nth_weekday_third_friday_june_2026() {
944        // 3rd Friday of June 2026 = 2026-06-19.
945        assert_eq!(
946            Date::nth_weekday_of_month(2026, 6, 3, Weekday::Fri).unwrap(),
947            Date::ymd(2026, 6, 19).unwrap()
948        );
949    }
950
951    #[test]
952    fn nth_weekday_fifth_monday_feb_2026_does_not_exist() {
953        // February 2026 has 28 days starting Sunday, so it has only four
954        // Mondays.
955        assert_eq!(
956            Date::nth_weekday_of_month(2026, 2, 5, Weekday::Mon),
957            Err(ValidationError::OutOfRange {
958                what: "nth weekday does not exist in this month",
959            })
960        );
961    }
962
963    #[test]
964    fn nth_weekday_first_of_each_weekday_jan_2026() {
965        // 2026-01-01 is a Thursday. So in January 2026:
966        //   first Thu = 2026-01-01, first Fri = 2026-01-02,
967        //   first Sat = 2026-01-03, first Sun = 2026-01-04,
968        //   first Mon = 2026-01-05, first Tue = 2026-01-06,
969        //   first Wed = 2026-01-07.
970        let cases = [
971            (Weekday::Thu, 1),
972            (Weekday::Fri, 2),
973            (Weekday::Sat, 3),
974            (Weekday::Sun, 4),
975            (Weekday::Mon, 5),
976            (Weekday::Tue, 6),
977            (Weekday::Wed, 7),
978        ];
979        for (wd, day) in cases {
980            assert_eq!(
981                Date::nth_weekday_of_month(2026, 1, 1, wd).unwrap(),
982                Date::ymd(2026, 1, day).unwrap(),
983                "first {wd:?} of Jan 2026",
984            );
985        }
986    }
987
988    #[test]
989    fn nth_weekday_rejects_invalid_n_and_month() {
990        assert_eq!(
991            Date::nth_weekday_of_month(2026, 1, 0, Weekday::Mon),
992            Err(ValidationError::OutOfRange {
993                what: "n must be 1..=5",
994            })
995        );
996        assert_eq!(
997            Date::nth_weekday_of_month(2026, 13, 1, Weekday::Mon),
998            Err(ValidationError::InvalidDate {
999                rule: "month-out-of-range",
1000            })
1001        );
1002    }
1003
1004    // ─── easter_sunday ───────────────────────────────────────────────────
1005
1006    #[test]
1007    fn easter_sunday_known_dates() {
1008        // Published Easter table — Western (Gregorian) computus.
1009        let cases = [
1010            (2024, 3, 31),
1011            (2025, 4, 20),
1012            (2026, 4, 5),
1013            (2027, 3, 28),
1014            (2028, 4, 16),
1015            (2030, 4, 21),
1016            (2038, 4, 25),
1017        ];
1018        for (y, m, d) in cases {
1019            assert_eq!(
1020                Date::easter_sunday(y),
1021                Date::ymd(y, m, d).unwrap(),
1022                "Easter {y}",
1023            );
1024        }
1025    }
1026
1027    // ─── days_between ────────────────────────────────────────────────────
1028
1029    #[test]
1030    fn days_between_one_day() {
1031        let a = Date::ymd(2026, 1, 1).unwrap();
1032        let b = Date::ymd(2026, 1, 2).unwrap();
1033        assert_eq!(a.days_between(b), 1);
1034        assert_eq!(b.days_between(a), -1);
1035    }
1036
1037    #[test]
1038    fn days_between_same_date_is_zero() {
1039        let a = Date::ymd(2026, 5, 23).unwrap();
1040        assert_eq!(a.days_between(a), 0);
1041    }
1042
1043    #[test]
1044    fn days_between_year_is_365_or_366() {
1045        // 2025 is not a leap year.
1046        let s = Date::ymd(2025, 1, 1).unwrap();
1047        let e = Date::ymd(2026, 1, 1).unwrap();
1048        assert_eq!(s.days_between(e), 365);
1049        // 2024 is a leap year.
1050        let s = Date::ymd(2024, 1, 1).unwrap();
1051        let e = Date::ymd(2025, 1, 1).unwrap();
1052        assert_eq!(s.days_between(e), 366);
1053    }
1054
1055    #[test]
1056    fn days_between_additive() {
1057        let a = Date::ymd(2024, 1, 1).unwrap();
1058        let b = Date::ymd(2024, 7, 15).unwrap();
1059        let c = Date::ymd(2025, 3, 31).unwrap();
1060        assert_eq!(
1061            a.days_between(b) + b.days_between(c),
1062            a.days_between(c),
1063            "additivity",
1064        );
1065    }
1066
1067    #[test]
1068    fn days_between_additive_proptest() {
1069        use proptest::prelude::*;
1070        let anchor = Date::ymd(2026, 1, 1).unwrap();
1071        proptest!(|(n1 in -3_000i32..3_000, n2 in -3_000i32..3_000)| {
1072            let b = anchor.add_days(n1);
1073            let c = b.add_days(n2);
1074            prop_assert_eq!(
1075                anchor.days_between(b) + b.days_between(c),
1076                anchor.days_between(c),
1077            );
1078        });
1079    }
1080
1081    // ─── Ord / Copy ──────────────────────────────────────────────────────
1082
1083    #[test]
1084    fn date_ordering_is_chronological() {
1085        let a = Date::ymd(2026, 1, 1).unwrap();
1086        let b = Date::ymd(2026, 1, 2).unwrap();
1087        let c = Date::ymd(2026, 2, 1).unwrap();
1088        let d = Date::ymd(2027, 1, 1).unwrap();
1089        assert!(a < b);
1090        assert!(b < c);
1091        assert!(c < d);
1092    }
1093
1094    #[test]
1095    fn date_is_copy() {
1096        let a = Date::ymd(2026, 5, 23).unwrap();
1097        let b = a; // Copy
1098        assert_eq!(a, b);
1099    }
1100}