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        // The elements of a list read per segment (`werte[]`: the CAVs of one
555        // SG10) sit in the parent's group instance.
556        let per_segment = candidates.iter().all(|l| l.per_segment);
557        for (index, item) in items.iter().enumerate() {
558            if !per_segment {
559                position.push((field.to_string(), index));
560            }
561            let here = position.clone();
562            let required_here = |status: &str| required(status, &here);
563            let matching: Vec<(&NestedListRequirement, &NestedElementKind)> = match chosen {
564                Some(list) => kind_of(list, item, alone)
565                    .filter(|k| applies(k) != Some(false))
566                    .map(|k| (list, k))
567                    .into_iter()
568                    .collect(),
569                None => candidates
570                    .iter()
571                    .flat_map(|l| {
572                        l.kinds
573                            .iter()
574                            .filter(|k| applies(k) != Some(false))
575                            .filter(|k| kind_matches(k, item))
576                            .map(move |k| (*l, k))
577                    })
578                    .collect(),
579            };
580            // Which variant of the parent's group an element came from is not
581            // recorded: it is judged by the matching kind it satisfies best, a
582            // kind with a selector before one without.
583            // A kind's per-segment lists count: their CAVs tell an SG10 of
584            // one SG8 variant from another's where the CCI cannot.
585            let best = matching
586                .iter()
587                .map(|(l, k)| {
588                    let element = match sent_code(per_segment, k, item) {
589                        Some(code) => format!("{label}[{code}]"),
590                        None => format!("{label}[]"),
591                    };
592                    let mut findings = element_findings(entity, &element, k, item, &required_here);
593                    nested_lists_at(
594                        entity,
595                        item,
596                        &k.lists,
597                        &format!("{label}[]."),
598                        &mut here.clone(),
599                        required,
600                        variant_holds,
601                        &|_, _| false,
602                        &mut findings,
603                    );
604                    (*l, *k, findings)
605                })
606                .min_by_key(|(_, k, f)| (f.len(), k.selector.is_empty()));
607            if let Some((list, kind, findings)) = best {
608                errors.extend(
609                    findings
610                        .into_iter()
611                        .filter(|f| !matches!(f, PidValidationError::NotAllowed { .. })),
612                );
613                // Present although its kind's condition does not hold, or
614                // carrying a field or code whose condition does not.
615                let forbidden_here = |status: &str| forbidden(status, &here);
616                if forbidden_here(&kind.ahb_status) {
617                    // A segment is named by the code it was sent with (CAV+Z61,
618                    // not its kind's first code Z56), a group element by its
619                    // kind.
620                    let sent = sent_code(per_segment, kind, item);
621                    let name = sent.clone().unwrap_or_else(|| {
622                        kind.selector
623                            .first()
624                            .and_then(|s| s.codes.first())
625                            .map(|c| c.bo4e_value.clone().unwrap_or_else(|| c.code.clone()))
626                            .unwrap_or_default()
627                    });
628                    errors.push(PidValidationError::NotAllowed {
629                        entity: entity.to_string(),
630                        field: format!("{label}[{name}]"),
631                        value: sent,
632                        ahb_status: kind.ahb_status.clone(),
633                        severity: Severity::Warning,
634                    });
635                } else {
636                    errors.extend(not_allowed_codes(
637                        entity,
638                        &format!("{label}[]."),
639                        &kind.fields,
640                        item,
641                        &forbidden_here,
642                    ));
643                }
644                nested_lists_at(
645                    entity,
646                    item,
647                    &list.nested,
648                    &format!("{label}[]."),
649                    position,
650                    required,
651                    variant_holds,
652                    forbidden,
653                    errors,
654                );
655                // The per-segment lists' findings are among `findings`; what
656                // their elements carry although not allowed is judged here.
657                let mut refused = Vec::new();
658                nested_lists_at(
659                    entity,
660                    item,
661                    &kind.lists,
662                    &format!("{label}[]."),
663                    position,
664                    &|_, _| None,
665                    variant_holds,
666                    forbidden,
667                    &mut refused,
668                );
669                errors.extend(
670                    refused
671                        .into_iter()
672                        .filter(|f| matches!(f, PidValidationError::NotAllowed { .. })),
673                );
674            } else if let (true, Some(list)) = (per_segment, chosen) {
675                // A segment of a qualifier none of the list's kinds has.
676                let selector = list.kinds.iter().flat_map(|k| &k.selector).next();
677                let value = selector
678                    .and_then(|s| get_nested(item, &s.field))
679                    .and_then(code_field_value);
680                if let (Some(selector), Some(value)) = (selector, value) {
681                    errors.push(PidValidationError::InvalidCode {
682                        entity: entity.to_string(),
683                        field: format!("{label}[].{}", selector.field),
684                        value: value.to_string(),
685                        valid_values: code_values_to_tuples(
686                            &list
687                                .kinds
688                                .iter()
689                                .flat_map(|k| &k.selector)
690                                .flat_map(|s| s.codes.iter().cloned())
691                                .collect::<Vec<_>>(),
692                        ),
693                    });
694                }
695            }
696            if !per_segment {
697                position.pop();
698            }
699        }
700    }
701}
702
703/// The code an element of a per-segment list was sent with: the value of its
704/// kind's selector field (CAV+Z61 → `Z61`). `None` for a group element.
705fn sent_code(per_segment: bool, kind: &NestedElementKind, item: &Value) -> Option<String> {
706    if !per_segment {
707        return None;
708    }
709    let selector = kind.selector.first()?;
710    get_nested(item, &selector.field)
711        .and_then(code_field_value)
712        .map(str::to_string)
713}
714
715/// The kind of `list` an element is, if any. A list alone on its field with a
716/// single kind takes every element as that kind, so a wrong code in its
717/// selector is reported as a wrong code rather than as a missing kind.
718fn kind_of<'l>(
719    list: &'l NestedListRequirement,
720    item: &Value,
721    alone: bool,
722) -> Option<&'l NestedElementKind> {
723    match &list.kinds[..] {
724        [only] if alone => Some(only),
725        kinds => kinds.iter().find(|k| kind_matches(k, item)),
726    }
727}
728
729/// Whether `item` is of `kind`: every selector field holds one of its codes,
730/// in raw or translated spelling.
731fn kind_matches(kind: &NestedElementKind, item: &Value) -> bool {
732    kind.selector
733        .iter()
734        .all(|selector| selector_matches(selector, item))
735}
736
737/// Whether `item`'s `selector.field` holds one of the selector's codes.
738fn selector_matches(selector: &KindSelector, item: &Value) -> bool {
739    get_nested(item, &selector.field)
740        .and_then(code_field_value)
741        .is_some_and(|value| {
742            selector
743                .codes
744                .iter()
745                .any(|c| c.code == value || c.bo4e_value.as_deref() == Some(value))
746        })
747}
748
749/// The codes `element` carries although their condition does not hold: codes
750/// sent in a segment of their own condition
751/// ([`CodeValue::ahb_status`](crate::pid_requirements::CodeValue), SG10
752/// CAV+ZB4 "Messstellenbetreiber Alt"). A plain field's status is not judged:
753/// its conditions state what the value must be (`[22]` "Es ist die Zeitraum-ID
754/// … einzutragen"), which the EDIFACT validator reports. `label` prefixes the
755/// field names (`zuordnungen[].`).
756pub fn not_allowed_codes(
757    entity: &str,
758    label: &str,
759    fields: &[FieldRequirement],
760    element: &Value,
761    forbidden: &dyn Fn(&str) -> bool,
762) -> Vec<PidValidationError> {
763    let mut out = Vec::new();
764    for field_req in fields {
765        let values = all_nested(element, &field_req.bo4e_name);
766        if values.is_empty() {
767            continue;
768        }
769        let name = format!("{label}{}", field_req.bo4e_name);
770        for value in values {
771            let Some(code) = code_field_value(value) else {
772                continue;
773            };
774            let status = field_req
775                .valid_codes
776                .iter()
777                .find(|cv| cv.code == code || cv.bo4e_value.as_deref() == Some(code))
778                .and_then(|cv| cv.ahb_status.as_deref());
779            if let Some(status) = status.filter(|s| forbidden(s)) {
780                out.push(PidValidationError::NotAllowed {
781                    entity: entity.to_string(),
782                    field: name.clone(),
783                    value: Some(code.to_string()),
784                    ahb_status: status.to_string(),
785                    severity: Severity::Warning,
786                });
787            }
788        }
789    }
790    out
791}
792
793/// The findings on one list element judged as `kind`: its missing fields and
794/// wrong codes.
795fn element_findings(
796    entity: &str,
797    element: &str,
798    kind: &NestedElementKind,
799    item: &Value,
800    required: &dyn Fn(&str) -> Option<Severity>,
801) -> Vec<PidValidationError> {
802    let mut out = Vec::new();
803    for field_req in &kind.fields {
804        let name = format!("{element}.{}", field_req.bo4e_name);
805        let values = all_nested(item, &field_req.bo4e_name);
806        if values.is_empty() {
807            if let Some(severity) = required(&field_req.ahb_status) {
808                out.push(PidValidationError::MissingField {
809                    entity: entity.to_string(),
810                    field: name,
811                    ahb_status: field_req.ahb_status.clone(),
812                    rust_type: field_req.enum_name.clone(),
813                    valid_values: code_values_to_tuples(&field_req.valid_codes),
814                    severity,
815                });
816            }
817            continue;
818        }
819        for value in values {
820            out.extend(too_long(entity, &name, value, field_req));
821            out.extend(not_a_number(entity, &name, value, field_req));
822            if let Some(value) = invalid_code_value(value, field_req) {
823                out.push(PidValidationError::InvalidCode {
824                    entity: entity.to_string(),
825                    field: name.clone(),
826                    value,
827                    valid_values: code_values_to_tuples(&field_req.valid_codes),
828                });
829            }
830        }
831    }
832    out
833}
834
835/// Every non-null value at `path`: all elements' for a list target
836/// (`werte[].code`), where [`get_nested`] finds only the first.
837fn all_nested<'a>(json: &'a Value, path: &str) -> Vec<&'a Value> {
838    if let Some((list, rest)) = path.split_once("[].") {
839        return get_nested(json, list)
840            .and_then(|v| v.as_array())
841            .map(|items| {
842                items
843                    .iter()
844                    .flat_map(|item| all_nested(item, rest))
845                    .collect()
846            })
847            .unwrap_or_default();
848    }
849    get_nested(json, path)
850        .filter(|v| !v.is_null())
851        .into_iter()
852        .collect()
853}
854
855/// A [`PidValidationError::NotANumber`] when the string `val` of a data field
856/// whose MIG format is numeric (`n..10`) is not a number: an optional minus
857/// sign, digits, at most one decimal mark (`.` or `,`). Code fields and the
858/// codes of a free data field are left to the code checks, as in [`too_long`].
859pub fn not_a_number(
860    entity: &str,
861    field: &str,
862    val: &Value,
863    field_req: &FieldRequirement,
864) -> Option<PidValidationError> {
865    if !field_req.valid_codes.is_empty() {
866        return None;
867    }
868    let format = field_req.format.as_deref()?;
869    let text = val.as_str()?;
870    if !format.trim().starts_with('n')
871        || text.is_empty()
872        || field_req
873            .free_data_codes
874            .iter()
875            .any(|c| c.code == text || c.bo4e_value.as_deref() == Some(text))
876    {
877        return None;
878    }
879    let unsigned = text.strip_prefix('-').unwrap_or(text);
880    let digits = unsigned.chars().filter(char::is_ascii_digit).count();
881    let marks = unsigned.chars().filter(|c| matches!(c, '.' | ',')).count();
882    let number = digits > 0 && marks <= 1 && digits + marks == unsigned.chars().count();
883    (!number).then(|| PidValidationError::NotANumber {
884        entity: entity.to_string(),
885        field: field.to_string(),
886        value: text.to_string(),
887        format: format.to_string(),
888    })
889}
890
891/// A [`PidValidationError::TooLong`] when the string `val` of a data field is
892/// longer than the maximum of the field's MIG format (`an..512` → 512, `n13`
893/// → 13).
894pub fn too_long(
895    entity: &str,
896    field: &str,
897    val: &Value,
898    field_req: &FieldRequirement,
899) -> Option<PidValidationError> {
900    // A code field carries the code's BO4E name (`transaktionsgrund`:
901    // `kundeVerlaesst…`), not the code; its codes are checked instead.
902    if !field_req.valid_codes.is_empty() {
903        return None;
904    }
905    let format = field_req.format.as_deref()?;
906    let text = val.as_str()?;
907    // Free data that is one of the codes another source of the field allows,
908    // in either spelling: a code is never too long, and its BO4E name is not
909    // what is sent.
910    if field_req
911        .free_data_codes
912        .iter()
913        .any(|c| c.code == text || c.bo4e_value.as_deref() == Some(text))
914    {
915        return None;
916    }
917    let digits = format
918        .trim()
919        .strip_prefix("an")
920        .or_else(|| format.trim().strip_prefix('a'))
921        .or_else(|| format.trim().strip_prefix('n'))?;
922    let max: usize = digits.strip_prefix("..").unwrap_or(digits).parse().ok()?;
923    // In a numeric the decimal mark and a sign are not part of an `n..15`
924    // value's length; everything else counts.
925    let length = if format.trim().starts_with('n') {
926        text.chars()
927            .filter(|c| !matches!(c, '.' | ',' | '-'))
928            .count()
929    } else {
930        text.chars().count()
931    };
932    (length > max).then(|| PidValidationError::TooLong {
933        entity: entity.to_string(),
934        field: field.to_string(),
935        length,
936        format: format.to_string(),
937    })
938}
939
940/// Validate that a code field's value is in the allowed set.
941fn validate_code_value(
942    val: &Value,
943    entity_req: &EntityRequirement,
944    field_req: &FieldRequirement,
945    errors: &mut Vec<PidValidationError>,
946) {
947    if let Some(value) = invalid_code_value(val, field_req) {
948        errors.push(PidValidationError::InvalidCode {
949            entity: entity_req.entity.clone(),
950            field: field_req.bo4e_name.clone(),
951            value,
952            valid_values: code_values_to_tuples(&field_req.valid_codes),
953        });
954    }
955    errors.extend(too_long(
956        &entity_req.entity,
957        &field_req.bo4e_name,
958        val,
959        field_req,
960    ));
961    errors.extend(not_a_number(
962        &entity_req.entity,
963        &field_req.bo4e_name,
964        val,
965        field_req,
966    ));
967}
968
969/// The code carried by a BO4E code field: a plain string, or the `code` member of
970/// a `{code, meaning, enum}` object as written by `from_edifact`.
971pub fn code_field_value(val: &Value) -> Option<&str> {
972    val.as_str()
973        .or_else(|| val.get("code").and_then(|c| c.as_str()))
974}
975
976/// Check a code field's value against `field_req.valid_codes`.
977///
978/// Returns the offending value if it is invalid, `None` if it is valid or not a
979/// code value at all. A value is valid when it equals a valid raw EDIFACT code
980/// or the BO4E value the mapping's `enum_map` translates a valid code to — the
981/// two spellings `to_edifact` accepts.
982pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
983    if field_req.valid_codes.is_empty() {
984        return None;
985    }
986    let value = code_field_value(val)?;
987    let is_valid = field_req
988        .valid_codes
989        .iter()
990        .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
991    (!is_valid).then(|| value.to_string())
992}
993
994/// The group statuses of the variants `element` belongs to: those whose
995/// discriminator field carries their code. Empty when the entity has no
996/// variants, none records its status, or the element names none.
997pub fn variant_statuses<'a>(entity_req: &'a EntityRequirement, element: &Value) -> Vec<&'a str> {
998    let mut out: Vec<&'a str> = Vec::new();
999    for v in &entity_req.variants {
1000        if v.ahb_status.is_empty() {
1001            continue;
1002        }
1003        let value = get_nested(element, &v.discriminator_field).and_then(code_field_value);
1004        if value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s))
1005            && !out.contains(&v.ahb_status.as_str())
1006        {
1007            out.push(v.ahb_status.as_str());
1008        }
1009    }
1010    out
1011}
1012
1013/// The field requirements that apply to one element of an entity.
1014///
1015/// For an entity without [`EntityRequirement::variants`] this is simply its
1016/// `fields`. For a multi-variant entity (e.g. `Geschaeftspartner` fed by
1017/// `sg12_z03`/`sg12_z07`/…), the element's discriminator value (raw code or
1018/// `enum_map`ped BO4E value, string or code object) selects the matching
1019/// variant, and the variant-owned fields are replaced by that variant's
1020/// requirements — fields the variant's group lacks do not apply.
1021///
1022/// If the discriminator is missing or matches no variant, all variants are
1023/// candidates and are combined leniently: codes are unioned, and a field keeps
1024/// its AHB status only if every candidate agrees on it (otherwise it is treated
1025/// as optional), so an unidentifiable element is never held to the required
1026/// fields of a variant it may not be.
1027pub fn effective_field_requirements<'a>(
1028    entity_req: &'a EntityRequirement,
1029    element: &Value,
1030) -> Cow<'a, [FieldRequirement]> {
1031    if entity_req.variants.is_empty() {
1032        return Cow::Borrowed(&entity_req.fields);
1033    }
1034
1035    // Candidate variants, per discriminator field.
1036    let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
1037    for v in &entity_req.variants {
1038        by_field
1039            .entry(v.discriminator_field.as_str())
1040            .or_default()
1041            .push(v);
1042    }
1043    let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
1044    for (field, group) in by_field {
1045        let value = get_nested(element, field).and_then(code_field_value);
1046        let matched: Vec<&EntityVariantRequirement> = group
1047            .iter()
1048            .copied()
1049            .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
1050            .collect();
1051        candidates.extend(if matched.is_empty() { group } else { matched });
1052    }
1053
1054    // Fields governed by variants (in first-seen order).
1055    let mut owned: Vec<&str> = Vec::new();
1056    let mut owned_set: BTreeSet<&str> = BTreeSet::new();
1057    for v in &entity_req.variants {
1058        for f in &v.fields {
1059            if owned_set.insert(f.bo4e_name.as_str()) {
1060                owned.push(f.bo4e_name.as_str());
1061            }
1062        }
1063    }
1064
1065    let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
1066    for name in owned {
1067        let reqs: Vec<&FieldRequirement> = candidates
1068            .iter()
1069            .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
1070            .collect();
1071        let Some((first, rest)) = reqs.split_first() else {
1072            continue; // not applicable to any candidate variant
1073        };
1074        let mut field = (*first).clone();
1075        let mut statuses_agree = reqs.len() == candidates.len();
1076        for r in rest {
1077            if r.ahb_status != field.ahb_status {
1078                statuses_agree = false;
1079            }
1080            for cv in &r.valid_codes {
1081                if !field.valid_codes.iter().any(|c| c.code == cv.code) {
1082                    field.valid_codes.push(cv.clone());
1083                }
1084            }
1085        }
1086        if !statuses_agree {
1087            field.ahb_status = String::new();
1088        }
1089        combined.insert(name, field);
1090    }
1091
1092    let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
1093    for f in &entity_req.fields {
1094        if owned_set.contains(f.bo4e_name.as_str()) {
1095            if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
1096                result.push(c);
1097            }
1098        } else {
1099            result.push(f.clone());
1100        }
1101    }
1102    result.extend(combined.into_values());
1103    Cow::Owned(result)
1104}
1105
1106/// The groups of a multi-group entity (see [`EntityRequirement::groups`]) that
1107/// `element` carries no data for.
1108pub fn absent_groups<'a>(
1109    entity_req: &'a EntityRequirement,
1110    element: &'a Value,
1111) -> impl Iterator<Item = &'a EntityGroupRequirement> + 'a {
1112    entity_req.groups.iter().filter(move |g| {
1113        !g.fields
1114            .iter()
1115            .any(|f| get_nested(element, f).is_some_and(|v| !v.is_null()))
1116    })
1117}
1118
1119/// The fields `element` need not carry because every group writing them is
1120/// absent from it and not required.
1121///
1122/// `group_required` decides from a group's AHB status. A present entity of a
1123/// multi-group entity says nothing about any one of its groups: 55043's
1124/// `Marktlokation` with only LOC+Z16 data has no ruhende Marktlokation, so the
1125/// LOC+Z22 group's `X` fields do not apply — unless that group is itself
1126/// required, in which case they are reported as usual.
1127pub fn fields_of_unrequired_absent_groups<'a>(
1128    entity_req: &'a EntityRequirement,
1129    element: &'a Value,
1130    mut group_required: impl FnMut(&str) -> bool,
1131) -> BTreeSet<&'a str> {
1132    let exempt: Vec<&EntityGroupRequirement> = absent_groups(entity_req, element)
1133        .filter(|g| !group_required(&g.ahb_status))
1134        .collect();
1135    let mut fields = BTreeSet::new();
1136    for g in &exempt {
1137        for f in &g.fields {
1138            // A field another group also writes stays, unless that group is
1139            // exempt as well.
1140            let kept_elsewhere = entity_req.groups.iter().any(|other| {
1141                other.fields.contains(f) && !exempt.iter().any(|e| std::ptr::eq(*e, other))
1142            });
1143            if !kept_elsewhere {
1144                fields.insert(f.as_str());
1145            }
1146        }
1147    }
1148    fields
1149}
1150
1151/// Convert CodeValue vec to (code, meaning) tuples.
1152fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
1153    codes
1154        .iter()
1155        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
1156        .collect()
1157}
1158
1159/// Convert PascalCase entity name to camelCase JSON key.
1160///
1161/// "Prozessdaten" → "prozessdaten"
1162/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
1163/// "Marktlokation" → "marktlokation"
1164fn to_camel_case(s: &str) -> String {
1165    if s.is_empty() {
1166        return String::new();
1167    }
1168    let mut chars = s.chars();
1169    let first = chars.next().unwrap();
1170    let mut result = first.to_lowercase().to_string();
1171    result.extend(chars);
1172    result
1173}
1174
1175/// Convert a snake_case field name to camelCase.
1176///
1177/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
1178/// the validator to find fields in JSON that was produced by typed structs even when
1179/// the requirement stores the field name as snake_case (as it comes from TOML).
1180///
1181/// Examples:
1182/// - `"code_codepflege"` → `"codeCodepflege"`
1183/// - `"vorgang_id"` → `"vorgangId"`
1184/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
1185fn snake_to_camel_case(s: &str) -> String {
1186    let mut result = String::with_capacity(s.len());
1187    let mut capitalize_next = false;
1188    for ch in s.chars() {
1189        if ch == '_' {
1190            capitalize_next = true;
1191        } else if capitalize_next {
1192            result.extend(ch.to_uppercase());
1193            capitalize_next = false;
1194        } else {
1195            result.push(ch);
1196        }
1197    }
1198    result
1199}
1200
1201/// Returns true if the AHB status indicates an unconditionally required field.
1202fn is_unconditionally_required(ahb_status: &str) -> bool {
1203    matches!(ahb_status, "X" | "Muss" | "Soll")
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208
1209    /// A field that is free data in one SG10 kind and a Merkmal code in its
1210    /// merged sibling (55043's `zuordnungen[].merkmal.code`, `an..3`): a
1211    /// translated code name is not the value sent, a 12-character Zählzeit
1212    /// code is too long.
1213    #[test]
1214    fn a_numeric_field_holds_a_number() {
1215        let field = FieldRequirement {
1216            bo4e_name: "geplantesPaket".to_string(),
1217            ahb_status: "X [914] ∧ [937]".to_string(),
1218            field_type: "data".to_string(),
1219            format: Some("n..10".to_string()),
1220            enum_name: None,
1221            free_data_codes: vec![],
1222            element_status: String::new(),
1223            context_status: String::new(),
1224            segment: None,
1225            valid_codes: vec![],
1226            ref_type: crate::pid_requirements::Bo4eRefType::Unknown,
1227        };
1228        let check = |v: &str| not_a_number("Prozessdaten", "geplantesPaket", &json!(v), &field);
1229        assert!(check("O5D2DXBG1A").is_some());
1230        assert!(check("1e3").is_some());
1231        assert!(check("1.2.3").is_some());
1232        assert!(check("12").is_none());
1233        assert!(check("-0,5").is_none());
1234        assert!(check("").is_none());
1235    }
1236
1237    #[test]
1238    fn too_long_skips_codes_of_a_free_data_field() {
1239        let field = FieldRequirement {
1240            bo4e_name: "merkmal.code".to_string(),
1241            ahb_status: "X".to_string(),
1242            field_type: "data".to_string(),
1243            format: Some("an..3".to_string()),
1244            enum_name: None,
1245            free_data_codes: vec![CodeValue {
1246                code: "ZA9".to_string(),
1247                meaning: "Übertragungsnetzbetreiber".to_string(),
1248                enum_name: None,
1249                bo4e_value: Some("uebertragungsnetzbetreiber".to_string()),
1250                ahb_status: None,
1251                code_status: None,
1252            }],
1253            element_status: String::new(),
1254            context_status: String::new(),
1255            segment: None,
1256            valid_codes: vec![],
1257            ref_type: crate::pid_requirements::Bo4eRefType::Unknown,
1258        };
1259        let check = |v: &str| too_long("E", "zuordnungen[].merkmal.code", &json!(v), &field);
1260        assert!(check("uebertragungsnetzbetreiber").is_none());
1261        assert!(check("ZA9").is_none());
1262        assert!(check("Z1").is_none());
1263        assert!(matches!(
1264            check("ZDNBNAPG56ZM"),
1265            Some(PidValidationError::TooLong { length: 12, .. })
1266        ));
1267    }
1268    use super::*;
1269
1270    /// A list kind bound to a transaction variant (55070: SG10s only in the
1271    /// IDE+24 variant) is demanded where that variant holds, and neither where
1272    /// another holds nor where it cannot be told.
1273    #[test]
1274    fn a_variant_bound_kind_is_demanded_only_in_its_variant() {
1275        use crate::pid_requirements::{NestedElementKind, NestedListRequirement, VariantQualifier};
1276        let lists = vec![NestedListRequirement {
1277            field: "zuordnungen".to_string(),
1278            bo4e_type: "Zuordnung".to_string(),
1279            source_path: "sg4.sg8_z22.sg10".to_string(),
1280            kinds: vec![NestedElementKind {
1281                ahb_status: "Muss".to_string(),
1282                selector: vec![],
1283                fields: vec![],
1284                transaction_variant: Some(VariantQualifier {
1285                    group: "SG4".to_string(),
1286                    segment: "IDE".to_string(),
1287                    codes: vec!["24".to_string()],
1288                }),
1289                lists: vec![],
1290            }],
1291            parent_selector: vec![],
1292            nested: vec![],
1293            per_segment: false,
1294        }];
1295        let demanded = |holds: Option<bool>| {
1296            let mut errors = Vec::new();
1297            validate_nested_lists(
1298                "SummenzeitreihenDaten",
1299                &serde_json::json!({}),
1300                &lists,
1301                "",
1302                &|_, _| Some(Severity::Error),
1303                &|_| holds,
1304                &|_, _| false,
1305                &mut errors,
1306            );
1307            errors.len()
1308        };
1309        assert_eq!(demanded(Some(true)), 1);
1310        assert_eq!(demanded(Some(false)), 0);
1311        assert_eq!(demanded(None), 0);
1312    }
1313    use crate::pid_requirements::{
1314        Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
1315    };
1316    use serde_json::json;
1317
1318    fn sample_requirements() -> PidRequirements {
1319        PidRequirements {
1320            transaktion_wiederholbar: false,
1321            pid: "55001".to_string(),
1322            beschreibung: "Anmeldung verb. MaLo".to_string(),
1323            entities: vec![
1324                EntityRequirement {
1325                    entity: "Prozessdaten".to_string(),
1326                    ref_type: Bo4eRefType::Object {
1327                        type_name: "Prozessdaten".to_string(),
1328
1329                        cardinality: Cardinality::REQUIRED,
1330                    },
1331
1332                    ahb_status: "Muss".to_string(),
1333                    map_key: None,
1334                    scope: EntityScope::Transaction,
1335                    variants: vec![],
1336                    groups: vec![],
1337                    fields: vec![
1338                        FieldRequirement {
1339                            bo4e_name: "vorgangId".to_string(),
1340                            ahb_status: "X".to_string(),
1341                            field_type: "data".to_string(),
1342                            format: None,
1343                            enum_name: None,
1344                            free_data_codes: Vec::new(),
1345                            element_status: String::new(),
1346                            context_status: String::new(),
1347                            segment: None,
1348                            valid_codes: vec![],
1349                            ref_type: Bo4eRefType::Unknown,
1350                        },
1351                        FieldRequirement {
1352                            bo4e_name: "transaktionsgrund".to_string(),
1353                            ahb_status: "X".to_string(),
1354                            field_type: "code".to_string(),
1355                            format: None,
1356                            enum_name: Some("Transaktionsgrund".to_string()),
1357                            free_data_codes: Vec::new(),
1358                            element_status: String::new(),
1359                            context_status: String::new(),
1360                            segment: None,
1361                            valid_codes: vec![
1362                                CodeValue {
1363                                    code: "E01".to_string(),
1364                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
1365                                    enum_name: None,
1366                                    bo4e_value: None,
1367                                    ahb_status: None,
1368                                    code_status: None,
1369                                },
1370                                CodeValue {
1371                                    code: "E03".to_string(),
1372                                    meaning: "Wechsel".to_string(),
1373                                    enum_name: None,
1374                                    bo4e_value: None,
1375                                    ahb_status: None,
1376                                    code_status: None,
1377                                },
1378                            ],
1379                            ref_type: Bo4eRefType::Unknown,
1380                        },
1381                    ],
1382                    nested: Vec::new(),
1383                },
1384                EntityRequirement {
1385                    entity: "Marktlokation".to_string(),
1386                    ref_type: Bo4eRefType::Object {
1387                        type_name: "Marktlokation".to_string(),
1388
1389                        cardinality: Cardinality::REQUIRED,
1390                    },
1391
1392                    ahb_status: "Muss".to_string(),
1393                    map_key: None,
1394                    scope: EntityScope::Transaction,
1395                    variants: vec![],
1396                    groups: vec![],
1397                    fields: vec![
1398                        FieldRequirement {
1399                            bo4e_name: "marktlokationsId".to_string(),
1400                            ahb_status: "X".to_string(),
1401                            field_type: "data".to_string(),
1402                            format: None,
1403                            enum_name: None,
1404                            free_data_codes: Vec::new(),
1405                            element_status: String::new(),
1406                            context_status: String::new(),
1407                            segment: None,
1408                            valid_codes: vec![],
1409                            ref_type: Bo4eRefType::Unknown,
1410                        },
1411                        FieldRequirement {
1412                            bo4e_name: "haushaltskunde".to_string(),
1413                            ahb_status: "X".to_string(),
1414                            field_type: "code".to_string(),
1415                            format: None,
1416                            enum_name: Some("Haushaltskunde".to_string()),
1417                            free_data_codes: Vec::new(),
1418                            element_status: String::new(),
1419                            context_status: String::new(),
1420                            segment: None,
1421                            valid_codes: vec![
1422                                CodeValue {
1423                                    code: "Z15".to_string(),
1424                                    meaning: "Ja".to_string(),
1425                                    enum_name: None,
1426                                    bo4e_value: None,
1427                                    ahb_status: None,
1428                                    code_status: None,
1429                                },
1430                                CodeValue {
1431                                    code: "Z18".to_string(),
1432                                    meaning: "Nein".to_string(),
1433                                    enum_name: None,
1434                                    bo4e_value: None,
1435                                    ahb_status: None,
1436                                    code_status: None,
1437                                },
1438                            ],
1439                            ref_type: Bo4eRefType::Unknown,
1440                        },
1441                    ],
1442                    nested: Vec::new(),
1443                },
1444                EntityRequirement {
1445                    entity: "Geschaeftspartner".to_string(),
1446                    ref_type: Bo4eRefType::Object {
1447                        type_name: "Geschaeftspartner".to_string(),
1448
1449                        cardinality: Cardinality {
1450                            min: 1,
1451                            max: Some(7),
1452                        },
1453                    },
1454
1455                    ahb_status: "Muss".to_string(),
1456                    map_key: None,
1457                    scope: EntityScope::Transaction,
1458                    variants: vec![],
1459                    groups: vec![],
1460                    fields: vec![FieldRequirement {
1461                        bo4e_name: "identifikation".to_string(),
1462                        ahb_status: "X".to_string(),
1463                        field_type: "data".to_string(),
1464                        format: None,
1465                        enum_name: None,
1466                        free_data_codes: Vec::new(),
1467                        element_status: String::new(),
1468                        context_status: String::new(),
1469                        segment: None,
1470                        valid_codes: vec![],
1471                        ref_type: Bo4eRefType::Unknown,
1472                    }],
1473                    nested: Vec::new(),
1474                },
1475            ],
1476        }
1477    }
1478
1479    #[test]
1480    fn test_validate_complete_json() {
1481        let reqs = sample_requirements();
1482        let json = json!({
1483            "prozessdaten": {
1484                "vorgangId": "ABC123",
1485                "transaktionsgrund": "E01"
1486            },
1487            "marktlokation": {
1488                "marktlokationsId": "51234567890",
1489                "haushaltskunde": "Z15"
1490            },
1491            "geschaeftspartner": [
1492                { "identifikation": "9900000000003" }
1493            ]
1494        });
1495
1496        let errors = validate_pid_json(&json, &reqs);
1497        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
1498    }
1499
1500    #[test]
1501    fn test_validate_missing_entity() {
1502        let reqs = sample_requirements();
1503        let json = json!({
1504            "prozessdaten": {
1505                "vorgangId": "ABC123",
1506                "transaktionsgrund": "E01"
1507            },
1508            "geschaeftspartner": [
1509                { "identifikation": "9900000000003" }
1510            ]
1511        });
1512        // Marktlokation is missing
1513
1514        let errors = validate_pid_json(&json, &reqs);
1515        assert_eq!(errors.len(), 1);
1516        match &errors[0] {
1517            PidValidationError::MissingEntity {
1518                entity,
1519                ahb_status,
1520                severity,
1521            } => {
1522                assert_eq!(entity, "Marktlokation");
1523                assert_eq!(ahb_status, "Muss");
1524                assert_eq!(severity, &Severity::Error);
1525            }
1526            other => panic!("Expected MissingEntity, got: {other:?}"),
1527        }
1528
1529        // Display check
1530        let msg = errors[0].to_string();
1531        assert!(msg.contains("ERROR"));
1532        assert!(msg.contains("Marktlokation"));
1533        assert!(msg.contains("Muss"));
1534    }
1535
1536    #[test]
1537    fn test_validate_missing_field() {
1538        let reqs = sample_requirements();
1539        let json = json!({
1540            "prozessdaten": {
1541                "transaktionsgrund": "E01"
1542                // vorgangId is missing
1543            },
1544            "marktlokation": {
1545                "marktlokationsId": "51234567890",
1546                "haushaltskunde": "Z15"
1547            },
1548            "geschaeftspartner": [
1549                { "identifikation": "9900000000003" }
1550            ]
1551        });
1552
1553        let errors = validate_pid_json(&json, &reqs);
1554        assert_eq!(errors.len(), 1);
1555        match &errors[0] {
1556            PidValidationError::MissingField {
1557                entity,
1558                field,
1559                ahb_status,
1560                severity,
1561                ..
1562            } => {
1563                assert_eq!(entity, "Prozessdaten");
1564                assert_eq!(field, "vorgangId");
1565                assert_eq!(ahb_status, "X");
1566                assert_eq!(severity, &Severity::Error);
1567            }
1568            other => panic!("Expected MissingField, got: {other:?}"),
1569        }
1570
1571        let msg = errors[0].to_string();
1572        assert!(msg.contains("ERROR"));
1573        assert!(msg.contains("Prozessdaten.vorgangId"));
1574    }
1575
1576    #[test]
1577    fn test_validate_invalid_code() {
1578        let reqs = sample_requirements();
1579        let json = json!({
1580            "prozessdaten": {
1581                "vorgangId": "ABC123",
1582                "transaktionsgrund": "E01"
1583            },
1584            "marktlokation": {
1585                "marktlokationsId": "51234567890",
1586                "haushaltskunde": "Z99"  // Invalid code
1587            },
1588            "geschaeftspartner": [
1589                { "identifikation": "9900000000003" }
1590            ]
1591        });
1592
1593        let errors = validate_pid_json(&json, &reqs);
1594        assert_eq!(errors.len(), 1);
1595        match &errors[0] {
1596            PidValidationError::InvalidCode {
1597                entity,
1598                field,
1599                value,
1600                valid_values,
1601            } => {
1602                assert_eq!(entity, "Marktlokation");
1603                assert_eq!(field, "haushaltskunde");
1604                assert_eq!(value, "Z99");
1605                assert_eq!(valid_values.len(), 2);
1606                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
1607                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
1608            }
1609            other => panic!("Expected InvalidCode, got: {other:?}"),
1610        }
1611
1612        let msg = errors[0].to_string();
1613        assert!(msg.contains("INVALID"));
1614        assert!(msg.contains("Z99"));
1615        assert!(msg.contains("Z15"));
1616    }
1617
1618    #[test]
1619    fn test_validate_array_entity() {
1620        let reqs = sample_requirements();
1621        let json = json!({
1622            "prozessdaten": {
1623                "vorgangId": "ABC123",
1624                "transaktionsgrund": "E01"
1625            },
1626            "marktlokation": {
1627                "marktlokationsId": "51234567890",
1628                "haushaltskunde": "Z15"
1629            },
1630            "geschaeftspartner": [
1631                { "identifikation": "9900000000003" },
1632                { }  // Missing identifikation in second element
1633            ]
1634        });
1635
1636        let errors = validate_pid_json(&json, &reqs);
1637        assert_eq!(errors.len(), 1);
1638        match &errors[0] {
1639            PidValidationError::MissingField { entity, field, .. } => {
1640                assert_eq!(entity, "Geschaeftspartner");
1641                assert_eq!(field, "identifikation");
1642            }
1643            other => panic!("Expected MissingField, got: {other:?}"),
1644        }
1645    }
1646
1647    #[test]
1648    fn test_to_camel_case() {
1649        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
1650        assert_eq!(
1651            to_camel_case("RuhendeMarktlokation"),
1652            "ruhendeMarktlokation"
1653        );
1654        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
1655        assert_eq!(to_camel_case(""), "");
1656    }
1657
1658    #[test]
1659    fn test_snake_to_camel_case() {
1660        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
1661        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
1662        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
1663        assert_eq!(snake_to_camel_case(""), "");
1664        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
1665    }
1666
1667    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
1668    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
1669    #[test]
1670    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
1671        let reqs = PidRequirements {
1672            transaktion_wiederholbar: false,
1673            pid: "55077".to_string(),
1674            beschreibung: "Test camelCase fallback".to_string(),
1675            entities: vec![EntityRequirement {
1676                entity: "Zuordnung".to_string(),
1677                ref_type: Bo4eRefType::Object {
1678                    type_name: "Zuordnung".to_string(),
1679
1680                    cardinality: Cardinality::REQUIRED,
1681                },
1682
1683                ahb_status: "Muss".to_string(),
1684                map_key: None,
1685                scope: EntityScope::Transaction,
1686                variants: vec![],
1687                groups: vec![],
1688                fields: vec![
1689                    FieldRequirement {
1690                        // snake_case as stored in TOML requirements
1691                        bo4e_name: "code_codepflege".to_string(),
1692                        ahb_status: "X".to_string(),
1693                        field_type: "data".to_string(),
1694                        format: None,
1695                        enum_name: None,
1696                        free_data_codes: Vec::new(),
1697                        element_status: String::new(),
1698                        context_status: String::new(),
1699                        segment: None,
1700                        valid_codes: vec![],
1701                        ref_type: Bo4eRefType::Unknown,
1702                    },
1703                    FieldRequirement {
1704                        bo4e_name: "codeliste".to_string(),
1705                        ahb_status: "X".to_string(),
1706                        field_type: "data".to_string(),
1707                        format: None,
1708                        enum_name: None,
1709                        free_data_codes: Vec::new(),
1710                        element_status: String::new(),
1711                        context_status: String::new(),
1712                        segment: None,
1713                        valid_codes: vec![],
1714                        ref_type: Bo4eRefType::Unknown,
1715                    },
1716                ],
1717                nested: Vec::new(),
1718            }],
1719        };
1720
1721        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
1722        // code_codepflege → codeCodepflege
1723        let json_camel = json!({
1724            "zuordnung": {
1725                "codeCodepflege": "DE_BDEW",
1726                "codeliste": "6"
1727            }
1728        });
1729
1730        let errors = validate_pid_json(&json_camel, &reqs);
1731        assert!(
1732            errors.is_empty(),
1733            "Expected no errors when field is present under camelCase key, got: {errors:?}"
1734        );
1735
1736        // Also verify that snake_case key in JSON still works (backward compat).
1737        let json_snake = json!({
1738            "zuordnung": {
1739                "code_codepflege": "DE_BDEW",
1740                "codeliste": "6"
1741            }
1742        });
1743
1744        let errors = validate_pid_json(&json_snake, &reqs);
1745        assert!(
1746            errors.is_empty(),
1747            "Expected no errors when field is present under snake_case key, got: {errors:?}"
1748        );
1749
1750        // When the field is truly absent, a MissingField error must still be raised.
1751        let json_missing = json!({
1752            "zuordnung": {
1753                "codeliste": "6"
1754            }
1755        });
1756
1757        let errors = validate_pid_json(&json_missing, &reqs);
1758        assert_eq!(errors.len(), 1);
1759        match &errors[0] {
1760            PidValidationError::MissingField { field, .. } => {
1761                assert_eq!(field, "code_codepflege");
1762            }
1763            other => panic!("Expected MissingField, got: {other:?}"),
1764        }
1765    }
1766
1767    #[test]
1768    fn test_is_unconditionally_required() {
1769        assert!(is_unconditionally_required("X"));
1770        assert!(is_unconditionally_required("Muss"));
1771        assert!(is_unconditionally_required("Soll"));
1772        assert!(!is_unconditionally_required("Kann"));
1773        assert!(!is_unconditionally_required("[1]"));
1774        assert!(!is_unconditionally_required(""));
1775    }
1776
1777    #[test]
1778    fn test_validation_report_display() {
1779        let errors = vec![
1780            PidValidationError::MissingEntity {
1781                entity: "Marktlokation".to_string(),
1782                ahb_status: "Muss".to_string(),
1783                severity: Severity::Error,
1784            },
1785            PidValidationError::MissingField {
1786                entity: "Prozessdaten".to_string(),
1787                field: "vorgangId".to_string(),
1788                ahb_status: "X".to_string(),
1789                rust_type: None,
1790                valid_values: vec![],
1791                severity: Severity::Error,
1792            },
1793        ];
1794        let report = ValidationReport(errors);
1795        assert!(report.has_errors());
1796        assert_eq!(report.len(), 2);
1797        assert!(!report.is_empty());
1798
1799        let display = report.to_string();
1800        assert!(display.contains("missing entity 'Marktlokation'"));
1801        assert!(display.contains("missing Prozessdaten.vorgangId"));
1802    }
1803
1804    #[test]
1805    fn test_missing_field_with_type_and_values_display() {
1806        let err = PidValidationError::MissingField {
1807            entity: "Marktlokation".to_string(),
1808            field: "haushaltskunde".to_string(),
1809            ahb_status: "Muss".to_string(),
1810            rust_type: Some("Haushaltskunde".to_string()),
1811            valid_values: vec![
1812                ("Z15".to_string(), "Ja".to_string()),
1813                ("Z18".to_string(), "Nein".to_string()),
1814            ],
1815            severity: Severity::Error,
1816        };
1817        let msg = err.to_string();
1818        assert!(msg.contains("type: Haushaltskunde"));
1819        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
1820    }
1821
1822    #[test]
1823    fn test_optional_fields_not_flagged() {
1824        let reqs = PidRequirements {
1825            transaktion_wiederholbar: false,
1826            pid: "99999".to_string(),
1827            beschreibung: "Test".to_string(),
1828            entities: vec![EntityRequirement {
1829                entity: "Test".to_string(),
1830                ref_type: Bo4eRefType::Object {
1831                    type_name: "Test".to_string(),
1832
1833                    cardinality: Cardinality::OPTIONAL,
1834                },
1835
1836                ahb_status: "Kann".to_string(),
1837                map_key: None,
1838                scope: EntityScope::Transaction,
1839                variants: vec![],
1840                groups: vec![],
1841                fields: vec![FieldRequirement {
1842                    bo4e_name: "optionalField".to_string(),
1843                    ahb_status: "Kann".to_string(),
1844                    field_type: "data".to_string(),
1845                    format: None,
1846                    enum_name: None,
1847                    free_data_codes: Vec::new(),
1848                    element_status: String::new(),
1849                    context_status: String::new(),
1850                    segment: None,
1851                    valid_codes: vec![],
1852                    ref_type: Bo4eRefType::Unknown,
1853                }],
1854                nested: Vec::new(),
1855            }],
1856        };
1857
1858        // Entity missing but optional — no error
1859        let errors = validate_pid_json(&json!({}), &reqs);
1860        assert!(errors.is_empty());
1861
1862        // Entity present, field missing but optional — no error
1863        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
1864        assert!(errors.is_empty());
1865    }
1866
1867    /// Regression test for issue #48: nested dot-path fields reported as missing
1868    /// even when present (e.g. `produktIdentifikation.funktion`).
1869    #[test]
1870    fn test_nested_dot_path_fields_not_falsely_missing() {
1871        let reqs = PidRequirements {
1872            transaktion_wiederholbar: false,
1873            pid: "55001".to_string(),
1874            beschreibung: "Test nested paths".to_string(),
1875            entities: vec![EntityRequirement {
1876                entity: "ProduktpaketDaten".to_string(),
1877                ref_type: Bo4eRefType::Object {
1878                    type_name: "ProduktpaketDaten".to_string(),
1879
1880                    cardinality: Cardinality {
1881                        min: 1,
1882                        max: Some(99999),
1883                    },
1884                },
1885
1886                ahb_status: "Muss".to_string(),
1887                map_key: None,
1888                scope: EntityScope::Transaction,
1889                variants: vec![],
1890                groups: vec![],
1891                fields: vec![
1892                    FieldRequirement {
1893                        bo4e_name: "produktIdentifikation.funktion".to_string(),
1894                        ahb_status: "X".to_string(),
1895                        field_type: "code".to_string(),
1896                        format: None,
1897                        enum_name: Some("Produktidentifikation".to_string()),
1898                        free_data_codes: Vec::new(),
1899                        element_status: String::new(),
1900                        context_status: String::new(),
1901                        segment: None,
1902                        valid_codes: vec![CodeValue {
1903                            code: "5".to_string(),
1904                            meaning: "Produktidentifikation".to_string(),
1905                            enum_name: None,
1906                            bo4e_value: None,
1907                            ahb_status: None,
1908                            code_status: None,
1909                        }],
1910                        ref_type: Bo4eRefType::Unknown,
1911                    },
1912                    FieldRequirement {
1913                        bo4e_name: "produktMerkmal.code".to_string(),
1914                        ahb_status: "X".to_string(),
1915                        field_type: "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: vec![],
1923                        ref_type: Bo4eRefType::Unknown,
1924                    },
1925                ],
1926                nested: Vec::new(),
1927            }],
1928        };
1929
1930        // Exact JSON from issue #48
1931        let json = json!({
1932            "produktpaketDaten": [{
1933                "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1934                "produktMerkmal": { "code": "ZH9" }
1935            }]
1936        });
1937
1938        let errors = validate_pid_json(&json, &reqs);
1939        assert!(
1940            errors.is_empty(),
1941            "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1942        );
1943    }
1944
1945    #[test]
1946    fn test_nested_dot_path_truly_missing() {
1947        let reqs = PidRequirements {
1948            transaktion_wiederholbar: false,
1949            pid: "55001".to_string(),
1950            beschreibung: "Test nested paths missing".to_string(),
1951            entities: vec![EntityRequirement {
1952                entity: "ProduktpaketDaten".to_string(),
1953                ref_type: Bo4eRefType::Object {
1954                    type_name: "ProduktpaketDaten".to_string(),
1955
1956                    cardinality: Cardinality {
1957                        min: 1,
1958                        max: Some(99999),
1959                    },
1960                },
1961
1962                ahb_status: "Muss".to_string(),
1963                map_key: None,
1964                scope: EntityScope::Transaction,
1965                variants: vec![],
1966                groups: vec![],
1967                fields: vec![FieldRequirement {
1968                    bo4e_name: "produktIdentifikation.funktion".to_string(),
1969                    ahb_status: "X".to_string(),
1970                    field_type: "data".to_string(),
1971                    format: None,
1972                    enum_name: None,
1973                    free_data_codes: Vec::new(),
1974                    element_status: String::new(),
1975                    context_status: String::new(),
1976                    segment: None,
1977                    valid_codes: vec![],
1978                    ref_type: Bo4eRefType::Unknown,
1979                }],
1980                nested: Vec::new(),
1981            }],
1982        };
1983
1984        // Parent exists but nested field is missing
1985        let json = json!({
1986            "produktpaketDaten": [{
1987                "produktIdentifikation": { "id": "123" }
1988            }]
1989        });
1990
1991        let errors = validate_pid_json(&json, &reqs);
1992        assert_eq!(errors.len(), 1, "Should report missing nested field");
1993        match &errors[0] {
1994            PidValidationError::MissingField { field, .. } => {
1995                assert_eq!(field, "produktIdentifikation.funktion");
1996            }
1997            other => panic!("Expected MissingField, got: {other:?}"),
1998        }
1999    }
2000
2001    fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
2002        FieldRequirement {
2003            bo4e_name: name.to_string(),
2004            ahb_status: status.to_string(),
2005            field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
2006            format: None,
2007            enum_name: None,
2008            free_data_codes: Vec::new(),
2009            element_status: String::new(),
2010            context_status: String::new(),
2011            segment: None,
2012            valid_codes: codes
2013                .iter()
2014                .map(|(code, mapped)| CodeValue {
2015                    code: code.to_string(),
2016                    meaning: String::new(),
2017                    enum_name: None,
2018                    bo4e_value: Some(mapped.to_string()),
2019                    ahb_status: None,
2020                    code_status: None,
2021                })
2022                .collect(),
2023            ref_type: Bo4eRefType::Unknown,
2024        }
2025    }
2026
2027    /// Geschaeftspartner fed by two NAD variants: Z03 (address, no name) and
2028    /// Z07 (name, no address), shaped like PID 55042 (issue #104).
2029    fn multi_variant_requirements() -> PidRequirements {
2030        let z03 = ("Z03", "messlokationsadresse");
2031        let z07 = ("Z07", "kundeMsb");
2032        PidRequirements {
2033            transaktion_wiederholbar: false,
2034            pid: "55042".to_string(),
2035            beschreibung: String::new(),
2036            entities: vec![EntityRequirement {
2037                entity: "Geschaeftspartner".to_string(),
2038                ref_type: Bo4eRefType::Object {
2039                    type_name: "Geschaeftspartner".to_string(),
2040                    cardinality: Cardinality {
2041                        min: 1,
2042                        max: Some(99),
2043                    },
2044                },
2045                ahb_status: "Muss".to_string(),
2046                // Entity-level union, as built from the merged schema groups.
2047                fields: vec![
2048                    field("adresse.ort", "X", &[]),
2049                    field("name1", "X", &[]),
2050                    field("partnerrolle", "X", &[z03, z07]),
2051                ],
2052                map_key: None,
2053                scope: EntityScope::Transaction,
2054                variants: vec![
2055                    EntityVariantRequirement {
2056                        discriminator_field: "partnerrolle".to_string(),
2057                        code: "Z03".to_string(),
2058                        bo4e_value: Some("messlokationsadresse".to_string()),
2059                        source_paths: vec!["sg4.sg12_z03".to_string()],
2060                        fields: vec![
2061                            field("adresse.ort", "X", &[]),
2062                            field("partnerrolle", "X", &[z03]),
2063                        ],
2064                        ahb_status: String::new(),
2065                    },
2066                    EntityVariantRequirement {
2067                        discriminator_field: "partnerrolle".to_string(),
2068                        code: "Z07".to_string(),
2069                        bo4e_value: Some("kundeMsb".to_string()),
2070                        source_paths: vec!["sg4.sg12_z07".to_string()],
2071                        fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
2072                        ahb_status: String::new(),
2073                    },
2074                ],
2075                groups: vec![],
2076                nested: Vec::new(),
2077            }],
2078        }
2079    }
2080
2081    /// `Marktlokation` fed by LOC+Z16 and LOC+Z22, as in FV2604 UTILMD_Strom.
2082    fn multi_group_requirements(z22_status: &str) -> PidRequirements {
2083        let group = |path: &str, status: &str, fields: &[&str]| EntityGroupRequirement {
2084            source_path: path.to_string(),
2085            ahb_status: status.to_string(),
2086            fields: fields.iter().map(|f| f.to_string()).collect(),
2087        };
2088        PidRequirements {
2089            transaktion_wiederholbar: false,
2090            pid: "55043".to_string(),
2091            beschreibung: String::new(),
2092            entities: vec![EntityRequirement {
2093                entity: "Marktlokation".to_string(),
2094                ref_type: Bo4eRefType::Object {
2095                    type_name: "Marktlokation".to_string(),
2096                    cardinality: Cardinality::REQUIRED,
2097                },
2098                ahb_status: "Muss".to_string(),
2099                fields: vec![
2100                    field("marktlokationsId", "X", &[]),
2101                    field("ruhendeMarktlokationsId", "X", &[]),
2102                    field("ruhendeMarktlokationZeitraumId", "Kann", &[]),
2103                ],
2104                map_key: None,
2105                scope: EntityScope::Transaction,
2106                variants: vec![],
2107                groups: vec![
2108                    group("sg4.sg5_z16", "Muss", &["marktlokationsId"]),
2109                    group(
2110                        "sg4.sg5_z22",
2111                        z22_status,
2112                        &["ruhendeMarktlokationsId", "ruhendeMarktlokationZeitraumId"],
2113                    ),
2114                ],
2115                nested: Vec::new(),
2116            }],
2117        }
2118    }
2119
2120    fn missing_fields(errors: &[PidValidationError]) -> Vec<&str> {
2121        errors
2122            .iter()
2123            .filter_map(|e| match e {
2124                PidValidationError::MissingField { field, .. } => Some(field.as_str()),
2125                _ => None,
2126            })
2127            .collect()
2128    }
2129
2130    #[test]
2131    fn an_absent_optional_groups_fields_are_not_demanded() {
2132        let reqs = multi_group_requirements("Soll [2003]");
2133        let errors = validate_pid_json(&json!({ "marktlokation": {} }), &reqs);
2134        // The Z16 group is required, so its fields are; Z22's are not.
2135        assert_eq!(missing_fields(&errors), vec!["marktlokationsId"]);
2136    }
2137
2138    #[test]
2139    fn a_filled_groups_fields_are_demanded() {
2140        let reqs = multi_group_requirements("Soll [2003]");
2141        let json = json!({ "marktlokation": {
2142            "marktlokationsId": "51238696781",
2143            "ruhendeMarktlokationZeitraumId": "1"
2144        }});
2145        let errors = validate_pid_json(&json, &reqs);
2146        assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
2147    }
2148
2149    #[test]
2150    fn an_absent_required_groups_fields_are_demanded() {
2151        let reqs = multi_group_requirements("Muss");
2152        let json = json!({ "marktlokation": { "marktlokationsId": "51238696781" } });
2153        let errors = validate_pid_json(&json, &reqs);
2154        assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
2155    }
2156
2157    #[test]
2158    fn multi_variant_entity_uses_the_elements_own_variant() {
2159        let reqs = multi_variant_requirements();
2160        // Every spelling of the qualifier: raw code / enum_map name, string / object.
2161        let json = json!({
2162            "geschaeftspartner": [
2163                { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
2164                { "partnerrolle": "kundeMsb", "name1": "Muster" },
2165                { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
2166                  "adresse": { "ort": "Köln" } },
2167                { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
2168            ]
2169        });
2170        let errors = validate_pid_json(&json, &reqs);
2171        assert!(errors.is_empty(), "{}", ValidationReport(errors));
2172    }
2173
2174    #[test]
2175    fn multi_variant_entity_reports_variant_required_fields() {
2176        let reqs = multi_variant_requirements();
2177        let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
2178        let errors = validate_pid_json(&json, &reqs);
2179        assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
2180        assert!(matches!(
2181            &errors[0],
2182            PidValidationError::MissingField { field, .. } if field == "name1"
2183        ));
2184    }
2185
2186    #[test]
2187    fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
2188        let reqs = multi_variant_requirements();
2189        for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
2190            let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
2191            let errors = validate_pid_json(&json, &reqs);
2192            // No variant matches: variant fields become optional, codes are unioned.
2193            assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
2194            match &errors[0] {
2195                PidValidationError::InvalidCode {
2196                    field,
2197                    valid_values,
2198                    ..
2199                } => {
2200                    assert_eq!(field, "partnerrolle");
2201                    let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
2202                    assert_eq!(codes, ["Z03", "Z07"]);
2203                }
2204                other => panic!("expected InvalidCode, got {other:?}"),
2205            }
2206        }
2207    }
2208
2209    #[test]
2210    fn code_objects_and_enum_mapped_names_are_code_checked() {
2211        let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
2212        assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
2213        assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
2214        assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
2215        assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
2216        assert_eq!(
2217            invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
2218            Some("Z99".to_string())
2219        );
2220        assert_eq!(
2221            invalid_code_value(&json!("kundeLf"), &f),
2222            Some("kundeLf".to_string())
2223        );
2224        // Non-code values (numbers, objects without code) are not code-checked.
2225        assert_eq!(invalid_code_value(&json!(7), &f), None);
2226        assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
2227    }
2228}