use crate::holidays::HolidayCalendar;
use chrono::NaiveDate;
#[derive(Debug, Clone, PartialEq)]
pub struct Repeater {
pub repeater_type: RepeaterType,
pub value: u32,
pub unit: RepeaterUnit,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RepeaterType {
Cumulative,
CatchUp,
Restart,
}
impl RepeaterType {
pub fn prefix(&self) -> &'static str {
match self {
RepeaterType::Cumulative => "+",
RepeaterType::CatchUp => "++",
RepeaterType::Restart => ".+",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RepeaterUnit {
Day,
Week,
Month,
Year,
Hour,
Workday,
}
impl RepeaterUnit {
pub fn suffix(&self) -> &'static str {
match self {
RepeaterUnit::Day => "d",
RepeaterUnit::Week => "w",
RepeaterUnit::Month => "m",
RepeaterUnit::Year => "y",
RepeaterUnit::Hour => "h",
RepeaterUnit::Workday => "wd",
}
}
}
pub fn parse_repeater(s: &str) -> Option<Repeater> {
let s = s.trim();
let (repeater_type, rest) = if let Some(r) = s.strip_prefix(".+") {
(RepeaterType::Restart, r)
} else if let Some(r) = s.strip_prefix("++") {
(RepeaterType::CatchUp, r)
} else if let Some(r) = s.strip_prefix('+') {
(RepeaterType::Cumulative, r)
} else {
tracing::trace!(input = %s, reason = "missing prefix", "parse_repeater: rejected");
return None;
};
if rest.is_empty() {
tracing::trace!(input = %s, reason = "empty after prefix", "parse_repeater: rejected");
return None;
}
if let Some(value_str) = rest.strip_suffix("wd") {
let value: u32 = match value_str.parse() {
Ok(v) => v,
Err(_) => {
tracing::trace!(input = %s, reason = "non-numeric value for wd", "parse_repeater: rejected");
return None;
}
};
if value == 0 {
tracing::trace!(input = %s, reason = "zero step for wd", "parse_repeater: rejected");
return None;
}
return Some(Repeater {
repeater_type,
value,
unit: RepeaterUnit::Workday,
});
}
let unit_char = match rest.chars().last() {
Some(c) => c,
None => {
tracing::trace!(input = %s, reason = "empty rest after wd check", "parse_repeater: rejected");
return None;
}
};
let value_str = &rest[..rest.len() - unit_char.len_utf8()];
let value: u32 = match value_str.parse() {
Ok(v) => v,
Err(_) => {
tracing::trace!(input = %s, reason = "non-numeric value", "parse_repeater: rejected");
return None;
}
};
if value == 0 {
tracing::trace!(input = %s, reason = "zero step", "parse_repeater: rejected");
return None;
}
let unit = match unit_char {
'd' => RepeaterUnit::Day,
'w' => RepeaterUnit::Week,
'm' => RepeaterUnit::Month,
'y' => RepeaterUnit::Year,
'h' => RepeaterUnit::Hour,
_ => {
tracing::trace!(
input = %s,
unit_char = %unit_char,
reason = "unknown unit",
"parse_repeater: rejected"
);
return None;
}
};
Some(Repeater {
repeater_type,
value,
unit,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DatePreference {
Past,
Future,
}
fn pick(
prefer: DatePreference,
current: NaiveDate,
n1: NaiveDate,
n2: NaiveDate,
) -> Option<NaiveDate> {
Some(match prefer {
DatePreference::Past => {
if current >= n2 {
n2
} else {
n1
}
}
DatePreference::Future => {
if current <= n1 {
n1
} else {
n2
}
}
})
}
fn bracket_year(
base_date: NaiveDate,
current: NaiveDate,
value: u32,
) -> Option<(NaiveDate, NaiveDate)> {
use chrono::Datelike;
let value = value as i32;
let base_month = base_date.month();
let base_day = base_date.day();
let base_year = base_date.year();
let current_year = current.year();
let max_complete = (current_year - base_year) / value;
let mut n1: Option<NaiveDate> = None;
let mut k = max_complete;
while k >= 0 {
let y = base_year + k * value;
if let Some(d) = NaiveDate::from_ymd_opt(y, base_month, base_day) {
if d <= current {
n1 = Some(d);
break;
}
}
k -= 1;
}
debug_assert!(
n1.is_some(),
"bracket_year: n1=None despite current >= base_date"
);
let n1 = n1?;
let mut k2 = (n1.year() - base_year) / value + 1;
let safety_limit = max_complete + 200; let n2 = loop {
if k2 > safety_limit {
return None;
}
let y = base_year + k2 * value;
if let Some(d) = NaiveDate::from_ymd_opt(y, base_month, base_day) {
if d > current {
break d;
}
}
k2 += 1;
};
Some((n1, n2))
}
fn bracket_month(
base_date: NaiveDate,
current: NaiveDate,
value: u32,
) -> Option<(NaiveDate, NaiveDate)> {
use chrono::Datelike;
let months_to_add = value as i32;
let base_day = base_date.day();
let months_diff = (current.year() - base_date.year()) * 12
+ (current.month() as i32 - base_date.month() as i32);
let complete_months = (months_diff / months_to_add) * months_to_add;
let n1_raw = add_months(base_date, complete_months)?;
let n1 = NaiveDate::from_ymd_opt(
n1_raw.year(),
n1_raw.month(),
base_day.min(days_in_month(n1_raw.year(), n1_raw.month())),
)?;
let n2_raw = add_months(base_date, complete_months + months_to_add)?;
let n2 = NaiveDate::from_ymd_opt(
n2_raw.year(),
n2_raw.month(),
base_day.min(days_in_month(n2_raw.year(), n2_raw.month())),
)?;
Some((n1, n2))
}
fn bracket_uniform_days(
base_date: NaiveDate,
current: NaiveDate,
days: i64,
) -> (NaiveDate, NaiveDate) {
let days_diff = (current - base_date).num_days();
let complete_periods = days_diff / days;
let n1 = base_date + chrono::Duration::days(complete_periods * days);
let n2 = n1 + chrono::Duration::days(days);
(n1, n2)
}
fn bracket_workday(base_date: NaiveDate, current: NaiveDate, value: u32) -> (NaiveDate, NaiveDate) {
let calendar = HolidayCalendar::global();
let step = value as i64;
let m = calendar.workdays_between_exclusive(base_date, current);
let k = m / step;
let n1 = if k == 0 {
base_date
} else {
calendar.nth_workday_after(base_date, (k * step) as u64)
};
let n2 = calendar.nth_workday_after(n1, step as u64);
(n1, n2)
}
pub fn closest_date(
base_date: NaiveDate,
current: NaiveDate,
prefer: DatePreference,
repeater: &Repeater,
) -> Option<NaiveDate> {
if current == base_date {
return Some(base_date);
}
if current < base_date {
return match prefer {
DatePreference::Past => None,
DatePreference::Future => Some(base_date),
};
}
let (n1, n2) = match repeater.unit {
RepeaterUnit::Year => bracket_year(base_date, current, repeater.value)?,
RepeaterUnit::Month => bracket_month(base_date, current, repeater.value)?,
RepeaterUnit::Day => bracket_uniform_days(base_date, current, repeater.value as i64),
RepeaterUnit::Week => bracket_uniform_days(base_date, current, (repeater.value * 7) as i64),
RepeaterUnit::Hour => bracket_uniform_days(base_date, current, 1),
RepeaterUnit::Workday => bracket_workday(base_date, current, repeater.value),
};
pick(prefer, current, n1, n2)
}
pub fn add_months(date: NaiveDate, months: i32) -> Option<NaiveDate> {
use chrono::Datelike;
let total = (date.year() as i64) * 12 + (date.month() as i64 - 1) + months as i64;
let year = total.div_euclid(12);
let month = (total.rem_euclid(12) + 1) as u32;
let year: i32 = year.try_into().ok()?;
let day = date.day().min(days_in_month(year, month));
NaiveDate::from_ymd_opt(year, month, day)
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
29
} else {
28
}
}
_ => unreachable!("invalid month: {month}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_workday_repeater() {
let r = parse_repeater("+1wd").unwrap();
assert_eq!(r.repeater_type, RepeaterType::Cumulative);
assert_eq!(r.value, 1);
assert_eq!(r.unit, RepeaterUnit::Workday);
}
#[test]
fn test_parse_workday_repeater_multiple() {
let r = parse_repeater("+2wd").unwrap();
assert_eq!(r.value, 2);
assert_eq!(r.unit, RepeaterUnit::Workday);
}
#[test]
fn test_parse_workday_catchup() {
let r = parse_repeater("++1wd").unwrap();
assert_eq!(r.repeater_type, RepeaterType::CatchUp);
assert_eq!(r.unit, RepeaterUnit::Workday);
}
#[test]
fn test_parse_workday_restart() {
let r = parse_repeater(".+1wd").unwrap();
assert_eq!(r.repeater_type, RepeaterType::Restart);
assert_eq!(r.unit, RepeaterUnit::Workday);
}
#[test]
fn test_parse_regular_day() {
let r = parse_repeater("+1d").unwrap();
assert_eq!(r.unit, RepeaterUnit::Day);
}
#[test]
fn test_parse_repeater_zero_rejected() {
assert!(parse_repeater("+0d").is_none());
assert!(parse_repeater("+0wd").is_none());
assert!(parse_repeater("++0w").is_none());
assert!(parse_repeater(".+0m").is_none());
}
#[test]
fn test_parse_repeater_multibyte_last_char_no_panic() {
assert!(parse_repeater("+1й").is_none());
assert!(parse_repeater("++2д").is_none());
assert!(parse_repeater(".+3Й").is_none());
assert!(parse_repeater("+1\u{1F600}").is_none());
}
#[test]
fn parse_repeater_rejects_each_failure_mode() {
assert!(parse_repeater("1d").is_none(), "no prefix");
assert!(parse_repeater("+").is_none(), "prefix only");
assert!(parse_repeater("+abwd").is_none(), "non-numeric wd");
assert!(parse_repeater("+1q").is_none(), "unknown unit");
assert!(parse_repeater("+abd").is_none(), "non-numeric value");
}
#[test]
fn test_parse_year_repeater() {
let r = parse_repeater("+1y").unwrap();
assert_eq!(r.repeater_type, RepeaterType::Cumulative);
assert_eq!(r.value, 1);
assert_eq!(r.unit, RepeaterUnit::Year);
}
#[test]
fn test_parse_hour_repeater() {
let r = parse_repeater("+1h").unwrap();
assert_eq!(r.repeater_type, RepeaterType::Cumulative);
assert_eq!(r.value, 1);
assert_eq!(r.unit, RepeaterUnit::Hour);
}
#[test]
fn test_closest_date_workday_value_2() {
let base = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 2,
unit: RepeaterUnit::Workday,
};
let c1 = NaiveDate::from_ymd_opt(2025, 12, 10).unwrap();
let past = closest_date(base, c1, DatePreference::Past, &repeater).unwrap();
assert_eq!(past, c1, "+2wd: 12-10 must be an occurrence");
let c2 = NaiveDate::from_ymd_opt(2025, 12, 11).unwrap();
let past = closest_date(base, c2, DatePreference::Past, &repeater).unwrap();
let fut = closest_date(base, c2, DatePreference::Future, &repeater).unwrap();
assert_eq!(past, NaiveDate::from_ymd_opt(2025, 12, 10).unwrap());
assert_eq!(fut, NaiveDate::from_ymd_opt(2025, 12, 12).unwrap());
}
#[test]
fn test_closest_date_hour_repeater_advances_daily() {
let base = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 1,
unit: RepeaterUnit::Hour,
};
let current = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
assert_eq!(past, current);
assert_eq!(fut, current);
}
#[test]
fn test_closest_date_hour_repeater_ignores_value() {
let base = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
let current = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
for value in [1u32, 5, 12, 25] {
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value,
unit: RepeaterUnit::Hour,
};
assert_eq!(
closest_date(base, current, DatePreference::Past, &repeater),
Some(current),
"+{value}h Past must be current day"
);
assert_eq!(
closest_date(base, current, DatePreference::Future, &repeater),
Some(current),
"+{value}h Future must be current day"
);
}
}
#[test]
fn test_closest_date_year_value_greater_than_diff() {
let base = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 10,
unit: RepeaterUnit::Year,
};
let current = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
assert_eq!(past, base, "+10y past from year-0 must stay on base");
assert_eq!(fut, NaiveDate::from_ymd_opt(2035, 1, 1).unwrap());
}
#[test]
fn test_closest_date_year_feb_29_skips_non_leap() {
let base = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap();
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 1,
unit: RepeaterUnit::Year,
};
let current = NaiveDate::from_ymd_opt(2025, 3, 1).unwrap();
let past = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
assert_eq!(past, base, "Feb-29 must not be truncated to Feb-28");
let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
assert_eq!(fut, NaiveDate::from_ymd_opt(2028, 2, 29).unwrap());
}
#[test]
fn test_closest_date_month_n2_preserves_base_day() {
let base = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 1,
unit: RepeaterUnit::Month,
};
let current = NaiveDate::from_ymd_opt(2024, 4, 15).unwrap();
let fut = closest_date(base, current, DatePreference::Future, &repeater).unwrap();
assert_eq!(fut, NaiveDate::from_ymd_opt(2024, 4, 30).unwrap());
let c2 = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap();
let fut2 = closest_date(base, c2, DatePreference::Future, &repeater).unwrap();
assert_eq!(fut2, NaiveDate::from_ymd_opt(2024, 5, 31).unwrap());
}
#[test]
fn test_closest_date_current_before_base_past_returns_none() {
let base = NaiveDate::from_ymd_opt(2025, 12, 10).unwrap();
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 1,
unit: RepeaterUnit::Day,
};
let current = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
assert!(closest_date(base, current, DatePreference::Past, &repeater).is_none());
assert_eq!(
closest_date(base, current, DatePreference::Future, &repeater),
Some(base),
);
}
fn closest_date_workday_oracle(
base_date: NaiveDate,
current: NaiveDate,
prefer: DatePreference,
step: u32,
) -> Option<NaiveDate> {
let calendar = crate::holidays::HolidayCalendar::global();
if current == base_date {
return Some(base_date);
}
if current < base_date {
return match prefer {
DatePreference::Past => None,
DatePreference::Future => Some(base_date),
};
}
let mut last_occurrence = base_date;
loop {
let mut next = last_occurrence;
for _ in 0..step {
next = calendar.next_workday(next);
}
if next > current {
break;
}
last_occurrence = next;
}
let n1 = last_occurrence;
let mut n2 = n1;
for _ in 0..step {
n2 = calendar.next_workday(n2);
}
match prefer {
DatePreference::Past => {
if current >= n2 {
Some(n2)
} else {
Some(n1)
}
}
DatePreference::Future => {
if current <= n1 {
Some(n1)
} else {
Some(n2)
}
}
}
}
#[test]
fn test_closest_date_workday_matches_oracle_across_2026() {
let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
for step in [1u32, 2, 3, 5] {
let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: step,
unit: RepeaterUnit::Workday,
};
let mut day = base;
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
while day <= end {
for &prefer in &[DatePreference::Past, DatePreference::Future] {
let got = closest_date(base, day, prefer, &repeater);
let want = closest_date_workday_oracle(base, day, prefer, step);
assert_eq!(
got, want,
"mismatch at base={base} current={day} step={step} prefer={prefer:?}"
);
}
day += chrono::Duration::days(1);
}
}
}
#[test]
fn test_closest_date_workday_handles_year_old_base() {
let base = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); let repeater = Repeater {
repeater_type: RepeaterType::Cumulative,
value: 1,
unit: RepeaterUnit::Workday,
};
let current = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); let got = closest_date(base, current, DatePreference::Past, &repeater).unwrap();
let want = closest_date_workday_oracle(base, current, DatePreference::Past, 1).unwrap();
assert_eq!(got, want);
assert!(got <= current);
}
#[test]
fn test_workdays_between_exclusive_basic() {
use crate::holidays::HolidayCalendar;
let cal = HolidayCalendar::global();
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);
let a = NaiveDate::from_ymd_opt(2025, 12, 29).unwrap();
let b = NaiveDate::from_ymd_opt(2026, 1, 13).unwrap();
assert_eq!(cal.workdays_between_exclusive(a, b), 3);
}
#[test]
fn test_nth_workday_after_basic() {
use crate::holidays::HolidayCalendar;
let cal = HolidayCalendar::global();
let base = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap();
assert_eq!(
cal.nth_workday_after(base, 1),
NaiveDate::from_ymd_opt(2025, 12, 8).unwrap()
);
assert_eq!(
cal.nth_workday_after(base, 5),
NaiveDate::from_ymd_opt(2025, 12, 12).unwrap()
);
assert_eq!(
cal.nth_workday_after(base, 6),
NaiveDate::from_ymd_opt(2025, 12, 15).unwrap()
);
}
}