use crate::{Date, EasterMethod, Month, Year, YearRange, easter_monday};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EasterOffset {
days: i16,
method: EasterMethod,
years: YearRange,
}
impl EasterOffset {
#[must_use]
pub const fn new(days: i16) -> Self {
Self {
days,
method: EasterMethod::Western,
years: YearRange::ALWAYS,
}
}
#[must_use]
pub const fn new_orthodox(days: i16) -> Self {
Self {
days,
method: EasterMethod::Orthodox,
years: YearRange::ALWAYS,
}
}
#[must_use]
pub const fn good_friday() -> Self {
Self::new(-2)
}
#[must_use]
pub const fn easter_sunday() -> Self {
Self::new(0)
}
#[must_use]
pub const fn easter_monday() -> Self {
Self::new(1)
}
#[must_use]
pub const fn ascension() -> Self {
Self::new(39)
}
#[must_use]
pub const fn whit_monday() -> Self {
Self::new(50)
}
#[must_use]
pub const fn corpus_christi() -> Self {
Self::new(60)
}
#[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 days(&self) -> i16 {
self.days
}
#[must_use]
pub const fn method(&self) -> EasterMethod {
self.method
}
#[must_use]
pub const fn year_range(&self) -> YearRange {
self.years
}
#[must_use]
pub fn is_holiday(&self, date: Date) -> bool {
let year = date.year();
if !self.years.contains(year) {
return false;
}
let em_doy = easter_monday(year, self.method);
let Ok(jan1) = Date::from_ymd(year.get(), Month::Jan, 1) else {
return false;
};
let Ok(easter_sun) = jan1.add_days(i32::from(em_doy) - 2) else {
return false;
};
let Ok(observed) = easter_sun.add_days(i32::from(self.days)) else {
return false;
};
observed == date
}
#[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(jan1) = Date::from_ymd(year.get(), Month::Jan, 1) else {
return None;
};
let offset = crate::easter_sunday(year, self.method) as i32 - 1 + self.days as i32;
if offset < 0 || offset >= year.length() as i32 {
return None;
}
super::date_ok(jan1.add_days(offset))
}
}
#[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 good_friday_anchors() {
let rule = EasterOffset::good_friday();
assert!(rule.is_holiday(ymd(2024, Month::Mar, 29)));
assert!(!rule.is_holiday(ymd(2024, Month::Mar, 28)));
assert!(rule.is_holiday(ymd(2025, Month::Apr, 18)));
assert!(rule.is_holiday(ymd(2026, Month::Apr, 3)));
}
#[test]
fn easter_monday_anchors() {
let rule = EasterOffset::easter_monday();
assert!(rule.is_holiday(ymd(2024, Month::Apr, 1)));
assert!(rule.is_holiday(ymd(2026, Month::Apr, 6)));
}
#[test]
fn ascension_whit_corpus_christi() {
assert!(EasterOffset::ascension().is_holiday(ymd(2024, Month::May, 9)));
assert!(EasterOffset::whit_monday().is_holiday(ymd(2024, Month::May, 20)));
assert!(EasterOffset::corpus_christi().is_holiday(ymd(2024, Month::May, 30)));
}
#[test]
fn orthodox_method() {
let ortho_sunday = EasterOffset::new_orthodox(0);
let ortho_monday = EasterOffset::new_orthodox(1);
assert!(ortho_sunday.is_holiday(ymd(2024, Month::May, 5)));
assert!(ortho_monday.is_holiday(ymd(2024, Month::May, 6)));
assert!(!ortho_monday.is_holiday(ymd(2024, Month::Apr, 1)));
}
#[test]
fn year_range_filter() {
let rule = EasterOffset::good_friday().from_year(Year::new(2025).unwrap());
assert!(!rule.is_holiday(ymd(2024, Month::Mar, 29)));
assert!(rule.is_holiday(ymd(2025, Month::Apr, 18)));
}
#[test]
fn rejects_unrelated_dates() {
let rule = EasterOffset::easter_monday();
assert!(!rule.is_holiday(ymd(2024, Month::Jan, 1)));
assert!(!rule.is_holiday(ymd(2024, Month::Dec, 25)));
}
}