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