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