use chrono::{Datelike, Duration, NaiveDate, Weekday};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Frequency {
Daily,
EveryNDays(DayInterval),
Weekly(Weekday),
Monthly(DayOfMonth),
SemiAnnually,
Annually,
Biennially,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct DayInterval(u16);
impl DayInterval {
pub fn new(value: u16) -> Option<Self> {
if value == 0 { None } else { Some(Self(value)) }
}
pub fn get(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct DayOfMonth(u8);
impl DayOfMonth {
pub fn new(value: u8) -> Option<Self> {
if (1..=31).contains(&value) {
Some(Self(value))
} else {
None
}
}
pub fn get(self) -> u8 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DateRange {
start_date: NaiveDate,
end_date: NaiveDate,
}
impl DateRange {
pub fn new(start_date: NaiveDate, end_date: NaiveDate) -> Option<Self> {
if end_date < start_date {
None
} else {
Some(Self {
start_date,
end_date,
})
}
}
pub fn start_date(self) -> NaiveDate {
self.start_date
}
pub fn end_date(self) -> NaiveDate {
self.end_date
}
pub fn all_days(self) -> Vec<NaiveDate> {
let mut days = Vec::new();
let mut current = self.start_date;
while current <= self.end_date {
days.push(current);
match current.checked_add_signed(Duration::days(1)) {
Some(next_day) => current = next_day,
None => break,
}
}
days
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Schedule {
starts_on: NaiveDate,
frequency: Frequency,
}
impl Schedule {
pub fn new(starts_on: NaiveDate, frequency: Frequency) -> Self {
Self {
starts_on,
frequency,
}
}
pub fn starts_on(self) -> NaiveDate {
self.starts_on
}
pub fn frequency(self) -> Frequency {
self.frequency
}
pub fn occurrences_between(self, range: DateRange) -> Vec<NaiveDate> {
match self.frequency {
Frequency::Daily => self.daily_occurrences(range),
Frequency::EveryNDays(interval) => self.every_n_days_occurrences(range, interval),
Frequency::Weekly(day_of_week) => self.weekly_occurrences(range, day_of_week),
Frequency::Monthly(day_of_month) => self.monthly_occurrences(range, day_of_month),
Frequency::SemiAnnually => self.every_n_months_occurrences(range, 6),
Frequency::Annually => self.every_n_months_occurrences(range, 12),
Frequency::Biennially => self.every_n_months_occurrences(range, 24),
}
}
fn daily_occurrences(self, range: DateRange) -> Vec<NaiveDate> {
let start = larger_date(range.start_date, self.starts_on);
let Some(adjusted_range) = DateRange::new(start, range.end_date) else {
return Vec::new();
};
adjusted_range.all_days()
}
fn every_n_days_occurrences(self, range: DateRange, interval: DayInterval) -> Vec<NaiveDate> {
let mut matches = Vec::new();
if range.end_date < self.starts_on {
return matches;
}
let interval = i64::from(interval.get());
let first_possible_date = larger_date(range.start_date, self.starts_on);
let days_since_start = first_possible_date
.signed_duration_since(self.starts_on)
.num_days();
let offset = if days_since_start <= 0 {
0
} else {
((days_since_start + interval - 1) / interval) * interval
};
let Some(mut current) = self.starts_on.checked_add_signed(Duration::days(offset)) else {
return matches;
};
while current <= range.end_date {
matches.push(current);
match current.checked_add_signed(Duration::days(interval)) {
Some(next_date) => current = next_date,
None => break,
}
}
matches
}
fn weekly_occurrences(self, range: DateRange, day_of_week: Weekday) -> Vec<NaiveDate> {
let mut matches = Vec::new();
if range.end_date < self.starts_on {
return matches;
}
let start = larger_date(range.start_date, self.starts_on);
let current_weekday = i64::from(start.weekday().num_days_from_monday());
let day_of_week = i64::from(day_of_week.num_days_from_monday());
let days_until_target = (7 + day_of_week - current_weekday) % 7;
let Some(mut current) = start.checked_add_signed(Duration::days(days_until_target)) else {
return matches;
};
while current <= range.end_date {
matches.push(current);
match current.checked_add_signed(Duration::days(7)) {
Some(next_date) => current = next_date,
None => break,
}
}
matches
}
fn monthly_occurrences(self, range: DateRange, day_of_month: DayOfMonth) -> Vec<NaiveDate> {
let mut matches = Vec::new();
if range.end_date < self.starts_on {
return matches;
}
let start = larger_date(range.start_date, self.starts_on);
let mut year = start.year();
let mut month = start.month();
while let Some(candidate) = date_in_month(year, month, day_of_month.get())
&& candidate <= range.end_date
{
if candidate >= start {
matches.push(candidate);
}
let Some((next_year, next_month)) = advance_month(year, month, 1) else {
break;
};
year = next_year;
month = next_month;
}
matches
}
fn every_n_months_occurrences(self, range: DateRange, month_interval: u32) -> Vec<NaiveDate> {
let mut matches = Vec::new();
if range.end_date < self.starts_on {
return matches;
}
let mut intervals_elapsed = 0;
loop {
let months_to_add = intervals_elapsed * month_interval;
let Some(candidate) = add_months_preserving_anchor_day(self.starts_on, months_to_add)
else {
break;
};
if candidate > range.end_date {
break;
}
if candidate >= range.start_date {
matches.push(candidate);
}
intervals_elapsed += 1;
}
matches
}
}
fn larger_date(date_1: NaiveDate, date_2: NaiveDate) -> NaiveDate {
if date_1 >= date_2 { date_1 } else { date_2 }
}
fn date_in_month(year: i32, month: u32, queried_date: u8) -> Option<NaiveDate> {
let last_day = last_day_of_month(year, month)?;
let day = u32::from(queried_date).min(last_day);
NaiveDate::from_ymd_opt(year, month, day)
}
fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
let (next_year, next_month) = if month == 12 {
(year.checked_add(1)?, 1)
} else {
(year, month + 1)
};
let first_day_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1)?;
let last_day = first_day_of_next_month.pred_opt()?;
Some(last_day.day())
}
fn advance_month(year: i32, month: u32, amount: u32) -> Option<(i32, u32)> {
if !(1..=12).contains(&month) {
return None;
}
let total_months = i64::from(year)
.checked_mul(12)?
.checked_add(i64::from(month - 1))?
.checked_add(i64::from(amount))?;
let new_year = total_months.div_euclid(12);
let new_month = total_months.rem_euclid(12) + 1;
if new_year < i64::from(i32::MIN) || new_year > i64::from(i32::MAX) {
return None;
}
Some((new_year as i32, new_month as u32))
}
fn add_months_preserving_anchor_day(date: NaiveDate, months: u32) -> Option<NaiveDate> {
let (year, month) = advance_month(date.year(), date.month(), months)?;
let last_day = last_day_of_month(year, month)?;
let day = date.day().min(last_day);
NaiveDate::from_ymd_opt(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn date_range_rejects_backwards_ranges() {
assert!(DateRange::new(date(2026, 1, 2), date(2026, 1, 1)).is_none());
}
#[test]
fn date_range_days_are_inclusive() {
let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 3)).unwrap();
assert_eq!(
range.all_days(),
vec![date(2026, 1, 1), date(2026, 1, 2), date(2026, 1, 3),]
);
}
#[test]
fn daily_schedule_returns_each_day_in_range_after_start() {
let schedule = Schedule::new(date(2026, 1, 2), Frequency::Daily);
let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 4)).unwrap();
assert_eq!(
schedule.occurrences_between(range),
vec![date(2026, 1, 2), date(2026, 1, 3), date(2026, 1, 4),]
);
}
#[test]
fn every_n_days_is_anchored_to_start_date() {
let schedule = Schedule::new(
date(2026, 1, 1),
Frequency::EveryNDays(DayInterval::new(3).unwrap()),
);
let range = DateRange::new(date(2026, 1, 2), date(2026, 1, 10)).unwrap();
assert_eq!(
schedule.occurrences_between(range),
vec![date(2026, 1, 4), date(2026, 1, 7), date(2026, 1, 10),]
);
}
#[test]
fn weekly_schedule_matches_requested_weekday() {
let schedule = Schedule::new(date(2026, 1, 1), Frequency::Weekly(Weekday::Mon));
let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 15)).unwrap();
assert_eq!(
schedule.occurrences_between(range),
vec![date(2026, 1, 5), date(2026, 1, 12)]
);
}
#[test]
fn monthly_schedule_clamps_to_last_day_of_short_months() {
let schedule = Schedule::new(
date(2026, 1, 1),
Frequency::Monthly(DayOfMonth::new(31).unwrap()),
);
let range = DateRange::new(date(2026, 1, 1), date(2026, 3, 31)).unwrap();
assert_eq!(
schedule.occurrences_between(range),
vec![date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31),]
);
}
#[test]
fn annually_preserves_anchor_day_when_possible() {
let schedule = Schedule::new(date(2024, 2, 29), Frequency::Annually);
let range = DateRange::new(date(2024, 1, 1), date(2028, 12, 31)).unwrap();
assert_eq!(
schedule.occurrences_between(range),
vec![
date(2024, 2, 29),
date(2025, 2, 28),
date(2026, 2, 28),
date(2027, 2, 28),
date(2028, 2, 29),
]
);
}
#[test]
fn biennially_uses_twenty_four_month_intervals() {
let schedule = Schedule::new(date(2026, 5, 10), Frequency::Biennially);
let range = DateRange::new(date(2026, 1, 1), date(2031, 1, 1)).unwrap();
assert_eq!(
schedule.occurrences_between(range),
vec![date(2026, 5, 10), date(2028, 5, 10), date(2030, 5, 10),]
);
}
}