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/// Parse EDIFACT DTM format 303 (`CCYYMMDDHHMM`, already UTC per BDEW convention)
11/// into UTC epoch minutes. Any timezone suffix (e.g. `+00`, `UTC`) is ignored —
12/// BDEW values are always UTC.
13///
14/// Returns `None` if the value is too short or contains invalid digits.
15pub fn parse_dtm303(value: &str) -> Option<i64> {
16    let s = value.trim();
17    if s.len() < 12 {
18        return None;
19    }
20    let year: i32 = s.get(0..4).and_then(|v| v.parse().ok())?;
21    let month: u32 = s.get(4..6).and_then(|v| v.parse().ok())?;
22    let day: u32 = s.get(6..8).and_then(|v| v.parse().ok())?;
23    let hour: u32 = s.get(8..10).and_then(|v| v.parse().ok())?;
24    let minute: u32 = s.get(10..12).and_then(|v| v.parse().ok())?;
25    let dt = NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, 0)?;
26    Some(dt.and_utc().timestamp() / 60)
27}
28
29/// Net DST delta across two UTC instants (expressed in epoch minutes).
30///
31/// Returns the net wall-clock/UTC divergence introduced by EU DST transitions
32/// lying strictly between `a` and `b` (in the half-open interval
33/// `[min(a,b), max(a,b))`):
34///
35/// * `0`   — no transition crossed, or equal number of forward/backward crossings
36/// * `+60` — net winter→summer crossing (spring forward, last Sunday of March 01:00 UTC)
37/// * `-60` — net summer→winter crossing (fall back, last Sunday of October 01:00 UTC)
38///
39/// For short intervals (under a few months) the result is at most one transition.
40pub fn dst_transitions_between(a_utc_min: i64, b_utc_min: i64) -> i32 {
41    let (lo, hi) = if a_utc_min <= b_utc_min {
42        (a_utc_min, b_utc_min)
43    } else {
44        (b_utc_min, a_utc_min)
45    };
46    if lo == hi {
47        return 0;
48    }
49    let lo_year = year_of_epoch_min(lo);
50    let hi_year = year_of_epoch_min(hi);
51    let mut delta: i32 = 0;
52    for y in lo_year..=hi_year {
53        let spring_forward = dst_instant_min(y, 3);
54        if lo <= spring_forward && spring_forward < hi {
55            delta += 60;
56        }
57        let fall_back = dst_instant_min(y, 10);
58        if lo <= fall_back && fall_back < hi {
59            delta -= 60;
60        }
61    }
62    delta
63}
64
65fn dst_instant_min(year: i32, month: u32) -> i64 {
66    let date = last_sunday_of_month(year, month);
67    date.and_hms_opt(1, 0, 0).unwrap().and_utc().timestamp() / 60
68}
69
70fn year_of_epoch_min(epoch_min: i64) -> i32 {
71    chrono::DateTime::from_timestamp(epoch_min * 60, 0)
72        .map(|dt| dt.date_naive().year())
73        .unwrap_or(1970)
74}
75
76/// Returns `True` if the given CCYYMMDDHHMM value falls within German summer time (MESZ),
77/// `False` if it falls in winter time (MEZ), or `Unknown` if the input is invalid/too short.
78///
79/// Only the first 12 characters are used, so a 15-char value with timezone suffix also works.
80pub fn is_mesz_utc(dtm_value: &str) -> ConditionResult {
81    let s = dtm_value.trim();
82    if s.len() < 8 {
83        return ConditionResult::Unknown;
84    }
85
86    let year: i32 = match s[0..4].parse() {
87        Ok(v) => v,
88        Err(_) => return ConditionResult::Unknown,
89    };
90    let month: u32 = match s[4..6].parse() {
91        Ok(v) => v,
92        Err(_) => return ConditionResult::Unknown,
93    };
94    let day: u32 = match s[6..8].parse() {
95        Ok(v) => v,
96        Err(_) => return ConditionResult::Unknown,
97    };
98    let hour: u32 = if s.len() >= 10 {
99        match s[8..10].parse() {
100            Ok(v) => v,
101            Err(_) => return ConditionResult::Unknown,
102        }
103    } else {
104        0
105    };
106    let minute: u32 = if s.len() >= 12 {
107        match s[10..12].parse() {
108            Ok(v) => v,
109            Err(_) => return ConditionResult::Unknown,
110        }
111    } else {
112        0
113    };
114
115    let Some(dt) =
116        NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(hour, minute, 0))
117    else {
118        return ConditionResult::Unknown;
119    };
120
121    let mesz_start = last_sunday_of_month(year, 3).and_hms_opt(1, 0, 0).unwrap();
122    let mesz_end = last_sunday_of_month(year, 10).and_hms_opt(1, 0, 0).unwrap();
123
124    ConditionResult::from(dt >= mesz_start && dt < mesz_end)
125}
126
127/// Returns `True` if the given CCYYMMDDHHMM value falls within German winter time (MEZ).
128///
129/// This is the complement of [`is_mesz_utc`].
130pub fn is_mez_utc(dtm_value: &str) -> ConditionResult {
131    match is_mesz_utc(dtm_value) {
132        ConditionResult::True => ConditionResult::False,
133        ConditionResult::False => ConditionResult::True,
134        ConditionResult::Unknown => ConditionResult::Unknown,
135    }
136}
137
138/// Last Sunday of the given month as a `NaiveDate`.
139fn last_sunday_of_month(year: i32, month: u32) -> NaiveDate {
140    // Start from the last day of the month and walk backwards to Sunday
141    let last_day = NaiveDate::from_ymd_opt(year, month + 1, 1)
142        .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
143        .pred_opt()
144        .unwrap();
145    let days_since_sunday = last_day.weekday().num_days_from_sunday();
146    last_day - chrono::Duration::days(days_since_sunday as i64)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_known_mesz_date() {
155        assert_eq!(is_mesz_utc("202607151200"), ConditionResult::True);
156    }
157
158    #[test]
159    fn test_known_mez_date() {
160        assert_eq!(is_mesz_utc("202601151200"), ConditionResult::False);
161    }
162
163    #[test]
164    fn test_march_transition_2026() {
165        // Last Sunday of March 2026 = March 29
166        assert_eq!(is_mesz_utc("202603290059"), ConditionResult::False);
167        assert_eq!(is_mesz_utc("202603290100"), ConditionResult::True);
168    }
169
170    #[test]
171    fn test_october_transition_2026() {
172        // Last Sunday of October 2026 = October 25
173        assert_eq!(is_mesz_utc("202610250059"), ConditionResult::True);
174        assert_eq!(is_mesz_utc("202610250100"), ConditionResult::False);
175    }
176
177    #[test]
178    fn test_short_input() {
179        assert_eq!(is_mesz_utc("2026"), ConditionResult::Unknown);
180        assert_eq!(is_mesz_utc(""), ConditionResult::Unknown);
181        assert_eq!(is_mesz_utc("20260715"), ConditionResult::True);
182        assert_eq!(is_mesz_utc("20260115"), ConditionResult::False);
183    }
184
185    #[test]
186    fn test_invalid_input_returns_unknown() {
187        assert_eq!(is_mesz_utc("abcdefghijkl"), ConditionResult::Unknown);
188        assert_eq!(is_mesz_utc("202613151200"), ConditionResult::Unknown);
189        assert_eq!(is_mesz_utc("202601321200"), ConditionResult::Unknown);
190    }
191
192    #[test]
193    fn test_is_mez_complements_is_mesz() {
194        assert_eq!(is_mez_utc("202607151200"), ConditionResult::False);
195        assert_eq!(is_mez_utc("202601151200"), ConditionResult::True);
196        assert_eq!(is_mez_utc("short"), ConditionResult::Unknown);
197    }
198
199    #[test]
200    fn test_value_with_timezone_suffix() {
201        assert_eq!(is_mesz_utc("202607151200UTC"), ConditionResult::True);
202        assert_eq!(is_mesz_utc("202601151200303"), ConditionResult::False);
203    }
204
205    #[test]
206    fn test_last_sunday_of_march_2026() {
207        assert_eq!(
208            last_sunday_of_month(2026, 3),
209            NaiveDate::from_ymd_opt(2026, 3, 29).unwrap()
210        );
211    }
212
213    #[test]
214    fn test_last_sunday_of_october_2026() {
215        assert_eq!(
216            last_sunday_of_month(2026, 10),
217            NaiveDate::from_ymd_opt(2026, 10, 25).unwrap()
218        );
219    }
220
221    #[test]
222    fn test_parse_dtm303_valid() {
223        // 2026-01-15 12:00 UTC
224        let m = parse_dtm303("202601151200").unwrap();
225        // Sanity: same as chrono naive UTC
226        let expected = NaiveDate::from_ymd_opt(2026, 1, 15)
227            .unwrap()
228            .and_hms_opt(12, 0, 0)
229            .unwrap()
230            .and_utc()
231            .timestamp()
232            / 60;
233        assert_eq!(m, expected);
234    }
235
236    #[test]
237    fn test_parse_dtm303_ignores_tz_suffix() {
238        let a = parse_dtm303("202603290100").unwrap();
239        let b = parse_dtm303("202603290100+00").unwrap();
240        let c = parse_dtm303("202603290100UTC").unwrap();
241        assert_eq!(a, b);
242        assert_eq!(a, c);
243    }
244
245    #[test]
246    fn test_parse_dtm303_rejects_short() {
247        assert!(parse_dtm303("20260115").is_none());
248        assert!(parse_dtm303("").is_none());
249    }
250
251    #[test]
252    fn test_parse_dtm303_rejects_invalid() {
253        assert!(parse_dtm303("2026ZZ151200").is_none());
254        assert!(parse_dtm303("202613151200").is_none());
255    }
256
257    #[test]
258    fn test_dst_no_transition_same_day() {
259        let a = parse_dtm303("202601150800").unwrap();
260        let b = parse_dtm303("202601151200").unwrap();
261        assert_eq!(dst_transitions_between(a, b), 0);
262    }
263
264    #[test]
265    fn test_dst_spring_forward_2026() {
266        // March transition 2026: last Sunday of March = 29th, 01:00 UTC
267        let before = parse_dtm303("202603290030").unwrap();
268        let after = parse_dtm303("202603290130").unwrap();
269        assert_eq!(dst_transitions_between(before, after), 60);
270        // Symmetric
271        assert_eq!(dst_transitions_between(after, before), 60);
272    }
273
274    #[test]
275    fn test_dst_fall_back_2026() {
276        // October transition 2026: last Sunday of October = 25th, 01:00 UTC
277        let before = parse_dtm303("202610250030").unwrap();
278        let after = parse_dtm303("202610250130").unwrap();
279        assert_eq!(dst_transitions_between(before, after), -60);
280        assert_eq!(dst_transitions_between(after, before), -60);
281    }
282
283    #[test]
284    fn test_dst_transition_at_exact_boundary() {
285        // Interval [spring_forward, spring_forward) is empty — no transition.
286        let at_boundary = parse_dtm303("202603290100").unwrap();
287        assert_eq!(dst_transitions_between(at_boundary, at_boundary), 0);
288        // Interval that includes the boundary.
289        let after = parse_dtm303("202603290200").unwrap();
290        assert_eq!(dst_transitions_between(at_boundary, after), 60);
291    }
292
293    #[test]
294    fn test_dst_both_transitions_cancel() {
295        // Full year span: spring forward + fall back = net zero.
296        let winter_a = parse_dtm303("202601010000").unwrap();
297        let winter_b = parse_dtm303("202612310000").unwrap();
298        assert_eq!(dst_transitions_between(winter_a, winter_b), 0);
299    }
300
301    #[test]
302    fn test_different_years() {
303        assert_eq!(
304            last_sunday_of_month(2025, 3),
305            NaiveDate::from_ymd_opt(2025, 3, 30).unwrap()
306        );
307        assert_eq!(
308            last_sunday_of_month(2025, 10),
309            NaiveDate::from_ymd_opt(2025, 10, 26).unwrap()
310        );
311        assert_eq!(
312            last_sunday_of_month(2024, 3),
313            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
314        );
315        assert_eq!(
316            last_sunday_of_month(2024, 10),
317            NaiveDate::from_ymd_opt(2024, 10, 27).unwrap()
318        );
319    }
320}