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::{
8    CodeValue, EntityRequirement, EntityScope, FieldRequirement, PidRequirements,
9};
10
11/// Severity of a validation error.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Severity {
14    /// Field is unconditionally required (Muss/X) or condition evaluated to True.
15    Error,
16    /// Condition evaluated to Unknown (depends on external context).
17    Warning,
18}
19
20/// A single PID validation error.
21#[derive(Debug, Clone)]
22pub enum PidValidationError {
23    /// An entire entity is missing from the interchange.
24    MissingEntity {
25        entity: String,
26        ahb_status: String,
27        severity: Severity,
28    },
29    /// A required field is None/missing.
30    MissingField {
31        entity: String,
32        field: String,
33        ahb_status: String,
34        rust_type: Option<String>,
35        valid_values: Vec<(String, String)>,
36        severity: Severity,
37    },
38    /// A code field has a value not in the allowed set.
39    InvalidCode {
40        entity: String,
41        field: String,
42        value: String,
43        valid_values: Vec<(String, String)>,
44    },
45}
46
47impl PidValidationError {
48    pub fn severity(&self) -> &Severity {
49        match self {
50            Self::MissingEntity { severity, .. } => severity,
51            Self::MissingField { severity, .. } => severity,
52            Self::InvalidCode { .. } => &Severity::Error,
53        }
54    }
55
56    pub fn is_error(&self) -> bool {
57        matches!(self.severity(), Severity::Error)
58    }
59}
60
61impl fmt::Display for PidValidationError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            PidValidationError::MissingEntity {
65                entity,
66                ahb_status,
67                severity,
68            } => {
69                let label = severity_label(severity);
70                write!(
71                    f,
72                    "{label}: missing entity '{entity}' (required: {ahb_status})"
73                )
74            }
75            PidValidationError::MissingField {
76                entity,
77                field,
78                ahb_status,
79                rust_type,
80                valid_values,
81                severity,
82            } => {
83                let label = severity_label(severity);
84                write!(
85                    f,
86                    "{label}: missing {entity}.{field} (required: {ahb_status})"
87                )?;
88                if let Some(rt) = rust_type {
89                    write!(f, "\n  → type: {rt}")?;
90                }
91                if !valid_values.is_empty() {
92                    let codes: Vec<String> = valid_values
93                        .iter()
94                        .map(|(code, meaning)| {
95                            if meaning.is_empty() {
96                                code.clone()
97                            } else {
98                                format!("{code} ({meaning})")
99                            }
100                        })
101                        .collect();
102                    write!(f, "\n  → valid: {}", codes.join(", "))?;
103                }
104                Ok(())
105            }
106            PidValidationError::InvalidCode {
107                entity,
108                field,
109                value,
110                valid_values,
111            } => {
112                write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
113                if !valid_values.is_empty() {
114                    let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
115                    write!(f, "\n  → valid: {}", codes.join(", "))?;
116                }
117                Ok(())
118            }
119        }
120    }
121}
122
123fn severity_label(severity: &Severity) -> &'static str {
124    match severity {
125        Severity::Error => "ERROR",
126        Severity::Warning => "WARNING",
127    }
128}
129
130/// A collection of validation errors for a PID.
131pub struct ValidationReport(pub Vec<PidValidationError>);
132
133impl ValidationReport {
134    /// Returns true if the report contains any errors (not just warnings).
135    pub fn has_errors(&self) -> bool {
136        self.0.iter().any(|e| e.is_error())
137    }
138
139    /// Returns only the errors (not warnings).
140    pub fn errors(&self) -> Vec<&PidValidationError> {
141        self.0.iter().filter(|e| e.is_error()).collect()
142    }
143
144    /// Returns true if the report is empty (no errors or warnings).
145    pub fn is_empty(&self) -> bool {
146        self.0.is_empty()
147    }
148
149    /// Returns the number of validation errors.
150    pub fn len(&self) -> usize {
151        self.0.len()
152    }
153}
154
155impl fmt::Display for ValidationReport {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        for (i, err) in self.0.iter().enumerate() {
158            if i > 0 {
159                writeln!(f)?;
160            }
161            write!(f, "{err}")?;
162        }
163        Ok(())
164    }
165}
166
167// ── Validation Logic ──────────────────────────────────────────────────────
168
169/// Validate a BO4E JSON value against PID requirements.
170///
171/// Validates ALL entities (both message-level and transaction-level).
172/// Use [`validate_pid_json_transaction`] to validate only transaction-level entities.
173///
174/// Walks the requirements and checks:
175/// 1. Required entities are present in the JSON
176/// 2. Required fields are present within each entity
177/// 3. Code fields have values in the allowed set
178pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
179    validate_entities(json, &requirements.entities, None)
180}
181
182/// Validate only transaction-level entities in a BO4E JSON value.
183///
184/// Skips message-level entities (e.g., Marktteilnehmer, Kontakt from SG2/SG3)
185/// that are outside the transaction scope. Use this when validating a transaction
186/// payload that doesn't include message-level data.
187pub fn validate_pid_json_transaction(
188    json: &Value,
189    requirements: &PidRequirements,
190) -> Vec<PidValidationError> {
191    validate_entities(
192        json,
193        &requirements.entities,
194        Some(EntityScope::Transaction),
195    )
196}
197
198/// Internal: validate entities, optionally filtering by scope.
199fn validate_entities(
200    json: &Value,
201    entities: &[EntityRequirement],
202    scope_filter: Option<EntityScope>,
203) -> Vec<PidValidationError> {
204    let mut errors = Vec::new();
205
206    for entity_req in entities {
207        // Skip entities not matching the requested scope
208        if let Some(ref scope) = scope_filter {
209            if &entity_req.scope != scope {
210                continue;
211            }
212        }
213
214        let key = to_camel_case(&entity_req.entity);
215
216        match json.get(&key) {
217            None | Some(serde_json::Value::Null) => {
218                if is_unconditionally_required(&entity_req.ahb_status) {
219                    errors.push(PidValidationError::MissingEntity {
220                        entity: entity_req.entity.clone(),
221                        ahb_status: entity_req.ahb_status.clone(),
222                        severity: Severity::Error,
223                    });
224                }
225            }
226            Some(val) => {
227                if entity_req.is_array {
228                    if let Some(arr) = val.as_array() {
229                        for element in arr {
230                            validate_entity_fields(element, entity_req, &mut errors);
231                        }
232                    } else {
233                        // Caller supplied a single object where the requirement
234                        // expects an array (e.g. typed structs that emit one
235                        // rep as an object instead of [obj]). Validate it as a
236                        // single rep rather than silently skipping field checks.
237                        validate_entity_fields(val, entity_req, &mut errors);
238                    }
239                } else {
240                    validate_entity_fields(val, entity_req, &mut errors);
241                }
242            }
243        }
244    }
245
246    errors
247}
248
249/// Traverse a dot-separated path in a JSON value, trying both the original
250/// key and its camelCase variant at each level.
251fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
252    let mut current = json;
253    for part in path.split('.') {
254        current = current.get(part).or_else(|| {
255            if part.contains('_') {
256                current.get(snake_to_camel_case(part))
257            } else {
258                None
259            }
260        })?;
261    }
262    Some(current)
263}
264
265/// Validate fields within a single entity JSON object.
266fn validate_entity_fields(
267    entity_json: &Value,
268    entity_req: &EntityRequirement,
269    errors: &mut Vec<PidValidationError>,
270) {
271    for field_req in &entity_req.fields {
272        // Traverse dot-separated paths (e.g. "produktIdentifikation.funktion")
273        // and try camelCase variants at each level for typed struct compatibility.
274        //
275        // For companion fields, also check under the *Edifact wrapper object
276        // (forward mapping format), falling back to the entity root (typed PID format).
277        let val = get_nested(entity_json, &field_req.bo4e_name).or_else(|| {
278            if field_req.is_companion {
279                if let Some(ref companion_type) = entity_req.companion_type {
280                    let companion_key = to_camel_case(companion_type);
281                    let companion_obj = entity_json.get(&companion_key)?;
282                    get_nested(companion_obj, &field_req.bo4e_name)
283                } else {
284                    None
285                }
286            } else {
287                None
288            }
289        });
290
291        // Treat null values as missing — JSON null means "not provided"
292        let val = val.filter(|v| !v.is_null());
293
294        match val {
295            None => {
296                if is_unconditionally_required(&field_req.ahb_status) {
297                    errors.push(PidValidationError::MissingField {
298                        entity: entity_req.entity.clone(),
299                        field: field_req.bo4e_name.clone(),
300                        ahb_status: field_req.ahb_status.clone(),
301                        rust_type: field_req.enum_name.clone(),
302                        valid_values: code_values_to_tuples(&field_req.valid_codes),
303                        severity: Severity::Error,
304                    });
305                }
306            }
307            Some(val) => {
308                if !field_req.valid_codes.is_empty() {
309                    validate_code_value(val, entity_req, field_req, errors);
310                }
311            }
312        }
313    }
314}
315
316/// Validate that a code field's value is in the allowed set.
317fn validate_code_value(
318    val: &Value,
319    entity_req: &EntityRequirement,
320    field_req: &FieldRequirement,
321    errors: &mut Vec<PidValidationError>,
322) {
323    let value_str = match val.as_str() {
324        Some(s) => s,
325        None => return, // Non-string values skip code validation
326    };
327
328    let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
329    if !is_valid {
330        errors.push(PidValidationError::InvalidCode {
331            entity: entity_req.entity.clone(),
332            field: field_req.bo4e_name.clone(),
333            value: value_str.to_string(),
334            valid_values: code_values_to_tuples(&field_req.valid_codes),
335        });
336    }
337}
338
339/// Convert CodeValue vec to (code, meaning) tuples.
340fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
341    codes
342        .iter()
343        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
344        .collect()
345}
346
347/// Convert PascalCase entity name to camelCase JSON key.
348///
349/// "Prozessdaten" → "prozessdaten"
350/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
351/// "Marktlokation" → "marktlokation"
352fn to_camel_case(s: &str) -> String {
353    if s.is_empty() {
354        return String::new();
355    }
356    let mut chars = s.chars();
357    let first = chars.next().unwrap();
358    let mut result = first.to_lowercase().to_string();
359    result.extend(chars);
360    result
361}
362
363/// Convert a snake_case field name to camelCase.
364///
365/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
366/// the validator to find fields in JSON that was produced by typed structs even when
367/// the requirement stores the field name as snake_case (as it comes from TOML).
368///
369/// Examples:
370/// - `"code_codepflege"` → `"codeCodepflege"`
371/// - `"vorgang_id"` → `"vorgangId"`
372/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
373fn snake_to_camel_case(s: &str) -> String {
374    let mut result = String::with_capacity(s.len());
375    let mut capitalize_next = false;
376    for ch in s.chars() {
377        if ch == '_' {
378            capitalize_next = true;
379        } else if capitalize_next {
380            result.extend(ch.to_uppercase());
381            capitalize_next = false;
382        } else {
383            result.push(ch);
384        }
385    }
386    result
387}
388
389/// Returns true if the AHB status indicates an unconditionally required field.
390fn is_unconditionally_required(ahb_status: &str) -> bool {
391    matches!(ahb_status, "X" | "Muss" | "Soll")
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::pid_requirements::{
398        CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
399    };
400    use serde_json::json;
401
402    fn sample_requirements() -> PidRequirements {
403        PidRequirements {
404            pid: "55001".to_string(),
405            beschreibung: "Anmeldung verb. MaLo".to_string(),
406            entities: vec![
407                EntityRequirement {
408                    entity: "Prozessdaten".to_string(),
409                    bo4e_type: "Prozessdaten".to_string(),
410                    companion_type: None,
411                    ahb_status: "Muss".to_string(),
412                    is_array: false,
413                    map_key: None,
414                    scope: EntityScope::Transaction,
415                    fields: vec![
416                        FieldRequirement {
417                            bo4e_name: "vorgangId".to_string(),
418                            ahb_status: "X".to_string(),
419                            is_companion: false,
420                            field_type: "data".to_string(),
421                            format: None,
422                            enum_name: None,
423                            valid_codes: vec![],
424                            child_group: None,
425                        },
426                        FieldRequirement {
427                            bo4e_name: "transaktionsgrund".to_string(),
428                            ahb_status: "X".to_string(),
429                            is_companion: false,
430                            field_type: "code".to_string(),
431                            format: None,
432                            enum_name: Some("Transaktionsgrund".to_string()),
433                            valid_codes: vec![
434                                CodeValue {
435                                    code: "E01".to_string(),
436                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
437                                    enum_name: None,
438                                },
439                                CodeValue {
440                                    code: "E03".to_string(),
441                                    meaning: "Wechsel".to_string(),
442                                    enum_name: None,
443                                },
444                            ],
445                            child_group: None,
446                        },
447                    ],
448                },
449                EntityRequirement {
450                    entity: "Marktlokation".to_string(),
451                    bo4e_type: "Marktlokation".to_string(),
452                    companion_type: Some("MarktlokationEdifact".to_string()),
453                    ahb_status: "Muss".to_string(),
454                    is_array: false,
455                    map_key: None,
456                    scope: EntityScope::Transaction,
457                    fields: vec![
458                        FieldRequirement {
459                            bo4e_name: "marktlokationsId".to_string(),
460                            ahb_status: "X".to_string(),
461                            is_companion: false,
462                            field_type: "data".to_string(),
463                            format: None,
464                            enum_name: None,
465                            valid_codes: vec![],
466                            child_group: None,
467                        },
468                        FieldRequirement {
469                            bo4e_name: "haushaltskunde".to_string(),
470                            ahb_status: "X".to_string(),
471                            is_companion: false,
472                            field_type: "code".to_string(),
473                            format: None,
474                            enum_name: Some("Haushaltskunde".to_string()),
475                            valid_codes: vec![
476                                CodeValue {
477                                    code: "Z15".to_string(),
478                                    meaning: "Ja".to_string(),
479                                    enum_name: None,
480                                },
481                                CodeValue {
482                                    code: "Z18".to_string(),
483                                    meaning: "Nein".to_string(),
484                                    enum_name: None,
485                                },
486                            ],
487                            child_group: None,
488                        },
489                    ],
490                },
491                EntityRequirement {
492                    entity: "Geschaeftspartner".to_string(),
493                    bo4e_type: "Geschaeftspartner".to_string(),
494                    companion_type: Some("GeschaeftspartnerEdifact".to_string()),
495                    ahb_status: "Muss".to_string(),
496                    is_array: true,
497                    map_key: None,
498                    scope: EntityScope::Transaction,
499                    fields: vec![FieldRequirement {
500                        bo4e_name: "identifikation".to_string(),
501                        ahb_status: "X".to_string(),
502                        is_companion: false,
503                        field_type: "data".to_string(),
504                        format: None,
505                        enum_name: None,
506                        valid_codes: vec![],
507                        child_group: None,
508                    }],
509                },
510            ],
511        }
512    }
513
514    #[test]
515    fn test_validate_complete_json() {
516        let reqs = sample_requirements();
517        let json = json!({
518            "prozessdaten": {
519                "vorgangId": "ABC123",
520                "transaktionsgrund": "E01"
521            },
522            "marktlokation": {
523                "marktlokationsId": "51234567890",
524                "haushaltskunde": "Z15"
525            },
526            "geschaeftspartner": [
527                { "identifikation": "9900000000003" }
528            ]
529        });
530
531        let errors = validate_pid_json(&json, &reqs);
532        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
533    }
534
535    #[test]
536    fn test_validate_missing_entity() {
537        let reqs = sample_requirements();
538        let json = json!({
539            "prozessdaten": {
540                "vorgangId": "ABC123",
541                "transaktionsgrund": "E01"
542            },
543            "geschaeftspartner": [
544                { "identifikation": "9900000000003" }
545            ]
546        });
547        // Marktlokation is missing
548
549        let errors = validate_pid_json(&json, &reqs);
550        assert_eq!(errors.len(), 1);
551        match &errors[0] {
552            PidValidationError::MissingEntity {
553                entity,
554                ahb_status,
555                severity,
556            } => {
557                assert_eq!(entity, "Marktlokation");
558                assert_eq!(ahb_status, "Muss");
559                assert_eq!(severity, &Severity::Error);
560            }
561            other => panic!("Expected MissingEntity, got: {other:?}"),
562        }
563
564        // Display check
565        let msg = errors[0].to_string();
566        assert!(msg.contains("ERROR"));
567        assert!(msg.contains("Marktlokation"));
568        assert!(msg.contains("Muss"));
569    }
570
571    #[test]
572    fn test_validate_missing_field() {
573        let reqs = sample_requirements();
574        let json = json!({
575            "prozessdaten": {
576                "transaktionsgrund": "E01"
577                // vorgangId is missing
578            },
579            "marktlokation": {
580                "marktlokationsId": "51234567890",
581                "haushaltskunde": "Z15"
582            },
583            "geschaeftspartner": [
584                { "identifikation": "9900000000003" }
585            ]
586        });
587
588        let errors = validate_pid_json(&json, &reqs);
589        assert_eq!(errors.len(), 1);
590        match &errors[0] {
591            PidValidationError::MissingField {
592                entity,
593                field,
594                ahb_status,
595                severity,
596                ..
597            } => {
598                assert_eq!(entity, "Prozessdaten");
599                assert_eq!(field, "vorgangId");
600                assert_eq!(ahb_status, "X");
601                assert_eq!(severity, &Severity::Error);
602            }
603            other => panic!("Expected MissingField, got: {other:?}"),
604        }
605
606        let msg = errors[0].to_string();
607        assert!(msg.contains("ERROR"));
608        assert!(msg.contains("Prozessdaten.vorgangId"));
609    }
610
611    #[test]
612    fn test_validate_invalid_code() {
613        let reqs = sample_requirements();
614        let json = json!({
615            "prozessdaten": {
616                "vorgangId": "ABC123",
617                "transaktionsgrund": "E01"
618            },
619            "marktlokation": {
620                "marktlokationsId": "51234567890",
621                "haushaltskunde": "Z99"  // Invalid code
622            },
623            "geschaeftspartner": [
624                { "identifikation": "9900000000003" }
625            ]
626        });
627
628        let errors = validate_pid_json(&json, &reqs);
629        assert_eq!(errors.len(), 1);
630        match &errors[0] {
631            PidValidationError::InvalidCode {
632                entity,
633                field,
634                value,
635                valid_values,
636            } => {
637                assert_eq!(entity, "Marktlokation");
638                assert_eq!(field, "haushaltskunde");
639                assert_eq!(value, "Z99");
640                assert_eq!(valid_values.len(), 2);
641                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
642                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
643            }
644            other => panic!("Expected InvalidCode, got: {other:?}"),
645        }
646
647        let msg = errors[0].to_string();
648        assert!(msg.contains("INVALID"));
649        assert!(msg.contains("Z99"));
650        assert!(msg.contains("Z15"));
651    }
652
653    #[test]
654    fn test_validate_array_entity() {
655        let reqs = sample_requirements();
656        let json = json!({
657            "prozessdaten": {
658                "vorgangId": "ABC123",
659                "transaktionsgrund": "E01"
660            },
661            "marktlokation": {
662                "marktlokationsId": "51234567890",
663                "haushaltskunde": "Z15"
664            },
665            "geschaeftspartner": [
666                { "identifikation": "9900000000003" },
667                { }  // Missing identifikation in second element
668            ]
669        });
670
671        let errors = validate_pid_json(&json, &reqs);
672        assert_eq!(errors.len(), 1);
673        match &errors[0] {
674            PidValidationError::MissingField { entity, field, .. } => {
675                assert_eq!(entity, "Geschaeftspartner");
676                assert_eq!(field, "identifikation");
677            }
678            other => panic!("Expected MissingField, got: {other:?}"),
679        }
680    }
681
682    #[test]
683    fn test_to_camel_case() {
684        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
685        assert_eq!(
686            to_camel_case("RuhendeMarktlokation"),
687            "ruhendeMarktlokation"
688        );
689        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
690        assert_eq!(to_camel_case(""), "");
691    }
692
693    #[test]
694    fn test_snake_to_camel_case() {
695        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
696        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
697        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
698        assert_eq!(snake_to_camel_case(""), "");
699        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
700    }
701
702    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
703    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
704    #[test]
705    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
706        let reqs = PidRequirements {
707            pid: "55077".to_string(),
708            beschreibung: "Test camelCase fallback".to_string(),
709            entities: vec![EntityRequirement {
710                entity: "Zuordnung".to_string(),
711                bo4e_type: "Zuordnung".to_string(),
712                companion_type: None,
713                ahb_status: "Muss".to_string(),
714                is_array: false,
715                map_key: None,
716                scope: EntityScope::Transaction,
717                fields: vec![
718                    FieldRequirement {
719                        // snake_case as stored in TOML requirements
720                        bo4e_name: "code_codepflege".to_string(),
721                        ahb_status: "X".to_string(),
722                        is_companion: false,
723                        field_type: "data".to_string(),
724                        format: None,
725                        enum_name: None,
726                        valid_codes: vec![],
727                        child_group: None,
728                    },
729                    FieldRequirement {
730                        bo4e_name: "codeliste".to_string(),
731                        ahb_status: "X".to_string(),
732                        is_companion: false,
733                        field_type: "data".to_string(),
734                        format: None,
735                        enum_name: None,
736                        valid_codes: vec![],
737                        child_group: None,
738                    },
739                ],
740            }],
741        };
742
743        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
744        // code_codepflege → codeCodepflege
745        let json_camel = json!({
746            "zuordnung": {
747                "codeCodepflege": "DE_BDEW",
748                "codeliste": "6"
749            }
750        });
751
752        let errors = validate_pid_json(&json_camel, &reqs);
753        assert!(
754            errors.is_empty(),
755            "Expected no errors when field is present under camelCase key, got: {errors:?}"
756        );
757
758        // Also verify that snake_case key in JSON still works (backward compat).
759        let json_snake = json!({
760            "zuordnung": {
761                "code_codepflege": "DE_BDEW",
762                "codeliste": "6"
763            }
764        });
765
766        let errors = validate_pid_json(&json_snake, &reqs);
767        assert!(
768            errors.is_empty(),
769            "Expected no errors when field is present under snake_case key, got: {errors:?}"
770        );
771
772        // When the field is truly absent, a MissingField error must still be raised.
773        let json_missing = json!({
774            "zuordnung": {
775                "codeliste": "6"
776            }
777        });
778
779        let errors = validate_pid_json(&json_missing, &reqs);
780        assert_eq!(errors.len(), 1);
781        match &errors[0] {
782            PidValidationError::MissingField { field, .. } => {
783                assert_eq!(field, "code_codepflege");
784            }
785            other => panic!("Expected MissingField, got: {other:?}"),
786        }
787    }
788
789    #[test]
790    fn test_is_unconditionally_required() {
791        assert!(is_unconditionally_required("X"));
792        assert!(is_unconditionally_required("Muss"));
793        assert!(is_unconditionally_required("Soll"));
794        assert!(!is_unconditionally_required("Kann"));
795        assert!(!is_unconditionally_required("[1]"));
796        assert!(!is_unconditionally_required(""));
797    }
798
799    #[test]
800    fn test_validation_report_display() {
801        let errors = vec![
802            PidValidationError::MissingEntity {
803                entity: "Marktlokation".to_string(),
804                ahb_status: "Muss".to_string(),
805                severity: Severity::Error,
806            },
807            PidValidationError::MissingField {
808                entity: "Prozessdaten".to_string(),
809                field: "vorgangId".to_string(),
810                ahb_status: "X".to_string(),
811                rust_type: None,
812                valid_values: vec![],
813                severity: Severity::Error,
814            },
815        ];
816        let report = ValidationReport(errors);
817        assert!(report.has_errors());
818        assert_eq!(report.len(), 2);
819        assert!(!report.is_empty());
820
821        let display = report.to_string();
822        assert!(display.contains("missing entity 'Marktlokation'"));
823        assert!(display.contains("missing Prozessdaten.vorgangId"));
824    }
825
826    #[test]
827    fn test_missing_field_with_type_and_values_display() {
828        let err = PidValidationError::MissingField {
829            entity: "Marktlokation".to_string(),
830            field: "haushaltskunde".to_string(),
831            ahb_status: "Muss".to_string(),
832            rust_type: Some("Haushaltskunde".to_string()),
833            valid_values: vec![
834                ("Z15".to_string(), "Ja".to_string()),
835                ("Z18".to_string(), "Nein".to_string()),
836            ],
837            severity: Severity::Error,
838        };
839        let msg = err.to_string();
840        assert!(msg.contains("type: Haushaltskunde"));
841        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
842    }
843
844    #[test]
845    fn test_optional_fields_not_flagged() {
846        let reqs = PidRequirements {
847            pid: "99999".to_string(),
848            beschreibung: "Test".to_string(),
849            entities: vec![EntityRequirement {
850                entity: "Test".to_string(),
851                bo4e_type: "Test".to_string(),
852                companion_type: None,
853                ahb_status: "Kann".to_string(),
854                is_array: false,
855                map_key: None,
856                scope: EntityScope::Transaction,
857                fields: vec![FieldRequirement {
858                    bo4e_name: "optionalField".to_string(),
859                    ahb_status: "Kann".to_string(),
860                    is_companion: false,
861                    field_type: "data".to_string(),
862                    format: None,
863                    enum_name: None,
864                    valid_codes: vec![],
865                    child_group: None,
866                }],
867            }],
868        };
869
870        // Entity missing but optional — no error
871        let errors = validate_pid_json(&json!({}), &reqs);
872        assert!(errors.is_empty());
873
874        // Entity present, field missing but optional — no error
875        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
876        assert!(errors.is_empty());
877    }
878
879    /// Regression test for issue #48: nested dot-path fields reported as missing
880    /// even when present (e.g. `produktIdentifikation.funktion`).
881    #[test]
882    fn test_nested_dot_path_fields_not_falsely_missing() {
883        let reqs = PidRequirements {
884            pid: "55001".to_string(),
885            beschreibung: "Test nested paths".to_string(),
886            entities: vec![EntityRequirement {
887                entity: "ProduktpaketDaten".to_string(),
888                bo4e_type: "ProduktpaketDaten".to_string(),
889                companion_type: None,
890                ahb_status: "Muss".to_string(),
891                is_array: true,
892                map_key: None,
893                scope: EntityScope::Transaction,
894                fields: vec![
895                    FieldRequirement {
896                        bo4e_name: "produktIdentifikation.funktion".to_string(),
897                        ahb_status: "X".to_string(),
898                        is_companion: false,
899                        field_type: "code".to_string(),
900                        format: None,
901                        enum_name: Some("Produktidentifikation".to_string()),
902                        valid_codes: vec![CodeValue {
903                            code: "5".to_string(),
904                            meaning: "Produktidentifikation".to_string(),
905                            enum_name: None,
906                        }],
907                        child_group: None,
908                    },
909                    FieldRequirement {
910                        bo4e_name: "produktMerkmal.code".to_string(),
911                        ahb_status: "X".to_string(),
912                        is_companion: false,
913                        field_type: "code".to_string(),
914                        format: None,
915                        enum_name: None,
916                        valid_codes: vec![],
917                        child_group: None,
918                    },
919                ],
920            }],
921        };
922
923        // Exact JSON from issue #48
924        let json = json!({
925            "produktpaketDaten": [{
926                "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
927                "produktMerkmal": { "code": "ZH9" }
928            }]
929        });
930
931        let errors = validate_pid_json(&json, &reqs);
932        assert!(
933            errors.is_empty(),
934            "Nested dot-path fields should be found (issue #48), got: {errors:?}"
935        );
936    }
937
938    #[test]
939    fn test_nested_dot_path_truly_missing() {
940        let reqs = PidRequirements {
941            pid: "55001".to_string(),
942            beschreibung: "Test nested paths missing".to_string(),
943            entities: vec![EntityRequirement {
944                entity: "ProduktpaketDaten".to_string(),
945                bo4e_type: "ProduktpaketDaten".to_string(),
946                companion_type: None,
947                ahb_status: "Muss".to_string(),
948                is_array: true,
949                map_key: None,
950                scope: EntityScope::Transaction,
951                fields: vec![FieldRequirement {
952                    bo4e_name: "produktIdentifikation.funktion".to_string(),
953                    ahb_status: "X".to_string(),
954                    is_companion: false,
955                    field_type: "data".to_string(),
956                    format: None,
957                    enum_name: None,
958                    valid_codes: vec![],
959                    child_group: None,
960                }],
961            }],
962        };
963
964        // Parent exists but nested field is missing
965        let json = json!({
966            "produktpaketDaten": [{
967                "produktIdentifikation": { "id": "123" }
968            }]
969        });
970
971        let errors = validate_pid_json(&json, &reqs);
972        assert_eq!(errors.len(), 1, "Should report missing nested field");
973        match &errors[0] {
974            PidValidationError::MissingField { field, .. } => {
975                assert_eq!(field, "produktIdentifikation.funktion");
976            }
977            other => panic!("Expected MissingField, got: {other:?}"),
978        }
979    }
980}