1use chrono::{Datelike, Duration, NaiveDate, Weekday};
15
16pub trait HolidayCalendar {
22 fn is_holiday(&self, date: NaiveDate) -> bool;
24
25 fn holidays_in_year(&self, year: i32) -> Vec<NaiveDate>;
27}
28
29pub 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
46pub struct USFederalCalendar;
55
56impl USFederalCalendar {
57 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 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 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 fn raw_holidays(year: i32) -> Vec<NaiveDate> {
90 vec![
91 NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
93 Self::nth_weekday(year, 1, Weekday::Mon, 3),
95 Self::nth_weekday(year, 2, Weekday::Mon, 3),
97 Self::last_weekday(year, 5, Weekday::Mon),
99 NaiveDate::from_ymd_opt(year, 6, 19).unwrap(),
101 NaiveDate::from_ymd_opt(year, 7, 4).unwrap(),
103 Self::nth_weekday(year, 9, Weekday::Mon, 1),
105 Self::nth_weekday(year, 10, Weekday::Mon, 2),
107 NaiveDate::from_ymd_opt(year, 11, 11).unwrap(),
109 Self::nth_weekday(year, 11, Weekday::Thu, 4),
111 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
134pub 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
144pub 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
153pub 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
178pub 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#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct DateRange {
212 start: NaiveDate,
213 end: NaiveDate,
214}
215
216impl DateRange {
217 pub fn new(start: NaiveDate, end: NaiveDate) -> Self {
219 assert!(start <= end, "DateRange: start must be <= end");
220 Self { start, end }
221 }
222
223 pub fn start(&self) -> NaiveDate {
225 self.start
226 }
227
228 pub fn end(&self) -> NaiveDate {
230 self.end
231 }
232
233 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 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 pub fn contains(&self, date: NaiveDate) -> bool {
270 date >= self.start && date <= self.end
271 }
272
273 pub fn overlaps(&self, other: &DateRange) -> bool {
275 self.start <= other.end && other.start <= self.end
276 }
277
278 pub fn days_count(&self) -> i64 {
280 (self.end - self.start).num_days() + 1
281 }
282
283 pub fn business_days_count(&self, calendar: &impl HolidayCalendar) -> i32 {
285 business_days_between(self.start, self.end + Duration::days(1), calendar)
287 }
288}
289
290pub fn quarter(date: NaiveDate) -> u8 {
296 ((date.month() - 1) / 3 + 1) as u8
297}
298
299pub 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
314pub 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
329pub fn format_long(date: NaiveDate) -> String {
331 date.format("%B %-d, %Y").to_string()
332}
333
334pub fn format_short(date: NaiveDate) -> String {
336 date.format("%b %-d, %Y").to_string()
337}
338
339pub fn format_iso(date: NaiveDate) -> String {
341 date.format("%Y-%m-%d").to_string()
342}
343
344pub fn start_of_month(date: NaiveDate) -> NaiveDate {
346 NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap()
347}
348
349pub 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
359pub 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
366pub 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#[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 #[test]
388 fn us_holidays_2026_known_dates() {
389 let cal = USFederalCalendar;
390 let holidays = cal.holidays_in_year(2026);
391
392 assert!(holidays.contains(&d(2026, 1, 1)));
394 assert!(holidays.contains(&d(2026, 1, 19)));
396 assert!(holidays.contains(&d(2026, 2, 16)));
398 assert!(holidays.contains(&d(2026, 5, 25)));
400 assert!(holidays.contains(&d(2026, 6, 19)));
402 assert!(holidays.contains(&d(2026, 7, 3)));
404 assert!(!holidays.contains(&d(2026, 7, 4)));
405 assert!(holidays.contains(&d(2026, 9, 7)));
407 assert!(holidays.contains(&d(2026, 10, 12)));
409 assert!(holidays.contains(&d(2026, 11, 11)));
411 assert!(holidays.contains(&d(2026, 11, 26)));
413 assert!(holidays.contains(&d(2026, 12, 25)));
415 }
416
417 #[test]
418 fn us_holiday_weekend_adjustment_saturday() {
419 let cal = USFederalCalendar;
420 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 assert!(cal.is_holiday(d(2021, 6, 18)));
430 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 #[test]
444 fn is_business_day_weekday() {
445 let cal = NoHolidayCalendar;
446 assert!(is_business_day(d(2026, 3, 18), &cal));
448 assert!(!is_business_day(d(2026, 3, 14), &cal));
450 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 assert!(!is_business_day(d(2026, 12, 25), &cal));
459 assert!(is_business_day(d(2026, 12, 24), &cal));
461 }
462
463 #[test]
464 fn add_business_days_forward() {
465 let cal = NoHolidayCalendar;
466 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 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 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 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 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 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 assert_eq!(next_business_day(d(2026, 3, 21), &cal), d(2026, 3, 23));
539 }
540
541 #[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 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 #[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 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 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 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 assert_eq!(end_of_month(d(2026, 2, 10)), d(2026, 2, 28));
663 assert_eq!(end_of_month(d(2024, 2, 10)), d(2024, 2, 29));
665 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 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 assert_eq!(
699 business_days_between(d(2025, 12, 29), d(2026, 1, 2), &cal),
700 3
701 );
702 }
703}