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