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}