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