use crate::date::Date;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Roll {
Unadjusted,
Following,
ModifiedFollowing,
Preceding,
ModifiedPreceding,
Nearest,
EndOfMonth,
}
const MAX_STEPS: u32 = 366;
#[must_use]
pub fn apply(date: Date, conv: Roll, is_business_day: impl Fn(Date) -> bool) -> Date {
match conv {
Roll::Unadjusted => date,
Roll::Following => walk_forward(date, &is_business_day),
Roll::Preceding => walk_backward(date, &is_business_day),
Roll::ModifiedFollowing => modified_following(date, &is_business_day),
Roll::ModifiedPreceding => {
let p = walk_backward(date, &is_business_day);
if p.month() == date.month() {
p
} else {
walk_forward(date, &is_business_day)
}
}
Roll::Nearest => {
if is_business_day(date) {
return date;
}
let forward = walk_forward(date, &is_business_day);
let backward = walk_backward(date, &is_business_day);
let fwd_dist = date.days_between(forward).unsigned_abs();
let bwd_dist = date.days_between(backward).unsigned_abs();
if fwd_dist <= bwd_dist {
forward
} else {
backward
}
}
Roll::EndOfMonth => {
if is_last_business_day_of_month(date, &is_business_day) {
last_business_day_of_month(date, &is_business_day)
} else {
modified_following(date, &is_business_day)
}
}
}
}
fn modified_following(date: Date, is_biz: &impl Fn(Date) -> bool) -> Date {
let f = walk_forward(date, is_biz);
if f.month() == date.month() {
f
} else {
walk_backward(date, is_biz)
}
}
fn walk_forward(date: Date, is_biz: &impl Fn(Date) -> bool) -> Date {
let mut d = date;
let mut steps = 0u32;
while !is_biz(d) {
if steps >= MAX_STEPS {
return date;
}
d = d.add_days(1);
steps += 1;
}
d
}
fn walk_backward(date: Date, is_biz: &impl Fn(Date) -> bool) -> Date {
let mut d = date;
let mut steps = 0u32;
while !is_biz(d) {
if steps >= MAX_STEPS {
return date;
}
d = d.add_days(-1);
steps += 1;
}
d
}
fn last_business_day_of_month(date: Date, is_biz: &impl Fn(Date) -> bool) -> Date {
let last = Date::ymd_unchecked(
date.year(),
date.month(),
Date::days_in_month(date.year(), date.month()),
);
let mut d = last;
let mut steps = 0u32;
while !is_biz(d) {
if steps >= MAX_STEPS {
return last;
}
let prev = d.add_days(-1);
if prev.month() != date.month() || prev.year() != date.year() {
return last;
}
d = prev;
steps += 1;
}
d
}
fn is_last_business_day_of_month(date: Date, is_biz: &impl Fn(Date) -> bool) -> bool {
if !is_biz(date) {
return false;
}
let mut d = date.add_days(1);
while d.month() == date.month() && d.year() == date.year() {
if is_biz(d) {
return false;
}
d = d.add_days(1);
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::date::Weekday;
fn weekends_only(d: Date) -> bool {
let wd = d.day_of_week();
wd != Weekday::Sat && wd != Weekday::Sun
}
fn weekends_plus_christmas(d: Date) -> bool {
if d == Date::ymd_unchecked(2026, 12, 25) {
return false;
}
weekends_only(d)
}
#[test]
fn unadjusted_returns_input() {
let sat = Date::ymd(2026, 5, 23).unwrap();
assert_eq!(apply(sat, Roll::Unadjusted, weekends_only), sat);
}
#[test]
fn following_from_saturday() {
let sat = Date::ymd(2026, 5, 23).unwrap();
let mon = Date::ymd(2026, 5, 25).unwrap();
assert_eq!(apply(sat, Roll::Following, weekends_only), mon);
}
#[test]
fn preceding_from_saturday() {
let sat = Date::ymd(2026, 5, 23).unwrap();
let fri = Date::ymd(2026, 5, 22).unwrap();
assert_eq!(apply(sat, Roll::Preceding, weekends_only), fri);
}
#[test]
fn following_on_business_day_is_identity() {
let mon = Date::ymd(2026, 5, 25).unwrap();
assert_eq!(apply(mon, Roll::Following, weekends_only), mon);
assert_eq!(apply(mon, Roll::Preceding, weekends_only), mon);
}
#[test]
fn modified_following_falls_back_when_month_changes() {
let sun = Date::ymd(2026, 5, 31).unwrap();
let fri = Date::ymd(2026, 5, 29).unwrap();
assert_eq!(apply(sun, Roll::ModifiedFollowing, weekends_only), fri);
}
#[test]
fn modified_following_within_month_just_follows() {
let sat = Date::ymd(2026, 5, 23).unwrap();
let mon = Date::ymd(2026, 5, 25).unwrap();
assert_eq!(apply(sat, Roll::ModifiedFollowing, weekends_only), mon);
}
#[test]
fn modified_preceding_within_month_just_precedes() {
let sat = Date::ymd(2026, 1, 31).unwrap();
let fri = Date::ymd(2026, 1, 30).unwrap();
assert_eq!(apply(sat, Roll::ModifiedPreceding, weekends_only), fri);
}
#[test]
fn modified_preceding_falls_back_when_month_changes() {
let sun = Date::ymd(2026, 2, 1).unwrap();
let mon = Date::ymd(2026, 2, 2).unwrap();
assert_eq!(apply(sun, Roll::ModifiedPreceding, weekends_only), mon);
}
#[test]
fn nearest_business_day_is_identity() {
let mon = Date::ymd(2026, 5, 25).unwrap();
assert_eq!(apply(mon, Roll::Nearest, weekends_only), mon);
}
#[test]
fn nearest_on_saturday_goes_friday() {
let sat = Date::ymd(2026, 5, 23).unwrap();
let fri = Date::ymd(2026, 5, 22).unwrap();
assert_eq!(apply(sat, Roll::Nearest, weekends_only), fri);
}
#[test]
fn nearest_on_sunday_goes_monday() {
let sun = Date::ymd(2026, 5, 24).unwrap();
let mon = Date::ymd(2026, 5, 25).unwrap();
assert_eq!(apply(sun, Roll::Nearest, weekends_only), mon);
}
#[test]
fn nearest_ties_break_forward() {
let is_biz = |d: Date| {
if d == Date::ymd_unchecked(2026, 5, 13) {
return false;
}
weekends_only(d)
};
let wed = Date::ymd(2026, 5, 13).unwrap();
let thu = Date::ymd(2026, 5, 14).unwrap();
assert_eq!(apply(wed, Roll::Nearest, is_biz), thu);
}
#[test]
fn end_of_month_anchors_when_last_business_day() {
let fri = Date::ymd(2026, 2, 27).unwrap();
assert_eq!(apply(fri, Roll::EndOfMonth, weekends_only), fri);
}
#[test]
fn end_of_month_falls_back_to_modified_following() {
let mid = Date::ymd(2026, 5, 15).unwrap();
assert_eq!(apply(mid, Roll::EndOfMonth, weekends_only), mid);
}
#[test]
fn end_of_month_holiday_aware() {
let thu = Date::ymd(2026, 12, 31).unwrap();
assert_eq!(apply(thu, Roll::EndOfMonth, weekends_plus_christmas), thu);
}
#[test]
fn following_skips_a_holiday() {
let xmas = Date::ymd(2026, 12, 25).unwrap();
let mon = Date::ymd(2026, 12, 28).unwrap();
assert_eq!(apply(xmas, Roll::Following, weekends_plus_christmas), mon);
}
#[test]
fn preceding_skips_a_holiday() {
let xmas = Date::ymd(2026, 12, 25).unwrap();
let thu = Date::ymd(2026, 12, 24).unwrap();
assert_eq!(apply(xmas, Roll::Preceding, weekends_plus_christmas), thu);
}
#[test]
fn pathological_predicate_returns_input() {
let always_false = |_d: Date| false;
let any = Date::ymd(2026, 5, 23).unwrap();
assert_eq!(apply(any, Roll::Following, always_false), any);
assert_eq!(apply(any, Roll::Preceding, always_false), any);
}
}