use time::{Date, Duration, OffsetDateTime, PrimitiveDateTime, Time, Weekday};
use time_tz::{OffsetResult, PrimitiveDateTimeExt, timezones};
pub const CONTRL_FRIST_HOURS: i64 = 6;
pub const CONTRL_FRIST_LABEL: &str = "contrl-delivery";
#[must_use]
pub fn contrl_due_at(received: OffsetDateTime) -> OffsetDateTime {
received + Duration::hours(CONTRL_FRIST_HOURS)
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HolidayCalendar {
BdewMaKo,
}
#[must_use]
pub fn add_hours(from: OffsetDateTime, hours: u32) -> OffsetDateTime {
from + Duration::hours(i64::from(hours))
}
#[must_use]
pub fn add_werktage(from: Date, n: u32, cal: HolidayCalendar) -> Date {
let mut current = from;
let mut remaining = n;
while remaining > 0 {
current = current.next_day().expect("date overflow");
if is_werktag(current, cal) {
remaining -= 1;
}
}
current
}
#[must_use]
pub fn next_werktag(from: Date, cal: HolidayCalendar) -> Date {
let mut current = from;
while !is_werktag(current, cal) {
current = current.next_day().expect("date overflow");
}
current
}
#[must_use]
pub fn deadline_at_werktage(
from: OffsetDateTime,
werktage: u32,
cal: HolidayCalendar,
) -> OffsetDateTime {
let due_date = add_werktage(from.date(), werktage, cal);
let local_17 = PrimitiveDateTime::new(
due_date,
Time::from_hms(17, 0, 0).expect("17:00:00 is valid"),
);
let berlin = timezones::db::europe::BERLIN;
match local_17.assume_timezone(berlin) {
OffsetResult::Some(dt) => dt,
OffsetResult::Ambiguous(earlier, _later) => earlier,
OffsetResult::None => {
local_17.assume_offset(time::UtcOffset::from_hms(1, 0, 0).unwrap())
}
}
}
#[must_use]
fn is_bdew_mako_holiday(date: Date) -> bool {
let (y, m, d) = (date.year(), date.month() as u8, date.day());
if matches!(
(m, d),
(1 | 5 | 11, 1) | (1, 6) | (8, 15) | (10, 3 | 31) | (12, 25 | 26) ) {
return true;
}
let e_date = easter_sunday(y);
let offsets: &[i64] = &[
-2, 1, 39, 49, 50, 60, ];
for &offset in offsets {
let holiday = e_date + Duration::days(offset);
if holiday == date {
return true;
}
}
false
}
#[allow(clippy::many_single_char_names)]
fn easter_sunday(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;
let month_u8 = u8::try_from(month).expect("algorithm yields valid month index");
let day_u8 = u8::try_from(day).expect("algorithm yields valid day");
Date::from_calendar_date(
year,
time::Month::try_from(month_u8).expect("algorithm yields valid month"),
day_u8,
)
.expect("algorithm yields valid date")
}
fn is_werktag(date: Date, cal: HolidayCalendar) -> bool {
if date.weekday() == Weekday::Sunday {
return false;
}
match cal {
HolidayCalendar::BdewMaKo => !is_bdew_mako_holiday(date),
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::{Date, Month, OffsetDateTime, Time};
fn date(y: i32, m: u8, d: u8) -> Date {
Date::from_calendar_date(y, Month::try_from(m).unwrap(), d).unwrap()
}
#[test]
fn add_hours_advances_exactly() {
let t = OffsetDateTime::now_utc();
assert_eq!(add_hours(t, 24) - t, Duration::hours(24));
}
#[test]
fn add_hours_crosses_midnight() {
let t = OffsetDateTime::now_utc();
let due = add_hours(t, 24);
assert_eq!(due.date(), t.date() + Duration::days(1));
}
#[test]
fn contrl_due_at_is_exactly_6h_after_received() {
let received = OffsetDateTime::now_utc();
let due = contrl_due_at(received);
assert_eq!(
due - received,
Duration::hours(6),
"CONTRL AHB 1.0 §1.2 requires exactly 6h frist"
);
}
#[test]
fn contrl_frist_label_is_stable() {
assert_eq!(CONTRL_FRIST_LABEL, "contrl-delivery");
}
#[test]
fn contrl_frist_hours_matches_constant() {
let received = OffsetDateTime::now_utc();
assert_eq!(
contrl_due_at(received) - received,
Duration::hours(CONTRL_FRIST_HOURS)
);
}
#[test]
fn fixed_holidays_are_detected() {
assert!(is_bdew_mako_holiday(date(2025, 1, 1)), "Neujahr");
assert!(
is_bdew_mako_holiday(date(2025, 1, 6)),
"Heilige Drei Könige"
);
assert!(is_bdew_mako_holiday(date(2025, 5, 1)), "Tag der Arbeit");
assert!(is_bdew_mako_holiday(date(2025, 8, 15)), "Mariä Himmelfahrt");
assert!(
is_bdew_mako_holiday(date(2025, 10, 3)),
"Tag der Deutschen Einheit"
);
assert!(is_bdew_mako_holiday(date(2025, 10, 31)), "Reformationstag");
assert!(is_bdew_mako_holiday(date(2025, 11, 1)), "Allerheiligen");
assert!(is_bdew_mako_holiday(date(2025, 12, 25)), "1. Weihnachtstag");
assert!(is_bdew_mako_holiday(date(2025, 12, 26)), "2. Weihnachtstag");
}
#[test]
fn easter_2025_moveable_holidays() {
assert!(is_bdew_mako_holiday(date(2025, 4, 18)), "Karfreitag");
assert!(is_bdew_mako_holiday(date(2025, 4, 21)), "Ostermontag");
assert!(
is_bdew_mako_holiday(date(2025, 5, 29)),
"Christi Himmelfahrt"
);
assert!(is_bdew_mako_holiday(date(2025, 6, 8)), "Pfingstsonntag");
assert!(is_bdew_mako_holiday(date(2025, 6, 9)), "Pfingstmontag");
assert!(is_bdew_mako_holiday(date(2025, 6, 19)), "Fronleichnam");
}
#[test]
fn easter_beyond_2035_table_ceiling() {
assert_eq!(easter_sunday(2036), date(2036, 4, 13));
assert!(is_bdew_mako_holiday(date(2036, 4, 11)), "Karfreitag 2036"); assert!(is_bdew_mako_holiday(date(2036, 4, 14)), "Ostermontag 2036"); assert!(
is_bdew_mako_holiday(date(2036, 5, 22)),
"Christi Himmelfahrt 2036"
); assert!(
is_bdew_mako_holiday(date(2036, 6, 1)),
"Pfingstsonntag 2036"
); assert!(is_bdew_mako_holiday(date(2036, 6, 2)), "Pfingstmontag 2036"); assert!(is_bdew_mako_holiday(date(2036, 6, 12)), "Fronleichnam 2036");
assert_eq!(easter_sunday(2050), date(2050, 4, 10));
}
#[test]
fn saturday_is_not_a_holiday() {
assert!(!is_bdew_mako_holiday(date(2025, 1, 4)));
}
#[test]
fn sunday_is_not_werktag() {
assert!(!is_werktag(date(2025, 1, 5), HolidayCalendar::BdewMaKo));
}
#[test]
fn saturday_is_werktag() {
assert!(is_werktag(date(2025, 1, 4), HolidayCalendar::BdewMaKo));
}
#[test]
fn holiday_is_not_werktag() {
assert!(!is_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo));
}
#[test]
fn landesfeiertage_are_not_werktage() {
assert!(!is_werktag(date(2025, 1, 6), HolidayCalendar::BdewMaKo));
assert!(!is_werktag(date(2025, 8, 15), HolidayCalendar::BdewMaKo));
assert!(!is_werktag(date(2025, 10, 31), HolidayCalendar::BdewMaKo));
assert!(!is_werktag(date(2025, 11, 1), HolidayCalendar::BdewMaKo));
}
#[test]
fn five_werktage_plain_week() {
let start = date(2025, 1, 6);
let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
assert_eq!(due, date(2025, 1, 11));
}
#[test]
fn skips_reformationstag_and_allerheiligen() {
let start = date(2025, 10, 29);
let due = add_werktage(start, 5, HolidayCalendar::BdewMaKo);
assert_eq!(due, date(2025, 11, 6));
}
#[test]
fn skips_heilige_drei_koenige() {
let start = date(2025, 1, 4);
let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
assert_eq!(due, date(2025, 1, 7));
}
#[test]
fn skips_sunday_correctly() {
let start = date(2025, 1, 11);
let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
assert_eq!(due, date(2025, 1, 13));
}
#[test]
fn skips_holiday_and_sunday() {
let start = date(2025, 4, 17);
let due = add_werktage(start, 1, HolidayCalendar::BdewMaKo);
assert_eq!(due, date(2025, 4, 19));
}
#[test]
fn zero_werktage_returns_start() {
let start = date(2025, 1, 6);
assert_eq!(add_werktage(start, 0, HolidayCalendar::BdewMaKo), start);
}
#[test]
fn next_werktag_from_sunday_advances_to_monday() {
let sunday = date(2025, 1, 12);
assert_eq!(
next_werktag(sunday, HolidayCalendar::BdewMaKo),
date(2025, 1, 13), );
}
#[test]
fn next_werktag_from_werktag_returns_same() {
let monday = date(2025, 1, 13);
assert_eq!(next_werktag(monday, HolidayCalendar::BdewMaKo), monday);
}
#[test]
fn next_werktag_from_holiday_advances_to_next_werktag() {
assert_eq!(
next_werktag(date(2025, 1, 1), HolidayCalendar::BdewMaKo),
date(2025, 1, 2),
);
}
#[test]
fn deadline_at_werktage_winter_cet() {
let received = OffsetDateTime::new_utc(date(2025, 1, 6), Time::MIDNIGHT);
let due = deadline_at_werktage(received, 5, HolidayCalendar::BdewMaKo);
assert_eq!(due.date(), date(2025, 1, 11));
assert_eq!(
due.to_offset(time::UtcOffset::UTC).hour(),
16,
"winter: 17:00 CET = 16:00 UTC"
);
assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
}
#[test]
fn deadline_at_werktage_summer_cest() {
let received = OffsetDateTime::new_utc(date(2025, 7, 1), Time::MIDNIGHT);
let due = deadline_at_werktage(received, 1, HolidayCalendar::BdewMaKo);
assert_eq!(
due.to_offset(time::UtcOffset::UTC).hour(),
15,
"summer: 17:00 CEST = 15:00 UTC"
);
assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
}
#[test]
fn deadline_on_day_after_spring_forward_is_cest() {
let received = OffsetDateTime::new_utc(date(2025, 3, 26), Time::MIDNIGHT);
let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
assert_eq!(
due.date(),
date(2025, 3, 31),
"should land on Monday 2025-03-31"
);
assert_eq!(
due.to_offset(time::UtcOffset::UTC).hour(),
15,
"CEST: 17:00 local = 15:00 UTC (spring-forward already happened)"
);
assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
}
#[test]
fn deadline_on_day_after_fall_back_is_cet() {
let received = OffsetDateTime::new_utc(date(2025, 10, 22), Time::MIDNIGHT);
let due = deadline_at_werktage(received, 4, HolidayCalendar::BdewMaKo);
assert_eq!(
due.date(),
date(2025, 10, 27),
"should land on Monday 2025-10-27"
);
assert_eq!(
due.to_offset(time::UtcOffset::UTC).hour(),
16,
"CET: 17:00 local = 16:00 UTC (fall-back already happened)"
);
assert_eq!(due.to_offset(time::UtcOffset::UTC).minute(), 0);
}
}