automapper-validation 0.1.53

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! DST timezone helpers for German MESZ/MEZ validation.
//!
//! EU DST rule: MESZ (summer time) starts the last Sunday of March at 01:00 UTC,
//! and ends the last Sunday of October at 01:00 UTC.

use chrono::{Datelike, NaiveDate};

use super::evaluator::ConditionResult;

/// Returns `True` if the given CCYYMMDDHHMM value falls within German summer time (MESZ),
/// `False` if it falls in winter time (MEZ), or `Unknown` if the input is invalid/too short.
///
/// Only the first 12 characters are used, so a 15-char value with timezone suffix also works.
pub fn is_mesz_utc(dtm_value: &str) -> ConditionResult {
    let s = dtm_value.trim();
    if s.len() < 8 {
        return ConditionResult::Unknown;
    }

    let year: i32 = match s[0..4].parse() {
        Ok(v) => v,
        Err(_) => return ConditionResult::Unknown,
    };
    let month: u32 = match s[4..6].parse() {
        Ok(v) => v,
        Err(_) => return ConditionResult::Unknown,
    };
    let day: u32 = match s[6..8].parse() {
        Ok(v) => v,
        Err(_) => return ConditionResult::Unknown,
    };
    let hour: u32 = if s.len() >= 10 {
        match s[8..10].parse() {
            Ok(v) => v,
            Err(_) => return ConditionResult::Unknown,
        }
    } else {
        0
    };
    let minute: u32 = if s.len() >= 12 {
        match s[10..12].parse() {
            Ok(v) => v,
            Err(_) => return ConditionResult::Unknown,
        }
    } else {
        0
    };

    let Some(dt) =
        NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(hour, minute, 0))
    else {
        return ConditionResult::Unknown;
    };

    let mesz_start = last_sunday_of_month(year, 3).and_hms_opt(1, 0, 0).unwrap();
    let mesz_end = last_sunday_of_month(year, 10).and_hms_opt(1, 0, 0).unwrap();

    ConditionResult::from(dt >= mesz_start && dt < mesz_end)
}

/// Returns `True` if the given CCYYMMDDHHMM value falls within German winter time (MEZ).
///
/// This is the complement of [`is_mesz_utc`].
pub fn is_mez_utc(dtm_value: &str) -> ConditionResult {
    match is_mesz_utc(dtm_value) {
        ConditionResult::True => ConditionResult::False,
        ConditionResult::False => ConditionResult::True,
        ConditionResult::Unknown => ConditionResult::Unknown,
    }
}

/// Last Sunday of the given month as a `NaiveDate`.
fn last_sunday_of_month(year: i32, month: u32) -> NaiveDate {
    // Start from the last day of the month and walk backwards to Sunday
    let last_day = NaiveDate::from_ymd_opt(year, month + 1, 1)
        .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
        .pred_opt()
        .unwrap();
    let days_since_sunday = last_day.weekday().num_days_from_sunday();
    last_day - chrono::Duration::days(days_since_sunday as i64)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_known_mesz_date() {
        assert_eq!(is_mesz_utc("202607151200"), ConditionResult::True);
    }

    #[test]
    fn test_known_mez_date() {
        assert_eq!(is_mesz_utc("202601151200"), ConditionResult::False);
    }

    #[test]
    fn test_march_transition_2026() {
        // Last Sunday of March 2026 = March 29
        assert_eq!(is_mesz_utc("202603290059"), ConditionResult::False);
        assert_eq!(is_mesz_utc("202603290100"), ConditionResult::True);
    }

    #[test]
    fn test_october_transition_2026() {
        // Last Sunday of October 2026 = October 25
        assert_eq!(is_mesz_utc("202610250059"), ConditionResult::True);
        assert_eq!(is_mesz_utc("202610250100"), ConditionResult::False);
    }

    #[test]
    fn test_short_input() {
        assert_eq!(is_mesz_utc("2026"), ConditionResult::Unknown);
        assert_eq!(is_mesz_utc(""), ConditionResult::Unknown);
        assert_eq!(is_mesz_utc("20260715"), ConditionResult::True);
        assert_eq!(is_mesz_utc("20260115"), ConditionResult::False);
    }

    #[test]
    fn test_invalid_input_returns_unknown() {
        assert_eq!(is_mesz_utc("abcdefghijkl"), ConditionResult::Unknown);
        assert_eq!(is_mesz_utc("202613151200"), ConditionResult::Unknown);
        assert_eq!(is_mesz_utc("202601321200"), ConditionResult::Unknown);
    }

    #[test]
    fn test_is_mez_complements_is_mesz() {
        assert_eq!(is_mez_utc("202607151200"), ConditionResult::False);
        assert_eq!(is_mez_utc("202601151200"), ConditionResult::True);
        assert_eq!(is_mez_utc("short"), ConditionResult::Unknown);
    }

    #[test]
    fn test_value_with_timezone_suffix() {
        assert_eq!(is_mesz_utc("202607151200UTC"), ConditionResult::True);
        assert_eq!(is_mesz_utc("202601151200303"), ConditionResult::False);
    }

    #[test]
    fn test_last_sunday_of_march_2026() {
        assert_eq!(
            last_sunday_of_month(2026, 3),
            NaiveDate::from_ymd_opt(2026, 3, 29).unwrap()
        );
    }

    #[test]
    fn test_last_sunday_of_october_2026() {
        assert_eq!(
            last_sunday_of_month(2026, 10),
            NaiveDate::from_ymd_opt(2026, 10, 25).unwrap()
        );
    }

    #[test]
    fn test_different_years() {
        assert_eq!(
            last_sunday_of_month(2025, 3),
            NaiveDate::from_ymd_opt(2025, 3, 30).unwrap()
        );
        assert_eq!(
            last_sunday_of_month(2025, 10),
            NaiveDate::from_ymd_opt(2025, 10, 26).unwrap()
        );
        assert_eq!(
            last_sunday_of_month(2024, 3),
            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
        );
        assert_eq!(
            last_sunday_of_month(2024, 10),
            NaiveDate::from_ymd_opt(2024, 10, 27).unwrap()
        );
    }
}