use crate::date::Date;
pub const SNAPSHOT_DATE: &str = "2026-05-23";
pub const COVERAGE: (i32, i32) = (2020, 2040);
pub const HOLIDAYS: &[(i32, u8, u8)] = &[
(2020, 1, 1), (2020, 1, 20), (2020, 2, 17), (2020, 4, 10), (2020, 5, 25), (2020, 7, 3), (2020, 9, 7), (2020, 11, 26), (2020, 12, 25), (2021, 1, 1), (2021, 1, 18), (2021, 2, 15), (2021, 4, 2), (2021, 5, 31), (2021, 7, 5), (2021, 9, 6), (2021, 11, 25), (2021, 12, 24), (2021, 12, 31), (2022, 1, 17), (2022, 2, 21), (2022, 4, 15), (2022, 5, 30), (2022, 6, 20), (2022, 7, 4), (2022, 9, 5), (2022, 11, 24), (2022, 12, 26), (2023, 1, 2), (2023, 1, 16), (2023, 2, 20), (2023, 4, 7), (2023, 5, 29), (2023, 6, 19), (2023, 7, 4), (2023, 9, 4), (2023, 11, 23), (2023, 12, 25), (2024, 1, 1), (2024, 1, 15), (2024, 2, 19), (2024, 3, 29), (2024, 5, 27), (2024, 6, 19), (2024, 7, 4), (2024, 9, 2), (2024, 11, 28), (2024, 12, 25), (2025, 1, 1), (2025, 1, 20), (2025, 2, 17), (2025, 4, 18), (2025, 5, 26), (2025, 6, 19), (2025, 7, 4), (2025, 9, 1), (2025, 11, 27), (2025, 12, 25), (2026, 1, 1), (2026, 1, 19), (2026, 2, 16), (2026, 4, 3), (2026, 5, 25), (2026, 6, 19), (2026, 7, 3), (2026, 9, 7), (2026, 11, 26), (2026, 12, 25), (2027, 1, 1), (2027, 1, 18), (2027, 2, 15), (2027, 3, 26), (2027, 5, 31), (2027, 6, 18), (2027, 7, 5), (2027, 9, 6), (2027, 11, 25), (2027, 12, 24), (2027, 12, 31), (2028, 1, 17), (2028, 2, 21), (2028, 4, 14), (2028, 5, 29), (2028, 6, 19), (2028, 7, 4), (2028, 9, 4), (2028, 11, 23), (2028, 12, 25), (2029, 1, 1), (2029, 1, 15), (2029, 2, 19), (2029, 3, 30), (2029, 5, 28), (2029, 6, 19), (2029, 7, 4), (2029, 9, 3), (2029, 11, 22), (2029, 12, 25), (2030, 1, 1), (2030, 1, 21), (2030, 2, 18), (2030, 4, 19), (2030, 5, 27), (2030, 6, 19), (2030, 7, 4), (2030, 9, 2), (2030, 11, 28), (2030, 12, 25), (2031, 1, 1), (2031, 1, 20), (2031, 2, 17), (2031, 4, 11), (2031, 5, 26), (2031, 6, 19), (2031, 7, 4), (2031, 9, 1), (2031, 11, 27), (2031, 12, 25), (2032, 1, 1), (2032, 1, 19), (2032, 2, 16), (2032, 3, 26), (2032, 5, 31), (2032, 6, 18), (2032, 7, 5), (2032, 9, 6), (2032, 11, 25), (2032, 12, 24), (2032, 12, 31), (2033, 1, 17), (2033, 2, 21), (2033, 4, 15), (2033, 5, 30), (2033, 6, 20), (2033, 7, 4), (2033, 9, 5), (2033, 11, 24), (2033, 12, 26), (2034, 1, 2), (2034, 1, 16), (2034, 2, 20), (2034, 4, 7), (2034, 5, 29), (2034, 6, 19), (2034, 7, 4), (2034, 9, 4), (2034, 11, 23), (2034, 12, 25), (2035, 1, 1), (2035, 1, 15), (2035, 2, 19), (2035, 3, 23), (2035, 5, 28), (2035, 6, 19), (2035, 7, 4), (2035, 9, 3), (2035, 11, 22), (2035, 12, 25), (2036, 1, 1), (2036, 1, 21), (2036, 2, 18), (2036, 4, 11), (2036, 5, 26), (2036, 6, 19), (2036, 7, 4), (2036, 9, 1), (2036, 11, 27), (2036, 12, 25), (2037, 1, 1), (2037, 1, 19), (2037, 2, 16), (2037, 4, 3), (2037, 5, 25), (2037, 6, 19), (2037, 7, 3), (2037, 9, 7), (2037, 11, 26), (2037, 12, 25), (2038, 1, 1), (2038, 1, 18), (2038, 2, 15), (2038, 4, 23), (2038, 5, 31), (2038, 6, 18), (2038, 7, 5), (2038, 9, 6), (2038, 11, 25), (2038, 12, 24), (2038, 12, 31), (2039, 1, 17), (2039, 2, 21), (2039, 4, 8), (2039, 5, 30), (2039, 6, 20), (2039, 7, 4), (2039, 9, 5), (2039, 11, 24), (2039, 12, 26), (2040, 1, 2), (2040, 1, 16), (2040, 2, 20), (2040, 3, 30), (2040, 5, 28), (2040, 6, 19), (2040, 7, 4), (2040, 9, 3), (2040, 11, 22), (2040, 12, 25), ];
#[must_use]
pub fn is_holiday(date: Date) -> bool {
let (lo, hi) = COVERAGE;
if date.year() < lo || date.year() > hi {
return false;
}
let key = (date.year(), date.month(), date.day());
HOLIDAYS.binary_search(&key).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn d(year: i32, month: u8, day: u8) -> Date {
Date::ymd(year, month, day).expect("valid test date")
}
#[test]
fn known_2024_holidays_are_recognised() {
let cases = [
(2024, 1, 1, "New Year's Day"),
(2024, 1, 15, "Martin Luther King Jr. Day"),
(2024, 2, 19, "Washington's Birthday"),
(2024, 3, 29, "Good Friday"),
(2024, 5, 27, "Memorial Day"),
(2024, 6, 19, "Juneteenth"),
(2024, 7, 4, "Independence Day"),
(2024, 9, 2, "Labor Day"),
(2024, 11, 28, "Thanksgiving Day"),
(2024, 12, 25, "Christmas Day"),
];
for (y, m, dd, name) in cases {
assert!(is_holiday(d(y, m, dd)), "{y}-{m:02}-{dd:02} {name}");
}
}
#[test]
fn new_year_2026_thursday_is_unshifted() {
assert!(is_holiday(d(2026, 1, 1)));
}
#[test]
fn new_year_2027_friday_is_unshifted() {
assert!(is_holiday(d(2027, 1, 1)));
}
#[test]
fn new_year_2028_saturday_shifts_to_prior_friday() {
assert!(is_holiday(d(2027, 12, 31)));
assert!(!is_holiday(d(2028, 1, 1)));
}
#[test]
fn new_year_2022_saturday_shifts_to_prior_friday() {
assert!(is_holiday(d(2021, 12, 31)));
assert!(!is_holiday(d(2022, 1, 1)));
}
#[test]
fn new_year_2023_sunday_shifts_to_following_monday() {
assert!(is_holiday(d(2023, 1, 2)));
assert!(!is_holiday(d(2023, 1, 1)));
}
#[test]
fn independence_day_2020_saturday_shifts_to_prior_friday() {
assert!(is_holiday(d(2020, 7, 3)));
assert!(!is_holiday(d(2020, 7, 4)));
}
#[test]
fn independence_day_2026_saturday_shifts_to_prior_friday() {
assert!(is_holiday(d(2026, 7, 3)));
assert!(!is_holiday(d(2026, 7, 4)));
}
#[test]
fn independence_day_2021_sunday_shifts_to_following_monday() {
assert!(is_holiday(d(2021, 7, 5)));
assert!(!is_holiday(d(2021, 7, 4)));
}
#[test]
fn juneteenth_not_observed_before_2022() {
assert!(!is_holiday(d(2020, 6, 19)));
assert!(!is_holiday(d(2021, 6, 19)));
}
#[test]
fn juneteenth_observed_from_2022() {
assert!(is_holiday(d(2022, 6, 20)));
assert!(is_holiday(d(2023, 6, 19)));
assert!(is_holiday(d(2024, 6, 19)));
}
#[test]
fn out_of_coverage_returns_false_below() {
assert!(!is_holiday(d(2019, 12, 25)));
}
#[test]
fn out_of_coverage_returns_false_above() {
assert!(!is_holiday(d(2041, 1, 1)));
}
#[test]
fn ordinary_business_day_is_not_a_holiday() {
assert!(!is_holiday(d(2026, 3, 15)));
assert!(!is_holiday(d(2026, 3, 16)));
}
#[test]
fn holidays_table_is_sorted_ascending() {
for window in HOLIDAYS.windows(2) {
assert!(window[0] < window[1], "out of order at {window:?}");
}
}
#[test]
fn holidays_table_has_no_duplicates() {
for window in HOLIDAYS.windows(2) {
assert_ne!(window[0], window[1], "duplicate row at {window:?}");
}
}
#[test]
fn holidays_table_entry_count() {
assert_eq!(HOLIDAYS.len(), 208);
}
#[test]
fn snapshot_date_and_coverage_constants() {
assert_eq!(SNAPSHOT_DATE, "2026-05-23");
assert_eq!(COVERAGE, (2020, 2040));
}
#[test]
fn every_holiday_row_matches_its_calendar_rule() {
use crate::date::Weekday;
fn shift_weekend(d: Date) -> Date {
match d.day_of_week() {
Weekday::Sat => d.add_days(-1),
Weekday::Sun => d.add_days(1),
_ => d,
}
}
fn last_monday_of_may(year: i32) -> Date {
Date::nth_weekday_of_month(year, 5, 5, Weekday::Mon).unwrap_or_else(|_| {
Date::nth_weekday_of_month(year, 5, 4, Weekday::Mon)
.expect("4th Monday of May exists in every Gregorian year")
})
}
let mut expected: [(i32, u8, u8); 256] = [(0, 0, 0); 256];
let mut n: usize = 0;
let (lo, hi) = COVERAGE;
for y in lo..=hi {
let nyd = shift_weekend(Date::ymd(y, 1, 1).expect("Jan 1 within coverage"));
if nyd.year() == y {
expected[n] = (nyd.year(), nyd.month(), nyd.day());
n += 1;
}
let mlk = Date::nth_weekday_of_month(y, 1, 3, Weekday::Mon)
.expect("3rd Mon Jan exists in every year");
expected[n] = (mlk.year(), mlk.month(), mlk.day());
n += 1;
let wbd = Date::nth_weekday_of_month(y, 2, 3, Weekday::Mon)
.expect("3rd Mon Feb exists in every year");
expected[n] = (wbd.year(), wbd.month(), wbd.day());
n += 1;
let gf = Date::easter_sunday(y).add_days(-2);
expected[n] = (gf.year(), gf.month(), gf.day());
n += 1;
let mem = last_monday_of_may(y);
expected[n] = (mem.year(), mem.month(), mem.day());
n += 1;
if y >= 2022 {
let jt = shift_weekend(Date::ymd(y, 6, 19).expect("Jun 19 within coverage"));
expected[n] = (jt.year(), jt.month(), jt.day());
n += 1;
}
let ind = shift_weekend(Date::ymd(y, 7, 4).expect("Jul 4 within coverage"));
expected[n] = (ind.year(), ind.month(), ind.day());
n += 1;
let lbr = Date::nth_weekday_of_month(y, 9, 1, Weekday::Mon)
.expect("1st Mon Sep exists in every year");
expected[n] = (lbr.year(), lbr.month(), lbr.day());
n += 1;
let tg = Date::nth_weekday_of_month(y, 11, 4, Weekday::Thu)
.expect("4th Thu Nov exists in every year");
expected[n] = (tg.year(), tg.month(), tg.day());
n += 1;
let xm = shift_weekend(Date::ymd(y, 12, 25).expect("Dec 25 within coverage"));
expected[n] = (xm.year(), xm.month(), xm.day());
n += 1;
let nyd_next =
shift_weekend(Date::ymd(y + 1, 1, 1).expect("Jan 1 of y+1 within coverage+1"));
if nyd_next.year() == y {
expected[n] = (nyd_next.year(), nyd_next.month(), nyd_next.day());
n += 1;
}
}
assert_eq!(
n,
HOLIDAYS.len(),
"row count drift: rule derives {n} rows, table has {}",
HOLIDAYS.len()
);
for (i, (got, want)) in HOLIDAYS.iter().zip(expected.iter().take(n)).enumerate() {
assert_eq!(
got, want,
"row {i}: table has {got:?}, rule derives {want:?}",
);
}
}
}