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