Skip to main content

philiprehberger_date_utils/
lib.rs

1//! Date and time utilities — business days, date ranges, holiday calendars, and formatting shortcuts.
2//!
3//! # Example
4//!
5//! ```rust
6//! use philiprehberger_date_utils::DateRange;
7//! use chrono::NaiveDate;
8//!
9//! let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
10//! let end = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
11//! let range = DateRange::new(start, end);
12//! ```
13
14use chrono::{Datelike, Duration, NaiveDate, Weekday};
15
16// ---------------------------------------------------------------------------
17// HolidayCalendar trait
18// ---------------------------------------------------------------------------
19
20/// A calendar that can report which dates are holidays.
21pub trait HolidayCalendar {
22    /// Returns `true` if the given date is a holiday.
23    fn is_holiday(&self, date: NaiveDate) -> bool;
24
25    /// Returns all holidays observed in the given year.
26    fn holidays_in_year(&self, year: i32) -> Vec<NaiveDate>;
27}
28
29// ---------------------------------------------------------------------------
30// NoHolidayCalendar
31// ---------------------------------------------------------------------------
32
33/// A calendar with no holidays — only weekends are non-business days.
34pub struct NoHolidayCalendar;
35
36impl HolidayCalendar for NoHolidayCalendar {
37    fn is_holiday(&self, _date: NaiveDate) -> bool {
38        false
39    }
40
41    fn holidays_in_year(&self, _year: i32) -> Vec<NaiveDate> {
42        Vec::new()
43    }
44}
45
46// ---------------------------------------------------------------------------
47// USFederalCalendar
48// ---------------------------------------------------------------------------
49
50/// US Federal holiday calendar with weekend adjustment rules.
51///
52/// If a holiday falls on Saturday it is observed on the preceding Friday.
53/// If a holiday falls on Sunday it is observed on the following Monday.
54pub struct USFederalCalendar;
55
56impl USFederalCalendar {
57    /// Return the nth occurrence of a given weekday in (year, month).
58    fn nth_weekday(year: i32, month: u32, weekday: Weekday, n: u32) -> NaiveDate {
59        let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
60        let first_wd = first.weekday();
61        let days_ahead = (weekday.num_days_from_monday() as i64
62            - first_wd.num_days_from_monday() as i64
63            + 7)
64            % 7;
65        first + Duration::days(days_ahead + 7 * (n as i64 - 1))
66    }
67
68    /// Return the last occurrence of a given weekday in (year, month).
69    fn last_weekday(year: i32, month: u32, weekday: Weekday) -> NaiveDate {
70        let last_day = end_of_month(NaiveDate::from_ymd_opt(year, month, 1).unwrap());
71        let last_wd = last_day.weekday();
72        let days_back = (last_wd.num_days_from_monday() as i64
73            - weekday.num_days_from_monday() as i64
74            + 7)
75            % 7;
76        last_day - Duration::days(days_back)
77    }
78
79    /// Apply weekend adjustment: Saturday → Friday, Sunday → Monday.
80    fn observe(date: NaiveDate) -> NaiveDate {
81        match date.weekday() {
82            Weekday::Sat => date - Duration::days(1),
83            Weekday::Sun => date + Duration::days(1),
84            _ => date,
85        }
86    }
87
88    /// Raw (unadjusted) federal holidays for a given year.
89    fn raw_holidays(year: i32) -> Vec<NaiveDate> {
90        vec![
91            // New Year's Day
92            NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
93            // MLK Day — 3rd Monday in January
94            Self::nth_weekday(year, 1, Weekday::Mon, 3),
95            // Presidents' Day — 3rd Monday in February
96            Self::nth_weekday(year, 2, Weekday::Mon, 3),
97            // Memorial Day — last Monday in May
98            Self::last_weekday(year, 5, Weekday::Mon),
99            // Juneteenth
100            NaiveDate::from_ymd_opt(year, 6, 19).unwrap(),
101            // Independence Day
102            NaiveDate::from_ymd_opt(year, 7, 4).unwrap(),
103            // Labor Day — 1st Monday in September
104            Self::nth_weekday(year, 9, Weekday::Mon, 1),
105            // Columbus Day — 2nd Monday in October
106            Self::nth_weekday(year, 10, Weekday::Mon, 2),
107            // Veterans Day
108            NaiveDate::from_ymd_opt(year, 11, 11).unwrap(),
109            // Thanksgiving — 4th Thursday in November
110            Self::nth_weekday(year, 11, Weekday::Thu, 4),
111            // Christmas
112            NaiveDate::from_ymd_opt(year, 12, 25).unwrap(),
113        ]
114    }
115}
116
117impl HolidayCalendar for USFederalCalendar {
118    fn is_holiday(&self, date: NaiveDate) -> bool {
119        self.holidays_in_year(date.year())
120            .contains(&date)
121    }
122
123    fn holidays_in_year(&self, year: i32) -> Vec<NaiveDate> {
124        let mut holidays: Vec<NaiveDate> = Self::raw_holidays(year)
125            .into_iter()
126            .map(Self::observe)
127            .collect();
128        holidays.sort();
129        holidays.dedup();
130        holidays
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Business day functions
136// ---------------------------------------------------------------------------
137
138/// Returns `true` if the date is a business day (not a weekend, not a holiday).
139pub fn is_business_day(date: NaiveDate, calendar: &impl HolidayCalendar) -> bool {
140    let wd = date.weekday();
141    wd != Weekday::Sat && wd != Weekday::Sun && !calendar.is_holiday(date)
142}
143
144/// Returns the next business day strictly after `date`.
145pub fn next_business_day(date: NaiveDate, calendar: &impl HolidayCalendar) -> NaiveDate {
146    let mut d = date + Duration::days(1);
147    while !is_business_day(d, calendar) {
148        d += Duration::days(1);
149    }
150    d
151}
152
153/// Add (or subtract) business days to a date, skipping weekends and holidays.
154///
155/// - Positive `days` moves forward.
156/// - Negative `days` moves backward.
157/// - Zero returns the date itself (even if it is not a business day).
158pub fn add_business_days(
159    date: NaiveDate,
160    days: i32,
161    calendar: &impl HolidayCalendar,
162) -> NaiveDate {
163    if days == 0 {
164        return date;
165    }
166    let step = if days > 0 { 1i64 } else { -1i64 };
167    let mut remaining = days.unsigned_abs();
168    let mut current = date;
169    while remaining > 0 {
170        current += Duration::days(step);
171        if is_business_day(current, calendar) {
172            remaining -= 1;
173        }
174    }
175    current
176}
177
178/// Count the number of business days between `start` (inclusive) and `end` (exclusive).
179///
180/// If `end < start` the result is negative.
181pub fn business_days_between(
182    start: NaiveDate,
183    end: NaiveDate,
184    calendar: &impl HolidayCalendar,
185) -> i32 {
186    if start == end {
187        return 0;
188    }
189    let (from, to, sign) = if start < end {
190        (start, end, 1)
191    } else {
192        (end, start, -1)
193    };
194    let mut count = 0i32;
195    let mut d = from;
196    while d < to {
197        if is_business_day(d, calendar) {
198            count += 1;
199        }
200        d += Duration::days(1);
201    }
202    count * sign
203}
204
205// ---------------------------------------------------------------------------
206// DateRange
207// ---------------------------------------------------------------------------
208
209/// An inclusive date range from `start` to `end`.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct DateRange {
212    start: NaiveDate,
213    end: NaiveDate,
214}
215
216impl DateRange {
217    /// Create a new `DateRange`. Panics if `start > end`.
218    pub fn new(start: NaiveDate, end: NaiveDate) -> Self {
219        assert!(start <= end, "DateRange: start must be <= end");
220        Self { start, end }
221    }
222
223    /// Start date of the range.
224    pub fn start(&self) -> NaiveDate {
225        self.start
226    }
227
228    /// End date of the range.
229    pub fn end(&self) -> NaiveDate {
230        self.end
231    }
232
233    /// Iterate over every day in the range (inclusive).
234    pub fn iter_days(&self) -> impl Iterator<Item = NaiveDate> {
235        let start = self.start;
236        let end = self.end;
237        let mut current = start;
238        std::iter::from_fn(move || {
239            if current <= end {
240                let d = current;
241                current += Duration::days(1);
242                Some(d)
243            } else {
244                None
245            }
246        })
247    }
248
249    /// Iterate over week-start dates within the range.
250    ///
251    /// The first yielded date is `start`, then every 7 days after that
252    /// while still within the range.
253    pub fn iter_weeks(&self) -> impl Iterator<Item = NaiveDate> {
254        let start = self.start;
255        let end = self.end;
256        let mut current = start;
257        std::iter::from_fn(move || {
258            if current <= end {
259                let d = current;
260                current += Duration::weeks(1);
261                Some(d)
262            } else {
263                None
264            }
265        })
266    }
267
268    /// Returns `true` if `date` falls within the range (inclusive).
269    pub fn contains(&self, date: NaiveDate) -> bool {
270        date >= self.start && date <= self.end
271    }
272
273    /// Returns `true` if this range overlaps with `other`.
274    pub fn overlaps(&self, other: &DateRange) -> bool {
275        self.start <= other.end && other.start <= self.end
276    }
277
278    /// Number of days in the range (inclusive).
279    pub fn days_count(&self) -> i64 {
280        (self.end - self.start).num_days() + 1
281    }
282
283    /// Number of business days in the range (inclusive of both endpoints).
284    pub fn business_days_count(&self, calendar: &impl HolidayCalendar) -> i32 {
285        // business_days_between is start-inclusive, end-exclusive, so add one day to end
286        business_days_between(self.start, self.end + Duration::days(1), calendar)
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Utility functions
292// ---------------------------------------------------------------------------
293
294/// Returns the quarter (1-4) for the given date.
295pub fn quarter(date: NaiveDate) -> u8 {
296    ((date.month() - 1) / 3 + 1) as u8
297}
298
299/// Returns the fiscal year for a given date with a custom fiscal-year start month.
300///
301/// If the date's month is >= `start_month`, the fiscal year equals the calendar year.
302/// Otherwise it equals the previous calendar year.
303///
304/// For example, with `start_month = 10` (October), September 2026 → FY 2025,
305/// October 2026 → FY 2026.
306pub fn fiscal_year(date: NaiveDate, start_month: u32) -> i32 {
307    if start_month <= 1 || date.month() >= start_month {
308        date.year()
309    } else {
310        date.year() - 1
311    }
312}
313
314/// Returns the number of full years between two dates (age-style calculation).
315///
316/// If `to < from` the result is negative.
317pub fn years_between(from: NaiveDate, to: NaiveDate) -> i32 {
318    if from <= to {
319        let mut years = to.year() - from.year();
320        if (to.month(), to.day()) < (from.month(), from.day()) {
321            years -= 1;
322        }
323        years
324    } else {
325        -years_between(to, from)
326    }
327}
328
329/// Format a date as "March 19, 2026".
330pub fn format_long(date: NaiveDate) -> String {
331    date.format("%B %-d, %Y").to_string()
332}
333
334/// Format a date as "Mar 19, 2026".
335pub fn format_short(date: NaiveDate) -> String {
336    date.format("%b %-d, %Y").to_string()
337}
338
339/// Format a date as "2026-03-19".
340pub fn format_iso(date: NaiveDate) -> String {
341    date.format("%Y-%m-%d").to_string()
342}
343
344/// Returns the first day of the month containing `date`.
345pub fn start_of_month(date: NaiveDate) -> NaiveDate {
346    NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap()
347}
348
349/// Returns the last day of the month containing `date`.
350pub fn end_of_month(date: NaiveDate) -> NaiveDate {
351    let (y, m) = if date.month() == 12 {
352        (date.year() + 1, 1)
353    } else {
354        (date.year(), date.month() + 1)
355    };
356    NaiveDate::from_ymd_opt(y, m, 1).unwrap() - Duration::days(1)
357}
358
359/// Returns the first day of the quarter containing `date`.
360pub fn start_of_quarter(date: NaiveDate) -> NaiveDate {
361    let q = quarter(date);
362    let month = (q as u32 - 1) * 3 + 1;
363    NaiveDate::from_ymd_opt(date.year(), month, 1).unwrap()
364}
365
366/// Returns the last day of the quarter containing `date`.
367pub fn end_of_quarter(date: NaiveDate) -> NaiveDate {
368    let q = quarter(date);
369    let last_month = q as u32 * 3;
370    end_of_month(NaiveDate::from_ymd_opt(date.year(), last_month, 1).unwrap())
371}
372
373// ---------------------------------------------------------------------------
374// Tests
375// ---------------------------------------------------------------------------
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn d(year: i32, month: u32, day: u32) -> NaiveDate {
382        NaiveDate::from_ymd_opt(year, month, day).unwrap()
383    }
384
385    // -- US Federal Holiday tests --
386
387    #[test]
388    fn us_holidays_2026_known_dates() {
389        let cal = USFederalCalendar;
390        let holidays = cal.holidays_in_year(2026);
391
392        // New Year's Day 2026 = Thursday
393        assert!(holidays.contains(&d(2026, 1, 1)));
394        // MLK Day 2026 = 3rd Monday Jan = Jan 19
395        assert!(holidays.contains(&d(2026, 1, 19)));
396        // Presidents' Day = 3rd Monday Feb = Feb 16
397        assert!(holidays.contains(&d(2026, 2, 16)));
398        // Memorial Day = last Monday May = May 25
399        assert!(holidays.contains(&d(2026, 5, 25)));
400        // Juneteenth = June 19, 2026 is Friday
401        assert!(holidays.contains(&d(2026, 6, 19)));
402        // Independence Day = July 4, 2026 is Saturday → observed Friday July 3
403        assert!(holidays.contains(&d(2026, 7, 3)));
404        assert!(!holidays.contains(&d(2026, 7, 4)));
405        // Labor Day = 1st Monday Sep = Sep 7
406        assert!(holidays.contains(&d(2026, 9, 7)));
407        // Columbus Day = 2nd Monday Oct = Oct 12
408        assert!(holidays.contains(&d(2026, 10, 12)));
409        // Veterans Day = Nov 11, 2026 is Wednesday
410        assert!(holidays.contains(&d(2026, 11, 11)));
411        // Thanksgiving = 4th Thursday Nov = Nov 26
412        assert!(holidays.contains(&d(2026, 11, 26)));
413        // Christmas = Dec 25, 2026 is Friday
414        assert!(holidays.contains(&d(2026, 12, 25)));
415    }
416
417    #[test]
418    fn us_holiday_weekend_adjustment_saturday() {
419        let cal = USFederalCalendar;
420        // July 4, 2026 is Saturday → observed Friday July 3
421        assert!(cal.is_holiday(d(2026, 7, 3)));
422        assert!(!cal.is_holiday(d(2026, 7, 4)));
423    }
424
425    #[test]
426    fn us_holiday_weekend_adjustment_sunday() {
427        let cal = USFederalCalendar;
428        // Juneteenth 2021: June 19 is Saturday → observed Friday June 18
429        assert!(cal.is_holiday(d(2021, 6, 18)));
430        // Christmas 2022: Dec 25 is Sunday → observed Monday Dec 26
431        assert!(cal.is_holiday(d(2022, 12, 26)));
432    }
433
434    #[test]
435    fn no_holiday_calendar() {
436        let cal = NoHolidayCalendar;
437        assert!(!cal.is_holiday(d(2026, 12, 25)));
438        assert!(cal.holidays_in_year(2026).is_empty());
439    }
440
441    // -- Business day tests --
442
443    #[test]
444    fn is_business_day_weekday() {
445        let cal = NoHolidayCalendar;
446        // Wednesday
447        assert!(is_business_day(d(2026, 3, 18), &cal));
448        // Saturday
449        assert!(!is_business_day(d(2026, 3, 14), &cal));
450        // Sunday
451        assert!(!is_business_day(d(2026, 3, 15), &cal));
452    }
453
454    #[test]
455    fn is_business_day_with_holiday() {
456        let cal = USFederalCalendar;
457        // Christmas 2026 is Thursday Dec 25
458        assert!(!is_business_day(d(2026, 12, 25), &cal));
459        // Dec 24 is Wednesday, not a holiday
460        assert!(is_business_day(d(2026, 12, 24), &cal));
461    }
462
463    #[test]
464    fn add_business_days_forward() {
465        let cal = NoHolidayCalendar;
466        // Wednesday + 3 business days = Monday (skip weekend)
467        assert_eq!(add_business_days(d(2026, 3, 18), 3, &cal), d(2026, 3, 23));
468    }
469
470    #[test]
471    fn add_business_days_backward() {
472        let cal = NoHolidayCalendar;
473        // Monday - 1 business day = Friday
474        assert_eq!(add_business_days(d(2026, 3, 23), -1, &cal), d(2026, 3, 20));
475    }
476
477    #[test]
478    fn add_business_days_zero() {
479        let cal = NoHolidayCalendar;
480        let saturday = d(2026, 3, 14);
481        assert_eq!(add_business_days(saturday, 0, &cal), saturday);
482    }
483
484    #[test]
485    fn add_business_days_with_holiday() {
486        let cal = USFederalCalendar;
487        // Dec 24 (Thu) + 1 business day should skip Christmas (Fri Dec 25) → Mon Dec 28
488        assert_eq!(add_business_days(d(2026, 12, 24), 1, &cal), d(2026, 12, 28));
489    }
490
491    #[test]
492    fn business_days_between_same_day() {
493        let cal = NoHolidayCalendar;
494        assert_eq!(business_days_between(d(2026, 3, 18), d(2026, 3, 18), &cal), 0);
495    }
496
497    #[test]
498    fn business_days_between_one_week() {
499        let cal = NoHolidayCalendar;
500        // Mon to next Mon = 5 business days
501        assert_eq!(
502            business_days_between(d(2026, 3, 16), d(2026, 3, 23), &cal),
503            5
504        );
505    }
506
507    #[test]
508    fn business_days_between_reversed() {
509        let cal = NoHolidayCalendar;
510        assert_eq!(
511            business_days_between(d(2026, 3, 23), d(2026, 3, 16), &cal),
512            -5
513        );
514    }
515
516    #[test]
517    fn business_days_between_with_holiday() {
518        let cal = USFederalCalendar;
519        // Week of Dec 21-26, 2026: Christmas (Dec 25 Thu) is holiday
520        // Mon-Fri = 5, minus 1 holiday = 4
521        assert_eq!(
522            business_days_between(d(2026, 12, 21), d(2026, 12, 26), &cal),
523            4
524        );
525    }
526
527    #[test]
528    fn next_business_day_from_friday() {
529        let cal = NoHolidayCalendar;
530        // Friday → Monday
531        assert_eq!(next_business_day(d(2026, 3, 20), &cal), d(2026, 3, 23));
532    }
533
534    #[test]
535    fn next_business_day_from_saturday() {
536        let cal = NoHolidayCalendar;
537        // Saturday → Monday
538        assert_eq!(next_business_day(d(2026, 3, 21), &cal), d(2026, 3, 23));
539    }
540
541    // -- DateRange tests --
542
543    #[test]
544    fn date_range_days_count() {
545        let range = DateRange::new(d(2026, 3, 1), d(2026, 3, 31));
546        assert_eq!(range.days_count(), 31);
547    }
548
549    #[test]
550    fn date_range_iter_days() {
551        let range = DateRange::new(d(2026, 3, 1), d(2026, 3, 3));
552        let days: Vec<_> = range.iter_days().collect();
553        assert_eq!(days, vec![d(2026, 3, 1), d(2026, 3, 2), d(2026, 3, 3)]);
554    }
555
556    #[test]
557    fn date_range_iter_weeks() {
558        let range = DateRange::new(d(2026, 3, 1), d(2026, 3, 20));
559        let weeks: Vec<_> = range.iter_weeks().collect();
560        assert_eq!(
561            weeks,
562            vec![d(2026, 3, 1), d(2026, 3, 8), d(2026, 3, 15)]
563        );
564    }
565
566    #[test]
567    fn date_range_contains() {
568        let range = DateRange::new(d(2026, 3, 10), d(2026, 3, 20));
569        assert!(range.contains(d(2026, 3, 10)));
570        assert!(range.contains(d(2026, 3, 15)));
571        assert!(range.contains(d(2026, 3, 20)));
572        assert!(!range.contains(d(2026, 3, 9)));
573        assert!(!range.contains(d(2026, 3, 21)));
574    }
575
576    #[test]
577    fn date_range_overlaps() {
578        let a = DateRange::new(d(2026, 3, 1), d(2026, 3, 15));
579        let b = DateRange::new(d(2026, 3, 10), d(2026, 3, 25));
580        let c = DateRange::new(d(2026, 3, 16), d(2026, 3, 20));
581        assert!(a.overlaps(&b));
582        assert!(b.overlaps(&a));
583        assert!(!a.overlaps(&c));
584    }
585
586    #[test]
587    fn date_range_business_days_count() {
588        let cal = NoHolidayCalendar;
589        // March 16 (Mon) to March 20 (Fri) = 5 business days
590        let range = DateRange::new(d(2026, 3, 16), d(2026, 3, 20));
591        assert_eq!(range.business_days_count(&cal), 5);
592    }
593
594    #[test]
595    #[should_panic(expected = "start must be <= end")]
596    fn date_range_invalid() {
597        DateRange::new(d(2026, 3, 20), d(2026, 3, 10));
598    }
599
600    // -- Utility function tests --
601
602    #[test]
603    fn test_quarter() {
604        assert_eq!(quarter(d(2026, 1, 15)), 1);
605        assert_eq!(quarter(d(2026, 3, 31)), 1);
606        assert_eq!(quarter(d(2026, 4, 1)), 2);
607        assert_eq!(quarter(d(2026, 6, 30)), 2);
608        assert_eq!(quarter(d(2026, 7, 1)), 3);
609        assert_eq!(quarter(d(2026, 9, 30)), 3);
610        assert_eq!(quarter(d(2026, 10, 1)), 4);
611        assert_eq!(quarter(d(2026, 12, 31)), 4);
612    }
613
614    #[test]
615    fn test_fiscal_year() {
616        // October start (US government)
617        assert_eq!(fiscal_year(d(2026, 9, 30), 10), 2025);
618        assert_eq!(fiscal_year(d(2026, 10, 1), 10), 2026);
619        assert_eq!(fiscal_year(d(2026, 12, 31), 10), 2026);
620        // January start = calendar year
621        assert_eq!(fiscal_year(d(2026, 6, 15), 1), 2026);
622    }
623
624    #[test]
625    fn test_years_between() {
626        assert_eq!(years_between(d(2000, 3, 19), d(2026, 3, 19)), 26);
627        assert_eq!(years_between(d(2000, 3, 19), d(2026, 3, 18)), 25);
628        assert_eq!(years_between(d(2026, 3, 19), d(2000, 3, 19)), -26);
629    }
630
631    #[test]
632    fn test_years_between_leap_year() {
633        // Born on leap day
634        assert_eq!(years_between(d(2000, 2, 29), d(2026, 2, 28)), 25);
635        assert_eq!(years_between(d(2000, 2, 29), d(2026, 3, 1)), 26);
636    }
637
638    #[test]
639    fn test_format_long() {
640        assert_eq!(format_long(d(2026, 3, 19)), "March 19, 2026");
641    }
642
643    #[test]
644    fn test_format_short() {
645        assert_eq!(format_short(d(2026, 3, 19)), "Mar 19, 2026");
646    }
647
648    #[test]
649    fn test_format_iso() {
650        assert_eq!(format_iso(d(2026, 3, 19)), "2026-03-19");
651    }
652
653    #[test]
654    fn test_start_of_month() {
655        assert_eq!(start_of_month(d(2026, 3, 19)), d(2026, 3, 1));
656    }
657
658    #[test]
659    fn test_end_of_month() {
660        assert_eq!(end_of_month(d(2026, 3, 19)), d(2026, 3, 31));
661        // February non-leap
662        assert_eq!(end_of_month(d(2026, 2, 10)), d(2026, 2, 28));
663        // February leap
664        assert_eq!(end_of_month(d(2024, 2, 10)), d(2024, 2, 29));
665        // December
666        assert_eq!(end_of_month(d(2026, 12, 5)), d(2026, 12, 31));
667    }
668
669    #[test]
670    fn test_start_of_quarter() {
671        assert_eq!(start_of_quarter(d(2026, 3, 19)), d(2026, 1, 1));
672        assert_eq!(start_of_quarter(d(2026, 5, 10)), d(2026, 4, 1));
673        assert_eq!(start_of_quarter(d(2026, 8, 20)), d(2026, 7, 1));
674        assert_eq!(start_of_quarter(d(2026, 11, 5)), d(2026, 10, 1));
675    }
676
677    #[test]
678    fn test_end_of_quarter() {
679        assert_eq!(end_of_quarter(d(2026, 2, 15)), d(2026, 3, 31));
680        assert_eq!(end_of_quarter(d(2026, 5, 10)), d(2026, 6, 30));
681        assert_eq!(end_of_quarter(d(2026, 8, 20)), d(2026, 9, 30));
682        assert_eq!(end_of_quarter(d(2026, 11, 5)), d(2026, 12, 31));
683    }
684
685    #[test]
686    fn add_business_days_across_year_boundary() {
687        let cal = USFederalCalendar;
688        // Dec 31, 2025 is Wednesday. Jan 1 2026 is holiday (Thursday).
689        // +1 business day from Dec 31 → Jan 2 (Friday)
690        assert_eq!(add_business_days(d(2025, 12, 31), 1, &cal), d(2026, 1, 2));
691    }
692
693    #[test]
694    fn business_days_between_across_year_boundary() {
695        let cal = USFederalCalendar;
696        // Dec 29 (Mon) to Jan 2 (Fri): Dec 29, 30, 31 are biz days, Jan 1 holiday
697        // So 3 business days
698        assert_eq!(
699            business_days_between(d(2025, 12, 29), d(2026, 1, 2), &cal),
700            3
701        );
702    }
703}