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