automapper-validation 0.1.54

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;

/// Parse EDIFACT DTM format 303 (`CCYYMMDDHHMM`, already UTC per BDEW convention)
/// into UTC epoch minutes. Any timezone suffix (e.g. `+00`, `UTC`) is ignored —
/// BDEW values are always UTC.
///
/// Returns `None` if the value is too short or contains invalid digits.
pub fn parse_dtm303(value: &str) -> Option<i64> {
    let s = value.trim();
    if s.len() < 12 {
        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())?;
    let hour: u32 = s.get(8..10).and_then(|v| v.parse().ok())?;
    let minute: u32 = s.get(10..12).and_then(|v| v.parse().ok())?;
    let dt = NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, 0)?;
    Some(dt.and_utc().timestamp() / 60)
}

/// Net DST delta across two UTC instants (expressed in epoch minutes).
///
/// Returns the net wall-clock/UTC divergence introduced by EU DST transitions
/// lying strictly between `a` and `b` (in the half-open interval
/// `[min(a,b), max(a,b))`):
///
/// * `0`   — no transition crossed, or equal number of forward/backward crossings
/// * `+60` — net winter→summer crossing (spring forward, last Sunday of March 01:00 UTC)
/// * `-60` — net summer→winter crossing (fall back, last Sunday of October 01:00 UTC)
///
/// For short intervals (under a few months) the result is at most one transition.
pub fn dst_transitions_between(a_utc_min: i64, b_utc_min: i64) -> i32 {
    let (lo, hi) = if a_utc_min <= b_utc_min {
        (a_utc_min, b_utc_min)
    } else {
        (b_utc_min, a_utc_min)
    };
    if lo == hi {
        return 0;
    }
    let lo_year = year_of_epoch_min(lo);
    let hi_year = year_of_epoch_min(hi);
    let mut delta: i32 = 0;
    for y in lo_year..=hi_year {
        let spring_forward = dst_instant_min(y, 3);
        if lo <= spring_forward && spring_forward < hi {
            delta += 60;
        }
        let fall_back = dst_instant_min(y, 10);
        if lo <= fall_back && fall_back < hi {
            delta -= 60;
        }
    }
    delta
}

fn dst_instant_min(year: i32, month: u32) -> i64 {
    let date = last_sunday_of_month(year, month);
    date.and_hms_opt(1, 0, 0).unwrap().and_utc().timestamp() / 60
}

fn year_of_epoch_min(epoch_min: i64) -> i32 {
    chrono::DateTime::from_timestamp(epoch_min * 60, 0)
        .map(|dt| dt.date_naive().year())
        .unwrap_or(1970)
}

/// 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_parse_dtm303_valid() {
        // 2026-01-15 12:00 UTC
        let m = parse_dtm303("202601151200").unwrap();
        // Sanity: same as chrono naive UTC
        let expected = NaiveDate::from_ymd_opt(2026, 1, 15)
            .unwrap()
            .and_hms_opt(12, 0, 0)
            .unwrap()
            .and_utc()
            .timestamp()
            / 60;
        assert_eq!(m, expected);
    }

    #[test]
    fn test_parse_dtm303_ignores_tz_suffix() {
        let a = parse_dtm303("202603290100").unwrap();
        let b = parse_dtm303("202603290100+00").unwrap();
        let c = parse_dtm303("202603290100UTC").unwrap();
        assert_eq!(a, b);
        assert_eq!(a, c);
    }

    #[test]
    fn test_parse_dtm303_rejects_short() {
        assert!(parse_dtm303("20260115").is_none());
        assert!(parse_dtm303("").is_none());
    }

    #[test]
    fn test_parse_dtm303_rejects_invalid() {
        assert!(parse_dtm303("2026ZZ151200").is_none());
        assert!(parse_dtm303("202613151200").is_none());
    }

    #[test]
    fn test_dst_no_transition_same_day() {
        let a = parse_dtm303("202601150800").unwrap();
        let b = parse_dtm303("202601151200").unwrap();
        assert_eq!(dst_transitions_between(a, b), 0);
    }

    #[test]
    fn test_dst_spring_forward_2026() {
        // March transition 2026: last Sunday of March = 29th, 01:00 UTC
        let before = parse_dtm303("202603290030").unwrap();
        let after = parse_dtm303("202603290130").unwrap();
        assert_eq!(dst_transitions_between(before, after), 60);
        // Symmetric
        assert_eq!(dst_transitions_between(after, before), 60);
    }

    #[test]
    fn test_dst_fall_back_2026() {
        // October transition 2026: last Sunday of October = 25th, 01:00 UTC
        let before = parse_dtm303("202610250030").unwrap();
        let after = parse_dtm303("202610250130").unwrap();
        assert_eq!(dst_transitions_between(before, after), -60);
        assert_eq!(dst_transitions_between(after, before), -60);
    }

    #[test]
    fn test_dst_transition_at_exact_boundary() {
        // Interval [spring_forward, spring_forward) is empty — no transition.
        let at_boundary = parse_dtm303("202603290100").unwrap();
        assert_eq!(dst_transitions_between(at_boundary, at_boundary), 0);
        // Interval that includes the boundary.
        let after = parse_dtm303("202603290200").unwrap();
        assert_eq!(dst_transitions_between(at_boundary, after), 60);
    }

    #[test]
    fn test_dst_both_transitions_cancel() {
        // Full year span: spring forward + fall back = net zero.
        let winter_a = parse_dtm303("202601010000").unwrap();
        let winter_b = parse_dtm303("202612310000").unwrap();
        assert_eq!(dst_transitions_between(winter_a, winter_b), 0);
    }

    #[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()
        );
    }
}