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    // Free data that is one of the codes another source of the field allows,
733    // in either spelling: a code is never too long, and its BO4E name is not
734    // what is sent.
735    if field_req
736        .free_data_codes
737        .iter()
738        .any(|c| c.code == text || c.bo4e_value.as_deref() == Some(text))
739    {
740        return None;
741    }
742    let digits = format
743        .trim()
744        .strip_prefix("an")
745        .or_else(|| format.trim().strip_prefix('a'))
746        .or_else(|| format.trim().strip_prefix('n'))?;
747    let max: usize = digits.strip_prefix("..").unwrap_or(digits).parse().ok()?;
748    // In a numeric the decimal mark and a sign are not part of an `n..15`
749    // value's length; everything else counts.
750    let length = if format.trim().starts_with('n') {
751        text.chars()
752            .filter(|c| !matches!(c, '.' | ',' | '-'))
753            .count()
754    } else {
755        text.chars().count()
756    };
757    (length > max).then(|| PidValidationError::TooLong {
758        entity: entity.to_string(),
759        field: field.to_string(),
760        length,
761        format: format.to_string(),
762    })
763}
764
765/// Validate that a code field's value is in the allowed set.
766fn validate_code_value(
767    val: &Value,
768    entity_req: &EntityRequirement,
769    field_req: &FieldRequirement,
770    errors: &mut Vec<PidValidationError>,
771) {
772    if let Some(value) = invalid_code_value(val, field_req) {
773        errors.push(PidValidationError::InvalidCode {
774            entity: entity_req.entity.clone(),
775            field: field_req.bo4e_name.clone(),
776            value,
777            valid_values: code_values_to_tuples(&field_req.valid_codes),
778        });
779    }
780    errors.extend(too_long(
781        &entity_req.entity,
782        &field_req.bo4e_name,
783        val,
784        field_req,
785    ));
786}
787
788/// The code carried by a BO4E code field: a plain string, or the `code` member of
789/// a `{code, meaning, enum}` object as written by `from_edifact`.
790pub fn code_field_value(val: &Value) -> Option<&str> {
791    val.as_str()
792        .or_else(|| val.get("code").and_then(|c| c.as_str()))
793}
794
795/// Check a code field's value against `field_req.valid_codes`.
796///
797/// Returns the offending value if it is invalid, `None` if it is valid or not a
798/// code value at all. A value is valid when it equals a valid raw EDIFACT code
799/// or the BO4E value the mapping's `enum_map` translates a valid code to — the
800/// two spellings `to_edifact` accepts.
801pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
802    if field_req.valid_codes.is_empty() {
803        return None;
804    }
805    let value = code_field_value(val)?;
806    let is_valid = field_req
807        .valid_codes
808        .iter()
809        .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
810    (!is_valid).then(|| value.to_string())
811}
812
813/// The group statuses of the variants `element` belongs to: those whose
814/// discriminator field carries their code. Empty when the entity has no
815/// variants, none records its status, or the element names none.
816pub fn variant_statuses<'a>(entity_req: &'a EntityRequirement, element: &Value) -> Vec<&'a str> {
817    let mut out: Vec<&'a str> = Vec::new();
818    for v in &entity_req.variants {
819        if v.ahb_status.is_empty() {
820            continue;
821        }
822        let value = get_nested(element, &v.discriminator_field).and_then(code_field_value);
823        if value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s))
824            && !out.contains(&v.ahb_status.as_str())
825        {
826            out.push(v.ahb_status.as_str());
827        }
828    }
829    out
830}
831
832/// The field requirements that apply to one element of an entity.
833///
834/// For an entity without [`EntityRequirement::variants`] this is simply its
835/// `fields`. For a multi-variant entity (e.g. `Geschaeftspartner` fed by
836/// `sg12_z03`/`sg12_z07`/…), the element's discriminator value (raw code or
837/// `enum_map`ped BO4E value, string or code object) selects the matching
838/// variant, and the variant-owned fields are replaced by that variant's
839/// requirements — fields the variant's group lacks do not apply.
840///
841/// If the discriminator is missing or matches no variant, all variants are
842/// candidates and are combined leniently: codes are unioned, and a field keeps
843/// its AHB status only if every candidate agrees on it (otherwise it is treated
844/// as optional), so an unidentifiable element is never held to the required
845/// fields of a variant it may not be.
846pub fn effective_field_requirements<'a>(
847    entity_req: &'a EntityRequirement,
848    element: &Value,
849) -> Cow<'a, [FieldRequirement]> {
850    if entity_req.variants.is_empty() {
851        return Cow::Borrowed(&entity_req.fields);
852    }
853
854    // Candidate variants, per discriminator field.
855    let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
856    for v in &entity_req.variants {
857        by_field
858            .entry(v.discriminator_field.as_str())
859            .or_default()
860            .push(v);
861    }
862    let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
863    for (field, group) in by_field {
864        let value = get_nested(element, field).and_then(code_field_value);
865        let matched: Vec<&EntityVariantRequirement> = group
866            .iter()
867            .copied()
868            .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
869            .collect();
870        candidates.extend(if matched.is_empty() { group } else { matched });
871    }
872
873    // Fields governed by variants (in first-seen order).
874    let mut owned: Vec<&str> = Vec::new();
875    let mut owned_set: BTreeSet<&str> = BTreeSet::new();
876    for v in &entity_req.variants {
877        for f in &v.fields {
878            if owned_set.insert(f.bo4e_name.as_str()) {
879                owned.push(f.bo4e_name.as_str());
880            }
881        }
882    }
883
884    let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
885    for name in owned {
886        let reqs: Vec<&FieldRequirement> = candidates
887            .iter()
888            .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
889            .collect();
890        let Some((first, rest)) = reqs.split_first() else {
891            continue; // not applicable to any candidate variant
892        };
893        let mut field = (*first).clone();
894        let mut statuses_agree = reqs.len() == candidates.len();
895        for r in rest {
896            if r.ahb_status != field.ahb_status {
897                statuses_agree = false;
898            }
899            for cv in &r.valid_codes {
900                if !field.valid_codes.iter().any(|c| c.code == cv.code) {
901                    field.valid_codes.push(cv.clone());
902                }
903            }
904        }
905        if !statuses_agree {
906            field.ahb_status = String::new();
907        }
908        combined.insert(name, field);
909    }
910
911    let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
912    for f in &entity_req.fields {
913        if owned_set.contains(f.bo4e_name.as_str()) {
914            if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
915                result.push(c);
916            }
917        } else {
918            result.push(f.clone());
919        }
920    }
921    result.extend(combined.into_values());
922    Cow::Owned(result)
923}
924
925/// The groups of a multi-group entity (see [`EntityRequirement::groups`]) that
926/// `element` carries no data for.
927pub fn absent_groups<'a>(
928    entity_req: &'a EntityRequirement,
929    element: &'a Value,
930) -> impl Iterator<Item = &'a EntityGroupRequirement> + 'a {
931    entity_req.groups.iter().filter(move |g| {
932        !g.fields
933            .iter()
934            .any(|f| get_nested(element, f).is_some_and(|v| !v.is_null()))
935    })
936}
937
938/// The fields `element` need not carry because every group writing them is
939/// absent from it and not required.
940///
941/// `group_required` decides from a group's AHB status. A present entity of a
942/// multi-group entity says nothing about any one of its groups: 55043's
943/// `Marktlokation` with only LOC+Z16 data has no ruhende Marktlokation, so the
944/// LOC+Z22 group's `X` fields do not apply — unless that group is itself
945/// required, in which case they are reported as usual.
946pub fn fields_of_unrequired_absent_groups<'a>(
947    entity_req: &'a EntityRequirement,
948    element: &'a Value,
949    mut group_required: impl FnMut(&str) -> bool,
950) -> BTreeSet<&'a str> {
951    let exempt: Vec<&EntityGroupRequirement> = absent_groups(entity_req, element)
952        .filter(|g| !group_required(&g.ahb_status))
953        .collect();
954    let mut fields = BTreeSet::new();
955    for g in &exempt {
956        for f in &g.fields {
957            // A field another group also writes stays, unless that group is
958            // exempt as well.
959            let kept_elsewhere = entity_req.groups.iter().any(|other| {
960                other.fields.contains(f) && !exempt.iter().any(|e| std::ptr::eq(*e, other))
961            });
962            if !kept_elsewhere {
963                fields.insert(f.as_str());
964            }
965        }
966    }
967    fields
968}
969
970/// Convert CodeValue vec to (code, meaning) tuples.
971fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
972    codes
973        .iter()
974        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
975        .collect()
976}
977
978/// Convert PascalCase entity name to camelCase JSON key.
979///
980/// "Prozessdaten" → "prozessdaten"
981/// "RuhendeMarktlokation" → "ruhendeMarktlokation"
982/// "Marktlokation" → "marktlokation"
983fn to_camel_case(s: &str) -> String {
984    if s.is_empty() {
985        return String::new();
986    }
987    let mut chars = s.chars();
988    let first = chars.next().unwrap();
989    let mut result = first.to_lowercase().to_string();
990    result.extend(chars);
991    result
992}
993
994/// Convert a snake_case field name to camelCase.
995///
996/// This mirrors what `#[serde(rename_all = "camelCase")]` does at runtime, allowing
997/// the validator to find fields in JSON that was produced by typed structs even when
998/// the requirement stores the field name as snake_case (as it comes from TOML).
999///
1000/// Examples:
1001/// - `"code_codepflege"` → `"codeCodepflege"`
1002/// - `"vorgang_id"` → `"vorgangId"`
1003/// - `"marktlokation"` → `"marktlokation"` (unchanged — no underscores)
1004fn snake_to_camel_case(s: &str) -> String {
1005    let mut result = String::with_capacity(s.len());
1006    let mut capitalize_next = false;
1007    for ch in s.chars() {
1008        if ch == '_' {
1009            capitalize_next = true;
1010        } else if capitalize_next {
1011            result.extend(ch.to_uppercase());
1012            capitalize_next = false;
1013        } else {
1014            result.push(ch);
1015        }
1016    }
1017    result
1018}
1019
1020/// Returns true if the AHB status indicates an unconditionally required field.
1021fn is_unconditionally_required(ahb_status: &str) -> bool {
1022    matches!(ahb_status, "X" | "Muss" | "Soll")
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027
1028    /// A field that is free data in one SG10 kind and a Merkmal code in its
1029    /// merged sibling (55043's `zuordnungen[].merkmal.code`, `an..3`): a
1030    /// translated code name is not the value sent, a 12-character Zählzeit
1031    /// code is too long.
1032    #[test]
1033    fn too_long_skips_codes_of_a_free_data_field() {
1034        let field = FieldRequirement {
1035            bo4e_name: "merkmal.code".to_string(),
1036            ahb_status: "X".to_string(),
1037            field_type: "data".to_string(),
1038            format: Some("an..3".to_string()),
1039            enum_name: None,
1040            free_data_codes: vec![CodeValue {
1041                code: "ZA9".to_string(),
1042                meaning: "Übertragungsnetzbetreiber".to_string(),
1043                enum_name: None,
1044                bo4e_value: Some("uebertragungsnetzbetreiber".to_string()),
1045                ahb_status: None,
1046            }],
1047            valid_codes: vec![],
1048            ref_type: crate::pid_requirements::Bo4eRefType::Unknown,
1049        };
1050        let check = |v: &str| too_long("E", "zuordnungen[].merkmal.code", &json!(v), &field);
1051        assert!(check("uebertragungsnetzbetreiber").is_none());
1052        assert!(check("ZA9").is_none());
1053        assert!(check("Z1").is_none());
1054        assert!(matches!(
1055            check("ZDNBNAPG56ZM"),
1056            Some(PidValidationError::TooLong { length: 12, .. })
1057        ));
1058    }
1059    use super::*;
1060
1061    /// A list kind bound to a transaction variant (55070: SG10s only in the
1062    /// IDE+24 variant) is demanded where that variant holds, and neither where
1063    /// another holds nor where it cannot be told.
1064    #[test]
1065    fn a_variant_bound_kind_is_demanded_only_in_its_variant() {
1066        use crate::pid_requirements::{NestedElementKind, NestedListRequirement, VariantQualifier};
1067        let lists = vec![NestedListRequirement {
1068            field: "zuordnungen".to_string(),
1069            bo4e_type: "Zuordnung".to_string(),
1070            source_path: "sg4.sg8_z22.sg10".to_string(),
1071            kinds: vec![NestedElementKind {
1072                ahb_status: "Muss".to_string(),
1073                selector: vec![],
1074                fields: vec![],
1075                transaction_variant: Some(VariantQualifier {
1076                    group: "SG4".to_string(),
1077                    segment: "IDE".to_string(),
1078                    codes: vec!["24".to_string()],
1079                }),
1080            }],
1081            parent_selector: vec![],
1082            nested: vec![],
1083        }];
1084        let demanded = |holds: Option<bool>| {
1085            let mut errors = Vec::new();
1086            validate_nested_lists(
1087                "SummenzeitreihenDaten",
1088                &serde_json::json!({}),
1089                &lists,
1090                "",
1091                &|_, _| Some(Severity::Error),
1092                &|_| holds,
1093                &|_, _| false,
1094                &mut errors,
1095            );
1096            errors.len()
1097        };
1098        assert_eq!(demanded(Some(true)), 1);
1099        assert_eq!(demanded(Some(false)), 0);
1100        assert_eq!(demanded(None), 0);
1101    }
1102    use crate::pid_requirements::{
1103        Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
1104    };
1105    use serde_json::json;
1106
1107    fn sample_requirements() -> PidRequirements {
1108        PidRequirements {
1109            transaktion_wiederholbar: false,
1110            pid: "55001".to_string(),
1111            beschreibung: "Anmeldung verb. MaLo".to_string(),
1112            entities: vec![
1113                EntityRequirement {
1114                    entity: "Prozessdaten".to_string(),
1115                    ref_type: Bo4eRefType::Object {
1116                        type_name: "Prozessdaten".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: "vorgangId".to_string(),
1129                            ahb_status: "X".to_string(),
1130                            field_type: "data".to_string(),
1131                            format: None,
1132                            enum_name: None,
1133                            free_data_codes: Vec::new(),
1134                            valid_codes: vec![],
1135                            ref_type: Bo4eRefType::Unknown,
1136                        },
1137                        FieldRequirement {
1138                            bo4e_name: "transaktionsgrund".to_string(),
1139                            ahb_status: "X".to_string(),
1140                            field_type: "code".to_string(),
1141                            format: None,
1142                            enum_name: Some("Transaktionsgrund".to_string()),
1143                            free_data_codes: Vec::new(),
1144                            valid_codes: vec![
1145                                CodeValue {
1146                                    code: "E01".to_string(),
1147                                    meaning: "Ein-/Auszug (Einzug)".to_string(),
1148                                    enum_name: None,
1149                                    bo4e_value: None,
1150                                    ahb_status: None,
1151                                },
1152                                CodeValue {
1153                                    code: "E03".to_string(),
1154                                    meaning: "Wechsel".to_string(),
1155                                    enum_name: None,
1156                                    bo4e_value: None,
1157                                    ahb_status: None,
1158                                },
1159                            ],
1160                            ref_type: Bo4eRefType::Unknown,
1161                        },
1162                    ],
1163                    nested: Vec::new(),
1164                },
1165                EntityRequirement {
1166                    entity: "Marktlokation".to_string(),
1167                    ref_type: Bo4eRefType::Object {
1168                        type_name: "Marktlokation".to_string(),
1169
1170                        cardinality: Cardinality::REQUIRED,
1171                    },
1172
1173                    ahb_status: "Muss".to_string(),
1174                    map_key: None,
1175                    scope: EntityScope::Transaction,
1176                    variants: vec![],
1177                    groups: vec![],
1178                    fields: vec![
1179                        FieldRequirement {
1180                            bo4e_name: "marktlokationsId".to_string(),
1181                            ahb_status: "X".to_string(),
1182                            field_type: "data".to_string(),
1183                            format: None,
1184                            enum_name: None,
1185                            free_data_codes: Vec::new(),
1186                            valid_codes: vec![],
1187                            ref_type: Bo4eRefType::Unknown,
1188                        },
1189                        FieldRequirement {
1190                            bo4e_name: "haushaltskunde".to_string(),
1191                            ahb_status: "X".to_string(),
1192                            field_type: "code".to_string(),
1193                            format: None,
1194                            enum_name: Some("Haushaltskunde".to_string()),
1195                            free_data_codes: Vec::new(),
1196                            valid_codes: vec![
1197                                CodeValue {
1198                                    code: "Z15".to_string(),
1199                                    meaning: "Ja".to_string(),
1200                                    enum_name: None,
1201                                    bo4e_value: None,
1202                                    ahb_status: None,
1203                                },
1204                                CodeValue {
1205                                    code: "Z18".to_string(),
1206                                    meaning: "Nein".to_string(),
1207                                    enum_name: None,
1208                                    bo4e_value: None,
1209                                    ahb_status: None,
1210                                },
1211                            ],
1212                            ref_type: Bo4eRefType::Unknown,
1213                        },
1214                    ],
1215                    nested: Vec::new(),
1216                },
1217                EntityRequirement {
1218                    entity: "Geschaeftspartner".to_string(),
1219                    ref_type: Bo4eRefType::Object {
1220                        type_name: "Geschaeftspartner".to_string(),
1221
1222                        cardinality: Cardinality {
1223                            min: 1,
1224                            max: Some(7),
1225                        },
1226                    },
1227
1228                    ahb_status: "Muss".to_string(),
1229                    map_key: None,
1230                    scope: EntityScope::Transaction,
1231                    variants: vec![],
1232                    groups: vec![],
1233                    fields: vec![FieldRequirement {
1234                        bo4e_name: "identifikation".to_string(),
1235                        ahb_status: "X".to_string(),
1236                        field_type: "data".to_string(),
1237                        format: None,
1238                        enum_name: None,
1239                        free_data_codes: Vec::new(),
1240                        valid_codes: vec![],
1241                        ref_type: Bo4eRefType::Unknown,
1242                    }],
1243                    nested: Vec::new(),
1244                },
1245            ],
1246        }
1247    }
1248
1249    #[test]
1250    fn test_validate_complete_json() {
1251        let reqs = sample_requirements();
1252        let json = json!({
1253            "prozessdaten": {
1254                "vorgangId": "ABC123",
1255                "transaktionsgrund": "E01"
1256            },
1257            "marktlokation": {
1258                "marktlokationsId": "51234567890",
1259                "haushaltskunde": "Z15"
1260            },
1261            "geschaeftspartner": [
1262                { "identifikation": "9900000000003" }
1263            ]
1264        });
1265
1266        let errors = validate_pid_json(&json, &reqs);
1267        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
1268    }
1269
1270    #[test]
1271    fn test_validate_missing_entity() {
1272        let reqs = sample_requirements();
1273        let json = json!({
1274            "prozessdaten": {
1275                "vorgangId": "ABC123",
1276                "transaktionsgrund": "E01"
1277            },
1278            "geschaeftspartner": [
1279                { "identifikation": "9900000000003" }
1280            ]
1281        });
1282        // Marktlokation is missing
1283
1284        let errors = validate_pid_json(&json, &reqs);
1285        assert_eq!(errors.len(), 1);
1286        match &errors[0] {
1287            PidValidationError::MissingEntity {
1288                entity,
1289                ahb_status,
1290                severity,
1291            } => {
1292                assert_eq!(entity, "Marktlokation");
1293                assert_eq!(ahb_status, "Muss");
1294                assert_eq!(severity, &Severity::Error);
1295            }
1296            other => panic!("Expected MissingEntity, got: {other:?}"),
1297        }
1298
1299        // Display check
1300        let msg = errors[0].to_string();
1301        assert!(msg.contains("ERROR"));
1302        assert!(msg.contains("Marktlokation"));
1303        assert!(msg.contains("Muss"));
1304    }
1305
1306    #[test]
1307    fn test_validate_missing_field() {
1308        let reqs = sample_requirements();
1309        let json = json!({
1310            "prozessdaten": {
1311                "transaktionsgrund": "E01"
1312                // vorgangId is missing
1313            },
1314            "marktlokation": {
1315                "marktlokationsId": "51234567890",
1316                "haushaltskunde": "Z15"
1317            },
1318            "geschaeftspartner": [
1319                { "identifikation": "9900000000003" }
1320            ]
1321        });
1322
1323        let errors = validate_pid_json(&json, &reqs);
1324        assert_eq!(errors.len(), 1);
1325        match &errors[0] {
1326            PidValidationError::MissingField {
1327                entity,
1328                field,
1329                ahb_status,
1330                severity,
1331                ..
1332            } => {
1333                assert_eq!(entity, "Prozessdaten");
1334                assert_eq!(field, "vorgangId");
1335                assert_eq!(ahb_status, "X");
1336                assert_eq!(severity, &Severity::Error);
1337            }
1338            other => panic!("Expected MissingField, got: {other:?}"),
1339        }
1340
1341        let msg = errors[0].to_string();
1342        assert!(msg.contains("ERROR"));
1343        assert!(msg.contains("Prozessdaten.vorgangId"));
1344    }
1345
1346    #[test]
1347    fn test_validate_invalid_code() {
1348        let reqs = sample_requirements();
1349        let json = json!({
1350            "prozessdaten": {
1351                "vorgangId": "ABC123",
1352                "transaktionsgrund": "E01"
1353            },
1354            "marktlokation": {
1355                "marktlokationsId": "51234567890",
1356                "haushaltskunde": "Z99"  // Invalid code
1357            },
1358            "geschaeftspartner": [
1359                { "identifikation": "9900000000003" }
1360            ]
1361        });
1362
1363        let errors = validate_pid_json(&json, &reqs);
1364        assert_eq!(errors.len(), 1);
1365        match &errors[0] {
1366            PidValidationError::InvalidCode {
1367                entity,
1368                field,
1369                value,
1370                valid_values,
1371            } => {
1372                assert_eq!(entity, "Marktlokation");
1373                assert_eq!(field, "haushaltskunde");
1374                assert_eq!(value, "Z99");
1375                assert_eq!(valid_values.len(), 2);
1376                assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
1377                assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
1378            }
1379            other => panic!("Expected InvalidCode, got: {other:?}"),
1380        }
1381
1382        let msg = errors[0].to_string();
1383        assert!(msg.contains("INVALID"));
1384        assert!(msg.contains("Z99"));
1385        assert!(msg.contains("Z15"));
1386    }
1387
1388    #[test]
1389    fn test_validate_array_entity() {
1390        let reqs = sample_requirements();
1391        let json = json!({
1392            "prozessdaten": {
1393                "vorgangId": "ABC123",
1394                "transaktionsgrund": "E01"
1395            },
1396            "marktlokation": {
1397                "marktlokationsId": "51234567890",
1398                "haushaltskunde": "Z15"
1399            },
1400            "geschaeftspartner": [
1401                { "identifikation": "9900000000003" },
1402                { }  // Missing identifikation in second element
1403            ]
1404        });
1405
1406        let errors = validate_pid_json(&json, &reqs);
1407        assert_eq!(errors.len(), 1);
1408        match &errors[0] {
1409            PidValidationError::MissingField { entity, field, .. } => {
1410                assert_eq!(entity, "Geschaeftspartner");
1411                assert_eq!(field, "identifikation");
1412            }
1413            other => panic!("Expected MissingField, got: {other:?}"),
1414        }
1415    }
1416
1417    #[test]
1418    fn test_to_camel_case() {
1419        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
1420        assert_eq!(
1421            to_camel_case("RuhendeMarktlokation"),
1422            "ruhendeMarktlokation"
1423        );
1424        assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
1425        assert_eq!(to_camel_case(""), "");
1426    }
1427
1428    #[test]
1429    fn test_snake_to_camel_case() {
1430        assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
1431        assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
1432        assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
1433        assert_eq!(snake_to_camel_case(""), "");
1434        assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
1435    }
1436
1437    /// A field stored as snake_case in requirements (e.g. from TOML) must be found
1438    /// in JSON that was produced by a typed struct using `#[serde(rename_all = "camelCase")]`.
1439    #[test]
1440    fn test_camel_case_fallback_for_snake_case_bo4e_name() {
1441        let reqs = PidRequirements {
1442            transaktion_wiederholbar: false,
1443            pid: "55077".to_string(),
1444            beschreibung: "Test camelCase fallback".to_string(),
1445            entities: vec![EntityRequirement {
1446                entity: "Zuordnung".to_string(),
1447                ref_type: Bo4eRefType::Object {
1448                    type_name: "Zuordnung".to_string(),
1449
1450                    cardinality: Cardinality::REQUIRED,
1451                },
1452
1453                ahb_status: "Muss".to_string(),
1454                map_key: None,
1455                scope: EntityScope::Transaction,
1456                variants: vec![],
1457                groups: vec![],
1458                fields: vec![
1459                    FieldRequirement {
1460                        // snake_case as stored in TOML requirements
1461                        bo4e_name: "code_codepflege".to_string(),
1462                        ahb_status: "X".to_string(),
1463                        field_type: "data".to_string(),
1464                        format: None,
1465                        enum_name: None,
1466                        free_data_codes: Vec::new(),
1467                        valid_codes: vec![],
1468                        ref_type: Bo4eRefType::Unknown,
1469                    },
1470                    FieldRequirement {
1471                        bo4e_name: "codeliste".to_string(),
1472                        ahb_status: "X".to_string(),
1473                        field_type: "data".to_string(),
1474                        format: None,
1475                        enum_name: None,
1476                        free_data_codes: Vec::new(),
1477                        valid_codes: vec![],
1478                        ref_type: Bo4eRefType::Unknown,
1479                    },
1480                ],
1481                nested: Vec::new(),
1482            }],
1483        };
1484
1485        // JSON produced by a typed struct with #[serde(rename_all = "camelCase")]:
1486        // code_codepflege → codeCodepflege
1487        let json_camel = json!({
1488            "zuordnung": {
1489                "codeCodepflege": "DE_BDEW",
1490                "codeliste": "6"
1491            }
1492        });
1493
1494        let errors = validate_pid_json(&json_camel, &reqs);
1495        assert!(
1496            errors.is_empty(),
1497            "Expected no errors when field is present under camelCase key, got: {errors:?}"
1498        );
1499
1500        // Also verify that snake_case key in JSON still works (backward compat).
1501        let json_snake = json!({
1502            "zuordnung": {
1503                "code_codepflege": "DE_BDEW",
1504                "codeliste": "6"
1505            }
1506        });
1507
1508        let errors = validate_pid_json(&json_snake, &reqs);
1509        assert!(
1510            errors.is_empty(),
1511            "Expected no errors when field is present under snake_case key, got: {errors:?}"
1512        );
1513
1514        // When the field is truly absent, a MissingField error must still be raised.
1515        let json_missing = json!({
1516            "zuordnung": {
1517                "codeliste": "6"
1518            }
1519        });
1520
1521        let errors = validate_pid_json(&json_missing, &reqs);
1522        assert_eq!(errors.len(), 1);
1523        match &errors[0] {
1524            PidValidationError::MissingField { field, .. } => {
1525                assert_eq!(field, "code_codepflege");
1526            }
1527            other => panic!("Expected MissingField, got: {other:?}"),
1528        }
1529    }
1530
1531    #[test]
1532    fn test_is_unconditionally_required() {
1533        assert!(is_unconditionally_required("X"));
1534        assert!(is_unconditionally_required("Muss"));
1535        assert!(is_unconditionally_required("Soll"));
1536        assert!(!is_unconditionally_required("Kann"));
1537        assert!(!is_unconditionally_required("[1]"));
1538        assert!(!is_unconditionally_required(""));
1539    }
1540
1541    #[test]
1542    fn test_validation_report_display() {
1543        let errors = vec![
1544            PidValidationError::MissingEntity {
1545                entity: "Marktlokation".to_string(),
1546                ahb_status: "Muss".to_string(),
1547                severity: Severity::Error,
1548            },
1549            PidValidationError::MissingField {
1550                entity: "Prozessdaten".to_string(),
1551                field: "vorgangId".to_string(),
1552                ahb_status: "X".to_string(),
1553                rust_type: None,
1554                valid_values: vec![],
1555                severity: Severity::Error,
1556            },
1557        ];
1558        let report = ValidationReport(errors);
1559        assert!(report.has_errors());
1560        assert_eq!(report.len(), 2);
1561        assert!(!report.is_empty());
1562
1563        let display = report.to_string();
1564        assert!(display.contains("missing entity 'Marktlokation'"));
1565        assert!(display.contains("missing Prozessdaten.vorgangId"));
1566    }
1567
1568    #[test]
1569    fn test_missing_field_with_type_and_values_display() {
1570        let err = PidValidationError::MissingField {
1571            entity: "Marktlokation".to_string(),
1572            field: "haushaltskunde".to_string(),
1573            ahb_status: "Muss".to_string(),
1574            rust_type: Some("Haushaltskunde".to_string()),
1575            valid_values: vec![
1576                ("Z15".to_string(), "Ja".to_string()),
1577                ("Z18".to_string(), "Nein".to_string()),
1578            ],
1579            severity: Severity::Error,
1580        };
1581        let msg = err.to_string();
1582        assert!(msg.contains("type: Haushaltskunde"));
1583        assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
1584    }
1585
1586    #[test]
1587    fn test_optional_fields_not_flagged() {
1588        let reqs = PidRequirements {
1589            transaktion_wiederholbar: false,
1590            pid: "99999".to_string(),
1591            beschreibung: "Test".to_string(),
1592            entities: vec![EntityRequirement {
1593                entity: "Test".to_string(),
1594                ref_type: Bo4eRefType::Object {
1595                    type_name: "Test".to_string(),
1596
1597                    cardinality: Cardinality::OPTIONAL,
1598                },
1599
1600                ahb_status: "Kann".to_string(),
1601                map_key: None,
1602                scope: EntityScope::Transaction,
1603                variants: vec![],
1604                groups: vec![],
1605                fields: vec![FieldRequirement {
1606                    bo4e_name: "optionalField".to_string(),
1607                    ahb_status: "Kann".to_string(),
1608                    field_type: "data".to_string(),
1609                    format: None,
1610                    enum_name: None,
1611                    free_data_codes: Vec::new(),
1612                    valid_codes: vec![],
1613                    ref_type: Bo4eRefType::Unknown,
1614                }],
1615                nested: Vec::new(),
1616            }],
1617        };
1618
1619        // Entity missing but optional — no error
1620        let errors = validate_pid_json(&json!({}), &reqs);
1621        assert!(errors.is_empty());
1622
1623        // Entity present, field missing but optional — no error
1624        let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
1625        assert!(errors.is_empty());
1626    }
1627
1628    /// Regression test for issue #48: nested dot-path fields reported as missing
1629    /// even when present (e.g. `produktIdentifikation.funktion`).
1630    #[test]
1631    fn test_nested_dot_path_fields_not_falsely_missing() {
1632        let reqs = PidRequirements {
1633            transaktion_wiederholbar: false,
1634            pid: "55001".to_string(),
1635            beschreibung: "Test nested paths".to_string(),
1636            entities: vec![EntityRequirement {
1637                entity: "ProduktpaketDaten".to_string(),
1638                ref_type: Bo4eRefType::Object {
1639                    type_name: "ProduktpaketDaten".to_string(),
1640
1641                    cardinality: Cardinality {
1642                        min: 1,
1643                        max: Some(99999),
1644                    },
1645                },
1646
1647                ahb_status: "Muss".to_string(),
1648                map_key: None,
1649                scope: EntityScope::Transaction,
1650                variants: vec![],
1651                groups: vec![],
1652                fields: vec![
1653                    FieldRequirement {
1654                        bo4e_name: "produktIdentifikation.funktion".to_string(),
1655                        ahb_status: "X".to_string(),
1656                        field_type: "code".to_string(),
1657                        format: None,
1658                        enum_name: Some("Produktidentifikation".to_string()),
1659                        free_data_codes: Vec::new(),
1660                        valid_codes: vec![CodeValue {
1661                            code: "5".to_string(),
1662                            meaning: "Produktidentifikation".to_string(),
1663                            enum_name: None,
1664                            bo4e_value: None,
1665                            ahb_status: None,
1666                        }],
1667                        ref_type: Bo4eRefType::Unknown,
1668                    },
1669                    FieldRequirement {
1670                        bo4e_name: "produktMerkmal.code".to_string(),
1671                        ahb_status: "X".to_string(),
1672                        field_type: "code".to_string(),
1673                        format: None,
1674                        enum_name: None,
1675                        free_data_codes: Vec::new(),
1676                        valid_codes: vec![],
1677                        ref_type: Bo4eRefType::Unknown,
1678                    },
1679                ],
1680                nested: Vec::new(),
1681            }],
1682        };
1683
1684        // Exact JSON from issue #48
1685        let json = json!({
1686            "produktpaketDaten": [{
1687                "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1688                "produktMerkmal": { "code": "ZH9" }
1689            }]
1690        });
1691
1692        let errors = validate_pid_json(&json, &reqs);
1693        assert!(
1694            errors.is_empty(),
1695            "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1696        );
1697    }
1698
1699    #[test]
1700    fn test_nested_dot_path_truly_missing() {
1701        let reqs = PidRequirements {
1702            transaktion_wiederholbar: false,
1703            pid: "55001".to_string(),
1704            beschreibung: "Test nested paths missing".to_string(),
1705            entities: vec![EntityRequirement {
1706                entity: "ProduktpaketDaten".to_string(),
1707                ref_type: Bo4eRefType::Object {
1708                    type_name: "ProduktpaketDaten".to_string(),
1709
1710                    cardinality: Cardinality {
1711                        min: 1,
1712                        max: Some(99999),
1713                    },
1714                },
1715
1716                ahb_status: "Muss".to_string(),
1717                map_key: None,
1718                scope: EntityScope::Transaction,
1719                variants: vec![],
1720                groups: vec![],
1721                fields: vec![FieldRequirement {
1722                    bo4e_name: "produktIdentifikation.funktion".to_string(),
1723                    ahb_status: "X".to_string(),
1724                    field_type: "data".to_string(),
1725                    format: None,
1726                    enum_name: None,
1727                    free_data_codes: Vec::new(),
1728                    valid_codes: vec![],
1729                    ref_type: Bo4eRefType::Unknown,
1730                }],
1731                nested: Vec::new(),
1732            }],
1733        };
1734
1735        // Parent exists but nested field is missing
1736        let json = json!({
1737            "produktpaketDaten": [{
1738                "produktIdentifikation": { "id": "123" }
1739            }]
1740        });
1741
1742        let errors = validate_pid_json(&json, &reqs);
1743        assert_eq!(errors.len(), 1, "Should report missing nested field");
1744        match &errors[0] {
1745            PidValidationError::MissingField { field, .. } => {
1746                assert_eq!(field, "produktIdentifikation.funktion");
1747            }
1748            other => panic!("Expected MissingField, got: {other:?}"),
1749        }
1750    }
1751
1752    fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
1753        FieldRequirement {
1754            bo4e_name: name.to_string(),
1755            ahb_status: status.to_string(),
1756            field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
1757            format: None,
1758            enum_name: None,
1759            free_data_codes: Vec::new(),
1760            valid_codes: codes
1761                .iter()
1762                .map(|(code, mapped)| CodeValue {
1763                    code: code.to_string(),
1764                    meaning: String::new(),
1765                    enum_name: None,
1766                    bo4e_value: Some(mapped.to_string()),
1767                    ahb_status: None,
1768                })
1769                .collect(),
1770            ref_type: Bo4eRefType::Unknown,
1771        }
1772    }
1773
1774    /// Geschaeftspartner fed by two NAD variants: Z03 (address, no name) and
1775    /// Z07 (name, no address), shaped like PID 55042 (issue #104).
1776    fn multi_variant_requirements() -> PidRequirements {
1777        let z03 = ("Z03", "messlokationsadresse");
1778        let z07 = ("Z07", "kundeMsb");
1779        PidRequirements {
1780            transaktion_wiederholbar: false,
1781            pid: "55042".to_string(),
1782            beschreibung: String::new(),
1783            entities: vec![EntityRequirement {
1784                entity: "Geschaeftspartner".to_string(),
1785                ref_type: Bo4eRefType::Object {
1786                    type_name: "Geschaeftspartner".to_string(),
1787                    cardinality: Cardinality {
1788                        min: 1,
1789                        max: Some(99),
1790                    },
1791                },
1792                ahb_status: "Muss".to_string(),
1793                // Entity-level union, as built from the merged schema groups.
1794                fields: vec![
1795                    field("adresse.ort", "X", &[]),
1796                    field("name1", "X", &[]),
1797                    field("partnerrolle", "X", &[z03, z07]),
1798                ],
1799                map_key: None,
1800                scope: EntityScope::Transaction,
1801                variants: vec![
1802                    EntityVariantRequirement {
1803                        discriminator_field: "partnerrolle".to_string(),
1804                        code: "Z03".to_string(),
1805                        bo4e_value: Some("messlokationsadresse".to_string()),
1806                        source_paths: vec!["sg4.sg12_z03".to_string()],
1807                        fields: vec![
1808                            field("adresse.ort", "X", &[]),
1809                            field("partnerrolle", "X", &[z03]),
1810                        ],
1811                        ahb_status: String::new(),
1812                    },
1813                    EntityVariantRequirement {
1814                        discriminator_field: "partnerrolle".to_string(),
1815                        code: "Z07".to_string(),
1816                        bo4e_value: Some("kundeMsb".to_string()),
1817                        source_paths: vec!["sg4.sg12_z07".to_string()],
1818                        fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
1819                        ahb_status: String::new(),
1820                    },
1821                ],
1822                groups: vec![],
1823                nested: Vec::new(),
1824            }],
1825        }
1826    }
1827
1828    /// `Marktlokation` fed by LOC+Z16 and LOC+Z22, as in FV2604 UTILMD_Strom.
1829    fn multi_group_requirements(z22_status: &str) -> PidRequirements {
1830        let group = |path: &str, status: &str, fields: &[&str]| EntityGroupRequirement {
1831            source_path: path.to_string(),
1832            ahb_status: status.to_string(),
1833            fields: fields.iter().map(|f| f.to_string()).collect(),
1834        };
1835        PidRequirements {
1836            transaktion_wiederholbar: false,
1837            pid: "55043".to_string(),
1838            beschreibung: String::new(),
1839            entities: vec![EntityRequirement {
1840                entity: "Marktlokation".to_string(),
1841                ref_type: Bo4eRefType::Object {
1842                    type_name: "Marktlokation".to_string(),
1843                    cardinality: Cardinality::REQUIRED,
1844                },
1845                ahb_status: "Muss".to_string(),
1846                fields: vec![
1847                    field("marktlokationsId", "X", &[]),
1848                    field("ruhendeMarktlokationsId", "X", &[]),
1849                    field("ruhendeMarktlokationZeitraumId", "Kann", &[]),
1850                ],
1851                map_key: None,
1852                scope: EntityScope::Transaction,
1853                variants: vec![],
1854                groups: vec![
1855                    group("sg4.sg5_z16", "Muss", &["marktlokationsId"]),
1856                    group(
1857                        "sg4.sg5_z22",
1858                        z22_status,
1859                        &["ruhendeMarktlokationsId", "ruhendeMarktlokationZeitraumId"],
1860                    ),
1861                ],
1862                nested: Vec::new(),
1863            }],
1864        }
1865    }
1866
1867    fn missing_fields(errors: &[PidValidationError]) -> Vec<&str> {
1868        errors
1869            .iter()
1870            .filter_map(|e| match e {
1871                PidValidationError::MissingField { field, .. } => Some(field.as_str()),
1872                _ => None,
1873            })
1874            .collect()
1875    }
1876
1877    #[test]
1878    fn an_absent_optional_groups_fields_are_not_demanded() {
1879        let reqs = multi_group_requirements("Soll [2003]");
1880        let errors = validate_pid_json(&json!({ "marktlokation": {} }), &reqs);
1881        // The Z16 group is required, so its fields are; Z22's are not.
1882        assert_eq!(missing_fields(&errors), vec!["marktlokationsId"]);
1883    }
1884
1885    #[test]
1886    fn a_filled_groups_fields_are_demanded() {
1887        let reqs = multi_group_requirements("Soll [2003]");
1888        let json = json!({ "marktlokation": {
1889            "marktlokationsId": "51238696781",
1890            "ruhendeMarktlokationZeitraumId": "1"
1891        }});
1892        let errors = validate_pid_json(&json, &reqs);
1893        assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
1894    }
1895
1896    #[test]
1897    fn an_absent_required_groups_fields_are_demanded() {
1898        let reqs = multi_group_requirements("Muss");
1899        let json = json!({ "marktlokation": { "marktlokationsId": "51238696781" } });
1900        let errors = validate_pid_json(&json, &reqs);
1901        assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
1902    }
1903
1904    #[test]
1905    fn multi_variant_entity_uses_the_elements_own_variant() {
1906        let reqs = multi_variant_requirements();
1907        // Every spelling of the qualifier: raw code / enum_map name, string / object.
1908        let json = json!({
1909            "geschaeftspartner": [
1910                { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
1911                { "partnerrolle": "kundeMsb", "name1": "Muster" },
1912                { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
1913                  "adresse": { "ort": "Köln" } },
1914                { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
1915            ]
1916        });
1917        let errors = validate_pid_json(&json, &reqs);
1918        assert!(errors.is_empty(), "{}", ValidationReport(errors));
1919    }
1920
1921    #[test]
1922    fn multi_variant_entity_reports_variant_required_fields() {
1923        let reqs = multi_variant_requirements();
1924        let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
1925        let errors = validate_pid_json(&json, &reqs);
1926        assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
1927        assert!(matches!(
1928            &errors[0],
1929            PidValidationError::MissingField { field, .. } if field == "name1"
1930        ));
1931    }
1932
1933    #[test]
1934    fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
1935        let reqs = multi_variant_requirements();
1936        for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
1937            let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
1938            let errors = validate_pid_json(&json, &reqs);
1939            // No variant matches: variant fields become optional, codes are unioned.
1940            assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
1941            match &errors[0] {
1942                PidValidationError::InvalidCode {
1943                    field,
1944                    valid_values,
1945                    ..
1946                } => {
1947                    assert_eq!(field, "partnerrolle");
1948                    let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
1949                    assert_eq!(codes, ["Z03", "Z07"]);
1950                }
1951                other => panic!("expected InvalidCode, got {other:?}"),
1952            }
1953        }
1954    }
1955
1956    #[test]
1957    fn code_objects_and_enum_mapped_names_are_code_checked() {
1958        let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
1959        assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
1960        assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
1961        assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
1962        assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
1963        assert_eq!(
1964            invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
1965            Some("Z99".to_string())
1966        );
1967        assert_eq!(
1968            invalid_code_value(&json!("kundeLf"), &f),
1969            Some("kundeLf".to_string())
1970        );
1971        // Non-code values (numbers, objects without code) are not code-checked.
1972        assert_eq!(invalid_code_value(&json!(7), &f), None);
1973        assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
1974    }
1975}