From 2e033fbd903d262e73d1c89a0a098ef2f49b6708 Mon Sep 17 00:00:00 2001 From: Hlars Date: Sun, 20 Jul 2025 19:00:19 +0200 Subject: [PATCH] initialisation --- .gitignore | 1 + Cargo.lock | 144 ++++++++++++++ Cargo.toml | 12 ++ src/countries/de.rs | 444 +++++++++++++++++++++++++++++++++++++++++++ src/countries/fr.rs | 259 +++++++++++++++++++++++++ src/countries/mod.rs | 50 +++++ src/countries/us.rs | 174 +++++++++++++++++ src/holiday.rs | 98 ++++++++++ src/lib.rs | 105 ++++++++++ src/utils.rs | 66 +++++++ 10 files changed, 1353 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/countries/de.rs create mode 100644 src/countries/fr.rs create mode 100644 src/countries/mod.rs create mode 100644 src/countries/us.rs create mode 100644 src/holiday.rs create mode 100644 src/lib.rs create mode 100644 src/utils.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ce3135f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,144 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "holidays" +version = "0.1.0" +dependencies = [ + "strum", + "time", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10a460a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "holidays" +version = "0.1.0" +edition = "2024" +publish = ["kellnr"] + +[dependencies] +time = "0.3.41" +strum = { version = "0.27.2", features = ["derive"] } + +[dev-dependencies] +time = { version = "0.3.41", features = ["macros"] } diff --git a/src/countries/de.rs b/src/countries/de.rs new file mode 100644 index 0000000..98b6923 --- /dev/null +++ b/src/countries/de.rs @@ -0,0 +1,444 @@ +use time::{Date, Duration, Month, Weekday}; + +use crate::{ + countries::{CountryHolidays, StateList}, + holiday::{Activity, HDate, Holiday}, + utils::{self}, +}; + +pub(super) struct GermanHolidays; + +#[derive(strum::Display, Hash, PartialEq, Eq)] +pub enum GermanState { + /// Baden-Württemberg + BW, + /// Bayern + BY, + /// Berlin + BE, + /// Brandenburg + BB, + /// Bremen + HB, + /// Hamburg + HH, + /// Hessen + HE, + /// Mecklenburg-Vorpommern + MV, + /// Niedersachsen + NI, + /// Nordrhein-Westfalen + NW, + /// Rheinland-Pfalz + RP, + /// Saarland + SL, + /// Sachsen + SN, + /// Sachsen-Anhalt + ST, + /// Schleswig-Holstein + SH, + /// Thüringen + TH, + /// Checks for holidays in any state + ANY, +} + +impl super::StateList for GermanState { + fn all_states_identifier() -> String { + Self::ANY.to_string() + } +} + +impl CountryHolidays for GermanHolidays { + fn new() -> (String, Vec) { + use GermanState::*; + use time::Month::*; + ( + GermanState::all_states_identifier(), + vec![ + // New Years Day + Holiday { + name: "Neujahrstag".to_string(), + date: HDate::Fixed(January, 1), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Epiphany + Holiday { + name: "Heilige drei Könige".to_string(), + date: HDate::Fixed(January, 6), + states: GermanState::list(&[ + (BW, &[Activity::after(1990)]), + (BY, &[Activity::after(1990)]), + (ST, &[Activity::after(1990)]), + ]), + }, + // International Women's Day + Holiday { + name: "Internationaler Frauentag".to_string(), + date: HDate::Fixed(March, 8), + states: GermanState::list(&[ + (BE, &[Activity::after(2019)]), + (MV, &[Activity::after(2023)]), + ]), + }, + // Good Friday + Holiday { + name: "Karfreitag".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) - Duration::days(3)), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Easter Monday + Holiday { + name: "Ostermontag".to_string(), + date: HDate::Calculated(utils::easter_monday), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Labour Day + Holiday { + name: "Tag der Arbeit".to_string(), + date: HDate::Fixed(May, 1), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Ascension Day + Holiday { + name: "Christi Himmelfahrt".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) + Duration::days(38)), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Whit Monday + Holiday { + name: "Pfingstmontag".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) + Duration::days(49)), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Corpus Christi + Holiday { + name: "Fronleichnam".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) + Duration::days(59)), + states: GermanState::list(&[ + (BW, &[Activity::after(1990)]), + (BY, &[Activity::after(1990)]), + (HE, &[Activity::after(1990)]), + (NW, &[Activity::after(1990)]), + (RP, &[Activity::after(1990)]), + (SL, &[Activity::after(1990)]), + ]), + }, + // Assumption Day + Holiday { + name: "Mariä Himmelfahrt".to_string(), + date: HDate::Fixed(August, 15), + states: GermanState::list(&[ + (BY, &[Activity::after(1990)]), + (SL, &[Activity::after(1990)]), + ]), + }, + // World Children's Day + Holiday { + name: "Weltkindertag".to_string(), + date: HDate::Fixed(September, 20), + states: GermanState::list(&[(TH, &[Activity::after(2019)])]), + }, + // German Unity Day + Holiday { + name: "Tag der Deutschen Einheit".to_string(), + date: HDate::Fixed(October, 3), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Reformation Day + Holiday { + name: "Reformationstag".to_string(), + date: HDate::Fixed(October, 31), + states: GermanState::list(&[ + (ANY, &[Activity::range(2017, 2017)]), + (BB, &[Activity::after(1990)]), + (MV, &[Activity::after(1990)]), + (SN, &[Activity::after(1990)]), + (ST, &[Activity::after(1990)]), + (TH, &[Activity::after(1990)]), + (HB, &[Activity::after(2018)]), + (HH, &[Activity::after(2018)]), + (NI, &[Activity::after(2018)]), + (SH, &[Activity::after(2018)]), + ]), + }, + // All Saints Day + Holiday { + name: "Allerheiligen".to_string(), + date: HDate::Fixed(November, 1), + states: GermanState::list(&[ + (BW, &[Activity::after(1990)]), + (BY, &[Activity::after(1990)]), + (NW, &[Activity::after(1990)]), + (RP, &[Activity::after(1990)]), + (SL, &[Activity::after(1990)]), + ]), + }, + // Repentance And Prayer Day + Holiday { + name: "Buß- und Bettag".to_string(), + date: HDate::Calculated(|year| { + // Der Buß- und Bettag ist immer ein Mittwoch, er liegt zwischen dem 16. und 22. November + let november_22 = Date::from_calendar_date(year, Month::November, 22) + .expect("22 Nov should exist every year"); + + if november_22.weekday() == Weekday::Wednesday { + november_22 + } else { + november_22.prev_occurrence(Weekday::Wednesday) + } + }), + states: GermanState::list(&[ + (ANY, &[Activity::range(1990, 1994)]), + (SN, &[Activity::after(1990)]), + ]), + }, + // Christmas Day + Holiday { + name: "Erster Weihnachtstag".to_string(), + date: HDate::Fixed(December, 25), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + // Second Day Of Christmas + Holiday { + name: "Zweiter Weihnachtstag".to_string(), + date: HDate::Fixed(December, 26), + states: GermanState::list(&[(ANY, &[Activity::after(1990)])]), + }, + ], + ) + } +} + +#[cfg(test)] +mod tests { + use time::macros::date; + + use crate::{ + HolidayChecker, + countries::{CountryCode, de::GermanState}, + }; + + #[test] + fn test_number_of_holidays() { + let checker = HolidayChecker::new(vec![CountryCode::DE]); + + let tests = [ + (GermanState::BW, 12), + (GermanState::BY, 13), + (GermanState::BE, 10), + (GermanState::BB, 10), + (GermanState::HB, 10), + (GermanState::HH, 10), + (GermanState::HE, 10), + (GermanState::MV, 11), + (GermanState::NI, 10), + (GermanState::NW, 11), + (GermanState::RP, 11), + (GermanState::SL, 12), + (GermanState::SN, 11), + (GermanState::ST, 11), + (GermanState::SH, 10), + (GermanState::TH, 11), + ]; + for (state, num) in tests { + assert_eq!( + checker.number_of_holidays(CountryCode::DE, &state.to_string(), 2025), + num + ); + } + } + + #[test] + fn test_holidays() { + let checker = HolidayChecker::new(vec![CountryCode::DE]); + let country = CountryCode::DE; + let state = GermanState::ANY.to_string(); + + // New Years Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 01 - 01)) + .is_some() + ); + // Epiphany + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 01 - 06)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::BW.to_string(), + 2025, + date!(2025 - 01 - 06) + ) + .is_some() + ); + // International Women's Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 03 - 08)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::BE.to_string(), + 2025, + date!(2025 - 03 - 08) + ) + .is_some() + ); + // Good Friday + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 04 - 18)) + .is_some() + ); + // Easter Monday + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 04 - 21)) + .is_some() + ); + // Labour Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 05 - 01)) + .is_some() + ); + // Ascension Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 05 - 29)) + .is_some() + ); + // Whit Monday + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 06 - 09)) + .is_some() + ); + // Corpus Christi + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 06 - 19)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::BW.to_string(), + 2025, + date!(2025 - 06 - 19) + ) + .is_some() + ); + // Assumption Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 08 - 15)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::BY.to_string(), + 2025, + date!(2025 - 08 - 15) + ) + .is_some() + ); + // World Children's Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 09 - 20)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::TH.to_string(), + 2025, + date!(2025 - 09 - 20) + ) + .is_some() + ); + // German Unity Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 10 - 03)) + .is_some() + ); + // Reformation Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 10 - 31)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::HH.to_string(), + 2025, + date!(2025 - 10 - 31) + ) + .is_some() + ); + // All Saints Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 11 - 01)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::BW.to_string(), + 2025, + date!(2025 - 11 - 01) + ) + .is_some() + ); + // Repentance And Prayer Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 11 - 19)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &GermanState::SN.to_string(), + 2025, + date!(2025 - 11 - 19) + ) + .is_some() + ); + // Christmas Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 12 - 25)) + .is_some() + ); + // Second Day Of Christmas + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 12 - 26)) + .is_some() + ); + } +} diff --git a/src/countries/fr.rs b/src/countries/fr.rs new file mode 100644 index 0000000..da68d0c --- /dev/null +++ b/src/countries/fr.rs @@ -0,0 +1,259 @@ +use time::Duration; + +use crate::{ + countries::{CountryHolidays, StateList}, + holiday::{Activity, HDate, Holiday}, + utils, +}; + +pub(super) struct FrenchHolidays; + +#[derive(strum::Display, Hash, PartialEq, Eq)] +pub enum FrenchState { + /// Bas-Rhin + BasRhin, + /// Haut-Rhin + HautRhin, + /// Moselle + Moselle, + /// Checks for holidays in any state + ANY, +} + +impl super::StateList for FrenchState { + fn all_states_identifier() -> String { + Self::ANY.to_string() + } +} + +impl CountryHolidays for FrenchHolidays { + fn new() -> (String, Vec) { + use FrenchState::*; + use time::Month::*; + ( + FrenchState::all_states_identifier(), + vec![ + // New Years Day + Holiday { + name: "Jour de l'an".to_string(), + date: HDate::Fixed(January, 1), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Good Friday + Holiday { + name: "Vendredi saint".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) - Duration::days(3)), + states: FrenchState::list(&[ + (BasRhin, &[Activity::unlimited()]), + (HautRhin, &[Activity::unlimited()]), + (Moselle, &[Activity::unlimited()]), + ]), + }, + // Easter Monday + Holiday { + name: "Lundi de Pâques".to_string(), + date: HDate::Calculated(utils::easter_monday), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Labour Day + Holiday { + name: "Fête du travail".to_string(), + date: HDate::Fixed(May, 1), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Victory Day (Second World War) + Holiday { + name: "Victoire 1945".to_string(), + date: HDate::Fixed(May, 8), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Ascension Day + Holiday { + name: "Ascension".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) + Duration::days(38)), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Whit Monday + Holiday { + name: "Lundi de Pentecôte".to_string(), + date: HDate::Calculated(|year| utils::easter_monday(year) + Duration::days(49)), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // National Day (14th July) + Holiday { + name: "Fête nationale".to_string(), + date: HDate::Fixed(July, 14), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Assumption Day + Holiday { + name: "Assomption".to_string(), + date: HDate::Fixed(August, 15), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // All Saints Day + Holiday { + name: "Toussaint".to_string(), + date: HDate::Fixed(November, 1), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Victory Day (First World War) + Holiday { + name: "Armistice 1918".to_string(), + date: HDate::Fixed(November, 11), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Christmas Day + Holiday { + name: "Jour de Noël".to_string(), + date: HDate::Fixed(December, 25), + states: FrenchState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Second Day Of Christmas + Holiday { + name: "Saint Étienne".to_string(), + date: HDate::Fixed(December, 26), + states: FrenchState::list(&[ + (BasRhin, &[Activity::unlimited()]), + (HautRhin, &[Activity::unlimited()]), + (Moselle, &[Activity::unlimited()]), + ]), + }, + ], + ) + } +} + +#[cfg(test)] +mod tests { + use time::macros::date; + + use crate::{ + HolidayChecker, + countries::{CountryCode, fr::FrenchState}, + }; + + #[test] + fn test_number_of_holidays() { + let checker = HolidayChecker::new(vec![CountryCode::FR]); + + let tests = [ + (FrenchState::BasRhin, 14), + (FrenchState::HautRhin, 14), + (FrenchState::Moselle, 14), + (FrenchState::ANY, 12), + ]; + for (state, num) in tests { + assert_eq!( + checker.number_of_holidays(CountryCode::FR, &state.to_string(), 2025), + num + ); + } + } + + #[test] + fn test_holidays() { + let checker = HolidayChecker::new(vec![CountryCode::FR]); + let country = CountryCode::FR; + let state = FrenchState::ANY.to_string(); + + // New Years Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 01 - 01)) + .is_some() + ); + // Good Friday + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 04 - 18)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &FrenchState::BasRhin.to_string(), + 2025, + date!(2025 - 04 - 18) + ) + .is_some() + ); + // Easter Monday + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 04 - 21)) + .is_some() + ); + // Labour Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 05 - 01)) + .is_some() + ); + // Victory Day (Second World War) + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 05 - 08)) + .is_some() + ); + // Ascension Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 05 - 29)) + .is_some() + ); + // Whit Monday + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 06 - 09)) + .is_some() + ); + // National Day (14th July) + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 07 - 14)) + .is_some() + ); + // Assumption Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 08 - 15)) + .is_some() + ); + // All Saints Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 11 - 01)) + .is_some() + ); + // Victory Day (First World War) + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 11 - 11)) + .is_some() + ); + // Christmas Day + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 12 - 25)) + .is_some() + ); + // Second Day Of Christmas + assert!( + checker + .is_holiday(country, &state, 2025, date!(2025 - 12 - 26)) + .is_none() + ); + assert!( + checker + .is_holiday( + country, + &FrenchState::BasRhin.to_string(), + 2025, + date!(2025 - 12 - 26) + ) + .is_some() + ); + } +} diff --git a/src/countries/mod.rs b/src/countries/mod.rs new file mode 100644 index 0000000..2221e51 --- /dev/null +++ b/src/countries/mod.rs @@ -0,0 +1,50 @@ +mod de; +mod fr; +mod us; + +use std::{collections::HashMap, fmt::Display, hash::Hash}; + +use crate::{ + countries::{de::GermanHolidays, fr::FrenchHolidays, us::USHolidays}, + holiday::{Activity, Holiday}, +}; + +trait CountryHolidays +where + T: StateList, +{ + /// Returns a Tuple consisting of the identifier for all states and the holiday days + fn new() -> (String, Vec); +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub enum CountryCode { + DE, + US, + FR, +} + +impl CountryCode { + pub(crate) fn get_holidays(&self) -> (String, Vec) { + match self { + CountryCode::DE => GermanHolidays::new(), + CountryCode::US => USHolidays::new(), + CountryCode::FR => FrenchHolidays::new(), + } + } +} + +pub(crate) trait StateList +where + Self: Sized + Display + Hash + Eq, +{ + fn list(states: &[(Self, &[Activity])]) -> HashMap> { + let mut map = HashMap::new(); + for state in states { + map.insert(state.0.to_string(), state.1.to_vec()); + } + map + } + + fn all_states_identifier() -> String; +} diff --git a/src/countries/us.rs b/src/countries/us.rs new file mode 100644 index 0000000..b62068b --- /dev/null +++ b/src/countries/us.rs @@ -0,0 +1,174 @@ +use time::{Date, Month, Weekday}; + +use crate::{ + countries::{CountryHolidays, StateList}, + holiday::{Activity, HDate, Holiday}, + utils, +}; + +pub(super) struct USHolidays; + +#[derive(strum::Display, Hash, PartialEq, Eq)] +pub enum USState { + // Northeast + Maine, + NewHampshire, + Vermont, + Massachusetts, + RhodeIsland, + Connecticut, + NewYork, + NewJersey, + Pennsylvania, + + // Southeast + Delaware, + Maryland, + Virginia, + WestVirginia, + NorthCarolina, + SouthCarolina, + Georgia, + Florida, + Kentucky, + Tennessee, + + // Midwest + Ohio, + Michigan, + Indiana, + Illinois, + Wisconsin, + Minnesota, + Iowa, + Missouri, + NorthDakota, + SouthDakota, + Nebraska, + Kansas, + + // Southwest + Texas, + Oklahoma, + NewMexico, + Arizona, + + // West + Colorado, + Wyoming, + Montana, + Idaho, + Utah, + Nevada, + California, + Oregon, + Washington, + Alaska, + Hawaii, + + // Additional states + Alabama, + Arkansas, + Mississippi, + Louisiana, + + // All states + ANY, +} + +impl super::StateList for USState { + fn all_states_identifier() -> String { + Self::ANY.to_string() + } +} + +impl CountryHolidays for USHolidays { + fn new() -> (String, Vec) { + use USState::*; + use time::Month::*; + ( + USState::all_states_identifier(), + vec![ + // NATIONAL HOLIDAYS + + // New Years Day + Holiday { + name: "New Year's Day".to_string(), + date: HDate::Fixed(January, 1), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Birthday of Martin Luther King, Jr. (Third Monday in January) + Holiday { + name: "Martin Luther King, Jr. Day".to_string(), + date: HDate::Calculated(|year| { + utils::nth_weekday_in_month(year, Month::January, Weekday::Monday, 3) + }), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Inauguration Day (January 20, every 4 years following a presidential election) + // Washington's Birthday (Also known as Presidents Day; third Monday in February) + Holiday { + name: "Washington's Birthday".to_string(), + date: HDate::Calculated(|year| { + utils::nth_weekday_in_month(year, Month::February, Weekday::Monday, 3) + }), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Memorial Day (Last Monday in May) + Holiday { + name: "Memorial Day".to_string(), + date: HDate::Calculated(|year| utils::last_monday_in_month(year, Month::May)), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Juneteenth National Independence Day (June 19) + Holiday { + name: "Juneteenth".to_string(), + date: HDate::Fixed(Month::June, 19), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Independence Day (July 4) + Holiday { + name: "Independence Day".to_string(), + date: HDate::Fixed(Month::July, 4), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Labor Day (First Monday in September) + Holiday { + name: "Labor Day".to_string(), + date: HDate::Calculated(|year| { + utils::nth_weekday_in_month(year, Month::September, Weekday::Monday, 1) + }), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Columbus Day (Second Monday in October) + Holiday { + name: "Columbus Day".to_string(), + date: HDate::Calculated(|year| { + utils::nth_weekday_in_month(year, Month::October, Weekday::Monday, 2) + }), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Veterans Day (November 11) + Holiday { + name: "Veterans Day".to_string(), + date: HDate::Fixed(Month::November, 11), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Thanksgiving Day (Fourth Thursday in November) + Holiday { + name: "Thanksgiving Day".to_string(), + date: HDate::Calculated(|year| { + utils::nth_weekday_in_month(year, Month::November, Weekday::Thursday, 4) + }), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + // Christmas Day (December 25) + Holiday { + name: "Christmas Day".to_string(), + date: HDate::Fixed(Month::December, 25), + states: USState::list(&[(ANY, &[Activity::unlimited()])]), + }, + ], + ) + } +} diff --git a/src/holiday.rs b/src/holiday.rs new file mode 100644 index 0000000..b39e268 --- /dev/null +++ b/src/holiday.rs @@ -0,0 +1,98 @@ +use std::collections::HashMap; + +use time::{Date, Month}; + +#[derive(Debug, Clone)] +pub(crate) struct Activity { + unlimited: bool, + from: i32, + to: Option, +} +impl Activity { + pub(crate) fn unlimited() -> Self { + Self { + unlimited: true, + from: 0, + to: None, + } + } + pub(crate) fn after(year: i32) -> Self { + Self { + unlimited: false, + from: year, + to: None, + } + } + + pub(crate) fn range(from: i32, to: i32) -> Self { + Self { + unlimited: false, + from, + to: Some(to), + } + } + + fn is_active(&self, year: i32) -> bool { + self.unlimited + || (year >= self.from + && match self.to { + Some(limit) => limit >= year, + None => true, + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum HDate { + Fixed(Month, u8), + // Optional for holidays with complex calculation (like Easter) + Calculated(fn(i32) -> Date), +} + +#[derive(Debug, Clone)] +pub(crate) struct Holiday { + pub(crate) name: String, + pub(crate) date: HDate, + pub(crate) states: HashMap>, +} + +impl Holiday { + pub(crate) fn is_active(&self, year: i32, all_states_identifier: &str, state: &str) -> bool { + if let Some(all_states) = self.states.get(all_states_identifier) { + // if holiday is valid for all states and is active in that year + if all_states.iter().any(|activity| activity.is_active(year)) { + return true; + } + } + if let Some(all_states) = self.states.get(state) { + // if holiday is only valid for this states and is active in that year + if all_states.iter().any(|activity| activity.is_active(year)) { + return true; + } + } + // holiday is inactive + false + } +} + +#[derive(Debug, Clone)] +pub struct HolidayDate { + pub name: String, + pub date: Date, +} + +#[cfg(test)] +mod test { + use crate::holiday::Activity; + + #[test] + fn test_activity() { + assert!(Activity::after(2000).is_active(2000)); + assert!(Activity::after(2000).is_active(2001)); + assert!(!Activity::after(2000).is_active(1999)); + + assert!(Activity::range(2000, 2000).is_active(2000)); + assert!(!Activity::range(2000, 2000).is_active(1999)); + assert!(!Activity::range(2000, 2000).is_active(2001)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b080279 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,105 @@ +use std::collections::HashMap; + +use time::Date; + +use crate::{ + countries::CountryCode, + holiday::{HDate, Holiday, HolidayDate}, +}; + +mod countries; +mod holiday; +pub(crate) mod utils; + +pub struct HolidayChecker { + countries: HashMap)>, +} + +impl HolidayChecker { + pub fn new(countries: Vec) -> Self { + let mut map = HashMap::new(); + + for country in countries { + let holidays = country.get_holidays(); + map.insert(country, holidays); + } + + Self { countries: map } + } + + pub fn holiday_list(&self, country: CountryCode, state: &str, year: i32) -> Vec { + Self::get_years_holidays( + self.countries + .get(&country) + .unwrap_or(&(String::new(), vec![])), + year, + state, + ) + } + + pub fn number_of_holidays(&self, country: CountryCode, state: &str, year: i32) -> usize { + Self::get_years_holidays( + self.countries + .get(&country) + .unwrap_or(&(String::new(), vec![])), + year, + state, + ) + .len() + } + + pub fn is_holiday( + &self, + country: CountryCode, + state: &str, + year: i32, + date: Date, + ) -> Option { + Self::get_years_holidays( + self.countries + .get(&country) + .unwrap_or(&(String::new(), vec![])), + year, + state, + ) + .iter() + .find(|holiday| holiday.date == date) + .map(|holiday| HolidayDate { + name: holiday.name.clone(), + date, + }) + } + + fn get_years_holidays( + holidays: &(String, Vec), + year: i32, + state: &str, + ) -> Vec { + let (all_states_identifier, holidays) = holidays; + holidays + .iter() + .filter_map(|holiday| { + // check if holiday is active + if !holiday.is_active(year, all_states_identifier, state) { + return None; + } + match holiday.date { + HDate::Calculated(calc_fn) => { + // For holidays with calculation function + Some(HolidayDate { + date: calc_fn(year), + name: holiday.name.clone(), + }) + } + HDate::Fixed(month, day) => { + // For fixed date holidays + Some(HolidayDate { + date: Date::from_calendar_date(year, month, day).unwrap(), + name: holiday.name.clone(), + }) + } + } + }) + .collect() + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..2250afb --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,66 @@ +use time::{Date, Duration, Month, Weekday}; + +// Calculate Easter Monday (Meeus/Jones/Butcher Algorithm) +pub(crate) fn easter_monday(year: i32) -> Date { + let a = year % 19; + let b = year / 100; + let c = year % 100; + let d = b / 4; + let e = b % 4; + let f = (b + 8) / 25; + let g = (b - f + 1) / 3; + let h = (19 * a + b - d - g + 15) % 30; + let i = c / 4; + let k = c % 4; + let l = (32 + 2 * e + 2 * i - h - k) % 7; + let m = (a + 11 * h + 22 * l) / 451; + + let month = (h + l - 7 * m + 114) / 31; + let day = ((h + l - 7 * m + 114) % 31) + 1; + + // Easter Sunday + let easter_sunday = + Date::from_calendar_date(year, Month::try_from(month as u8).unwrap(), day as u8).unwrap(); + + // Easter Monday is the day after Easter Sunday + easter_sunday + Duration::days(1) +} + +// Find the nth 'Weekday' in a given month +pub(crate) fn nth_weekday_in_month( + year: i32, + month: Month, + weekday: Weekday, + occurrence: u8, +) -> Date { + let first = Date::from_calendar_date(year, month, 1).unwrap(); + if first.weekday() == weekday { + if occurrence == 1 { + first + } else { + first.nth_next_occurrence(weekday, occurrence - 1) + } + } else { + first.nth_next_occurrence(weekday, occurrence) + } +} + +// Find the last Monday in a given month +pub(crate) fn last_monday_in_month(year: i32, month: Month) -> Date { + // Get the first day of the next month + let first_day_next_month = Date::from_calendar_date( + if month == Month::December { + year + 1 + } else { + year + }, + month.next(), + 1, + ) + .unwrap(); + + // Subtract one day to get the last day of the current month + let date = first_day_next_month.previous_day().unwrap(); + + date.prev_occurrence(Weekday::Monday) +}