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