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 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
264fn 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 let val = get_nested(entity_json, &field_req.bo4e_name);
277
278 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
302fn 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
319pub 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
326pub 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
344pub 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 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 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; };
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
437pub 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
450pub 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 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
482fn 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
490fn 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
506fn 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
532fn 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 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 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 },
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" },
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 { } ]
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 #[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 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 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 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 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 let errors = validate_pid_json(&json!({}), &reqs);
1047 assert!(errors.is_empty());
1048
1049 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
1051 assert!(errors.is_empty());
1052 }
1053
1054 #[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 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 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 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 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 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 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 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 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 assert_eq!(invalid_code_value(&json!(7), &f), None);
1387 assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
1388 }
1389}