Skip to main content

automapper_validation/eval/
format_validators.rs

1//! Format validation helpers for AHB 900-series conditions.
2//!
3//! These validate the FORMAT of data element values (decimal places, numeric ranges,
4//! time patterns, ID formats, etc.). They operate on string values extracted from
5//! EDIFACT segments and return `ConditionResult`.
6
7use super::evaluator::ConditionResult;
8
9// Re-export timezone helpers so generated code can use `use crate::eval::format_validators::*`
10pub use super::timezone::{dst_transitions_between, is_mesz_utc, is_mez_utc, parse_dtm303};
11pub use super::werktag::{
12    compute_easter, is_german_holiday, is_werktag, parse_ccyymmdd_prefix, werktage_between,
13};
14
15// --- Decimal/digit place validation ---
16
17/// Validate that a numeric string has at most `max` decimal places.
18///
19/// Returns `True` if the value has <= max decimal places (or no decimal point),
20/// `False` if it has more, `Unknown` if the value is empty.
21///
22/// Example: `validate_max_decimal_places("123.45", 2)` → True
23/// Example: `validate_max_decimal_places("123.456", 2)` → False
24/// Example: `validate_max_decimal_places("123", 2)` → True (no decimal → 0 places)
25pub fn validate_max_decimal_places(value: &str, max: usize) -> ConditionResult {
26    if value.is_empty() {
27        return ConditionResult::Unknown;
28    }
29    let decimal_places = match value.find('.') {
30        Some(pos) => value.len() - pos - 1,
31        None => 0,
32    };
33    ConditionResult::from(decimal_places <= max)
34}
35
36/// Validate that a numeric string has at most `max` integer digits (before decimal point).
37///
38/// Ignores leading minus sign.
39pub fn validate_max_integer_digits(value: &str, max: usize) -> ConditionResult {
40    if value.is_empty() {
41        return ConditionResult::Unknown;
42    }
43    let s = value.strip_prefix('-').unwrap_or(value);
44    let integer_part = match s.find('.') {
45        Some(pos) => &s[..pos],
46        None => s,
47    };
48    ConditionResult::from(integer_part.len() <= max)
49}
50
51// --- Numeric range validation ---
52
53/// Validate a numeric value against a comparison.
54///
55/// `op` is one of: "==", "!=", ">", ">=", "<", "<="
56/// An EDIFACT number is an optional sign, digits and at most one decimal mark
57/// (`.` or `,`, which the UNA may set). Anything else is no possible value and
58/// fails (`O5D2DXBG1A` is not "> 0"); only an empty value is `Unknown`.
59///
60/// Example: `validate_numeric(value, ">=", 0.0)` for "Wert >= 0"
61/// Example: `validate_numeric(value, "==", 1.0)` for "Wert = 1"
62pub fn validate_numeric(value: &str, op: &str, threshold: f64) -> ConditionResult {
63    if value.is_empty() {
64        return ConditionResult::Unknown;
65    }
66    let parsed = match edifact_number(value) {
67        Some(v) => v,
68        None => return ConditionResult::False,
69    };
70    let result = match op {
71        "==" => (parsed - threshold).abs() < f64::EPSILON,
72        "!=" => (parsed - threshold).abs() >= f64::EPSILON,
73        ">" => parsed > threshold,
74        ">=" => parsed >= threshold,
75        "<" => parsed < threshold,
76        "<=" => parsed <= threshold,
77        _ => return ConditionResult::Unknown,
78    };
79    ConditionResult::from(result)
80}
81
82/// Whether `value` is an EDIFACT number — what a data element of numeric
83/// format (`n..10`) may hold: an optional minus sign, at least one digit, and
84/// at most one decimal mark (`.` or `,`).
85pub fn is_edifact_number(value: &str) -> bool {
86    edifact_number(value).is_some()
87}
88
89/// `value` as an EDIFACT number: an optional sign, at least one digit, and at
90/// most one decimal mark (`.` or `,`). `None` for anything else (`1e3`, `NaN`).
91fn edifact_number(value: &str) -> Option<f64> {
92    let unsigned = value.strip_prefix('-').unwrap_or(value);
93    let mut marks = 0;
94    let mut digits = 0;
95    for c in unsigned.chars() {
96        match c {
97            '0'..='9' => digits += 1,
98            '.' | ',' => marks += 1,
99            _ => return None,
100        }
101    }
102    if digits == 0 || marks > 1 {
103        return None;
104    }
105    value.replace(',', ".").parse().ok()
106}
107
108// --- DTM time/timezone validation ---
109
110/// Validate that a DTM value's HHMM portion equals the expected value.
111///
112/// DTM format 303 is CCYYMMDDHHMM (12 chars) or CCYYMMDDHHMMZZZ (15 chars with timezone).
113/// Extracts characters at positions 8..12 (HHMM) for comparison.
114///
115/// Example: `validate_hhmm_equals("202601012200+00", "2200")` → True
116pub fn validate_hhmm_equals(dtm_value: &str, expected_hhmm: &str) -> ConditionResult {
117    if dtm_value.len() < 12 {
118        // Value too short — no HHMM component present, condition is not met
119        return ConditionResult::False;
120    }
121    ConditionResult::from(&dtm_value[8..12] == expected_hhmm)
122}
123
124/// Validate that a DTM value's HHMM portion is within a range (inclusive).
125///
126/// Example: `validate_hhmm_range("202601011530+00", "0000", "2359")` → True
127pub fn validate_hhmm_range(dtm_value: &str, min: &str, max: &str) -> ConditionResult {
128    if dtm_value.len() < 12 {
129        return ConditionResult::False;
130    }
131    let hhmm = &dtm_value[8..12];
132    ConditionResult::from(hhmm >= min && hhmm <= max)
133}
134
135/// Validate that a DTM value's MMDDHHMM portion equals the expected value.
136///
137/// Extracts characters at positions 4..12 for comparison.
138///
139/// Example: `validate_mmddhhmm_equals("202612312300+00", "12312300")` → True
140pub fn validate_mmddhhmm_equals(dtm_value: &str, expected: &str) -> ConditionResult {
141    if dtm_value.len() < 12 {
142        return ConditionResult::False;
143    }
144    ConditionResult::from(&dtm_value[4..12] == expected)
145}
146
147/// Validate that a DTM value's timezone portion ZZZ is "+00" (UTC).
148///
149/// The zone is the value's last three characters: format 303
150/// `CCYYMMDDHHMMZZZ` (15 chars) and 304 `CCYYMMDDHHMMSSZZZ` (17 chars) both
151/// end in it. Reading it at a fixed offset of 12 took 304's seconds for the
152/// zone and failed every 304 value.
153///
154/// Example: `validate_timezone_utc("202601012200+00")` → True
155/// Example: `validate_timezone_utc("20260101220000+00")` → True
156/// Example: `validate_timezone_utc("202601012200+01")` → False
157pub fn validate_timezone_utc(dtm_value: &str) -> ConditionResult {
158    if dtm_value.len() < 15 || !dtm_value.is_char_boundary(dtm_value.len() - 3) {
159        // Value too short — no timezone suffix present, condition is not met
160        return ConditionResult::False;
161    }
162    ConditionResult::from(&dtm_value[dtm_value.len() - 3..] == "+00")
163}
164
165// --- Contact format validation ---
166
167/// Validate email format: must contain both '@' and '.'.
168///
169/// Example: `validate_email("user@example.com")` → True
170pub fn validate_email(value: &str) -> ConditionResult {
171    if value.is_empty() {
172        return ConditionResult::Unknown;
173    }
174    ConditionResult::from(value.contains('@') && value.contains('.'))
175}
176
177/// Validate phone format: must start with '+' followed by only digits.
178///
179/// Example: `validate_phone("+4930123456")` → True
180/// Example: `validate_phone("030123456")` → False
181pub fn validate_phone(value: &str) -> ConditionResult {
182    if value.is_empty() {
183        return ConditionResult::Unknown;
184    }
185    if !value.starts_with('+') || value.len() < 2 {
186        return ConditionResult::from(false);
187    }
188    ConditionResult::from(value[1..].chars().all(|c| c.is_ascii_digit()))
189}
190
191// --- ID format validation ---
192
193/// Validate Marktlokations-ID (MaLo-ID): exactly 11 digits with BDEW check digit.
194///
195/// This is **not** standard Luhn — per the BDEW "Anwendungshilfe MaLo-ID v1.0" spec
196/// and the Hochfrequenz BO4E-dotnet reference implementation
197/// (`BO4E/BO/Marktlokation.cs::GetChecksum`), digits 1–10 are numbered left-to-right
198/// (1-based), digits at even positions are multiplied by 2 and **added as the full
199/// value** (no digit-sum, no mod-by-9 step), digits at odd positions are added as-is.
200/// Check digit = `(10 - (sum mod 10)) mod 10`.
201///
202/// Example: `"51238696781"` — odd (1,3,5,7,9): 5+2+8+9+7=31; even doubled
203/// (2,4,6,8,10): 2*(1+3+6+6+8)=48; sum=79; check = (10-79%10)%10 = 1 ✓.
204pub fn validate_malo_id(value: &str) -> ConditionResult {
205    if value.len() != 11 {
206        return ConditionResult::from(false);
207    }
208    if !value.chars().all(|c| c.is_ascii_digit()) {
209        return ConditionResult::from(false);
210    }
211    let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
212    let check = digits[10];
213    let mut sum = 0u32;
214    for (i, &d) in digits[..10].iter().enumerate() {
215        // 1-based position is i+1; even positions are doubled, odd positions as-is.
216        if (i + 1) % 2 == 0 {
217            sum += 2 * d;
218        } else {
219            sum += d;
220        }
221    }
222    let expected = (10 - (sum % 10)) % 10;
223    ConditionResult::from(check == expected)
224}
225
226/// Validate Transaktionsreferenz-ID (TR-ID): 1-35 alphanumeric characters.
227pub fn validate_tr_id(value: &str) -> ConditionResult {
228    if value.is_empty() {
229        return ConditionResult::Unknown;
230    }
231    ConditionResult::from(value.len() <= 35 && value.chars().all(|c| c.is_ascii_alphanumeric()))
232}
233
234/// A BDEW location ID of the lettered kind: `prefix` followed by ten ASCII
235/// uppercase letters or digits (11 characters).
236fn validate_prefixed_location_id(value: &str, prefix: char) -> ConditionResult {
237    let mut chars = value.chars();
238    ConditionResult::from(
239        value.len() == 11
240            && chars.next() == Some(prefix)
241            && chars.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase()),
242    )
243}
244
245/// Validate Steuerbare-Ressource-ID (SR-ID): "C" + ten alphanumerics
246/// (`C816417ST77`).
247pub fn validate_sr_id(value: &str) -> ConditionResult {
248    validate_prefixed_location_id(value, 'C')
249}
250
251/// Validate Netzlokations-ID: "E" + ten alphanumerics (`E1688117482`).
252pub fn validate_nelo_id(value: &str) -> ConditionResult {
253    validate_prefixed_location_id(value, 'E')
254}
255
256// --- OBIS code pattern matching ---
257
258/// Parse an OBIS code of the form `A-B:C.D.E[*F]` and return `(C, D)`.
259///
260/// OBIS structure (IEC 62056-61, BDEW/DVGW usage):
261/// - `A` — medium (1 = electricity, 7 = gas, 6 = heat, …)
262/// - `B` — channel
263/// - `C` — measurement type (1 = +A active energy import, 2 = -A active energy
264///   export, 3 = +R reactive import, 4 = -R reactive export, …)
265/// - `D` — measurement mode (8 = total time integral, 9 = billing-period
266///   integral, 29 = previous-billing-period partial value for interval
267///   measurements, 5 = actual/instantaneous value, …)
268/// - `E` — rate/tariff
269/// - `F` — optional billing period marker
270///
271/// Returns `None` when the value doesn't fit the shape. Whitespace-tolerant.
272pub fn parse_obis(value: &str) -> Option<(u32, u32)> {
273    let value = value.trim();
274    // Split at the colon between `A-B` and `C.D.E`.
275    let (_ab, cde) = value.split_once(':')?;
276    // Drop optional `*F` suffix.
277    let cde = cde.split('*').next()?;
278    let mut parts = cde.split('.');
279    let c = parts.next()?.parse::<u32>().ok()?;
280    let d = parts.next()?.parse::<u32>().ok()?;
281    Some((c, d))
282}
283
284/// The tariff `E` of an OBIS code `A-B:C.D.E[*F]`; `None` when the value
285/// doesn't fit the shape. UTILMD Strom `[391]` names it: "OBIS mit zugeordneter
286/// Zählzeit (Tarif: e = 1 .. 62)".
287pub fn obis_tariff(value: &str) -> Option<u32> {
288    let (_ab, cde) = value.trim().split_once(':')?;
289    let cde = cde.split('*').next()?;
290    let mut parts = cde.split('.');
291    parts.next()?.parse::<u32>().ok()?;
292    parts.next()?.parse::<u32>().ok()?;
293    parts.next()?.parse::<u32>().ok()
294}
295
296/// Is the OBIS code "Wirkarbeit kumuliert" — active energy as a cumulated
297/// time integral (total or per billing period)?
298///
299/// Matches BDEW convention: `C ∈ {1, 2}` (active energy import/export) and
300/// `D ∈ {8, 9}` (total time integral / billing-period integral).
301pub fn is_obis_wirkarbeit_kumuliert(value: &str) -> bool {
302    matches!(parse_obis(value), Some((1 | 2, 8 | 9)))
303}
304
305/// Is the OBIS code "Wirkarbeit 1/4 Stunde" — active energy 15-minute
306/// interval (Lastgang) measurement?
307///
308/// Matches BDEW convention for quarter-hour Lastgang: `C ∈ {1, 2}` and
309/// `D = 29` (partial value for previous billing period, the standard marker
310/// for interval-measured energy in German energy market MSCONS/UTILMD).
311pub fn is_obis_wirkarbeit_quarter_hour(value: &str) -> bool {
312    matches!(parse_obis(value), Some((1 | 2, 29)))
313}
314
315/// Is the OBIS code an energy register, `1-b:c.8.e` with `c` 1 to 8?
316///
317/// UTILMD Strom `[273]` lists exactly these: "1-b?:1.8.e / 1-b?:2.8.e / … /
318/// 1-b?:8.8.e / 1-b?:1.8.63" (1.8.63 is a 1.8.e). A load profile
319/// (`1-1:1.29.0`) or a register of another medium (`7-…`) is not one.
320pub fn is_energy_register_obis(value: &str) -> bool {
321    value.trim().starts_with("1-") && matches!(parse_obis(value), Some((1..=8, 8)))
322}
323
324/// Validate an X.509 certificate body per BSI TR-03109-4.
325///
326/// In EDIFACT messages (e.g. ORDERS/REQOTE for Smartmeter gateway config) the
327/// certificate body is transmitted as base64-encoded DER — the PEM armoring
328/// (`-----BEGIN CERTIFICATE-----` / `-----END CERTIFICATE-----` lines) may or
329/// may not be present depending on the profile.
330///
331/// This validator performs a structural check only:
332/// - non-empty
333/// - composed exclusively of base64 characters (`A-Za-z0-9+/=`) plus
334///   whitespace (`\n\r\t` and space — PEM lines wrap at 64 chars)
335/// - decoded length plausible for a DER-encoded X.509 certificate (we allow a
336///   loose lower bound — a minimal self-signed cert is ~400 bytes, so the
337///   base64 form is ≥ ~540 chars; we use 100 as a sanity floor)
338///
339/// Cryptographic validation (signature, CA chain, TR-03109 certificate
340/// extensions) is out of scope for static AHB validation — those require a
341/// trust store and runtime context.
342pub fn validate_x509_cert_body(value: &str) -> ConditionResult {
343    let trimmed = value.trim();
344    if trimmed.is_empty() {
345        return ConditionResult::Unknown;
346    }
347    // Strip optional PEM armoring so the charset check operates on the
348    // encoded body alone.
349    let body = trimmed
350        .trim_start_matches("-----BEGIN CERTIFICATE-----")
351        .trim_end_matches("-----END CERTIFICATE-----")
352        .trim();
353    let chars_ok = body.chars().all(|c| {
354        c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '\n' | '\r' | '\t' | ' ')
355    });
356    // Minimum plausible length for a base64-encoded X.509 body. Shorter
357    // values are almost certainly not a real certificate.
358    let len_ok = body.chars().filter(|c| !c.is_whitespace()).count() >= 100;
359    ConditionResult::from(chars_ok && len_ok)
360}
361
362/// Validate Zahlpunktbezeichnung: exactly 33 alphanumeric characters.
363pub fn validate_zahlpunkt(value: &str) -> ConditionResult {
364    if value.len() != 33 {
365        return ConditionResult::from(false);
366    }
367    ConditionResult::from(value.chars().all(|c| c.is_ascii_alphanumeric()))
368}
369
370/// Validate either MaLo-ID or Zahlpunktbezeichnung format.
371pub fn validate_malo_or_zahlpunkt(value: &str) -> ConditionResult {
372    if value.len() == 11 && validate_malo_id(value).is_true() {
373        return ConditionResult::True;
374    }
375    if value.len() == 33 && validate_zahlpunkt(value).is_true() {
376        return ConditionResult::True;
377    }
378    ConditionResult::False
379}
380
381// --- Artikelnummer pattern validation ---
382
383/// Validate a dash-separated digit pattern like "n1-n2-n1-n3".
384///
385/// `segment_lengths` defines expected digit counts per dash-separated segment.
386///
387/// Example: `validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3])` → True
388/// Example: `validate_artikel_pattern("1-23-4", &[1, 2, 1])` → True
389pub fn validate_artikel_pattern(value: &str, segment_lengths: &[usize]) -> ConditionResult {
390    if value.is_empty() {
391        return ConditionResult::Unknown;
392    }
393    let parts: Vec<&str> = value.split('-').collect();
394    if parts.len() != segment_lengths.len() {
395        return ConditionResult::from(false);
396    }
397    let valid = parts
398        .iter()
399        .zip(segment_lengths.iter())
400        .all(|(part, &expected_len)| {
401            part.len() == expected_len && part.chars().all(|c| c.is_ascii_digit())
402        });
403    ConditionResult::from(valid)
404}
405
406// --- General string validation ---
407
408/// Validate exact character length.
409pub fn validate_exact_length(value: &str, expected: usize) -> ConditionResult {
410    if value.is_empty() {
411        return ConditionResult::Unknown;
412    }
413    ConditionResult::from(value.len() == expected)
414}
415
416/// Validate maximum character length.
417pub fn validate_max_length(value: &str, max: usize) -> ConditionResult {
418    if value.is_empty() {
419        return ConditionResult::Unknown;
420    }
421    ConditionResult::from(value.len() <= max)
422}
423
424/// Validate that a string contains only digits (positive integer check).
425pub fn validate_all_digits(value: &str) -> ConditionResult {
426    if value.is_empty() {
427        return ConditionResult::Unknown;
428    }
429    ConditionResult::from(value.chars().all(|c| c.is_ascii_digit()))
430}
431
432/// Current UTC date+time as a CCYYMMDDHHMM string (12 chars).
433pub fn utc_now_ccyymmddhhmm() -> String {
434    chrono::Utc::now().format("%Y%m%d%H%M").to_string()
435}
436
437/// A DTM format 303 value (`CCYYMMDDHHMMZZZ`, `202610312300+00`) as German
438/// local time: CET, or CEST from the last Sunday of March to the last Sunday of
439/// October (01:00 UTC each). `None` if it is no such value.
440///
441/// "0 Uhr gem. deutscher Zeit" (UTILMD [87]) is `…2300+00` in winter and
442/// `…2200+00` in summer; the digits alone never show midnight.
443pub fn german_local_time(value: &str) -> Option<chrono::NaiveDateTime> {
444    use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, Weekday};
445    let value = value.replace('?', "");
446    let stamp = NaiveDateTime::parse_from_str(value.get(..12)?, "%Y%m%d%H%M").ok()?;
447    let offset: i64 = match value.get(12..) {
448        None | Some("") => 0,
449        Some(zone) => zone.parse().ok()?,
450    };
451    let utc = stamp - Duration::hours(offset);
452    let switch = |month: u32| {
453        let mut day = NaiveDate::from_ymd_opt(utc.year(), month, 31)?;
454        while day.weekday() != Weekday::Sun {
455            day = day.pred_opt()?;
456        }
457        day.and_hms_opt(1, 0, 0)
458    };
459    let summer = utc >= switch(3)? && utc < switch(10)?;
460    Some(utc + Duration::hours(if summer { 2 } else { 1 }))
461}
462
463#[cfg(test)]
464mod tests {
465    #[test]
466    fn the_tariff_is_the_e_of_c_d_e() {
467        use super::obis_tariff;
468        assert_eq!(obis_tariff("1-1:1.8.1"), Some(1));
469        assert_eq!(obis_tariff("1-0:2.8.62*255"), Some(62));
470        assert_eq!(obis_tariff("1-0:1.8.0"), Some(0));
471        assert_eq!(obis_tariff("1-0:1.8"), None);
472        assert_eq!(obis_tariff("x"), None);
473    }
474
475    #[test]
476    fn energy_registers_are_c_8_e_with_c_one_to_eight() {
477        use super::is_energy_register_obis;
478        for obis in ["1-1:1.8.0", "1-0:2.8.1", "1-1:8.8.0", "1-1:1.8.63", "1-65:3.8.2"] {
479            assert!(is_energy_register_obis(obis), "{obis}");
480        }
481        for obis in ["1-1:1.29.0", "1-1:2.29.0", "1-1:9.8.0", "7-0:3.0.0", "1-1:1.9.0", "x"] {
482            assert!(!is_energy_register_obis(obis), "{obis}");
483        }
484    }
485
486    use super::*;
487
488    // --- Decimal places ---
489
490    #[test]
491    fn test_max_decimal_places() {
492        assert_eq!(
493            validate_max_decimal_places("123.45", 2),
494            ConditionResult::True
495        );
496        assert_eq!(
497            validate_max_decimal_places("123.456", 2),
498            ConditionResult::False
499        );
500        assert_eq!(validate_max_decimal_places("123", 2), ConditionResult::True);
501        assert_eq!(validate_max_decimal_places("0.1", 3), ConditionResult::True);
502        assert_eq!(validate_max_decimal_places("", 2), ConditionResult::Unknown);
503    }
504
505    #[test]
506    fn test_no_decimal_places() {
507        assert_eq!(validate_max_decimal_places("100", 0), ConditionResult::True);
508        assert_eq!(
509            validate_max_decimal_places("100.5", 0),
510            ConditionResult::False
511        );
512    }
513
514    #[test]
515    fn test_max_integer_digits() {
516        assert_eq!(
517            validate_max_integer_digits("1234", 4),
518            ConditionResult::True
519        );
520        assert_eq!(
521            validate_max_integer_digits("12345", 4),
522            ConditionResult::False
523        );
524        assert_eq!(
525            validate_max_integer_digits("-123.45", 4),
526            ConditionResult::True
527        );
528        assert_eq!(validate_max_integer_digits("", 4), ConditionResult::Unknown);
529    }
530
531    // --- Numeric range ---
532
533    #[test]
534    fn test_validate_numeric() {
535        assert_eq!(validate_numeric("5.0", ">=", 0.0), ConditionResult::True);
536        assert_eq!(validate_numeric("-1.0", ">=", 0.0), ConditionResult::False);
537        assert_eq!(validate_numeric("1", "==", 1.0), ConditionResult::True);
538        assert_eq!(validate_numeric("2", "==", 1.0), ConditionResult::False);
539        assert_eq!(validate_numeric("0", ">", 0.0), ConditionResult::False);
540        assert_eq!(validate_numeric("1", ">", 0.0), ConditionResult::True);
541        // Not a number is no "möglicher Wert" (mako-twin: RFF+Z60
542        // `O5D2DXBG1A` passed `[914]` "> 0").
543        assert_eq!(validate_numeric("abc", ">=", 0.0), ConditionResult::False);
544        assert_eq!(
545            validate_numeric("O5D2DXBG1A", ">", 0.0),
546            ConditionResult::False
547        );
548        assert_eq!(validate_numeric("NaN", ">", 0.0), ConditionResult::False);
549        assert_eq!(validate_numeric("inf", ">", 0.0), ConditionResult::False);
550        assert_eq!(validate_numeric("1e3", ">", 0.0), ConditionResult::False);
551        // A comma is a decimal mark the UNA may set.
552        assert_eq!(validate_numeric("0,5", ">", 0.0), ConditionResult::True);
553        assert_eq!(validate_numeric("-2", "<=", 0.0), ConditionResult::True);
554        assert_eq!(validate_numeric("", ">", 0.0), ConditionResult::Unknown);
555    }
556
557    // --- DTM validation ---
558
559    #[test]
560    fn test_hhmm_equals() {
561        assert_eq!(
562            validate_hhmm_equals("202601012200+00", "2200"),
563            ConditionResult::True
564        );
565        assert_eq!(
566            validate_hhmm_equals("202601012300+00", "2200"),
567            ConditionResult::False
568        );
569        assert_eq!(
570            validate_hhmm_equals("short", "2200"),
571            ConditionResult::False
572        );
573    }
574
575    #[test]
576    fn test_hhmm_range() {
577        assert_eq!(
578            validate_hhmm_range("202601011530+00", "0000", "2359"),
579            ConditionResult::True
580        );
581        assert_eq!(
582            validate_hhmm_range("202601010000+00", "0000", "2359"),
583            ConditionResult::True
584        );
585        assert_eq!(
586            validate_hhmm_range("202601012359+00", "0000", "2359"),
587            ConditionResult::True
588        );
589    }
590
591    #[test]
592    fn test_mmddhhmm_equals() {
593        assert_eq!(
594            validate_mmddhhmm_equals("202612312300+00", "12312300"),
595            ConditionResult::True
596        );
597        assert_eq!(
598            validate_mmddhhmm_equals("202601012200+00", "12312300"),
599            ConditionResult::False
600        );
601    }
602
603    #[test]
604    fn test_timezone_utc() {
605        assert_eq!(
606            validate_timezone_utc("202601012200+00"),
607            ConditionResult::True
608        );
609        assert_eq!(
610            validate_timezone_utc("202601012200+01"),
611            ConditionResult::False
612        );
613        assert_eq!(
614            validate_timezone_utc("202601012200"),
615            ConditionResult::False
616        );
617        // Format 304 carries seconds before the zone.
618        assert_eq!(
619            validate_timezone_utc("20260101220000+00"),
620            ConditionResult::True
621        );
622        assert_eq!(
623            validate_timezone_utc("20260101220000+01"),
624            ConditionResult::False
625        );
626    }
627
628    // --- Contact validation ---
629
630    #[test]
631    fn test_email() {
632        assert_eq!(validate_email("user@example.com"), ConditionResult::True);
633        assert_eq!(validate_email("nope"), ConditionResult::False);
634        assert_eq!(validate_email("has@but-no-dot"), ConditionResult::False);
635        assert_eq!(validate_email(""), ConditionResult::Unknown);
636    }
637
638    #[test]
639    fn test_phone() {
640        assert_eq!(validate_phone("+4930123456"), ConditionResult::True);
641        assert_eq!(validate_phone("030123456"), ConditionResult::False);
642        assert_eq!(validate_phone("+"), ConditionResult::False);
643        assert_eq!(validate_phone("+49 30 123"), ConditionResult::False); // spaces not allowed
644        assert_eq!(validate_phone(""), ConditionResult::Unknown);
645    }
646
647    // --- ID validation ---
648
649    #[test]
650    fn test_malo_id() {
651        // Valid MaLo-IDs — taken directly from the BO4E-dotnet reference test
652        // suite (TestBO4E/TestMaLoMeLoId.cs), which uses the BDEW MaLo-ID spec
653        // (no digit-sum on doubled products; see `validate_malo_id` docstring).
654        assert_eq!(validate_malo_id("51238696781"), ConditionResult::True);
655        assert_eq!(validate_malo_id("41373559241"), ConditionResult::True);
656        assert_eq!(validate_malo_id("56789012345"), ConditionResult::True);
657        assert_eq!(validate_malo_id("52935155442"), ConditionResult::True);
658
659        // Negative cases from the same reference tests.
660        assert_eq!(validate_malo_id("41373559240"), ConditionResult::False); // wrong check digit
661        assert_eq!(validate_malo_id("512386967890"), ConditionResult::False); // 12 digits
662        assert_eq!(validate_malo_id("1234567890"), ConditionResult::False); // too short
663        assert_eq!(validate_malo_id("abcdefghijk"), ConditionResult::False); // not digits
664    }
665
666    #[test]
667    fn test_zahlpunkt() {
668        let valid = "DE0001234567890123456789012345678";
669        assert_eq!(valid.len(), 33);
670        assert_eq!(validate_zahlpunkt(valid), ConditionResult::True);
671        assert_eq!(validate_zahlpunkt("tooshort"), ConditionResult::False);
672    }
673
674    // --- Artikelnummer pattern ---
675
676    #[test]
677    fn test_artikel_pattern() {
678        assert_eq!(
679            validate_artikel_pattern("1-23-4", &[1, 2, 1]),
680            ConditionResult::True
681        );
682        assert_eq!(
683            validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3]),
684            ConditionResult::True
685        );
686        assert_eq!(
687            validate_artikel_pattern("1-23-4-56", &[1, 2, 1, 3]),
688            ConditionResult::False
689        );
690        assert_eq!(
691            validate_artikel_pattern("1-AB-4", &[1, 2, 1]),
692            ConditionResult::False
693        );
694        assert_eq!(
695            validate_artikel_pattern("", &[1, 2, 1]),
696            ConditionResult::Unknown
697        );
698    }
699
700    // --- TR-ID / SR-ID validation ---
701
702    #[test]
703    fn test_tr_id() {
704        assert_eq!(validate_tr_id("ABC123"), ConditionResult::True);
705        assert_eq!(validate_tr_id("A"), ConditionResult::True);
706        assert_eq!(validate_tr_id(&"A".repeat(35)), ConditionResult::True);
707        assert_eq!(validate_tr_id(&"A".repeat(36)), ConditionResult::False);
708        assert_eq!(validate_tr_id("has spaces"), ConditionResult::False);
709        assert_eq!(validate_tr_id("has-dash"), ConditionResult::False);
710        assert_eq!(validate_tr_id(""), ConditionResult::Unknown);
711    }
712
713    #[test]
714    fn test_parse_obis() {
715        assert_eq!(parse_obis("1-1:1.8.0"), Some((1, 8)));
716        assert_eq!(parse_obis("1-1:2.29.0"), Some((2, 29)));
717        assert_eq!(parse_obis("1-0:1.8.0*255"), Some((1, 8))); // star suffix
718        assert_eq!(parse_obis(" 1-1:1.8.0 "), Some((1, 8))); // whitespace
719        assert_eq!(parse_obis("no-colon"), None);
720        assert_eq!(parse_obis("1-1:abc.8.0"), None);
721        assert_eq!(parse_obis(""), None);
722    }
723
724    #[test]
725    fn test_obis_wirkarbeit_kumuliert() {
726        // Active energy import/export, total or billing-period integral.
727        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.8.0"));
728        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.8.0"));
729        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.9.0"));
730        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.9.0"));
731        assert!(is_obis_wirkarbeit_kumuliert("1-0:1.8.0*255"));
732        // Reactive energy — not Wirkarbeit.
733        assert!(!is_obis_wirkarbeit_kumuliert("1-1:3.8.0"));
734        assert!(!is_obis_wirkarbeit_kumuliert("1-1:4.8.0"));
735        // 15-min partial value — not kumuliert.
736        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.29.0"));
737        // Actual value — not kumuliert.
738        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.5.0"));
739    }
740
741    #[test]
742    fn test_obis_wirkarbeit_quarter_hour() {
743        // Active energy 15-min Lastgang.
744        assert!(is_obis_wirkarbeit_quarter_hour("1-1:1.29.0"));
745        assert!(is_obis_wirkarbeit_quarter_hour("1-1:2.29.0"));
746        assert!(is_obis_wirkarbeit_quarter_hour("1-0:1.29.0*255"));
747        // Cumulated — not 1/4h.
748        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.8.0"));
749        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.9.0"));
750        // Reactive.
751        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:3.29.0"));
752    }
753
754    #[test]
755    fn test_x509_cert_body() {
756        // Valid: 600-char base64 body (well above 100-char floor).
757        let body = "A".repeat(600);
758        assert_eq!(validate_x509_cert_body(&body), ConditionResult::True);
759
760        // Valid with base64 padding and newlines (PEM-style wrapping).
761        let pem_body = "A".repeat(64) + "\n" + &"B".repeat(64) + "\n" + &"C".repeat(64);
762        assert_eq!(validate_x509_cert_body(&pem_body), ConditionResult::True);
763
764        // Valid with explicit PEM armoring — stripped before charset check.
765        let armored = format!(
766            "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
767            "A".repeat(200)
768        );
769        assert_eq!(validate_x509_cert_body(&armored), ConditionResult::True);
770
771        // Valid: base64 chars include +, /, =
772        let with_punct = "AB+/==".to_string() + &"A".repeat(200);
773        assert_eq!(validate_x509_cert_body(&with_punct), ConditionResult::True);
774
775        // Too short — not a plausible cert.
776        assert_eq!(validate_x509_cert_body("QUJD"), ConditionResult::False);
777
778        // Non-base64 characters.
779        let invalid_chars = "A".repeat(200) + "!@#";
780        assert_eq!(
781            validate_x509_cert_body(&invalid_chars),
782            ConditionResult::False
783        );
784
785        // Empty is Unknown (consistent with other format validators).
786        assert_eq!(validate_x509_cert_body(""), ConditionResult::Unknown);
787        assert_eq!(validate_x509_cert_body("   \n  "), ConditionResult::Unknown);
788    }
789
790    /// SR-IDs are "C" + ten alphanumerics, not MaLo-shaped: every SR-ID in
791    /// `example_market_communication_bo4e_transactions/` looks like this
792    /// (`LOC+Z19+C816417ST77`), and a MaLo-ID check rejected all of them.
793    #[test]
794    fn test_sr_id() {
795        for real in ["C816417ST77", "CZZ6M5DGSS5", "COPFWS2EX96"] {
796            assert_eq!(validate_sr_id(real), ConditionResult::True, "{real}");
797        }
798        assert_eq!(
799            validate_sr_id("51238696781"),
800            ConditionResult::False,
801            "a MaLo-ID"
802        );
803        assert_eq!(validate_sr_id("C12345"), ConditionResult::False);
804        assert_eq!(validate_sr_id(""), ConditionResult::False);
805    }
806
807    /// Netzlokations-IDs are "E" + ten alphanumerics (`LOC+Z18+E1688117482`,
808    /// `EU5UQAWW8D0` in the example transactions). The generated evaluators
809    /// checked them as MaLo-IDs, so every real one failed `[960]`.
810    #[test]
811    fn test_nelo_id() {
812        for real in ["E1688117482", "EU5UQAWW8D0", "EEW7TM7V906"] {
813            assert_eq!(validate_nelo_id(real), ConditionResult::True, "{real}");
814        }
815        assert_eq!(
816            validate_nelo_id("51238696781"),
817            ConditionResult::False,
818            "a MaLo-ID"
819        );
820        assert_eq!(validate_nelo_id("E123"), ConditionResult::False);
821        assert_eq!(validate_nelo_id("e1688117482"), ConditionResult::False);
822    }
823
824    // --- String validation ---
825
826    #[test]
827    fn test_exact_length() {
828        assert_eq!(
829            validate_exact_length("1234567890123456", 16),
830            ConditionResult::True
831        );
832        assert_eq!(validate_exact_length("123", 16), ConditionResult::False);
833        assert_eq!(validate_exact_length("", 16), ConditionResult::Unknown);
834    }
835
836    #[test]
837    fn test_all_digits() {
838        assert_eq!(validate_all_digits("12345"), ConditionResult::True);
839        assert_eq!(validate_all_digits("123a5"), ConditionResult::False);
840        assert_eq!(validate_all_digits(""), ConditionResult::Unknown);
841    }
842}