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        return ConditionResult::Unknown;
85    }
86    ConditionResult::from(&dtm_value[8..12] == expected_hhmm)
87}
88
89/// Validate that a DTM value's HHMM portion is within a range (inclusive).
90///
91/// Example: `validate_hhmm_range("202601011530+00", "0000", "2359")` → True
92pub fn validate_hhmm_range(dtm_value: &str, min: &str, max: &str) -> ConditionResult {
93    if dtm_value.len() < 12 {
94        return ConditionResult::Unknown;
95    }
96    let hhmm = &dtm_value[8..12];
97    ConditionResult::from(hhmm >= min && hhmm <= max)
98}
99
100/// Validate that a DTM value's MMDDHHMM portion equals the expected value.
101///
102/// Extracts characters at positions 4..12 for comparison.
103///
104/// Example: `validate_mmddhhmm_equals("202612312300+00", "12312300")` → True
105pub fn validate_mmddhhmm_equals(dtm_value: &str, expected: &str) -> ConditionResult {
106    if dtm_value.len() < 12 {
107        return ConditionResult::Unknown;
108    }
109    ConditionResult::from(&dtm_value[4..12] == expected)
110}
111
112/// Validate that a DTM value's timezone portion is "+00" (UTC).
113///
114/// DTM format 303 with timezone: CCYYMMDDHHMM+ZZ or CCYYMMDDHHMM-ZZ (15 chars).
115/// Checks that the last 3 characters are "+00".
116///
117/// Example: `validate_timezone_utc("202601012200+00")` → True
118/// Example: `validate_timezone_utc("202601012200+01")` → False
119pub fn validate_timezone_utc(dtm_value: &str) -> ConditionResult {
120    if dtm_value.len() < 15 {
121        return ConditionResult::Unknown;
122    }
123    ConditionResult::from(&dtm_value[12..] == "+00")
124}
125
126// --- Contact format validation ---
127
128/// Validate email format: must contain both '@' and '.'.
129///
130/// Example: `validate_email("user@example.com")` → True
131pub fn validate_email(value: &str) -> ConditionResult {
132    if value.is_empty() {
133        return ConditionResult::Unknown;
134    }
135    ConditionResult::from(value.contains('@') && value.contains('.'))
136}
137
138/// Validate phone format: must start with '+' followed by only digits.
139///
140/// Example: `validate_phone("+4930123456")` → True
141/// Example: `validate_phone("030123456")` → False
142pub fn validate_phone(value: &str) -> ConditionResult {
143    if value.is_empty() {
144        return ConditionResult::Unknown;
145    }
146    if !value.starts_with('+') || value.len() < 2 {
147        return ConditionResult::from(false);
148    }
149    ConditionResult::from(value[1..].chars().all(|c| c.is_ascii_digit()))
150}
151
152// --- ID format validation ---
153
154/// Validate Marktlokations-ID (MaLo-ID): exactly 11 digits.
155///
156/// The 11th digit is a check digit (Luhn algorithm modulo 10).
157pub fn validate_malo_id(value: &str) -> ConditionResult {
158    if value.len() != 11 {
159        return ConditionResult::from(false);
160    }
161    if !value.chars().all(|c| c.is_ascii_digit()) {
162        return ConditionResult::from(false);
163    }
164    // Luhn check digit validation
165    let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
166    let check = digits[10];
167    let mut sum = 0u32;
168    for (i, &d) in digits[..10].iter().enumerate() {
169        let multiplied = if i % 2 == 0 { d } else { d * 2 };
170        sum += if multiplied > 9 {
171            multiplied - 9
172        } else {
173            multiplied
174        };
175    }
176    let expected = (10 - (sum % 10)) % 10;
177    ConditionResult::from(check == expected)
178}
179
180/// Validate Transaktionsreferenz-ID (TR-ID): 1-35 alphanumeric characters.
181pub fn validate_tr_id(value: &str) -> ConditionResult {
182    if value.is_empty() {
183        return ConditionResult::Unknown;
184    }
185    ConditionResult::from(value.len() <= 35 && value.chars().all(|c| c.is_ascii_alphanumeric()))
186}
187
188/// Validate Steuerbare-Ressource-ID (SR-ID): same format as MaLo-ID (11 digits, Luhn check).
189pub fn validate_sr_id(value: &str) -> ConditionResult {
190    validate_malo_id(value)
191}
192
193/// Validate Zahlpunktbezeichnung: exactly 33 alphanumeric characters.
194pub fn validate_zahlpunkt(value: &str) -> ConditionResult {
195    if value.len() != 33 {
196        return ConditionResult::from(false);
197    }
198    ConditionResult::from(value.chars().all(|c| c.is_ascii_alphanumeric()))
199}
200
201/// Validate either MaLo-ID or Zahlpunktbezeichnung format.
202pub fn validate_malo_or_zahlpunkt(value: &str) -> ConditionResult {
203    if value.len() == 11 && validate_malo_id(value).is_true() {
204        return ConditionResult::True;
205    }
206    if value.len() == 33 && validate_zahlpunkt(value).is_true() {
207        return ConditionResult::True;
208    }
209    ConditionResult::False
210}
211
212// --- Artikelnummer pattern validation ---
213
214/// Validate a dash-separated digit pattern like "n1-n2-n1-n3".
215///
216/// `segment_lengths` defines expected digit counts per dash-separated segment.
217///
218/// Example: `validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3])` → True
219/// Example: `validate_artikel_pattern("1-23-4", &[1, 2, 1])` → True
220pub fn validate_artikel_pattern(value: &str, segment_lengths: &[usize]) -> ConditionResult {
221    if value.is_empty() {
222        return ConditionResult::Unknown;
223    }
224    let parts: Vec<&str> = value.split('-').collect();
225    if parts.len() != segment_lengths.len() {
226        return ConditionResult::from(false);
227    }
228    let valid = parts
229        .iter()
230        .zip(segment_lengths.iter())
231        .all(|(part, &expected_len)| {
232            part.len() == expected_len && part.chars().all(|c| c.is_ascii_digit())
233        });
234    ConditionResult::from(valid)
235}
236
237// --- General string validation ---
238
239/// Validate exact character length.
240pub fn validate_exact_length(value: &str, expected: usize) -> ConditionResult {
241    if value.is_empty() {
242        return ConditionResult::Unknown;
243    }
244    ConditionResult::from(value.len() == expected)
245}
246
247/// Validate maximum character length.
248pub fn validate_max_length(value: &str, max: usize) -> ConditionResult {
249    if value.is_empty() {
250        return ConditionResult::Unknown;
251    }
252    ConditionResult::from(value.len() <= max)
253}
254
255/// Validate that a string contains only digits (positive integer check).
256pub fn validate_all_digits(value: &str) -> ConditionResult {
257    if value.is_empty() {
258        return ConditionResult::Unknown;
259    }
260    ConditionResult::from(value.chars().all(|c| c.is_ascii_digit()))
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    // --- Decimal places ---
268
269    #[test]
270    fn test_max_decimal_places() {
271        assert_eq!(
272            validate_max_decimal_places("123.45", 2),
273            ConditionResult::True
274        );
275        assert_eq!(
276            validate_max_decimal_places("123.456", 2),
277            ConditionResult::False
278        );
279        assert_eq!(validate_max_decimal_places("123", 2), ConditionResult::True);
280        assert_eq!(validate_max_decimal_places("0.1", 3), ConditionResult::True);
281        assert_eq!(validate_max_decimal_places("", 2), ConditionResult::Unknown);
282    }
283
284    #[test]
285    fn test_no_decimal_places() {
286        assert_eq!(validate_max_decimal_places("100", 0), ConditionResult::True);
287        assert_eq!(
288            validate_max_decimal_places("100.5", 0),
289            ConditionResult::False
290        );
291    }
292
293    #[test]
294    fn test_max_integer_digits() {
295        assert_eq!(
296            validate_max_integer_digits("1234", 4),
297            ConditionResult::True
298        );
299        assert_eq!(
300            validate_max_integer_digits("12345", 4),
301            ConditionResult::False
302        );
303        assert_eq!(
304            validate_max_integer_digits("-123.45", 4),
305            ConditionResult::True
306        );
307        assert_eq!(validate_max_integer_digits("", 4), ConditionResult::Unknown);
308    }
309
310    // --- Numeric range ---
311
312    #[test]
313    fn test_validate_numeric() {
314        assert_eq!(validate_numeric("5.0", ">=", 0.0), ConditionResult::True);
315        assert_eq!(validate_numeric("-1.0", ">=", 0.0), ConditionResult::False);
316        assert_eq!(validate_numeric("1", "==", 1.0), ConditionResult::True);
317        assert_eq!(validate_numeric("2", "==", 1.0), ConditionResult::False);
318        assert_eq!(validate_numeric("0", ">", 0.0), ConditionResult::False);
319        assert_eq!(validate_numeric("1", ">", 0.0), ConditionResult::True);
320        assert_eq!(validate_numeric("abc", ">=", 0.0), ConditionResult::Unknown);
321    }
322
323    // --- DTM validation ---
324
325    #[test]
326    fn test_hhmm_equals() {
327        assert_eq!(
328            validate_hhmm_equals("202601012200+00", "2200"),
329            ConditionResult::True
330        );
331        assert_eq!(
332            validate_hhmm_equals("202601012300+00", "2200"),
333            ConditionResult::False
334        );
335        assert_eq!(
336            validate_hhmm_equals("short", "2200"),
337            ConditionResult::Unknown
338        );
339    }
340
341    #[test]
342    fn test_hhmm_range() {
343        assert_eq!(
344            validate_hhmm_range("202601011530+00", "0000", "2359"),
345            ConditionResult::True
346        );
347        assert_eq!(
348            validate_hhmm_range("202601010000+00", "0000", "2359"),
349            ConditionResult::True
350        );
351        assert_eq!(
352            validate_hhmm_range("202601012359+00", "0000", "2359"),
353            ConditionResult::True
354        );
355    }
356
357    #[test]
358    fn test_mmddhhmm_equals() {
359        assert_eq!(
360            validate_mmddhhmm_equals("202612312300+00", "12312300"),
361            ConditionResult::True
362        );
363        assert_eq!(
364            validate_mmddhhmm_equals("202601012200+00", "12312300"),
365            ConditionResult::False
366        );
367    }
368
369    #[test]
370    fn test_timezone_utc() {
371        assert_eq!(
372            validate_timezone_utc("202601012200+00"),
373            ConditionResult::True
374        );
375        assert_eq!(
376            validate_timezone_utc("202601012200+01"),
377            ConditionResult::False
378        );
379        assert_eq!(
380            validate_timezone_utc("202601012200"),
381            ConditionResult::Unknown
382        );
383    }
384
385    // --- Contact validation ---
386
387    #[test]
388    fn test_email() {
389        assert_eq!(validate_email("user@example.com"), ConditionResult::True);
390        assert_eq!(validate_email("nope"), ConditionResult::False);
391        assert_eq!(validate_email("has@but-no-dot"), ConditionResult::False);
392        assert_eq!(validate_email(""), ConditionResult::Unknown);
393    }
394
395    #[test]
396    fn test_phone() {
397        assert_eq!(validate_phone("+4930123456"), ConditionResult::True);
398        assert_eq!(validate_phone("030123456"), ConditionResult::False);
399        assert_eq!(validate_phone("+"), ConditionResult::False);
400        assert_eq!(validate_phone("+49 30 123"), ConditionResult::False); // spaces not allowed
401        assert_eq!(validate_phone(""), ConditionResult::Unknown);
402    }
403
404    // --- ID validation ---
405
406    #[test]
407    fn test_malo_id() {
408        // Valid MaLo-ID: 50820849851 (example with valid Luhn check)
409        // Let's compute: digits 5,0,8,2,0,8,4,9,8,5 → check digit
410        // Manual Luhn: pos 0(x1)=5, 1(x2)=0, 2(x1)=8, 3(x2)=4, 4(x1)=0, 5(x2)=16→7, 6(x1)=4, 7(x2)=18→9, 8(x1)=8, 9(x2)=10→1
411        // sum = 5+0+8+4+0+7+4+9+8+1 = 46, check = (10 - 46%10) % 10 = 4
412        assert_eq!(validate_malo_id("50820849854"), ConditionResult::True);
413        assert_eq!(validate_malo_id("50820849855"), ConditionResult::False); // wrong check digit
414        assert_eq!(validate_malo_id("1234567890"), ConditionResult::False); // too short
415        assert_eq!(validate_malo_id("abcdefghijk"), ConditionResult::False); // not digits
416    }
417
418    #[test]
419    fn test_zahlpunkt() {
420        let valid = "DE0001234567890123456789012345678";
421        assert_eq!(valid.len(), 33);
422        assert_eq!(validate_zahlpunkt(valid), ConditionResult::True);
423        assert_eq!(validate_zahlpunkt("tooshort"), ConditionResult::False);
424    }
425
426    // --- Artikelnummer pattern ---
427
428    #[test]
429    fn test_artikel_pattern() {
430        assert_eq!(
431            validate_artikel_pattern("1-23-4", &[1, 2, 1]),
432            ConditionResult::True
433        );
434        assert_eq!(
435            validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3]),
436            ConditionResult::True
437        );
438        assert_eq!(
439            validate_artikel_pattern("1-23-4-56", &[1, 2, 1, 3]),
440            ConditionResult::False
441        );
442        assert_eq!(
443            validate_artikel_pattern("1-AB-4", &[1, 2, 1]),
444            ConditionResult::False
445        );
446        assert_eq!(
447            validate_artikel_pattern("", &[1, 2, 1]),
448            ConditionResult::Unknown
449        );
450    }
451
452    // --- TR-ID / SR-ID validation ---
453
454    #[test]
455    fn test_tr_id() {
456        assert_eq!(validate_tr_id("ABC123"), ConditionResult::True);
457        assert_eq!(validate_tr_id("A"), ConditionResult::True);
458        assert_eq!(validate_tr_id(&"A".repeat(35)), ConditionResult::True);
459        assert_eq!(validate_tr_id(&"A".repeat(36)), ConditionResult::False);
460        assert_eq!(validate_tr_id("has spaces"), ConditionResult::False);
461        assert_eq!(validate_tr_id("has-dash"), ConditionResult::False);
462        assert_eq!(validate_tr_id(""), ConditionResult::Unknown);
463    }
464
465    #[test]
466    fn test_sr_id() {
467        assert_eq!(validate_sr_id("50820849854"), ConditionResult::True);
468        assert_eq!(validate_sr_id("50820849855"), ConditionResult::False);
469        assert_eq!(validate_sr_id("1234567890"), ConditionResult::False);
470        assert_eq!(validate_sr_id(""), ConditionResult::False);
471    }
472
473    // --- String validation ---
474
475    #[test]
476    fn test_exact_length() {
477        assert_eq!(
478            validate_exact_length("1234567890123456", 16),
479            ConditionResult::True
480        );
481        assert_eq!(validate_exact_length("123", 16), ConditionResult::False);
482        assert_eq!(validate_exact_length("", 16), ConditionResult::Unknown);
483    }
484
485    #[test]
486    fn test_all_digits() {
487        assert_eq!(validate_all_digits("12345"), ConditionResult::True);
488        assert_eq!(validate_all_digits("123a5"), ConditionResult::False);
489        assert_eq!(validate_all_digits(""), ConditionResult::Unknown);
490    }
491}