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