Skip to main content

mig_bo4e/
pid_validation.rs

1//! PID validation errors — typed, LLM-consumable error reports.
2
3use std::borrow::Cow;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt;
6
7use serde_json::Value;
8
9use crate::pid_requirements::{
10    CodeValue, EntityRequirement, EntityScope, EntityVariantRequirement, FieldRequirement,
11    PidRequirements,
12};
13
14/// Severity of a validation error.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Severity {
17    /// Field is unconditionally required (Muss/X) or condition evaluated to True.
18    Error,
19    /// Condition evaluated to Unknown (depends on external context).
20    Warning,
21}
22
23/// A single PID validation error.
24#[derive(Debug, Clone)]
25pub enum PidValidationError {
26    /// An entire entity is missing from the interchange.
27    MissingEntity {
28        entity: String,
29        ahb_status: String,
30        severity: Severity,
31    },
32    /// A required field is None/missing.
33    MissingField {
34        entity: String,
35        field: String,
36        ahb_status: String,
37        rust_type: Option<String>,
38        valid_values: Vec<(String, String)>,
39        severity: Severity,
40    },
41    /// A code field has a value not in the allowed set.
42    InvalidCode {
43        entity: String,
44        field: String,
45        value: String,
46        valid_values: Vec<(String, String)>,
47    },
48}
49
50impl PidValidationError {
51    pub fn severity(&self) -> &Severity {
52        match self {
53            Self::MissingEntity { severity, .. } => severity,
54            Self::MissingField { severity, .. } => severity,
55            Self::InvalidCode { .. } => &Severity::Error,
56        }
57    }
58
59    pub fn is_error(&self) -> bool {
60        matches!(self.severity(), Severity::Error)
61    }
62}
63
64impl fmt::Display for PidValidationError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            PidValidationError::MissingEntity {
68                entity,
69                ahb_status,
70                severity,
71            } => {
72                let label = severity_label(severity);
73                write!(
74                    f,
75                    "{label}: missing entity '{entity}' (required: {ahb_status})"
76                )
77            }
78            PidValidationError::MissingField {
79                entity,
80                field,
81                ahb_status,
82                rust_type,
83                valid_values,
84                severity,
85            } => {
86                let label = severity_label(severity);
87                write!(
88                    f,
89                    "{label}: missing {entity}.{field} (required: {ahb_status})"
90                )?;
91                if let Some(rt) = rust_type {
92                    write!(f, "\n  → type: {rt}")?;
93                }
94                if !valid_values.is_empty() {
95                    let codes: Vec<String> = valid_values
96                        .iter()
97                        .map(|(code, meaning)| {
98                            if meaning.is_empty() {
99                                code.clone()
100                            } else {
101                                format!("{code} ({meaning})")
102                            }
103                        })
104                        .collect();
105                    write!(f, "\n  → valid: {}", codes.join(", "))?;
106                }
107                Ok(())
108            }
109            PidValidationError::InvalidCode {
110                entity,
111                field,
112                value,
113                valid_values,
114            } => {
115                write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
116                if !valid_values.is_empty() {
117                    let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
118                    write!(f, "\n  → valid: {}", codes.join(", "))?;
119                }
120                Ok(())
121            }
122        }
123    }
124}
125
126fn severity_label(severity: &Severity) -> &'static str {
127    match severity {
128        Severity::Error => "ERROR",
129        Severity::Warning => "WARNING",
130    }
131}
132
133/// A collection of validation errors for a PID.
134pub struct ValidationReport(pub Vec<PidValidationError>);
135
136impl ValidationReport {
137    /// Returns true if the report contains any errors (not just warnings).
138    pub fn has_errors(&self) -> bool {
139        self.0.iter().any(|e| e.is_error())
140    }
141
142    /// Returns only the errors (not warnings).
143    pub fn errors(&self) -> Vec<&PidValidationError> {
144        self.0.iter().filter(|e| e.is_error()).collect()
145    }
146
147    /// Returns true if the report is empty (no errors or warnings).
148    pub fn is_empty(&self) -> bool {
149        self.0.is_empty()
150    }
151
152    /// Returns the number of validation errors.
153    pub fn len(&self) -> usize {
154        self.0.len()
155    }
156}
157
158impl fmt::Display for ValidationReport {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        for (i, err) in self.0.iter().enumerate() {
161            if i > 0 {
162                writeln!(f)?;
163            }
164            write!(f, "{err}")?;
165        }
166        Ok(())
167    }
168}
169
170// ── Validation Logic ──────────────────────────────────────────────────────
171
172/// Validate a BO4E JSON value against PID requirements.
173///
174/// Validates ALL entities (both message-level and transaction-level).
175/// Use [`validate_pid_json_transaction`] to validate only transaction-level entities.
176///
177/// Walks the requirements and checks:
178/// 1. Required entities are present in the JSON
179/// 2. Required fields are present within each entity
180/// 3. Code fields have values in the allowed set
181pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
182    validate_entities(json, &requirements.entities, None)
183}
184
185/// Validate only transaction-level entities in a BO4E JSON value.
186///
187/// Skips message-level entities (e.g., Marktteilnehmer, Kontakt from SG2/SG3)
188/// that are outside the transaction scope. Use this when validating a transaction
189/// payload that doesn't include message-level data.
190pub fn validate_pid_json_transaction(
191    json: &Value,
192    requirements: &PidRequirements,
193) -> Vec<PidValidationError> {
194    validate_entities(json, &requirements.entities, Some(EntityScope::Transaction))
195}
196
197/// Internal: validate entities, optionally filtering by scope.
198fn validate_entities(
199    json: &Value,
200    entities: &[EntityRequirement],
201    scope_filter: Option<EntityScope>,
202) -> Vec<PidValidationError> {
203    let mut errors = Vec::new();
204
205    for entity_req in entities {
206        // Skip entities not matching the requested scope
207        if let Some(ref scope) = scope_filter {
208            if &entity_req.scope != scope {
209                continue;
210            }
211        }
212
213        let key = to_camel_case(&entity_req.entity);
214
215        match json.get(&key) {
216            None | Some(serde_json::Value::Null) => {
217                if is_unconditionally_required(&entity_req.ahb_status) {
218                    errors.push(PidValidationError::MissingEntity {
219                        entity: entity_req.entity.clone(),
220                        ahb_status: entity_req.ahb_status.clone(),
221                        severity: Severity::Error,
222                    });
223                }
224            }
225            Some(val) => {
226                if entity_req.cardinality().is_list() {
227                    if let Some(arr) = val.as_array() {
228                        for element in arr {
229                            validate_entity_fields(element, entity_req, &mut errors);
230                        }
231                    } else {
232                        // Caller supplied a single object where the requirement
233                        // expects an array (e.g. typed structs that emit one
234                        // rep as an object instead of [obj]). Validate it as a
235                        // single rep rather than silently skipping field checks.
236                        validate_entity_fields(val, entity_req, &mut errors);
237                    }
238                } else {
239                    validate_entity_fields(val, entity_req, &mut errors);
240                }
241            }
242        }
243    }
244
245    errors
246}
247
248/// Traverse a dot-separated path in a JSON value, trying both the original
249/// key and its camelCase variant at each level.
250pub fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
251    let mut current = json;
252    for part in path.split('.') {
253        current = current.get(part).or_else(|| {
254            if part.contains('_') {
255                current.get(snake_to_camel_case(part))
256            } else {
257                None
258            }
259        })?;
260    }
261    Some(current)
262}
263
264/// Validate fields within a single entity JSON object.
265fn validate_entity_fields(
266    entity_json: &Value,
267    entity_req: &EntityRequirement,
268    errors: &mut Vec<PidValidationError>,
269) {
270    let fields = effective_field_requirements(entity_req, entity_json);
271    for field_req in fields.iter() {
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                validate_code_value(val, entity_req, field_req, errors);
294            }
295        }
296    }
297}
298
299/// Validate that a code field's value is in the allowed set.
300fn validate_code_value(
301    val: &Value,
302    entity_req: &EntityRequirement,
303    field_req: &FieldRequirement,
304    errors: &mut Vec<PidValidationError>,
305) {
306    if let Some(value) = invalid_code_value(val, field_req) {
307        errors.push(PidValidationError::InvalidCode {
308            entity: entity_req.entity.clone(),
309            field: field_req.bo4e_name.clone(),
310            value,
311            valid_values: code_values_to_tuples(&field_req.valid_codes),
312        });
313    }
314}
315
316/// The code carried by a BO4E code field: a plain string, or the `code` member of
317/// a `{code, meaning, enum}` object as written by `from_edifact`.
318pub fn code_field_value(val: &Value) -> Option<&str> {
319    val.as_str()
320        .or_else(|| val.get("code").and_then(|c| c.as_str()))
321}
322
323/// Check a code field's value against `field_req.valid_codes`.
324///
325/// Returns the offending value if it is invalid, `None` if it is valid or not a
326/// code value at all. A value is valid when it equals a valid raw EDIFACT code
327/// or the BO4E value the mapping's `enum_map` translates a valid code to — the
328/// two spellings `to_edifact` accepts.
329pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
330    if field_req.valid_codes.is_empty() {
331        return None;
332    }
333    let value = code_field_value(val)?;
334    let is_valid = field_req
335        .valid_codes
336        .iter()
337        .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
338    (!is_valid).then(|| value.to_string())
339}
340
341/// The field requirements that apply to one element of an entity.
342///
343/// For an entity without [`EntityRequirement::variants`] this is simply its
344/// `fields`. For a multi-variant entity (e.g. `Geschaeftspartner` fed by
345/// `sg12_z03`/`sg12_z07`/…), the element's discriminator value (raw code or
346/// `enum_map`ped BO4E value, string or code object) selects the matching
347/// variant, and the variant-owned fields are replaced by that variant's
348/// requirements — fields the variant's group lacks do not apply.
349///
350/// If the discriminator is missing or matches no variant, all variants are
351/// candidates and are combined leniently: codes are unioned, and a field keeps
352/// its AHB status only if every candidate agrees on it (otherwise it is treated
353/// as optional), so an unidentifiable element is never held to the required
354/// fields of a variant it may not be.
355pub fn effective_field_requirements<'a>(
356    entity_req: &'a EntityRequirement,
357    element: &Value,
358) -> Cow<'a, [FieldRequirement]> {
359    if entity_req.variants.is_empty() {
360        return Cow::Borrowed(&entity_req.fields);
361    }
362
363    // Candidate variants, per discriminator field.
364    let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
365    for v in &entity_req.variants {
366        by_field
367            .entry(v.discriminator_field.as_str())
368            .or_default()
369            .push(v);
370    }
371    let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
372    for (field, group) in by_field {
373        let value = get_nested(element, field).and_then(code_field_value);
374        let matched: Vec<&EntityVariantRequirement> = group
375            .iter()
376            .copied()
377            .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
378            .collect();
379        candidates.extend(if matched.is_empty() { group } else { matched });
380    }
381
382    // Fields governed by variants (in first-seen order).
383    let mut owned: Vec<&str> = Vec::new();
384    let mut owned_set: BTreeSet<&str> = BTreeSet::new();
385    for v in &entity_req.variants {
386        for f in &v.fields {
387            if owned_set.insert(f.bo4e_name.as_str()) {
388                owned.push(f.bo4e_name.as_str());
389            }
390        }
391    }
392
393    let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
394    for name in owned {
395        let reqs: Vec<&FieldRequirement> = candidates
396            .iter()
397            .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
398            .collect();
399        let Some((first, rest)) = reqs.split_first() else {
400            continue; // not applicable to any candidate variant
401        };
402        let mut field = (*first).clone();
403        let mut statuses_agree = reqs.len() == candidates.len();
404        for r in rest {
405            if r.ahb_status != field.ahb_status {
406                statuses_agree = false;
407            }
408            for cv in &r.valid_codes {
409                if !field.valid_codes.iter().any(|c| c.code == cv.code) {
410                    field.valid_codes.push(cv.clone());
411                }
412            }
413        }
414        if !statuses_agree {
415            field.ahb_status = String::new();
416        }
417        combined.insert(name, field);
418    }
419
420    let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
421    for f in &entity_req.fields {
422        if owned_set.contains(f.bo4e_name.as_str()) {
423            if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
424                result.push(c);
425            }
426        } else {
427            result.push(f.clone());
428        }
429    }
430    result.extend(combined.into_values());
431    Cow::Owned(result)
432}
433
434/// Convert CodeValue vec to (code, meaning) tuples.
435fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
436    codes
437        .iter()
438        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
439        .collect()
440}
441
442/// Convert PascalCase entity name to camelCase JSON key.
443///
444/// "Prozessdaten" → "prozessdaten"
445/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
446/// "Marktlokation" → "marktlokation"
447fn to_camel_case(s: &str) -> String {
448    if s.is_empty() {
449        return String::new();
450    }
451    let mut chars = s.chars();
452    let first = chars.next().unwrap();
453    let mut result = first.to_lowercase().to_string();
454    result.extend(chars);
455    result
456}
457
458/// Convert a snake_case field name to camelCase.
459///
460/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
461/// the validator to find fields in JSON that was produced by typed structs even when
462/// the requirement stores the field name as snake_case (as it comes from TOML).
463///
464/// Examples:
465/// - `"code_codepflege"` → `"codeCodepflege"`
466/// - `"vorgang_id"` → `"vorgangId"`
467/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
468fn snake_to_camel_case(s: &str) -> String {
469    let mut result = String::with_capacity(s.len());
470    let mut capitalize_next = false;
471    for ch in s.chars() {
472        if ch == '_' {
473            capitalize_next = true;
474        } else if capitalize_next {
475            result.extend(ch.to_uppercase());
476            capitalize_next = false;
477        } else {
478            result.push(ch);
479        }
480    }
481    result
482}
483
484/// Returns true if the AHB status indicates an unconditionally required field.
485fn is_unconditionally_required(ahb_status: &str) -> bool {
486    matches!(ahb_status, "X" | "Muss" | "Soll")
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::pid_requirements::{
493        Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
494    };
495    use serde_json::json;
496
497    fn sample_requirements() -> PidRequirements {
498        PidRequirements {
499            pid: "55001".to_string(),
500            beschreibung: "Anmeldung verb. MaLo".to_string(),
501            entities: vec![
502                EntityRequirement {
503                    entity: "Prozessdaten".to_string(),
504                    ref_type: Bo4eRefType::Object {
505                        type_name: "Prozessdaten".to_string(),
506
507                        cardinality: Cardinality::REQUIRED,
508                    },
509
510                    ahb_status: "Muss".to_string(),
511                    map_key: None,
512                    scope: EntityScope::Transaction,
513                    variants: vec![],
514                    fields: vec![
515                        FieldRequirement {
516                            bo4e_name: "vorgangId".to_string(),
517                            ahb_status: "X".to_string(),
518                            field_type: "data".to_string(),
519                            format: None,
520                            enum_name: None,
521                            valid_codes: vec![],
522                            child_group: None,
523                            ref_type: Bo4eRefType::Unknown,
524                        },
525                        FieldRequirement {
526                            bo4e_name: "transaktionsgrund".to_string(),
527                            ahb_status: "X".to_string(),
528                            field_type: "code".to_string(),
529                            format: None,
530                            enum_name: Some("Transaktionsgrund".to_string()),
531                            valid_codes: vec![
532                                CodeValue {
533                                    code: "E01".to_string(),
534                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
535                                    enum_name: None,
536                                    bo4e_value: None,
537                                },
538                                CodeValue {
539                                    code: "E03".to_string(),
540                                    meaning: "Wechsel".to_string(),
541                                    enum_name: None,
542                                    bo4e_value: None,
543                                },
544                            ],
545                            child_group: None,
546                            ref_type: Bo4eRefType::Unknown,
547                        },
548                    ],
549                },
550                EntityRequirement {
551                    entity: "Marktlokation".to_string(),
552                    ref_type: Bo4eRefType::Object {
553                        type_name: "Marktlokation".to_string(),
554
555                        cardinality: Cardinality::REQUIRED,
556                    },
557
558                    ahb_status: "Muss".to_string(),
559                    map_key: None,
560                    scope: EntityScope::Transaction,
561                    variants: vec![],
562                    fields: vec![
563                        FieldRequirement {
564                            bo4e_name: "marktlokationsId".to_string(),
565                            ahb_status: "X".to_string(),
566                            field_type: "data".to_string(),
567                            format: None,
568                            enum_name: None,
569                            valid_codes: vec![],
570                            child_group: None,
571                            ref_type: Bo4eRefType::Unknown,
572                        },
573                        FieldRequirement {
574                            bo4e_name: "haushaltskunde".to_string(),
575                            ahb_status: "X".to_string(),
576                            field_type: "code".to_string(),
577                            format: None,
578                            enum_name: Some("Haushaltskunde".to_string()),
579                            valid_codes: vec![
580                                CodeValue {
581                                    code: "Z15".to_string(),
582                                    meaning: "Ja".to_string(),
583                                    enum_name: None,
584                                    bo4e_value: None,
585                                },
586                                CodeValue {
587                                    code: "Z18".to_string(),
588                                    meaning: "Nein".to_string(),
589                                    enum_name: None,
590                                    bo4e_value: None,
591                                },
592                            ],
593                            child_group: None,
594                            ref_type: Bo4eRefType::Unknown,
595                        },
596                    ],
597                },
598                EntityRequirement {
599                    entity: "Geschaeftspartner".to_string(),
600                    ref_type: Bo4eRefType::Object {
601                        type_name: "Geschaeftspartner".to_string(),
602
603                        cardinality: Cardinality {
604                            min: 1,
605                            max: Some(7),
606                        },
607                    },
608
609                    ahb_status: "Muss".to_string(),
610                    map_key: None,
611                    scope: EntityScope::Transaction,
612                    variants: vec![],
613                    fields: vec![FieldRequirement {
614                        bo4e_name: "identifikation".to_string(),
615                        ahb_status: "X".to_string(),
616                        field_type: "data".to_string(),
617                        format: None,
618                        enum_name: None,
619                        valid_codes: vec![],
620                        child_group: None,
621                        ref_type: Bo4eRefType::Unknown,
622                    }],
623                },
624            ],
625        }
626    }
627
628    #[test]
629    fn test_validate_complete_json() {
630        let reqs = sample_requirements();
631        let json = json!({
632            "prozessdaten": {
633                "vorgangId": "ABC123",
634                "transaktionsgrund": "E01"
635            },
636            "marktlokation": {
637                "marktlokationsId": "51234567890",
638                "haushaltskunde": "Z15"
639            },
640            "geschaeftspartner": [
641                { "identifikation": "9900000000003" }
642            ]
643        });
644
645        let errors = validate_pid_json(&json, &reqs);
646        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
647    }
648
649    #[test]
650    fn test_validate_missing_entity() {
651        let reqs = sample_requirements();
652        let json = json!({
653            "prozessdaten": {
654                "vorgangId": "ABC123",
655                "transaktionsgrund": "E01"
656            },
657            "geschaeftspartner": [
658                { "identifikation": "9900000000003" }
659            ]
660        });
661        // Marktlokation is missing
662
663        let errors = validate_pid_json(&json, &reqs);
664        assert_eq!(errors.len(), 1);
665        match &errors[0] {
666            PidValidationError::MissingEntity {
667                entity,
668                ahb_status,
669                severity,
670            } => {
671                assert_eq!(entity, "Marktlokation");
672                assert_eq!(ahb_status, "Muss");
673                assert_eq!(severity, &Severity::Error);
674            }
675            other => panic!("Expected MissingEntity, got: {other:?}"),
676        }
677
678        // Display check
679        let msg = errors[0].to_string();
680        assert!(msg.contains("ERROR"));
681        assert!(msg.contains("Marktlokation"));
682        assert!(msg.contains("Muss"));
683    }
684
685    #[test]
686    fn test_validate_missing_field() {
687        let reqs = sample_requirements();
688        let json = json!({
689            "prozessdaten": {
690                "transaktionsgrund": "E01"
691                // vorgangId is missing
692            },
693            "marktlokation": {
694                "marktlokationsId": "51234567890",
695                "haushaltskunde": "Z15"
696            },
697            "geschaeftspartner": [
698                { "identifikation": "9900000000003" }
699            ]
700        });
701
702        let errors = validate_pid_json(&json, &reqs);
703        assert_eq!(errors.len(), 1);
704        match &errors[0] {
705            PidValidationError::MissingField {
706                entity,
707                field,
708                ahb_status,
709                severity,
710                ..
711            } => {
712                assert_eq!(entity, "Prozessdaten");
713                assert_eq!(field, "vorgangId");
714                assert_eq!(ahb_status, "X");
715                assert_eq!(severity, &Severity::Error);
716            }
717            other => panic!("Expected MissingField, got: {other:?}"),
718        }
719
720        let msg = errors[0].to_string();
721        assert!(msg.contains("ERROR"));
722        assert!(msg.contains("Prozessdaten.vorgangId"));
723    }
724
725    #[test]
726    fn test_validate_invalid_code() {
727        let reqs = sample_requirements();
728        let json = json!({
729            "prozessdaten": {
730                "vorgangId": "ABC123",
731                "transaktionsgrund": "E01"
732            },
733            "marktlokation": {
734                "marktlokationsId": "51234567890",
735                "haushaltskunde": "Z99"  // Invalid code
736            },
737            "geschaeftspartner": [
738                { "identifikation": "9900000000003" }
739            ]
740        });
741
742        let errors = validate_pid_json(&json, &reqs);
743        assert_eq!(errors.len(), 1);
744        match &errors[0] {
745            PidValidationError::InvalidCode {
746                entity,
747                field,
748                value,
749                valid_values,
750            } => {
751                assert_eq!(entity, "Marktlokation");
752                assert_eq!(field, "haushaltskunde");
753                assert_eq!(value, "Z99");
754                assert_eq!(valid_values.len(), 2);
755                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
756                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
757            }
758            other => panic!("Expected InvalidCode, got: {other:?}"),
759        }
760
761        let msg = errors[0].to_string();
762        assert!(msg.contains("INVALID"));
763        assert!(msg.contains("Z99"));
764        assert!(msg.contains("Z15"));
765    }
766
767    #[test]
768    fn test_validate_array_entity() {
769        let reqs = sample_requirements();
770        let json = json!({
771            "prozessdaten": {
772                "vorgangId": "ABC123",
773                "transaktionsgrund": "E01"
774            },
775            "marktlokation": {
776                "marktlokationsId": "51234567890",
777                "haushaltskunde": "Z15"
778            },
779            "geschaeftspartner": [
780                { "identifikation": "9900000000003" },
781                { }  // Missing identifikation in second element
782            ]
783        });
784
785        let errors = validate_pid_json(&json, &reqs);
786        assert_eq!(errors.len(), 1);
787        match &errors[0] {
788            PidValidationError::MissingField { entity, field, .. } => {
789                assert_eq!(entity, "Geschaeftspartner");
790                assert_eq!(field, "identifikation");
791            }
792            other => panic!("Expected MissingField, got: {other:?}"),
793        }
794    }
795
796    #[test]
797    fn test_to_camel_case() {
798        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
799        assert_eq!(
800            to_camel_case("RuhendeMarktlokation"),
801            "ruhendeMarktlokation"
802        );
803        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
804        assert_eq!(to_camel_case(""), "");
805    }
806
807    #[test]
808    fn test_snake_to_camel_case() {
809        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
810        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
811        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
812        assert_eq!(snake_to_camel_case(""), "");
813        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
814    }
815
816    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
817    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
818    #[test]
819    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
820        let reqs = PidRequirements {
821            pid: "55077".to_string(),
822            beschreibung: "Test camelCase fallback".to_string(),
823            entities: vec![EntityRequirement {
824                entity: "Zuordnung".to_string(),
825                ref_type: Bo4eRefType::Object {
826                    type_name: "Zuordnung".to_string(),
827
828                    cardinality: Cardinality::REQUIRED,
829                },
830
831                ahb_status: "Muss".to_string(),
832                map_key: None,
833                scope: EntityScope::Transaction,
834                variants: vec![],
835                fields: vec![
836                    FieldRequirement {
837                        // snake_case as stored in TOML requirements
838                        bo4e_name: "code_codepflege".to_string(),
839                        ahb_status: "X".to_string(),
840                        field_type: "data".to_string(),
841                        format: None,
842                        enum_name: None,
843                        valid_codes: vec![],
844                        child_group: None,
845                        ref_type: Bo4eRefType::Unknown,
846                    },
847                    FieldRequirement {
848                        bo4e_name: "codeliste".to_string(),
849                        ahb_status: "X".to_string(),
850                        field_type: "data".to_string(),
851                        format: None,
852                        enum_name: None,
853                        valid_codes: vec![],
854                        child_group: None,
855                        ref_type: Bo4eRefType::Unknown,
856                    },
857                ],
858            }],
859        };
860
861        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
862        // code_codepflege → codeCodepflege
863        let json_camel = json!({
864            "zuordnung": {
865                "codeCodepflege": "DE_BDEW",
866                "codeliste": "6"
867            }
868        });
869
870        let errors = validate_pid_json(&json_camel, &reqs);
871        assert!(
872            errors.is_empty(),
873            "Expected no errors when field is present under camelCase key, got: {errors:?}"
874        );
875
876        // Also verify that snake_case key in JSON still works (backward compat).
877        let json_snake = json!({
878            "zuordnung": {
879                "code_codepflege": "DE_BDEW",
880                "codeliste": "6"
881            }
882        });
883
884        let errors = validate_pid_json(&json_snake, &reqs);
885        assert!(
886            errors.is_empty(),
887            "Expected no errors when field is present under snake_case key, got: {errors:?}"
888        );
889
890        // When the field is truly absent, a MissingField error must still be raised.
891        let json_missing = json!({
892            "zuordnung": {
893                "codeliste": "6"
894            }
895        });
896
897        let errors = validate_pid_json(&json_missing, &reqs);
898        assert_eq!(errors.len(), 1);
899        match &errors[0] {
900            PidValidationError::MissingField { field, .. } => {
901                assert_eq!(field, "code_codepflege");
902            }
903            other => panic!("Expected MissingField, got: {other:?}"),
904        }
905    }
906
907    #[test]
908    fn test_is_unconditionally_required() {
909        assert!(is_unconditionally_required("X"));
910        assert!(is_unconditionally_required("Muss"));
911        assert!(is_unconditionally_required("Soll"));
912        assert!(!is_unconditionally_required("Kann"));
913        assert!(!is_unconditionally_required("[1]"));
914        assert!(!is_unconditionally_required(""));
915    }
916
917    #[test]
918    fn test_validation_report_display() {
919        let errors = vec![
920            PidValidationError::MissingEntity {
921                entity: "Marktlokation".to_string(),
922                ahb_status: "Muss".to_string(),
923                severity: Severity::Error,
924            },
925            PidValidationError::MissingField {
926                entity: "Prozessdaten".to_string(),
927                field: "vorgangId".to_string(),
928                ahb_status: "X".to_string(),
929                rust_type: None,
930                valid_values: vec![],
931                severity: Severity::Error,
932            },
933        ];
934        let report = ValidationReport(errors);
935        assert!(report.has_errors());
936        assert_eq!(report.len(), 2);
937        assert!(!report.is_empty());
938
939        let display = report.to_string();
940        assert!(display.contains("missing entity 'Marktlokation'"));
941        assert!(display.contains("missing Prozessdaten.vorgangId"));
942    }
943
944    #[test]
945    fn test_missing_field_with_type_and_values_display() {
946        let err = PidValidationError::MissingField {
947            entity: "Marktlokation".to_string(),
948            field: "haushaltskunde".to_string(),
949            ahb_status: "Muss".to_string(),
950            rust_type: Some("Haushaltskunde".to_string()),
951            valid_values: vec![
952                ("Z15".to_string(), "Ja".to_string()),
953                ("Z18".to_string(), "Nein".to_string()),
954            ],
955            severity: Severity::Error,
956        };
957        let msg = err.to_string();
958        assert!(msg.contains("type: Haushaltskunde"));
959        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
960    }
961
962    #[test]
963    fn test_optional_fields_not_flagged() {
964        let reqs = PidRequirements {
965            pid: "99999".to_string(),
966            beschreibung: "Test".to_string(),
967            entities: vec![EntityRequirement {
968                entity: "Test".to_string(),
969                ref_type: Bo4eRefType::Object {
970                    type_name: "Test".to_string(),
971
972                    cardinality: Cardinality::OPTIONAL,
973                },
974
975                ahb_status: "Kann".to_string(),
976                map_key: None,
977                scope: EntityScope::Transaction,
978                variants: vec![],
979                fields: vec![FieldRequirement {
980                    bo4e_name: "optionalField".to_string(),
981                    ahb_status: "Kann".to_string(),
982                    field_type: "data".to_string(),
983                    format: None,
984                    enum_name: None,
985                    valid_codes: vec![],
986                    child_group: None,
987                    ref_type: Bo4eRefType::Unknown,
988                }],
989            }],
990        };
991
992        // Entity missing but optional — no error
993        let errors = validate_pid_json(&json!({}), &reqs);
994        assert!(errors.is_empty());
995
996        // Entity present, field missing but optional — no error
997        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
998        assert!(errors.is_empty());
999    }
1000
1001    /// Regression test for issue #48: nested dot-path fields reported as missing
1002    /// even when present (e.g. `produktIdentifikation.funktion`).
1003    #[test]
1004    fn test_nested_dot_path_fields_not_falsely_missing() {
1005        let reqs = PidRequirements {
1006            pid: "55001".to_string(),
1007            beschreibung: "Test nested paths".to_string(),
1008            entities: vec![EntityRequirement {
1009                entity: "ProduktpaketDaten".to_string(),
1010                ref_type: Bo4eRefType::Object {
1011                    type_name: "ProduktpaketDaten".to_string(),
1012
1013                    cardinality: Cardinality {
1014                        min: 1,
1015                        max: Some(99999),
1016                    },
1017                },
1018
1019                ahb_status: "Muss".to_string(),
1020                map_key: None,
1021                scope: EntityScope::Transaction,
1022                variants: vec![],
1023                fields: vec![
1024                    FieldRequirement {
1025                        bo4e_name: "produktIdentifikation.funktion".to_string(),
1026                        ahb_status: "X".to_string(),
1027                        field_type: "code".to_string(),
1028                        format: None,
1029                        enum_name: Some("Produktidentifikation".to_string()),
1030                        valid_codes: vec![CodeValue {
1031                            code: "5".to_string(),
1032                            meaning: "Produktidentifikation".to_string(),
1033                            enum_name: None,
1034                            bo4e_value: None,
1035                        }],
1036                        child_group: None,
1037                        ref_type: Bo4eRefType::Unknown,
1038                    },
1039                    FieldRequirement {
1040                        bo4e_name: "produktMerkmal.code".to_string(),
1041                        ahb_status: "X".to_string(),
1042                        field_type: "code".to_string(),
1043                        format: None,
1044                        enum_name: None,
1045                        valid_codes: vec![],
1046                        child_group: None,
1047                        ref_type: Bo4eRefType::Unknown,
1048                    },
1049                ],
1050            }],
1051        };
1052
1053        // Exact JSON from issue #48
1054        let json = json!({
1055            "produktpaketDaten": [{
1056                "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1057                "produktMerkmal": { "code": "ZH9" }
1058            }]
1059        });
1060
1061        let errors = validate_pid_json(&json, &reqs);
1062        assert!(
1063            errors.is_empty(),
1064            "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_nested_dot_path_truly_missing() {
1070        let reqs = PidRequirements {
1071            pid: "55001".to_string(),
1072            beschreibung: "Test nested paths missing".to_string(),
1073            entities: vec![EntityRequirement {
1074                entity: "ProduktpaketDaten".to_string(),
1075                ref_type: Bo4eRefType::Object {
1076                    type_name: "ProduktpaketDaten".to_string(),
1077
1078                    cardinality: Cardinality {
1079                        min: 1,
1080                        max: Some(99999),
1081                    },
1082                },
1083
1084                ahb_status: "Muss".to_string(),
1085                map_key: None,
1086                scope: EntityScope::Transaction,
1087                variants: vec![],
1088                fields: vec![FieldRequirement {
1089                    bo4e_name: "produktIdentifikation.funktion".to_string(),
1090                    ahb_status: "X".to_string(),
1091                    field_type: "data".to_string(),
1092                    format: None,
1093                    enum_name: None,
1094                    valid_codes: vec![],
1095                    child_group: None,
1096                    ref_type: Bo4eRefType::Unknown,
1097                }],
1098            }],
1099        };
1100
1101        // Parent exists but nested field is missing
1102        let json = json!({
1103            "produktpaketDaten": [{
1104                "produktIdentifikation": { "id": "123" }
1105            }]
1106        });
1107
1108        let errors = validate_pid_json(&json, &reqs);
1109        assert_eq!(errors.len(), 1, "Should report missing nested field");
1110        match &errors[0] {
1111            PidValidationError::MissingField { field, .. } => {
1112                assert_eq!(field, "produktIdentifikation.funktion");
1113            }
1114            other => panic!("Expected MissingField, got: {other:?}"),
1115        }
1116    }
1117
1118    fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
1119        FieldRequirement {
1120            bo4e_name: name.to_string(),
1121            ahb_status: status.to_string(),
1122            field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
1123            format: None,
1124            enum_name: None,
1125            valid_codes: codes
1126                .iter()
1127                .map(|(code, mapped)| CodeValue {
1128                    code: code.to_string(),
1129                    meaning: String::new(),
1130                    enum_name: None,
1131                    bo4e_value: Some(mapped.to_string()),
1132                })
1133                .collect(),
1134            child_group: None,
1135            ref_type: Bo4eRefType::Unknown,
1136        }
1137    }
1138
1139    /// Geschaeftspartner fed by two NAD variants: Z03 (address, no name) and
1140    /// Z07 (name, no address), shaped like PID 55042 (issue #104).
1141    fn multi_variant_requirements() -> PidRequirements {
1142        let z03 = ("Z03", "messlokationsadresse");
1143        let z07 = ("Z07", "kundeMsb");
1144        PidRequirements {
1145            pid: "55042".to_string(),
1146            beschreibung: String::new(),
1147            entities: vec![EntityRequirement {
1148                entity: "Geschaeftspartner".to_string(),
1149                ref_type: Bo4eRefType::Object {
1150                    type_name: "Geschaeftspartner".to_string(),
1151                    cardinality: Cardinality {
1152                        min: 1,
1153                        max: Some(99),
1154                    },
1155                },
1156                ahb_status: "Muss".to_string(),
1157                // Entity-level union, as built from the merged schema groups.
1158                fields: vec![
1159                    field("adresse.ort", "X", &[]),
1160                    field("name1", "X", &[]),
1161                    field("partnerrolle", "X", &[z03, z07]),
1162                ],
1163                map_key: None,
1164                scope: EntityScope::Transaction,
1165                variants: vec![
1166                    EntityVariantRequirement {
1167                        discriminator_field: "partnerrolle".to_string(),
1168                        code: "Z03".to_string(),
1169                        bo4e_value: Some("messlokationsadresse".to_string()),
1170                        source_paths: vec!["sg4.sg12_z03".to_string()],
1171                        fields: vec![
1172                            field("adresse.ort", "X", &[]),
1173                            field("partnerrolle", "X", &[z03]),
1174                        ],
1175                    },
1176                    EntityVariantRequirement {
1177                        discriminator_field: "partnerrolle".to_string(),
1178                        code: "Z07".to_string(),
1179                        bo4e_value: Some("kundeMsb".to_string()),
1180                        source_paths: vec!["sg4.sg12_z07".to_string()],
1181                        fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
1182                    },
1183                ],
1184            }],
1185        }
1186    }
1187
1188    #[test]
1189    fn multi_variant_entity_uses_the_elements_own_variant() {
1190        let reqs = multi_variant_requirements();
1191        // Every spelling of the qualifier: raw code / enum_map name, string / object.
1192        let json = json!({
1193            "geschaeftspartner": [
1194                { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
1195                { "partnerrolle": "kundeMsb", "name1": "Muster" },
1196                { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
1197                  "adresse": { "ort": "Köln" } },
1198                { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
1199            ]
1200        });
1201        let errors = validate_pid_json(&json, &reqs);
1202        assert!(errors.is_empty(), "{}", ValidationReport(errors));
1203    }
1204
1205    #[test]
1206    fn multi_variant_entity_reports_variant_required_fields() {
1207        let reqs = multi_variant_requirements();
1208        let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
1209        let errors = validate_pid_json(&json, &reqs);
1210        assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
1211        assert!(matches!(
1212            &errors[0],
1213            PidValidationError::MissingField { field, .. } if field == "name1"
1214        ));
1215    }
1216
1217    #[test]
1218    fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
1219        let reqs = multi_variant_requirements();
1220        for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
1221            let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
1222            let errors = validate_pid_json(&json, &reqs);
1223            // No variant matches: variant fields become optional, codes are unioned.
1224            assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
1225            match &errors[0] {
1226                PidValidationError::InvalidCode {
1227                    field,
1228                    valid_values,
1229                    ..
1230                } => {
1231                    assert_eq!(field, "partnerrolle");
1232                    let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
1233                    assert_eq!(codes, ["Z03", "Z07"]);
1234                }
1235                other => panic!("expected InvalidCode, got {other:?}"),
1236            }
1237        }
1238    }
1239
1240    #[test]
1241    fn code_objects_and_enum_mapped_names_are_code_checked() {
1242        let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
1243        assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
1244        assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
1245        assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
1246        assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
1247        assert_eq!(
1248            invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
1249            Some("Z99".to_string())
1250        );
1251        assert_eq!(
1252            invalid_code_value(&json!("kundeLf"), &f),
1253            Some("kundeLf".to_string())
1254        );
1255        // Non-code values (numbers, objects without code) are not code-checked.
1256        assert_eq!(invalid_code_value(&json!(7), &f), None);
1257        assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
1258    }
1259}