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, 4, 10), (2020, 4, 13), (2020, 5, 8), (2020, 5, 25), (2020, 8, 31), (2020, 12, 25), (2020, 12, 28), (2021, 1, 1), (2021, 4, 2), (2021, 4, 5), (2021, 5, 3), (2021, 5, 31), (2021, 8, 30), (2021, 12, 27), (2021, 12, 28), (2022, 1, 3), (2022, 4, 15), (2022, 4, 18), (2022, 5, 2), (2022, 6, 2), (2022, 6, 3), (2022, 8, 29), (2022, 9, 19), (2022, 12, 26), (2022, 12, 27), (2023, 1, 2), (2023, 4, 7), (2023, 4, 10), (2023, 5, 1), (2023, 5, 8), (2023, 5, 29), (2023, 8, 28), (2023, 12, 25), (2023, 12, 26), (2024, 1, 1), (2024, 3, 29), (2024, 4, 1), (2024, 5, 6), (2024, 5, 27), (2024, 8, 26), (2024, 12, 25), (2024, 12, 26), (2025, 1, 1), (2025, 4, 18), (2025, 4, 21), (2025, 5, 5), (2025, 5, 26), (2025, 8, 25), (2025, 12, 25), (2025, 12, 26), (2026, 1, 1), (2026, 4, 3), (2026, 4, 6), (2026, 5, 4), (2026, 5, 25), (2026, 8, 31), (2026, 12, 25), (2026, 12, 28), (2027, 1, 1), (2027, 3, 26), (2027, 3, 29), (2027, 5, 3), (2027, 5, 31), (2027, 8, 30), (2027, 12, 27), (2027, 12, 28), (2028, 1, 3), (2028, 4, 14), (2028, 4, 17), (2028, 5, 1), (2028, 5, 29), (2028, 8, 28), (2028, 12, 25), (2028, 12, 26), (2029, 1, 1), (2029, 3, 30), (2029, 4, 2), (2029, 5, 7), (2029, 5, 28), (2029, 8, 27), (2029, 12, 25), (2029, 12, 26), (2030, 1, 1), (2030, 4, 19), (2030, 4, 22), (2030, 5, 6), (2030, 5, 27), (2030, 8, 26), (2030, 12, 25), (2030, 12, 26), (2031, 1, 1), (2031, 4, 11), (2031, 4, 14), (2031, 5, 5), (2031, 5, 26), (2031, 8, 25), (2031, 12, 25), (2031, 12, 26), (2032, 1, 1), (2032, 3, 26), (2032, 3, 29), (2032, 5, 3), (2032, 5, 31), (2032, 8, 30), (2032, 12, 27), (2032, 12, 28), (2033, 1, 3), (2033, 4, 15), (2033, 4, 18), (2033, 5, 2), (2033, 5, 30), (2033, 8, 29), (2033, 12, 26), (2033, 12, 27), (2034, 1, 2), (2034, 4, 7), (2034, 4, 10), (2034, 5, 1), (2034, 5, 29), (2034, 8, 28), (2034, 12, 25), (2034, 12, 26), (2035, 1, 1), (2035, 3, 23), (2035, 3, 26), (2035, 5, 7), (2035, 5, 28), (2035, 8, 27), (2035, 12, 25), (2035, 12, 26), (2036, 1, 1), (2036, 4, 11), (2036, 4, 14), (2036, 5, 5), (2036, 5, 26), (2036, 8, 25), (2036, 12, 25), (2036, 12, 26), (2037, 1, 1), (2037, 4, 3), (2037, 4, 6), (2037, 5, 4), (2037, 5, 25), (2037, 8, 31), (2037, 12, 25), (2037, 12, 28), (2038, 1, 1), (2038, 4, 23), (2038, 4, 26), (2038, 5, 3), (2038, 5, 31), (2038, 8, 30), (2038, 12, 27), (2038, 12, 28), (2039, 1, 3), (2039, 4, 8), (2039, 4, 11), (2039, 5, 2), (2039, 5, 30), (2039, 8, 29), (2039, 12, 26), (2039, 12, 27), (2040, 1, 2), (2040, 3, 30), (2040, 4, 2), (2040, 5, 7), (2040, 5, 28), (2040, 8, 27), (2040, 12, 25), (2040, 12, 26), ];
#[must_use]
pub fn is_holiday(date: Date) -> bool {
let (min_year, max_year) = COVERAGE;
let y = date.year();
if y < min_year || y > max_year {
return false;
}
let key = (y, date.month(), date.day());
HOLIDAYS.binary_search(&key).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn holidays_table_is_sorted_strictly_ascending() {
for w in HOLIDAYS.windows(2) {
assert!(
w[0] < w[1],
"HOLIDAYS not sorted at {:?} -> {:?}",
w[0],
w[1]
);
}
}
#[test]
fn holidays_within_coverage_window() {
let (lo, hi) = COVERAGE;
for &(y, _, _) in HOLIDAYS {
assert!((lo..=hi).contains(&y), "year {y} outside COVERAGE");
}
}
#[test]
fn snapshot_constants_have_expected_shape() {
assert_eq!(SNAPSHOT_DATE, "2026-05-23");
assert_eq!(COVERAGE, (2020, 2040));
}
#[test]
fn year_2024_full_schedule() {
let expected = [
(2024, 1, 1), (2024, 3, 29), (2024, 4, 1), (2024, 5, 6), (2024, 5, 27), (2024, 8, 26), (2024, 12, 25), (2024, 12, 26), ];
for (y, m, d) in expected {
assert!(
is_holiday(Date::ymd(y, m, d).unwrap()),
"{y}-{m:02}-{d:02} should be a UK bank holiday",
);
}
assert!(!is_holiday(Date::ymd(2024, 5, 28).unwrap()));
}
#[test]
fn year_2026_full_schedule() {
let expected = [
(2026, 1, 1), (2026, 4, 3), (2026, 4, 6), (2026, 5, 4), (2026, 5, 25), (2026, 8, 31), (2026, 12, 25), (2026, 12, 28), ];
for (y, m, d) in expected {
assert!(
is_holiday(Date::ymd(y, m, d).unwrap()),
"{y}-{m:02}-{d:02} should be a UK bank holiday",
);
}
assert!(!is_holiday(Date::ymd(2026, 12, 26).unwrap()));
}
#[test]
fn new_year_2022_observed_monday() {
assert!(!is_holiday(Date::ymd(2022, 1, 1).unwrap()));
assert!(is_holiday(Date::ymd(2022, 1, 3).unwrap()));
}
#[test]
fn christmas_2021_sat_and_boxing_sun_both_shifted() {
assert!(!is_holiday(Date::ymd(2021, 12, 25).unwrap()));
assert!(!is_holiday(Date::ymd(2021, 12, 26).unwrap()));
assert!(is_holiday(Date::ymd(2021, 12, 27).unwrap()));
assert!(is_holiday(Date::ymd(2021, 12, 28).unwrap()));
}
#[test]
fn platinum_jubilee_2022_specials() {
assert!(is_holiday(Date::ymd(2022, 6, 2).unwrap()));
assert!(is_holiday(Date::ymd(2022, 6, 3).unwrap()));
assert!(!is_holiday(Date::ymd(2022, 5, 30).unwrap()));
}
#[test]
fn queen_elizabeth_ii_state_funeral_2022_09_19() {
assert!(is_holiday(Date::ymd(2022, 9, 19).unwrap()));
}
#[test]
fn king_charles_iii_coronation_2023_05_08() {
assert!(is_holiday(Date::ymd(2023, 5, 8).unwrap()));
}
#[test]
fn ve_day_75th_2020_05_08_replaces_first_monday() {
assert!(is_holiday(Date::ymd(2020, 5, 8).unwrap()));
assert!(!is_holiday(Date::ymd(2020, 5, 4).unwrap()));
}
#[test]
fn derivation_matches_table() {
use crate::Weekday;
let (lo, hi) = COVERAGE;
let mut buf: [(i32, u8, u8); 200] = [(0, 0, 0); 200];
let mut n: usize = 0;
let last_monday = |y: i32, m: u8| -> Date {
let mut last_n: u8 = 1;
for cand in 1..=5u8 {
if Date::nth_weekday_of_month(y, m, cand, Weekday::Mon).is_ok() {
last_n = cand;
}
}
Date::nth_weekday_of_month(y, m, last_n, Weekday::Mon).unwrap()
};
for y in lo..=hi {
let nyd = Date::ymd(y, 1, 1).unwrap();
let nyd_obs = match nyd.day_of_week() {
Weekday::Sat => Date::ymd(y, 1, 3).unwrap(),
Weekday::Sun => Date::ymd(y, 1, 2).unwrap(),
_ => nyd,
};
buf[n] = (nyd_obs.year(), nyd_obs.month(), nyd_obs.day());
n += 1;
let easter = Date::easter_sunday(y);
let gf = easter.add_days(-2);
buf[n] = (gf.year(), gf.month(), gf.day());
n += 1;
let em = easter.add_days(1);
buf[n] = (em.year(), em.month(), em.day());
n += 1;
if y == 2020 {
buf[n] = (2020, 5, 8);
} else {
let d = Date::nth_weekday_of_month(y, 5, 1, Weekday::Mon).unwrap();
buf[n] = (d.year(), d.month(), d.day());
}
n += 1;
if y == 2022 {
buf[n] = (2022, 6, 2);
} else {
let d = last_monday(y, 5);
buf[n] = (d.year(), d.month(), d.day());
}
n += 1;
let d = last_monday(y, 8);
buf[n] = (d.year(), d.month(), d.day());
n += 1;
let xmas = Date::ymd(y, 12, 25).unwrap();
let (x_obs, b_obs) = match xmas.day_of_week() {
Weekday::Fri => ((y, 12, 25), (y, 12, 28)), Weekday::Sat => ((y, 12, 27), (y, 12, 28)), Weekday::Sun => ((y, 12, 26), (y, 12, 27)), _ => ((y, 12, 25), (y, 12, 26)),
};
buf[n] = x_obs;
n += 1;
buf[n] = b_obs;
n += 1;
}
buf[n] = (2022, 6, 3); n += 1;
buf[n] = (2022, 9, 19); n += 1;
buf[n] = (2023, 5, 8); n += 1;
buf[..n].sort_unstable();
assert_eq!(
n,
HOLIDAYS.len(),
"rule-derived row count {} != HOLIDAYS.len() {}",
n,
HOLIDAYS.len(),
);
for (i, (got, want)) in buf[..n].iter().zip(HOLIDAYS.iter()).enumerate() {
assert_eq!(got, want, "row {i}: derived {got:?} != HOLIDAYS {want:?}");
}
}
#[test]
fn out_of_coverage_returns_false() {
assert!(!is_holiday(Date::ymd(2019, 12, 25).unwrap()));
assert!(!is_holiday(Date::ymd(2041, 1, 1).unwrap()));
}
}