use std::fmt;
use std::str::FromStr;
use time::{Date, Month, Weekday};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::ParseError;
use crate::load_profile::SlpDayType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "UPPERCASE"))]
pub enum Bundesland {
Bw,
By,
Be,
Bb,
Hb,
Hh,
He,
Mv,
Ni,
Nw,
Rp,
Sl,
Sn,
St,
Sh,
Th,
}
impl Bundesland {
pub const ALL: [Self; 16] = [
Self::Bw,
Self::By,
Self::Be,
Self::Bb,
Self::Hb,
Self::Hh,
Self::He,
Self::Mv,
Self::Ni,
Self::Nw,
Self::Rp,
Self::Sl,
Self::Sn,
Self::St,
Self::Sh,
Self::Th,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Bw => "BW",
Self::By => "BY",
Self::Be => "BE",
Self::Bb => "BB",
Self::Hb => "HB",
Self::Hh => "HH",
Self::He => "HE",
Self::Mv => "MV",
Self::Ni => "NI",
Self::Nw => "NW",
Self::Rp => "RP",
Self::Sl => "SL",
Self::Sn => "SN",
Self::St => "ST",
Self::Sh => "SH",
Self::Th => "TH",
}
}
pub const CODES: &'static [&'static str] = &[
"BW", "BY", "BE", "BB", "HB", "HH", "HE", "MV", "NI", "NW", "RP", "SL", "SN", "ST", "SH",
"TH",
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Bw => "Baden-Württemberg",
Self::By => "Bayern",
Self::Be => "Berlin",
Self::Bb => "Brandenburg",
Self::Hb => "Bremen",
Self::Hh => "Hamburg",
Self::He => "Hessen",
Self::Mv => "Mecklenburg-Vorpommern",
Self::Ni => "Niedersachsen",
Self::Nw => "Nordrhein-Westfalen",
Self::Rp => "Rheinland-Pfalz",
Self::Sl => "Saarland",
Self::Sn => "Sachsen",
Self::St => "Sachsen-Anhalt",
Self::Sh => "Schleswig-Holstein",
Self::Th => "Thüringen",
}
}
#[must_use]
pub fn holiday(self, date: Date) -> Option<Holiday> {
Holiday::on(date).find(|h| h.applies_in(self))
}
#[must_use]
pub fn is_holiday(self, date: Date) -> bool {
self.holiday(date).is_some()
}
#[must_use]
pub fn holidays_in_year(self, year: i32) -> Vec<(Date, Holiday)> {
let mut out: Vec<(Date, Holiday)> = Holiday::ALL
.iter()
.filter(|h| h.applies_in(self))
.filter_map(|h| h.date_in(year).map(|d| (d, *h)))
.collect();
out.sort_by_key(|(d, _)| *d);
out
}
}
impl fmt::Display for Bundesland {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Bundesland {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let t = s.trim().to_uppercase();
let code = t.strip_prefix("DE-").unwrap_or(&t);
Self::ALL
.iter()
.copied()
.find(|b| b.as_str() == code)
.ok_or_else(|| ParseError::one_of("Bundesland", s, Self::CODES))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum Holiday {
Neujahr,
HeiligeDreiKoenige,
Frauentag,
Karfreitag,
Ostersonntag,
Ostermontag,
TagDerArbeit,
ChristiHimmelfahrt,
Pfingstsonntag,
Pfingstmontag,
Fronleichnam,
MariaeHimmelfahrt,
Weltkindertag,
TagDerDeutschenEinheit,
Reformationstag,
Allerheiligen,
BussUndBettag,
ErsterWeihnachtstag,
ZweiterWeihnachtstag,
}
impl Holiday {
pub const ALL: [Self; 19] = [
Self::Neujahr,
Self::HeiligeDreiKoenige,
Self::Frauentag,
Self::Karfreitag,
Self::Ostersonntag,
Self::Ostermontag,
Self::TagDerArbeit,
Self::ChristiHimmelfahrt,
Self::Pfingstsonntag,
Self::Pfingstmontag,
Self::Fronleichnam,
Self::MariaeHimmelfahrt,
Self::Weltkindertag,
Self::TagDerDeutschenEinheit,
Self::Reformationstag,
Self::Allerheiligen,
Self::BussUndBettag,
Self::ErsterWeihnachtstag,
Self::ZweiterWeihnachtstag,
];
pub const NATIONWIDE: [Self; 9] = [
Self::Neujahr,
Self::Karfreitag,
Self::Ostermontag,
Self::TagDerArbeit,
Self::ChristiHimmelfahrt,
Self::Pfingstmontag,
Self::TagDerDeutschenEinheit,
Self::ErsterWeihnachtstag,
Self::ZweiterWeihnachtstag,
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Neujahr => "Neujahr",
Self::HeiligeDreiKoenige => "Heilige Drei Könige",
Self::Frauentag => "Internationaler Frauentag",
Self::Karfreitag => "Karfreitag",
Self::Ostersonntag => "Ostersonntag",
Self::Ostermontag => "Ostermontag",
Self::TagDerArbeit => "Tag der Arbeit",
Self::ChristiHimmelfahrt => "Christi Himmelfahrt",
Self::Pfingstsonntag => "Pfingstsonntag",
Self::Pfingstmontag => "Pfingstmontag",
Self::Fronleichnam => "Fronleichnam",
Self::MariaeHimmelfahrt => "Mariä Himmelfahrt",
Self::Weltkindertag => "Weltkindertag",
Self::TagDerDeutschenEinheit => "Tag der Deutschen Einheit",
Self::Reformationstag => "Reformationstag",
Self::Allerheiligen => "Allerheiligen",
Self::BussUndBettag => "Buß- und Bettag",
Self::ErsterWeihnachtstag => "Erster Weihnachtstag",
Self::ZweiterWeihnachtstag => "Zweiter Weihnachtstag",
}
}
#[must_use]
pub const fn laender(self) -> &'static [Bundesland] {
use Bundesland as B;
match self {
Self::Neujahr
| Self::Karfreitag
| Self::Ostermontag
| Self::TagDerArbeit
| Self::ChristiHimmelfahrt
| Self::Pfingstmontag
| Self::TagDerDeutschenEinheit
| Self::ErsterWeihnachtstag
| Self::ZweiterWeihnachtstag => &B::ALL,
Self::HeiligeDreiKoenige => &[B::Bw, B::By, B::St],
Self::Frauentag => &[B::Be, B::Mv],
Self::Ostersonntag | Self::Pfingstsonntag => &[B::Bb],
Self::Fronleichnam => &[B::Bw, B::By, B::He, B::Nw, B::Rp, B::Sl],
Self::MariaeHimmelfahrt => &[B::Sl],
Self::Weltkindertag => &[B::Th],
Self::Reformationstag => &[
B::Bb,
B::Hb,
B::Hh,
B::Mv,
B::Ni,
B::Sh,
B::Sn,
B::St,
B::Th,
],
Self::Allerheiligen => &[B::Bw, B::By, B::Nw, B::Rp, B::Sl],
Self::BussUndBettag => &[B::Sn],
}
}
#[must_use]
pub fn is_nationwide(self) -> bool {
self.laender().len() == Bundesland::ALL.len()
}
#[must_use]
pub fn applies_in(self, land: Bundesland) -> bool {
self.laender().contains(&land)
}
#[must_use]
pub fn date_in(self, year: i32) -> Option<Date> {
let fixed = |m: Month, d: u8| Date::from_calendar_date(year, m, d).ok();
let from_easter =
|offset: i64| easter_sunday(year)?.checked_add(time::Duration::days(offset));
match self {
Self::Neujahr => fixed(Month::January, 1),
Self::HeiligeDreiKoenige => fixed(Month::January, 6),
Self::Frauentag => fixed(Month::March, 8),
Self::Karfreitag => from_easter(-2),
Self::Ostersonntag => easter_sunday(year),
Self::Ostermontag => from_easter(1),
Self::TagDerArbeit => fixed(Month::May, 1),
Self::ChristiHimmelfahrt => from_easter(39),
Self::Pfingstsonntag => from_easter(49),
Self::Pfingstmontag => from_easter(50),
Self::Fronleichnam => from_easter(60),
Self::MariaeHimmelfahrt => fixed(Month::August, 15),
Self::Weltkindertag => fixed(Month::September, 20),
Self::TagDerDeutschenEinheit => fixed(Month::October, 3),
Self::Reformationstag => fixed(Month::October, 31),
Self::Allerheiligen => fixed(Month::November, 1),
Self::BussUndBettag => buss_und_bettag(year),
Self::ErsterWeihnachtstag => fixed(Month::December, 25),
Self::ZweiterWeihnachtstag => fixed(Month::December, 26),
}
}
pub fn on(date: Date) -> impl Iterator<Item = Self> {
let year = date.year();
Self::ALL
.into_iter()
.filter(move |h| h.date_in(year) == Some(date))
}
}
impl fmt::Display for Holiday {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[must_use]
pub fn slp_day_type(date: Date, land: Bundesland) -> SlpDayType {
if date.weekday() == Weekday::Sunday || land.is_holiday(date) {
SlpDayType::SonnFeiertag
} else if date.weekday() == Weekday::Saturday {
SlpDayType::Samstag
} else {
SlpDayType::Werktag
}
}
#[must_use]
pub fn easter_sunday(year: i32) -> Option<Date> {
let a = year.rem_euclid(19);
let b = year.div_euclid(100);
let c = year.rem_euclid(100);
let d = b.div_euclid(4);
let e = b.rem_euclid(4);
let f = (b + 8).div_euclid(25);
let g = (b - f + 1).div_euclid(3);
let h = (19 * a + b - d - g + 15).rem_euclid(30);
let i = c.div_euclid(4);
let k = c.rem_euclid(4);
let l = (32 + 2 * e + 2 * i - h - k).rem_euclid(7);
let m = (a + 11 * h + 22 * l).div_euclid(451);
let month = (h + l - 7 * m + 114).div_euclid(31); let day = (h + l - 7 * m + 114).rem_euclid(31) + 1;
let month = Month::try_from(u8::try_from(month).ok()?).ok()?;
Date::from_calendar_date(year, month, u8::try_from(day).ok()?).ok()
}
fn buss_und_bettag(year: i32) -> Option<Date> {
let reference = Date::from_calendar_date(year, Month::November, 23).ok()?;
let back = match reference.weekday() {
Weekday::Wednesday => 7,
Weekday::Thursday => 1,
Weekday::Friday => 2,
Weekday::Saturday => 3,
Weekday::Sunday => 4,
Weekday::Monday => 5,
Weekday::Tuesday => 6,
};
reference.checked_sub(time::Duration::days(back))
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
#[test]
fn easter_matches_the_published_dates() {
let cases = [
(2020, date!(2020 - 04 - 12)),
(2021, date!(2021 - 04 - 04)),
(2022, date!(2022 - 04 - 17)),
(2023, date!(2023 - 04 - 09)),
(2024, date!(2024 - 03 - 31)),
(2025, date!(2025 - 04 - 20)),
(2026, date!(2026 - 04 - 05)),
(2027, date!(2027 - 03 - 28)),
(2028, date!(2028 - 04 - 16)),
(2030, date!(2030 - 04 - 21)),
(2038, date!(2038 - 04 - 25)), (2285, date!(2285 - 03 - 22)), ];
for (year, expected) in cases {
assert_eq!(easter_sunday(year), Some(expected), "Easter {year}");
}
}
#[test]
fn easter_is_always_a_sunday_in_range() {
for year in 1900..2200 {
let e = easter_sunday(year).expect("in range");
assert_eq!(e.weekday(), Weekday::Sunday, "{year}");
assert!(
e >= Date::from_calendar_date(year, Month::March, 22).unwrap()
&& e <= Date::from_calendar_date(year, Month::April, 25).unwrap(),
"{year}: {e}"
);
}
}
#[test]
fn movable_feasts_2026() {
assert_eq!(
Holiday::Karfreitag.date_in(2026),
Some(date!(2026 - 04 - 03))
);
assert_eq!(
Holiday::Ostermontag.date_in(2026),
Some(date!(2026 - 04 - 06))
);
assert_eq!(
Holiday::ChristiHimmelfahrt.date_in(2026),
Some(date!(2026 - 05 - 14))
);
assert_eq!(
Holiday::Pfingstmontag.date_in(2026),
Some(date!(2026 - 05 - 25))
);
assert_eq!(
Holiday::Fronleichnam.date_in(2026),
Some(date!(2026 - 06 - 04))
);
}
#[test]
fn buss_und_bettag_is_the_wednesday_before_the_23rd() {
let cases = [
(2022, date!(2022 - 11 - 16)), (2023, date!(2023 - 11 - 22)),
(2024, date!(2024 - 11 - 20)),
(2025, date!(2025 - 11 - 19)),
(2026, date!(2026 - 11 - 18)),
(2027, date!(2027 - 11 - 17)),
(2028, date!(2028 - 11 - 22)),
];
for (year, expected) in cases {
let d = Holiday::BussUndBettag.date_in(year).expect("in range");
assert_eq!(d, expected, "Buß- und Bettag {year}");
assert_eq!(d.weekday(), Weekday::Wednesday);
assert!((16..=22).contains(&d.day()), "{d} must fall in 16–22 Nov");
}
}
#[test]
fn regional_holidays_are_regional() {
let fronleichnam = date!(2026 - 06 - 04);
for land in [
Bundesland::Bw,
Bundesland::By,
Bundesland::He,
Bundesland::Nw,
Bundesland::Rp,
Bundesland::Sl,
] {
assert!(
land.is_holiday(fronleichnam),
"{land} observes Fronleichnam"
);
}
for land in [Bundesland::Be, Bundesland::Hh, Bundesland::Ni] {
assert!(!land.is_holiday(fronleichnam), "{land} does not");
}
let buss = date!(2026 - 11 - 18);
assert!(Bundesland::Sn.is_holiday(buss));
assert_eq!(
Bundesland::ALL
.iter()
.filter(|l| l.is_holiday(buss))
.count(),
1
);
let reformation = date!(2026 - 10 - 31);
assert_eq!(
Bundesland::ALL
.iter()
.filter(|l| l.is_holiday(reformation))
.count(),
9
);
}
#[test]
fn the_nationwide_nine_hold_everywhere() {
for h in Holiday::NATIONWIDE {
assert!(h.is_nationwide(), "{h}");
let d = h.date_in(2026).expect("in range");
for land in Bundesland::ALL {
assert!(land.is_holiday(d), "{h} in {land}");
}
}
for land in Bundesland::ALL {
let n = land.holidays_in_year(2026).len();
assert!(n >= 9, "{land} has only {n} holidays");
}
assert_eq!(Bundesland::By.holidays_in_year(2026).len(), 12);
assert_eq!(Bundesland::Be.holidays_in_year(2026).len(), 10);
}
#[test]
fn holidays_in_year_are_sorted() {
let list = Bundesland::By.holidays_in_year(2026);
assert!(list.windows(2).all(|w| w[0].0 <= w[1].0));
assert_eq!(list.first().unwrap().1, Holiday::Neujahr);
assert_eq!(list.last().unwrap().1, Holiday::ZweiterWeihnachtstag);
}
#[test]
fn slp_day_types_follow_the_land_calendar() {
let fronleichnam = date!(2026 - 06 - 04); assert_eq!(
slp_day_type(fronleichnam, Bundesland::By),
SlpDayType::SonnFeiertag
);
assert_eq!(
slp_day_type(fronleichnam, Bundesland::Be),
SlpDayType::Werktag,
"a Berlin Fronleichnam is an ordinary Thursday"
);
assert_eq!(
slp_day_type(date!(2026 - 06 - 06), Bundesland::Be),
SlpDayType::Samstag
);
assert_eq!(
slp_day_type(date!(2026 - 06 - 07), Bundesland::Be),
SlpDayType::SonnFeiertag
);
assert_eq!(date!(2026 - 08 - 15).weekday(), Weekday::Saturday);
assert_eq!(
slp_day_type(date!(2026 - 08 - 15), Bundesland::Sl),
SlpDayType::SonnFeiertag
);
assert_eq!(
slp_day_type(date!(2026 - 08 - 15), Bundesland::Nw),
SlpDayType::Samstag
);
assert_eq!(
slp_day_type(date!(2026 - 12 - 24), Bundesland::Be),
SlpDayType::Werktag
);
}
#[test]
fn coinciding_holidays_are_both_reported() {
let both: Vec<_> = Holiday::on(date!(2008 - 05 - 01)).collect();
assert_eq!(
both,
vec![Holiday::TagDerArbeit, Holiday::ChristiHimmelfahrt]
);
}
#[test]
fn bundesland_codes_round_trip() {
assert_eq!(Bundesland::ALL.len(), Bundesland::CODES.len());
for (land, code) in Bundesland::ALL.iter().zip(Bundesland::CODES) {
assert_eq!(land.as_str(), *code);
assert_eq!(&land.to_string().parse::<Bundesland>().unwrap(), land);
assert!(!land.name().is_empty());
}
let unique: std::collections::BTreeSet<_> = Bundesland::CODES.iter().collect();
assert_eq!(unique.len(), Bundesland::CODES.len());
assert_eq!("de-by".parse::<Bundesland>().unwrap(), Bundesland::By);
assert_eq!(" ni ".parse::<Bundesland>().unwrap(), Bundesland::Ni);
assert!("XX".parse::<Bundesland>().is_err());
}
#[test]
fn scope_accessors_agree() {
for h in Holiday::ALL {
assert!(!h.name().is_empty());
assert!(h.date_in(2026).is_some(), "{h}");
for land in Bundesland::ALL {
assert_eq!(
h.applies_in(land),
h.laender().contains(&land),
"{h}/{land}"
);
}
assert_eq!(
h.is_nationwide(),
Holiday::NATIONWIDE.contains(&h),
"{h} nationwide flag"
);
}
}
}