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/// Traverse a dot-separated path in a JSON value, trying both the original
207/// key and its camelCase variant at each level.
208fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
209    let mut current = json;
210    for part in path.split('.') {
211        current = current.get(part).or_else(|| {
212            if part.contains('_') {
213                current.get(snake_to_camel_case(part))
214            } else {
215                None
216            }
217        })?;
218    }
219    Some(current)
220}
221
222/// Validate fields within a single entity JSON object.
223fn validate_entity_fields(
224    entity_json: &Value,
225    entity_req: &EntityRequirement,
226    errors: &mut Vec<PidValidationError>,
227) {
228    for field_req in &entity_req.fields {
229        // Traverse dot-separated paths (e.g. "produktIdentifikation.funktion")
230        // and try camelCase variants at each level for typed struct compatibility.
231        let val = get_nested(entity_json, &field_req.bo4e_name);
232
233        match val {
234            None => {
235                if is_unconditionally_required(&field_req.ahb_status) {
236                    errors.push(PidValidationError::MissingField {
237                        entity: entity_req.entity.clone(),
238                        field: field_req.bo4e_name.clone(),
239                        ahb_status: field_req.ahb_status.clone(),
240                        rust_type: field_req.enum_name.clone(),
241                        valid_values: code_values_to_tuples(&field_req.valid_codes),
242                        severity: Severity::Error,
243                    });
244                }
245            }
246            Some(val) => {
247                if !field_req.valid_codes.is_empty() {
248                    validate_code_value(val, entity_req, field_req, errors);
249                }
250            }
251        }
252    }
253}
254
255/// Validate that a code field's value is in the allowed set.
256fn validate_code_value(
257    val: &Value,
258    entity_req: &EntityRequirement,
259    field_req: &FieldRequirement,
260    errors: &mut Vec<PidValidationError>,
261) {
262    let value_str = match val.as_str() {
263        Some(s) => s,
264        None => return, // Non-string values skip code validation
265    };
266
267    let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
268    if !is_valid {
269        errors.push(PidValidationError::InvalidCode {
270            entity: entity_req.entity.clone(),
271            field: field_req.bo4e_name.clone(),
272            value: value_str.to_string(),
273            valid_values: code_values_to_tuples(&field_req.valid_codes),
274        });
275    }
276}
277
278/// Convert CodeValue vec to (code, meaning) tuples.
279fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
280    codes
281        .iter()
282        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
283        .collect()
284}
285
286/// Convert PascalCase entity name to camelCase JSON key.
287///
288/// "Prozessdaten" → "prozessdaten"
289/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
290/// "Marktlokation" → "marktlokation"
291fn to_camel_case(s: &str) -> String {
292    if s.is_empty() {
293        return String::new();
294    }
295    let mut chars = s.chars();
296    let first = chars.next().unwrap();
297    let mut result = first.to_lowercase().to_string();
298    result.extend(chars);
299    result
300}
301
302/// Convert a snake_case field name to camelCase.
303///
304/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
305/// the validator to find fields in JSON that was produced by typed structs even when
306/// the requirement stores the field name as snake_case (as it comes from TOML).
307///
308/// Examples:
309/// - `"code_codepflege"` → `"codeCodepflege"`
310/// - `"vorgang_id"` → `"vorgangId"`
311/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
312fn snake_to_camel_case(s: &str) -> String {
313    let mut result = String::with_capacity(s.len());
314    let mut capitalize_next = false;
315    for ch in s.chars() {
316        if ch == '_' {
317            capitalize_next = true;
318        } else if capitalize_next {
319            result.extend(ch.to_uppercase());
320            capitalize_next = false;
321        } else {
322            result.push(ch);
323        }
324    }
325    result
326}
327
328/// Returns true if the AHB status indicates an unconditionally required field.
329fn is_unconditionally_required(ahb_status: &str) -> bool {
330    matches!(ahb_status, "X" | "Muss" | "Soll")
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::pid_requirements::{
337        CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
338    };
339    use serde_json::json;
340
341    fn sample_requirements() -> PidRequirements {
342        PidRequirements {
343            pid: "55001".to_string(),
344            beschreibung: "Anmeldung verb. MaLo".to_string(),
345            entities: vec![
346                EntityRequirement {
347                    entity: "Prozessdaten".to_string(),
348                    bo4e_type: "Prozessdaten".to_string(),
349                    companion_type: None,
350                    ahb_status: "Muss".to_string(),
351                    is_array: false,
352                    map_key: None,
353                    fields: vec![
354                        FieldRequirement {
355                            bo4e_name: "vorgangId".to_string(),
356                            ahb_status: "X".to_string(),
357                            is_companion: false,
358                            field_type: "data".to_string(),
359                            format: None,
360                            enum_name: None,
361                            valid_codes: vec![],
362                            child_group: None,
363                        },
364                        FieldRequirement {
365                            bo4e_name: "transaktionsgrund".to_string(),
366                            ahb_status: "X".to_string(),
367                            is_companion: false,
368                            field_type: "code".to_string(),
369                            format: None,
370                            enum_name: Some("Transaktionsgrund".to_string()),
371                            valid_codes: vec![
372                                CodeValue {
373                                    code: "E01".to_string(),
374                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
375                                    enum_name: None,
376                                },
377                                CodeValue {
378                                    code: "E03".to_string(),
379                                    meaning: "Wechsel".to_string(),
380                                    enum_name: None,
381                                },
382                            ],
383                            child_group: None,
384                        },
385                    ],
386                },
387                EntityRequirement {
388                    entity: "Marktlokation".to_string(),
389                    bo4e_type: "Marktlokation".to_string(),
390                    companion_type: Some("MarktlokationEdifact".to_string()),
391                    ahb_status: "Muss".to_string(),
392                    is_array: false,
393                    map_key: None,
394                    fields: vec![
395                        FieldRequirement {
396                            bo4e_name: "marktlokationsId".to_string(),
397                            ahb_status: "X".to_string(),
398                            is_companion: false,
399                            field_type: "data".to_string(),
400                            format: None,
401                            enum_name: None,
402                            valid_codes: vec![],
403                            child_group: None,
404                        },
405                        FieldRequirement {
406                            bo4e_name: "haushaltskunde".to_string(),
407                            ahb_status: "X".to_string(),
408                            is_companion: false,
409                            field_type: "code".to_string(),
410                            format: None,
411                            enum_name: Some("Haushaltskunde".to_string()),
412                            valid_codes: vec![
413                                CodeValue {
414                                    code: "Z15".to_string(),
415                                    meaning: "Ja".to_string(),
416                                    enum_name: None,
417                                },
418                                CodeValue {
419                                    code: "Z18".to_string(),
420                                    meaning: "Nein".to_string(),
421                                    enum_name: None,
422                                },
423                            ],
424                            child_group: None,
425                        },
426                    ],
427                },
428                EntityRequirement {
429                    entity: "Geschaeftspartner".to_string(),
430                    bo4e_type: "Geschaeftspartner".to_string(),
431                    companion_type: Some("GeschaeftspartnerEdifact".to_string()),
432                    ahb_status: "Muss".to_string(),
433                    is_array: true,
434                    map_key: None,
435                    fields: vec![FieldRequirement {
436                        bo4e_name: "identifikation".to_string(),
437                        ahb_status: "X".to_string(),
438                        is_companion: false,
439                        field_type: "data".to_string(),
440                        format: None,
441                        enum_name: None,
442                        valid_codes: vec![],
443                        child_group: None,
444                    }],
445                },
446            ],
447        }
448    }
449
450    #[test]
451    fn test_validate_complete_json() {
452        let reqs = sample_requirements();
453        let json = json!({
454            "prozessdaten": {
455                "vorgangId": "ABC123",
456                "transaktionsgrund": "E01"
457            },
458            "marktlokation": {
459                "marktlokationsId": "51234567890",
460                "haushaltskunde": "Z15"
461            },
462            "geschaeftspartner": [
463                { "identifikation": "9900000000003" }
464            ]
465        });
466
467        let errors = validate_pid_json(&json, &reqs);
468        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
469    }
470
471    #[test]
472    fn test_validate_missing_entity() {
473        let reqs = sample_requirements();
474        let json = json!({
475            "prozessdaten": {
476                "vorgangId": "ABC123",
477                "transaktionsgrund": "E01"
478            },
479            "geschaeftspartner": [
480                { "identifikation": "9900000000003" }
481            ]
482        });
483        // Marktlokation is missing
484
485        let errors = validate_pid_json(&json, &reqs);
486        assert_eq!(errors.len(), 1);
487        match &errors[0] {
488            PidValidationError::MissingEntity {
489                entity,
490                ahb_status,
491                severity,
492            } => {
493                assert_eq!(entity, "Marktlokation");
494                assert_eq!(ahb_status, "Muss");
495                assert_eq!(severity, &Severity::Error);
496            }
497            other => panic!("Expected MissingEntity, got: {other:?}"),
498        }
499
500        // Display check
501        let msg = errors[0].to_string();
502        assert!(msg.contains("ERROR"));
503        assert!(msg.contains("Marktlokation"));
504        assert!(msg.contains("Muss"));
505    }
506
507    #[test]
508    fn test_validate_missing_field() {
509        let reqs = sample_requirements();
510        let json = json!({
511            "prozessdaten": {
512                "transaktionsgrund": "E01"
513                // vorgangId is missing
514            },
515            "marktlokation": {
516                "marktlokationsId": "51234567890",
517                "haushaltskunde": "Z15"
518            },
519            "geschaeftspartner": [
520                { "identifikation": "9900000000003" }
521            ]
522        });
523
524        let errors = validate_pid_json(&json, &reqs);
525        assert_eq!(errors.len(), 1);
526        match &errors[0] {
527            PidValidationError::MissingField {
528                entity,
529                field,
530                ahb_status,
531                severity,
532                ..
533            } => {
534                assert_eq!(entity, "Prozessdaten");
535                assert_eq!(field, "vorgangId");
536                assert_eq!(ahb_status, "X");
537                assert_eq!(severity, &Severity::Error);
538            }
539            other => panic!("Expected MissingField, got: {other:?}"),
540        }
541
542        let msg = errors[0].to_string();
543        assert!(msg.contains("ERROR"));
544        assert!(msg.contains("Prozessdaten.vorgangId"));
545    }
546
547    #[test]
548    fn test_validate_invalid_code() {
549        let reqs = sample_requirements();
550        let json = json!({
551            "prozessdaten": {
552                "vorgangId": "ABC123",
553                "transaktionsgrund": "E01"
554            },
555            "marktlokation": {
556                "marktlokationsId": "51234567890",
557                "haushaltskunde": "Z99"  // Invalid code
558            },
559            "geschaeftspartner": [
560                { "identifikation": "9900000000003" }
561            ]
562        });
563
564        let errors = validate_pid_json(&json, &reqs);
565        assert_eq!(errors.len(), 1);
566        match &errors[0] {
567            PidValidationError::InvalidCode {
568                entity,
569                field,
570                value,
571                valid_values,
572            } => {
573                assert_eq!(entity, "Marktlokation");
574                assert_eq!(field, "haushaltskunde");
575                assert_eq!(value, "Z99");
576                assert_eq!(valid_values.len(), 2);
577                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
578                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
579            }
580            other => panic!("Expected InvalidCode, got: {other:?}"),
581        }
582
583        let msg = errors[0].to_string();
584        assert!(msg.contains("INVALID"));
585        assert!(msg.contains("Z99"));
586        assert!(msg.contains("Z15"));
587    }
588
589    #[test]
590    fn test_validate_array_entity() {
591        let reqs = sample_requirements();
592        let json = json!({
593            "prozessdaten": {
594                "vorgangId": "ABC123",
595                "transaktionsgrund": "E01"
596            },
597            "marktlokation": {
598                "marktlokationsId": "51234567890",
599                "haushaltskunde": "Z15"
600            },
601            "geschaeftspartner": [
602                { "identifikation": "9900000000003" },
603                { }  // Missing identifikation in second element
604            ]
605        });
606
607        let errors = validate_pid_json(&json, &reqs);
608        assert_eq!(errors.len(), 1);
609        match &errors[0] {
610            PidValidationError::MissingField { entity, field, .. } => {
611                assert_eq!(entity, "Geschaeftspartner");
612                assert_eq!(field, "identifikation");
613            }
614            other => panic!("Expected MissingField, got: {other:?}"),
615        }
616    }
617
618    #[test]
619    fn test_to_camel_case() {
620        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
621        assert_eq!(
622            to_camel_case("RuhendeMarktlokation"),
623            "ruhendeMarktlokation"
624        );
625        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
626        assert_eq!(to_camel_case(""), "");
627    }
628
629    #[test]
630    fn test_snake_to_camel_case() {
631        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
632        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
633        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
634        assert_eq!(snake_to_camel_case(""), "");
635        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
636    }
637
638    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
639    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
640    #[test]
641    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
642        let reqs = PidRequirements {
643            pid: "55077".to_string(),
644            beschreibung: "Test camelCase fallback".to_string(),
645            entities: vec![EntityRequirement {
646                entity: "Zuordnung".to_string(),
647                bo4e_type: "Zuordnung".to_string(),
648                companion_type: None,
649                ahb_status: "Muss".to_string(),
650                is_array: false,
651                map_key: None,
652                fields: vec![
653                    FieldRequirement {
654                        // snake_case as stored in TOML requirements
655                        bo4e_name: "code_codepflege".to_string(),
656                        ahb_status: "X".to_string(),
657                        is_companion: false,
658                        field_type: "data".to_string(),
659                        format: None,
660                        enum_name: None,
661                        valid_codes: vec![],
662                        child_group: None,
663                    },
664                    FieldRequirement {
665                        bo4e_name: "codeliste".to_string(),
666                        ahb_status: "X".to_string(),
667                        is_companion: false,
668                        field_type: "data".to_string(),
669                        format: None,
670                        enum_name: None,
671                        valid_codes: vec![],
672                        child_group: None,
673                    },
674                ],
675            }],
676        };
677
678        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
679        // code_codepflege → codeCodepflege
680        let json_camel = json!({
681            "zuordnung": {
682                "codeCodepflege": "DE_BDEW",
683                "codeliste": "6"
684            }
685        });
686
687        let errors = validate_pid_json(&json_camel, &reqs);
688        assert!(
689            errors.is_empty(),
690            "Expected no errors when field is present under camelCase key, got: {errors:?}"
691        );
692
693        // Also verify that snake_case key in JSON still works (backward compat).
694        let json_snake = json!({
695            "zuordnung": {
696                "code_codepflege": "DE_BDEW",
697                "codeliste": "6"
698            }
699        });
700
701        let errors = validate_pid_json(&json_snake, &reqs);
702        assert!(
703            errors.is_empty(),
704            "Expected no errors when field is present under snake_case key, got: {errors:?}"
705        );
706
707        // When the field is truly absent, a MissingField error must still be raised.
708        let json_missing = json!({
709            "zuordnung": {
710                "codeliste": "6"
711            }
712        });
713
714        let errors = validate_pid_json(&json_missing, &reqs);
715        assert_eq!(errors.len(), 1);
716        match &errors[0] {
717            PidValidationError::MissingField { field, .. } => {
718                assert_eq!(field, "code_codepflege");
719            }
720            other => panic!("Expected MissingField, got: {other:?}"),
721        }
722    }
723
724    #[test]
725    fn test_is_unconditionally_required() {
726        assert!(is_unconditionally_required("X"));
727        assert!(is_unconditionally_required("Muss"));
728        assert!(is_unconditionally_required("Soll"));
729        assert!(!is_unconditionally_required("Kann"));
730        assert!(!is_unconditionally_required("[1]"));
731        assert!(!is_unconditionally_required(""));
732    }
733
734    #[test]
735    fn test_validation_report_display() {
736        let errors = vec![
737            PidValidationError::MissingEntity {
738                entity: "Marktlokation".to_string(),
739                ahb_status: "Muss".to_string(),
740                severity: Severity::Error,
741            },
742            PidValidationError::MissingField {
743                entity: "Prozessdaten".to_string(),
744                field: "vorgangId".to_string(),
745                ahb_status: "X".to_string(),
746                rust_type: None,
747                valid_values: vec![],
748                severity: Severity::Error,
749            },
750        ];
751        let report = ValidationReport(errors);
752        assert!(report.has_errors());
753        assert_eq!(report.len(), 2);
754        assert!(!report.is_empty());
755
756        let display = report.to_string();
757        assert!(display.contains("missing entity 'Marktlokation'"));
758        assert!(display.contains("missing Prozessdaten.vorgangId"));
759    }
760
761    #[test]
762    fn test_missing_field_with_type_and_values_display() {
763        let err = PidValidationError::MissingField {
764            entity: "Marktlokation".to_string(),
765            field: "haushaltskunde".to_string(),
766            ahb_status: "Muss".to_string(),
767            rust_type: Some("Haushaltskunde".to_string()),
768            valid_values: vec![
769                ("Z15".to_string(), "Ja".to_string()),
770                ("Z18".to_string(), "Nein".to_string()),
771            ],
772            severity: Severity::Error,
773        };
774        let msg = err.to_string();
775        assert!(msg.contains("type: Haushaltskunde"));
776        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
777    }
778
779    #[test]
780    fn test_optional_fields_not_flagged() {
781        let reqs = PidRequirements {
782            pid: "99999".to_string(),
783            beschreibung: "Test".to_string(),
784            entities: vec![EntityRequirement {
785                entity: "Test".to_string(),
786                bo4e_type: "Test".to_string(),
787                companion_type: None,
788                ahb_status: "Kann".to_string(),
789                is_array: false,
790                map_key: None,
791                fields: vec![FieldRequirement {
792                    bo4e_name: "optionalField".to_string(),
793                    ahb_status: "Kann".to_string(),
794                    is_companion: false,
795                    field_type: "data".to_string(),
796                    format: None,
797                    enum_name: None,
798                    valid_codes: vec![],
799                    child_group: None,
800                }],
801            }],
802        };
803
804        // Entity missing but optional — no error
805        let errors = validate_pid_json(&json!({}), &reqs);
806        assert!(errors.is_empty());
807
808        // Entity present, field missing but optional — no error
809        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
810        assert!(errors.is_empty());
811    }
812
813    /// Regression test for issue #48: nested dot-path fields reported as missing
814    /// even when present (e.g. `produktIdentifikation.funktion`).
815    #[test]
816    fn test_nested_dot_path_fields_not_falsely_missing() {
817        let reqs = PidRequirements {
818            pid: "55001".to_string(),
819            beschreibung: "Test nested paths".to_string(),
820            entities: vec![EntityRequirement {
821                entity: "ProduktpaketDaten".to_string(),
822                bo4e_type: "ProduktpaketDaten".to_string(),
823                companion_type: None,
824                ahb_status: "Muss".to_string(),
825                is_array: true,
826                map_key: None,
827                fields: vec![
828                    FieldRequirement {
829                        bo4e_name: "produktIdentifikation.funktion".to_string(),
830                        ahb_status: "X".to_string(),
831                        is_companion: false,
832                        field_type: "code".to_string(),
833                        format: None,
834                        enum_name: Some("Produktidentifikation".to_string()),
835                        valid_codes: vec![CodeValue {
836                            code: "5".to_string(),
837                            meaning: "Produktidentifikation".to_string(),
838                            enum_name: None,
839                        }],
840                        child_group: None,
841                    },
842                    FieldRequirement {
843                        bo4e_name: "produktMerkmal.code".to_string(),
844                        ahb_status: "X".to_string(),
845                        is_companion: false,
846                        field_type: "code".to_string(),
847                        format: None,
848                        enum_name: None,
849                        valid_codes: vec![],
850                        child_group: None,
851                    },
852                ],
853            }],
854        };
855
856        // Exact JSON from issue #48
857        let json = json!({
858            "produktpaketDaten": [{
859                "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
860                "produktMerkmal": { "code": "ZH9" }
861            }]
862        });
863
864        let errors = validate_pid_json(&json, &reqs);
865        assert!(
866            errors.is_empty(),
867            "Nested dot-path fields should be found (issue #48), got: {errors:?}"
868        );
869    }
870
871    #[test]
872    fn test_nested_dot_path_truly_missing() {
873        let reqs = PidRequirements {
874            pid: "55001".to_string(),
875            beschreibung: "Test nested paths missing".to_string(),
876            entities: vec![EntityRequirement {
877                entity: "ProduktpaketDaten".to_string(),
878                bo4e_type: "ProduktpaketDaten".to_string(),
879                companion_type: None,
880                ahb_status: "Muss".to_string(),
881                is_array: true,
882                map_key: None,
883                fields: vec![FieldRequirement {
884                    bo4e_name: "produktIdentifikation.funktion".to_string(),
885                    ahb_status: "X".to_string(),
886                    is_companion: false,
887                    field_type: "data".to_string(),
888                    format: None,
889                    enum_name: None,
890                    valid_codes: vec![],
891                    child_group: None,
892                }],
893            }],
894        };
895
896        // Parent exists but nested field is missing
897        let json = json!({
898            "produktpaketDaten": [{
899                "produktIdentifikation": { "id": "123" }
900            }]
901        });
902
903        let errors = validate_pid_json(&json, &reqs);
904        assert_eq!(errors.len(), 1, "Should report missing nested field");
905        match &errors[0] {
906            PidValidationError::MissingField { field, .. } => {
907                assert_eq!(field, "produktIdentifikation.funktion");
908            }
909            other => panic!("Expected MissingField, got: {other:?}"),
910        }
911    }
912}