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