use crate::{Date, Month, Ordinal, Weekday, Year, YearRange};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NthWeekday {
n: Ordinal,
weekday: Weekday,
month: Month,
years: YearRange,
}
impl NthWeekday {
#[must_use]
pub const fn new(n: Ordinal, weekday: Weekday, month: Month) -> Self {
Self {
n,
weekday,
month,
years: YearRange::ALWAYS,
}
}
#[must_use]
pub const fn from_year(mut self, year: Year) -> Self {
self.years = YearRange::from_year(year);
self
}
#[must_use]
pub const fn years(mut self, range: YearRange) -> Self {
self.years = range;
self
}
#[must_use]
pub const fn n(&self) -> Ordinal {
self.n
}
#[must_use]
pub const fn weekday(&self) -> Weekday {
self.weekday
}
#[must_use]
pub const fn month(&self) -> Month {
self.month
}
#[must_use]
pub const fn year_range(&self) -> YearRange {
self.years
}
#[must_use]
pub fn is_holiday(&self, date: Date) -> bool {
if !self.years.contains(date.year()) {
return false;
}
if date.month() as u8 != self.month as u8 {
return false;
}
if date.weekday() as u8 != self.weekday as u8 {
return false;
}
let occurrence = (date.day() - 1) / 7 + 1;
occurrence == self.n.get()
}
#[allow(clippy::cast_lossless)]
pub(crate) const fn natural_date_in(self, year: Year) -> Option<Date> {
if !self.years.contains(year) {
return None;
}
let Ok(first) = Date::from_ymd(year.get(), self.month, 1) else {
return None;
};
let to_first = (self.weekday.get() as i32 - first.weekday().get() as i32).rem_euclid(7);
let day_of_month = 1 + to_first + 7 * (self.n.get() as i32 - 1);
if day_of_month > self.month.length(year) as i32 {
return None;
}
super::date_ok(first.add_days(day_of_month - 1))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn ymd(y: u16, m: Month, d: u8) -> Date {
Date::from_ymd(y, m, d).unwrap()
}
#[test]
fn third_monday_of_january_is_mlk() {
let mlk = NthWeekday::new(Ordinal::Third, Weekday::Mon, Month::Jan);
assert!(mlk.is_holiday(ymd(2024, Month::Jan, 15)));
assert!(mlk.is_holiday(ymd(2025, Month::Jan, 20)));
assert!(mlk.is_holiday(ymd(2026, Month::Jan, 19)));
}
#[test]
fn fourth_thursday_of_november_is_thanksgiving() {
let tg = NthWeekday::new(Ordinal::Fourth, Weekday::Thu, Month::Nov);
assert!(tg.is_holiday(ymd(2024, Month::Nov, 28)));
assert!(tg.is_holiday(ymd(2025, Month::Nov, 27)));
assert!(tg.is_holiday(ymd(2026, Month::Nov, 26)));
}
#[test]
fn rejects_other_weekdays_and_months() {
let mlk = NthWeekday::new(Ordinal::Third, Weekday::Mon, Month::Jan);
assert!(!mlk.is_holiday(ymd(2026, Month::Jan, 20)));
assert!(!mlk.is_holiday(ymd(2026, Month::Feb, 16)));
}
#[test]
fn fifth_occurrence_may_not_exist() {
let rule = NthWeekday::new(Ordinal::Fifth, Weekday::Fri, Month::Jan);
assert!(rule.is_holiday(ymd(2026, Month::Jan, 30)));
assert!(rule.is_holiday(ymd(2027, Month::Jan, 29)));
assert!(!rule.is_holiday(ymd(2026, Month::Jan, 23)));
}
#[test]
fn year_range_filter() {
let rule = NthWeekday::new(Ordinal::Third, Weekday::Mon, Month::Jan)
.from_year(Year::new(1986).unwrap());
assert!(!rule.is_holiday(ymd(1985, Month::Jan, 21)));
assert!(rule.is_holiday(ymd(1986, Month::Jan, 20)));
}
}