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                    map_key: None,
307                    fields: vec![
308                        FieldRequirement {
309                            bo4e_name: "vorgangId".to_string(),
310                            ahb_status: "X".to_string(),
311                            is_companion: false,
312                            field_type: "data".to_string(),
313                            format: None,
314                            enum_name: None,
315                            valid_codes: vec![],
316                            child_group: None,
317                        },
318                        FieldRequirement {
319                            bo4e_name: "transaktionsgrund".to_string(),
320                            ahb_status: "X".to_string(),
321                            is_companion: false,
322                            field_type: "code".to_string(),
323                            format: None,
324                            enum_name: Some("Transaktionsgrund".to_string()),
325                            valid_codes: vec![
326                                CodeValue {
327                                    code: "E01".to_string(),
328                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
329                                    enum_name: None,
330                                },
331                                CodeValue {
332                                    code: "E03".to_string(),
333                                    meaning: "Wechsel".to_string(),
334                                    enum_name: None,
335                                },
336                            ],
337                            child_group: None,
338                        },
339                    ],
340                },
341                EntityRequirement {
342                    entity: "Marktlokation".to_string(),
343                    bo4e_type: "Marktlokation".to_string(),
344                    companion_type: Some("MarktlokationEdifact".to_string()),
345                    ahb_status: "Muss".to_string(),
346                    is_array: false,
347                    map_key: None,
348                    fields: vec![
349                        FieldRequirement {
350                            bo4e_name: "marktlokationsId".to_string(),
351                            ahb_status: "X".to_string(),
352                            is_companion: false,
353                            field_type: "data".to_string(),
354                            format: None,
355                            enum_name: None,
356                            valid_codes: vec![],
357                            child_group: None,
358                        },
359                        FieldRequirement {
360                            bo4e_name: "haushaltskunde".to_string(),
361                            ahb_status: "X".to_string(),
362                            is_companion: false,
363                            field_type: "code".to_string(),
364                            format: None,
365                            enum_name: Some("Haushaltskunde".to_string()),
366                            valid_codes: vec![
367                                CodeValue {
368                                    code: "Z15".to_string(),
369                                    meaning: "Ja".to_string(),
370                                    enum_name: None,
371                                },
372                                CodeValue {
373                                    code: "Z18".to_string(),
374                                    meaning: "Nein".to_string(),
375                                    enum_name: None,
376                                },
377                            ],
378                            child_group: None,
379                        },
380                    ],
381                },
382                EntityRequirement {
383                    entity: "Geschaeftspartner".to_string(),
384                    bo4e_type: "Geschaeftspartner".to_string(),
385                    companion_type: Some("GeschaeftspartnerEdifact".to_string()),
386                    ahb_status: "Muss".to_string(),
387                    is_array: true,
388                    map_key: None,
389                    fields: vec![FieldRequirement {
390                        bo4e_name: "identifikation".to_string(),
391                        ahb_status: "X".to_string(),
392                        is_companion: false,
393                        field_type: "data".to_string(),
394                        format: None,
395                        enum_name: None,
396                        valid_codes: vec![],
397                        child_group: None,
398                    }],
399                },
400            ],
401        }
402    }
403
404    #[test]
405    fn test_validate_complete_json() {
406        let reqs = sample_requirements();
407        let json = json!({
408            "prozessdaten": {
409                "vorgangId": "ABC123",
410                "transaktionsgrund": "E01"
411            },
412            "marktlokation": {
413                "marktlokationsId": "51234567890",
414                "haushaltskunde": "Z15"
415            },
416            "geschaeftspartner": [
417                { "identifikation": "9900000000003" }
418            ]
419        });
420
421        let errors = validate_pid_json(&json, &reqs);
422        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
423    }
424
425    #[test]
426    fn test_validate_missing_entity() {
427        let reqs = sample_requirements();
428        let json = json!({
429            "prozessdaten": {
430                "vorgangId": "ABC123",
431                "transaktionsgrund": "E01"
432            },
433            "geschaeftspartner": [
434                { "identifikation": "9900000000003" }
435            ]
436        });
437        // Marktlokation is missing
438
439        let errors = validate_pid_json(&json, &reqs);
440        assert_eq!(errors.len(), 1);
441        match &errors[0] {
442            PidValidationError::MissingEntity {
443                entity,
444                ahb_status,
445                severity,
446            } => {
447                assert_eq!(entity, "Marktlokation");
448                assert_eq!(ahb_status, "Muss");
449                assert_eq!(severity, &Severity::Error);
450            }
451            other => panic!("Expected MissingEntity, got: {other:?}"),
452        }
453
454        // Display check
455        let msg = errors[0].to_string();
456        assert!(msg.contains("ERROR"));
457        assert!(msg.contains("Marktlokation"));
458        assert!(msg.contains("Muss"));
459    }
460
461    #[test]
462    fn test_validate_missing_field() {
463        let reqs = sample_requirements();
464        let json = json!({
465            "prozessdaten": {
466                "transaktionsgrund": "E01"
467                // vorgangId is missing
468            },
469            "marktlokation": {
470                "marktlokationsId": "51234567890",
471                "haushaltskunde": "Z15"
472            },
473            "geschaeftspartner": [
474                { "identifikation": "9900000000003" }
475            ]
476        });
477
478        let errors = validate_pid_json(&json, &reqs);
479        assert_eq!(errors.len(), 1);
480        match &errors[0] {
481            PidValidationError::MissingField {
482                entity,
483                field,
484                ahb_status,
485                severity,
486                ..
487            } => {
488                assert_eq!(entity, "Prozessdaten");
489                assert_eq!(field, "vorgangId");
490                assert_eq!(ahb_status, "X");
491                assert_eq!(severity, &Severity::Error);
492            }
493            other => panic!("Expected MissingField, got: {other:?}"),
494        }
495
496        let msg = errors[0].to_string();
497        assert!(msg.contains("ERROR"));
498        assert!(msg.contains("Prozessdaten.vorgangId"));
499    }
500
501    #[test]
502    fn test_validate_invalid_code() {
503        let reqs = sample_requirements();
504        let json = json!({
505            "prozessdaten": {
506                "vorgangId": "ABC123",
507                "transaktionsgrund": "E01"
508            },
509            "marktlokation": {
510                "marktlokationsId": "51234567890",
511                "haushaltskunde": "Z99"  // Invalid code
512            },
513            "geschaeftspartner": [
514                { "identifikation": "9900000000003" }
515            ]
516        });
517
518        let errors = validate_pid_json(&json, &reqs);
519        assert_eq!(errors.len(), 1);
520        match &errors[0] {
521            PidValidationError::InvalidCode {
522                entity,
523                field,
524                value,
525                valid_values,
526            } => {
527                assert_eq!(entity, "Marktlokation");
528                assert_eq!(field, "haushaltskunde");
529                assert_eq!(value, "Z99");
530                assert_eq!(valid_values.len(), 2);
531                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
532                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
533            }
534            other => panic!("Expected InvalidCode, got: {other:?}"),
535        }
536
537        let msg = errors[0].to_string();
538        assert!(msg.contains("INVALID"));
539        assert!(msg.contains("Z99"));
540        assert!(msg.contains("Z15"));
541    }
542
543    #[test]
544    fn test_validate_array_entity() {
545        let reqs = sample_requirements();
546        let json = json!({
547            "prozessdaten": {
548                "vorgangId": "ABC123",
549                "transaktionsgrund": "E01"
550            },
551            "marktlokation": {
552                "marktlokationsId": "51234567890",
553                "haushaltskunde": "Z15"
554            },
555            "geschaeftspartner": [
556                { "identifikation": "9900000000003" },
557                { }  // Missing identifikation in second element
558            ]
559        });
560
561        let errors = validate_pid_json(&json, &reqs);
562        assert_eq!(errors.len(), 1);
563        match &errors[0] {
564            PidValidationError::MissingField { entity, field, .. } => {
565                assert_eq!(entity, "Geschaeftspartner");
566                assert_eq!(field, "identifikation");
567            }
568            other => panic!("Expected MissingField, got: {other:?}"),
569        }
570    }
571
572    #[test]
573    fn test_to_camel_case() {
574        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
575        assert_eq!(
576            to_camel_case("RuhendeMarktlokation"),
577            "ruhendeMarktlokation"
578        );
579        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
580        assert_eq!(to_camel_case(""), "");
581    }
582
583    #[test]
584    fn test_is_unconditionally_required() {
585        assert!(is_unconditionally_required("X"));
586        assert!(is_unconditionally_required("Muss"));
587        assert!(is_unconditionally_required("Soll"));
588        assert!(!is_unconditionally_required("Kann"));
589        assert!(!is_unconditionally_required("[1]"));
590        assert!(!is_unconditionally_required(""));
591    }
592
593    #[test]
594    fn test_validation_report_display() {
595        let errors = vec![
596            PidValidationError::MissingEntity {
597                entity: "Marktlokation".to_string(),
598                ahb_status: "Muss".to_string(),
599                severity: Severity::Error,
600            },
601            PidValidationError::MissingField {
602                entity: "Prozessdaten".to_string(),
603                field: "vorgangId".to_string(),
604                ahb_status: "X".to_string(),
605                rust_type: None,
606                valid_values: vec![],
607                severity: Severity::Error,
608            },
609        ];
610        let report = ValidationReport(errors);
611        assert!(report.has_errors());
612        assert_eq!(report.len(), 2);
613        assert!(!report.is_empty());
614
615        let display = report.to_string();
616        assert!(display.contains("missing entity 'Marktlokation'"));
617        assert!(display.contains("missing Prozessdaten.vorgangId"));
618    }
619
620    #[test]
621    fn test_missing_field_with_type_and_values_display() {
622        let err = PidValidationError::MissingField {
623            entity: "Marktlokation".to_string(),
624            field: "haushaltskunde".to_string(),
625            ahb_status: "Muss".to_string(),
626            rust_type: Some("Haushaltskunde".to_string()),
627            valid_values: vec![
628                ("Z15".to_string(), "Ja".to_string()),
629                ("Z18".to_string(), "Nein".to_string()),
630            ],
631            severity: Severity::Error,
632        };
633        let msg = err.to_string();
634        assert!(msg.contains("type: Haushaltskunde"));
635        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
636    }
637
638    #[test]
639    fn test_optional_fields_not_flagged() {
640        let reqs = PidRequirements {
641            pid: "99999".to_string(),
642            beschreibung: "Test".to_string(),
643            entities: vec![EntityRequirement {
644                entity: "Test".to_string(),
645                bo4e_type: "Test".to_string(),
646                companion_type: None,
647                ahb_status: "Kann".to_string(),
648                is_array: false,
649                map_key: None,
650                fields: vec![FieldRequirement {
651                    bo4e_name: "optionalField".to_string(),
652                    ahb_status: "Kann".to_string(),
653                    is_companion: false,
654                    field_type: "data".to_string(),
655                    format: None,
656                    enum_name: None,
657                    valid_codes: vec![],
658                    child_group: None,
659                }],
660            }],
661        };
662
663        // Entity missing but optional — no error
664        let errors = validate_pid_json(&json!({}), &reqs);
665        assert!(errors.is_empty());
666
667        // Entity present, field missing but optional — no error
668        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
669        assert!(errors.is_empty());
670    }
671}