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::{is_mesz_utc, is_mez_utc};
11
12// --- Decimal/digit place validation ---
13
14/// Validate that a numeric string has at most `max` decimal places.
15///
16/// Returns `True` if the value has <= max decimal places (or no decimal point),
17/// `False` if it has more, `Unknown` if the value is empty.
18///
19/// Example: `validate_max_decimal_places("123.45", 2)` → True
20/// Example: `validate_max_decimal_places("123.456", 2)` → False
21/// Example: `validate_max_decimal_places("123", 2)` → True (no decimal → 0 places)
22pub fn validate_max_decimal_places(value: &str, max: usize) -> ConditionResult {
23    if value.is_empty() {
24        return ConditionResult::Unknown;
25    }
26    let decimal_places = match value.find('.') {
27        Some(pos) => value.len() - pos - 1,
28        None => 0,
29    };
30    ConditionResult::from(decimal_places <= max)
31}
32
33/// Validate that a numeric string has at most `max` integer digits (before decimal point).
34///
35/// Ignores leading minus sign.
36pub fn validate_max_integer_digits(value: &str, max: usize) -> ConditionResult {
37    if value.is_empty() {
38        return ConditionResult::Unknown;
39    }
40    let s = value.strip_prefix('-').unwrap_or(value);
41    let integer_part = match s.find('.') {
42        Some(pos) => &s[..pos],
43        None => s,
44    };
45    ConditionResult::from(integer_part.len() <= max)
46}
47
48// --- Numeric range validation ---
49
50/// Validate a numeric value against a comparison.
51///
52/// `op` is one of: "==", "!=", ">", ">=", "<", "<="
53/// Returns `Unknown` if the value cannot be parsed as a number.
54///
55/// Example: `validate_numeric(value, ">=", 0.0)` for "Wert >= 0"
56/// Example: `validate_numeric(value, "==", 1.0)` for "Wert = 1"
57pub fn validate_numeric(value: &str, op: &str, threshold: f64) -> ConditionResult {
58    let parsed = match value.parse::<f64>() {
59        Ok(v) => v,
60        Err(_) => return ConditionResult::Unknown,
61    };
62    let result = match op {
63        "==" => (parsed - threshold).abs() < f64::EPSILON,
64        "!=" => (parsed - threshold).abs() >= f64::EPSILON,
65        ">" => parsed > threshold,
66        ">=" => parsed >= threshold,
67        "<" => parsed < threshold,
68        "<=" => parsed <= threshold,
69        _ => return ConditionResult::Unknown,
70    };
71    ConditionResult::from(result)
72}
73
74// --- DTM time/timezone validation ---
75
76/// Validate that a DTM value's HHMM portion equals the expected value.
77///
78/// DTM format 303 is CCYYMMDDHHMM (12 chars) or CCYYMMDDHHMMZZZ (15 chars with timezone).
79/// Extracts characters at positions 8..12 (HHMM) for comparison.
80///
81/// Example: `validate_hhmm_equals("202601012200+00", "2200")` → True
82pub fn validate_hhmm_equals(dtm_value: &str, expected_hhmm: &str) -> ConditionResult {
83    if dtm_value.len() < 12 {
84        // Value too short — no HHMM component present, condition is not met
85        return ConditionResult::False;
86    }
87    ConditionResult::from(&dtm_value[8..12] == expected_hhmm)
88}
89
90/// Validate that a DTM value's HHMM portion is within a range (inclusive).
91///
92/// Example: `validate_hhmm_range("202601011530+00", "0000", "2359")` → True
93pub fn validate_hhmm_range(dtm_value: &str, min: &str, max: &str) -> ConditionResult {
94    if dtm_value.len() < 12 {
95        return ConditionResult::False;
96    }
97    let hhmm = &dtm_value[8..12];
98    ConditionResult::from(hhmm >= min && hhmm <= max)
99}
100
101/// Validate that a DTM value's MMDDHHMM portion equals the expected value.
102///
103/// Extracts characters at positions 4..12 for comparison.
104///
105/// Example: `validate_mmddhhmm_equals("202612312300+00", "12312300")` → True
106pub fn validate_mmddhhmm_equals(dtm_value: &str, expected: &str) -> ConditionResult {
107    if dtm_value.len() < 12 {
108        return ConditionResult::False;
109    }
110    ConditionResult::from(&dtm_value[4..12] == expected)
111}
112
113/// Validate that a DTM value's timezone portion is "+00" (UTC).
114///
115/// DTM format 303 with timezone: CCYYMMDDHHMM+ZZ or CCYYMMDDHHMM-ZZ (15 chars).
116/// Checks that the last 3 characters are "+00".
117///
118/// Example: `validate_timezone_utc("202601012200+00")` → True
119/// Example: `validate_timezone_utc("202601012200+01")` → False
120pub fn validate_timezone_utc(dtm_value: &str) -> ConditionResult {
121    if dtm_value.len() < 15 {
122        // Value too short — no timezone suffix present, condition is not met
123        return ConditionResult::False;
124    }
125    ConditionResult::from(&dtm_value[12..] == "+00")
126}
127
128// --- Contact format validation ---
129
130/// Validate email format: must contain both '@' and '.'.
131///
132/// Example: `validate_email("user@example.com")` → True
133pub fn validate_email(value: &str) -> ConditionResult {
134    if value.is_empty() {
135        return ConditionResult::Unknown;
136    }
137    ConditionResult::from(value.contains('@') && value.contains('.'))
138}
139
140/// Validate phone format: must start with '+' followed by only digits.
141///
142/// Example: `validate_phone("+4930123456")` → True
143/// Example: `validate_phone("030123456")` → False
144pub fn validate_phone(value: &str) -> ConditionResult {
145    if value.is_empty() {
146        return ConditionResult::Unknown;
147    }
148    if !value.starts_with('+') || value.len() < 2 {
149        return ConditionResult::from(false);
150    }
151    ConditionResult::from(value[1..].chars().all(|c| c.is_ascii_digit()))
152}
153
154// --- ID format validation ---
155
156/// Validate Marktlokations-ID (MaLo-ID): exactly 11 digits with BDEW check digit.
157///
158/// This is **not** standard Luhn — per the BDEW "Anwendungshilfe MaLo-ID v1.0" spec
159/// and the Hochfrequenz BO4E-dotnet reference implementation
160/// (`BO4E/BO/Marktlokation.cs::GetChecksum`), digits 1–10 are numbered left-to-right
161/// (1-based), digits at even positions are multiplied by 2 and **added as the full
162/// value** (no digit-sum, no mod-by-9 step), digits at odd positions are added as-is.
163/// Check digit = `(10 - (sum mod 10)) mod 10`.
164///
165/// Example: `"51238696781"` — odd (1,3,5,7,9): 5+2+8+9+7=31; even doubled
166/// (2,4,6,8,10): 2*(1+3+6+6+8)=48; sum=79; check = (10-79%10)%10 = 1 ✓.
167pub fn validate_malo_id(value: &str) -> ConditionResult {
168    if value.len() != 11 {
169        return ConditionResult::from(false);
170    }
171    if !value.chars().all(|c| c.is_ascii_digit()) {
172        return ConditionResult::from(false);
173    }
174    let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
175    let check = digits[10];
176    let mut sum = 0u32;
177    for (i, &d) in digits[..10].iter().enumerate() {
178        // 1-based position is i+1; even positions are doubled, odd positions as-is.
179        if (i + 1) % 2 == 0 {
180            sum += 2 * d;
181        } else {
182            sum += d;
183        }
184    }
185    let expected = (10 - (sum % 10)) % 10;
186    ConditionResult::from(check == expected)
187}
188
189/// Validate Transaktionsreferenz-ID (TR-ID): 1-35 alphanumeric characters.
190pub fn validate_tr_id(value: &str) -> ConditionResult {
191    if value.is_empty() {
192        return ConditionResult::Unknown;
193    }
194    ConditionResult::from(value.len() <= 35 && value.chars().all(|c| c.is_ascii_alphanumeric()))
195}
196
197/// Validate Steuerbare-Ressource-ID (SR-ID): same format as MaLo-ID (11 digits, Luhn check).
198pub fn validate_sr_id(value: &str) -> ConditionResult {
199    validate_malo_id(value)
200}
201
202/// Validate Zahlpunktbezeichnung: exactly 33 alphanumeric characters.
203pub fn validate_zahlpunkt(value: &str) -> ConditionResult {
204    if value.len() != 33 {
205        return ConditionResult::from(false);
206    }
207    ConditionResult::from(value.chars().all(|c| c.is_ascii_alphanumeric()))
208}
209
210/// Validate either MaLo-ID or Zahlpunktbezeichnung format.
211pub fn validate_malo_or_zahlpunkt(value: &str) -> ConditionResult {
212    if value.len() == 11 && validate_malo_id(value).is_true() {
213        return ConditionResult::True;
214    }
215    if value.len() == 33 && validate_zahlpunkt(value).is_true() {
216        return ConditionResult::True;
217    }
218    ConditionResult::False
219}
220
221// --- Artikelnummer pattern validation ---
222
223/// Validate a dash-separated digit pattern like "n1-n2-n1-n3".
224///
225/// `segment_lengths` defines expected digit counts per dash-separated segment.
226///
227/// Example: `validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3])` → True
228/// Example: `validate_artikel_pattern("1-23-4", &[1, 2, 1])` → True
229pub fn validate_artikel_pattern(value: &str, segment_lengths: &[usize]) -> ConditionResult {
230    if value.is_empty() {
231        return ConditionResult::Unknown;
232    }
233    let parts: Vec<&str> = value.split('-').collect();
234    if parts.len() != segment_lengths.len() {
235        return ConditionResult::from(false);
236    }
237    let valid = parts
238        .iter()
239        .zip(segment_lengths.iter())
240        .all(|(part, &expected_len)| {
241            part.len() == expected_len && part.chars().all(|c| c.is_ascii_digit())
242        });
243    ConditionResult::from(valid)
244}
245
246// --- General string validation ---
247
248/// Validate exact character length.
249pub fn validate_exact_length(value: &str, expected: usize) -> ConditionResult {
250    if value.is_empty() {
251        return ConditionResult::Unknown;
252    }
253    ConditionResult::from(value.len() == expected)
254}
255
256/// Validate maximum character length.
257pub fn validate_max_length(value: &str, max: usize) -> ConditionResult {
258    if value.is_empty() {
259        return ConditionResult::Unknown;
260    }
261    ConditionResult::from(value.len() <= max)
262}
263
264/// Validate that a string contains only digits (positive integer check).
265pub fn validate_all_digits(value: &str) -> ConditionResult {
266    if value.is_empty() {
267        return ConditionResult::Unknown;
268    }
269    ConditionResult::from(value.chars().all(|c| c.is_ascii_digit()))
270}
271
272/// Current UTC date+time as a CCYYMMDDHHMM string (12 chars).
273pub fn utc_now_ccyymmddhhmm() -> String {
274    chrono::Utc::now().format("%Y%m%d%H%M").to_string()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    // --- Decimal places ---
282
283    #[test]
284    fn test_max_decimal_places() {
285        assert_eq!(
286            validate_max_decimal_places("123.45", 2),
287            ConditionResult::True
288        );
289        assert_eq!(
290            validate_max_decimal_places("123.456", 2),
291            ConditionResult::False
292        );
293        assert_eq!(validate_max_decimal_places("123", 2), ConditionResult::True);
294        assert_eq!(validate_max_decimal_places("0.1", 3), ConditionResult::True);
295        assert_eq!(validate_max_decimal_places("", 2), ConditionResult::Unknown);
296    }
297
298    #[test]
299    fn test_no_decimal_places() {
300        assert_eq!(validate_max_decimal_places("100", 0), ConditionResult::True);
301        assert_eq!(
302            validate_max_decimal_places("100.5", 0),
303            ConditionResult::False
304        );
305    }
306
307    #[test]
308    fn test_max_integer_digits() {
309        assert_eq!(
310            validate_max_integer_digits("1234", 4),
311            ConditionResult::True
312        );
313        assert_eq!(
314            validate_max_integer_digits("12345", 4),
315            ConditionResult::False
316        );
317        assert_eq!(
318            validate_max_integer_digits("-123.45", 4),
319            ConditionResult::True
320        );
321        assert_eq!(validate_max_integer_digits("", 4), ConditionResult::Unknown);
322    }
323
324    // --- Numeric range ---
325
326    #[test]
327    fn test_validate_numeric() {
328        assert_eq!(validate_numeric("5.0", ">=", 0.0), ConditionResult::True);
329        assert_eq!(validate_numeric("-1.0", ">=", 0.0), ConditionResult::False);
330        assert_eq!(validate_numeric("1", "==", 1.0), ConditionResult::True);
331        assert_eq!(validate_numeric("2", "==", 1.0), ConditionResult::False);
332        assert_eq!(validate_numeric("0", ">", 0.0), ConditionResult::False);
333        assert_eq!(validate_numeric("1", ">", 0.0), ConditionResult::True);
334        assert_eq!(validate_numeric("abc", ">=", 0.0), ConditionResult::Unknown);
335    }
336
337    // --- DTM validation ---
338
339    #[test]
340    fn test_hhmm_equals() {
341        assert_eq!(
342            validate_hhmm_equals("202601012200+00", "2200"),
343            ConditionResult::True
344        );
345        assert_eq!(
346            validate_hhmm_equals("202601012300+00", "2200"),
347            ConditionResult::False
348        );
349        assert_eq!(
350            validate_hhmm_equals("short", "2200"),
351            ConditionResult::False
352        );
353    }
354
355    #[test]
356    fn test_hhmm_range() {
357        assert_eq!(
358            validate_hhmm_range("202601011530+00", "0000", "2359"),
359            ConditionResult::True
360        );
361        assert_eq!(
362            validate_hhmm_range("202601010000+00", "0000", "2359"),
363            ConditionResult::True
364        );
365        assert_eq!(
366            validate_hhmm_range("202601012359+00", "0000", "2359"),
367            ConditionResult::True
368        );
369    }
370
371    #[test]
372    fn test_mmddhhmm_equals() {
373        assert_eq!(
374            validate_mmddhhmm_equals("202612312300+00", "12312300"),
375            ConditionResult::True
376        );
377        assert_eq!(
378            validate_mmddhhmm_equals("202601012200+00", "12312300"),
379            ConditionResult::False
380        );
381    }
382
383    #[test]
384    fn test_timezone_utc() {
385        assert_eq!(
386            validate_timezone_utc("202601012200+00"),
387            ConditionResult::True
388        );
389        assert_eq!(
390            validate_timezone_utc("202601012200+01"),
391            ConditionResult::False
392        );
393        assert_eq!(
394            validate_timezone_utc("202601012200"),
395            ConditionResult::False
396        );
397    }
398
399    // --- Contact validation ---
400
401    #[test]
402    fn test_email() {
403        assert_eq!(validate_email("user@example.com"), ConditionResult::True);
404        assert_eq!(validate_email("nope"), ConditionResult::False);
405        assert_eq!(validate_email("has@but-no-dot"), ConditionResult::False);
406        assert_eq!(validate_email(""), ConditionResult::Unknown);
407    }
408
409    #[test]
410    fn test_phone() {
411        assert_eq!(validate_phone("+4930123456"), ConditionResult::True);
412        assert_eq!(validate_phone("030123456"), ConditionResult::False);
413        assert_eq!(validate_phone("+"), ConditionResult::False);
414        assert_eq!(validate_phone("+49 30 123"), ConditionResult::False); // spaces not allowed
415        assert_eq!(validate_phone(""), ConditionResult::Unknown);
416    }
417
418    // --- ID validation ---
419
420    #[test]
421    fn test_malo_id() {
422        // Valid MaLo-IDs — taken directly from the BO4E-dotnet reference test
423        // suite (TestBO4E/TestMaLoMeLoId.cs), which uses the BDEW MaLo-ID spec
424        // (no digit-sum on doubled products; see `validate_malo_id` docstring).
425        assert_eq!(validate_malo_id("51238696781"), ConditionResult::True);
426        assert_eq!(validate_malo_id("41373559241"), ConditionResult::True);
427        assert_eq!(validate_malo_id("56789012345"), ConditionResult::True);
428        assert_eq!(validate_malo_id("52935155442"), ConditionResult::True);
429
430        // Negative cases from the same reference tests.
431        assert_eq!(validate_malo_id("41373559240"), ConditionResult::False); // wrong check digit
432        assert_eq!(validate_malo_id("512386967890"), ConditionResult::False); // 12 digits
433        assert_eq!(validate_malo_id("1234567890"), ConditionResult::False); // too short
434        assert_eq!(validate_malo_id("abcdefghijk"), ConditionResult::False); // not digits
435    }
436
437    #[test]
438    fn test_zahlpunkt() {
439        let valid = "DE0001234567890123456789012345678";
440        assert_eq!(valid.len(), 33);
441        assert_eq!(validate_zahlpunkt(valid), ConditionResult::True);
442        assert_eq!(validate_zahlpunkt("tooshort"), ConditionResult::False);
443    }
444
445    // --- Artikelnummer pattern ---
446
447    #[test]
448    fn test_artikel_pattern() {
449        assert_eq!(
450            validate_artikel_pattern("1-23-4", &[1, 2, 1]),
451            ConditionResult::True
452        );
453        assert_eq!(
454            validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3]),
455            ConditionResult::True
456        );
457        assert_eq!(
458            validate_artikel_pattern("1-23-4-56", &[1, 2, 1, 3]),
459            ConditionResult::False
460        );
461        assert_eq!(
462            validate_artikel_pattern("1-AB-4", &[1, 2, 1]),
463            ConditionResult::False
464        );
465        assert_eq!(
466            validate_artikel_pattern("", &[1, 2, 1]),
467            ConditionResult::Unknown
468        );
469    }
470
471    // --- TR-ID / SR-ID validation ---
472
473    #[test]
474    fn test_tr_id() {
475        assert_eq!(validate_tr_id("ABC123"), ConditionResult::True);
476        assert_eq!(validate_tr_id("A"), ConditionResult::True);
477        assert_eq!(validate_tr_id(&"A".repeat(35)), ConditionResult::True);
478        assert_eq!(validate_tr_id(&"A".repeat(36)), ConditionResult::False);
479        assert_eq!(validate_tr_id("has spaces"), ConditionResult::False);
480        assert_eq!(validate_tr_id("has-dash"), ConditionResult::False);
481        assert_eq!(validate_tr_id(""), ConditionResult::Unknown);
482    }
483
484    #[test]
485    fn test_sr_id() {
486        // SR-ID uses the same BDEW 11-digit check format as MaLo-ID; reuse
487        // the reference-suite valid example from BO4E-dotnet tests.
488        assert_eq!(validate_sr_id("51238696781"), ConditionResult::True);
489        assert_eq!(validate_sr_id("41373559240"), ConditionResult::False);
490        assert_eq!(validate_sr_id("1234567890"), ConditionResult::False);
491        assert_eq!(validate_sr_id(""), ConditionResult::False);
492    }
493
494    // --- String validation ---
495
496    #[test]
497    fn test_exact_length() {
498        assert_eq!(
499            validate_exact_length("1234567890123456", 16),
500            ConditionResult::True
501        );
502        assert_eq!(validate_exact_length("123", 16), ConditionResult::False);
503        assert_eq!(validate_exact_length("", 16), ConditionResult::Unknown);
504    }
505
506    #[test]
507    fn test_all_digits() {
508        assert_eq!(validate_all_digits("12345"), ConditionResult::True);
509        assert_eq!(validate_all_digits("123a5"), ConditionResult::False);
510        assert_eq!(validate_all_digits(""), ConditionResult::Unknown);
511    }
512}