1use std::borrow::Cow;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt;
6
7use serde_json::Value;
8
9use crate::pid_requirements::{
10 CodeValue, EntityGroupRequirement, EntityRequirement, EntityScope, EntityVariantRequirement,
11 FieldRequirement, KindSelector, NestedElementKind, NestedListRequirement, PidRequirements,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Severity {
17 Error,
19 Warning,
21}
22
23#[derive(Debug, Clone)]
25pub enum PidValidationError {
26 MissingEntity {
28 entity: String,
29 ahb_status: String,
30 severity: Severity,
31 },
32 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 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
133pub struct ValidationReport(pub Vec<PidValidationError>);
135
136impl ValidationReport {
137 pub fn has_errors(&self) -> bool {
139 self.0.iter().any(|e| e.is_error())
140 }
141
142 pub fn errors(&self) -> Vec<&PidValidationError> {
144 self.0.iter().filter(|e| e.is_error()).collect()
145 }
146
147 pub fn is_empty(&self) -> bool {
149 self.0.is_empty()
150 }
151
152 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
170pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
182 validate_entities(json, &requirements.entities, None)
183}
184
185pub 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
197fn 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 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 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
248pub fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
251 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
272fn 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 let val = get_nested(entity_json, &field_req.bo4e_name);
285
286 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 validate_nested_lists(
309 &entity_req.entity,
310 entity_json,
311 &entity_req.nested,
312 "",
313 &|status| is_unconditionally_required(status).then_some(Severity::Error),
314 errors,
315 );
316}
317
318pub fn validate_nested_lists(
325 entity: &str,
326 element: &Value,
327 lists: &[NestedListRequirement],
328 prefix: &str,
329 required: &dyn Fn(&str) -> Option<Severity>,
330 errors: &mut Vec<PidValidationError>,
331) {
332 let mut fields: Vec<&str> = lists.iter().map(|l| l.field.as_str()).collect();
335 fields.sort_unstable();
336 fields.dedup();
337 for field in fields {
338 let candidates: Vec<&NestedListRequirement> = lists
341 .iter()
342 .filter(|l| l.field == field)
343 .filter(|l| {
344 l.parent_selector
345 .iter()
346 .all(|selector| selector_matches(selector, element))
347 })
348 .collect();
349 if candidates.is_empty() {
350 continue;
351 }
352 let items: &[Value] = get_nested(element, field)
353 .and_then(|v| v.as_array())
354 .map_or(&[], |a| a.as_slice());
355 let label = format!("{prefix}{field}");
356 let alone = candidates.len() == 1;
357
358 let chosen = match &candidates[..] {
362 [only] => Some(*only),
363 _ if items.is_empty() => None,
364 _ => {
365 let fitting: Vec<&&NestedListRequirement> = candidates
366 .iter()
367 .filter(|l| items.iter().all(|item| kind_of(l, item, false).is_some()))
368 .collect();
369 match fitting[..] {
370 [one] => Some(*one),
371 _ => None,
372 }
373 }
374 };
375
376 if let Some(list) = chosen {
377 for kind in &list.kinds {
378 let present = if alone && list.kinds.len() == 1 {
379 !items.is_empty()
380 } else {
381 items.iter().any(|item| kind_matches(kind, item))
382 };
383 if present {
384 continue;
385 }
386 if let Some(severity) = required(&kind.ahb_status) {
387 let codes = kind.selector.first().map_or(&[][..], |s| &s.codes[..]);
388 let name = if alone && list.kinds.len() == 1 {
391 String::new()
392 } else {
393 codes
394 .first()
395 .map(|c| c.bo4e_value.clone().unwrap_or_else(|| c.code.clone()))
396 .unwrap_or_default()
397 };
398 errors.push(PidValidationError::MissingField {
399 entity: entity.to_string(),
400 field: format!("{label}[{name}]"),
401 ahb_status: kind.ahb_status.clone(),
402 rust_type: None,
403 valid_values: code_values_to_tuples(codes),
404 severity,
405 });
406 }
407 }
408 }
409
410 for item in items {
411 let matching: Vec<(&NestedListRequirement, &NestedElementKind)> = match chosen {
412 Some(list) => kind_of(list, item, alone)
413 .map(|k| (list, k))
414 .into_iter()
415 .collect(),
416 None => candidates
417 .iter()
418 .flat_map(|l| {
419 l.kinds
420 .iter()
421 .filter(|k| kind_matches(k, item))
422 .map(move |k| (*l, k))
423 })
424 .collect(),
425 };
426 let Some((list, findings)) = matching
430 .iter()
431 .map(|(l, k)| (*l, k, element_findings(entity, &label, k, item, required)))
432 .min_by_key(|(_, k, f)| (f.len(), k.selector.is_empty()))
433 .map(|(l, _, f)| (l, f))
434 else {
435 continue;
436 };
437 errors.extend(findings);
438 validate_nested_lists(
439 entity,
440 item,
441 &list.nested,
442 &format!("{label}[]."),
443 required,
444 errors,
445 );
446 }
447 }
448}
449
450fn kind_of<'l>(
454 list: &'l NestedListRequirement,
455 item: &Value,
456 alone: bool,
457) -> Option<&'l NestedElementKind> {
458 match &list.kinds[..] {
459 [only] if alone => Some(only),
460 kinds => kinds.iter().find(|k| kind_matches(k, item)),
461 }
462}
463
464fn kind_matches(kind: &NestedElementKind, item: &Value) -> bool {
467 kind.selector
468 .iter()
469 .all(|selector| selector_matches(selector, item))
470}
471
472fn selector_matches(selector: &KindSelector, item: &Value) -> bool {
474 get_nested(item, &selector.field)
475 .and_then(code_field_value)
476 .is_some_and(|value| {
477 selector
478 .codes
479 .iter()
480 .any(|c| c.code == value || c.bo4e_value.as_deref() == Some(value))
481 })
482}
483
484fn element_findings(
487 entity: &str,
488 label: &str,
489 kind: &NestedElementKind,
490 item: &Value,
491 required: &dyn Fn(&str) -> Option<Severity>,
492) -> Vec<PidValidationError> {
493 let mut out = Vec::new();
494 for field_req in &kind.fields {
495 let name = format!("{label}[].{}", field_req.bo4e_name);
496 let values = all_nested(item, &field_req.bo4e_name);
497 if values.is_empty() {
498 if let Some(severity) = required(&field_req.ahb_status) {
499 out.push(PidValidationError::MissingField {
500 entity: entity.to_string(),
501 field: name,
502 ahb_status: field_req.ahb_status.clone(),
503 rust_type: field_req.enum_name.clone(),
504 valid_values: code_values_to_tuples(&field_req.valid_codes),
505 severity,
506 });
507 }
508 continue;
509 }
510 for value in values {
511 if let Some(value) = invalid_code_value(value, field_req) {
512 out.push(PidValidationError::InvalidCode {
513 entity: entity.to_string(),
514 field: name.clone(),
515 value,
516 valid_values: code_values_to_tuples(&field_req.valid_codes),
517 });
518 }
519 }
520 }
521 out
522}
523
524fn all_nested<'a>(json: &'a Value, path: &str) -> Vec<&'a Value> {
527 if let Some((list, rest)) = path.split_once("[].") {
528 return get_nested(json, list)
529 .and_then(|v| v.as_array())
530 .map(|items| {
531 items
532 .iter()
533 .flat_map(|item| all_nested(item, rest))
534 .collect()
535 })
536 .unwrap_or_default();
537 }
538 get_nested(json, path)
539 .filter(|v| !v.is_null())
540 .into_iter()
541 .collect()
542}
543
544fn validate_code_value(
546 val: &Value,
547 entity_req: &EntityRequirement,
548 field_req: &FieldRequirement,
549 errors: &mut Vec<PidValidationError>,
550) {
551 if let Some(value) = invalid_code_value(val, field_req) {
552 errors.push(PidValidationError::InvalidCode {
553 entity: entity_req.entity.clone(),
554 field: field_req.bo4e_name.clone(),
555 value,
556 valid_values: code_values_to_tuples(&field_req.valid_codes),
557 });
558 }
559}
560
561pub fn code_field_value(val: &Value) -> Option<&str> {
564 val.as_str()
565 .or_else(|| val.get("code").and_then(|c| c.as_str()))
566}
567
568pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
575 if field_req.valid_codes.is_empty() {
576 return None;
577 }
578 let value = code_field_value(val)?;
579 let is_valid = field_req
580 .valid_codes
581 .iter()
582 .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
583 (!is_valid).then(|| value.to_string())
584}
585
586pub fn effective_field_requirements<'a>(
601 entity_req: &'a EntityRequirement,
602 element: &Value,
603) -> Cow<'a, [FieldRequirement]> {
604 if entity_req.variants.is_empty() {
605 return Cow::Borrowed(&entity_req.fields);
606 }
607
608 let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
610 for v in &entity_req.variants {
611 by_field
612 .entry(v.discriminator_field.as_str())
613 .or_default()
614 .push(v);
615 }
616 let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
617 for (field, group) in by_field {
618 let value = get_nested(element, field).and_then(code_field_value);
619 let matched: Vec<&EntityVariantRequirement> = group
620 .iter()
621 .copied()
622 .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
623 .collect();
624 candidates.extend(if matched.is_empty() { group } else { matched });
625 }
626
627 let mut owned: Vec<&str> = Vec::new();
629 let mut owned_set: BTreeSet<&str> = BTreeSet::new();
630 for v in &entity_req.variants {
631 for f in &v.fields {
632 if owned_set.insert(f.bo4e_name.as_str()) {
633 owned.push(f.bo4e_name.as_str());
634 }
635 }
636 }
637
638 let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
639 for name in owned {
640 let reqs: Vec<&FieldRequirement> = candidates
641 .iter()
642 .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
643 .collect();
644 let Some((first, rest)) = reqs.split_first() else {
645 continue; };
647 let mut field = (*first).clone();
648 let mut statuses_agree = reqs.len() == candidates.len();
649 for r in rest {
650 if r.ahb_status != field.ahb_status {
651 statuses_agree = false;
652 }
653 for cv in &r.valid_codes {
654 if !field.valid_codes.iter().any(|c| c.code == cv.code) {
655 field.valid_codes.push(cv.clone());
656 }
657 }
658 }
659 if !statuses_agree {
660 field.ahb_status = String::new();
661 }
662 combined.insert(name, field);
663 }
664
665 let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
666 for f in &entity_req.fields {
667 if owned_set.contains(f.bo4e_name.as_str()) {
668 if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
669 result.push(c);
670 }
671 } else {
672 result.push(f.clone());
673 }
674 }
675 result.extend(combined.into_values());
676 Cow::Owned(result)
677}
678
679pub fn absent_groups<'a>(
682 entity_req: &'a EntityRequirement,
683 element: &'a Value,
684) -> impl Iterator<Item = &'a EntityGroupRequirement> + 'a {
685 entity_req.groups.iter().filter(move |g| {
686 !g.fields
687 .iter()
688 .any(|f| get_nested(element, f).is_some_and(|v| !v.is_null()))
689 })
690}
691
692pub fn fields_of_unrequired_absent_groups<'a>(
701 entity_req: &'a EntityRequirement,
702 element: &'a Value,
703 mut group_required: impl FnMut(&str) -> bool,
704) -> BTreeSet<&'a str> {
705 let exempt: Vec<&EntityGroupRequirement> = absent_groups(entity_req, element)
706 .filter(|g| !group_required(&g.ahb_status))
707 .collect();
708 let mut fields = BTreeSet::new();
709 for g in &exempt {
710 for f in &g.fields {
711 let kept_elsewhere = entity_req.groups.iter().any(|other| {
714 other.fields.contains(f) && !exempt.iter().any(|e| std::ptr::eq(*e, other))
715 });
716 if !kept_elsewhere {
717 fields.insert(f.as_str());
718 }
719 }
720 }
721 fields
722}
723
724fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
726 codes
727 .iter()
728 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
729 .collect()
730}
731
732fn to_camel_case(s: &str) -> String {
738 if s.is_empty() {
739 return String::new();
740 }
741 let mut chars = s.chars();
742 let first = chars.next().unwrap();
743 let mut result = first.to_lowercase().to_string();
744 result.extend(chars);
745 result
746}
747
748fn snake_to_camel_case(s: &str) -> String {
759 let mut result = String::with_capacity(s.len());
760 let mut capitalize_next = false;
761 for ch in s.chars() {
762 if ch == '_' {
763 capitalize_next = true;
764 } else if capitalize_next {
765 result.extend(ch.to_uppercase());
766 capitalize_next = false;
767 } else {
768 result.push(ch);
769 }
770 }
771 result
772}
773
774fn is_unconditionally_required(ahb_status: &str) -> bool {
776 matches!(ahb_status, "X" | "Muss" | "Soll")
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::pid_requirements::{
783 Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
784 };
785 use serde_json::json;
786
787 fn sample_requirements() -> PidRequirements {
788 PidRequirements {
789 pid: "55001".to_string(),
790 beschreibung: "Anmeldung verb. MaLo".to_string(),
791 entities: vec![
792 EntityRequirement {
793 entity: "Prozessdaten".to_string(),
794 ref_type: Bo4eRefType::Object {
795 type_name: "Prozessdaten".to_string(),
796
797 cardinality: Cardinality::REQUIRED,
798 },
799
800 ahb_status: "Muss".to_string(),
801 map_key: None,
802 scope: EntityScope::Transaction,
803 variants: vec![],
804 groups: vec![],
805 fields: vec![
806 FieldRequirement {
807 bo4e_name: "vorgangId".to_string(),
808 ahb_status: "X".to_string(),
809 field_type: "data".to_string(),
810 format: None,
811 enum_name: None,
812 valid_codes: vec![],
813 child_group: None,
814 ref_type: Bo4eRefType::Unknown,
815 },
816 FieldRequirement {
817 bo4e_name: "transaktionsgrund".to_string(),
818 ahb_status: "X".to_string(),
819 field_type: "code".to_string(),
820 format: None,
821 enum_name: Some("Transaktionsgrund".to_string()),
822 valid_codes: vec![
823 CodeValue {
824 code: "E01".to_string(),
825 meaning: "Ein-/Auszug (Einzug)".to_string(),
826 enum_name: None,
827 bo4e_value: None,
828 },
829 CodeValue {
830 code: "E03".to_string(),
831 meaning: "Wechsel".to_string(),
832 enum_name: None,
833 bo4e_value: None,
834 },
835 ],
836 child_group: None,
837 ref_type: Bo4eRefType::Unknown,
838 },
839 ],
840 nested: Vec::new(),
841 },
842 EntityRequirement {
843 entity: "Marktlokation".to_string(),
844 ref_type: Bo4eRefType::Object {
845 type_name: "Marktlokation".to_string(),
846
847 cardinality: Cardinality::REQUIRED,
848 },
849
850 ahb_status: "Muss".to_string(),
851 map_key: None,
852 scope: EntityScope::Transaction,
853 variants: vec![],
854 groups: vec![],
855 fields: vec![
856 FieldRequirement {
857 bo4e_name: "marktlokationsId".to_string(),
858 ahb_status: "X".to_string(),
859 field_type: "data".to_string(),
860 format: None,
861 enum_name: None,
862 valid_codes: vec![],
863 child_group: None,
864 ref_type: Bo4eRefType::Unknown,
865 },
866 FieldRequirement {
867 bo4e_name: "haushaltskunde".to_string(),
868 ahb_status: "X".to_string(),
869 field_type: "code".to_string(),
870 format: None,
871 enum_name: Some("Haushaltskunde".to_string()),
872 valid_codes: vec![
873 CodeValue {
874 code: "Z15".to_string(),
875 meaning: "Ja".to_string(),
876 enum_name: None,
877 bo4e_value: None,
878 },
879 CodeValue {
880 code: "Z18".to_string(),
881 meaning: "Nein".to_string(),
882 enum_name: None,
883 bo4e_value: None,
884 },
885 ],
886 child_group: None,
887 ref_type: Bo4eRefType::Unknown,
888 },
889 ],
890 nested: Vec::new(),
891 },
892 EntityRequirement {
893 entity: "Geschaeftspartner".to_string(),
894 ref_type: Bo4eRefType::Object {
895 type_name: "Geschaeftspartner".to_string(),
896
897 cardinality: Cardinality {
898 min: 1,
899 max: Some(7),
900 },
901 },
902
903 ahb_status: "Muss".to_string(),
904 map_key: None,
905 scope: EntityScope::Transaction,
906 variants: vec![],
907 groups: vec![],
908 fields: vec![FieldRequirement {
909 bo4e_name: "identifikation".to_string(),
910 ahb_status: "X".to_string(),
911 field_type: "data".to_string(),
912 format: None,
913 enum_name: None,
914 valid_codes: vec![],
915 child_group: None,
916 ref_type: Bo4eRefType::Unknown,
917 }],
918 nested: Vec::new(),
919 },
920 ],
921 }
922 }
923
924 #[test]
925 fn test_validate_complete_json() {
926 let reqs = sample_requirements();
927 let json = json!({
928 "prozessdaten": {
929 "vorgangId": "ABC123",
930 "transaktionsgrund": "E01"
931 },
932 "marktlokation": {
933 "marktlokationsId": "51234567890",
934 "haushaltskunde": "Z15"
935 },
936 "geschaeftspartner": [
937 { "identifikation": "9900000000003" }
938 ]
939 });
940
941 let errors = validate_pid_json(&json, &reqs);
942 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
943 }
944
945 #[test]
946 fn test_validate_missing_entity() {
947 let reqs = sample_requirements();
948 let json = json!({
949 "prozessdaten": {
950 "vorgangId": "ABC123",
951 "transaktionsgrund": "E01"
952 },
953 "geschaeftspartner": [
954 { "identifikation": "9900000000003" }
955 ]
956 });
957 let errors = validate_pid_json(&json, &reqs);
960 assert_eq!(errors.len(), 1);
961 match &errors[0] {
962 PidValidationError::MissingEntity {
963 entity,
964 ahb_status,
965 severity,
966 } => {
967 assert_eq!(entity, "Marktlokation");
968 assert_eq!(ahb_status, "Muss");
969 assert_eq!(severity, &Severity::Error);
970 }
971 other => panic!("Expected MissingEntity, got: {other:?}"),
972 }
973
974 let msg = errors[0].to_string();
976 assert!(msg.contains("ERROR"));
977 assert!(msg.contains("Marktlokation"));
978 assert!(msg.contains("Muss"));
979 }
980
981 #[test]
982 fn test_validate_missing_field() {
983 let reqs = sample_requirements();
984 let json = json!({
985 "prozessdaten": {
986 "transaktionsgrund": "E01"
987 },
989 "marktlokation": {
990 "marktlokationsId": "51234567890",
991 "haushaltskunde": "Z15"
992 },
993 "geschaeftspartner": [
994 { "identifikation": "9900000000003" }
995 ]
996 });
997
998 let errors = validate_pid_json(&json, &reqs);
999 assert_eq!(errors.len(), 1);
1000 match &errors[0] {
1001 PidValidationError::MissingField {
1002 entity,
1003 field,
1004 ahb_status,
1005 severity,
1006 ..
1007 } => {
1008 assert_eq!(entity, "Prozessdaten");
1009 assert_eq!(field, "vorgangId");
1010 assert_eq!(ahb_status, "X");
1011 assert_eq!(severity, &Severity::Error);
1012 }
1013 other => panic!("Expected MissingField, got: {other:?}"),
1014 }
1015
1016 let msg = errors[0].to_string();
1017 assert!(msg.contains("ERROR"));
1018 assert!(msg.contains("Prozessdaten.vorgangId"));
1019 }
1020
1021 #[test]
1022 fn test_validate_invalid_code() {
1023 let reqs = sample_requirements();
1024 let json = json!({
1025 "prozessdaten": {
1026 "vorgangId": "ABC123",
1027 "transaktionsgrund": "E01"
1028 },
1029 "marktlokation": {
1030 "marktlokationsId": "51234567890",
1031 "haushaltskunde": "Z99" },
1033 "geschaeftspartner": [
1034 { "identifikation": "9900000000003" }
1035 ]
1036 });
1037
1038 let errors = validate_pid_json(&json, &reqs);
1039 assert_eq!(errors.len(), 1);
1040 match &errors[0] {
1041 PidValidationError::InvalidCode {
1042 entity,
1043 field,
1044 value,
1045 valid_values,
1046 } => {
1047 assert_eq!(entity, "Marktlokation");
1048 assert_eq!(field, "haushaltskunde");
1049 assert_eq!(value, "Z99");
1050 assert_eq!(valid_values.len(), 2);
1051 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
1052 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
1053 }
1054 other => panic!("Expected InvalidCode, got: {other:?}"),
1055 }
1056
1057 let msg = errors[0].to_string();
1058 assert!(msg.contains("INVALID"));
1059 assert!(msg.contains("Z99"));
1060 assert!(msg.contains("Z15"));
1061 }
1062
1063 #[test]
1064 fn test_validate_array_entity() {
1065 let reqs = sample_requirements();
1066 let json = json!({
1067 "prozessdaten": {
1068 "vorgangId": "ABC123",
1069 "transaktionsgrund": "E01"
1070 },
1071 "marktlokation": {
1072 "marktlokationsId": "51234567890",
1073 "haushaltskunde": "Z15"
1074 },
1075 "geschaeftspartner": [
1076 { "identifikation": "9900000000003" },
1077 { } ]
1079 });
1080
1081 let errors = validate_pid_json(&json, &reqs);
1082 assert_eq!(errors.len(), 1);
1083 match &errors[0] {
1084 PidValidationError::MissingField { entity, field, .. } => {
1085 assert_eq!(entity, "Geschaeftspartner");
1086 assert_eq!(field, "identifikation");
1087 }
1088 other => panic!("Expected MissingField, got: {other:?}"),
1089 }
1090 }
1091
1092 #[test]
1093 fn test_to_camel_case() {
1094 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
1095 assert_eq!(
1096 to_camel_case("RuhendeMarktlokation"),
1097 "ruhendeMarktlokation"
1098 );
1099 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
1100 assert_eq!(to_camel_case(""), "");
1101 }
1102
1103 #[test]
1104 fn test_snake_to_camel_case() {
1105 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
1106 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
1107 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
1108 assert_eq!(snake_to_camel_case(""), "");
1109 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
1110 }
1111
1112 #[test]
1115 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
1116 let reqs = PidRequirements {
1117 pid: "55077".to_string(),
1118 beschreibung: "Test camelCase fallback".to_string(),
1119 entities: vec![EntityRequirement {
1120 entity: "Zuordnung".to_string(),
1121 ref_type: Bo4eRefType::Object {
1122 type_name: "Zuordnung".to_string(),
1123
1124 cardinality: Cardinality::REQUIRED,
1125 },
1126
1127 ahb_status: "Muss".to_string(),
1128 map_key: None,
1129 scope: EntityScope::Transaction,
1130 variants: vec![],
1131 groups: vec![],
1132 fields: vec![
1133 FieldRequirement {
1134 bo4e_name: "code_codepflege".to_string(),
1136 ahb_status: "X".to_string(),
1137 field_type: "data".to_string(),
1138 format: None,
1139 enum_name: None,
1140 valid_codes: vec![],
1141 child_group: None,
1142 ref_type: Bo4eRefType::Unknown,
1143 },
1144 FieldRequirement {
1145 bo4e_name: "codeliste".to_string(),
1146 ahb_status: "X".to_string(),
1147 field_type: "data".to_string(),
1148 format: None,
1149 enum_name: None,
1150 valid_codes: vec![],
1151 child_group: None,
1152 ref_type: Bo4eRefType::Unknown,
1153 },
1154 ],
1155 nested: Vec::new(),
1156 }],
1157 };
1158
1159 let json_camel = json!({
1162 "zuordnung": {
1163 "codeCodepflege": "DE_BDEW",
1164 "codeliste": "6"
1165 }
1166 });
1167
1168 let errors = validate_pid_json(&json_camel, &reqs);
1169 assert!(
1170 errors.is_empty(),
1171 "Expected no errors when field is present under camelCase key, got: {errors:?}"
1172 );
1173
1174 let json_snake = json!({
1176 "zuordnung": {
1177 "code_codepflege": "DE_BDEW",
1178 "codeliste": "6"
1179 }
1180 });
1181
1182 let errors = validate_pid_json(&json_snake, &reqs);
1183 assert!(
1184 errors.is_empty(),
1185 "Expected no errors when field is present under snake_case key, got: {errors:?}"
1186 );
1187
1188 let json_missing = json!({
1190 "zuordnung": {
1191 "codeliste": "6"
1192 }
1193 });
1194
1195 let errors = validate_pid_json(&json_missing, &reqs);
1196 assert_eq!(errors.len(), 1);
1197 match &errors[0] {
1198 PidValidationError::MissingField { field, .. } => {
1199 assert_eq!(field, "code_codepflege");
1200 }
1201 other => panic!("Expected MissingField, got: {other:?}"),
1202 }
1203 }
1204
1205 #[test]
1206 fn test_is_unconditionally_required() {
1207 assert!(is_unconditionally_required("X"));
1208 assert!(is_unconditionally_required("Muss"));
1209 assert!(is_unconditionally_required("Soll"));
1210 assert!(!is_unconditionally_required("Kann"));
1211 assert!(!is_unconditionally_required("[1]"));
1212 assert!(!is_unconditionally_required(""));
1213 }
1214
1215 #[test]
1216 fn test_validation_report_display() {
1217 let errors = vec![
1218 PidValidationError::MissingEntity {
1219 entity: "Marktlokation".to_string(),
1220 ahb_status: "Muss".to_string(),
1221 severity: Severity::Error,
1222 },
1223 PidValidationError::MissingField {
1224 entity: "Prozessdaten".to_string(),
1225 field: "vorgangId".to_string(),
1226 ahb_status: "X".to_string(),
1227 rust_type: None,
1228 valid_values: vec![],
1229 severity: Severity::Error,
1230 },
1231 ];
1232 let report = ValidationReport(errors);
1233 assert!(report.has_errors());
1234 assert_eq!(report.len(), 2);
1235 assert!(!report.is_empty());
1236
1237 let display = report.to_string();
1238 assert!(display.contains("missing entity 'Marktlokation'"));
1239 assert!(display.contains("missing Prozessdaten.vorgangId"));
1240 }
1241
1242 #[test]
1243 fn test_missing_field_with_type_and_values_display() {
1244 let err = PidValidationError::MissingField {
1245 entity: "Marktlokation".to_string(),
1246 field: "haushaltskunde".to_string(),
1247 ahb_status: "Muss".to_string(),
1248 rust_type: Some("Haushaltskunde".to_string()),
1249 valid_values: vec![
1250 ("Z15".to_string(), "Ja".to_string()),
1251 ("Z18".to_string(), "Nein".to_string()),
1252 ],
1253 severity: Severity::Error,
1254 };
1255 let msg = err.to_string();
1256 assert!(msg.contains("type: Haushaltskunde"));
1257 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
1258 }
1259
1260 #[test]
1261 fn test_optional_fields_not_flagged() {
1262 let reqs = PidRequirements {
1263 pid: "99999".to_string(),
1264 beschreibung: "Test".to_string(),
1265 entities: vec![EntityRequirement {
1266 entity: "Test".to_string(),
1267 ref_type: Bo4eRefType::Object {
1268 type_name: "Test".to_string(),
1269
1270 cardinality: Cardinality::OPTIONAL,
1271 },
1272
1273 ahb_status: "Kann".to_string(),
1274 map_key: None,
1275 scope: EntityScope::Transaction,
1276 variants: vec![],
1277 groups: vec![],
1278 fields: vec![FieldRequirement {
1279 bo4e_name: "optionalField".to_string(),
1280 ahb_status: "Kann".to_string(),
1281 field_type: "data".to_string(),
1282 format: None,
1283 enum_name: None,
1284 valid_codes: vec![],
1285 child_group: None,
1286 ref_type: Bo4eRefType::Unknown,
1287 }],
1288 nested: Vec::new(),
1289 }],
1290 };
1291
1292 let errors = validate_pid_json(&json!({}), &reqs);
1294 assert!(errors.is_empty());
1295
1296 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
1298 assert!(errors.is_empty());
1299 }
1300
1301 #[test]
1304 fn test_nested_dot_path_fields_not_falsely_missing() {
1305 let reqs = PidRequirements {
1306 pid: "55001".to_string(),
1307 beschreibung: "Test nested paths".to_string(),
1308 entities: vec![EntityRequirement {
1309 entity: "ProduktpaketDaten".to_string(),
1310 ref_type: Bo4eRefType::Object {
1311 type_name: "ProduktpaketDaten".to_string(),
1312
1313 cardinality: Cardinality {
1314 min: 1,
1315 max: Some(99999),
1316 },
1317 },
1318
1319 ahb_status: "Muss".to_string(),
1320 map_key: None,
1321 scope: EntityScope::Transaction,
1322 variants: vec![],
1323 groups: vec![],
1324 fields: vec![
1325 FieldRequirement {
1326 bo4e_name: "produktIdentifikation.funktion".to_string(),
1327 ahb_status: "X".to_string(),
1328 field_type: "code".to_string(),
1329 format: None,
1330 enum_name: Some("Produktidentifikation".to_string()),
1331 valid_codes: vec![CodeValue {
1332 code: "5".to_string(),
1333 meaning: "Produktidentifikation".to_string(),
1334 enum_name: None,
1335 bo4e_value: None,
1336 }],
1337 child_group: None,
1338 ref_type: Bo4eRefType::Unknown,
1339 },
1340 FieldRequirement {
1341 bo4e_name: "produktMerkmal.code".to_string(),
1342 ahb_status: "X".to_string(),
1343 field_type: "code".to_string(),
1344 format: None,
1345 enum_name: None,
1346 valid_codes: vec![],
1347 child_group: None,
1348 ref_type: Bo4eRefType::Unknown,
1349 },
1350 ],
1351 nested: Vec::new(),
1352 }],
1353 };
1354
1355 let json = json!({
1357 "produktpaketDaten": [{
1358 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1359 "produktMerkmal": { "code": "ZH9" }
1360 }]
1361 });
1362
1363 let errors = validate_pid_json(&json, &reqs);
1364 assert!(
1365 errors.is_empty(),
1366 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_nested_dot_path_truly_missing() {
1372 let reqs = PidRequirements {
1373 pid: "55001".to_string(),
1374 beschreibung: "Test nested paths missing".to_string(),
1375 entities: vec![EntityRequirement {
1376 entity: "ProduktpaketDaten".to_string(),
1377 ref_type: Bo4eRefType::Object {
1378 type_name: "ProduktpaketDaten".to_string(),
1379
1380 cardinality: Cardinality {
1381 min: 1,
1382 max: Some(99999),
1383 },
1384 },
1385
1386 ahb_status: "Muss".to_string(),
1387 map_key: None,
1388 scope: EntityScope::Transaction,
1389 variants: vec![],
1390 groups: vec![],
1391 fields: vec![FieldRequirement {
1392 bo4e_name: "produktIdentifikation.funktion".to_string(),
1393 ahb_status: "X".to_string(),
1394 field_type: "data".to_string(),
1395 format: None,
1396 enum_name: None,
1397 valid_codes: vec![],
1398 child_group: None,
1399 ref_type: Bo4eRefType::Unknown,
1400 }],
1401 nested: Vec::new(),
1402 }],
1403 };
1404
1405 let json = json!({
1407 "produktpaketDaten": [{
1408 "produktIdentifikation": { "id": "123" }
1409 }]
1410 });
1411
1412 let errors = validate_pid_json(&json, &reqs);
1413 assert_eq!(errors.len(), 1, "Should report missing nested field");
1414 match &errors[0] {
1415 PidValidationError::MissingField { field, .. } => {
1416 assert_eq!(field, "produktIdentifikation.funktion");
1417 }
1418 other => panic!("Expected MissingField, got: {other:?}"),
1419 }
1420 }
1421
1422 fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
1423 FieldRequirement {
1424 bo4e_name: name.to_string(),
1425 ahb_status: status.to_string(),
1426 field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
1427 format: None,
1428 enum_name: None,
1429 valid_codes: codes
1430 .iter()
1431 .map(|(code, mapped)| CodeValue {
1432 code: code.to_string(),
1433 meaning: String::new(),
1434 enum_name: None,
1435 bo4e_value: Some(mapped.to_string()),
1436 })
1437 .collect(),
1438 child_group: None,
1439 ref_type: Bo4eRefType::Unknown,
1440 }
1441 }
1442
1443 fn multi_variant_requirements() -> PidRequirements {
1446 let z03 = ("Z03", "messlokationsadresse");
1447 let z07 = ("Z07", "kundeMsb");
1448 PidRequirements {
1449 pid: "55042".to_string(),
1450 beschreibung: String::new(),
1451 entities: vec![EntityRequirement {
1452 entity: "Geschaeftspartner".to_string(),
1453 ref_type: Bo4eRefType::Object {
1454 type_name: "Geschaeftspartner".to_string(),
1455 cardinality: Cardinality {
1456 min: 1,
1457 max: Some(99),
1458 },
1459 },
1460 ahb_status: "Muss".to_string(),
1461 fields: vec![
1463 field("adresse.ort", "X", &[]),
1464 field("name1", "X", &[]),
1465 field("partnerrolle", "X", &[z03, z07]),
1466 ],
1467 map_key: None,
1468 scope: EntityScope::Transaction,
1469 variants: vec![
1470 EntityVariantRequirement {
1471 discriminator_field: "partnerrolle".to_string(),
1472 code: "Z03".to_string(),
1473 bo4e_value: Some("messlokationsadresse".to_string()),
1474 source_paths: vec!["sg4.sg12_z03".to_string()],
1475 fields: vec![
1476 field("adresse.ort", "X", &[]),
1477 field("partnerrolle", "X", &[z03]),
1478 ],
1479 },
1480 EntityVariantRequirement {
1481 discriminator_field: "partnerrolle".to_string(),
1482 code: "Z07".to_string(),
1483 bo4e_value: Some("kundeMsb".to_string()),
1484 source_paths: vec!["sg4.sg12_z07".to_string()],
1485 fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
1486 },
1487 ],
1488 groups: vec![],
1489 nested: Vec::new(),
1490 }],
1491 }
1492 }
1493
1494 fn multi_group_requirements(z22_status: &str) -> PidRequirements {
1496 let group = |path: &str, status: &str, fields: &[&str]| EntityGroupRequirement {
1497 source_path: path.to_string(),
1498 ahb_status: status.to_string(),
1499 fields: fields.iter().map(|f| f.to_string()).collect(),
1500 };
1501 PidRequirements {
1502 pid: "55043".to_string(),
1503 beschreibung: String::new(),
1504 entities: vec![EntityRequirement {
1505 entity: "Marktlokation".to_string(),
1506 ref_type: Bo4eRefType::Object {
1507 type_name: "Marktlokation".to_string(),
1508 cardinality: Cardinality::REQUIRED,
1509 },
1510 ahb_status: "Muss".to_string(),
1511 fields: vec![
1512 field("marktlokationsId", "X", &[]),
1513 field("ruhendeMarktlokationsId", "X", &[]),
1514 field("ruhendeMarktlokationZeitraumId", "Kann", &[]),
1515 ],
1516 map_key: None,
1517 scope: EntityScope::Transaction,
1518 variants: vec![],
1519 groups: vec![
1520 group("sg4.sg5_z16", "Muss", &["marktlokationsId"]),
1521 group(
1522 "sg4.sg5_z22",
1523 z22_status,
1524 &["ruhendeMarktlokationsId", "ruhendeMarktlokationZeitraumId"],
1525 ),
1526 ],
1527 nested: Vec::new(),
1528 }],
1529 }
1530 }
1531
1532 fn missing_fields(errors: &[PidValidationError]) -> Vec<&str> {
1533 errors
1534 .iter()
1535 .filter_map(|e| match e {
1536 PidValidationError::MissingField { field, .. } => Some(field.as_str()),
1537 _ => None,
1538 })
1539 .collect()
1540 }
1541
1542 #[test]
1543 fn an_absent_optional_groups_fields_are_not_demanded() {
1544 let reqs = multi_group_requirements("Soll [2003]");
1545 let errors = validate_pid_json(&json!({ "marktlokation": {} }), &reqs);
1546 assert_eq!(missing_fields(&errors), vec!["marktlokationsId"]);
1548 }
1549
1550 #[test]
1551 fn a_filled_groups_fields_are_demanded() {
1552 let reqs = multi_group_requirements("Soll [2003]");
1553 let json = json!({ "marktlokation": {
1554 "marktlokationsId": "51238696781",
1555 "ruhendeMarktlokationZeitraumId": "1"
1556 }});
1557 let errors = validate_pid_json(&json, &reqs);
1558 assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
1559 }
1560
1561 #[test]
1562 fn an_absent_required_groups_fields_are_demanded() {
1563 let reqs = multi_group_requirements("Muss");
1564 let json = json!({ "marktlokation": { "marktlokationsId": "51238696781" } });
1565 let errors = validate_pid_json(&json, &reqs);
1566 assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
1567 }
1568
1569 #[test]
1570 fn multi_variant_entity_uses_the_elements_own_variant() {
1571 let reqs = multi_variant_requirements();
1572 let json = json!({
1574 "geschaeftspartner": [
1575 { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
1576 { "partnerrolle": "kundeMsb", "name1": "Muster" },
1577 { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
1578 "adresse": { "ort": "Köln" } },
1579 { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
1580 ]
1581 });
1582 let errors = validate_pid_json(&json, &reqs);
1583 assert!(errors.is_empty(), "{}", ValidationReport(errors));
1584 }
1585
1586 #[test]
1587 fn multi_variant_entity_reports_variant_required_fields() {
1588 let reqs = multi_variant_requirements();
1589 let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
1590 let errors = validate_pid_json(&json, &reqs);
1591 assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
1592 assert!(matches!(
1593 &errors[0],
1594 PidValidationError::MissingField { field, .. } if field == "name1"
1595 ));
1596 }
1597
1598 #[test]
1599 fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
1600 let reqs = multi_variant_requirements();
1601 for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
1602 let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
1603 let errors = validate_pid_json(&json, &reqs);
1604 assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
1606 match &errors[0] {
1607 PidValidationError::InvalidCode {
1608 field,
1609 valid_values,
1610 ..
1611 } => {
1612 assert_eq!(field, "partnerrolle");
1613 let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
1614 assert_eq!(codes, ["Z03", "Z07"]);
1615 }
1616 other => panic!("expected InvalidCode, got {other:?}"),
1617 }
1618 }
1619 }
1620
1621 #[test]
1622 fn code_objects_and_enum_mapped_names_are_code_checked() {
1623 let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
1624 assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
1625 assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
1626 assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
1627 assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
1628 assert_eq!(
1629 invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
1630 Some("Z99".to_string())
1631 );
1632 assert_eq!(
1633 invalid_code_value(&json!("kundeLf"), &f),
1634 Some("kundeLf".to_string())
1635 );
1636 assert_eq!(invalid_code_value(&json!(7), &f), None);
1638 assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
1639 }
1640}