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