Skip to main content

markdown_org_extract/timestamp/
repeater.rs

1use crate::holidays::HolidayCalendar;
2use chrono::NaiveDate;
3
4/// Repeater type and interval
5#[derive(Debug, Clone, PartialEq)]
6pub struct Repeater {
7    /// How the next occurrence is derived from the previous one.
8    pub repeater_type: RepeaterType,
9    /// Interval count `N` in `+Nd` — how many units apart occurrences are.
10    pub value: u32,
11    /// The unit the interval is counted in.
12    pub unit: RepeaterUnit,
13}
14
15/// Type of repeater (org-mode prefix)
16#[derive(Debug, Clone, PartialEq)]
17pub enum RepeaterType {
18    /// `+` — Cumulative: next = base + N*step
19    Cumulative,
20    /// `++` — Catch-up: next = first base + N*step >= from
21    CatchUp,
22    /// `.+` — Restart: next = from + step (resets from completion)
23    Restart,
24}
25
26impl RepeaterType {
27    /// Org-mode prefix string (`+`, `++`, `.+`)
28    pub fn prefix(&self) -> &'static str {
29        match self {
30            RepeaterType::Cumulative => "+",
31            RepeaterType::CatchUp => "++",
32            RepeaterType::Restart => ".+",
33        }
34    }
35}
36
37/// Repeater unit
38#[derive(Debug, Clone, PartialEq)]
39pub enum RepeaterUnit {
40    /// `d` — days.
41    Day,
42    /// `w` — weeks.
43    Week,
44    /// `m` — calendar months.
45    Month,
46    /// `y` — calendar years.
47    Year,
48    /// `+Nh` — intra-day repeater. For the agenda-by-day view this projects
49    /// onto a daily grid: every day is an occurrence regardless of the numeric
50    /// value `N`. A hypothetical "+25h" therefore still shows up every day, not
51    /// every other day. Documented behaviour, see `closest_date`.
52    Hour,
53    /// `wd` — working days, skipping weekends and public holidays according
54    /// to [`HolidayCalendar`].
55    Workday,
56}
57
58impl RepeaterUnit {
59    /// Org-mode suffix string (`d`, `w`, `m`, `y`, `h`, `wd`)
60    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    /// Canonical org-mode repeater string: prefix + value + unit suffix
74    /// (`++7d`, `.+1m`, `+1wd`). Round-trips with `parse_repeater`.
75    pub fn canonical(&self) -> String {
76        format!(
77            "{}{}{}",
78            self.repeater_type.prefix(),
79            self.value,
80            self.unit.suffix()
81        )
82    }
83}
84
85/// Parse repeater string like `+1d`, `++2w`, `.+1m`, `+1wd`
86///
87/// Returns `None` for malformed input or when the numeric value is zero
88/// (zero-step repeaters cause division-by-zero in occurrence math).
89///
90/// At `trace` level, every rejection is logged with a specific reason so a
91/// caller running with `-vvv` can tell `+1` (missing unit), `+1ф` (non-ASCII
92/// unit), `+0d` (zero step) and `1d` (missing prefix) apart without rerunning.
93pub 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    // Check for "wd" suffix first
113    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/// Preference for closest date calculation
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub enum DatePreference {
179    /// Return latest occurrence <= current, or `None` if no past occurrence exists
180    Past,
181    /// Return earliest occurrence >= current
182    Future,
183}
184
185/// Select the occurrence side (`n1` or `n2`) that matches the requested
186/// `prefer`ence, given the half-open bracket `n1 <= current < n2`.
187///
188/// The interval is right-open by construction (every `bracket_*` builder
189/// returns `n2` strictly after `current`), matching upstream org-mode where
190/// a period boundary belongs to the *next* period. So `current == n1` is the
191/// reachable left edge (the occurrence itself), while `current == n2` never
192/// occurs here — were it to, `Past` would already treat it as the next
193/// period (`current >= n2` → `n2`) and `Future` would skip past `n1`. `Past`
194/// returns the latest occurrence `<= current`; `Future` the earliest
195/// `>= current` (F7, 2026-05-25 logic review).
196fn 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
220/// Bracket containing `current` on the year-repeater grid.
221/// Returns `(latest occurrence <= current, earliest occurrence > current)`.
222/// Skips truncations for Feb-29 by walking valid `from_ymd_opt` candidates.
223fn 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    // Search for the latest valid occurrence on or before `current`.
237    // For dates like Feb 29 we skip non-leap years entirely instead of truncating.
238    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    // n1 = None requires `current < base_date`, which `closest_date` rules out
252    // before dispatching here. Assert in debug, return None in release as a
253    // safe degradation rather than panicking on a malformed call.
254    debug_assert!(
255        n1.is_some(),
256        "bracket_year: n1=None despite current >= base_date"
257    );
258    let n1 = n1?;
259
260    // Next valid occurrence strictly after `current`.
261    let mut k2 = (n1.year() - base_year) / value + 1;
262    // Accommodate Feb-29 (gap up to 8 years). `max_complete` is >= 0 whenever
263    // `current >= base_date`, which `closest_date` guarantees before
264    // dispatching here, so `+ 200` is already a positive ceiling. The
265    // `.max(0)` is defense-in-depth (F6, 2026-05-25 logic review): a direct
266    // call with `current < base_date` would otherwise yield a negative
267    // `max_complete` and a ceiling below `k2`, returning None silently
268    // instead of looping with a sane bound.
269    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
286/// Bracket on the month-repeater grid, truncating the day to fit the
287/// destination month while always starting from `base_date` so that
288/// `base_day` is preserved across truncations.
289///
290/// The returned pair satisfies the `pick` / `closest_date` invariant
291/// `n1 <= current < n2`. The month-number difference alone does not
292/// guarantee that: when `base_day` falls later in the month than
293/// `current`'s day (or `base_day` is truncated to a short month),
294/// `add_months(base, complete_months)` can land *after* `current`
295/// inside `current`'s own month. In that case the occurrence in
296/// `current`'s month is actually `n2`, so we step `complete_months`
297/// back one full period. `add_months` already truncates the day, so we
298/// reuse it directly instead of recomputing the truncation by hand.
299fn 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        // The occurrence in `current`'s month has not been reached yet;
315        // the previous period is the latest occurrence on or before
316        // `current`. Stepping back by one full period lands in a month
317        // strictly earlier than `current`'s, so the invariant holds and
318        // the date we just rejected becomes `n2`.
319        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
332/// Bracket on a uniform daily grid (Day/Week/Hour repeaters).
333/// `days` is the period length expressed in days.
334///
335/// Returns `None` when the grid runs off the calendar. The step comes from
336/// user input (`parse_repeater` only rejects zero, so `++99999999d` is a
337/// valid repeater), and the plain `+` operator on `NaiveDate` panics on
338/// overflow — a malformed note must not take the process down.
339fn 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
353/// Bracket on the workday-repeater grid using the calendar's O(log n)
354/// workday-counting primitive instead of walking day-by-day.
355fn 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
371/// Calculate closest occurrence date relative to `current` for the given repeater.
372///
373/// Contract:
374/// - If `current == base_date`, returns `Some(base_date)`.
375/// - If `current < base_date`:
376///   - `Past` returns `None` (no past occurrence exists yet);
377///   - `Future` returns `Some(base_date)` (first occurrence).
378/// - Otherwise, returns the closest occurrence on or before / on or after `current`
379///   according to `prefer`.
380pub 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        // Widen before multiplying: `value * 7` in u32 wraps for a large
401        // week step and would silently bracket on the wrong grid.
402        RepeaterUnit::Week => bracket_uniform_days(base_date, current, repeater.value as i64 * 7)?,
403        // Hour repeaters always project onto a daily grid: any +Nh repeater is
404        // intra-day so for an agenda-by-day view every day is an occurrence.
405        // The numeric value is intentionally ignored — see docstring on
406        // `RepeaterUnit::Hour` and the README "Repeaters" section. Documented
407        // explicitly so a future contributor does not "fix" this by using
408        // `repeater.value`, which would silently turn +5h into "every 5 days".
409        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
416/// Add `months` to a date, truncating the day to fit the destination month.
417/// Constant-time (no per-month loops), correct for negative `months`.
418pub fn add_months(date: NaiveDate, months: i32) -> Option<NaiveDate> {
419    use chrono::Datelike;
420
421    // Convert (year, 1..=12) into a 0-based "total months since year 0".
422    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        // Regression guard for the prefix-stripping order in `parse_repeater`:
475        // `++` must be matched before `+`. If the order were swapped, the
476        // `+` arm would consume the first character of `++1d` and the
477        // parser would silently classify the remainder as
478        // `RepeaterType::Cumulative` with value 1 — same arithmetic, wrong
479        // semantics (org-mode's CatchUp resets occurrences on completion).
480        // The cheap explicit assertion below is what catches a refactor
481        // that re-orders the strip_prefix arms.
482        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        // Last char is multibyte (Cyrillic / emoji). Must return None, not panic
518        // on a byte-index-not-char-boundary slice.
519        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        // F4 (2026-05-25 logic review): the slice-safety guard at
528        // `rest[..rest.len() - unit_char.len_utf8()]` only strips the *last*
529        // char, so a multibyte char left in the *middle* of the value
530        // (`+1ф5d`: ascii unit `d`, Cyrillic `ф` inside the digits) survives
531        // into `value_str`. `u32::parse` then rejects it, so the function
532        // returns None without panicking — but that path was not pinned. The
533        // multibyte byte boundary must also not be mistaken for a slice index.
534        assert!(parse_repeater("+1ф5d").is_none());
535        assert!(parse_repeater("++2д3w").is_none());
536        assert!(parse_repeater(".+1喜2y").is_none());
537        // Cyrillic inside a workday value, ascii `wd` suffix.
538        assert!(parse_repeater("+1ф2wd").is_none());
539    }
540
541    #[test]
542    fn parse_repeater_rejects_each_failure_mode() {
543        // Each rejection branch logs a distinct reason at trace-level; we cannot
544        // observe the trace output here without a test subscriber, but at least
545        // pin that every branch still returns None so a future refactor cannot
546        // silently turn one of them into Some(...).
547        // missing prefix
548        assert!(parse_repeater("1d").is_none(), "no prefix");
549        // empty after prefix
550        assert!(parse_repeater("+").is_none(), "prefix only");
551        // non-numeric wd value
552        assert!(parse_repeater("+abwd").is_none(), "non-numeric wd");
553        // unknown ASCII unit
554        assert!(parse_repeater("+1q").is_none(), "unknown unit");
555        // non-numeric value with valid unit
556        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    // --- Regression tests for fixed bugs ---
576
577    #[test]
578    fn test_closest_date_workday_value_2() {
579        // base = Mon 2025-12-08, +2wd → 12-08, 12-10, 12-12, 12-16, 12-18, ...
580        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        // current = Wed 12-10 should be on the grid
588        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        // current = Thu 12-11 → past should be 12-10, future should be 12-12
593        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        // Hour repeater is projected onto daily grid; for +1h:
603        // base = 2025-12-05, current = 2025-12-08 → past must be 2025-12-08 (every day is an occurrence)
604        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        // Documented behaviour: hour-repeaters project onto a daily grid; the
620        // numeric value is irrelevant for agenda-by-day. +1h, +12h, and even
621        // +25h all yield "every day is an occurrence" — this test locks the
622        // semantics so a refactor that uses repeater.value can't slip past CI.
623        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        // base = 2025-01-01, +10y. current = 2025-12-05 → max_complete = 0,
647        // n1 must be base (k=0 candidate), n2 must be 2035-01-01.
648        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        // base = 2024-02-29 (leap), +1y. For current = 2025-03-01,
664        // last valid occurrence must be 2024-02-29 (NOT truncated 2025-02-28).
665        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        // Next occurrence after 2025 must be 2028-02-29
676        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        // base = 2024-01-31, +1m. current = 2024-04-15.
683        // complete_months = 3, n1 = 2024-04-30 (truncated). n2 must come from base + 4m = 2024-05-31.
684        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        // n1 = 2024-04-30 (truncated). 2024-04-15 < n1, so Future returns n1.
693        assert_eq!(fut, NaiveDate::from_ymd_opt(2024, 4, 30).unwrap());
694
695        // current = 2024-05-01 → n1 = 2024-04-30, n2 = 2024-05-31 (preserves base_day)
696        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        // Regression for F1 (2026-05-25 logic review): the `pick` /
704        // `closest_date` contract guarantees `n1 <= current < n2`, so for
705        // `Past` the answer must be the latest occurrence on or before
706        // `current` — never a date strictly after it.
707        //
708        // base = 2024-01-31, +1m. Occurrences (with the project's
709        // day-truncation semantics) are 2024-01-31, 02-29, 03-31, 04-30, …
710        // For current = 2024-04-15 the latest occurrence on or before is
711        // 2024-03-31, NOT the truncated April occurrence 2024-04-30 (which
712        // is after current and was returned before the fix).
713        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        // Future from the same point is unchanged: the earliest occurrence on
729        // or after 2024-04-15 is the truncated April date 2024-04-30.
730        let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
731        assert_eq!(fut, NaiveDate::from_ymd_opt(2024, 4, 30).unwrap());
732
733        // A multi-month period (+3m) must also keep the invariant. base + 3m
734        // grid lands on 2024-04-30; for current = 2024-04-15 the last
735        // occurrence on or before is the base date 2024-01-31.
736        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    /// Reference implementation of the workday `closest_date` using the
766    /// original O(N) day-by-day walk. Used only as a test oracle to verify
767    /// the optimized O(log N) version produces identical results.
768    fn closest_date_workday_oracle(
769        base_date: NaiveDate,
770        current: NaiveDate,
771        prefer: DatePreference,
772        step: u32,
773    ) -> Option<NaiveDate> {
774        // F3 (2026-05-25 logic review): a zero step makes the inner
775        // `for _ in 0..step` walk no day, so `next` never advances past
776        // `current` and the outer `loop` spins forever. `parse_repeater`
777        // already rejects `+0wd`, so production never reaches here with 0;
778        // this guard stops a future test that passes 0 from hanging the
779        // suite, failing loudly in debug instead.
780        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        // Sweep every day of 2026 against the slow oracle to make sure the
828        // optimized O(log N) path produces identical results to the original
829        // day-by-day walk. Covers all the holiday-cluster and weekend boundary
830        // cases that exist in the bundled calendar.
831        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        // Regression for the O(N) workday loop: with a year-old base date the
857        // optimized path must still land on the right grid point and return
858        // it in well under the previous "hundreds of next_workday calls".
859        let base = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); // Mon, first workday of Jan 2025
860        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(); // Mon
866        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        // Mon-Fri 2025-12-08..2025-12-12 → 5 workdays in (12-07, 12-12].
877        let a = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap(); // Sun
878        let b = NaiveDate::from_ymd_opt(2025, 12, 12).unwrap(); // Fri
879        assert_eq!(cal.workdays_between_exclusive(a, b), 5);
880
881        // Across Jan 2026 holidays: (2025-12-29, 2026-01-13]. Jan 1-9 are
882        // holidays, Jan 10-11 weekend, Jan 12-13 workdays. Dec 29 Mon was
883        // start; Dec 30 Tue, Dec 31 Wed are holidays per JSON (2025-12-31 in
884        // data). Wait: only 2025-12-31 is a holiday, so Dec 30 Tue is a workday.
885        let a = NaiveDate::from_ymd_opt(2025, 12, 29).unwrap();
886        let b = NaiveDate::from_ymd_opt(2026, 1, 13).unwrap();
887        // Manually: workdays in (Dec 29, Jan 13]:
888        //   Dec 30 (Tue, workday), Dec 31 (Wed, holiday), Jan 1-9 (holidays),
889        //   Jan 10-11 (weekend), Jan 12 (Mon, workday), Jan 13 (Tue, workday) = 3.
890        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        // From Sun 2025-12-07, the 1st workday after is Mon 2025-12-08.
898        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        // The 5th workday after Sun 2025-12-07 is Fri 2025-12-12.
904        assert_eq!(
905            cal.nth_workday_after(base, 5),
906            NaiveDate::from_ymd_opt(2025, 12, 12).unwrap()
907        );
908        // The 6th workday after Sun 2025-12-07 skips the weekend → Mon 2025-12-15.
909        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        // `parse_repeater` only rejects a zero step, so a note can legitimately
918        // carry `++99999999d`. Bracketing that grid runs off the calendar; the
919        // answer is "no occurrence", never a panic that would take the whole
920        // run down in the middle of a scan.
921        let base = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
922        let current = NaiveDate::from_ymd_opt(2026, 7, 25).unwrap();
923
924        // Hour repeaters are excluded on purpose: they project onto a
925        // one-day grid and ignore their value, so no step can overflow.
926        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}