use chrono::{Datelike, Duration, NaiveDate, Weekday};
pub fn compute_easter(year: i32) -> NaiveDate {
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) as u32;
let day = (((h + l - 7 * m + 114) % 31) + 1) as u32;
NaiveDate::from_ymd_opt(year, month, day).expect("easter date is always valid")
}
pub fn buss_und_bettag(year: i32) -> NaiveDate {
let nov_23 = NaiveDate::from_ymd_opt(year, 11, 23).expect("23.11. is always valid");
let days_back = match nov_23.weekday() {
Weekday::Wed => 7,
Weekday::Thu => 1,
Weekday::Fri => 2,
Weekday::Sat => 3,
Weekday::Sun => 4,
Weekday::Mon => 5,
Weekday::Tue => 6,
};
nov_23 - Duration::days(days_back)
}
pub fn is_german_holiday(date: NaiveDate) -> bool {
let (year, month, day) = (date.year(), date.month(), date.day());
if matches!(
(month, day),
(1, 1) | (1, 6) | (3, 8) | (5, 1) | (8, 15) | (9, 20) | (10, 3) | (10, 31) | (11, 1) | (12, 24) | (12, 25) | (12, 26) | (12, 31) ) {
return true;
}
let easter = compute_easter(year);
if date == easter - Duration::days(2)
|| date == easter + Duration::days(1)
|| date == easter + Duration::days(39)
|| date == easter + Duration::days(50)
|| date == easter + Duration::days(60)
{
return true;
}
if month == 11 && date == buss_und_bettag(year) {
return true;
}
false
}
pub fn is_werktag(date: NaiveDate) -> bool {
!matches!(date.weekday(), Weekday::Sat | Weekday::Sun) && !is_german_holiday(date)
}
pub fn werktage_between(from: NaiveDate, to: NaiveDate) -> i32 {
if from == to {
return 0;
}
let (start, end, sign) = if from < to {
(from, to, 1)
} else {
(to, from, -1)
};
let mut count = 0;
let mut cur = start;
while cur < end {
cur = cur
.succ_opt()
.expect("succ_opt within validated date range");
if is_werktag(cur) {
count += 1;
}
}
count * sign
}
pub fn parse_ccyymmdd_prefix(value: &str) -> Option<NaiveDate> {
let s = value.trim();
if s.len() < 8 {
return None;
}
let year: i32 = s.get(0..4).and_then(|v| v.parse().ok())?;
let month: u32 = s.get(4..6).and_then(|v| v.parse().ok())?;
let day: u32 = s.get(6..8).and_then(|v| v.parse().ok())?;
NaiveDate::from_ymd_opt(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_easter_2024() {
assert_eq!(
compute_easter(2024),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
);
}
#[test]
fn test_easter_2025() {
assert_eq!(
compute_easter(2025),
NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()
);
}
#[test]
fn test_easter_2026() {
assert_eq!(
compute_easter(2026),
NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()
);
}
#[test]
fn test_fixed_federal_holidays_2026() {
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap()));
assert!(is_german_holiday(
NaiveDate::from_ymd_opt(2026, 10, 3).unwrap()
));
assert!(is_german_holiday(
NaiveDate::from_ymd_opt(2026, 12, 25).unwrap()
));
assert!(is_german_holiday(
NaiveDate::from_ymd_opt(2026, 12, 26).unwrap()
));
}
#[test]
fn test_state_level_holidays_included_bundesweit_2026() {
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 1, 6).unwrap())); assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 3, 8).unwrap())); assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 8, 15).unwrap())); assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 9, 20).unwrap())); assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 10, 31).unwrap())); assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 1).unwrap())); }
#[test]
fn test_bdew_specific_holidays_2026() {
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 12, 24).unwrap()));
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()));
}
#[test]
fn test_fronleichnam_2026() {
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 6, 4).unwrap()));
}
#[test]
fn test_buss_und_bettag() {
assert_eq!(buss_und_bettag(2024), NaiveDate::from_ymd_opt(2024, 11, 20).unwrap());
assert_eq!(buss_und_bettag(2025), NaiveDate::from_ymd_opt(2025, 11, 19).unwrap());
assert_eq!(buss_und_bettag(2026), NaiveDate::from_ymd_opt(2026, 11, 18).unwrap());
assert_eq!(buss_und_bettag(2022), NaiveDate::from_ymd_opt(2022, 11, 16).unwrap());
}
#[test]
fn test_buss_und_bettag_is_holiday() {
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 18).unwrap()));
assert!(!is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 11).unwrap()));
assert!(!is_german_holiday(NaiveDate::from_ymd_opt(2026, 11, 25).unwrap()));
}
#[test]
fn test_easter_holidays_2026() {
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 4, 3).unwrap()));
assert!(is_german_holiday(NaiveDate::from_ymd_opt(2026, 4, 6).unwrap()));
assert!(is_german_holiday(
NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()
));
assert!(is_german_holiday(
NaiveDate::from_ymd_opt(2026, 5, 25).unwrap()
));
}
#[test]
fn test_non_holidays() {
assert!(!is_german_holiday(
NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()
));
assert!(!is_german_holiday(
NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()
));
}
#[test]
fn test_werktag_skips_weekend() {
assert!(!is_werktag(
NaiveDate::from_ymd_opt(2026, 4, 4).unwrap()
));
assert!(!is_werktag(
NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()
));
}
#[test]
fn test_werktag_skips_holiday() {
assert!(!is_werktag(
NaiveDate::from_ymd_opt(2026, 4, 3).unwrap()
));
}
#[test]
fn test_werktage_between_simple() {
let from = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
let to = NaiveDate::from_ymd_opt(2026, 1, 16).unwrap();
assert_eq!(werktage_between(from, to), 4);
}
#[test]
fn test_werktage_between_skips_hl_drei_koenige() {
let from = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
let to = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
assert_eq!(werktage_between(from, to), 3);
}
#[test]
fn test_werktage_between_across_weekend() {
let from = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
let to = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
assert_eq!(werktage_between(from, to), 1);
}
#[test]
fn test_werktage_between_skips_easter_holidays() {
let from = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
let to = NaiveDate::from_ymd_opt(2026, 4, 7).unwrap();
assert_eq!(werktage_between(from, to), 2);
}
#[test]
fn test_werktage_between_10_wt_after_monday() {
let from = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
let to = NaiveDate::from_ymd_opt(2026, 1, 26).unwrap();
assert_eq!(werktage_between(from, to), 10);
}
#[test]
fn test_werktage_between_negative() {
let from = NaiveDate::from_ymd_opt(2026, 1, 26).unwrap();
let to = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
assert_eq!(werktage_between(from, to), -10);
}
#[test]
fn test_werktage_between_same_day() {
let d = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
assert_eq!(werktage_between(d, d), 0);
}
#[test]
fn test_parse_ccyymmdd_prefix_from_format_102() {
assert_eq!(
parse_ccyymmdd_prefix("20260115"),
Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
);
}
#[test]
fn test_parse_ccyymmdd_prefix_from_format_303() {
assert_eq!(
parse_ccyymmdd_prefix("202601151200+00"),
Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
);
}
#[test]
fn test_parse_ccyymmdd_prefix_rejects_short() {
assert!(parse_ccyymmdd_prefix("2026").is_none());
assert!(parse_ccyymmdd_prefix("").is_none());
}
#[test]
fn test_parse_ccyymmdd_prefix_rejects_invalid() {
assert!(parse_ccyymmdd_prefix("20261315").is_none());
}
}