automapper-validation 0.49.3

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! 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)
}

/// UTC epoch minutes at which a DTM value begins: the instant itself for
/// format 303 (`CCYYMMDDHHMM`, UTC), the German midnight that starts the day
/// for format 102 (`CCYYMMDD`, a calendar day in German time).
///
/// Returns `None` for other formats and for values that do not parse.
pub fn dtm_start_utc_min(value: &str, format: &str) -> Option<i64> {
    match format {
        "303" => parse_dtm303(value),
        "102" => {
            let s = value.trim();
            if s.len() != 8 {
                return None;
            }
            let utc_midnight = parse_dtm303(&format!("{s}0000"))?;
            // German midnight is 22:00 UTC the day before in MESZ, 23:00 in
            // MEZ. Clocks change at 01:00 UTC, never at German midnight.
            let mesz_midnight = utc_midnight - 120;
            let mesz = chrono::DateTime::from_timestamp(mesz_midnight * 60, 0)?
                .format("%Y%m%d%H%M")
                .to_string();
            match is_mesz_utc(&mesz) {
                ConditionResult::True => Some(mesz_midnight),
                ConditionResult::False => Some(utc_midnight - 60),
                ConditionResult::Unknown => None,
            }
        }
        _ => None,
    }
}

/// Whether a DTM value (format 303 or 102) lies after a format-303 reference,
/// such as the document date in DTM+137. A 102 day lies after it when it
/// begins after it — a later German calendar day.
///
/// A value that is no date of its format lies after nothing: `False`.
/// `Unknown` when the format is neither 303 nor 102 or the reference does
/// not parse.
pub fn dtm_after(value: &str, format: &str, reference_303: &str) -> ConditionResult {
    if !matches!(format, "303" | "102") {
        return ConditionResult::Unknown;
    }
    let Some(reference) = parse_dtm303(reference_303) else {
        return ConditionResult::Unknown;
    };
    match dtm_start_utc_min(value, format) {
        Some(v) => ConditionResult::from(v > reference),
        None => ConditionResult::False,
    }
}

/// Whether a DTM value lies no later than a format-303 reference, such as
/// the document date in DTM+137 (`[495]` "Der Zeitpunkt muss ≤ dem Wert im
/// DE2380 des DTM+137 sein"). A 102 day qualifies when it begins no later —
/// the reference's German day or one before (`[515]`); a 304 instant compares
/// to the second. Comparing the strings put `20241115230000+00` (304) after
/// `202411152300+00`, since `0` sorts after `+`.
///
/// `format` `None` (no segment in hand) and formats without an instant
/// (610 `CCYYMM`, 602 `CCYY`) compare as strings, which orders fixed-width
/// prefixes correctly. A value that is no date of its format is `False`.
pub fn dtm_not_after(value: &str, format: Option<&str>, reference_303: &str) -> ConditionResult {
    let Some(reference) = parse_dtm303(reference_303) else {
        return ConditionResult::Unknown;
    };
    match format {
        Some(format @ ("303" | "102")) => match dtm_start_utc_min(value, format) {
            Some(v) => ConditionResult::from(v <= reference),
            None => ConditionResult::False,
        },
        Some("304") => {
            let seconds = value.get(12..14).and_then(|s| s.parse::<u32>().ok());
            match (parse_dtm303(value), seconds) {
                (Some(v), Some(seconds)) => {
                    ConditionResult::from(v < reference || (v == reference && seconds == 0))
                }
                _ => ConditionResult::False,
            }
        }
        _ => ConditionResult::from(value <= reference_303),
    }
}

/// 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 {

    #[test]
    fn not_after_compares_each_format_as_what_it_means() {
        let reference = "202411152300+00"; // German 16 Nov 2024, 00:00
        let not_after = |v: &str, f: Option<&str>| dtm_not_after(v, f, reference);
        assert_eq!(
            not_after("20241115230000+00", Some("304")),
            ConditionResult::True
        );
        assert_eq!(
            not_after("20241115230001+00", Some("304")),
            ConditionResult::False
        );
        assert_eq!(
            not_after("202411152300+00", Some("303")),
            ConditionResult::True
        );
        assert_eq!(
            not_after("202411152301+00", Some("303")),
            ConditionResult::False
        );
        // The day the reference opens, and the one before it.
        assert_eq!(not_after("20241116", Some("102")), ConditionResult::True);
        assert_eq!(not_after("20241115", Some("102")), ConditionResult::True);
        assert_eq!(not_after("20241117", Some("102")), ConditionResult::False);
        assert_eq!(not_after("202411", Some("610")), ConditionResult::True);
        assert_eq!(not_after("202412", Some("610")), ConditionResult::False);
        assert_eq!(not_after("1", Some("102")), ConditionResult::False);
    }

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