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