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, 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}
309
310fn validate_code_value(
312 val: &Value,
313 entity_req: &EntityRequirement,
314 field_req: &FieldRequirement,
315 errors: &mut Vec<PidValidationError>,
316) {
317 if let Some(value) = invalid_code_value(val, field_req) {
318 errors.push(PidValidationError::InvalidCode {
319 entity: entity_req.entity.clone(),
320 field: field_req.bo4e_name.clone(),
321 value,
322 valid_values: code_values_to_tuples(&field_req.valid_codes),
323 });
324 }
325}
326
327pub fn code_field_value(val: &Value) -> Option<&str> {
330 val.as_str()
331 .or_else(|| val.get("code").and_then(|c| c.as_str()))
332}
333
334pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
341 if field_req.valid_codes.is_empty() {
342 return None;
343 }
344 let value = code_field_value(val)?;
345 let is_valid = field_req
346 .valid_codes
347 .iter()
348 .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
349 (!is_valid).then(|| value.to_string())
350}
351
352pub fn effective_field_requirements<'a>(
367 entity_req: &'a EntityRequirement,
368 element: &Value,
369) -> Cow<'a, [FieldRequirement]> {
370 if entity_req.variants.is_empty() {
371 return Cow::Borrowed(&entity_req.fields);
372 }
373
374 let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
376 for v in &entity_req.variants {
377 by_field
378 .entry(v.discriminator_field.as_str())
379 .or_default()
380 .push(v);
381 }
382 let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
383 for (field, group) in by_field {
384 let value = get_nested(element, field).and_then(code_field_value);
385 let matched: Vec<&EntityVariantRequirement> = group
386 .iter()
387 .copied()
388 .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
389 .collect();
390 candidates.extend(if matched.is_empty() { group } else { matched });
391 }
392
393 let mut owned: Vec<&str> = Vec::new();
395 let mut owned_set: BTreeSet<&str> = BTreeSet::new();
396 for v in &entity_req.variants {
397 for f in &v.fields {
398 if owned_set.insert(f.bo4e_name.as_str()) {
399 owned.push(f.bo4e_name.as_str());
400 }
401 }
402 }
403
404 let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
405 for name in owned {
406 let reqs: Vec<&FieldRequirement> = candidates
407 .iter()
408 .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
409 .collect();
410 let Some((first, rest)) = reqs.split_first() else {
411 continue; };
413 let mut field = (*first).clone();
414 let mut statuses_agree = reqs.len() == candidates.len();
415 for r in rest {
416 if r.ahb_status != field.ahb_status {
417 statuses_agree = false;
418 }
419 for cv in &r.valid_codes {
420 if !field.valid_codes.iter().any(|c| c.code == cv.code) {
421 field.valid_codes.push(cv.clone());
422 }
423 }
424 }
425 if !statuses_agree {
426 field.ahb_status = String::new();
427 }
428 combined.insert(name, field);
429 }
430
431 let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
432 for f in &entity_req.fields {
433 if owned_set.contains(f.bo4e_name.as_str()) {
434 if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
435 result.push(c);
436 }
437 } else {
438 result.push(f.clone());
439 }
440 }
441 result.extend(combined.into_values());
442 Cow::Owned(result)
443}
444
445pub fn absent_groups<'a>(
448 entity_req: &'a EntityRequirement,
449 element: &'a Value,
450) -> impl Iterator<Item = &'a EntityGroupRequirement> + 'a {
451 entity_req.groups.iter().filter(move |g| {
452 !g.fields
453 .iter()
454 .any(|f| get_nested(element, f).is_some_and(|v| !v.is_null()))
455 })
456}
457
458pub fn fields_of_unrequired_absent_groups<'a>(
467 entity_req: &'a EntityRequirement,
468 element: &'a Value,
469 mut group_required: impl FnMut(&str) -> bool,
470) -> BTreeSet<&'a str> {
471 let exempt: Vec<&EntityGroupRequirement> = absent_groups(entity_req, element)
472 .filter(|g| !group_required(&g.ahb_status))
473 .collect();
474 let mut fields = BTreeSet::new();
475 for g in &exempt {
476 for f in &g.fields {
477 let kept_elsewhere = entity_req.groups.iter().any(|other| {
480 other.fields.contains(f) && !exempt.iter().any(|e| std::ptr::eq(*e, other))
481 });
482 if !kept_elsewhere {
483 fields.insert(f.as_str());
484 }
485 }
486 }
487 fields
488}
489
490fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
492 codes
493 .iter()
494 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
495 .collect()
496}
497
498fn to_camel_case(s: &str) -> String {
504 if s.is_empty() {
505 return String::new();
506 }
507 let mut chars = s.chars();
508 let first = chars.next().unwrap();
509 let mut result = first.to_lowercase().to_string();
510 result.extend(chars);
511 result
512}
513
514fn snake_to_camel_case(s: &str) -> String {
525 let mut result = String::with_capacity(s.len());
526 let mut capitalize_next = false;
527 for ch in s.chars() {
528 if ch == '_' {
529 capitalize_next = true;
530 } else if capitalize_next {
531 result.extend(ch.to_uppercase());
532 capitalize_next = false;
533 } else {
534 result.push(ch);
535 }
536 }
537 result
538}
539
540fn is_unconditionally_required(ahb_status: &str) -> bool {
542 matches!(ahb_status, "X" | "Muss" | "Soll")
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use crate::pid_requirements::{
549 Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
550 };
551 use serde_json::json;
552
553 fn sample_requirements() -> PidRequirements {
554 PidRequirements {
555 pid: "55001".to_string(),
556 beschreibung: "Anmeldung verb. MaLo".to_string(),
557 entities: vec![
558 EntityRequirement {
559 entity: "Prozessdaten".to_string(),
560 ref_type: Bo4eRefType::Object {
561 type_name: "Prozessdaten".to_string(),
562
563 cardinality: Cardinality::REQUIRED,
564 },
565
566 ahb_status: "Muss".to_string(),
567 map_key: None,
568 scope: EntityScope::Transaction,
569 variants: vec![],
570 groups: vec![],
571 fields: vec![
572 FieldRequirement {
573 bo4e_name: "vorgangId".to_string(),
574 ahb_status: "X".to_string(),
575 field_type: "data".to_string(),
576 format: None,
577 enum_name: None,
578 valid_codes: vec![],
579 child_group: None,
580 ref_type: Bo4eRefType::Unknown,
581 },
582 FieldRequirement {
583 bo4e_name: "transaktionsgrund".to_string(),
584 ahb_status: "X".to_string(),
585 field_type: "code".to_string(),
586 format: None,
587 enum_name: Some("Transaktionsgrund".to_string()),
588 valid_codes: vec![
589 CodeValue {
590 code: "E01".to_string(),
591 meaning: "Ein-/Auszug (Einzug)".to_string(),
592 enum_name: None,
593 bo4e_value: None,
594 },
595 CodeValue {
596 code: "E03".to_string(),
597 meaning: "Wechsel".to_string(),
598 enum_name: None,
599 bo4e_value: None,
600 },
601 ],
602 child_group: None,
603 ref_type: Bo4eRefType::Unknown,
604 },
605 ],
606 },
607 EntityRequirement {
608 entity: "Marktlokation".to_string(),
609 ref_type: Bo4eRefType::Object {
610 type_name: "Marktlokation".to_string(),
611
612 cardinality: Cardinality::REQUIRED,
613 },
614
615 ahb_status: "Muss".to_string(),
616 map_key: None,
617 scope: EntityScope::Transaction,
618 variants: vec![],
619 groups: vec![],
620 fields: vec![
621 FieldRequirement {
622 bo4e_name: "marktlokationsId".to_string(),
623 ahb_status: "X".to_string(),
624 field_type: "data".to_string(),
625 format: None,
626 enum_name: None,
627 valid_codes: vec![],
628 child_group: None,
629 ref_type: Bo4eRefType::Unknown,
630 },
631 FieldRequirement {
632 bo4e_name: "haushaltskunde".to_string(),
633 ahb_status: "X".to_string(),
634 field_type: "code".to_string(),
635 format: None,
636 enum_name: Some("Haushaltskunde".to_string()),
637 valid_codes: vec![
638 CodeValue {
639 code: "Z15".to_string(),
640 meaning: "Ja".to_string(),
641 enum_name: None,
642 bo4e_value: None,
643 },
644 CodeValue {
645 code: "Z18".to_string(),
646 meaning: "Nein".to_string(),
647 enum_name: None,
648 bo4e_value: None,
649 },
650 ],
651 child_group: None,
652 ref_type: Bo4eRefType::Unknown,
653 },
654 ],
655 },
656 EntityRequirement {
657 entity: "Geschaeftspartner".to_string(),
658 ref_type: Bo4eRefType::Object {
659 type_name: "Geschaeftspartner".to_string(),
660
661 cardinality: Cardinality {
662 min: 1,
663 max: Some(7),
664 },
665 },
666
667 ahb_status: "Muss".to_string(),
668 map_key: None,
669 scope: EntityScope::Transaction,
670 variants: vec![],
671 groups: vec![],
672 fields: vec![FieldRequirement {
673 bo4e_name: "identifikation".to_string(),
674 ahb_status: "X".to_string(),
675 field_type: "data".to_string(),
676 format: None,
677 enum_name: None,
678 valid_codes: vec![],
679 child_group: None,
680 ref_type: Bo4eRefType::Unknown,
681 }],
682 },
683 ],
684 }
685 }
686
687 #[test]
688 fn test_validate_complete_json() {
689 let reqs = sample_requirements();
690 let json = json!({
691 "prozessdaten": {
692 "vorgangId": "ABC123",
693 "transaktionsgrund": "E01"
694 },
695 "marktlokation": {
696 "marktlokationsId": "51234567890",
697 "haushaltskunde": "Z15"
698 },
699 "geschaeftspartner": [
700 { "identifikation": "9900000000003" }
701 ]
702 });
703
704 let errors = validate_pid_json(&json, &reqs);
705 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
706 }
707
708 #[test]
709 fn test_validate_missing_entity() {
710 let reqs = sample_requirements();
711 let json = json!({
712 "prozessdaten": {
713 "vorgangId": "ABC123",
714 "transaktionsgrund": "E01"
715 },
716 "geschaeftspartner": [
717 { "identifikation": "9900000000003" }
718 ]
719 });
720 let errors = validate_pid_json(&json, &reqs);
723 assert_eq!(errors.len(), 1);
724 match &errors[0] {
725 PidValidationError::MissingEntity {
726 entity,
727 ahb_status,
728 severity,
729 } => {
730 assert_eq!(entity, "Marktlokation");
731 assert_eq!(ahb_status, "Muss");
732 assert_eq!(severity, &Severity::Error);
733 }
734 other => panic!("Expected MissingEntity, got: {other:?}"),
735 }
736
737 let msg = errors[0].to_string();
739 assert!(msg.contains("ERROR"));
740 assert!(msg.contains("Marktlokation"));
741 assert!(msg.contains("Muss"));
742 }
743
744 #[test]
745 fn test_validate_missing_field() {
746 let reqs = sample_requirements();
747 let json = json!({
748 "prozessdaten": {
749 "transaktionsgrund": "E01"
750 },
752 "marktlokation": {
753 "marktlokationsId": "51234567890",
754 "haushaltskunde": "Z15"
755 },
756 "geschaeftspartner": [
757 { "identifikation": "9900000000003" }
758 ]
759 });
760
761 let errors = validate_pid_json(&json, &reqs);
762 assert_eq!(errors.len(), 1);
763 match &errors[0] {
764 PidValidationError::MissingField {
765 entity,
766 field,
767 ahb_status,
768 severity,
769 ..
770 } => {
771 assert_eq!(entity, "Prozessdaten");
772 assert_eq!(field, "vorgangId");
773 assert_eq!(ahb_status, "X");
774 assert_eq!(severity, &Severity::Error);
775 }
776 other => panic!("Expected MissingField, got: {other:?}"),
777 }
778
779 let msg = errors[0].to_string();
780 assert!(msg.contains("ERROR"));
781 assert!(msg.contains("Prozessdaten.vorgangId"));
782 }
783
784 #[test]
785 fn test_validate_invalid_code() {
786 let reqs = sample_requirements();
787 let json = json!({
788 "prozessdaten": {
789 "vorgangId": "ABC123",
790 "transaktionsgrund": "E01"
791 },
792 "marktlokation": {
793 "marktlokationsId": "51234567890",
794 "haushaltskunde": "Z99" },
796 "geschaeftspartner": [
797 { "identifikation": "9900000000003" }
798 ]
799 });
800
801 let errors = validate_pid_json(&json, &reqs);
802 assert_eq!(errors.len(), 1);
803 match &errors[0] {
804 PidValidationError::InvalidCode {
805 entity,
806 field,
807 value,
808 valid_values,
809 } => {
810 assert_eq!(entity, "Marktlokation");
811 assert_eq!(field, "haushaltskunde");
812 assert_eq!(value, "Z99");
813 assert_eq!(valid_values.len(), 2);
814 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
815 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
816 }
817 other => panic!("Expected InvalidCode, got: {other:?}"),
818 }
819
820 let msg = errors[0].to_string();
821 assert!(msg.contains("INVALID"));
822 assert!(msg.contains("Z99"));
823 assert!(msg.contains("Z15"));
824 }
825
826 #[test]
827 fn test_validate_array_entity() {
828 let reqs = sample_requirements();
829 let json = json!({
830 "prozessdaten": {
831 "vorgangId": "ABC123",
832 "transaktionsgrund": "E01"
833 },
834 "marktlokation": {
835 "marktlokationsId": "51234567890",
836 "haushaltskunde": "Z15"
837 },
838 "geschaeftspartner": [
839 { "identifikation": "9900000000003" },
840 { } ]
842 });
843
844 let errors = validate_pid_json(&json, &reqs);
845 assert_eq!(errors.len(), 1);
846 match &errors[0] {
847 PidValidationError::MissingField { entity, field, .. } => {
848 assert_eq!(entity, "Geschaeftspartner");
849 assert_eq!(field, "identifikation");
850 }
851 other => panic!("Expected MissingField, got: {other:?}"),
852 }
853 }
854
855 #[test]
856 fn test_to_camel_case() {
857 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
858 assert_eq!(
859 to_camel_case("RuhendeMarktlokation"),
860 "ruhendeMarktlokation"
861 );
862 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
863 assert_eq!(to_camel_case(""), "");
864 }
865
866 #[test]
867 fn test_snake_to_camel_case() {
868 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
869 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
870 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
871 assert_eq!(snake_to_camel_case(""), "");
872 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
873 }
874
875 #[test]
878 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
879 let reqs = PidRequirements {
880 pid: "55077".to_string(),
881 beschreibung: "Test camelCase fallback".to_string(),
882 entities: vec![EntityRequirement {
883 entity: "Zuordnung".to_string(),
884 ref_type: Bo4eRefType::Object {
885 type_name: "Zuordnung".to_string(),
886
887 cardinality: Cardinality::REQUIRED,
888 },
889
890 ahb_status: "Muss".to_string(),
891 map_key: None,
892 scope: EntityScope::Transaction,
893 variants: vec![],
894 groups: vec![],
895 fields: vec![
896 FieldRequirement {
897 bo4e_name: "code_codepflege".to_string(),
899 ahb_status: "X".to_string(),
900 field_type: "data".to_string(),
901 format: None,
902 enum_name: None,
903 valid_codes: vec![],
904 child_group: None,
905 ref_type: Bo4eRefType::Unknown,
906 },
907 FieldRequirement {
908 bo4e_name: "codeliste".to_string(),
909 ahb_status: "X".to_string(),
910 field_type: "data".to_string(),
911 format: None,
912 enum_name: None,
913 valid_codes: vec![],
914 child_group: None,
915 ref_type: Bo4eRefType::Unknown,
916 },
917 ],
918 }],
919 };
920
921 let json_camel = json!({
924 "zuordnung": {
925 "codeCodepflege": "DE_BDEW",
926 "codeliste": "6"
927 }
928 });
929
930 let errors = validate_pid_json(&json_camel, &reqs);
931 assert!(
932 errors.is_empty(),
933 "Expected no errors when field is present under camelCase key, got: {errors:?}"
934 );
935
936 let json_snake = json!({
938 "zuordnung": {
939 "code_codepflege": "DE_BDEW",
940 "codeliste": "6"
941 }
942 });
943
944 let errors = validate_pid_json(&json_snake, &reqs);
945 assert!(
946 errors.is_empty(),
947 "Expected no errors when field is present under snake_case key, got: {errors:?}"
948 );
949
950 let json_missing = json!({
952 "zuordnung": {
953 "codeliste": "6"
954 }
955 });
956
957 let errors = validate_pid_json(&json_missing, &reqs);
958 assert_eq!(errors.len(), 1);
959 match &errors[0] {
960 PidValidationError::MissingField { field, .. } => {
961 assert_eq!(field, "code_codepflege");
962 }
963 other => panic!("Expected MissingField, got: {other:?}"),
964 }
965 }
966
967 #[test]
968 fn test_is_unconditionally_required() {
969 assert!(is_unconditionally_required("X"));
970 assert!(is_unconditionally_required("Muss"));
971 assert!(is_unconditionally_required("Soll"));
972 assert!(!is_unconditionally_required("Kann"));
973 assert!(!is_unconditionally_required("[1]"));
974 assert!(!is_unconditionally_required(""));
975 }
976
977 #[test]
978 fn test_validation_report_display() {
979 let errors = vec![
980 PidValidationError::MissingEntity {
981 entity: "Marktlokation".to_string(),
982 ahb_status: "Muss".to_string(),
983 severity: Severity::Error,
984 },
985 PidValidationError::MissingField {
986 entity: "Prozessdaten".to_string(),
987 field: "vorgangId".to_string(),
988 ahb_status: "X".to_string(),
989 rust_type: None,
990 valid_values: vec![],
991 severity: Severity::Error,
992 },
993 ];
994 let report = ValidationReport(errors);
995 assert!(report.has_errors());
996 assert_eq!(report.len(), 2);
997 assert!(!report.is_empty());
998
999 let display = report.to_string();
1000 assert!(display.contains("missing entity 'Marktlokation'"));
1001 assert!(display.contains("missing Prozessdaten.vorgangId"));
1002 }
1003
1004 #[test]
1005 fn test_missing_field_with_type_and_values_display() {
1006 let err = PidValidationError::MissingField {
1007 entity: "Marktlokation".to_string(),
1008 field: "haushaltskunde".to_string(),
1009 ahb_status: "Muss".to_string(),
1010 rust_type: Some("Haushaltskunde".to_string()),
1011 valid_values: vec![
1012 ("Z15".to_string(), "Ja".to_string()),
1013 ("Z18".to_string(), "Nein".to_string()),
1014 ],
1015 severity: Severity::Error,
1016 };
1017 let msg = err.to_string();
1018 assert!(msg.contains("type: Haushaltskunde"));
1019 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
1020 }
1021
1022 #[test]
1023 fn test_optional_fields_not_flagged() {
1024 let reqs = PidRequirements {
1025 pid: "99999".to_string(),
1026 beschreibung: "Test".to_string(),
1027 entities: vec![EntityRequirement {
1028 entity: "Test".to_string(),
1029 ref_type: Bo4eRefType::Object {
1030 type_name: "Test".to_string(),
1031
1032 cardinality: Cardinality::OPTIONAL,
1033 },
1034
1035 ahb_status: "Kann".to_string(),
1036 map_key: None,
1037 scope: EntityScope::Transaction,
1038 variants: vec![],
1039 groups: vec![],
1040 fields: vec![FieldRequirement {
1041 bo4e_name: "optionalField".to_string(),
1042 ahb_status: "Kann".to_string(),
1043 field_type: "data".to_string(),
1044 format: None,
1045 enum_name: None,
1046 valid_codes: vec![],
1047 child_group: None,
1048 ref_type: Bo4eRefType::Unknown,
1049 }],
1050 }],
1051 };
1052
1053 let errors = validate_pid_json(&json!({}), &reqs);
1055 assert!(errors.is_empty());
1056
1057 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
1059 assert!(errors.is_empty());
1060 }
1061
1062 #[test]
1065 fn test_nested_dot_path_fields_not_falsely_missing() {
1066 let reqs = PidRequirements {
1067 pid: "55001".to_string(),
1068 beschreibung: "Test nested paths".to_string(),
1069 entities: vec![EntityRequirement {
1070 entity: "ProduktpaketDaten".to_string(),
1071 ref_type: Bo4eRefType::Object {
1072 type_name: "ProduktpaketDaten".to_string(),
1073
1074 cardinality: Cardinality {
1075 min: 1,
1076 max: Some(99999),
1077 },
1078 },
1079
1080 ahb_status: "Muss".to_string(),
1081 map_key: None,
1082 scope: EntityScope::Transaction,
1083 variants: vec![],
1084 groups: vec![],
1085 fields: vec![
1086 FieldRequirement {
1087 bo4e_name: "produktIdentifikation.funktion".to_string(),
1088 ahb_status: "X".to_string(),
1089 field_type: "code".to_string(),
1090 format: None,
1091 enum_name: Some("Produktidentifikation".to_string()),
1092 valid_codes: vec![CodeValue {
1093 code: "5".to_string(),
1094 meaning: "Produktidentifikation".to_string(),
1095 enum_name: None,
1096 bo4e_value: None,
1097 }],
1098 child_group: None,
1099 ref_type: Bo4eRefType::Unknown,
1100 },
1101 FieldRequirement {
1102 bo4e_name: "produktMerkmal.code".to_string(),
1103 ahb_status: "X".to_string(),
1104 field_type: "code".to_string(),
1105 format: None,
1106 enum_name: None,
1107 valid_codes: vec![],
1108 child_group: None,
1109 ref_type: Bo4eRefType::Unknown,
1110 },
1111 ],
1112 }],
1113 };
1114
1115 let json = json!({
1117 "produktpaketDaten": [{
1118 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1119 "produktMerkmal": { "code": "ZH9" }
1120 }]
1121 });
1122
1123 let errors = validate_pid_json(&json, &reqs);
1124 assert!(
1125 errors.is_empty(),
1126 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_nested_dot_path_truly_missing() {
1132 let reqs = PidRequirements {
1133 pid: "55001".to_string(),
1134 beschreibung: "Test nested paths missing".to_string(),
1135 entities: vec![EntityRequirement {
1136 entity: "ProduktpaketDaten".to_string(),
1137 ref_type: Bo4eRefType::Object {
1138 type_name: "ProduktpaketDaten".to_string(),
1139
1140 cardinality: Cardinality {
1141 min: 1,
1142 max: Some(99999),
1143 },
1144 },
1145
1146 ahb_status: "Muss".to_string(),
1147 map_key: None,
1148 scope: EntityScope::Transaction,
1149 variants: vec![],
1150 groups: vec![],
1151 fields: vec![FieldRequirement {
1152 bo4e_name: "produktIdentifikation.funktion".to_string(),
1153 ahb_status: "X".to_string(),
1154 field_type: "data".to_string(),
1155 format: None,
1156 enum_name: None,
1157 valid_codes: vec![],
1158 child_group: None,
1159 ref_type: Bo4eRefType::Unknown,
1160 }],
1161 }],
1162 };
1163
1164 let json = json!({
1166 "produktpaketDaten": [{
1167 "produktIdentifikation": { "id": "123" }
1168 }]
1169 });
1170
1171 let errors = validate_pid_json(&json, &reqs);
1172 assert_eq!(errors.len(), 1, "Should report missing nested field");
1173 match &errors[0] {
1174 PidValidationError::MissingField { field, .. } => {
1175 assert_eq!(field, "produktIdentifikation.funktion");
1176 }
1177 other => panic!("Expected MissingField, got: {other:?}"),
1178 }
1179 }
1180
1181 fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
1182 FieldRequirement {
1183 bo4e_name: name.to_string(),
1184 ahb_status: status.to_string(),
1185 field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
1186 format: None,
1187 enum_name: None,
1188 valid_codes: codes
1189 .iter()
1190 .map(|(code, mapped)| CodeValue {
1191 code: code.to_string(),
1192 meaning: String::new(),
1193 enum_name: None,
1194 bo4e_value: Some(mapped.to_string()),
1195 })
1196 .collect(),
1197 child_group: None,
1198 ref_type: Bo4eRefType::Unknown,
1199 }
1200 }
1201
1202 fn multi_variant_requirements() -> PidRequirements {
1205 let z03 = ("Z03", "messlokationsadresse");
1206 let z07 = ("Z07", "kundeMsb");
1207 PidRequirements {
1208 pid: "55042".to_string(),
1209 beschreibung: String::new(),
1210 entities: vec![EntityRequirement {
1211 entity: "Geschaeftspartner".to_string(),
1212 ref_type: Bo4eRefType::Object {
1213 type_name: "Geschaeftspartner".to_string(),
1214 cardinality: Cardinality {
1215 min: 1,
1216 max: Some(99),
1217 },
1218 },
1219 ahb_status: "Muss".to_string(),
1220 fields: vec![
1222 field("adresse.ort", "X", &[]),
1223 field("name1", "X", &[]),
1224 field("partnerrolle", "X", &[z03, z07]),
1225 ],
1226 map_key: None,
1227 scope: EntityScope::Transaction,
1228 variants: vec![
1229 EntityVariantRequirement {
1230 discriminator_field: "partnerrolle".to_string(),
1231 code: "Z03".to_string(),
1232 bo4e_value: Some("messlokationsadresse".to_string()),
1233 source_paths: vec!["sg4.sg12_z03".to_string()],
1234 fields: vec![
1235 field("adresse.ort", "X", &[]),
1236 field("partnerrolle", "X", &[z03]),
1237 ],
1238 },
1239 EntityVariantRequirement {
1240 discriminator_field: "partnerrolle".to_string(),
1241 code: "Z07".to_string(),
1242 bo4e_value: Some("kundeMsb".to_string()),
1243 source_paths: vec!["sg4.sg12_z07".to_string()],
1244 fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
1245 },
1246 ],
1247 groups: vec![],
1248 }],
1249 }
1250 }
1251
1252 fn multi_group_requirements(z22_status: &str) -> PidRequirements {
1254 let group = |path: &str, status: &str, fields: &[&str]| EntityGroupRequirement {
1255 source_path: path.to_string(),
1256 ahb_status: status.to_string(),
1257 fields: fields.iter().map(|f| f.to_string()).collect(),
1258 };
1259 PidRequirements {
1260 pid: "55043".to_string(),
1261 beschreibung: String::new(),
1262 entities: vec![EntityRequirement {
1263 entity: "Marktlokation".to_string(),
1264 ref_type: Bo4eRefType::Object {
1265 type_name: "Marktlokation".to_string(),
1266 cardinality: Cardinality::REQUIRED,
1267 },
1268 ahb_status: "Muss".to_string(),
1269 fields: vec![
1270 field("marktlokationsId", "X", &[]),
1271 field("ruhendeMarktlokationsId", "X", &[]),
1272 field("ruhendeMarktlokationZeitraumId", "Kann", &[]),
1273 ],
1274 map_key: None,
1275 scope: EntityScope::Transaction,
1276 variants: vec![],
1277 groups: vec![
1278 group("sg4.sg5_z16", "Muss", &["marktlokationsId"]),
1279 group(
1280 "sg4.sg5_z22",
1281 z22_status,
1282 &["ruhendeMarktlokationsId", "ruhendeMarktlokationZeitraumId"],
1283 ),
1284 ],
1285 }],
1286 }
1287 }
1288
1289 fn missing_fields(errors: &[PidValidationError]) -> Vec<&str> {
1290 errors
1291 .iter()
1292 .filter_map(|e| match e {
1293 PidValidationError::MissingField { field, .. } => Some(field.as_str()),
1294 _ => None,
1295 })
1296 .collect()
1297 }
1298
1299 #[test]
1300 fn an_absent_optional_groups_fields_are_not_demanded() {
1301 let reqs = multi_group_requirements("Soll [2003]");
1302 let errors = validate_pid_json(&json!({ "marktlokation": {} }), &reqs);
1303 assert_eq!(missing_fields(&errors), vec!["marktlokationsId"]);
1305 }
1306
1307 #[test]
1308 fn a_filled_groups_fields_are_demanded() {
1309 let reqs = multi_group_requirements("Soll [2003]");
1310 let json = json!({ "marktlokation": {
1311 "marktlokationsId": "51238696781",
1312 "ruhendeMarktlokationZeitraumId": "1"
1313 }});
1314 let errors = validate_pid_json(&json, &reqs);
1315 assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
1316 }
1317
1318 #[test]
1319 fn an_absent_required_groups_fields_are_demanded() {
1320 let reqs = multi_group_requirements("Muss");
1321 let json = json!({ "marktlokation": { "marktlokationsId": "51238696781" } });
1322 let errors = validate_pid_json(&json, &reqs);
1323 assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
1324 }
1325
1326 #[test]
1327 fn multi_variant_entity_uses_the_elements_own_variant() {
1328 let reqs = multi_variant_requirements();
1329 let json = json!({
1331 "geschaeftspartner": [
1332 { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
1333 { "partnerrolle": "kundeMsb", "name1": "Muster" },
1334 { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
1335 "adresse": { "ort": "Köln" } },
1336 { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
1337 ]
1338 });
1339 let errors = validate_pid_json(&json, &reqs);
1340 assert!(errors.is_empty(), "{}", ValidationReport(errors));
1341 }
1342
1343 #[test]
1344 fn multi_variant_entity_reports_variant_required_fields() {
1345 let reqs = multi_variant_requirements();
1346 let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
1347 let errors = validate_pid_json(&json, &reqs);
1348 assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
1349 assert!(matches!(
1350 &errors[0],
1351 PidValidationError::MissingField { field, .. } if field == "name1"
1352 ));
1353 }
1354
1355 #[test]
1356 fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
1357 let reqs = multi_variant_requirements();
1358 for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
1359 let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
1360 let errors = validate_pid_json(&json, &reqs);
1361 assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
1363 match &errors[0] {
1364 PidValidationError::InvalidCode {
1365 field,
1366 valid_values,
1367 ..
1368 } => {
1369 assert_eq!(field, "partnerrolle");
1370 let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
1371 assert_eq!(codes, ["Z03", "Z07"]);
1372 }
1373 other => panic!("expected InvalidCode, got {other:?}"),
1374 }
1375 }
1376 }
1377
1378 #[test]
1379 fn code_objects_and_enum_mapped_names_are_code_checked() {
1380 let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
1381 assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
1382 assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
1383 assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
1384 assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
1385 assert_eq!(
1386 invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
1387 Some("Z99".to_string())
1388 );
1389 assert_eq!(
1390 invalid_code_value(&json!("kundeLf"), &f),
1391 Some("kundeLf".to_string())
1392 );
1393 assert_eq!(invalid_code_value(&json!(7), &f), None);
1395 assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
1396 }
1397}