1use std::borrow::Cow;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt;
6
7use serde_json::Value;
8
9use crate::pid_requirements::{
10 CodeValue, EntityRequirement, EntityScope, EntityVariantRequirement, FieldRequirement,
11 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 for field_req in fields.iter() {
272 let val = get_nested(entity_json, &field_req.bo4e_name);
275
276 let val = val.filter(|v| !v.is_null());
278
279 match val {
280 None => {
281 if is_unconditionally_required(&field_req.ahb_status) {
282 errors.push(PidValidationError::MissingField {
283 entity: entity_req.entity.clone(),
284 field: field_req.bo4e_name.clone(),
285 ahb_status: field_req.ahb_status.clone(),
286 rust_type: field_req.enum_name.clone(),
287 valid_values: code_values_to_tuples(&field_req.valid_codes),
288 severity: Severity::Error,
289 });
290 }
291 }
292 Some(val) => {
293 validate_code_value(val, entity_req, field_req, errors);
294 }
295 }
296 }
297}
298
299fn validate_code_value(
301 val: &Value,
302 entity_req: &EntityRequirement,
303 field_req: &FieldRequirement,
304 errors: &mut Vec<PidValidationError>,
305) {
306 if let Some(value) = invalid_code_value(val, field_req) {
307 errors.push(PidValidationError::InvalidCode {
308 entity: entity_req.entity.clone(),
309 field: field_req.bo4e_name.clone(),
310 value,
311 valid_values: code_values_to_tuples(&field_req.valid_codes),
312 });
313 }
314}
315
316pub fn code_field_value(val: &Value) -> Option<&str> {
319 val.as_str()
320 .or_else(|| val.get("code").and_then(|c| c.as_str()))
321}
322
323pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
330 if field_req.valid_codes.is_empty() {
331 return None;
332 }
333 let value = code_field_value(val)?;
334 let is_valid = field_req
335 .valid_codes
336 .iter()
337 .any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
338 (!is_valid).then(|| value.to_string())
339}
340
341pub fn effective_field_requirements<'a>(
356 entity_req: &'a EntityRequirement,
357 element: &Value,
358) -> Cow<'a, [FieldRequirement]> {
359 if entity_req.variants.is_empty() {
360 return Cow::Borrowed(&entity_req.fields);
361 }
362
363 let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
365 for v in &entity_req.variants {
366 by_field
367 .entry(v.discriminator_field.as_str())
368 .or_default()
369 .push(v);
370 }
371 let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
372 for (field, group) in by_field {
373 let value = get_nested(element, field).and_then(code_field_value);
374 let matched: Vec<&EntityVariantRequirement> = group
375 .iter()
376 .copied()
377 .filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
378 .collect();
379 candidates.extend(if matched.is_empty() { group } else { matched });
380 }
381
382 let mut owned: Vec<&str> = Vec::new();
384 let mut owned_set: BTreeSet<&str> = BTreeSet::new();
385 for v in &entity_req.variants {
386 for f in &v.fields {
387 if owned_set.insert(f.bo4e_name.as_str()) {
388 owned.push(f.bo4e_name.as_str());
389 }
390 }
391 }
392
393 let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
394 for name in owned {
395 let reqs: Vec<&FieldRequirement> = candidates
396 .iter()
397 .filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
398 .collect();
399 let Some((first, rest)) = reqs.split_first() else {
400 continue; };
402 let mut field = (*first).clone();
403 let mut statuses_agree = reqs.len() == candidates.len();
404 for r in rest {
405 if r.ahb_status != field.ahb_status {
406 statuses_agree = false;
407 }
408 for cv in &r.valid_codes {
409 if !field.valid_codes.iter().any(|c| c.code == cv.code) {
410 field.valid_codes.push(cv.clone());
411 }
412 }
413 }
414 if !statuses_agree {
415 field.ahb_status = String::new();
416 }
417 combined.insert(name, field);
418 }
419
420 let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
421 for f in &entity_req.fields {
422 if owned_set.contains(f.bo4e_name.as_str()) {
423 if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
424 result.push(c);
425 }
426 } else {
427 result.push(f.clone());
428 }
429 }
430 result.extend(combined.into_values());
431 Cow::Owned(result)
432}
433
434fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
436 codes
437 .iter()
438 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
439 .collect()
440}
441
442fn to_camel_case(s: &str) -> String {
448 if s.is_empty() {
449 return String::new();
450 }
451 let mut chars = s.chars();
452 let first = chars.next().unwrap();
453 let mut result = first.to_lowercase().to_string();
454 result.extend(chars);
455 result
456}
457
458fn snake_to_camel_case(s: &str) -> String {
469 let mut result = String::with_capacity(s.len());
470 let mut capitalize_next = false;
471 for ch in s.chars() {
472 if ch == '_' {
473 capitalize_next = true;
474 } else if capitalize_next {
475 result.extend(ch.to_uppercase());
476 capitalize_next = false;
477 } else {
478 result.push(ch);
479 }
480 }
481 result
482}
483
484fn is_unconditionally_required(ahb_status: &str) -> bool {
486 matches!(ahb_status, "X" | "Muss" | "Soll")
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::pid_requirements::{
493 Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
494 };
495 use serde_json::json;
496
497 fn sample_requirements() -> PidRequirements {
498 PidRequirements {
499 pid: "55001".to_string(),
500 beschreibung: "Anmeldung verb. MaLo".to_string(),
501 entities: vec![
502 EntityRequirement {
503 entity: "Prozessdaten".to_string(),
504 ref_type: Bo4eRefType::Object {
505 type_name: "Prozessdaten".to_string(),
506
507 cardinality: Cardinality::REQUIRED,
508 },
509
510 ahb_status: "Muss".to_string(),
511 map_key: None,
512 scope: EntityScope::Transaction,
513 variants: vec![],
514 fields: vec![
515 FieldRequirement {
516 bo4e_name: "vorgangId".to_string(),
517 ahb_status: "X".to_string(),
518 field_type: "data".to_string(),
519 format: None,
520 enum_name: None,
521 valid_codes: vec![],
522 child_group: None,
523 ref_type: Bo4eRefType::Unknown,
524 },
525 FieldRequirement {
526 bo4e_name: "transaktionsgrund".to_string(),
527 ahb_status: "X".to_string(),
528 field_type: "code".to_string(),
529 format: None,
530 enum_name: Some("Transaktionsgrund".to_string()),
531 valid_codes: vec![
532 CodeValue {
533 code: "E01".to_string(),
534 meaning: "Ein-/Auszug (Einzug)".to_string(),
535 enum_name: None,
536 bo4e_value: None,
537 },
538 CodeValue {
539 code: "E03".to_string(),
540 meaning: "Wechsel".to_string(),
541 enum_name: None,
542 bo4e_value: None,
543 },
544 ],
545 child_group: None,
546 ref_type: Bo4eRefType::Unknown,
547 },
548 ],
549 },
550 EntityRequirement {
551 entity: "Marktlokation".to_string(),
552 ref_type: Bo4eRefType::Object {
553 type_name: "Marktlokation".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 fields: vec![
563 FieldRequirement {
564 bo4e_name: "marktlokationsId".to_string(),
565 ahb_status: "X".to_string(),
566 field_type: "data".to_string(),
567 format: None,
568 enum_name: None,
569 valid_codes: vec![],
570 child_group: None,
571 ref_type: Bo4eRefType::Unknown,
572 },
573 FieldRequirement {
574 bo4e_name: "haushaltskunde".to_string(),
575 ahb_status: "X".to_string(),
576 field_type: "code".to_string(),
577 format: None,
578 enum_name: Some("Haushaltskunde".to_string()),
579 valid_codes: vec![
580 CodeValue {
581 code: "Z15".to_string(),
582 meaning: "Ja".to_string(),
583 enum_name: None,
584 bo4e_value: None,
585 },
586 CodeValue {
587 code: "Z18".to_string(),
588 meaning: "Nein".to_string(),
589 enum_name: None,
590 bo4e_value: None,
591 },
592 ],
593 child_group: None,
594 ref_type: Bo4eRefType::Unknown,
595 },
596 ],
597 },
598 EntityRequirement {
599 entity: "Geschaeftspartner".to_string(),
600 ref_type: Bo4eRefType::Object {
601 type_name: "Geschaeftspartner".to_string(),
602
603 cardinality: Cardinality {
604 min: 1,
605 max: Some(7),
606 },
607 },
608
609 ahb_status: "Muss".to_string(),
610 map_key: None,
611 scope: EntityScope::Transaction,
612 variants: vec![],
613 fields: vec![FieldRequirement {
614 bo4e_name: "identifikation".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 },
624 ],
625 }
626 }
627
628 #[test]
629 fn test_validate_complete_json() {
630 let reqs = sample_requirements();
631 let json = json!({
632 "prozessdaten": {
633 "vorgangId": "ABC123",
634 "transaktionsgrund": "E01"
635 },
636 "marktlokation": {
637 "marktlokationsId": "51234567890",
638 "haushaltskunde": "Z15"
639 },
640 "geschaeftspartner": [
641 { "identifikation": "9900000000003" }
642 ]
643 });
644
645 let errors = validate_pid_json(&json, &reqs);
646 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
647 }
648
649 #[test]
650 fn test_validate_missing_entity() {
651 let reqs = sample_requirements();
652 let json = json!({
653 "prozessdaten": {
654 "vorgangId": "ABC123",
655 "transaktionsgrund": "E01"
656 },
657 "geschaeftspartner": [
658 { "identifikation": "9900000000003" }
659 ]
660 });
661 let errors = validate_pid_json(&json, &reqs);
664 assert_eq!(errors.len(), 1);
665 match &errors[0] {
666 PidValidationError::MissingEntity {
667 entity,
668 ahb_status,
669 severity,
670 } => {
671 assert_eq!(entity, "Marktlokation");
672 assert_eq!(ahb_status, "Muss");
673 assert_eq!(severity, &Severity::Error);
674 }
675 other => panic!("Expected MissingEntity, got: {other:?}"),
676 }
677
678 let msg = errors[0].to_string();
680 assert!(msg.contains("ERROR"));
681 assert!(msg.contains("Marktlokation"));
682 assert!(msg.contains("Muss"));
683 }
684
685 #[test]
686 fn test_validate_missing_field() {
687 let reqs = sample_requirements();
688 let json = json!({
689 "prozessdaten": {
690 "transaktionsgrund": "E01"
691 },
693 "marktlokation": {
694 "marktlokationsId": "51234567890",
695 "haushaltskunde": "Z15"
696 },
697 "geschaeftspartner": [
698 { "identifikation": "9900000000003" }
699 ]
700 });
701
702 let errors = validate_pid_json(&json, &reqs);
703 assert_eq!(errors.len(), 1);
704 match &errors[0] {
705 PidValidationError::MissingField {
706 entity,
707 field,
708 ahb_status,
709 severity,
710 ..
711 } => {
712 assert_eq!(entity, "Prozessdaten");
713 assert_eq!(field, "vorgangId");
714 assert_eq!(ahb_status, "X");
715 assert_eq!(severity, &Severity::Error);
716 }
717 other => panic!("Expected MissingField, got: {other:?}"),
718 }
719
720 let msg = errors[0].to_string();
721 assert!(msg.contains("ERROR"));
722 assert!(msg.contains("Prozessdaten.vorgangId"));
723 }
724
725 #[test]
726 fn test_validate_invalid_code() {
727 let reqs = sample_requirements();
728 let json = json!({
729 "prozessdaten": {
730 "vorgangId": "ABC123",
731 "transaktionsgrund": "E01"
732 },
733 "marktlokation": {
734 "marktlokationsId": "51234567890",
735 "haushaltskunde": "Z99" },
737 "geschaeftspartner": [
738 { "identifikation": "9900000000003" }
739 ]
740 });
741
742 let errors = validate_pid_json(&json, &reqs);
743 assert_eq!(errors.len(), 1);
744 match &errors[0] {
745 PidValidationError::InvalidCode {
746 entity,
747 field,
748 value,
749 valid_values,
750 } => {
751 assert_eq!(entity, "Marktlokation");
752 assert_eq!(field, "haushaltskunde");
753 assert_eq!(value, "Z99");
754 assert_eq!(valid_values.len(), 2);
755 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
756 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
757 }
758 other => panic!("Expected InvalidCode, got: {other:?}"),
759 }
760
761 let msg = errors[0].to_string();
762 assert!(msg.contains("INVALID"));
763 assert!(msg.contains("Z99"));
764 assert!(msg.contains("Z15"));
765 }
766
767 #[test]
768 fn test_validate_array_entity() {
769 let reqs = sample_requirements();
770 let json = json!({
771 "prozessdaten": {
772 "vorgangId": "ABC123",
773 "transaktionsgrund": "E01"
774 },
775 "marktlokation": {
776 "marktlokationsId": "51234567890",
777 "haushaltskunde": "Z15"
778 },
779 "geschaeftspartner": [
780 { "identifikation": "9900000000003" },
781 { } ]
783 });
784
785 let errors = validate_pid_json(&json, &reqs);
786 assert_eq!(errors.len(), 1);
787 match &errors[0] {
788 PidValidationError::MissingField { entity, field, .. } => {
789 assert_eq!(entity, "Geschaeftspartner");
790 assert_eq!(field, "identifikation");
791 }
792 other => panic!("Expected MissingField, got: {other:?}"),
793 }
794 }
795
796 #[test]
797 fn test_to_camel_case() {
798 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
799 assert_eq!(
800 to_camel_case("RuhendeMarktlokation"),
801 "ruhendeMarktlokation"
802 );
803 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
804 assert_eq!(to_camel_case(""), "");
805 }
806
807 #[test]
808 fn test_snake_to_camel_case() {
809 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
810 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
811 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
812 assert_eq!(snake_to_camel_case(""), "");
813 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
814 }
815
816 #[test]
819 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
820 let reqs = PidRequirements {
821 pid: "55077".to_string(),
822 beschreibung: "Test camelCase fallback".to_string(),
823 entities: vec![EntityRequirement {
824 entity: "Zuordnung".to_string(),
825 ref_type: Bo4eRefType::Object {
826 type_name: "Zuordnung".to_string(),
827
828 cardinality: Cardinality::REQUIRED,
829 },
830
831 ahb_status: "Muss".to_string(),
832 map_key: None,
833 scope: EntityScope::Transaction,
834 variants: vec![],
835 fields: vec![
836 FieldRequirement {
837 bo4e_name: "code_codepflege".to_string(),
839 ahb_status: "X".to_string(),
840 field_type: "data".to_string(),
841 format: None,
842 enum_name: None,
843 valid_codes: vec![],
844 child_group: None,
845 ref_type: Bo4eRefType::Unknown,
846 },
847 FieldRequirement {
848 bo4e_name: "codeliste".to_string(),
849 ahb_status: "X".to_string(),
850 field_type: "data".to_string(),
851 format: None,
852 enum_name: None,
853 valid_codes: vec![],
854 child_group: None,
855 ref_type: Bo4eRefType::Unknown,
856 },
857 ],
858 }],
859 };
860
861 let json_camel = json!({
864 "zuordnung": {
865 "codeCodepflege": "DE_BDEW",
866 "codeliste": "6"
867 }
868 });
869
870 let errors = validate_pid_json(&json_camel, &reqs);
871 assert!(
872 errors.is_empty(),
873 "Expected no errors when field is present under camelCase key, got: {errors:?}"
874 );
875
876 let json_snake = json!({
878 "zuordnung": {
879 "code_codepflege": "DE_BDEW",
880 "codeliste": "6"
881 }
882 });
883
884 let errors = validate_pid_json(&json_snake, &reqs);
885 assert!(
886 errors.is_empty(),
887 "Expected no errors when field is present under snake_case key, got: {errors:?}"
888 );
889
890 let json_missing = json!({
892 "zuordnung": {
893 "codeliste": "6"
894 }
895 });
896
897 let errors = validate_pid_json(&json_missing, &reqs);
898 assert_eq!(errors.len(), 1);
899 match &errors[0] {
900 PidValidationError::MissingField { field, .. } => {
901 assert_eq!(field, "code_codepflege");
902 }
903 other => panic!("Expected MissingField, got: {other:?}"),
904 }
905 }
906
907 #[test]
908 fn test_is_unconditionally_required() {
909 assert!(is_unconditionally_required("X"));
910 assert!(is_unconditionally_required("Muss"));
911 assert!(is_unconditionally_required("Soll"));
912 assert!(!is_unconditionally_required("Kann"));
913 assert!(!is_unconditionally_required("[1]"));
914 assert!(!is_unconditionally_required(""));
915 }
916
917 #[test]
918 fn test_validation_report_display() {
919 let errors = vec![
920 PidValidationError::MissingEntity {
921 entity: "Marktlokation".to_string(),
922 ahb_status: "Muss".to_string(),
923 severity: Severity::Error,
924 },
925 PidValidationError::MissingField {
926 entity: "Prozessdaten".to_string(),
927 field: "vorgangId".to_string(),
928 ahb_status: "X".to_string(),
929 rust_type: None,
930 valid_values: vec![],
931 severity: Severity::Error,
932 },
933 ];
934 let report = ValidationReport(errors);
935 assert!(report.has_errors());
936 assert_eq!(report.len(), 2);
937 assert!(!report.is_empty());
938
939 let display = report.to_string();
940 assert!(display.contains("missing entity 'Marktlokation'"));
941 assert!(display.contains("missing Prozessdaten.vorgangId"));
942 }
943
944 #[test]
945 fn test_missing_field_with_type_and_values_display() {
946 let err = PidValidationError::MissingField {
947 entity: "Marktlokation".to_string(),
948 field: "haushaltskunde".to_string(),
949 ahb_status: "Muss".to_string(),
950 rust_type: Some("Haushaltskunde".to_string()),
951 valid_values: vec![
952 ("Z15".to_string(), "Ja".to_string()),
953 ("Z18".to_string(), "Nein".to_string()),
954 ],
955 severity: Severity::Error,
956 };
957 let msg = err.to_string();
958 assert!(msg.contains("type: Haushaltskunde"));
959 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
960 }
961
962 #[test]
963 fn test_optional_fields_not_flagged() {
964 let reqs = PidRequirements {
965 pid: "99999".to_string(),
966 beschreibung: "Test".to_string(),
967 entities: vec![EntityRequirement {
968 entity: "Test".to_string(),
969 ref_type: Bo4eRefType::Object {
970 type_name: "Test".to_string(),
971
972 cardinality: Cardinality::OPTIONAL,
973 },
974
975 ahb_status: "Kann".to_string(),
976 map_key: None,
977 scope: EntityScope::Transaction,
978 variants: vec![],
979 fields: vec![FieldRequirement {
980 bo4e_name: "optionalField".to_string(),
981 ahb_status: "Kann".to_string(),
982 field_type: "data".to_string(),
983 format: None,
984 enum_name: None,
985 valid_codes: vec![],
986 child_group: None,
987 ref_type: Bo4eRefType::Unknown,
988 }],
989 }],
990 };
991
992 let errors = validate_pid_json(&json!({}), &reqs);
994 assert!(errors.is_empty());
995
996 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
998 assert!(errors.is_empty());
999 }
1000
1001 #[test]
1004 fn test_nested_dot_path_fields_not_falsely_missing() {
1005 let reqs = PidRequirements {
1006 pid: "55001".to_string(),
1007 beschreibung: "Test nested paths".to_string(),
1008 entities: vec![EntityRequirement {
1009 entity: "ProduktpaketDaten".to_string(),
1010 ref_type: Bo4eRefType::Object {
1011 type_name: "ProduktpaketDaten".to_string(),
1012
1013 cardinality: Cardinality {
1014 min: 1,
1015 max: Some(99999),
1016 },
1017 },
1018
1019 ahb_status: "Muss".to_string(),
1020 map_key: None,
1021 scope: EntityScope::Transaction,
1022 variants: vec![],
1023 fields: vec![
1024 FieldRequirement {
1025 bo4e_name: "produktIdentifikation.funktion".to_string(),
1026 ahb_status: "X".to_string(),
1027 field_type: "code".to_string(),
1028 format: None,
1029 enum_name: Some("Produktidentifikation".to_string()),
1030 valid_codes: vec![CodeValue {
1031 code: "5".to_string(),
1032 meaning: "Produktidentifikation".to_string(),
1033 enum_name: None,
1034 bo4e_value: None,
1035 }],
1036 child_group: None,
1037 ref_type: Bo4eRefType::Unknown,
1038 },
1039 FieldRequirement {
1040 bo4e_name: "produktMerkmal.code".to_string(),
1041 ahb_status: "X".to_string(),
1042 field_type: "code".to_string(),
1043 format: None,
1044 enum_name: None,
1045 valid_codes: vec![],
1046 child_group: None,
1047 ref_type: Bo4eRefType::Unknown,
1048 },
1049 ],
1050 }],
1051 };
1052
1053 let json = json!({
1055 "produktpaketDaten": [{
1056 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
1057 "produktMerkmal": { "code": "ZH9" }
1058 }]
1059 });
1060
1061 let errors = validate_pid_json(&json, &reqs);
1062 assert!(
1063 errors.is_empty(),
1064 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_nested_dot_path_truly_missing() {
1070 let reqs = PidRequirements {
1071 pid: "55001".to_string(),
1072 beschreibung: "Test nested paths missing".to_string(),
1073 entities: vec![EntityRequirement {
1074 entity: "ProduktpaketDaten".to_string(),
1075 ref_type: Bo4eRefType::Object {
1076 type_name: "ProduktpaketDaten".to_string(),
1077
1078 cardinality: Cardinality {
1079 min: 1,
1080 max: Some(99999),
1081 },
1082 },
1083
1084 ahb_status: "Muss".to_string(),
1085 map_key: None,
1086 scope: EntityScope::Transaction,
1087 variants: vec![],
1088 fields: vec![FieldRequirement {
1089 bo4e_name: "produktIdentifikation.funktion".to_string(),
1090 ahb_status: "X".to_string(),
1091 field_type: "data".to_string(),
1092 format: None,
1093 enum_name: None,
1094 valid_codes: vec![],
1095 child_group: None,
1096 ref_type: Bo4eRefType::Unknown,
1097 }],
1098 }],
1099 };
1100
1101 let json = json!({
1103 "produktpaketDaten": [{
1104 "produktIdentifikation": { "id": "123" }
1105 }]
1106 });
1107
1108 let errors = validate_pid_json(&json, &reqs);
1109 assert_eq!(errors.len(), 1, "Should report missing nested field");
1110 match &errors[0] {
1111 PidValidationError::MissingField { field, .. } => {
1112 assert_eq!(field, "produktIdentifikation.funktion");
1113 }
1114 other => panic!("Expected MissingField, got: {other:?}"),
1115 }
1116 }
1117
1118 fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
1119 FieldRequirement {
1120 bo4e_name: name.to_string(),
1121 ahb_status: status.to_string(),
1122 field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
1123 format: None,
1124 enum_name: None,
1125 valid_codes: codes
1126 .iter()
1127 .map(|(code, mapped)| CodeValue {
1128 code: code.to_string(),
1129 meaning: String::new(),
1130 enum_name: None,
1131 bo4e_value: Some(mapped.to_string()),
1132 })
1133 .collect(),
1134 child_group: None,
1135 ref_type: Bo4eRefType::Unknown,
1136 }
1137 }
1138
1139 fn multi_variant_requirements() -> PidRequirements {
1142 let z03 = ("Z03", "messlokationsadresse");
1143 let z07 = ("Z07", "kundeMsb");
1144 PidRequirements {
1145 pid: "55042".to_string(),
1146 beschreibung: String::new(),
1147 entities: vec![EntityRequirement {
1148 entity: "Geschaeftspartner".to_string(),
1149 ref_type: Bo4eRefType::Object {
1150 type_name: "Geschaeftspartner".to_string(),
1151 cardinality: Cardinality {
1152 min: 1,
1153 max: Some(99),
1154 },
1155 },
1156 ahb_status: "Muss".to_string(),
1157 fields: vec![
1159 field("adresse.ort", "X", &[]),
1160 field("name1", "X", &[]),
1161 field("partnerrolle", "X", &[z03, z07]),
1162 ],
1163 map_key: None,
1164 scope: EntityScope::Transaction,
1165 variants: vec![
1166 EntityVariantRequirement {
1167 discriminator_field: "partnerrolle".to_string(),
1168 code: "Z03".to_string(),
1169 bo4e_value: Some("messlokationsadresse".to_string()),
1170 source_paths: vec!["sg4.sg12_z03".to_string()],
1171 fields: vec![
1172 field("adresse.ort", "X", &[]),
1173 field("partnerrolle", "X", &[z03]),
1174 ],
1175 },
1176 EntityVariantRequirement {
1177 discriminator_field: "partnerrolle".to_string(),
1178 code: "Z07".to_string(),
1179 bo4e_value: Some("kundeMsb".to_string()),
1180 source_paths: vec!["sg4.sg12_z07".to_string()],
1181 fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
1182 },
1183 ],
1184 }],
1185 }
1186 }
1187
1188 #[test]
1189 fn multi_variant_entity_uses_the_elements_own_variant() {
1190 let reqs = multi_variant_requirements();
1191 let json = json!({
1193 "geschaeftspartner": [
1194 { "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
1195 { "partnerrolle": "kundeMsb", "name1": "Muster" },
1196 { "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
1197 "adresse": { "ort": "Köln" } },
1198 { "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
1199 ]
1200 });
1201 let errors = validate_pid_json(&json, &reqs);
1202 assert!(errors.is_empty(), "{}", ValidationReport(errors));
1203 }
1204
1205 #[test]
1206 fn multi_variant_entity_reports_variant_required_fields() {
1207 let reqs = multi_variant_requirements();
1208 let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
1209 let errors = validate_pid_json(&json, &reqs);
1210 assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
1211 assert!(matches!(
1212 &errors[0],
1213 PidValidationError::MissingField { field, .. } if field == "name1"
1214 ));
1215 }
1216
1217 #[test]
1218 fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
1219 let reqs = multi_variant_requirements();
1220 for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
1221 let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
1222 let errors = validate_pid_json(&json, &reqs);
1223 assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
1225 match &errors[0] {
1226 PidValidationError::InvalidCode {
1227 field,
1228 valid_values,
1229 ..
1230 } => {
1231 assert_eq!(field, "partnerrolle");
1232 let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
1233 assert_eq!(codes, ["Z03", "Z07"]);
1234 }
1235 other => panic!("expected InvalidCode, got {other:?}"),
1236 }
1237 }
1238 }
1239
1240 #[test]
1241 fn code_objects_and_enum_mapped_names_are_code_checked() {
1242 let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
1243 assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
1244 assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
1245 assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
1246 assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
1247 assert_eq!(
1248 invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
1249 Some("Z99".to_string())
1250 );
1251 assert_eq!(
1252 invalid_code_value(&json!("kundeLf"), &f),
1253 Some("kundeLf".to_string())
1254 );
1255 assert_eq!(invalid_code_value(&json!(7), &f), None);
1257 assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
1258 }
1259}