Skip to main content

mig_bo4e/
pid_validation.rs

1//! PID validation errors — typed, LLM-consumable error reports.
2
3use std::fmt;
4
5use serde_json::Value;
6
7use crate::pid_requirements::{CodeValue, EntityRequirement, FieldRequirement, PidRequirements};
8
9/// Severity of a validation error.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12    /// Field is unconditionally required (Muss/X) or condition evaluated to True.
13    Error,
14    /// Condition evaluated to Unknown (depends on external context).
15    Warning,
16}
17
18/// A single PID validation error.
19#[derive(Debug, Clone)]
20pub enum PidValidationError {
21    /// An entire entity is missing from the interchange.
22    MissingEntity {
23        entity: String,
24        ahb_status: String,
25        severity: Severity,
26    },
27    /// A required field is None/missing.
28    MissingField {
29        entity: String,
30        field: String,
31        ahb_status: String,
32        rust_type: Option<String>,
33        valid_values: Vec<(String, String)>,
34        severity: Severity,
35    },
36    /// A code field has a value not in the allowed set.
37    InvalidCode {
38        entity: String,
39        field: String,
40        value: String,
41        valid_values: Vec<(String, String)>,
42    },
43}
44
45impl PidValidationError {
46    pub fn severity(&self) -> &Severity {
47        match self {
48            Self::MissingEntity { severity, .. } => severity,
49            Self::MissingField { severity, .. } => severity,
50            Self::InvalidCode { .. } => &Severity::Error,
51        }
52    }
53
54    pub fn is_error(&self) -> bool {
55        matches!(self.severity(), Severity::Error)
56    }
57}
58
59impl fmt::Display for PidValidationError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            PidValidationError::MissingEntity {
63                entity,
64                ahb_status,
65                severity,
66            } => {
67                let label = severity_label(severity);
68                write!(
69                    f,
70                    "{label}: missing entity '{entity}' (required: {ahb_status})"
71                )
72            }
73            PidValidationError::MissingField {
74                entity,
75                field,
76                ahb_status,
77                rust_type,
78                valid_values,
79                severity,
80            } => {
81                let label = severity_label(severity);
82                write!(
83                    f,
84                    "{label}: missing {entity}.{field} (required: {ahb_status})"
85                )?;
86                if let Some(rt) = rust_type {
87                    write!(f, "\n  → type: {rt}")?;
88                }
89                if !valid_values.is_empty() {
90                    let codes: Vec<String> = valid_values
91                        .iter()
92                        .map(|(code, meaning)| {
93                            if meaning.is_empty() {
94                                code.clone()
95                            } else {
96                                format!("{code} ({meaning})")
97                            }
98                        })
99                        .collect();
100                    write!(f, "\n  → valid: {}", codes.join(", "))?;
101                }
102                Ok(())
103            }
104            PidValidationError::InvalidCode {
105                entity,
106                field,
107                value,
108                valid_values,
109            } => {
110                write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
111                if !valid_values.is_empty() {
112                    let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
113                    write!(f, "\n  → valid: {}", codes.join(", "))?;
114                }
115                Ok(())
116            }
117        }
118    }
119}
120
121fn severity_label(severity: &Severity) -> &'static str {
122    match severity {
123        Severity::Error => "ERROR",
124        Severity::Warning => "WARNING",
125    }
126}
127
128/// A collection of validation errors for a PID.
129pub struct ValidationReport(pub Vec<PidValidationError>);
130
131impl ValidationReport {
132    /// Returns true if the report contains any errors (not just warnings).
133    pub fn has_errors(&self) -> bool {
134        self.0.iter().any(|e| e.is_error())
135    }
136
137    /// Returns only the errors (not warnings).
138    pub fn errors(&self) -> Vec<&PidValidationError> {
139        self.0.iter().filter(|e| e.is_error()).collect()
140    }
141
142    /// Returns true if the report is empty (no errors or warnings).
143    pub fn is_empty(&self) -> bool {
144        self.0.is_empty()
145    }
146
147    /// Returns the number of validation errors.
148    pub fn len(&self) -> usize {
149        self.0.len()
150    }
151}
152
153impl fmt::Display for ValidationReport {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        for (i, err) in self.0.iter().enumerate() {
156            if i > 0 {
157                writeln!(f)?;
158            }
159            write!(f, "{err}")?;
160        }
161        Ok(())
162    }
163}
164
165// ── Validation Logic ──────────────────────────────────────────────────────
166
167/// Validate a BO4E JSON value against PID requirements.
168///
169/// Walks the requirements and checks:
170/// 1. Required entities are present in the JSON
171/// 2. Required fields are present within each entity
172/// 3. Code fields have values in the allowed set
173pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
174    let mut errors = Vec::new();
175
176    for entity_req in &requirements.entities {
177        let key = to_camel_case(&entity_req.entity);
178
179        match json.get(&key) {
180            None => {
181                if is_unconditionally_required(&entity_req.ahb_status) {
182                    errors.push(PidValidationError::MissingEntity {
183                        entity: entity_req.entity.clone(),
184                        ahb_status: entity_req.ahb_status.clone(),
185                        severity: Severity::Error,
186                    });
187                }
188            }
189            Some(val) => {
190                if entity_req.is_array {
191                    if let Some(arr) = val.as_array() {
192                        for element in arr {
193                            validate_entity_fields(element, entity_req, &mut errors);
194                        }
195                    }
196                } else {
197                    validate_entity_fields(val, entity_req, &mut errors);
198                }
199            }
200        }
201    }
202
203    errors
204}
205
206/// Validate fields within a single entity JSON object.
207fn validate_entity_fields(
208    entity_json: &Value,
209    entity_req: &EntityRequirement,
210    errors: &mut Vec<PidValidationError>,
211) {
212    for field_req in &entity_req.fields {
213        match entity_json.get(&field_req.bo4e_name) {
214            None => {
215                if is_unconditionally_required(&field_req.ahb_status) {
216                    errors.push(PidValidationError::MissingField {
217                        entity: entity_req.entity.clone(),
218                        field: field_req.bo4e_name.clone(),
219                        ahb_status: field_req.ahb_status.clone(),
220                        rust_type: field_req.enum_name.clone(),
221                        valid_values: code_values_to_tuples(&field_req.valid_codes),
222                        severity: Severity::Error,
223                    });
224                }
225            }
226            Some(val) => {
227                if !field_req.valid_codes.is_empty() {
228                    validate_code_value(val, entity_req, field_req, errors);
229                }
230            }
231        }
232    }
233}
234
235/// Validate that a code field's value is in the allowed set.
236fn validate_code_value(
237    val: &Value,
238    entity_req: &EntityRequirement,
239    field_req: &FieldRequirement,
240    errors: &mut Vec<PidValidationError>,
241) {
242    let value_str = match val.as_str() {
243        Some(s) => s,
244        None => return, // Non-string values skip code validation
245    };
246
247    let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
248    if !is_valid {
249        errors.push(PidValidationError::InvalidCode {
250            entity: entity_req.entity.clone(),
251            field: field_req.bo4e_name.clone(),
252            value: value_str.to_string(),
253            valid_values: code_values_to_tuples(&field_req.valid_codes),
254        });
255    }
256}
257
258/// Convert CodeValue vec to (code, meaning) tuples.
259fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
260    codes
261        .iter()
262        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
263        .collect()
264}
265
266/// Convert PascalCase entity name to camelCase JSON key.
267///
268/// "Prozessdaten" → "prozessdaten"
269/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
270/// "Marktlokation" → "marktlokation"
271fn to_camel_case(s: &str) -> String {
272    if s.is_empty() {
273        return String::new();
274    }
275    let mut chars = s.chars();
276    let first = chars.next().unwrap();
277    let mut result = first.to_lowercase().to_string();
278    result.extend(chars);
279    result
280}
281
282/// Returns true if the AHB status indicates an unconditionally required field.
283fn is_unconditionally_required(ahb_status: &str) -> bool {
284    matches!(ahb_status, "X" | "Muss" | "Soll")
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::pid_requirements::{
291        CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
292    };
293    use serde_json::json;
294
295    fn sample_requirements() -> PidRequirements {
296        PidRequirements {
297            pid: "55001".to_string(),
298            beschreibung: "Anmeldung verb. MaLo".to_string(),
299            entities: vec![
300                EntityRequirement {
301                    entity: "Prozessdaten".to_string(),
302                    bo4e_type: "Prozessdaten".to_string(),
303                    companion_type: None,
304                    ahb_status: "Muss".to_string(),
305                    is_array: false,
306                    fields: vec![
307                        FieldRequirement {
308                            bo4e_name: "vorgangId".to_string(),
309                            ahb_status: "X".to_string(),
310                            is_companion: false,
311                            field_type: "data".to_string(),
312                            format: None,
313                            enum_name: None,
314                            valid_codes: vec![],
315                        },
316                        FieldRequirement {
317                            bo4e_name: "transaktionsgrund".to_string(),
318                            ahb_status: "X".to_string(),
319                            is_companion: false,
320                            field_type: "code".to_string(),
321                            format: None,
322                            enum_name: Some("Transaktionsgrund".to_string()),
323                            valid_codes: vec![
324                                CodeValue {
325                                    code: "E01".to_string(),
326                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
327                                },
328                                CodeValue {
329                                    code: "E03".to_string(),
330                                    meaning: "Wechsel".to_string(),
331                                },
332                            ],
333                        },
334                    ],
335                },
336                EntityRequirement {
337                    entity: "Marktlokation".to_string(),
338                    bo4e_type: "Marktlokation".to_string(),
339                    companion_type: Some("MarktlokationEdifact".to_string()),
340                    ahb_status: "Muss".to_string(),
341                    is_array: false,
342                    fields: vec![
343                        FieldRequirement {
344                            bo4e_name: "marktlokationsId".to_string(),
345                            ahb_status: "X".to_string(),
346                            is_companion: false,
347                            field_type: "data".to_string(),
348                            format: None,
349                            enum_name: None,
350                            valid_codes: vec![],
351                        },
352                        FieldRequirement {
353                            bo4e_name: "haushaltskunde".to_string(),
354                            ahb_status: "X".to_string(),
355                            is_companion: false,
356                            field_type: "code".to_string(),
357                            format: None,
358                            enum_name: Some("Haushaltskunde".to_string()),
359                            valid_codes: vec![
360                                CodeValue {
361                                    code: "Z15".to_string(),
362                                    meaning: "Ja".to_string(),
363                                },
364                                CodeValue {
365                                    code: "Z18".to_string(),
366                                    meaning: "Nein".to_string(),
367                                },
368                            ],
369                        },
370                    ],
371                },
372                EntityRequirement {
373                    entity: "Geschaeftspartner".to_string(),
374                    bo4e_type: "Geschaeftspartner".to_string(),
375                    companion_type: Some("GeschaeftspartnerEdifact".to_string()),
376                    ahb_status: "Muss".to_string(),
377                    is_array: true,
378                    fields: vec![FieldRequirement {
379                        bo4e_name: "identifikation".to_string(),
380                        ahb_status: "X".to_string(),
381                        is_companion: false,
382                        field_type: "data".to_string(),
383                        format: None,
384                        enum_name: None,
385                        valid_codes: vec![],
386                    }],
387                },
388            ],
389        }
390    }
391
392    #[test]
393    fn test_validate_complete_json() {
394        let reqs = sample_requirements();
395        let json = json!({
396            "prozessdaten": {
397                "vorgangId": "ABC123",
398                "transaktionsgrund": "E01"
399            },
400            "marktlokation": {
401                "marktlokationsId": "51234567890",
402                "haushaltskunde": "Z15"
403            },
404            "geschaeftspartner": [
405                { "identifikation": "9900000000003" }
406            ]
407        });
408
409        let errors = validate_pid_json(&json, &reqs);
410        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
411    }
412
413    #[test]
414    fn test_validate_missing_entity() {
415        let reqs = sample_requirements();
416        let json = json!({
417            "prozessdaten": {
418                "vorgangId": "ABC123",
419                "transaktionsgrund": "E01"
420            },
421            "geschaeftspartner": [
422                { "identifikation": "9900000000003" }
423            ]
424        });
425        // Marktlokation is missing
426
427        let errors = validate_pid_json(&json, &reqs);
428        assert_eq!(errors.len(), 1);
429        match &errors[0] {
430            PidValidationError::MissingEntity {
431                entity,
432                ahb_status,
433                severity,
434            } => {
435                assert_eq!(entity, "Marktlokation");
436                assert_eq!(ahb_status, "Muss");
437                assert_eq!(severity, &Severity::Error);
438            }
439            other => panic!("Expected MissingEntity, got: {other:?}"),
440        }
441
442        // Display check
443        let msg = errors[0].to_string();
444        assert!(msg.contains("ERROR"));
445        assert!(msg.contains("Marktlokation"));
446        assert!(msg.contains("Muss"));
447    }
448
449    #[test]
450    fn test_validate_missing_field() {
451        let reqs = sample_requirements();
452        let json = json!({
453            "prozessdaten": {
454                "transaktionsgrund": "E01"
455                // vorgangId is missing
456            },
457            "marktlokation": {
458                "marktlokationsId": "51234567890",
459                "haushaltskunde": "Z15"
460            },
461            "geschaeftspartner": [
462                { "identifikation": "9900000000003" }
463            ]
464        });
465
466        let errors = validate_pid_json(&json, &reqs);
467        assert_eq!(errors.len(), 1);
468        match &errors[0] {
469            PidValidationError::MissingField {
470                entity,
471                field,
472                ahb_status,
473                severity,
474                ..
475            } => {
476                assert_eq!(entity, "Prozessdaten");
477                assert_eq!(field, "vorgangId");
478                assert_eq!(ahb_status, "X");
479                assert_eq!(severity, &Severity::Error);
480            }
481            other => panic!("Expected MissingField, got: {other:?}"),
482        }
483
484        let msg = errors[0].to_string();
485        assert!(msg.contains("ERROR"));
486        assert!(msg.contains("Prozessdaten.vorgangId"));
487    }
488
489    #[test]
490    fn test_validate_invalid_code() {
491        let reqs = sample_requirements();
492        let json = json!({
493            "prozessdaten": {
494                "vorgangId": "ABC123",
495                "transaktionsgrund": "E01"
496            },
497            "marktlokation": {
498                "marktlokationsId": "51234567890",
499                "haushaltskunde": "Z99"  // Invalid code
500            },
501            "geschaeftspartner": [
502                { "identifikation": "9900000000003" }
503            ]
504        });
505
506        let errors = validate_pid_json(&json, &reqs);
507        assert_eq!(errors.len(), 1);
508        match &errors[0] {
509            PidValidationError::InvalidCode {
510                entity,
511                field,
512                value,
513                valid_values,
514            } => {
515                assert_eq!(entity, "Marktlokation");
516                assert_eq!(field, "haushaltskunde");
517                assert_eq!(value, "Z99");
518                assert_eq!(valid_values.len(), 2);
519                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
520                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
521            }
522            other => panic!("Expected InvalidCode, got: {other:?}"),
523        }
524
525        let msg = errors[0].to_string();
526        assert!(msg.contains("INVALID"));
527        assert!(msg.contains("Z99"));
528        assert!(msg.contains("Z15"));
529    }
530
531    #[test]
532    fn test_validate_array_entity() {
533        let reqs = sample_requirements();
534        let json = json!({
535            "prozessdaten": {
536                "vorgangId": "ABC123",
537                "transaktionsgrund": "E01"
538            },
539            "marktlokation": {
540                "marktlokationsId": "51234567890",
541                "haushaltskunde": "Z15"
542            },
543            "geschaeftspartner": [
544                { "identifikation": "9900000000003" },
545                { }  // Missing identifikation in second element
546            ]
547        });
548
549        let errors = validate_pid_json(&json, &reqs);
550        assert_eq!(errors.len(), 1);
551        match &errors[0] {
552            PidValidationError::MissingField { entity, field, .. } => {
553                assert_eq!(entity, "Geschaeftspartner");
554                assert_eq!(field, "identifikation");
555            }
556            other => panic!("Expected MissingField, got: {other:?}"),
557        }
558    }
559
560    #[test]
561    fn test_to_camel_case() {
562        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
563        assert_eq!(
564            to_camel_case("RuhendeMarktlokation"),
565            "ruhendeMarktlokation"
566        );
567        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
568        assert_eq!(to_camel_case(""), "");
569    }
570
571    #[test]
572    fn test_is_unconditionally_required() {
573        assert!(is_unconditionally_required("X"));
574        assert!(is_unconditionally_required("Muss"));
575        assert!(is_unconditionally_required("Soll"));
576        assert!(!is_unconditionally_required("Kann"));
577        assert!(!is_unconditionally_required("[1]"));
578        assert!(!is_unconditionally_required(""));
579    }
580
581    #[test]
582    fn test_validation_report_display() {
583        let errors = vec![
584            PidValidationError::MissingEntity {
585                entity: "Marktlokation".to_string(),
586                ahb_status: "Muss".to_string(),
587                severity: Severity::Error,
588            },
589            PidValidationError::MissingField {
590                entity: "Prozessdaten".to_string(),
591                field: "vorgangId".to_string(),
592                ahb_status: "X".to_string(),
593                rust_type: None,
594                valid_values: vec![],
595                severity: Severity::Error,
596            },
597        ];
598        let report = ValidationReport(errors);
599        assert!(report.has_errors());
600        assert_eq!(report.len(), 2);
601        assert!(!report.is_empty());
602
603        let display = report.to_string();
604        assert!(display.contains("missing entity 'Marktlokation'"));
605        assert!(display.contains("missing Prozessdaten.vorgangId"));
606    }
607
608    #[test]
609    fn test_missing_field_with_type_and_values_display() {
610        let err = PidValidationError::MissingField {
611            entity: "Marktlokation".to_string(),
612            field: "haushaltskunde".to_string(),
613            ahb_status: "Muss".to_string(),
614            rust_type: Some("Haushaltskunde".to_string()),
615            valid_values: vec![
616                ("Z15".to_string(), "Ja".to_string()),
617                ("Z18".to_string(), "Nein".to_string()),
618            ],
619            severity: Severity::Error,
620        };
621        let msg = err.to_string();
622        assert!(msg.contains("type: Haushaltskunde"));
623        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
624    }
625
626    #[test]
627    fn test_optional_fields_not_flagged() {
628        let reqs = PidRequirements {
629            pid: "99999".to_string(),
630            beschreibung: "Test".to_string(),
631            entities: vec![EntityRequirement {
632                entity: "Test".to_string(),
633                bo4e_type: "Test".to_string(),
634                companion_type: None,
635                ahb_status: "Kann".to_string(),
636                is_array: false,
637                fields: vec![FieldRequirement {
638                    bo4e_name: "optionalField".to_string(),
639                    ahb_status: "Kann".to_string(),
640                    is_companion: false,
641                    field_type: "data".to_string(),
642                    format: None,
643                    enum_name: None,
644                    valid_codes: vec![],
645                }],
646            }],
647        };
648
649        // Entity missing but optional — no error
650        let errors = validate_pid_json(&json!({}), &reqs);
651        assert!(errors.is_empty());
652
653        // Entity present, field missing but optional — no error
654        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
655        assert!(errors.is_empty());
656    }
657}