1use crate::holidays::HolidayCalendar;
2use chrono::NaiveDate;
3
4#[derive(Debug, Clone, PartialEq)]
6pub struct Repeater {
7 pub repeater_type: RepeaterType,
9 pub value: u32,
11 pub unit: RepeaterUnit,
13}
14
15#[derive(Debug, Clone, PartialEq)]
17pub enum RepeaterType {
18 Cumulative,
20 CatchUp,
22 Restart,
24}
25
26impl RepeaterType {
27 pub fn prefix(&self) -> &'static str {
29 match self {
30 RepeaterType::Cumulative => "+",
31 RepeaterType::CatchUp => "++",
32 RepeaterType::Restart => ".+",
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub enum RepeaterUnit {
40 Day,
42 Week,
44 Month,
46 Year,
48 Hour,
53 Workday,
56}
57
58impl RepeaterUnit {
59 pub fn suffix(&self) -> &'static str {
61 match self {
62 RepeaterUnit::Day => "d",
63 RepeaterUnit::Week => "w",
64 RepeaterUnit::Month => "m",
65 RepeaterUnit::Year => "y",
66 RepeaterUnit::Hour => "h",
67 RepeaterUnit::Workday => "wd",
68 }
69 }
70}
71
72impl Repeater {
73 pub fn canonical(&self) -> String {
76 format!(
77 "{}{}{}",
78 self.repeater_type.prefix(),
79 self.value,
80 self.unit.suffix()
81 )
82 }
83}
84
85pub fn parse_repeater(s: &str) -> Option<Repeater> {
94 let s = s.trim();
95
96 let (repeater_type, rest) = if let Some(r) = s.strip_prefix(".+") {
97 (RepeaterType::Restart, r)
98 } else if let Some(r) = s.strip_prefix("++") {
99 (RepeaterType::CatchUp, r)
100 } else if let Some(r) = s.strip_prefix('+') {
101 (RepeaterType::Cumulative, r)
102 } else {
103 tracing::trace!(input = %s, reason = "missing prefix", "parse_repeater_rejected");
104 return None;
105 };
106
107 if rest.is_empty() {
108 tracing::trace!(input = %s, reason = "empty after prefix", "parse_repeater_rejected");
109 return None;
110 }
111
112 if let Some(value_str) = rest.strip_suffix("wd") {
114 let value: u32 = match value_str.parse() {
115 Ok(v) => v,
116 Err(_) => {
117 tracing::trace!(input = %s, reason = "non-numeric value for wd", "parse_repeater_rejected");
118 return None;
119 }
120 };
121 if value == 0 {
122 tracing::trace!(input = %s, reason = "zero step for wd", "parse_repeater_rejected");
123 return None;
124 }
125 return Some(Repeater {
126 repeater_type,
127 value,
128 unit: RepeaterUnit::Workday,
129 });
130 }
131
132 let unit_char = match rest.chars().last() {
133 Some(c) => c,
134 None => {
135 tracing::trace!(input = %s, reason = "empty rest after wd check", "parse_repeater_rejected");
136 return None;
137 }
138 };
139 let value_str = &rest[..rest.len() - unit_char.len_utf8()];
140 let value: u32 = match value_str.parse() {
141 Ok(v) => v,
142 Err(_) => {
143 tracing::trace!(input = %s, reason = "non-numeric value", "parse_repeater_rejected");
144 return None;
145 }
146 };
147 if value == 0 {
148 tracing::trace!(input = %s, reason = "zero step", "parse_repeater_rejected");
149 return None;
150 }
151
152 let unit = match unit_char {
153 'd' => RepeaterUnit::Day,
154 'w' => RepeaterUnit::Week,
155 'm' => RepeaterUnit::Month,
156 'y' => RepeaterUnit::Year,
157 'h' => RepeaterUnit::Hour,
158 _ => {
159 tracing::trace!(
160 input = %s,
161 unit_char = %unit_char,
162 reason = "unknown unit",
163 "parse_repeater_rejected"
164 );
165 return None;
166 }
167 };
168
169 Some(Repeater {
170 repeater_type,
171 value,
172 unit,
173 })
174}
175
176#[derive(Debug, Clone, Copy, PartialEq)]
178pub enum DatePreference {
179 Past,
181 Future,
183}
184
185fn pick(
197 prefer: DatePreference,
198 current: NaiveDate,
199 n1: NaiveDate,
200 n2: NaiveDate,
201) -> Option<NaiveDate> {
202 Some(match prefer {
203 DatePreference::Past => {
204 if current >= n2 {
205 n2
206 } else {
207 n1
208 }
209 }
210 DatePreference::Future => {
211 if current <= n1 {
212 n1
213 } else {
214 n2
215 }
216 }
217 })
218}
219
220fn bracket_year(
224 base_date: NaiveDate,
225 current: NaiveDate,
226 value: u32,
227) -> Option<(NaiveDate, NaiveDate)> {
228 use chrono::Datelike;
229
230 let value = value as i32;
231 let base_month = base_date.month();
232 let base_day = base_date.day();
233 let base_year = base_date.year();
234 let current_year = current.year();
235
236 let max_complete = (current_year - base_year) / value;
239 let mut n1: Option<NaiveDate> = None;
240 let mut k = max_complete;
241 while k >= 0 {
242 let y = base_year + k * value;
243 if let Some(d) = NaiveDate::from_ymd_opt(y, base_month, base_day) {
244 if d <= current {
245 n1 = Some(d);
246 break;
247 }
248 }
249 k -= 1;
250 }
251 debug_assert!(
255 n1.is_some(),
256 "bracket_year: n1=None despite current >= base_date"
257 );
258 let n1 = n1?;
259
260 let mut k2 = (n1.year() - base_year) / value + 1;
262 let safety_limit = max_complete.max(0) + 200;
270 let n2 = loop {
271 if k2 > safety_limit {
272 return None;
273 }
274 let y = base_year + k2 * value;
275 if let Some(d) = NaiveDate::from_ymd_opt(y, base_month, base_day) {
276 if d > current {
277 break d;
278 }
279 }
280 k2 += 1;
281 };
282
283 Some((n1, n2))
284}
285
286fn bracket_month(
300 base_date: NaiveDate,
301 current: NaiveDate,
302 value: u32,
303) -> Option<(NaiveDate, NaiveDate)> {
304 use chrono::Datelike;
305
306 let months_to_add = value as i32;
307
308 let months_diff = (current.year() - base_date.year()) * 12
309 + (current.month() as i32 - base_date.month() as i32);
310 let mut complete_months = (months_diff / months_to_add) * months_to_add;
311
312 let mut n1 = add_months(base_date, complete_months)?;
313 if n1 > current {
314 complete_months -= months_to_add;
320 n1 = add_months(base_date, complete_months)?;
321 }
322 debug_assert!(
323 n1 <= current,
324 "bracket_month: n1={n1} still after current={current} after step-back"
325 );
326
327 let n2 = add_months(base_date, complete_months + months_to_add)?;
328
329 Some((n1, n2))
330}
331
332fn bracket_uniform_days(
340 base_date: NaiveDate,
341 current: NaiveDate,
342 days: i64,
343) -> Option<(NaiveDate, NaiveDate)> {
344 let days_diff = (current - base_date).num_days();
345 let complete_periods = days_diff / days;
346
347 let offset = complete_periods.checked_mul(days)?;
348 let n1 = base_date.checked_add_signed(chrono::TimeDelta::try_days(offset)?)?;
349 let n2 = n1.checked_add_signed(chrono::TimeDelta::try_days(days)?)?;
350 Some((n1, n2))
351}
352
353fn bracket_workday(base_date: NaiveDate, current: NaiveDate, value: u32) -> (NaiveDate, NaiveDate) {
356 let calendar = HolidayCalendar::global();
357 let step = value as i64;
358
359 let m = calendar.workdays_between_exclusive(base_date, current);
360 let k = m / step;
361
362 let n1 = if k == 0 {
363 base_date
364 } else {
365 calendar.nth_workday_after(base_date, (k * step) as u64)
366 };
367 let n2 = calendar.nth_workday_after(n1, step as u64);
368 (n1, n2)
369}
370
371pub fn closest_date(
381 base_date: NaiveDate,
382 current: NaiveDate,
383 prefer: DatePreference,
384 repeater: &Repeater,
385) -> Option<NaiveDate> {
386 if current == base_date {
387 return Some(base_date);
388 }
389 if current < base_date {
390 return match prefer {
391 DatePreference::Past => None,
392 DatePreference::Future => Some(base_date),
393 };
394 }
395
396 let (n1, n2) = match repeater.unit {
397 RepeaterUnit::Year => bracket_year(base_date, current, repeater.value)?,
398 RepeaterUnit::Month => bracket_month(base_date, current, repeater.value)?,
399 RepeaterUnit::Day => bracket_uniform_days(base_date, current, repeater.value as i64)?,
400 RepeaterUnit::Week => bracket_uniform_days(base_date, current, repeater.value as i64 * 7)?,
403 RepeaterUnit::Hour => bracket_uniform_days(base_date, current, 1)?,
410 RepeaterUnit::Workday => bracket_workday(base_date, current, repeater.value),
411 };
412
413 pick(prefer, current, n1, n2)
414}
415
416pub fn add_months(date: NaiveDate, months: i32) -> Option<NaiveDate> {
419 use chrono::Datelike;
420
421 let total = (date.year() as i64) * 12 + (date.month() as i64 - 1) + months as i64;
423 let year = total.div_euclid(12);
424 let month = (total.rem_euclid(12) + 1) as u32;
425 let year: i32 = year.try_into().ok()?;
426
427 let day = date.day().min(days_in_month(year, month));
428 NaiveDate::from_ymd_opt(year, month, day)
429}
430
431fn days_in_month(year: i32, month: u32) -> u32 {
432 match month {
433 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
434 4 | 6 | 9 | 11 => 30,
435 2 => {
436 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
437 29
438 } else {
439 28
440 }
441 }
442 _ => unreachable!("invalid month: {month}"),
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn test_parse_workday_repeater() {
452 let r = parse_repeater("+1wd").unwrap();
453 assert_eq!(r.repeater_type, RepeaterType::Cumulative);
454 assert_eq!(r.value, 1);
455 assert_eq!(r.unit, RepeaterUnit::Workday);
456 }
457
458 #[test]
459 fn test_parse_workday_repeater_multiple() {
460 let r = parse_repeater("+2wd").unwrap();
461 assert_eq!(r.value, 2);
462 assert_eq!(r.unit, RepeaterUnit::Workday);
463 }
464
465 #[test]
466 fn test_parse_workday_catchup() {
467 let r = parse_repeater("++1wd").unwrap();
468 assert_eq!(r.repeater_type, RepeaterType::CatchUp);
469 assert_eq!(r.unit, RepeaterUnit::Workday);
470 }
471
472 #[test]
473 fn prefix_check_order_distinguishes_catchup_from_cumulative() {
474 let cat = parse_repeater("++1d").expect("++1d must parse");
483 assert_eq!(
484 cat.repeater_type,
485 RepeaterType::CatchUp,
486 "++ must be matched before +; got Cumulative instead"
487 );
488 let cum = parse_repeater("+1d").expect("+1d must parse");
489 assert_eq!(cum.repeater_type, RepeaterType::Cumulative);
490 let res = parse_repeater(".+1d").expect(".+1d must parse");
491 assert_eq!(res.repeater_type, RepeaterType::Restart);
492 }
493
494 #[test]
495 fn test_parse_workday_restart() {
496 let r = parse_repeater(".+1wd").unwrap();
497 assert_eq!(r.repeater_type, RepeaterType::Restart);
498 assert_eq!(r.unit, RepeaterUnit::Workday);
499 }
500
501 #[test]
502 fn test_parse_regular_day() {
503 let r = parse_repeater("+1d").unwrap();
504 assert_eq!(r.unit, RepeaterUnit::Day);
505 }
506
507 #[test]
508 fn test_parse_repeater_zero_rejected() {
509 assert!(parse_repeater("+0d").is_none());
510 assert!(parse_repeater("+0wd").is_none());
511 assert!(parse_repeater("++0w").is_none());
512 assert!(parse_repeater(".+0m").is_none());
513 }
514
515 #[test]
516 fn test_parse_repeater_multibyte_last_char_no_panic() {
517 assert!(parse_repeater("+1й").is_none());
520 assert!(parse_repeater("++2д").is_none());
521 assert!(parse_repeater(".+3Й").is_none());
522 assert!(parse_repeater("+1\u{1F600}").is_none());
523 }
524
525 #[test]
526 fn test_parse_repeater_multibyte_in_value_no_panic() {
527 assert!(parse_repeater("+1ф5d").is_none());
535 assert!(parse_repeater("++2д3w").is_none());
536 assert!(parse_repeater(".+1喜2y").is_none());
537 assert!(parse_repeater("+1ф2wd").is_none());
539 }
540
541 #[test]
542 fn parse_repeater_rejects_each_failure_mode() {
543 assert!(parse_repeater("1d").is_none(), "no prefix");
549 assert!(parse_repeater("+").is_none(), "prefix only");
551 assert!(parse_repeater("+abwd").is_none(), "non-numeric wd");
553 assert!(parse_repeater("+1q").is_none(), "unknown unit");
555 assert!(parse_repeater("+abd").is_none(), "non-numeric value");
557 }
558
559 #[test]
560 fn test_parse_year_repeater() {
561 let r = parse_repeater("+1y").unwrap();
562 assert_eq!(r.repeater_type, RepeaterType::Cumulative);
563 assert_eq!(r.value, 1);
564 assert_eq!(r.unit, RepeaterUnit::Year);
565 }
566
567 #[test]
568 fn test_parse_hour_repeater() {
569 let r = parse_repeater("+1h").unwrap();
570 assert_eq!(r.repeater_type, RepeaterType::Cumulative);
571 assert_eq!(r.value, 1);
572 assert_eq!(r.unit, RepeaterUnit::Hour);
573 }
574
575 #[test]
578 fn test_closest_date_workday_value_2() {
579 let base = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
581 let repeater = Repeater {
582 repeater_type: RepeaterType::Cumulative,
583 value: 2,
584 unit: RepeaterUnit::Workday,
585 };
586
587 let c1 = NaiveDate::from_ymd_opt(2025, 12, 10).unwrap();
589 let past = closest_date(base, c1, DatePreference::Past, &repeater).unwrap();
590 assert_eq!(past, c1, "+2wd: 12-10 must be an occurrence");
591
592 let c2 = NaiveDate::from_ymd_opt(2025, 12, 11).unwrap();
594 let past = closest_date(base, c2, DatePreference::Past, &repeater).unwrap();
595 let fut = closest_date(base, c2, DatePreference::Future, &repeater).unwrap();
596 assert_eq!(past, NaiveDate::from_ymd_opt(2025, 12, 10).unwrap());
597 assert_eq!(fut, NaiveDate::from_ymd_opt(2025, 12, 12).unwrap());
598 }
599
600 #[test]
601 fn test_closest_date_hour_repeater_advances_daily() {
602 let base = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
605 let repeater = Repeater {
606 repeater_type: RepeaterType::Cumulative,
607 value: 1,
608 unit: RepeaterUnit::Hour,
609 };
610 let current = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
611 let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
612 let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
613 assert_eq!(past, current);
614 assert_eq!(fut, current);
615 }
616
617 #[test]
618 fn test_closest_date_hour_repeater_ignores_value() {
619 let base = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
624 let current = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
625 for value in [1u32, 5, 12, 25] {
626 let repeater = Repeater {
627 repeater_type: RepeaterType::Cumulative,
628 value,
629 unit: RepeaterUnit::Hour,
630 };
631 assert_eq!(
632 closest_date(base, current, DatePreference::Past, &repeater),
633 Some(current),
634 "+{value}h Past must be current day"
635 );
636 assert_eq!(
637 closest_date(base, current, DatePreference::Future, &repeater),
638 Some(current),
639 "+{value}h Future must be current day"
640 );
641 }
642 }
643
644 #[test]
645 fn test_closest_date_year_value_greater_than_diff() {
646 let base = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
649 let repeater = Repeater {
650 repeater_type: RepeaterType::Cumulative,
651 value: 10,
652 unit: RepeaterUnit::Year,
653 };
654 let current = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
655 let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
656 let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
657 assert_eq!(past, base, "+10y past from year-0 must stay on base");
658 assert_eq!(fut, NaiveDate::from_ymd_opt(2035, 1, 1).unwrap());
659 }
660
661 #[test]
662 fn test_closest_date_year_feb_29_skips_non_leap() {
663 let base = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap();
666 let repeater = Repeater {
667 repeater_type: RepeaterType::Cumulative,
668 value: 1,
669 unit: RepeaterUnit::Year,
670 };
671 let current = NaiveDate::from_ymd_opt(2025, 3, 1).unwrap();
672 let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
673 assert_eq!(past, base, "Feb-29 must not be truncated to Feb-28");
674
675 let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
677 assert_eq!(fut, NaiveDate::from_ymd_opt(2028, 2, 29).unwrap());
678 }
679
680 #[test]
681 fn test_closest_date_month_n2_preserves_base_day() {
682 let base = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
685 let repeater = Repeater {
686 repeater_type: RepeaterType::Cumulative,
687 value: 1,
688 unit: RepeaterUnit::Month,
689 };
690 let current = NaiveDate::from_ymd_opt(2024, 4, 15).unwrap();
691 let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
692 assert_eq!(fut, NaiveDate::from_ymd_opt(2024, 4, 30).unwrap());
694
695 let c2 = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap();
697 let fut2 = closest_date(base, c2, DatePreference::Future, &repeater).unwrap();
698 assert_eq!(fut2, NaiveDate::from_ymd_opt(2024, 5, 31).unwrap());
699 }
700
701 #[test]
702 fn test_closest_date_month_past_respects_invariant() {
703 let base = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
714 let repeater = Repeater {
715 repeater_type: RepeaterType::Cumulative,
716 value: 1,
717 unit: RepeaterUnit::Month,
718 };
719 let current = NaiveDate::from_ymd_opt(2024, 4, 15).unwrap();
720 let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
721 assert_eq!(
722 past,
723 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
724 "Past must be the last occurrence on or before current, not after it"
725 );
726 assert!(past <= current, "invariant n1 <= current violated");
727
728 let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
731 assert_eq!(fut, NaiveDate::from_ymd_opt(2024, 4, 30).unwrap());
732
733 let r3 = Repeater {
737 repeater_type: RepeaterType::Cumulative,
738 value: 3,
739 unit: RepeaterUnit::Month,
740 };
741 let past3 = closest_date(base, current, DatePreference::Past, &r3).unwrap();
742 assert_eq!(
743 past3, base,
744 "+3m Past from 2024-04-15 must be the base 2024-01-31"
745 );
746 assert!(past3 <= current);
747 }
748
749 #[test]
750 fn test_closest_date_current_before_base_past_returns_none() {
751 let base = NaiveDate::from_ymd_opt(2025, 12, 10).unwrap();
752 let repeater = Repeater {
753 repeater_type: RepeaterType::Cumulative,
754 value: 1,
755 unit: RepeaterUnit::Day,
756 };
757 let current = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
758 assert!(closest_date(base, current, DatePreference::Past, &repeater).is_none());
759 assert_eq!(
760 closest_date(base, current, DatePreference::Future, &repeater),
761 Some(base),
762 );
763 }
764
765 fn closest_date_workday_oracle(
769 base_date: NaiveDate,
770 current: NaiveDate,
771 prefer: DatePreference,
772 step: u32,
773 ) -> Option<NaiveDate> {
774 debug_assert!(step > 0, "workday oracle requires step > 0 to terminate");
781 let calendar = crate::holidays::HolidayCalendar::global();
782 if current == base_date {
783 return Some(base_date);
784 }
785 if current < base_date {
786 return match prefer {
787 DatePreference::Past => None,
788 DatePreference::Future => Some(base_date),
789 };
790 }
791 let mut last_occurrence = base_date;
792 loop {
793 let mut next = last_occurrence;
794 for _ in 0..step {
795 next = calendar.next_workday(next);
796 }
797 if next > current {
798 break;
799 }
800 last_occurrence = next;
801 }
802 let n1 = last_occurrence;
803 let mut n2 = n1;
804 for _ in 0..step {
805 n2 = calendar.next_workday(n2);
806 }
807 match prefer {
808 DatePreference::Past => {
809 if current >= n2 {
810 Some(n2)
811 } else {
812 Some(n1)
813 }
814 }
815 DatePreference::Future => {
816 if current <= n1 {
817 Some(n1)
818 } else {
819 Some(n2)
820 }
821 }
822 }
823 }
824
825 #[test]
826 fn test_closest_date_workday_matches_oracle_across_2026() {
827 let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
832 for step in [1u32, 2, 3, 5] {
833 let repeater = Repeater {
834 repeater_type: RepeaterType::Cumulative,
835 value: step,
836 unit: RepeaterUnit::Workday,
837 };
838 let mut day = base;
839 let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
840 while day <= end {
841 for &prefer in &[DatePreference::Past, DatePreference::Future] {
842 let got = closest_date(base, day, prefer, &repeater);
843 let want = closest_date_workday_oracle(base, day, prefer, step);
844 assert_eq!(
845 got, want,
846 "mismatch at base={base} current={day} step={step} prefer={prefer:?}"
847 );
848 }
849 day += chrono::Duration::days(1);
850 }
851 }
852 }
853
854 #[test]
855 fn test_closest_date_workday_handles_year_old_base() {
856 let base = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); let repeater = Repeater {
861 repeater_type: RepeaterType::Cumulative,
862 value: 1,
863 unit: RepeaterUnit::Workday,
864 };
865 let current = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); let got = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
867 let want = closest_date_workday_oracle(base, current, DatePreference::Past, 1).unwrap();
868 assert_eq!(got, want);
869 assert!(got <= current);
870 }
871
872 #[test]
873 fn test_workdays_between_exclusive_basic() {
874 use crate::holidays::HolidayCalendar;
875 let cal = HolidayCalendar::global();
876 let a = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap(); let b = NaiveDate::from_ymd_opt(2025, 12, 12).unwrap(); assert_eq!(cal.workdays_between_exclusive(a, b), 5);
880
881 let a = NaiveDate::from_ymd_opt(2025, 12, 29).unwrap();
886 let b = NaiveDate::from_ymd_opt(2026, 1, 13).unwrap();
887 assert_eq!(cal.workdays_between_exclusive(a, b), 3);
891 }
892
893 #[test]
894 fn test_nth_workday_after_basic() {
895 use crate::holidays::HolidayCalendar;
896 let cal = HolidayCalendar::global();
897 let base = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap();
899 assert_eq!(
900 cal.nth_workday_after(base, 1),
901 NaiveDate::from_ymd_opt(2025, 12, 8).unwrap()
902 );
903 assert_eq!(
905 cal.nth_workday_after(base, 5),
906 NaiveDate::from_ymd_opt(2025, 12, 12).unwrap()
907 );
908 assert_eq!(
910 cal.nth_workday_after(base, 6),
911 NaiveDate::from_ymd_opt(2025, 12, 15).unwrap()
912 );
913 }
914
915 #[test]
916 fn closest_date_returns_none_instead_of_panicking_on_an_absurd_step() {
917 let base = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
922 let current = NaiveDate::from_ymd_opt(2026, 7, 25).unwrap();
923
924 for step in ["+99999999d", "+99999999w"] {
927 let rep = parse_repeater(step).expect(step);
928 assert_eq!(
929 closest_date(base, current, DatePreference::Future, &rep),
930 None,
931 "step {step} must bracket to None, not panic"
932 );
933 assert_eq!(
934 closest_date(base, current, DatePreference::Past, &rep),
935 None,
936 "step {step} must bracket to None, not panic"
937 );
938 }
939 }
940}