Skip to main content

automapper_validation/eval/
timezone.rs

1//! DST timezone helpers for German MESZ/MEZ validation.
2//!
3//! EU DST rule: MESZ (summer time) starts the last Sunday of March at 01:00 UTC,
4//! and ends the last Sunday of October at 01:00 UTC.
5
6use chrono::{Datelike, NaiveDate};
7
8use super::evaluator::ConditionResult;
9
10/// Returns `True` if the given CCYYMMDDHHMM value falls within German summer time (MESZ),
11/// `False` if it falls in winter time (MEZ), or `Unknown` if the input is invalid/too short.
12///
13/// Only the first 12 characters are used, so a 15-char value with timezone suffix also works.
14pub fn is_mesz_utc(dtm_value: &str) -> ConditionResult {
15    let s = dtm_value.trim();
16    if s.len() < 8 {
17        return ConditionResult::Unknown;
18    }
19
20    let year: i32 = match s[0..4].parse() {
21        Ok(v) => v,
22        Err(_) => return ConditionResult::Unknown,
23    };
24    let month: u32 = match s[4..6].parse() {
25        Ok(v) => v,
26        Err(_) => return ConditionResult::Unknown,
27    };
28    let day: u32 = match s[6..8].parse() {
29        Ok(v) => v,
30        Err(_) => return ConditionResult::Unknown,
31    };
32    let hour: u32 = if s.len() >= 10 {
33        match s[8..10].parse() {
34            Ok(v) => v,
35            Err(_) => return ConditionResult::Unknown,
36        }
37    } else {
38        0
39    };
40    let minute: u32 = if s.len() >= 12 {
41        match s[10..12].parse() {
42            Ok(v) => v,
43            Err(_) => return ConditionResult::Unknown,
44        }
45    } else {
46        0
47    };
48
49    let Some(dt) = NaiveDate::from_ymd_opt(year, month, day)
50        .and_then(|d| d.and_hms_opt(hour, minute, 0))
51    else {
52        return ConditionResult::Unknown;
53    };
54
55    let mesz_start = last_sunday_of_month(year, 3).and_hms_opt(1, 0, 0).unwrap();
56    let mesz_end = last_sunday_of_month(year, 10).and_hms_opt(1, 0, 0).unwrap();
57
58    ConditionResult::from(dt >= mesz_start && dt < mesz_end)
59}
60
61/// Returns `True` if the given CCYYMMDDHHMM value falls within German winter time (MEZ).
62///
63/// This is the complement of [`is_mesz_utc`].
64pub fn is_mez_utc(dtm_value: &str) -> ConditionResult {
65    match is_mesz_utc(dtm_value) {
66        ConditionResult::True => ConditionResult::False,
67        ConditionResult::False => ConditionResult::True,
68        ConditionResult::Unknown => ConditionResult::Unknown,
69    }
70}
71
72/// Last Sunday of the given month as a `NaiveDate`.
73fn last_sunday_of_month(year: i32, month: u32) -> NaiveDate {
74    // Start from the last day of the month and walk backwards to Sunday
75    let last_day = NaiveDate::from_ymd_opt(year, month + 1, 1)
76        .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
77        .pred_opt()
78        .unwrap();
79    let days_since_sunday = last_day.weekday().num_days_from_sunday();
80    last_day - chrono::Duration::days(days_since_sunday as i64)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn test_known_mesz_date() {
89        assert_eq!(is_mesz_utc("202607151200"), ConditionResult::True);
90    }
91
92    #[test]
93    fn test_known_mez_date() {
94        assert_eq!(is_mesz_utc("202601151200"), ConditionResult::False);
95    }
96
97    #[test]
98    fn test_march_transition_2026() {
99        // Last Sunday of March 2026 = March 29
100        assert_eq!(is_mesz_utc("202603290059"), ConditionResult::False);
101        assert_eq!(is_mesz_utc("202603290100"), ConditionResult::True);
102    }
103
104    #[test]
105    fn test_october_transition_2026() {
106        // Last Sunday of October 2026 = October 25
107        assert_eq!(is_mesz_utc("202610250059"), ConditionResult::True);
108        assert_eq!(is_mesz_utc("202610250100"), ConditionResult::False);
109    }
110
111    #[test]
112    fn test_short_input() {
113        assert_eq!(is_mesz_utc("2026"), ConditionResult::Unknown);
114        assert_eq!(is_mesz_utc(""), ConditionResult::Unknown);
115        assert_eq!(is_mesz_utc("20260715"), ConditionResult::True);
116        assert_eq!(is_mesz_utc("20260115"), ConditionResult::False);
117    }
118
119    #[test]
120    fn test_invalid_input_returns_unknown() {
121        assert_eq!(is_mesz_utc("abcdefghijkl"), ConditionResult::Unknown);
122        assert_eq!(is_mesz_utc("202613151200"), ConditionResult::Unknown);
123        assert_eq!(is_mesz_utc("202601321200"), ConditionResult::Unknown);
124    }
125
126    #[test]
127    fn test_is_mez_complements_is_mesz() {
128        assert_eq!(is_mez_utc("202607151200"), ConditionResult::False);
129        assert_eq!(is_mez_utc("202601151200"), ConditionResult::True);
130        assert_eq!(is_mez_utc("short"), ConditionResult::Unknown);
131    }
132
133    #[test]
134    fn test_value_with_timezone_suffix() {
135        assert_eq!(is_mesz_utc("202607151200UTC"), ConditionResult::True);
136        assert_eq!(is_mesz_utc("202601151200303"), ConditionResult::False);
137    }
138
139    #[test]
140    fn test_last_sunday_of_march_2026() {
141        assert_eq!(last_sunday_of_month(2026, 3), NaiveDate::from_ymd_opt(2026, 3, 29).unwrap());
142    }
143
144    #[test]
145    fn test_last_sunday_of_october_2026() {
146        assert_eq!(last_sunday_of_month(2026, 10), NaiveDate::from_ymd_opt(2026, 10, 25).unwrap());
147    }
148
149    #[test]
150    fn test_different_years() {
151        assert_eq!(last_sunday_of_month(2025, 3), NaiveDate::from_ymd_opt(2025, 3, 30).unwrap());
152        assert_eq!(last_sunday_of_month(2025, 10), NaiveDate::from_ymd_opt(2025, 10, 26).unwrap());
153        assert_eq!(last_sunday_of_month(2024, 3), NaiveDate::from_ymd_opt(2024, 3, 31).unwrap());
154        assert_eq!(last_sunday_of_month(2024, 10), NaiveDate::from_ymd_opt(2024, 10, 27).unwrap());
155    }
156}