Skip to main content

mig_bo4e/
pid_validation.rs

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