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