use crate::schedule::DayOfWeek;
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
const MAX_SCAN_DAYS: i64 = 3660;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CalendarHoliday {
Fixed {
name: String,
year: i32,
month: u32,
day: u32,
},
Recurring {
name: String,
month: u32,
day: u32,
},
}
impl CalendarHoliday {
pub fn name(&self) -> &str {
match self {
CalendarHoliday::Fixed { name, .. } => name,
CalendarHoliday::Recurring { name, .. } => name,
}
}
pub fn matches(&self, date: NaiveDate) -> bool {
match self {
CalendarHoliday::Fixed {
year, month, day, ..
} => date.year() == *year && date.month() == *month && date.day() == *day,
CalendarHoliday::Recurring { month, day, .. } => {
date.month() == *month && date.day() == *day
}
}
}
pub fn is_recurring(&self) -> bool {
matches!(self, CalendarHoliday::Recurring { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingCalendar {
holidays: Vec<CalendarHoliday>,
weekend: Vec<DayOfWeek>,
}
impl Default for WorkingCalendar {
fn default() -> Self {
Self::new()
}
}
impl WorkingCalendar {
pub fn new() -> Self {
Self {
holidays: Vec::new(),
weekend: vec![DayOfWeek::Saturday, DayOfWeek::Sunday],
}
}
pub fn with_weekend(weekend: impl IntoIterator<Item = DayOfWeek>) -> Self {
let mut cal = Self {
holidays: Vec::new(),
weekend: Vec::new(),
};
cal.set_weekend(weekend);
cal
}
pub fn set_weekend(&mut self, weekend: impl IntoIterator<Item = DayOfWeek>) {
let mut days: Vec<DayOfWeek> = Vec::new();
for day in weekend {
if !days.contains(&day) {
days.push(day);
}
}
self.weekend = days;
}
pub fn weekend(&self) -> &[DayOfWeek] {
&self.weekend
}
pub fn is_weekend_day(&self, day: DayOfWeek) -> bool {
self.weekend.contains(&day)
}
pub fn add_fixed_holiday(&mut self, name: impl Into<String>, year: i32, month: u32, day: u32) {
self.holidays.push(CalendarHoliday::Fixed {
name: name.into(),
year,
month,
day,
});
}
pub fn add_recurring_holiday(&mut self, name: impl Into<String>, month: u32, day: u32) {
self.holidays.push(CalendarHoliday::Recurring {
name: name.into(),
month,
day,
});
}
pub fn add_holiday(&mut self, holiday: CalendarHoliday) {
self.holidays.push(holiday);
}
pub fn remove_holiday_by_name(&mut self, name: &str) -> usize {
let before = self.holidays.len();
self.holidays.retain(|h| h.name() != name);
before - self.holidays.len()
}
pub fn remove_holiday_on(&mut self, date: NaiveDate) -> usize {
let before = self.holidays.len();
self.holidays.retain(|h| !h.matches(date));
before - self.holidays.len()
}
pub fn holidays(&self) -> &[CalendarHoliday] {
&self.holidays
}
pub fn len(&self) -> usize {
self.holidays.len()
}
pub fn is_empty(&self) -> bool {
self.holidays.is_empty()
}
pub fn is_holiday(&self, date: NaiveDate) -> bool {
self.holidays.iter().any(|h| h.matches(date))
}
pub fn holiday_name(&self, date: NaiveDate) -> Option<&str> {
self.holidays
.iter()
.find(|h| h.matches(date))
.map(|h| h.name())
}
pub fn holidays_in_year(&self, year: i32) -> Vec<(NaiveDate, String)> {
let mut result: Vec<(NaiveDate, String)> = Vec::new();
for holiday in &self.holidays {
let candidate = match holiday {
CalendarHoliday::Fixed {
year: hy,
month,
day,
name,
} => {
if *hy == year {
NaiveDate::from_ymd_opt(year, *month, *day).map(|d| (d, name.clone()))
} else {
None
}
}
CalendarHoliday::Recurring { month, day, name } => {
NaiveDate::from_ymd_opt(year, *month, *day).map(|d| (d, name.clone()))
}
};
if let Some(entry) = candidate {
result.push(entry);
}
}
result.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
result
}
pub fn is_business_day(&self, date: NaiveDate) -> bool {
let day = DayOfWeek::from_chrono(date.weekday());
!self.is_weekend_day(day) && !self.is_holiday(date)
}
pub fn next_business_day(&self, date: NaiveDate) -> Option<NaiveDate> {
let mut current = date;
for _ in 0..MAX_SCAN_DAYS {
current = current.succ_opt()?;
if self.is_business_day(current) {
return Some(current);
}
}
None
}
pub fn previous_business_day(&self, date: NaiveDate) -> Option<NaiveDate> {
let mut current = date;
for _ in 0..MAX_SCAN_DAYS {
current = current.pred_opt()?;
if self.is_business_day(current) {
return Some(current);
}
}
None
}
pub fn business_day_on_or_after(&self, date: NaiveDate) -> Option<NaiveDate> {
if self.is_business_day(date) {
return Some(date);
}
self.next_business_day(date)
}
pub fn business_day_on_or_before(&self, date: NaiveDate) -> Option<NaiveDate> {
if self.is_business_day(date) {
return Some(date);
}
self.previous_business_day(date)
}
pub fn add_business_days(&self, date: NaiveDate, n: i64) -> Option<NaiveDate> {
if n == 0 {
return Some(date);
}
let mut remaining = n.unsigned_abs();
let mut current = date;
let forward = n > 0;
for _ in 0..MAX_SCAN_DAYS {
current = if forward {
current.succ_opt()?
} else {
current.pred_opt()?
};
if self.is_business_day(current) {
remaining -= 1;
if remaining == 0 {
return Some(current);
}
}
}
None
}
pub fn business_days_between(&self, start: NaiveDate, end: NaiveDate) -> Option<i64> {
use std::cmp::Ordering;
match start.cmp(&end) {
Ordering::Equal => Some(0),
Ordering::Less => self.count_business_days_exclusive_inclusive(start, end),
Ordering::Greater => self
.count_business_days_exclusive_inclusive(end, start)
.map(|c| -c),
}
}
fn count_business_days_exclusive_inclusive(&self, lo: NaiveDate, hi: NaiveDate) -> Option<i64> {
let mut count = 0i64;
let mut current = lo;
for _ in 0..MAX_SCAN_DAYS {
current = current.succ_opt()?;
if current > hi {
break;
}
if self.is_business_day(current) {
count += 1;
}
}
if current < hi {
return None;
}
Some(count)
}
}
pub fn date_of(timestamp: DateTime<Utc>) -> NaiveDate {
timestamp.date_naive()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schedule::DayOfWeek;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).expect("valid test date")
}
#[test]
fn weekend_defaults_to_sat_sun() {
let cal = WorkingCalendar::new();
assert!(cal.is_weekend_day(DayOfWeek::Saturday));
assert!(cal.is_weekend_day(DayOfWeek::Sunday));
assert!(!cal.is_weekend_day(DayOfWeek::Monday));
assert!(!cal.is_business_day(d(2026, 6, 13)));
assert!(!cal.is_business_day(d(2026, 6, 14)));
assert!(cal.is_business_day(d(2026, 6, 12)));
}
#[test]
fn custom_weekend_friday_saturday() {
let cal = WorkingCalendar::with_weekend([DayOfWeek::Friday, DayOfWeek::Saturday]);
assert!(!cal.is_business_day(d(2026, 6, 12))); assert!(!cal.is_business_day(d(2026, 6, 13))); assert!(cal.is_business_day(d(2026, 6, 14))); }
#[test]
fn set_weekend_dedups() {
let mut cal = WorkingCalendar::new();
cal.set_weekend([DayOfWeek::Sunday, DayOfWeek::Sunday, DayOfWeek::Saturday]);
assert_eq!(cal.weekend().len(), 2);
}
#[test]
fn fixed_and_recurring_holidays() {
let mut cal = WorkingCalendar::new();
cal.add_recurring_holiday("New Year's Day", 1, 1);
cal.add_fixed_holiday("Company Offsite", 2026, 3, 16);
assert!(cal.is_holiday(d(2025, 1, 1)));
assert!(cal.is_holiday(d(2026, 1, 1)));
assert!(cal.is_holiday(d(2030, 1, 1)));
assert_eq!(cal.holiday_name(d(2026, 1, 1)), Some("New Year's Day"));
assert!(cal.is_holiday(d(2026, 3, 16)));
assert!(!cal.is_holiday(d(2027, 3, 16)));
}
#[test]
fn remove_holiday_by_name_and_date() {
let mut cal = WorkingCalendar::new();
cal.add_recurring_holiday("New Year's Day", 1, 1);
cal.add_fixed_holiday("Offsite", 2026, 3, 16);
cal.add_fixed_holiday("Offsite", 2027, 3, 16);
assert_eq!(cal.remove_holiday_by_name("Offsite"), 2);
assert!(cal.is_holiday(d(2026, 1, 1)));
assert!(!cal.is_holiday(d(2026, 3, 16)));
assert_eq!(cal.remove_holiday_on(d(2026, 1, 1)), 1);
assert!(!cal.is_holiday(d(2026, 1, 1)));
assert!(cal.is_empty());
}
#[test]
fn holidays_in_year_materialises_recurring() {
let mut cal = WorkingCalendar::new();
cal.add_recurring_holiday("New Year's Day", 1, 1);
cal.add_recurring_holiday("Christmas", 12, 25);
cal.add_fixed_holiday("One-off 2026", 2026, 6, 10);
cal.add_fixed_holiday("One-off 2025", 2025, 6, 10);
let in_2026 = cal.holidays_in_year(2026);
assert_eq!(in_2026.len(), 3);
assert_eq!(in_2026[0].0, d(2026, 1, 1));
assert_eq!(in_2026[1].0, d(2026, 6, 10));
assert_eq!(in_2026[1].1, "One-off 2026");
assert_eq!(in_2026[2].0, d(2026, 12, 25));
}
#[test]
fn business_day_skips_weekend() {
let cal = WorkingCalendar::new();
assert_eq!(cal.next_business_day(d(2026, 6, 12)), Some(d(2026, 6, 15)));
assert_eq!(
cal.previous_business_day(d(2026, 6, 15)),
Some(d(2026, 6, 12))
);
}
#[test]
fn business_day_skips_holiday() {
let mut cal = WorkingCalendar::new();
cal.add_fixed_holiday("Independence Day (observed)", 2026, 7, 3);
assert_eq!(cal.next_business_day(d(2026, 7, 2)), Some(d(2026, 7, 6)));
}
#[test]
fn on_or_after_and_before() {
let cal = WorkingCalendar::new();
assert_eq!(
cal.business_day_on_or_after(d(2026, 6, 13)),
Some(d(2026, 6, 15))
);
assert_eq!(
cal.business_day_on_or_before(d(2026, 6, 13)),
Some(d(2026, 6, 12))
);
assert_eq!(
cal.business_day_on_or_after(d(2026, 6, 12)),
Some(d(2026, 6, 12))
);
}
#[test]
fn add_business_days_positive() {
let cal = WorkingCalendar::new();
assert_eq!(
cal.add_business_days(d(2026, 6, 8), 5),
Some(d(2026, 6, 15))
);
assert_eq!(
cal.add_business_days(d(2026, 6, 12), 1),
Some(d(2026, 6, 15))
);
}
#[test]
fn add_business_days_negative() {
let cal = WorkingCalendar::new();
assert_eq!(
cal.add_business_days(d(2026, 6, 15), -1),
Some(d(2026, 6, 12))
);
assert_eq!(
cal.add_business_days(d(2026, 6, 15), -5),
Some(d(2026, 6, 8))
);
}
#[test]
fn add_business_days_zero_is_identity() {
let cal = WorkingCalendar::new();
assert_eq!(
cal.add_business_days(d(2026, 6, 13), 0),
Some(d(2026, 6, 13))
);
}
#[test]
fn add_business_days_with_holiday() {
let mut cal = WorkingCalendar::new();
cal.add_fixed_holiday("Holiday", 2026, 6, 10); assert_eq!(
cal.add_business_days(d(2026, 6, 8), 3),
Some(d(2026, 6, 12))
);
}
#[test]
fn business_days_between_basic() {
let cal = WorkingCalendar::new();
assert_eq!(
cal.business_days_between(d(2026, 6, 8), d(2026, 6, 12)),
Some(4)
);
assert_eq!(
cal.business_days_between(d(2026, 6, 12), d(2026, 6, 15)),
Some(1)
);
assert_eq!(
cal.business_days_between(d(2026, 6, 12), d(2026, 6, 12)),
Some(0)
);
}
#[test]
fn business_days_between_is_signed() {
let cal = WorkingCalendar::new();
let forward = cal
.business_days_between(d(2026, 6, 8), d(2026, 6, 12))
.expect("forward count");
let backward = cal
.business_days_between(d(2026, 6, 12), d(2026, 6, 8))
.expect("backward count");
assert_eq!(forward, 4);
assert_eq!(backward, -4);
}
#[test]
fn business_days_between_excludes_holidays() {
let mut cal = WorkingCalendar::new();
cal.add_fixed_holiday("Holiday", 2026, 6, 10); assert_eq!(
cal.business_days_between(d(2026, 6, 8), d(2026, 6, 12)),
Some(3)
);
}
#[test]
fn add_then_between_round_trips_for_business_days() {
let cal = WorkingCalendar::new();
let start = d(2026, 6, 8); for n in 1..=10i64 {
let target = cal.add_business_days(start, n).expect("add");
assert_eq!(
cal.business_days_between(start, target),
Some(n),
"round-trip failed for n={n}"
);
}
}
#[test]
fn date_of_extracts_utc_date() {
use chrono::TimeZone;
let ts = Utc.with_ymd_and_hms(2026, 6, 13, 23, 59, 0).unwrap();
assert_eq!(date_of(ts), d(2026, 6, 13));
}
}