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 value against its format code ---
109
110/// Whether a DTM value (C507 DE2380) has the shape its format code (DE2379)
111/// gives it: `20250401` fits 102 `CCYYMMDD` but not 104 `MMWW-MMWW`, which
112/// is two month-and-week pairs (`04010402`). `None` for a format code not
113/// known here, which is not judged. Months, days, hours and minutes must be
114/// in range; a time zone (303/304) is a sign and two digits.
115pub fn fits_date_format(value: &str, format: &str) -> Option<bool> {
116    let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
117    let num = |s: &str| s.parse::<u32>().unwrap_or(u32::MAX);
118    let month = |s: &str| (1..=12).contains(&num(s));
119    let day = |s: &str| (1..=31).contains(&num(s));
120    let week = |s: &str| (1..=53).contains(&num(s));
121    let time = |s: &str| num(&s[..2]) <= 23 && num(&s[2..]) <= 59;
122    let date = |s: &str| digits(s) && s.len() == 8 && month(&s[4..6]) && day(&s[6..8]);
123    let zone =
124        |s: &str| s.is_empty() || (s.len() == 3 && matches!(&s[..1], "+" | "-") && digits(&s[1..]));
125    // Every shape is ASCII, and slicing below relies on it.
126    if !value.is_ascii() {
127        return fits_date_format("", format);
128    }
129    let fits = match format {
130        "102" => date(value),
131        "104" => {
132            digits(value)
133                && value.len() == 8
134                && month(&value[..2])
135                && week(&value[2..4])
136                && month(&value[4..6])
137                && week(&value[6..])
138        }
139        "106" => digits(value) && value.len() == 4 && month(&value[..2]) && day(&value[2..]),
140        "203" => value.len() == 12 && date(&value[..8]) && digits(&value[8..]) && time(&value[8..]),
141        "303" => {
142            value.len() >= 12
143                && date(&value[..8])
144                && digits(&value[8..12])
145                && time(&value[8..12])
146                && zone(&value[12..])
147        }
148        "304" => {
149            value.len() >= 14
150                && date(&value[..8])
151                && digits(&value[8..14])
152                && time(&value[8..12])
153                && num(&value[12..14]) <= 59
154                && zone(&value[14..])
155        }
156        "401" => digits(value) && value.len() == 4 && time(value),
157        "501" => digits(value) && value.len() == 8 && time(&value[..4]) && time(&value[4..]),
158        "602" => digits(value) && value.len() == 4,
159        "610" => digits(value) && value.len() == 6 && month(&value[4..]),
160        "802" | "803" | "804" => digits(value),
161        // ZZRB (UTILMD Kündigungsfrist): a number, its unit (Tag, Woche,
162        // Monat) and its reference point (Monats-, Quartals-, Halbjahres-,
163        // Jahresende, Termin, rollierend): `03MM`.
164        "Z01" => {
165            let n = value.len().saturating_sub(2);
166            (1..=2).contains(&n)
167                && digits(&value[..n])
168                && matches!(&value[n..n + 1], "T" | "W" | "M")
169                && matches!(&value[n + 1..], "M" | "Q" | "H" | "J" | "T" | "R")
170        }
171        _ => return None,
172    };
173    Some(fits)
174}
175
176// --- DTM time/timezone validation ---
177
178/// Validate that a DTM value's HHMM portion equals the expected value.
179///
180/// DTM format 303 is CCYYMMDDHHMM (12 chars) or CCYYMMDDHHMMZZZ (15 chars with timezone).
181/// Extracts characters at positions 8..12 (HHMM) for comparison.
182///
183/// Example: `validate_hhmm_equals("202601012200+00", "2200")` → True
184pub fn validate_hhmm_equals(dtm_value: &str, expected_hhmm: &str) -> ConditionResult {
185    if dtm_value.len() < 12 {
186        // Value too short — no HHMM component present, condition is not met
187        return ConditionResult::False;
188    }
189    ConditionResult::from(&dtm_value[8..12] == expected_hhmm)
190}
191
192/// Validate that a DTM value's HHMM portion is within a range (inclusive).
193///
194/// Example: `validate_hhmm_range("202601011530+00", "0000", "2359")` → True
195pub fn validate_hhmm_range(dtm_value: &str, min: &str, max: &str) -> ConditionResult {
196    if dtm_value.len() < 12 {
197        return ConditionResult::False;
198    }
199    let hhmm = &dtm_value[8..12];
200    ConditionResult::from(hhmm >= min && hhmm <= max)
201}
202
203/// Validate that a DTM value's MMDDHHMM portion equals the expected value.
204///
205/// Extracts characters at positions 4..12 for comparison.
206///
207/// Example: `validate_mmddhhmm_equals("202612312300+00", "12312300")` → True
208pub fn validate_mmddhhmm_equals(dtm_value: &str, expected: &str) -> ConditionResult {
209    if dtm_value.len() < 12 {
210        return ConditionResult::False;
211    }
212    ConditionResult::from(&dtm_value[4..12] == expected)
213}
214
215/// Validate that a DTM value's timezone portion ZZZ is "+00" (UTC).
216///
217/// The zone is the value's last three characters: format 303
218/// `CCYYMMDDHHMMZZZ` (15 chars) and 304 `CCYYMMDDHHMMSSZZZ` (17 chars) both
219/// end in it. Reading it at a fixed offset of 12 took 304's seconds for the
220/// zone and failed every 304 value.
221///
222/// Example: `validate_timezone_utc("202601012200+00")` → True
223/// Example: `validate_timezone_utc("20260101220000+00")` → True
224/// Example: `validate_timezone_utc("202601012200+01")` → False
225pub fn validate_timezone_utc(dtm_value: &str) -> ConditionResult {
226    if dtm_value.len() < 15 || !dtm_value.is_char_boundary(dtm_value.len() - 3) {
227        // Value too short — no timezone suffix present, condition is not met
228        return ConditionResult::False;
229    }
230    ConditionResult::from(&dtm_value[dtm_value.len() - 3..] == "+00")
231}
232
233// --- Contact format validation ---
234
235/// Validate email format: must contain both '@' and '.'.
236///
237/// Example: `validate_email("user@example.com")` → True
238pub fn validate_email(value: &str) -> ConditionResult {
239    if value.is_empty() {
240        return ConditionResult::Unknown;
241    }
242    ConditionResult::from(value.contains('@') && value.contains('.'))
243}
244
245/// Validate phone format: must start with '+' followed by only digits.
246///
247/// Example: `validate_phone("+4930123456")` → True
248/// Example: `validate_phone("030123456")` → False
249pub fn validate_phone(value: &str) -> ConditionResult {
250    if value.is_empty() {
251        return ConditionResult::Unknown;
252    }
253    if !value.starts_with('+') || value.len() < 2 {
254        return ConditionResult::from(false);
255    }
256    ConditionResult::from(value[1..].chars().all(|c| c.is_ascii_digit()))
257}
258
259// --- ID format validation ---
260
261/// Validate Marktlokations-ID (MaLo-ID): exactly 11 digits with BDEW check digit.
262///
263/// This is **not** standard Luhn — per the BDEW "Anwendungshilfe MaLo-ID v1.0" spec
264/// and the Hochfrequenz BO4E-dotnet reference implementation
265/// (`BO4E/BO/Marktlokation.cs::GetChecksum`), digits 1–10 are numbered left-to-right
266/// (1-based), digits at even positions are multiplied by 2 and **added as the full
267/// value** (no digit-sum, no mod-by-9 step), digits at odd positions are added as-is.
268/// Check digit = `(10 - (sum mod 10)) mod 10`.
269///
270/// Example: `"51238696781"` — odd (1,3,5,7,9): 5+2+8+9+7=31; even doubled
271/// (2,4,6,8,10): 2*(1+3+6+6+8)=48; sum=79; check = (10-79%10)%10 = 1 ✓.
272pub fn validate_malo_id(value: &str) -> ConditionResult {
273    if value.len() != 11 {
274        return ConditionResult::from(false);
275    }
276    if !value.chars().all(|c| c.is_ascii_digit()) {
277        return ConditionResult::from(false);
278    }
279    let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
280    let check = digits[10];
281    let mut sum = 0u32;
282    for (i, &d) in digits[..10].iter().enumerate() {
283        // 1-based position is i+1; even positions are doubled, odd positions as-is.
284        if (i + 1) % 2 == 0 {
285            sum += 2 * d;
286        } else {
287            sum += d;
288        }
289    }
290    let expected = (10 - (sum % 10)) % 10;
291    ConditionResult::from(check == expected)
292}
293
294/// Validate Transaktionsreferenz-ID (TR-ID): 1-35 alphanumeric characters.
295pub fn validate_tr_id(value: &str) -> ConditionResult {
296    if value.is_empty() {
297        return ConditionResult::Unknown;
298    }
299    ConditionResult::from(value.len() <= 35 && value.chars().all(|c| c.is_ascii_alphanumeric()))
300}
301
302/// A BDEW location ID of the lettered kind: `prefix` followed by ten ASCII
303/// uppercase letters or digits (11 characters).
304fn validate_prefixed_location_id(value: &str, prefix: char) -> ConditionResult {
305    let mut chars = value.chars();
306    ConditionResult::from(
307        value.len() == 11
308            && chars.next() == Some(prefix)
309            && chars.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase()),
310    )
311}
312
313/// Validate Steuerbare-Ressource-ID (SR-ID): "C" + ten alphanumerics
314/// (`C816417ST77`).
315pub fn validate_sr_id(value: &str) -> ConditionResult {
316    validate_prefixed_location_id(value, 'C')
317}
318
319/// Validate the ID of a Technische Ressource (TR-ID): "D" + ten
320/// alphanumerics (`D1234567890`). Not the Transaktionsreferenz of
321/// [`validate_tr_id`].
322pub fn validate_technische_ressource_id(value: &str) -> ConditionResult {
323    validate_prefixed_location_id(value, 'D')
324}
325
326/// Validate Netzlokations-ID: "E" + ten alphanumerics (`E1688117482`).
327pub fn validate_nelo_id(value: &str) -> ConditionResult {
328    validate_prefixed_location_id(value, 'E')
329}
330
331// --- OBIS code pattern matching ---
332
333/// Parse an OBIS code of the form `A-B:C.D.E[*F]` and return `(C, D)`.
334///
335/// OBIS structure (IEC 62056-61, BDEW/DVGW usage):
336/// - `A` — medium (1 = electricity, 7 = gas, 6 = heat, …)
337/// - `B` — channel
338/// - `C` — measurement type (1 = +A active energy import, 2 = -A active energy
339///   export, 3 = +R reactive import, 4 = -R reactive export, …)
340/// - `D` — measurement mode; for electricity the BDEW "Codeliste der
341///   OBIS-Kennzahlen und Medien" (2.5c) uses 6 = Maximum, 8 = Zeitintegral 1
342///   (Zählerstände), 9 = Zeitintegral 2 (Vorschübe, "Energiemenge für einen
343///   beliebigen Zeitraum") and 29 = Zeitintegral 5 (Lastgang, "Energiemengen
344///   für Zeitintervalle von äquidistanter Dauer")
345/// - `E` — rate/tariff
346/// - `F` — optional billing period marker
347///
348/// Returns `None` when the value doesn't fit the shape. Whitespace-tolerant.
349pub fn parse_obis(value: &str) -> Option<(u32, u32)> {
350    let value = value.trim();
351    // Split at the colon between `A-B` and `C.D.E`.
352    let (_ab, cde) = value.split_once(':')?;
353    // Drop optional `*F` suffix.
354    let cde = cde.split('*').next()?;
355    let mut parts = cde.split('.');
356    let c = parts.next()?.parse::<u32>().ok()?;
357    let d = parts.next()?.parse::<u32>().ok()?;
358    Some((c, d))
359}
360
361/// The tariff `E` of an OBIS code `A-B:C.D.E[*F]`; `None` when the value
362/// doesn't fit the shape. UTILMD Strom `[391]` names it: "OBIS mit zugeordneter
363/// Zählzeit (Tarif: e = 1 .. 62)".
364pub fn obis_tariff(value: &str) -> Option<u32> {
365    let (_ab, cde) = value.trim().split_once(':')?;
366    let cde = cde.split('*').next()?;
367    let mut parts = cde.split('.');
368    parts.next()?.parse::<u32>().ok()?;
369    parts.next()?.parse::<u32>().ok()?;
370    parts.next()?.parse::<u32>().ok()
371}
372
373/// Is the OBIS code "Wirkarbeit kumuliert" — active energy as a cumulated
374/// time integral (total or per billing period)?
375///
376/// Matches BDEW convention: `C ∈ {1, 2}` (active energy import/export) and
377/// `D ∈ {8, 9}` (total time integral / billing-period integral).
378pub fn is_obis_wirkarbeit_kumuliert(value: &str) -> bool {
379    matches!(parse_obis(value), Some((1 | 2, 8 | 9)))
380}
381
382/// Is the OBIS code "Wirkarbeit 1/4 Stunde" — active energy 15-minute
383/// interval (Lastgang) measurement?
384///
385/// Matches the BDEW code list's quarter-hour Lastgang: `C ∈ {1, 2}` (Wirkarbeit
386/// Bezug / Lieferung) and `D = 29` (Zeitintegral 5, Lastgang; its example
387/// "Vorschub (1/4 Std. Lastgang) elektrische Wirkarbeit" is `1-b:1.29.0`).
388pub fn is_obis_wirkarbeit_quarter_hour(value: &str) -> bool {
389    matches!(parse_obis(value), Some((1 | 2, 29)))
390}
391
392/// Is the electricity OBIS code an "Energiemenge" — a Vorschub (`D = 9`) or a
393/// Lastgang (`D = 29`)? The BDEW code list tells "Messwerte (Energiemengen,
394/// Zählerstände)" apart: a Zählerstand (`D = 8`) is not one.
395pub fn is_obis_energiemenge(value: &str) -> bool {
396    value.trim().starts_with("1-") && matches!(parse_obis(value), Some((_, 9 | 29)))
397}
398
399/// Is the electricity OBIS code a "Zählerstand" (`D = 8`, Zeitintegral 1)?
400pub fn is_obis_zaehlerstand(value: &str) -> bool {
401    value.trim().starts_with("1-") && matches!(parse_obis(value), Some((_, 8)))
402}
403
404/// Is the OBIS code a Wirkleistung maximum (`C ∈ {1, 2}`, `D = 6`), the code
405/// list's "Wirkleistung Bezug (+) / Lieferung (-) Maximum" (`1-b:1.6.0`,
406/// `1-b:2.6.0`) — the highest quarter hour's energy of a period?
407pub fn is_obis_wirkleistung_maximum(value: &str) -> bool {
408    value.trim().starts_with("1-") && matches!(parse_obis(value), Some((1 | 2, 6)))
409}
410
411/// Is the electricity OBIS code a Blindarbeit (`C` 3 to 8, reactive energy
412/// and its quadrants) as a Zählerstand, Vorschub or Lastgang (`D ∈ {8, 9, 29}`)?
413pub fn is_obis_blindarbeit(value: &str) -> bool {
414    value.trim().starts_with("1-") && matches!(parse_obis(value), Some((3..=8, 8 | 9 | 29)))
415}
416
417/// Whether the OBIS code names a Zählzeit: tariff `e = 1 … 62` ("OBIS mit
418/// zugeordneter Zählzeit"); `0` is "tariflos".
419pub fn obis_has_zaehlzeit(value: &str) -> bool {
420    obis_tariff(value).is_some_and(|e| (1..=62).contains(&e))
421}
422
423/// Is the OBIS code an energy register, `1-b:c.8.e` with `c` 1 to 8?
424///
425/// UTILMD Strom `[273]` lists exactly these: "1-b?:1.8.e / 1-b?:2.8.e / … /
426/// 1-b?:8.8.e / 1-b?:1.8.63" (1.8.63 is a 1.8.e). A load profile
427/// (`1-1:1.29.0`) or a register of another medium (`7-…`) is not one.
428pub fn is_energy_register_obis(value: &str) -> bool {
429    value.trim().starts_with("1-") && matches!(parse_obis(value), Some((1..=8, 8)))
430}
431
432/// Validate an X.509 certificate body per BSI TR-03109-4.
433///
434/// In EDIFACT messages (e.g. ORDERS/REQOTE for Smartmeter gateway config) the
435/// certificate body is transmitted as base64-encoded DER — the PEM armoring
436/// (`-----BEGIN CERTIFICATE-----` / `-----END CERTIFICATE-----` lines) may or
437/// may not be present depending on the profile.
438///
439/// This validator performs a structural check only:
440/// - non-empty
441/// - composed exclusively of base64 characters (`A-Za-z0-9+/=`) plus
442///   whitespace (`\n\r\t` and space — PEM lines wrap at 64 chars)
443/// - decoded length plausible for a DER-encoded X.509 certificate (we allow a
444///   loose lower bound — a minimal self-signed cert is ~400 bytes, so the
445///   base64 form is ≥ ~540 chars; we use 100 as a sanity floor)
446///
447/// Cryptographic validation (signature, CA chain, TR-03109 certificate
448/// extensions) is out of scope for static AHB validation — those require a
449/// trust store and runtime context.
450pub fn validate_x509_cert_body(value: &str) -> ConditionResult {
451    let trimmed = value.trim();
452    if trimmed.is_empty() {
453        return ConditionResult::Unknown;
454    }
455    // Strip optional PEM armoring so the charset check operates on the
456    // encoded body alone.
457    let body = trimmed
458        .trim_start_matches("-----BEGIN CERTIFICATE-----")
459        .trim_end_matches("-----END CERTIFICATE-----")
460        .trim();
461    let chars_ok = body.chars().all(|c| {
462        c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '\n' | '\r' | '\t' | ' ')
463    });
464    // Minimum plausible length for a base64-encoded X.509 body. Shorter
465    // values are almost certainly not a real certificate.
466    let len_ok = body.chars().filter(|c| !c.is_whitespace()).count() >= 100;
467    ConditionResult::from(chars_ok && len_ok)
468}
469
470/// Validate Zahlpunktbezeichnung: exactly 33 alphanumeric characters.
471pub fn validate_zahlpunkt(value: &str) -> ConditionResult {
472    if value.len() != 33 {
473        return ConditionResult::from(false);
474    }
475    ConditionResult::from(value.chars().all(|c| c.is_ascii_alphanumeric()))
476}
477
478/// Validate either MaLo-ID or Zahlpunktbezeichnung format.
479pub fn validate_malo_or_zahlpunkt(value: &str) -> ConditionResult {
480    if value.len() == 11 && validate_malo_id(value).is_true() {
481        return ConditionResult::True;
482    }
483    if value.len() == 33 && validate_zahlpunkt(value).is_true() {
484        return ConditionResult::True;
485    }
486    ConditionResult::False
487}
488
489// --- Artikelnummer pattern validation ---
490
491/// Validate a dash-separated digit pattern like "n1-n2-n1-n3".
492///
493/// `segment_lengths` defines expected digit counts per dash-separated segment.
494///
495/// Example: `validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3])` → True
496/// Example: `validate_artikel_pattern("1-23-4", &[1, 2, 1])` → True
497pub fn validate_artikel_pattern(value: &str, segment_lengths: &[usize]) -> ConditionResult {
498    if value.is_empty() {
499        return ConditionResult::Unknown;
500    }
501    let parts: Vec<&str> = value.split('-').collect();
502    if parts.len() != segment_lengths.len() {
503        return ConditionResult::from(false);
504    }
505    let valid = parts
506        .iter()
507        .zip(segment_lengths.iter())
508        .all(|(part, &expected_len)| {
509            part.len() == expected_len && part.chars().all(|c| c.is_ascii_digit())
510        });
511    ConditionResult::from(valid)
512}
513
514// --- General string validation ---
515
516/// Validate exact character length.
517pub fn validate_exact_length(value: &str, expected: usize) -> ConditionResult {
518    if value.is_empty() {
519        return ConditionResult::Unknown;
520    }
521    ConditionResult::from(value.len() == expected)
522}
523
524/// Validate maximum character length.
525pub fn validate_max_length(value: &str, max: usize) -> ConditionResult {
526    if value.is_empty() {
527        return ConditionResult::Unknown;
528    }
529    ConditionResult::from(value.len() <= max)
530}
531
532/// Validate that a string contains only digits (positive integer check).
533pub fn validate_all_digits(value: &str) -> ConditionResult {
534    if value.is_empty() {
535        return ConditionResult::Unknown;
536    }
537    ConditionResult::from(value.chars().all(|c| c.is_ascii_digit()))
538}
539
540/// Current UTC date+time as a CCYYMMDDHHMM string (12 chars).
541pub fn utc_now_ccyymmddhhmm() -> String {
542    chrono::Utc::now().format("%Y%m%d%H%M").to_string()
543}
544
545/// A DTM format 303 value (`CCYYMMDDHHMMZZZ`, `202610312300+00`) as German
546/// local time: CET, or CEST from the last Sunday of March to the last Sunday of
547/// October (01:00 UTC each). `None` if it is no such value.
548///
549/// "0 Uhr gem. deutscher Zeit" (UTILMD [87]) is `…2300+00` in winter and
550/// `…2200+00` in summer; the digits alone never show midnight.
551pub fn german_local_time(value: &str) -> Option<chrono::NaiveDateTime> {
552    use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, Weekday};
553    let value = value.replace('?', "");
554    let stamp = NaiveDateTime::parse_from_str(value.get(..12)?, "%Y%m%d%H%M").ok()?;
555    let offset: i64 = match value.get(12..) {
556        None | Some("") => 0,
557        Some(zone) => zone.parse().ok()?,
558    };
559    let utc = stamp - Duration::hours(offset);
560    let switch = |month: u32| {
561        let mut day = NaiveDate::from_ymd_opt(utc.year(), month, 31)?;
562        while day.weekday() != Weekday::Sun {
563            day = day.pred_opt()?;
564        }
565        day.and_hms_opt(1, 0, 0)
566    };
567    let summer = utc >= switch(3)? && utc < switch(10)?;
568    Some(utc + Duration::hours(if summer { 2 } else { 1 }))
569}
570
571#[cfg(test)]
572mod tests {
573
574    #[test]
575    fn date_values_follow_their_format_code() {
576        assert_eq!(fits_date_format("20250401", "102"), Some(true));
577        assert_eq!(fits_date_format("20250401", "104"), Some(false));
578        assert_eq!(fits_date_format("04010402", "104"), Some(true));
579        assert_eq!(fits_date_format("20250401", "106"), Some(false));
580        assert_eq!(fits_date_format("0401", "106"), Some(true));
581        assert_eq!(fits_date_format("202503312200+00", "303"), Some(true));
582        assert_eq!(fits_date_format("202503312200", "303"), Some(true));
583        assert_eq!(fits_date_format("20250331", "303"), Some(false));
584        assert_eq!(fits_date_format("20250331220000+00", "304"), Some(true));
585        assert_eq!(fits_date_format("2025", "602"), Some(true));
586        assert_eq!(fits_date_format("202513", "610"), Some(false));
587        assert_eq!(fits_date_format("12", "802"), Some(true));
588        assert_eq!(fits_date_format("06002200", "501"), Some(true));
589        assert_eq!(fits_date_format("03MM", "Z01"), Some(true));
590        assert_eq!(fits_date_format("20250401", "Z01"), Some(false));
591        assert_eq!(fits_date_format("x", "999"), None);
592        assert_eq!(fits_date_format("ä", "999"), None);
593        assert_eq!(fits_date_format("2025040ä", "102"), Some(false));
594    }
595
596    /// The classes the BDEW "Codeliste der OBIS-Kennzahlen und Medien" 2.5c
597    /// defines, on codes it lists.
598    #[test]
599    fn obis_classes_follow_the_code_list() {
600        use super::*;
601        // Zählerstand, Vorschub, Lastgang of Wirkarbeit Bezug.
602        assert!(is_obis_zaehlerstand("1-1:1.8.0") && !is_obis_energiemenge("1-1:1.8.0"));
603        assert!(is_obis_energiemenge("1-1:1.9.0") && !is_obis_zaehlerstand("1-1:1.9.0"));
604        assert!(
605            is_obis_energiemenge("1-1:1.29.0") && is_obis_wirkarbeit_quarter_hour("1-1:1.29.0")
606        );
607        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.9.0"));
608        // Wirkleistung Maximum, Blindarbeit.
609        assert!(is_obis_wirkleistung_maximum("1-1:2.6.0"));
610        assert!(!is_obis_wirkleistung_maximum("1-1:3.6.0"));
611        assert!(is_obis_blindarbeit("1-1:3.9.0") && is_obis_blindarbeit("1-1:4.29.0"));
612        assert!(!is_obis_blindarbeit("1-1:1.8.0"));
613        // Tarif e = 1 … 62 names a Zählzeit, 0 is tariflos.
614        assert!(obis_has_zaehlzeit("1-1:1.9.1") && obis_has_zaehlzeit("1-1:1.8.62"));
615        assert!(!obis_has_zaehlzeit("1-1:1.9.0") && !obis_has_zaehlzeit("1-1:1.8.63"));
616        // Gas is not electricity.
617        assert!(!is_obis_energiemenge("7-0:3.9.0"));
618    }
619
620    #[test]
621    fn the_tariff_is_the_e_of_c_d_e() {
622        use super::obis_tariff;
623        assert_eq!(obis_tariff("1-1:1.8.1"), Some(1));
624        assert_eq!(obis_tariff("1-0:2.8.62*255"), Some(62));
625        assert_eq!(obis_tariff("1-0:1.8.0"), Some(0));
626        assert_eq!(obis_tariff("1-0:1.8"), None);
627        assert_eq!(obis_tariff("x"), None);
628    }
629
630    #[test]
631    fn energy_registers_are_c_8_e_with_c_one_to_eight() {
632        use super::is_energy_register_obis;
633        for obis in [
634            "1-1:1.8.0",
635            "1-0:2.8.1",
636            "1-1:8.8.0",
637            "1-1:1.8.63",
638            "1-65:3.8.2",
639        ] {
640            assert!(is_energy_register_obis(obis), "{obis}");
641        }
642        for obis in [
643            "1-1:1.29.0",
644            "1-1:2.29.0",
645            "1-1:9.8.0",
646            "7-0:3.0.0",
647            "1-1:1.9.0",
648            "x",
649        ] {
650            assert!(!is_energy_register_obis(obis), "{obis}");
651        }
652    }
653
654    use super::*;
655
656    // --- Decimal places ---
657
658    #[test]
659    fn test_max_decimal_places() {
660        assert_eq!(
661            validate_max_decimal_places("123.45", 2),
662            ConditionResult::True
663        );
664        assert_eq!(
665            validate_max_decimal_places("123.456", 2),
666            ConditionResult::False
667        );
668        assert_eq!(validate_max_decimal_places("123", 2), ConditionResult::True);
669        assert_eq!(validate_max_decimal_places("0.1", 3), ConditionResult::True);
670        assert_eq!(validate_max_decimal_places("", 2), ConditionResult::Unknown);
671    }
672
673    #[test]
674    fn test_no_decimal_places() {
675        assert_eq!(validate_max_decimal_places("100", 0), ConditionResult::True);
676        assert_eq!(
677            validate_max_decimal_places("100.5", 0),
678            ConditionResult::False
679        );
680    }
681
682    #[test]
683    fn test_max_integer_digits() {
684        assert_eq!(
685            validate_max_integer_digits("1234", 4),
686            ConditionResult::True
687        );
688        assert_eq!(
689            validate_max_integer_digits("12345", 4),
690            ConditionResult::False
691        );
692        assert_eq!(
693            validate_max_integer_digits("-123.45", 4),
694            ConditionResult::True
695        );
696        assert_eq!(validate_max_integer_digits("", 4), ConditionResult::Unknown);
697    }
698
699    // --- Numeric range ---
700
701    #[test]
702    fn test_validate_numeric() {
703        assert_eq!(validate_numeric("5.0", ">=", 0.0), ConditionResult::True);
704        assert_eq!(validate_numeric("-1.0", ">=", 0.0), ConditionResult::False);
705        assert_eq!(validate_numeric("1", "==", 1.0), ConditionResult::True);
706        assert_eq!(validate_numeric("2", "==", 1.0), ConditionResult::False);
707        assert_eq!(validate_numeric("0", ">", 0.0), ConditionResult::False);
708        assert_eq!(validate_numeric("1", ">", 0.0), ConditionResult::True);
709        // Not a number is no "möglicher Wert" (mako-twin: RFF+Z60
710        // `O5D2DXBG1A` passed `[914]` "> 0").
711        assert_eq!(validate_numeric("abc", ">=", 0.0), ConditionResult::False);
712        assert_eq!(
713            validate_numeric("O5D2DXBG1A", ">", 0.0),
714            ConditionResult::False
715        );
716        assert_eq!(validate_numeric("NaN", ">", 0.0), ConditionResult::False);
717        assert_eq!(validate_numeric("inf", ">", 0.0), ConditionResult::False);
718        assert_eq!(validate_numeric("1e3", ">", 0.0), ConditionResult::False);
719        // A comma is a decimal mark the UNA may set.
720        assert_eq!(validate_numeric("0,5", ">", 0.0), ConditionResult::True);
721        assert_eq!(validate_numeric("-2", "<=", 0.0), ConditionResult::True);
722        assert_eq!(validate_numeric("", ">", 0.0), ConditionResult::Unknown);
723    }
724
725    // --- DTM validation ---
726
727    #[test]
728    fn test_hhmm_equals() {
729        assert_eq!(
730            validate_hhmm_equals("202601012200+00", "2200"),
731            ConditionResult::True
732        );
733        assert_eq!(
734            validate_hhmm_equals("202601012300+00", "2200"),
735            ConditionResult::False
736        );
737        assert_eq!(
738            validate_hhmm_equals("short", "2200"),
739            ConditionResult::False
740        );
741    }
742
743    #[test]
744    fn test_hhmm_range() {
745        assert_eq!(
746            validate_hhmm_range("202601011530+00", "0000", "2359"),
747            ConditionResult::True
748        );
749        assert_eq!(
750            validate_hhmm_range("202601010000+00", "0000", "2359"),
751            ConditionResult::True
752        );
753        assert_eq!(
754            validate_hhmm_range("202601012359+00", "0000", "2359"),
755            ConditionResult::True
756        );
757    }
758
759    #[test]
760    fn test_mmddhhmm_equals() {
761        assert_eq!(
762            validate_mmddhhmm_equals("202612312300+00", "12312300"),
763            ConditionResult::True
764        );
765        assert_eq!(
766            validate_mmddhhmm_equals("202601012200+00", "12312300"),
767            ConditionResult::False
768        );
769    }
770
771    #[test]
772    fn test_timezone_utc() {
773        assert_eq!(
774            validate_timezone_utc("202601012200+00"),
775            ConditionResult::True
776        );
777        assert_eq!(
778            validate_timezone_utc("202601012200+01"),
779            ConditionResult::False
780        );
781        assert_eq!(
782            validate_timezone_utc("202601012200"),
783            ConditionResult::False
784        );
785        // Format 304 carries seconds before the zone.
786        assert_eq!(
787            validate_timezone_utc("20260101220000+00"),
788            ConditionResult::True
789        );
790        assert_eq!(
791            validate_timezone_utc("20260101220000+01"),
792            ConditionResult::False
793        );
794    }
795
796    // --- Contact validation ---
797
798    #[test]
799    fn test_email() {
800        assert_eq!(validate_email("user@example.com"), ConditionResult::True);
801        assert_eq!(validate_email("nope"), ConditionResult::False);
802        assert_eq!(validate_email("has@but-no-dot"), ConditionResult::False);
803        assert_eq!(validate_email(""), ConditionResult::Unknown);
804    }
805
806    #[test]
807    fn test_phone() {
808        assert_eq!(validate_phone("+4930123456"), ConditionResult::True);
809        assert_eq!(validate_phone("030123456"), ConditionResult::False);
810        assert_eq!(validate_phone("+"), ConditionResult::False);
811        assert_eq!(validate_phone("+49 30 123"), ConditionResult::False); // spaces not allowed
812        assert_eq!(validate_phone(""), ConditionResult::Unknown);
813    }
814
815    // --- ID validation ---
816
817    #[test]
818    fn test_malo_id() {
819        // Valid MaLo-IDs — taken directly from the BO4E-dotnet reference test
820        // suite (TestBO4E/TestMaLoMeLoId.cs), which uses the BDEW MaLo-ID spec
821        // (no digit-sum on doubled products; see `validate_malo_id` docstring).
822        assert_eq!(validate_malo_id("51238696781"), ConditionResult::True);
823        assert_eq!(validate_malo_id("41373559241"), ConditionResult::True);
824        assert_eq!(validate_malo_id("56789012345"), ConditionResult::True);
825        assert_eq!(validate_malo_id("52935155442"), ConditionResult::True);
826
827        // Negative cases from the same reference tests.
828        assert_eq!(validate_malo_id("41373559240"), ConditionResult::False); // wrong check digit
829        assert_eq!(validate_malo_id("512386967890"), ConditionResult::False); // 12 digits
830        assert_eq!(validate_malo_id("1234567890"), ConditionResult::False); // too short
831        assert_eq!(validate_malo_id("abcdefghijk"), ConditionResult::False); // not digits
832    }
833
834    #[test]
835    fn test_zahlpunkt() {
836        let valid = "DE0001234567890123456789012345678";
837        assert_eq!(valid.len(), 33);
838        assert_eq!(validate_zahlpunkt(valid), ConditionResult::True);
839        assert_eq!(validate_zahlpunkt("tooshort"), ConditionResult::False);
840    }
841
842    // --- Artikelnummer pattern ---
843
844    #[test]
845    fn test_artikel_pattern() {
846        assert_eq!(
847            validate_artikel_pattern("1-23-4", &[1, 2, 1]),
848            ConditionResult::True
849        );
850        assert_eq!(
851            validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3]),
852            ConditionResult::True
853        );
854        assert_eq!(
855            validate_artikel_pattern("1-23-4-56", &[1, 2, 1, 3]),
856            ConditionResult::False
857        );
858        assert_eq!(
859            validate_artikel_pattern("1-AB-4", &[1, 2, 1]),
860            ConditionResult::False
861        );
862        assert_eq!(
863            validate_artikel_pattern("", &[1, 2, 1]),
864            ConditionResult::Unknown
865        );
866    }
867
868    // --- TR-ID / SR-ID validation ---
869
870    #[test]
871    fn test_tr_id() {
872        assert_eq!(validate_tr_id("ABC123"), ConditionResult::True);
873        assert_eq!(validate_tr_id("A"), ConditionResult::True);
874        assert_eq!(validate_tr_id(&"A".repeat(35)), ConditionResult::True);
875        assert_eq!(validate_tr_id(&"A".repeat(36)), ConditionResult::False);
876        assert_eq!(validate_tr_id("has spaces"), ConditionResult::False);
877        assert_eq!(validate_tr_id("has-dash"), ConditionResult::False);
878        assert_eq!(validate_tr_id(""), ConditionResult::Unknown);
879    }
880
881    #[test]
882    fn test_parse_obis() {
883        assert_eq!(parse_obis("1-1:1.8.0"), Some((1, 8)));
884        assert_eq!(parse_obis("1-1:2.29.0"), Some((2, 29)));
885        assert_eq!(parse_obis("1-0:1.8.0*255"), Some((1, 8))); // star suffix
886        assert_eq!(parse_obis(" 1-1:1.8.0 "), Some((1, 8))); // whitespace
887        assert_eq!(parse_obis("no-colon"), None);
888        assert_eq!(parse_obis("1-1:abc.8.0"), None);
889        assert_eq!(parse_obis(""), None);
890    }
891
892    #[test]
893    fn test_obis_wirkarbeit_kumuliert() {
894        // Active energy import/export, total or billing-period integral.
895        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.8.0"));
896        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.8.0"));
897        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.9.0"));
898        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.9.0"));
899        assert!(is_obis_wirkarbeit_kumuliert("1-0:1.8.0*255"));
900        // Reactive energy — not Wirkarbeit.
901        assert!(!is_obis_wirkarbeit_kumuliert("1-1:3.8.0"));
902        assert!(!is_obis_wirkarbeit_kumuliert("1-1:4.8.0"));
903        // 15-min partial value — not kumuliert.
904        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.29.0"));
905        // Actual value — not kumuliert.
906        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.5.0"));
907    }
908
909    #[test]
910    fn test_obis_wirkarbeit_quarter_hour() {
911        // Active energy 15-min Lastgang.
912        assert!(is_obis_wirkarbeit_quarter_hour("1-1:1.29.0"));
913        assert!(is_obis_wirkarbeit_quarter_hour("1-1:2.29.0"));
914        assert!(is_obis_wirkarbeit_quarter_hour("1-0:1.29.0*255"));
915        // Cumulated — not 1/4h.
916        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.8.0"));
917        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.9.0"));
918        // Reactive.
919        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:3.29.0"));
920    }
921
922    #[test]
923    fn test_x509_cert_body() {
924        // Valid: 600-char base64 body (well above 100-char floor).
925        let body = "A".repeat(600);
926        assert_eq!(validate_x509_cert_body(&body), ConditionResult::True);
927
928        // Valid with base64 padding and newlines (PEM-style wrapping).
929        let pem_body = "A".repeat(64) + "\n" + &"B".repeat(64) + "\n" + &"C".repeat(64);
930        assert_eq!(validate_x509_cert_body(&pem_body), ConditionResult::True);
931
932        // Valid with explicit PEM armoring — stripped before charset check.
933        let armored = format!(
934            "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
935            "A".repeat(200)
936        );
937        assert_eq!(validate_x509_cert_body(&armored), ConditionResult::True);
938
939        // Valid: base64 chars include +, /, =
940        let with_punct = "AB+/==".to_string() + &"A".repeat(200);
941        assert_eq!(validate_x509_cert_body(&with_punct), ConditionResult::True);
942
943        // Too short — not a plausible cert.
944        assert_eq!(validate_x509_cert_body("QUJD"), ConditionResult::False);
945
946        // Non-base64 characters.
947        let invalid_chars = "A".repeat(200) + "!@#";
948        assert_eq!(
949            validate_x509_cert_body(&invalid_chars),
950            ConditionResult::False
951        );
952
953        // Empty is Unknown (consistent with other format validators).
954        assert_eq!(validate_x509_cert_body(""), ConditionResult::Unknown);
955        assert_eq!(validate_x509_cert_body("   \n  "), ConditionResult::Unknown);
956    }
957
958    /// SR-IDs are "C" + ten alphanumerics, not MaLo-shaped: every SR-ID in
959    /// `example_market_communication_bo4e_transactions/` looks like this
960    /// (`LOC+Z19+C816417ST77`), and a MaLo-ID check rejected all of them.
961    #[test]
962    fn test_sr_id() {
963        for real in ["C816417ST77", "CZZ6M5DGSS5", "COPFWS2EX96"] {
964            assert_eq!(validate_sr_id(real), ConditionResult::True, "{real}");
965        }
966        assert_eq!(
967            validate_sr_id("51238696781"),
968            ConditionResult::False,
969            "a MaLo-ID"
970        );
971        assert_eq!(validate_sr_id("C12345"), ConditionResult::False);
972        assert_eq!(validate_sr_id(""), ConditionResult::False);
973    }
974
975    /// Netzlokations-IDs are "E" + ten alphanumerics (`LOC+Z18+E1688117482`,
976    /// `EU5UQAWW8D0` in the example transactions). The generated evaluators
977    /// checked them as MaLo-IDs, so every real one failed `[960]`.
978    #[test]
979    fn test_nelo_id() {
980        for real in ["E1688117482", "EU5UQAWW8D0", "EEW7TM7V906"] {
981            assert_eq!(validate_nelo_id(real), ConditionResult::True, "{real}");
982        }
983        assert_eq!(
984            validate_nelo_id("51238696781"),
985            ConditionResult::False,
986            "a MaLo-ID"
987        );
988        assert_eq!(validate_nelo_id("E123"), ConditionResult::False);
989        assert_eq!(validate_nelo_id("e1688117482"), ConditionResult::False);
990    }
991
992    // --- String validation ---
993
994    #[test]
995    fn test_exact_length() {
996        assert_eq!(
997            validate_exact_length("1234567890123456", 16),
998            ConditionResult::True
999        );
1000        assert_eq!(validate_exact_length("123", 16), ConditionResult::False);
1001        assert_eq!(validate_exact_length("", 16), ConditionResult::Unknown);
1002    }
1003
1004    #[test]
1005    fn test_all_digits() {
1006        assert_eq!(validate_all_digits("12345"), ConditionResult::True);
1007        assert_eq!(validate_all_digits("123a5"), ConditionResult::False);
1008        assert_eq!(validate_all_digits(""), ConditionResult::Unknown);
1009    }
1010}