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, EntityGroupRequirement, EntityRequirement, EntityScope, EntityVariantRequirement,
11    FieldRequirement, KindSelector, NestedElementKind, NestedListRequirement, PidRequirements,
12    VariantQualifier,
13};
14
15/// Severity of a validation error.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Severity {
18    /// Field is unconditionally required (Muss/X) or condition evaluated to True.
19    Error,
20    /// Condition evaluated to Unknown (depends on external context).
21    Warning,
22}
23
24/// A single PID validation error.
25#[derive(Debug, Clone)]
26pub enum PidValidationError {
27    /// An entire entity is missing from the interchange.
28    MissingEntity {
29        entity: String,
30        ahb_status: String,
31        severity: Severity,
32    },
33    /// A required field is None/missing.
34    MissingField {
35        entity: String,
36        field: String,
37        ahb_status: String,
38        rust_type: Option<String>,
39        valid_values: Vec<(String, String)>,
40        severity: Severity,
41    },
42    /// A code field has a value not in the allowed set.
43    InvalidCode {
44        entity: String,
45        field: String,
46        value: String,
47        valid_values: Vec<(String, String)>,
48    },
49    /// Something is present although its condition does not hold: an entity
50    /// (`field` empty), a code (`value`) or a list element. Only a
51    /// condition-aware validator raises it, for a `Muss`/`X` status with a
52    /// condition that evaluates to false where the element sits. A warning for
53    /// now, while the check proves itself on real messages.
54    NotAllowed {
55        entity: String,
56        field: String,
57        value: Option<String>,
58        ahb_status: String,
59        severity: Severity,
60    },
61    /// A present value that breaks its own element's condition — what the
62    /// AHB says the value must be: `[360]` "nur Antwortcodes aus dem Cluster
63    /// Zustimmung" with A50, `[914]` "> 0" with `O5D2DXBG1A`. Only a
64    /// condition-aware validator raises it; the EDIFACT validator reports the
65    /// same as AHB003.
66    ConditionNotSatisfied {
67        entity: String,
68        field: String,
69        value: String,
70        ahb_status: String,
71    },
72    /// An entity sent `count` times in one transaction although its group is
73    /// allowed once per transaction (UTILMD `[2061]` "genau einmal je SG4 IDE").
74    RepeatedInTransaction {
75        entity: String,
76        count: usize,
77        ahb_status: String,
78    },
79    /// A value of a field whose MIG format is numeric (`n..10`) that is not a
80    /// number (`O5D2DXBG1A`): it cannot be sent as it is.
81    NotANumber {
82        entity: String,
83        field: String,
84        value: String,
85        format: String,
86    },
87    /// A value longer than the field's MIG format allows (`an..512`): it
88    /// cannot be sent as it is.
89    TooLong {
90        entity: String,
91        field: String,
92        length: usize,
93        format: String,
94    },
95}
96
97impl PidValidationError {
98    pub fn severity(&self) -> &Severity {
99        match self {
100            Self::MissingEntity { severity, .. } => severity,
101            Self::MissingField { severity, .. } => severity,
102            Self::NotAllowed { severity, .. } => severity,
103            Self::InvalidCode { .. }
104            | Self::TooLong { .. }
105            | Self::ConditionNotSatisfied { .. }
106            | Self::RepeatedInTransaction { .. }
107            | Self::NotANumber { .. } => &Severity::Error,
108        }
109    }
110
111    pub fn is_error(&self) -> bool {
112        matches!(self.severity(), Severity::Error)
113    }
114}
115
116impl fmt::Display for PidValidationError {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            PidValidationError::MissingEntity {
120                entity,
121                ahb_status,
122                severity,
123            } => {
124                let label = severity_label(severity);
125                write!(
126                    f,
127                    "{label}: missing entity '{entity}' (required: {ahb_status})"
128                )
129            }
130            PidValidationError::MissingField {
131                entity,
132                field,
133                ahb_status,
134                rust_type,
135                valid_values,
136                severity,
137            } => {
138                let label = severity_label(severity);
139                write!(
140                    f,
141                    "{label}: missing {entity}.{field} (required: {ahb_status})"
142                )?;
143                if let Some(rt) = rust_type {
144                    write!(f, "\n  → type: {rt}")?;
145                }
146                if !valid_values.is_empty() {
147                    let codes: Vec<String> = valid_values
148                        .iter()
149                        .map(|(code, meaning)| {
150                            if meaning.is_empty() {
151                                code.clone()
152                            } else {
153                                format!("{code} ({meaning})")
154                            }
155                        })
156                        .collect();
157                    write!(f, "\n  → valid: {}", codes.join(", "))?;
158                }
159                Ok(())
160            }
161            PidValidationError::NotANumber {
162                entity,
163                field,
164                value,
165                format,
166            } => write!(
167                f,
168                "INVALID: {entity}.{field} = \"{value}\" is not a number\n  → format: {format}"
169            ),
170            PidValidationError::TooLong {
171                entity,
172                field,
173                length,
174                format,
175            } => write!(
176                f,
177                "INVALID: {entity}.{field} has {length} characters\n  → format: {format}"
178            ),
179            PidValidationError::InvalidCode {
180                entity,
181                field,
182                value,
183                valid_values,
184            } => {
185                write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
186                if !valid_values.is_empty() {
187                    let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
188                    write!(f, "\n  → valid: {}", codes.join(", "))?;
189                }
190                Ok(())
191            }
192            PidValidationError::RepeatedInTransaction {
193                entity,
194                count,
195                ahb_status,
196            } => write!(
197                f,
198                "ERROR: {entity} is sent {count} times in one transaction; its condition allows it once ({ahb_status})"
199            ),
200            PidValidationError::ConditionNotSatisfied {
201                entity,
202                field,
203                value,
204                ahb_status,
205            } => write!(
206                f,
207                "ERROR: {entity}.{field} = \"{value}\" does not satisfy its condition ({ahb_status})"
208            ),
209            PidValidationError::NotAllowed {
210                entity,
211                field,
212                value,
213                ahb_status,
214                severity,
215            } => {
216                write!(f, "{}: {entity}", severity_label(severity))?;
217                if !field.is_empty() {
218                    write!(f, ".{field}")?;
219                }
220                if let Some(value) = value {
221                    write!(f, " = \"{value}\"")?;
222                }
223                write!(
224                    f,
225                    " present although its condition does not hold ({ahb_status})"
226                )
227            }
228        }
229    }
230}
231
232fn severity_label(severity: &Severity) -> &'static str {
233    match severity {
234        Severity::Error => "ERROR",
235        Severity::Warning => "WARNING",
236    }
237}
238
239/// A collection of validation errors for a PID.
240pub struct ValidationReport(pub Vec<PidValidationError>);
241
242impl ValidationReport {
243    /// Returns true if the report contains any errors (not just warnings).
244    pub fn has_errors(&self) -> bool {
245        self.0.iter().any(|e| e.is_error())
246    }
247
248    /// Returns only the errors (not warnings).
249    pub fn errors(&self) -> Vec<&PidValidationError> {
250        self.0.iter().filter(|e| e.is_error()).collect()
251    }
252
253    /// Returns true if the report is empty (no errors or warnings).
254    pub fn is_empty(&self) -> bool {
255        self.0.is_empty()
256    }
257
258    /// Returns the number of validation errors.
259    pub fn len(&self) -> usize {
260        self.0.len()
261    }
262}
263
264impl fmt::Display for ValidationReport {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        for (i, err) in self.0.iter().enumerate() {
267            if i > 0 {
268                writeln!(f)?;
269            }
270            write!(f, "{err}")?;
271        }
272        Ok(())
273    }
274}
275
276// ── Validation Logic ──────────────────────────────────────────────────────
277
278/// Validate a BO4E JSON value against PID requirements.
279///
280/// Validates ALL entities (both message-level and transaction-level).
281/// Use [`validate_pid_json_transaction`] to validate only transaction-level entities.
282///
283/// Walks the requirements and checks:
284/// 1. Required entities are present in the JSON
285/// 2. Required fields are present within each entity
286/// 3. Code fields have values in the allowed set
287pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
288    validate_entities(json, &requirements.entities, None)
289}
290
291/// Validate only transaction-level entities in a BO4E JSON value.
292///
293/// Skips message-level entities (e.g., Marktteilnehmer, Kontakt from SG2/SG3)
294/// that are outside the transaction scope. Use this when validating a transaction
295/// payload that doesn't include message-level data.
296pub fn validate_pid_json_transaction(
297    json: &Value,
298    requirements: &PidRequirements,
299) -> Vec<PidValidationError> {
300    validate_entities(json, &requirements.entities, Some(EntityScope::Transaction))
301}
302
303/// Internal: validate entities, optionally filtering by scope.
304fn validate_entities(
305    json: &Value,
306    entities: &[EntityRequirement],
307    scope_filter: Option<EntityScope>,
308) -> Vec<PidValidationError> {
309    let mut errors = Vec::new();
310
311    for entity_req in entities {
312        // Skip entities not matching the requested scope
313        if let Some(ref scope) = scope_filter {
314            if &entity_req.scope != scope {
315                continue;
316            }
317        }
318
319        let key = to_camel_case(&entity_req.entity);
320
321        match json.get(&key) {
322            None | Some(serde_json::Value::Null) => {
323                if is_unconditionally_required(&entity_req.ahb_status) {
324                    errors.push(PidValidationError::MissingEntity {
325                        entity: entity_req.entity.clone(),
326                        ahb_status: entity_req.ahb_status.clone(),
327                        severity: Severity::Error,
328                    });
329                }
330            }
331            Some(val) => {
332                // An array is one element per repetition, whatever the schema
333                // says the group allows (a merged group can count MaxRep 1).
334                if let Some(arr) = val.as_array() {
335                    for element in arr {
336                        validate_entity_fields(element, entity_req, &mut errors);
337                    }
338                } else {
339                    validate_entity_fields(val, entity_req, &mut errors);
340                }
341            }
342        }
343    }
344
345    errors
346}
347
348/// Traverse a dot-separated path in a JSON value, trying both the original
349/// key and its camelCase variant at each level.
350pub fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
351    // A list target (`werte[].code`): the value in the first element that has
352    // one — a list with the field present counts as present.
353    if let Some((list, rest)) = path.split_once("[].") {
354        return get_nested(json, list)?
355            .as_array()?
356            .iter()
357            .find_map(|item| get_nested(item, rest).filter(|v| !v.is_null()));
358    }
359    let mut current = json;
360    for part in path.split('.') {
361        current = current.get(part).or_else(|| {
362            if part.contains('_') {
363                current.get(snake_to_camel_case(part))
364            } else {
365                None
366            }
367        })?;
368    }
369    Some(current)
370}
371
372/// Validate fields within a single entity JSON object.
373fn validate_entity_fields(
374    entity_json: &Value,
375    entity_req: &EntityRequirement,
376    errors: &mut Vec<PidValidationError>,
377) {
378    let fields = effective_field_requirements(entity_req, entity_json);
379    let exempt =
380        fields_of_unrequired_absent_groups(entity_req, entity_json, is_unconditionally_required);
381    for field_req in fields.iter() {
382        // Traverse dot-separated paths (e.g. "produktIdentifikation.funktion")
383        // and try camelCase variants at each level for typed struct compatibility.
384        let val = get_nested(entity_json, &field_req.bo4e_name);
385
386        // Treat null values as missing — JSON null means "not provided"
387        let val = val.filter(|v| !v.is_null());
388
389        match val {
390            None if exempt.contains(field_req.bo4e_name.as_str()) => {}
391            None => {
392                if is_unconditionally_required(&field_req.ahb_status) {
393                    errors.push(PidValidationError::MissingField {
394                        entity: entity_req.entity.clone(),
395                        field: field_req.bo4e_name.clone(),
396                        ahb_status: field_req.ahb_status.clone(),
397                        rust_type: field_req.enum_name.clone(),
398                        valid_values: code_values_to_tuples(&field_req.valid_codes),
399                        severity: Severity::Error,
400                    });
401                }
402            }
403            Some(val) => {
404                validate_code_value(val, entity_req, field_req, errors);
405            }
406        }
407    }
408    validate_nested_lists(
409        &entity_req.entity,
410        entity_json,
411        &entity_req.nested,
412        "",
413        &|status, _| is_unconditionally_required(status).then_some(Severity::Error),
414        &|_| None,
415        // Only a condition-aware validator can tell a condition does not hold.
416        &|_, _| false,
417        errors,
418    );
419}
420
421/// Validate the lists nested in one entity element (see
422/// [`NestedListRequirement`]).
423///
424/// `required(status, position)` says whether a status demands its part of the
425/// message here, and how firmly: `position` is where in the element the part
426/// is (`[("zuordnungen", 3)]` for the fields of a list element, `[]` for the
427/// list itself), so a condition can be judged in that element's own group
428/// instance ("im selben CCI"). `prefix` names the enclosing list for findings
429/// in a list nested in another (`betreiber[].`).
430pub fn validate_nested_lists(
431    entity: &str,
432    element: &Value,
433    lists: &[NestedListRequirement],
434    prefix: &str,
435    required: &dyn Fn(&str, &[(String, usize)]) -> Option<Severity>,
436    variant_holds: &dyn Fn(&VariantQualifier) -> Option<bool>,
437    forbidden: &dyn Fn(&str, &[(String, usize)]) -> bool,
438    errors: &mut Vec<PidValidationError>,
439) {
440    nested_lists_at(
441        entity,
442        element,
443        lists,
444        prefix,
445        &mut Vec::new(),
446        required,
447        variant_holds,
448        forbidden,
449        errors,
450    );
451}
452
453/// [`validate_nested_lists`] for the element at `position`.
454#[allow(clippy::too_many_arguments)]
455fn nested_lists_at(
456    entity: &str,
457    element: &Value,
458    lists: &[NestedListRequirement],
459    prefix: &str,
460    position: &mut Vec<(String, usize)>,
461    required: &dyn Fn(&str, &[(String, usize)]) -> Option<Severity>,
462    variant_holds: &dyn Fn(&VariantQualifier) -> Option<bool>,
463    forbidden: &dyn Fn(&str, &[(String, usize)]) -> bool,
464    errors: &mut Vec<PidValidationError>,
465) {
466    // A kind of another transaction variant does not apply here; one whose
467    // variant cannot be told (the basic validator has no tree) is not demanded.
468    let applies = |kind: &NestedElementKind| {
469        kind.transaction_variant
470            .as_ref()
471            .map_or(Some(true), variant_holds)
472    };
473    // Several requirements may write one list (one per variant of the parent's
474    // group, e.g. six SG8 variants feeding `ZaehlerDaten.zuordnungen`).
475    let mut fields: Vec<&str> = lists.iter().map(|l| l.field.as_str()).collect();
476    fields.sort_unstable();
477    fields.dedup();
478    for field in fields {
479        // Only the lists of this parent's variant (`kontaktwege` of the Z08
480        // partner, not of every partner).
481        let candidates: Vec<&NestedListRequirement> = lists
482            .iter()
483            .filter(|l| l.field == field)
484            .filter(|l| {
485                l.parent_selector
486                    .iter()
487                    .all(|selector| selector_matches(selector, element))
488            })
489            .collect();
490        if candidates.is_empty() {
491            continue;
492        }
493        let items: &[Value] = get_nested(element, field)
494            .and_then(|v| v.as_array())
495            .map_or(&[], |a| a.as_slice());
496        let label = format!("{prefix}{field}");
497        let alone = candidates.len() == 1;
498
499        // The requirement this parent's list follows: the only one, or the one
500        // whose kinds every element is. Otherwise which kinds are needed here
501        // cannot be told, and only the elements present are judged.
502        let chosen = match &candidates[..] {
503            [only] => Some(*only),
504            _ if items.is_empty() => None,
505            _ => {
506                let fitting: Vec<&&NestedListRequirement> = candidates
507                    .iter()
508                    .filter(|l| items.iter().all(|item| kind_of(l, item, false).is_some()))
509                    .collect();
510                match fitting[..] {
511                    [one] => Some(*one),
512                    _ => None,
513                }
514            }
515        };
516
517        if let Some(list) = chosen {
518            for kind in &list.kinds {
519                if applies(kind) != Some(true) {
520                    continue;
521                }
522                let present = if alone && list.kinds.len() == 1 {
523                    !items.is_empty()
524                } else {
525                    items.iter().any(|item| kind_matches(kind, item))
526                };
527                if present {
528                    continue;
529                }
530                if let Some(severity) = required(&kind.ahb_status, position) {
531                    let codes = kind.selector.first().map_or(&[][..], |s| &s.codes[..]);
532                    // Named by its selector's code where the list has several
533                    // kinds to tell apart (`zuordnungen[zugeordneterMarktpartner]`).
534                    let name = if alone && list.kinds.len() == 1 {
535                        String::new()
536                    } else {
537                        codes
538                            .first()
539                            .map(|c| c.bo4e_value.clone().unwrap_or_else(|| c.code.clone()))
540                            .unwrap_or_default()
541                    };
542                    errors.push(PidValidationError::MissingField {
543                        entity: entity.to_string(),
544                        field: format!("{label}[{name}]"),
545                        ahb_status: kind.ahb_status.clone(),
546                        rust_type: None,
547                        valid_values: code_values_to_tuples(codes),
548                        severity,
549                    });
550                }
551            }
552        }
553
554        for (index, item) in items.iter().enumerate() {
555            position.push((field.to_string(), index));
556            let here = position.clone();
557            let required_here = |status: &str| required(status, &here);
558            let matching: Vec<(&NestedListRequirement, &NestedElementKind)> = match chosen {
559                Some(list) => kind_of(list, item, alone)
560                    .filter(|k| applies(k) != Some(false))
561                    .map(|k| (list, k))
562                    .into_iter()
563                    .collect(),
564                None => candidates
565                    .iter()
566                    .flat_map(|l| {
567                        l.kinds
568                            .iter()
569                            .filter(|k| applies(k) != Some(false))
570                            .filter(|k| kind_matches(k, item))
571                            .map(move |k| (*l, k))
572                    })
573                    .collect(),
574            };
575            // Which variant of the parent's group an element came from is not
576            // recorded: it is judged by the matching kind it satisfies best, a
577            // kind with a selector before one without.
578            let best = matching
579                .iter()
580                .map(|(l, k)| {
581                    (
582                        *l,
583                        *k,
584                        element_findings(entity, &label, k, item, &required_here),
585                    )
586                })
587                .min_by_key(|(_, k, f)| (f.len(), k.selector.is_empty()));
588            if let Some((list, kind, findings)) = best {
589                errors.extend(findings);
590                // Present although its kind's condition does not hold, or
591                // carrying a field or code whose condition does not.
592                let forbidden_here = |status: &str| forbidden(status, &here);
593                if forbidden_here(&kind.ahb_status) {
594                    let name = kind
595                        .selector
596                        .first()
597                        .and_then(|s| s.codes.first())
598                        .map(|c| c.bo4e_value.clone().unwrap_or_else(|| c.code.clone()))
599                        .unwrap_or_default();
600                    errors.push(PidValidationError::NotAllowed {
601                        entity: entity.to_string(),
602                        field: format!("{label}[{name}]"),
603                        value: None,
604                        ahb_status: kind.ahb_status.clone(),
605                        severity: Severity::Warning,
606                    });
607                } else {
608                    errors.extend(not_allowed_codes(
609                        entity,
610                        &format!("{label}[]."),
611                        &kind.fields,
612                        item,
613                        &forbidden_here,
614                    ));
615                }
616                nested_lists_at(
617                    entity,
618                    item,
619                    &list.nested,
620                    &format!("{label}[]."),
621                    position,
622                    required,
623                    variant_holds,
624                    forbidden,
625                    errors,
626                );
627            }
628            position.pop();
629        }
630    }
631}
632
633/// The kind of `list` an element is, if any. A list alone on its field with a
634/// single kind takes every element as that kind, so a wrong code in its
635/// selector is reported as a wrong code rather than as a missing kind.
636fn kind_of<'l>(
637    list: &'l NestedListRequirement,
638    item: &Value,
639    alone: bool,
640) -> Option<&'l NestedElementKind> {
641    match &list.kinds[..] {
642        [only] if alone => Some(only),
643        kinds => kinds.iter().find(|k| kind_matches(k, item)),
644    }
645}
646
647/// Whether `item` is of `kind`: every selector field holds one of its codes,
648/// in raw or translated spelling.
649fn kind_matches(kind: &NestedElementKind, item: &Value) -> bool {
650    kind.selector
651        .iter()
652        .all(|selector| selector_matches(selector, item))
653}
654
655/// Whether `item`'s `selector.field` holds one of the selector's codes.
656fn selector_matches(selector: &KindSelector, item: &Value) -> bool {
657    get_nested(item, &selector.field)
658        .and_then(code_field_value)
659        .is_some_and(|value| {
660            selector
661                .codes
662                .iter()
663                .any(|c| c.code == value || c.bo4e_value.as_deref() == Some(value))
664        })
665}
666
667/// The codes `element` carries although their condition does not hold: codes
668/// sent in a segment of their own condition
669/// ([`CodeValue::ahb_status`](crate::pid_requirements::CodeValue), SG10
670/// CAV+ZB4 "Messstellenbetreiber Alt"). A plain field's status is not judged:
671/// its conditions state what the value must be (`[22]` "Es ist die Zeitraum-ID
672/// … einzutragen"), which the EDIFACT validator reports. `label` prefixes the
673/// field names (`zuordnungen[].`).
674pub fn not_allowed_codes(
675    entity: &str,
676    label: &str,
677    fields: &[FieldRequirement],
678    element: &Value,
679    forbidden: &dyn Fn(&str) -> bool,
680) -> Vec<PidValidationError> {
681    let mut out = Vec::new();
682    for field_req in fields {
683        let values = all_nested(element, &field_req.bo4e_name);
684        if values.is_empty() {
685            continue;
686        }
687        let name = format!("{label}{}", field_req.bo4e_name);
688        for value in values {
689            let Some(code) = code_field_value(value) else {
690                continue;
691            };
692            let status = field_req
693                .valid_codes
694                .iter()
695                .find(|cv| cv.code == code || cv.bo4e_value.as_deref() == Some(code))
696                .and_then(|cv| cv.ahb_status.as_deref());
697            if let Some(status) = status.filter(|s| forbidden(s)) {
698                out.push(PidValidationError::NotAllowed {
699                    entity: entity.to_string(),
700                    field: name.clone(),
701                    value: Some(code.to_string()),
702                    ahb_status: status.to_string(),
703                    severity: Severity::Warning,
704                });
705            }
706        }
707    }
708    out
709}
710
711/// The findings on one list element judged as `kind`: its missing fields and
712/// wrong codes.
713fn element_findings(
714    entity: &str,
715    label: &str,
716    kind: &NestedElementKind,
717    item: &Value,
718    required: &dyn Fn(&str) -> Option<Severity>,
719) -> Vec<PidValidationError> {
720    let mut out = Vec::new();
721    for field_req in &kind.fields {
722        let name = format!("{label}[].{}", field_req.bo4e_name);
723        let values = all_nested(item, &field_req.bo4e_name);
724        if values.is_empty() {
725            if let Some(severity) = required(&field_req.ahb_status) {
726                out.push(PidValidationError::MissingField {
727                    entity: entity.to_string(),
728                    field: name,
729                    ahb_status: field_req.ahb_status.clone(),
730                    rust_type: field_req.enum_name.clone(),
731                    valid_values: code_values_to_tuples(&field_req.valid_codes),
732                    severity,
733                });
734            }
735            continue;
736        }
737        for value in values {
738            out.extend(too_long(entity, &name, value, field_req));
739            out.extend(not_a_number(entity, &name, value, field_req));
740            if let Some(value) = invalid_code_value(value, field_req) {
741                out.push(PidValidationError::InvalidCode {
742                    entity: entity.to_string(),
743                    field: name.clone(),
744                    value,
745                    valid_values: code_values_to_tuples(&field_req.valid_codes),
746                });
747            }
748        }
749    }
750    out
751}
752
753/// Every non-null value at `path`: all elements' for a list target
754/// (`werte[].code`), where [`get_nested`] finds only the first.
755fn all_nested<'a>(json: &'a Value, path: &str) -> Vec<&'a Value> {
756    if let Some((list, rest)) = path.split_once("[].") {
757        return get_nested(json, list)
758            .and_then(|v| v.as_array())
759            .map(|items| {
760                items
761                    .iter()
762                    .flat_map(|item| all_nested(item, rest))
763                    .collect()
764            })
765            .unwrap_or_default();
766    }
767    get_nested(json, path)
768        .filter(|v| !v.is_null())
769        .into_iter()
770        .collect()
771}
772
773/// A [`PidValidationError::NotANumber`] when the string `val` of a data field
774/// whose MIG format is numeric (`n..10`) is not a number: an optional minus
775/// sign, digits, at most one decimal mark (`.` or `,`). Code fields and the
776/// codes of a free data field are left to the code checks, as in [`too_long`].
777pub fn not_a_number(
778    entity: &str,
779    field: &str,
780    val: &Value,
781    field_req: &FieldRequirement,
782) -> Option<PidValidationError> {
783    if !field_req.valid_codes.is_empty() {
784        return None;
785    }
786    let format = field_req.format.as_deref()?;
787    let text = val.as_str()?;
788    if !format.trim().starts_with('n')
789        || text.is_empty()
790        || field_req
791            .free_data_codes
792            .iter()
793            .any(|c| c.code == text || c.bo4e_value.as_deref() == Some(text))
794    {
795        return None;
796    }
797    let unsigned = text.strip_prefix('-').unwrap_or(text);
798    let digits = unsigned.chars().filter(char::is_ascii_digit).count();
799    let marks = unsigned.chars().filter(|c| matches!(c, '.' | ',')).count();
800    let number = digits > 0 && marks <= 1 && digits + marks == unsigned.chars().count();
801    (!number).then(|| PidValidationError::NotANumber {
802        entity: entity.to_string(),
803        field: field.to_string(),
804        value: text.to_string(),
805        format: format.to_string(),
806    })
807}
808
809/// A [`PidValidationError::TooLong`] when the string `val` of a data field is
810/// longer than the maximum of the field's MIG format (`an..512` → 512, `n13`
811/// → 13).
812pub fn too_long(
813    entity: &str,
814    field: &str,
815    val: &Value,
816    field_req: &FieldRequirement,
817) -> Option<PidValidationError> {
818    // A code field carries the code's BO4E name (`transaktionsgrund`:
819    // `kundeVerlaesst…`), not the code; its codes are checked instead.
820    if !field_req.valid_codes.is_empty() {
821        return None;
822    }
823    let format = field_req.format.as_deref()?;
824    let text = val.as_str()?;
825    // Free data that is one of the codes another source of the field allows,
826    // in either spelling: a code is never too long, and its BO4E name is not
827    // what is sent.
828    if field_req
829        .free_data_codes
830        .iter()
831        .any(|c| c.code == text || c.bo4e_value.as_deref() == Some(text))
832    {
833        return None;
834    }
835    let digits = format
836        .trim()
837        .strip_prefix("an")
838        .or_else(|| format.trim().strip_prefix('a'))
839        .or_else(|| format.trim().strip_prefix('n'))?;
840    let max: usize = digits.strip_prefix("..").unwrap_or(digits).parse().ok()?;
841    // In a numeric the decimal mark and a sign are not part of an `n..15`
842    // value's length; everything else counts.
843    let length = if format.trim().starts_with('n') {
844        text.chars()
845            .filter(|c| !matches!(c, '.' | ',' | '-'))
846            .count()
847    } else {
848        text.chars().count()
849    };
850    (length > max).then(|| PidValidationError::TooLong {
851        entity: entity.to_string(),
852        field: field.to_string(),
853        length,
854        format: format.to_string(),
855    })
856}
857
858/// Validate that a code field's value is in the allowed set.
859fn validate_code_value(
860    val: &Value,
861    entity_req: &EntityRequirement,
862    field_req: &FieldRequirement,
863    errors: &mut Vec<PidValidationError>,
864) {
865    if let Some(value) = invalid_code_value(val, field_req) {
866        errors.push(PidValidationError::InvalidCode {
867            entity: entity_req.entity.clone(),
868            field: field_req.bo4e_name.clone(),
869            value,
870            valid_values: code_values_to_tuples(&field_req.valid_codes),
871        });
872    }
873    errors.extend(too_long(
874        &entity_req.entity,
875        &field_req.bo4e_name,
876        val,
877        field_req,
878    ));
879    errors.extend(not_a_number(
880        &entity_req.entity,
881        &field_req.bo4e_name,
882        val,
883        field_req,
884    ));
885}
886
887/// The code carried by a BO4E code field: a plain string, or the `code` member of
888/// a `{code, meaning, enum}` object as written by `from_edifact`.
889pub fn code_field_value(val: &Value) -> Option<&str> {
890    val.as_str()
891        .or_else(|| val.get("code").and_then(|c| c.as_str()))
892}
893
894/// Check a code field's value against `field_req.valid_codes`.
895///
896/// Returns the offending value if it is invalid, `None` if it is valid or not a
897/// code value at all. A value is valid when it equals a valid raw EDIFACT code
898/// or the BO4E value the mapping's `enum_map` translates a valid code to — the
899/// two spellings `to_edifact` accepts.
900pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
901    if field_req.valid_codes.is_empty() {
902        return None;
903    }
904    let value = code_field_value(val)?;
905    let is_valid = field_req
906        .valid_codes
907        .iter()
908        .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
909    (!is_valid).then(|| value.to_string())
910}
911
912/// The group statuses of the variants `element` belongs to: those whose
913/// discriminator field carries their code. Empty when the entity has no
914/// variants, none records its status, or the element names none.
915pub fn variant_statuses<'a>(entity_req: &'a EntityRequirement, element: &Value) -> Vec<&'a str> {
916    let mut out: Vec<&'a str> = Vec::new();
917    for v in &entity_req.variants {
918        if v.ahb_status.is_empty() {
919            continue;
920        }
921        let value = get_nested(element, &v.discriminator_field).and_then(code_field_value);
922        if value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s))
923            && !out.contains(&v.ahb_status.as_str())
924        {
925            out.push(v.ahb_status.as_str());
926        }
927    }
928    out
929}
930
931/// The field requirements that apply to one element of an entity.
932///
933/// For an entity without [`EntityRequirement::variants`] this is simply its
934/// `fields`. For a multi-variant entity (e.g. `Geschaeftspartner` fed by
935/// `sg12_z03`/`sg12_z07`/…), the element's discriminator value (raw code or
936/// `enum_map`ped BO4E value, string or code object) selects the matching
937/// variant, and the variant-owned fields are replaced by that variant's
938/// requirements — fields the variant's group lacks do not apply.
939///
940/// If the discriminator is missing or matches no variant, all variants are
941/// candidates and are combined leniently: codes are unioned, and a field keeps
942/// its AHB status only if every candidate agrees on it (otherwise it is treated
943/// as optional), so an unidentifiable element is never held to the required
944/// fields of a variant it may not be.
945pub fn effective_field_requirements<'a>(
946    entity_req: &'a EntityRequirement,
947    element: &Value,
948) -> Cow<'a, [FieldRequirement]> {
949    if entity_req.variants.is_empty() {
950        return Cow::Borrowed(&entity_req.fields);
951    }
952
953    // Candidate variants, per discriminator field.
954    let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
955    for v in &entity_req.variants {
956        by_field
957            .entry(v.discriminator_field.as_str())
958            .or_default()
959            .push(v);
960    }
961    let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
962    for (field, group) in by_field {
963        let value = get_nested(element, field).and_then(code_field_value);
964        let matched: Vec<&EntityVariantRequirement> = group
965            .iter()
966            .copied()
967            .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
968            .collect();
969        candidates.extend(if matched.is_empty() { group } else { matched });
970    }
971
972    // Fields governed by variants (in first-seen order).
973    let mut owned: Vec<&str> = Vec::new();
974    let mut owned_set: BTreeSet<&str> = BTreeSet::new();
975    for v in &entity_req.variants {
976        for f in &v.fields {
977            if owned_set.insert(f.bo4e_name.as_str()) {
978                owned.push(f.bo4e_name.as_str());
979            }
980        }
981    }
982
983    let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
984    for name in owned {
985        let reqs: Vec<&FieldRequirement> = candidates
986            .iter()
987            .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
988            .collect();
989        let Some((first, rest)) = reqs.split_first() else {
990            continue; // not applicable to any candidate variant
991        };
992        let mut field = (*first).clone();
993        let mut statuses_agree = reqs.len() == candidates.len();
994        for r in rest {
995            if r.ahb_status != field.ahb_status {
996                statuses_agree = false;
997            }
998            for cv in &r.valid_codes {
999                if !field.valid_codes.iter().any(|c| c.code == cv.code) {
1000                    field.valid_codes.push(cv.clone());
1001                }
1002            }
1003        }
1004        if !statuses_agree {
1005            field.ahb_status = String::new();
1006        }
1007        combined.insert(name, field);
1008    }
1009
1010    let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
1011    for f in &entity_req.fields {
1012        if owned_set.contains(f.bo4e_name.as_str()) {
1013            if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
1014                result.push(c);
1015            }
1016        } else {
1017            result.push(f.clone());
1018        }
1019    }
1020    result.extend(combined.into_values());
1021    Cow::Owned(result)
1022}
1023
1024/// The groups of a multi-group entity (see [`EntityRequirement::groups`]) that
1025/// `element` carries no data for.
1026pub fn absent_groups<'a>(
1027    entity_req: &'a EntityRequirement,
1028    element: &'a Value,
1029) -> impl Iterator<Item = &'a EntityGroupRequirement> + 'a {
1030    entity_req.groups.iter().filter(move |g| {
1031        !g.fields
1032            .iter()
1033            .any(|f| get_nested(element, f).is_some_and(|v| !v.is_null()))
1034    })
1035}
1036
1037/// The fields `element` need not carry because every group writing them is
1038/// absent from it and not required.
1039///
1040/// `group_required` decides from a group's AHB status. A present entity of a
1041/// multi-group entity says nothing about any one of its groups: 55043's
1042/// `Marktlokation` with only LOC+Z16 data has no ruhende Marktlokation, so the
1043/// LOC+Z22 group's `X` fields do not apply — unless that group is itself
1044/// required, in which case they are reported as usual.
1045pub fn fields_of_unrequired_absent_groups<'a>(
1046    entity_req: &'a EntityRequirement,
1047    element: &'a Value,
1048    mut group_required: impl FnMut(&str) -> bool,
1049) -> BTreeSet<&'a str> {
1050    let exempt: Vec<&EntityGroupRequirement> = absent_groups(entity_req, element)
1051        .filter(|g| !group_required(&g.ahb_status))
1052        .collect();
1053    let mut fields = BTreeSet::new();
1054    for g in &exempt {
1055        for f in &g.fields {
1056            // A field another group also writes stays, unless that group is
1057            // exempt as well.
1058            let kept_elsewhere = entity_req.groups.iter().any(|other| {
1059                other.fields.contains(f) && !exempt.iter().any(|e| std::ptr::eq(*e, other))
1060            });
1061            if !kept_elsewhere {
1062                fields.insert(f.as_str());
1063            }
1064        }
1065    }
1066    fields
1067}
1068
1069/// Convert CodeValue vec to (code, meaning) tuples.
1070fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
1071    codes
1072        .iter()
1073        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
1074        .collect()
1075}
1076
1077/// Convert PascalCase entity name to camelCase JSON key.
1078///
1079/// "Prozessdaten" → "prozessdaten"
1080/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
1081/// "Marktlokation" → "marktlokation"
1082fn to_camel_case(s: &str) -> String {
1083    if s.is_empty() {
1084        return String::new();
1085    }
1086    let mut chars = s.chars();
1087    let first = chars.next().unwrap();
1088    let mut result = first.to_lowercase().to_string();
1089    result.extend(chars);
1090    result
1091}
1092
1093/// Convert a snake_case field name to camelCase.
1094///
1095/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
1096/// the validator to find fields in JSON that was produced by typed structs even when
1097/// the requirement stores the field name as snake_case (as it comes from TOML).
1098///
1099/// Examples:
1100/// - `"code_codepflege"` → `"codeCodepflege"`
1101/// - `"vorgang_id"` → `"vorgangId"`
1102/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
1103fn snake_to_camel_case(s: &str) -> String {
1104    let mut result = String::with_capacity(s.len());
1105    let mut capitalize_next = false;
1106    for ch in s.chars() {
1107        if ch == '_' {
1108            capitalize_next = true;
1109        } else if capitalize_next {
1110            result.extend(ch.to_uppercase());
1111            capitalize_next = false;
1112        } else {
1113            result.push(ch);
1114        }
1115    }
1116    result
1117}
1118
1119/// Returns true if the AHB status indicates an unconditionally required field.
1120fn is_unconditionally_required(ahb_status: &str) -> bool {
1121    matches!(ahb_status, "X" | "Muss" | "Soll")
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126
1127    /// A field that is free data in one SG10 kind and a Merkmal code in its
1128    /// merged sibling (55043's `zuordnungen[].merkmal.code`, `an..3`): a
1129    /// translated code name is not the value sent, a 12-character Zählzeit
1130    /// code is too long.
1131    #[test]
1132    fn a_numeric_field_holds_a_number() {
1133        let field = FieldRequirement {
1134            bo4e_name: "geplantesPaket".to_string(),
1135            ahb_status: "X [914] ∧ [937]".to_string(),
1136            field_type: "data".to_string(),
1137            format: Some("n..10".to_string()),
1138            enum_name: None,
1139            free_data_codes: vec![],
1140            element_status: String::new(),
1141            context_status: String::new(),
1142            segment: None,
1143            valid_codes: vec![],
1144            ref_type: crate::pid_requirements::Bo4eRefType::Unknown,
1145        };
1146        let check = |v: &str| not_a_number("Prozessdaten", "geplantesPaket", &json!(v), &field);
1147        assert!(check("O5D2DXBG1A").is_some());
1148        assert!(check("1e3").is_some());
1149        assert!(check("1.2.3").is_some());
1150        assert!(check("12").is_none());
1151        assert!(check("-0,5").is_none());
1152        assert!(check("").is_none());
1153    }
1154
1155    #[test]
1156    fn too_long_skips_codes_of_a_free_data_field() {
1157        let field = FieldRequirement {
1158            bo4e_name: "merkmal.code".to_string(),
1159            ahb_status: "X".to_string(),
1160            field_type: "data".to_string(),
1161            format: Some("an..3".to_string()),
1162            enum_name: None,
1163            free_data_codes: vec![CodeValue {
1164                code: "ZA9".to_string(),
1165                meaning: "Übertragungsnetzbetreiber".to_string(),
1166                enum_name: None,
1167                bo4e_value: Some("uebertragungsnetzbetreiber".to_string()),
1168                ahb_status: None,
1169            }],
1170            element_status: String::new(),
1171            context_status: String::new(),
1172            segment: None,
1173            valid_codes: vec![],
1174            ref_type: crate::pid_requirements::Bo4eRefType::Unknown,
1175        };
1176        let check = |v: &str| too_long("E", "zuordnungen[].merkmal.code", &json!(v), &field);
1177        assert!(check("uebertragungsnetzbetreiber").is_none());
1178        assert!(check("ZA9").is_none());
1179        assert!(check("Z1").is_none());
1180        assert!(matches!(
1181            check("ZDNBNAPG56ZM"),
1182            Some(PidValidationError::TooLong { length: 12, .. })
1183        ));
1184    }
1185    use super::*;
1186
1187    /// A list kind bound to a transaction variant (55070: SG10s only in the
1188    /// IDE+24 variant) is demanded where that variant holds, and neither where
1189    /// another holds nor where it cannot be told.
1190    #[test]
1191    fn a_variant_bound_kind_is_demanded_only_in_its_variant() {
1192        use crate::pid_requirements::{NestedElementKind, NestedListRequirement, VariantQualifier};
1193        let lists = vec![NestedListRequirement {
1194            field: "zuordnungen".to_string(),
1195            bo4e_type: "Zuordnung".to_string(),
1196            source_path: "sg4.sg8_z22.sg10".to_string(),
1197            kinds: vec![NestedElementKind {
1198                ahb_status: "Muss".to_string(),
1199                selector: vec![],
1200                fields: vec![],
1201                transaction_variant: Some(VariantQualifier {
1202                    group: "SG4".to_string(),
1203                    segment: "IDE".to_string(),
1204                    codes: vec!["24".to_string()],
1205                }),
1206            }],
1207            parent_selector: vec![],
1208            nested: vec![],
1209        }];
1210        let demanded = |holds: Option<bool>| {
1211            let mut errors = Vec::new();
1212            validate_nested_lists(
1213                "SummenzeitreihenDaten",
1214                &serde_json::json!({}),
1215                &lists,
1216                "",
1217                &|_, _| Some(Severity::Error),
1218                &|_| holds,
1219                &|_, _| false,
1220                &mut errors,
1221            );
1222            errors.len()
1223        };
1224        assert_eq!(demanded(Some(true)), 1);
1225        assert_eq!(demanded(Some(false)), 0);
1226        assert_eq!(demanded(None), 0);
1227    }
1228    use crate::pid_requirements::{
1229        Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
1230    };
1231    use serde_json::json;
1232
1233    fn sample_requirements() -> PidRequirements {
1234        PidRequirements {
1235            transaktion_wiederholbar: false,
1236            pid: "55001".to_string(),
1237            beschreibung: "Anmeldung verb. MaLo".to_string(),
1238            entities: vec![
1239                EntityRequirement {
1240                    entity: "Prozessdaten".to_string(),
1241                    ref_type: Bo4eRefType::Object {
1242                        type_name: "Prozessdaten".to_string(),
1243
1244                        cardinality: Cardinality::REQUIRED,
1245                    },
1246
1247                    ahb_status: "Muss".to_string(),
1248                    map_key: None,
1249                    scope: EntityScope::Transaction,
1250                    variants: vec![],
1251                    groups: vec![],
1252                    fields: vec![
1253                        FieldRequirement {
1254                            bo4e_name: "vorgangId".to_string(),
1255                            ahb_status: "X".to_string(),
1256                            field_type: "data".to_string(),
1257                            format: None,
1258                            enum_name: None,
1259                            free_data_codes: Vec::new(),
1260                            element_status: String::new(),
1261                            context_status: String::new(),
1262                            segment: None,
1263                            valid_codes: vec![],
1264                            ref_type: Bo4eRefType::Unknown,
1265                        },
1266                        FieldRequirement {
1267                            bo4e_name: "transaktionsgrund".to_string(),
1268                            ahb_status: "X".to_string(),
1269                            field_type: "code".to_string(),
1270                            format: None,
1271                            enum_name: Some("Transaktionsgrund".to_string()),
1272                            free_data_codes: Vec::new(),
1273                            element_status: String::new(),
1274                            context_status: String::new(),
1275                            segment: None,
1276                            valid_codes: vec![
1277                                CodeValue {
1278                                    code: "E01".to_string(),
1279                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
1280                                    enum_name: None,
1281                                    bo4e_value: None,
1282                                    ahb_status: None,
1283                                },
1284                                CodeValue {
1285                                    code: "E03".to_string(),
1286                                    meaning: "Wechsel".to_string(),
1287                                    enum_name: None,
1288                                    bo4e_value: None,
1289                                    ahb_status: None,
1290                                },
1291                            ],
1292                            ref_type: Bo4eRefType::Unknown,
1293                        },
1294                    ],
1295                    nested: Vec::new(),
1296                },
1297                EntityRequirement {
1298                    entity: "Marktlokation".to_string(),
1299                    ref_type: Bo4eRefType::Object {
1300                        type_name: "Marktlokation".to_string(),
1301
1302                        cardinality: Cardinality::REQUIRED,
1303                    },
1304
1305                    ahb_status: "Muss".to_string(),
1306                    map_key: None,
1307                    scope: EntityScope::Transaction,
1308                    variants: vec![],
1309                    groups: vec![],
1310                    fields: vec![
1311                        FieldRequirement {
1312                            bo4e_name: "marktlokationsId".to_string(),
1313                            ahb_status: "X".to_string(),
1314                            field_type: "data".to_string(),
1315                            format: None,
1316                            enum_name: None,
1317                            free_data_codes: Vec::new(),
1318                            element_status: String::new(),
1319                            context_status: String::new(),
1320                            segment: None,
1321                            valid_codes: vec![],
1322                            ref_type: Bo4eRefType::Unknown,
1323                        },
1324                        FieldRequirement {
1325                            bo4e_name: "haushaltskunde".to_string(),
1326                            ahb_status: "X".to_string(),
1327                            field_type: "code".to_string(),
1328                            format: None,
1329                            enum_name: Some("Haushaltskunde".to_string()),
1330                            free_data_codes: Vec::new(),
1331                            element_status: String::new(),
1332                            context_status: String::new(),
1333                            segment: None,
1334                            valid_codes: vec![
1335                                CodeValue {
1336                                    code: "Z15".to_string(),
1337                                    meaning: "Ja".to_string(),
1338                                    enum_name: None,
1339                                    bo4e_value: None,
1340                                    ahb_status: None,
1341                                },
1342                                CodeValue {
1343                                    code: "Z18".to_string(),
1344                                    meaning: "Nein".to_string(),
1345                                    enum_name: None,
1346                                    bo4e_value: None,
1347                                    ahb_status: None,
1348                                },
1349                            ],
1350                            ref_type: Bo4eRefType::Unknown,
1351                        },
1352                    ],
1353                    nested: Vec::new(),
1354                },
1355                EntityRequirement {
1356                    entity: "Geschaeftspartner".to_string(),
1357                    ref_type: Bo4eRefType::Object {
1358                        type_name: "Geschaeftspartner".to_string(),
1359
1360                        cardinality: Cardinality {
1361                            min: 1,
1362                            max: Some(7),
1363                        },
1364                    },
1365
1366                    ahb_status: "Muss".to_string(),
1367                    map_key: None,
1368                    scope: EntityScope::Transaction,
1369                    variants: vec![],
1370                    groups: vec![],
1371                    fields: vec![FieldRequirement {
1372                        bo4e_name: "identifikation".to_string(),
1373                        ahb_status: "X".to_string(),
1374                        field_type: "data".to_string(),
1375                        format: None,
1376                        enum_name: None,
1377                        free_data_codes: Vec::new(),
1378                        element_status: String::new(),
1379                        context_status: String::new(),
1380                        segment: None,
1381                        valid_codes: vec![],
1382                        ref_type: Bo4eRefType::Unknown,
1383                    }],
1384                    nested: Vec::new(),
1385                },
1386            ],
1387        }
1388    }
1389
1390    #[test]
1391    fn test_validate_complete_json() {
1392        let reqs = sample_requirements();
1393        let json = json!({
1394            "prozessdaten": {
1395                "vorgangId": "ABC123",
1396                "transaktionsgrund": "E01"
1397            },
1398            "marktlokation": {
1399                "marktlokationsId": "51234567890",
1400                "haushaltskunde": "Z15"
1401            },
1402            "geschaeftspartner": [
1403                { "identifikation": "9900000000003" }
1404            ]
1405        });
1406
1407        let errors = validate_pid_json(&json, &reqs);
1408        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
1409    }
1410
1411    #[test]
1412    fn test_validate_missing_entity() {
1413        let reqs = sample_requirements();
1414        let json = json!({
1415            "prozessdaten": {
1416                "vorgangId": "ABC123",
1417                "transaktionsgrund": "E01"
1418            },
1419            "geschaeftspartner": [
1420                { "identifikation": "9900000000003" }
1421            ]
1422        });
1423        // Marktlokation is missing
1424
1425        let errors = validate_pid_json(&json, &reqs);
1426        assert_eq!(errors.len(), 1);
1427        match &errors[0] {
1428            PidValidationError::MissingEntity {
1429                entity,
1430                ahb_status,
1431                severity,
1432            } => {
1433                assert_eq!(entity, "Marktlokation");
1434                assert_eq!(ahb_status, "Muss");
1435                assert_eq!(severity, &Severity::Error);
1436            }
1437            other => panic!("Expected MissingEntity, got: {other:?}"),
1438        }
1439
1440        // Display check
1441        let msg = errors[0].to_string();
1442        assert!(msg.contains("ERROR"));
1443        assert!(msg.contains("Marktlokation"));
1444        assert!(msg.contains("Muss"));
1445    }
1446
1447    #[test]
1448    fn test_validate_missing_field() {
1449        let reqs = sample_requirements();
1450        let json = json!({
1451            "prozessdaten": {
1452                "transaktionsgrund": "E01"
1453                // vorgangId is missing
1454            },
1455            "marktlokation": {
1456                "marktlokationsId": "51234567890",
1457                "haushaltskunde": "Z15"
1458            },
1459            "geschaeftspartner": [
1460                { "identifikation": "9900000000003" }
1461            ]
1462        });
1463
1464        let errors = validate_pid_json(&json, &reqs);
1465        assert_eq!(errors.len(), 1);
1466        match &errors[0] {
1467            PidValidationError::MissingField {
1468                entity,
1469                field,
1470                ahb_status,
1471                severity,
1472                ..
1473            } => {
1474                assert_eq!(entity, "Prozessdaten");
1475                assert_eq!(field, "vorgangId");
1476                assert_eq!(ahb_status, "X");
1477                assert_eq!(severity, &Severity::Error);
1478            }
1479            other => panic!("Expected MissingField, got: {other:?}"),
1480        }
1481
1482        let msg = errors[0].to_string();
1483        assert!(msg.contains("ERROR"));
1484        assert!(msg.contains("Prozessdaten.vorgangId"));
1485    }
1486
1487    #[test]
1488    fn test_validate_invalid_code() {
1489        let reqs = sample_requirements();
1490        let json = json!({
1491            "prozessdaten": {
1492                "vorgangId": "ABC123",
1493                "transaktionsgrund": "E01"
1494            },
1495            "marktlokation": {
1496                "marktlokationsId": "51234567890",
1497                "haushaltskunde": "Z99"  // Invalid code
1498            },
1499            "geschaeftspartner": [
1500                { "identifikation": "9900000000003" }
1501            ]
1502        });
1503
1504        let errors = validate_pid_json(&json, &reqs);
1505        assert_eq!(errors.len(), 1);
1506        match &errors[0] {
1507            PidValidationError::InvalidCode {
1508                entity,
1509                field,
1510                value,
1511                valid_values,
1512            } => {
1513                assert_eq!(entity, "Marktlokation");
1514                assert_eq!(field, "haushaltskunde");
1515                assert_eq!(value, "Z99");
1516                assert_eq!(valid_values.len(), 2);
1517                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
1518                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
1519            }
1520            other => panic!("Expected InvalidCode, got: {other:?}"),
1521        }
1522
1523        let msg = errors[0].to_string();
1524        assert!(msg.contains("INVALID"));
1525        assert!(msg.contains("Z99"));
1526        assert!(msg.contains("Z15"));
1527    }
1528
1529    #[test]
1530    fn test_validate_array_entity() {
1531        let reqs = sample_requirements();
1532        let json = json!({
1533            "prozessdaten": {
1534                "vorgangId": "ABC123",
1535                "transaktionsgrund": "E01"
1536            },
1537            "marktlokation": {
1538                "marktlokationsId": "51234567890",
1539                "haushaltskunde": "Z15"
1540            },
1541            "geschaeftspartner": [
1542                { "identifikation": "9900000000003" },
1543                { }  // Missing identifikation in second element
1544            ]
1545        });
1546
1547        let errors = validate_pid_json(&json, &reqs);
1548        assert_eq!(errors.len(), 1);
1549        match &errors[0] {
1550            PidValidationError::MissingField { entity, field, .. } => {
1551                assert_eq!(entity, "Geschaeftspartner");
1552                assert_eq!(field, "identifikation");
1553            }
1554            other => panic!("Expected MissingField, got: {other:?}"),
1555        }
1556    }
1557
1558    #[test]
1559    fn test_to_camel_case() {
1560        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
1561        assert_eq!(
1562            to_camel_case("RuhendeMarktlokation"),
1563            "ruhendeMarktlokation"
1564        );
1565        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
1566        assert_eq!(to_camel_case(""), "");
1567    }
1568
1569    #[test]
1570    fn test_snake_to_camel_case() {
1571        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
1572        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
1573        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
1574        assert_eq!(snake_to_camel_case(""), "");
1575        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
1576    }
1577
1578    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
1579    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
1580    #[test]
1581    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
1582        let reqs = PidRequirements {
1583            transaktion_wiederholbar: false,
1584            pid: "55077".to_string(),
1585            beschreibung: "Test camelCase fallback".to_string(),
1586            entities: vec![EntityRequirement {
1587                entity: "Zuordnung".to_string(),
1588                ref_type: Bo4eRefType::Object {
1589                    type_name: "Zuordnung".to_string(),
1590
1591                    cardinality: Cardinality::REQUIRED,
1592                },
1593
1594                ahb_status: "Muss".to_string(),
1595                map_key: None,
1596                scope: EntityScope::Transaction,
1597                variants: vec![],
1598                groups: vec![],
1599                fields: vec![
1600                    FieldRequirement {
1601                        // snake_case as stored in TOML requirements
1602                        bo4e_name: "code_codepflege".to_string(),
1603                        ahb_status: "X".to_string(),
1604                        field_type: "data".to_string(),
1605                        format: None,
1606                        enum_name: None,
1607                        free_data_codes: Vec::new(),
1608                        element_status: String::new(),
1609                        context_status: String::new(),
1610                        segment: None,
1611                        valid_codes: vec![],
1612                        ref_type: Bo4eRefType::Unknown,
1613                    },
1614                    FieldRequirement {
1615                        bo4e_name: "codeliste".to_string(),
1616                        ahb_status: "X".to_string(),
1617                        field_type: "data".to_string(),
1618                        format: None,
1619                        enum_name: None,
1620                        free_data_codes: Vec::new(),
1621                        element_status: String::new(),
1622                        context_status: String::new(),
1623                        segment: None,
1624                        valid_codes: vec![],
1625                        ref_type: Bo4eRefType::Unknown,
1626                    },
1627                ],
1628                nested: Vec::new(),
1629            }],
1630        };
1631
1632        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
1633        // code_codepflege → codeCodepflege
1634        let json_camel = json!({
1635            "zuordnung": {
1636                "codeCodepflege": "DE_BDEW",
1637                "codeliste": "6"
1638            }
1639        });
1640
1641        let errors = validate_pid_json(&json_camel, &reqs);
1642        assert!(
1643            errors.is_empty(),
1644            "Expected no errors when field is present under camelCase key, got: {errors:?}"
1645        );
1646
1647        // Also verify that snake_case key in JSON still works (backward compat).
1648        let json_snake = json!({
1649            "zuordnung": {
1650                "code_codepflege": "DE_BDEW",
1651                "codeliste": "6"
1652            }
1653        });
1654
1655        let errors = validate_pid_json(&json_snake, &reqs);
1656        assert!(
1657            errors.is_empty(),
1658            "Expected no errors when field is present under snake_case key, got: {errors:?}"
1659        );
1660
1661        // When the field is truly absent, a MissingField error must still be raised.
1662        let json_missing = json!({
1663            "zuordnung": {
1664                "codeliste": "6"
1665            }
1666        });
1667
1668        let errors = validate_pid_json(&json_missing, &reqs);
1669        assert_eq!(errors.len(), 1);
1670        match &errors[0] {
1671            PidValidationError::MissingField { field, .. } => {
1672                assert_eq!(field, "code_codepflege");
1673            }
1674            other => panic!("Expected MissingField, got: {other:?}"),
1675        }
1676    }
1677
1678    #[test]
1679    fn test_is_unconditionally_required() {
1680        assert!(is_unconditionally_required("X"));
1681        assert!(is_unconditionally_required("Muss"));
1682        assert!(is_unconditionally_required("Soll"));
1683        assert!(!is_unconditionally_required("Kann"));
1684        assert!(!is_unconditionally_required("[1]"));
1685        assert!(!is_unconditionally_required(""));
1686    }
1687
1688    #[test]
1689    fn test_validation_report_display() {
1690        let errors = vec![
1691            PidValidationError::MissingEntity {
1692                entity: "Marktlokation".to_string(),
1693                ahb_status: "Muss".to_string(),
1694                severity: Severity::Error,
1695            },
1696            PidValidationError::MissingField {
1697                entity: "Prozessdaten".to_string(),
1698                field: "vorgangId".to_string(),
1699                ahb_status: "X".to_string(),
1700                rust_type: None,
1701                valid_values: vec![],
1702                severity: Severity::Error,
1703            },
1704        ];
1705        let report = ValidationReport(errors);
1706        assert!(report.has_errors());
1707        assert_eq!(report.len(), 2);
1708        assert!(!report.is_empty());
1709
1710        let display = report.to_string();
1711        assert!(display.contains("missing entity 'Marktlokation'"));
1712        assert!(display.contains("missing Prozessdaten.vorgangId"));
1713    }
1714
1715    #[test]
1716    fn test_missing_field_with_type_and_values_display() {
1717        let err = PidValidationError::MissingField {
1718            entity: "Marktlokation".to_string(),
1719            field: "haushaltskunde".to_string(),
1720            ahb_status: "Muss".to_string(),
1721            rust_type: Some("Haushaltskunde".to_string()),
1722            valid_values: vec![
1723                ("Z15".to_string(), "Ja".to_string()),
1724                ("Z18".to_string(), "Nein".to_string()),
1725            ],
1726            severity: Severity::Error,
1727        };
1728        let msg = err.to_string();
1729        assert!(msg.contains("type: Haushaltskunde"));
1730        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
1731    }
1732
1733    #[test]
1734    fn test_optional_fields_not_flagged() {
1735        let reqs = PidRequirements {
1736            transaktion_wiederholbar: false,
1737            pid: "99999".to_string(),
1738            beschreibung: "Test".to_string(),
1739            entities: vec![EntityRequirement {
1740                entity: "Test".to_string(),
1741                ref_type: Bo4eRefType::Object {
1742                    type_name: "Test".to_string(),
1743
1744                    cardinality: Cardinality::OPTIONAL,
1745                },
1746
1747                ahb_status: "Kann".to_string(),
1748                map_key: None,
1749                scope: EntityScope::Transaction,
1750                variants: vec![],
1751                groups: vec![],
1752                fields: vec![FieldRequirement {
1753                    bo4e_name: "optionalField".to_string(),
1754                    ahb_status: "Kann".to_string(),
1755                    field_type: "data".to_string(),
1756                    format: None,
1757                    enum_name: None,
1758                    free_data_codes: Vec::new(),
1759                    element_status: String::new(),
1760                    context_status: String::new(),
1761                    segment: None,
1762                    valid_codes: vec![],
1763                    ref_type: Bo4eRefType::Unknown,
1764                }],
1765                nested: Vec::new(),
1766            }],
1767        };
1768
1769        // Entity missing but optional — no error
1770        let errors = validate_pid_json(&json!({}), &reqs);
1771        assert!(errors.is_empty());
1772
1773        // Entity present, field missing but optional — no error
1774        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
1775        assert!(errors.is_empty());
1776    }
1777
1778    /// Regression test for issue #48: nested dot-path fields reported as missing
1779    /// even when present (e.g. `produktIdentifikation.funktion`).
1780    #[test]
1781    fn test_nested_dot_path_fields_not_falsely_missing() {
1782        let reqs = PidRequirements {
1783            transaktion_wiederholbar: false,
1784            pid: "55001".to_string(),
1785            beschreibung: "Test nested paths".to_string(),
1786            entities: vec![EntityRequirement {
1787                entity: "ProduktpaketDaten".to_string(),
1788                ref_type: Bo4eRefType::Object {
1789                    type_name: "ProduktpaketDaten".to_string(),
1790
1791                    cardinality: Cardinality {
1792                        min: 1,
1793                        max: Some(99999),
1794                    },
1795                },
1796
1797                ahb_status: "Muss".to_string(),
1798                map_key: None,
1799                scope: EntityScope::Transaction,
1800                variants: vec![],
1801                groups: vec![],
1802                fields: vec![
1803                    FieldRequirement {
1804                        bo4e_name: "produktIdentifikation.funktion".to_string(),
1805                        ahb_status: "X".to_string(),
1806                        field_type: "code".to_string(),
1807                        format: None,
1808                        enum_name: Some("Produktidentifikation".to_string()),
1809                        free_data_codes: Vec::new(),
1810                        element_status: String::new(),
1811                        context_status: String::new(),
1812                        segment: None,
1813                        valid_codes: vec![CodeValue {
1814                            code: "5".to_string(),
1815                            meaning: "Produktidentifikation".to_string(),
1816                            enum_name: None,
1817                            bo4e_value: None,
1818                            ahb_status: None,
1819                        }],
1820                        ref_type: Bo4eRefType::Unknown,
1821                    },
1822                    FieldRequirement {
1823                        bo4e_name: "produktMerkmal.code".to_string(),
1824                        ahb_status: "X".to_string(),
1825                        field_type: "code".to_string(),
1826                        format: None,
1827                        enum_name: None,
1828                        free_data_codes: Vec::new(),
1829                        element_status: String::new(),
1830                        context_status: String::new(),
1831                        segment: None,
1832                        valid_codes: vec![],
1833                        ref_type: Bo4eRefType::Unknown,
1834                    },
1835                ],
1836                nested: Vec::new(),
1837            }],
1838        };
1839
1840        // Exact JSON from issue #48
1841        let json = json!({
1842            "produktpaketDaten": [{
1843                "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1844                "produktMerkmal": { "code": "ZH9" }
1845            }]
1846        });
1847
1848        let errors = validate_pid_json(&json, &reqs);
1849        assert!(
1850            errors.is_empty(),
1851            "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1852        );
1853    }
1854
1855    #[test]
1856    fn test_nested_dot_path_truly_missing() {
1857        let reqs = PidRequirements {
1858            transaktion_wiederholbar: false,
1859            pid: "55001".to_string(),
1860            beschreibung: "Test nested paths missing".to_string(),
1861            entities: vec![EntityRequirement {
1862                entity: "ProduktpaketDaten".to_string(),
1863                ref_type: Bo4eRefType::Object {
1864                    type_name: "ProduktpaketDaten".to_string(),
1865
1866                    cardinality: Cardinality {
1867                        min: 1,
1868                        max: Some(99999),
1869                    },
1870                },
1871
1872                ahb_status: "Muss".to_string(),
1873                map_key: None,
1874                scope: EntityScope::Transaction,
1875                variants: vec![],
1876                groups: vec![],
1877                fields: vec![FieldRequirement {
1878                    bo4e_name: "produktIdentifikation.funktion".to_string(),
1879                    ahb_status: "X".to_string(),
1880                    field_type: "data".to_string(),
1881                    format: None,
1882                    enum_name: None,
1883                    free_data_codes: Vec::new(),
1884                    element_status: String::new(),
1885                    context_status: String::new(),
1886                    segment: None,
1887                    valid_codes: vec![],
1888                    ref_type: Bo4eRefType::Unknown,
1889                }],
1890                nested: Vec::new(),
1891            }],
1892        };
1893
1894        // Parent exists but nested field is missing
1895        let json = json!({
1896            "produktpaketDaten": [{
1897                "produktIdentifikation": { "id": "123" }
1898            }]
1899        });
1900
1901        let errors = validate_pid_json(&json, &reqs);
1902        assert_eq!(errors.len(), 1, "Should report missing nested field");
1903        match &errors[0] {
1904            PidValidationError::MissingField { field, .. } => {
1905                assert_eq!(field, "produktIdentifikation.funktion");
1906            }
1907            other => panic!("Expected MissingField, got: {other:?}"),
1908        }
1909    }
1910
1911    fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
1912        FieldRequirement {
1913            bo4e_name: name.to_string(),
1914            ahb_status: status.to_string(),
1915            field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
1916            format: None,
1917            enum_name: None,
1918            free_data_codes: Vec::new(),
1919            element_status: String::new(),
1920            context_status: String::new(),
1921            segment: None,
1922            valid_codes: codes
1923                .iter()
1924                .map(|(code, mapped)| CodeValue {
1925                    code: code.to_string(),
1926                    meaning: String::new(),
1927                    enum_name: None,
1928                    bo4e_value: Some(mapped.to_string()),
1929                    ahb_status: None,
1930                })
1931                .collect(),
1932            ref_type: Bo4eRefType::Unknown,
1933        }
1934    }
1935
1936    /// Geschaeftspartner fed by two NAD variants: Z03 (address, no name) and
1937    /// Z07 (name, no address), shaped like PID 55042 (issue #104).
1938    fn multi_variant_requirements() -> PidRequirements {
1939        let z03 = ("Z03", "messlokationsadresse");
1940        let z07 = ("Z07", "kundeMsb");
1941        PidRequirements {
1942            transaktion_wiederholbar: false,
1943            pid: "55042".to_string(),
1944            beschreibung: String::new(),
1945            entities: vec![EntityRequirement {
1946                entity: "Geschaeftspartner".to_string(),
1947                ref_type: Bo4eRefType::Object {
1948                    type_name: "Geschaeftspartner".to_string(),
1949                    cardinality: Cardinality {
1950                        min: 1,
1951                        max: Some(99),
1952                    },
1953                },
1954                ahb_status: "Muss".to_string(),
1955                // Entity-level union, as built from the merged schema groups.
1956                fields: vec![
1957                    field("adresse.ort", "X", &[]),
1958                    field("name1", "X", &[]),
1959                    field("partnerrolle", "X", &[z03, z07]),
1960                ],
1961                map_key: None,
1962                scope: EntityScope::Transaction,
1963                variants: vec![
1964                    EntityVariantRequirement {
1965                        discriminator_field: "partnerrolle".to_string(),
1966                        code: "Z03".to_string(),
1967                        bo4e_value: Some("messlokationsadresse".to_string()),
1968                        source_paths: vec!["sg4.sg12_z03".to_string()],
1969                        fields: vec![
1970                            field("adresse.ort", "X", &[]),
1971                            field("partnerrolle", "X", &[z03]),
1972                        ],
1973                        ahb_status: String::new(),
1974                    },
1975                    EntityVariantRequirement {
1976                        discriminator_field: "partnerrolle".to_string(),
1977                        code: "Z07".to_string(),
1978                        bo4e_value: Some("kundeMsb".to_string()),
1979                        source_paths: vec!["sg4.sg12_z07".to_string()],
1980                        fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
1981                        ahb_status: String::new(),
1982                    },
1983                ],
1984                groups: vec![],
1985                nested: Vec::new(),
1986            }],
1987        }
1988    }
1989
1990    /// `Marktlokation` fed by LOC+Z16 and LOC+Z22, as in FV2604 UTILMD_Strom.
1991    fn multi_group_requirements(z22_status: &str) -> PidRequirements {
1992        let group = |path: &str, status: &str, fields: &[&str]| EntityGroupRequirement {
1993            source_path: path.to_string(),
1994            ahb_status: status.to_string(),
1995            fields: fields.iter().map(|f| f.to_string()).collect(),
1996        };
1997        PidRequirements {
1998            transaktion_wiederholbar: false,
1999            pid: "55043".to_string(),
2000            beschreibung: String::new(),
2001            entities: vec![EntityRequirement {
2002                entity: "Marktlokation".to_string(),
2003                ref_type: Bo4eRefType::Object {
2004                    type_name: "Marktlokation".to_string(),
2005                    cardinality: Cardinality::REQUIRED,
2006                },
2007                ahb_status: "Muss".to_string(),
2008                fields: vec![
2009                    field("marktlokationsId", "X", &[]),
2010                    field("ruhendeMarktlokationsId", "X", &[]),
2011                    field("ruhendeMarktlokationZeitraumId", "Kann", &[]),
2012                ],
2013                map_key: None,
2014                scope: EntityScope::Transaction,
2015                variants: vec![],
2016                groups: vec![
2017                    group("sg4.sg5_z16", "Muss", &["marktlokationsId"]),
2018                    group(
2019                        "sg4.sg5_z22",
2020                        z22_status,
2021                        &["ruhendeMarktlokationsId", "ruhendeMarktlokationZeitraumId"],
2022                    ),
2023                ],
2024                nested: Vec::new(),
2025            }],
2026        }
2027    }
2028
2029    fn missing_fields(errors: &[PidValidationError]) -> Vec<&str> {
2030        errors
2031            .iter()
2032            .filter_map(|e| match e {
2033                PidValidationError::MissingField { field, .. } => Some(field.as_str()),
2034                _ => None,
2035            })
2036            .collect()
2037    }
2038
2039    #[test]
2040    fn an_absent_optional_groups_fields_are_not_demanded() {
2041        let reqs = multi_group_requirements("Soll [2003]");
2042        let errors = validate_pid_json(&json!({ "marktlokation": {} }), &reqs);
2043        // The Z16 group is required, so its fields are; Z22's are not.
2044        assert_eq!(missing_fields(&errors), vec!["marktlokationsId"]);
2045    }
2046
2047    #[test]
2048    fn a_filled_groups_fields_are_demanded() {
2049        let reqs = multi_group_requirements("Soll [2003]");
2050        let json = json!({ "marktlokation": {
2051            "marktlokationsId": "51238696781",
2052            "ruhendeMarktlokationZeitraumId": "1"
2053        }});
2054        let errors = validate_pid_json(&json, &reqs);
2055        assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
2056    }
2057
2058    #[test]
2059    fn an_absent_required_groups_fields_are_demanded() {
2060        let reqs = multi_group_requirements("Muss");
2061        let json = json!({ "marktlokation": { "marktlokationsId": "51238696781" } });
2062        let errors = validate_pid_json(&json, &reqs);
2063        assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
2064    }
2065
2066    #[test]
2067    fn multi_variant_entity_uses_the_elements_own_variant() {
2068        let reqs = multi_variant_requirements();
2069        // Every spelling of the qualifier: raw code / enum_map name, string / object.
2070        let json = json!({
2071            "geschaeftspartner": [
2072                { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
2073                { "partnerrolle": "kundeMsb", "name1": "Muster" },
2074                { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
2075                  "adresse": { "ort": "Köln" } },
2076                { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
2077            ]
2078        });
2079        let errors = validate_pid_json(&json, &reqs);
2080        assert!(errors.is_empty(), "{}", ValidationReport(errors));
2081    }
2082
2083    #[test]
2084    fn multi_variant_entity_reports_variant_required_fields() {
2085        let reqs = multi_variant_requirements();
2086        let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
2087        let errors = validate_pid_json(&json, &reqs);
2088        assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
2089        assert!(matches!(
2090            &errors[0],
2091            PidValidationError::MissingField { field, .. } if field == "name1"
2092        ));
2093    }
2094
2095    #[test]
2096    fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
2097        let reqs = multi_variant_requirements();
2098        for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
2099            let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
2100            let errors = validate_pid_json(&json, &reqs);
2101            // No variant matches: variant fields become optional, codes are unioned.
2102            assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
2103            match &errors[0] {
2104                PidValidationError::InvalidCode {
2105                    field,
2106                    valid_values,
2107                    ..
2108                } => {
2109                    assert_eq!(field, "partnerrolle");
2110                    let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
2111                    assert_eq!(codes, ["Z03", "Z07"]);
2112                }
2113                other => panic!("expected InvalidCode, got {other:?}"),
2114            }
2115        }
2116    }
2117
2118    #[test]
2119    fn code_objects_and_enum_mapped_names_are_code_checked() {
2120        let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
2121        assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
2122        assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
2123        assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
2124        assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
2125        assert_eq!(
2126            invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
2127            Some("Z99".to_string())
2128        );
2129        assert_eq!(
2130            invalid_code_value(&json!("kundeLf"), &f),
2131            Some("kundeLf".to_string())
2132        );
2133        // Non-code values (numbers, objects without code) are not code-checked.
2134        assert_eq!(invalid_code_value(&json!(7), &f), None);
2135        assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
2136    }
2137}