1use std::fmt;
4
5use serde_json::Value;
6
7use crate::pid_requirements::{
8 CodeValue, EntityRequirement, EntityScope, FieldRequirement, PidRequirements,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Severity {
14 Error,
16 Warning,
18}
19
20#[derive(Debug, Clone)]
22pub enum PidValidationError {
23 MissingEntity {
25 entity: String,
26 ahb_status: String,
27 severity: Severity,
28 },
29 MissingField {
31 entity: String,
32 field: String,
33 ahb_status: String,
34 rust_type: Option<String>,
35 valid_values: Vec<(String, String)>,
36 severity: Severity,
37 },
38 InvalidCode {
40 entity: String,
41 field: String,
42 value: String,
43 valid_values: Vec<(String, String)>,
44 },
45}
46
47impl PidValidationError {
48 pub fn severity(&self) -> &Severity {
49 match self {
50 Self::MissingEntity { severity, .. } => severity,
51 Self::MissingField { severity, .. } => severity,
52 Self::InvalidCode { .. } => &Severity::Error,
53 }
54 }
55
56 pub fn is_error(&self) -> bool {
57 matches!(self.severity(), Severity::Error)
58 }
59}
60
61impl fmt::Display for PidValidationError {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 PidValidationError::MissingEntity {
65 entity,
66 ahb_status,
67 severity,
68 } => {
69 let label = severity_label(severity);
70 write!(
71 f,
72 "{label}: missing entity '{entity}' (required: {ahb_status})"
73 )
74 }
75 PidValidationError::MissingField {
76 entity,
77 field,
78 ahb_status,
79 rust_type,
80 valid_values,
81 severity,
82 } => {
83 let label = severity_label(severity);
84 write!(
85 f,
86 "{label}: missing {entity}.{field} (required: {ahb_status})"
87 )?;
88 if let Some(rt) = rust_type {
89 write!(f, "\n → type: {rt}")?;
90 }
91 if !valid_values.is_empty() {
92 let codes: Vec<String> = valid_values
93 .iter()
94 .map(|(code, meaning)| {
95 if meaning.is_empty() {
96 code.clone()
97 } else {
98 format!("{code} ({meaning})")
99 }
100 })
101 .collect();
102 write!(f, "\n → valid: {}", codes.join(", "))?;
103 }
104 Ok(())
105 }
106 PidValidationError::InvalidCode {
107 entity,
108 field,
109 value,
110 valid_values,
111 } => {
112 write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
113 if !valid_values.is_empty() {
114 let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
115 write!(f, "\n → valid: {}", codes.join(", "))?;
116 }
117 Ok(())
118 }
119 }
120 }
121}
122
123fn severity_label(severity: &Severity) -> &'static str {
124 match severity {
125 Severity::Error => "ERROR",
126 Severity::Warning => "WARNING",
127 }
128}
129
130pub struct ValidationReport(pub Vec<PidValidationError>);
132
133impl ValidationReport {
134 pub fn has_errors(&self) -> bool {
136 self.0.iter().any(|e| e.is_error())
137 }
138
139 pub fn errors(&self) -> Vec<&PidValidationError> {
141 self.0.iter().filter(|e| e.is_error()).collect()
142 }
143
144 pub fn is_empty(&self) -> bool {
146 self.0.is_empty()
147 }
148
149 pub fn len(&self) -> usize {
151 self.0.len()
152 }
153}
154
155impl fmt::Display for ValidationReport {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 for (i, err) in self.0.iter().enumerate() {
158 if i > 0 {
159 writeln!(f)?;
160 }
161 write!(f, "{err}")?;
162 }
163 Ok(())
164 }
165}
166
167pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
179 validate_entities(json, &requirements.entities, None)
180}
181
182pub fn validate_pid_json_transaction(
188 json: &Value,
189 requirements: &PidRequirements,
190) -> Vec<PidValidationError> {
191 validate_entities(
192 json,
193 &requirements.entities,
194 Some(EntityScope::Transaction),
195 )
196}
197
198fn validate_entities(
200 json: &Value,
201 entities: &[EntityRequirement],
202 scope_filter: Option<EntityScope>,
203) -> Vec<PidValidationError> {
204 let mut errors = Vec::new();
205
206 for entity_req in entities {
207 if let Some(ref scope) = scope_filter {
209 if &entity_req.scope != scope {
210 continue;
211 }
212 }
213
214 let key = to_camel_case(&entity_req.entity);
215
216 match json.get(&key) {
217 None | Some(serde_json::Value::Null) => {
218 if is_unconditionally_required(&entity_req.ahb_status) {
219 errors.push(PidValidationError::MissingEntity {
220 entity: entity_req.entity.clone(),
221 ahb_status: entity_req.ahb_status.clone(),
222 severity: Severity::Error,
223 });
224 }
225 }
226 Some(val) => {
227 if entity_req.cardinality().is_list() {
228 if let Some(arr) = val.as_array() {
229 for element in arr {
230 validate_entity_fields(element, entity_req, &mut errors);
231 }
232 } else {
233 validate_entity_fields(val, entity_req, &mut errors);
238 }
239 } else {
240 validate_entity_fields(val, entity_req, &mut errors);
241 }
242 }
243 }
244 }
245
246 errors
247}
248
249fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
252 let mut current = json;
253 for part in path.split('.') {
254 current = current.get(part).or_else(|| {
255 if part.contains('_') {
256 current.get(snake_to_camel_case(part))
257 } else {
258 None
259 }
260 })?;
261 }
262 Some(current)
263}
264
265fn validate_entity_fields(
267 entity_json: &Value,
268 entity_req: &EntityRequirement,
269 errors: &mut Vec<PidValidationError>,
270) {
271 for field_req in &entity_req.fields {
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 if !field_req.valid_codes.is_empty() {
294 validate_code_value(val, entity_req, field_req, errors);
295 }
296 }
297 }
298 }
299}
300
301fn validate_code_value(
303 val: &Value,
304 entity_req: &EntityRequirement,
305 field_req: &FieldRequirement,
306 errors: &mut Vec<PidValidationError>,
307) {
308 let value_str = match val.as_str() {
309 Some(s) => s,
310 None => return, };
312
313 let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
314 if !is_valid {
315 errors.push(PidValidationError::InvalidCode {
316 entity: entity_req.entity.clone(),
317 field: field_req.bo4e_name.clone(),
318 value: value_str.to_string(),
319 valid_values: code_values_to_tuples(&field_req.valid_codes),
320 });
321 }
322}
323
324fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
326 codes
327 .iter()
328 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
329 .collect()
330}
331
332fn to_camel_case(s: &str) -> String {
338 if s.is_empty() {
339 return String::new();
340 }
341 let mut chars = s.chars();
342 let first = chars.next().unwrap();
343 let mut result = first.to_lowercase().to_string();
344 result.extend(chars);
345 result
346}
347
348fn snake_to_camel_case(s: &str) -> String {
359 let mut result = String::with_capacity(s.len());
360 let mut capitalize_next = false;
361 for ch in s.chars() {
362 if ch == '_' {
363 capitalize_next = true;
364 } else if capitalize_next {
365 result.extend(ch.to_uppercase());
366 capitalize_next = false;
367 } else {
368 result.push(ch);
369 }
370 }
371 result
372}
373
374fn is_unconditionally_required(ahb_status: &str) -> bool {
376 matches!(ahb_status, "X" | "Muss" | "Soll")
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::pid_requirements::{
383 Bo4eRefType, Cardinality, CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
384 };
385 use serde_json::json;
386
387 fn sample_requirements() -> PidRequirements {
388 PidRequirements {
389 pid: "55001".to_string(),
390 beschreibung: "Anmeldung verb. MaLo".to_string(),
391 entities: vec![
392 EntityRequirement {
393 entity: "Prozessdaten".to_string(),
394 ref_type: Bo4eRefType::Object {
395
396 type_name: "Prozessdaten".to_string(),
397
398 cardinality: Cardinality::REQUIRED,
399
400 },
401
402
403 ahb_status: "Muss".to_string(),
404 map_key: None,
405 scope: EntityScope::Transaction,
406 fields: vec![
407 FieldRequirement {
408 bo4e_name: "vorgangId".to_string(),
409 ahb_status: "X".to_string(),
410 field_type: "data".to_string(),
411 format: None,
412 enum_name: None,
413 valid_codes: vec![],
414 child_group: None,
415 ref_type: Bo4eRefType::Unknown,
416 },
417 FieldRequirement {
418 bo4e_name: "transaktionsgrund".to_string(),
419 ahb_status: "X".to_string(),
420 field_type: "code".to_string(),
421 format: None,
422 enum_name: Some("Transaktionsgrund".to_string()),
423 valid_codes: vec![
424 CodeValue {
425 code: "E01".to_string(),
426 meaning: "Ein-/Auszug (Einzug)".to_string(),
427 enum_name: None,
428 },
429 CodeValue {
430 code: "E03".to_string(),
431 meaning: "Wechsel".to_string(),
432 enum_name: None,
433 },
434 ],
435 child_group: None,
436 ref_type: Bo4eRefType::Unknown,
437 },
438 ],
439 },
440 EntityRequirement {
441 entity: "Marktlokation".to_string(),
442 ref_type: Bo4eRefType::Object {
443
444 type_name: "Marktlokation".to_string(),
445
446 cardinality: Cardinality::REQUIRED,
447
448 },
449
450
451 ahb_status: "Muss".to_string(),
452 map_key: None,
453 scope: EntityScope::Transaction,
454 fields: vec![
455 FieldRequirement {
456 bo4e_name: "marktlokationsId".to_string(),
457 ahb_status: "X".to_string(),
458 field_type: "data".to_string(),
459 format: None,
460 enum_name: None,
461 valid_codes: vec![],
462 child_group: None,
463 ref_type: Bo4eRefType::Unknown,
464 },
465 FieldRequirement {
466 bo4e_name: "haushaltskunde".to_string(),
467 ahb_status: "X".to_string(),
468 field_type: "code".to_string(),
469 format: None,
470 enum_name: Some("Haushaltskunde".to_string()),
471 valid_codes: vec![
472 CodeValue {
473 code: "Z15".to_string(),
474 meaning: "Ja".to_string(),
475 enum_name: None,
476 },
477 CodeValue {
478 code: "Z18".to_string(),
479 meaning: "Nein".to_string(),
480 enum_name: None,
481 },
482 ],
483 child_group: None,
484 ref_type: Bo4eRefType::Unknown,
485 },
486 ],
487 },
488 EntityRequirement {
489 entity: "Geschaeftspartner".to_string(),
490 ref_type: Bo4eRefType::Object {
491
492 type_name: "Geschaeftspartner".to_string(),
493
494 cardinality: Cardinality { min: 1, max: Some(7) },
495
496 },
497
498
499 ahb_status: "Muss".to_string(),
500 map_key: None,
501 scope: EntityScope::Transaction,
502 fields: vec![FieldRequirement {
503 bo4e_name: "identifikation".to_string(),
504 ahb_status: "X".to_string(),
505 field_type: "data".to_string(),
506 format: None,
507 enum_name: None,
508 valid_codes: vec![],
509 child_group: None,
510 ref_type: Bo4eRefType::Unknown,
511 }],
512 },
513 ],
514 }
515 }
516
517 #[test]
518 fn test_validate_complete_json() {
519 let reqs = sample_requirements();
520 let json = json!({
521 "prozessdaten": {
522 "vorgangId": "ABC123",
523 "transaktionsgrund": "E01"
524 },
525 "marktlokation": {
526 "marktlokationsId": "51234567890",
527 "haushaltskunde": "Z15"
528 },
529 "geschaeftspartner": [
530 { "identifikation": "9900000000003" }
531 ]
532 });
533
534 let errors = validate_pid_json(&json, &reqs);
535 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
536 }
537
538 #[test]
539 fn test_validate_missing_entity() {
540 let reqs = sample_requirements();
541 let json = json!({
542 "prozessdaten": {
543 "vorgangId": "ABC123",
544 "transaktionsgrund": "E01"
545 },
546 "geschaeftspartner": [
547 { "identifikation": "9900000000003" }
548 ]
549 });
550 let errors = validate_pid_json(&json, &reqs);
553 assert_eq!(errors.len(), 1);
554 match &errors[0] {
555 PidValidationError::MissingEntity {
556 entity,
557 ahb_status,
558 severity,
559 } => {
560 assert_eq!(entity, "Marktlokation");
561 assert_eq!(ahb_status, "Muss");
562 assert_eq!(severity, &Severity::Error);
563 }
564 other => panic!("Expected MissingEntity, got: {other:?}"),
565 }
566
567 let msg = errors[0].to_string();
569 assert!(msg.contains("ERROR"));
570 assert!(msg.contains("Marktlokation"));
571 assert!(msg.contains("Muss"));
572 }
573
574 #[test]
575 fn test_validate_missing_field() {
576 let reqs = sample_requirements();
577 let json = json!({
578 "prozessdaten": {
579 "transaktionsgrund": "E01"
580 },
582 "marktlokation": {
583 "marktlokationsId": "51234567890",
584 "haushaltskunde": "Z15"
585 },
586 "geschaeftspartner": [
587 { "identifikation": "9900000000003" }
588 ]
589 });
590
591 let errors = validate_pid_json(&json, &reqs);
592 assert_eq!(errors.len(), 1);
593 match &errors[0] {
594 PidValidationError::MissingField {
595 entity,
596 field,
597 ahb_status,
598 severity,
599 ..
600 } => {
601 assert_eq!(entity, "Prozessdaten");
602 assert_eq!(field, "vorgangId");
603 assert_eq!(ahb_status, "X");
604 assert_eq!(severity, &Severity::Error);
605 }
606 other => panic!("Expected MissingField, got: {other:?}"),
607 }
608
609 let msg = errors[0].to_string();
610 assert!(msg.contains("ERROR"));
611 assert!(msg.contains("Prozessdaten.vorgangId"));
612 }
613
614 #[test]
615 fn test_validate_invalid_code() {
616 let reqs = sample_requirements();
617 let json = json!({
618 "prozessdaten": {
619 "vorgangId": "ABC123",
620 "transaktionsgrund": "E01"
621 },
622 "marktlokation": {
623 "marktlokationsId": "51234567890",
624 "haushaltskunde": "Z99" },
626 "geschaeftspartner": [
627 { "identifikation": "9900000000003" }
628 ]
629 });
630
631 let errors = validate_pid_json(&json, &reqs);
632 assert_eq!(errors.len(), 1);
633 match &errors[0] {
634 PidValidationError::InvalidCode {
635 entity,
636 field,
637 value,
638 valid_values,
639 } => {
640 assert_eq!(entity, "Marktlokation");
641 assert_eq!(field, "haushaltskunde");
642 assert_eq!(value, "Z99");
643 assert_eq!(valid_values.len(), 2);
644 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
645 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
646 }
647 other => panic!("Expected InvalidCode, got: {other:?}"),
648 }
649
650 let msg = errors[0].to_string();
651 assert!(msg.contains("INVALID"));
652 assert!(msg.contains("Z99"));
653 assert!(msg.contains("Z15"));
654 }
655
656 #[test]
657 fn test_validate_array_entity() {
658 let reqs = sample_requirements();
659 let json = json!({
660 "prozessdaten": {
661 "vorgangId": "ABC123",
662 "transaktionsgrund": "E01"
663 },
664 "marktlokation": {
665 "marktlokationsId": "51234567890",
666 "haushaltskunde": "Z15"
667 },
668 "geschaeftspartner": [
669 { "identifikation": "9900000000003" },
670 { } ]
672 });
673
674 let errors = validate_pid_json(&json, &reqs);
675 assert_eq!(errors.len(), 1);
676 match &errors[0] {
677 PidValidationError::MissingField { entity, field, .. } => {
678 assert_eq!(entity, "Geschaeftspartner");
679 assert_eq!(field, "identifikation");
680 }
681 other => panic!("Expected MissingField, got: {other:?}"),
682 }
683 }
684
685 #[test]
686 fn test_to_camel_case() {
687 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
688 assert_eq!(
689 to_camel_case("RuhendeMarktlokation"),
690 "ruhendeMarktlokation"
691 );
692 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
693 assert_eq!(to_camel_case(""), "");
694 }
695
696 #[test]
697 fn test_snake_to_camel_case() {
698 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
699 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
700 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
701 assert_eq!(snake_to_camel_case(""), "");
702 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
703 }
704
705 #[test]
708 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
709 let reqs = PidRequirements {
710 pid: "55077".to_string(),
711 beschreibung: "Test camelCase fallback".to_string(),
712 entities: vec![EntityRequirement {
713 entity: "Zuordnung".to_string(),
714 ref_type: Bo4eRefType::Object {
715
716 type_name: "Zuordnung".to_string(),
717
718 cardinality: Cardinality::REQUIRED,
719
720 },
721
722
723 ahb_status: "Muss".to_string(),
724 map_key: None,
725 scope: EntityScope::Transaction,
726 fields: vec![
727 FieldRequirement {
728 bo4e_name: "code_codepflege".to_string(),
730 ahb_status: "X".to_string(),
731 field_type: "data".to_string(),
732 format: None,
733 enum_name: None,
734 valid_codes: vec![],
735 child_group: None,
736 ref_type: Bo4eRefType::Unknown,
737 },
738 FieldRequirement {
739 bo4e_name: "codeliste".to_string(),
740 ahb_status: "X".to_string(),
741 field_type: "data".to_string(),
742 format: None,
743 enum_name: None,
744 valid_codes: vec![],
745 child_group: None,
746 ref_type: Bo4eRefType::Unknown,
747 },
748 ],
749 }],
750 };
751
752 let json_camel = json!({
755 "zuordnung": {
756 "codeCodepflege": "DE_BDEW",
757 "codeliste": "6"
758 }
759 });
760
761 let errors = validate_pid_json(&json_camel, &reqs);
762 assert!(
763 errors.is_empty(),
764 "Expected no errors when field is present under camelCase key, got: {errors:?}"
765 );
766
767 let json_snake = json!({
769 "zuordnung": {
770 "code_codepflege": "DE_BDEW",
771 "codeliste": "6"
772 }
773 });
774
775 let errors = validate_pid_json(&json_snake, &reqs);
776 assert!(
777 errors.is_empty(),
778 "Expected no errors when field is present under snake_case key, got: {errors:?}"
779 );
780
781 let json_missing = json!({
783 "zuordnung": {
784 "codeliste": "6"
785 }
786 });
787
788 let errors = validate_pid_json(&json_missing, &reqs);
789 assert_eq!(errors.len(), 1);
790 match &errors[0] {
791 PidValidationError::MissingField { field, .. } => {
792 assert_eq!(field, "code_codepflege");
793 }
794 other => panic!("Expected MissingField, got: {other:?}"),
795 }
796 }
797
798 #[test]
799 fn test_is_unconditionally_required() {
800 assert!(is_unconditionally_required("X"));
801 assert!(is_unconditionally_required("Muss"));
802 assert!(is_unconditionally_required("Soll"));
803 assert!(!is_unconditionally_required("Kann"));
804 assert!(!is_unconditionally_required("[1]"));
805 assert!(!is_unconditionally_required(""));
806 }
807
808 #[test]
809 fn test_validation_report_display() {
810 let errors = vec![
811 PidValidationError::MissingEntity {
812 entity: "Marktlokation".to_string(),
813 ahb_status: "Muss".to_string(),
814 severity: Severity::Error,
815 },
816 PidValidationError::MissingField {
817 entity: "Prozessdaten".to_string(),
818 field: "vorgangId".to_string(),
819 ahb_status: "X".to_string(),
820 rust_type: None,
821 valid_values: vec![],
822 severity: Severity::Error,
823 },
824 ];
825 let report = ValidationReport(errors);
826 assert!(report.has_errors());
827 assert_eq!(report.len(), 2);
828 assert!(!report.is_empty());
829
830 let display = report.to_string();
831 assert!(display.contains("missing entity 'Marktlokation'"));
832 assert!(display.contains("missing Prozessdaten.vorgangId"));
833 }
834
835 #[test]
836 fn test_missing_field_with_type_and_values_display() {
837 let err = PidValidationError::MissingField {
838 entity: "Marktlokation".to_string(),
839 field: "haushaltskunde".to_string(),
840 ahb_status: "Muss".to_string(),
841 rust_type: Some("Haushaltskunde".to_string()),
842 valid_values: vec![
843 ("Z15".to_string(), "Ja".to_string()),
844 ("Z18".to_string(), "Nein".to_string()),
845 ],
846 severity: Severity::Error,
847 };
848 let msg = err.to_string();
849 assert!(msg.contains("type: Haushaltskunde"));
850 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
851 }
852
853 #[test]
854 fn test_optional_fields_not_flagged() {
855 let reqs = PidRequirements {
856 pid: "99999".to_string(),
857 beschreibung: "Test".to_string(),
858 entities: vec![EntityRequirement {
859 entity: "Test".to_string(),
860 ref_type: Bo4eRefType::Object {
861
862 type_name: "Test".to_string(),
863
864 cardinality: Cardinality::OPTIONAL,
865
866 },
867
868
869 ahb_status: "Kann".to_string(),
870 map_key: None,
871 scope: EntityScope::Transaction,
872 fields: vec![FieldRequirement {
873 bo4e_name: "optionalField".to_string(),
874 ahb_status: "Kann".to_string(),
875 field_type: "data".to_string(),
876 format: None,
877 enum_name: None,
878 valid_codes: vec![],
879 child_group: None,
880 ref_type: Bo4eRefType::Unknown,
881 }],
882 }],
883 };
884
885 let errors = validate_pid_json(&json!({}), &reqs);
887 assert!(errors.is_empty());
888
889 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
891 assert!(errors.is_empty());
892 }
893
894 #[test]
897 fn test_nested_dot_path_fields_not_falsely_missing() {
898 let reqs = PidRequirements {
899 pid: "55001".to_string(),
900 beschreibung: "Test nested paths".to_string(),
901 entities: vec![EntityRequirement {
902 entity: "ProduktpaketDaten".to_string(),
903 ref_type: Bo4eRefType::Object {
904
905 type_name: "ProduktpaketDaten".to_string(),
906
907 cardinality: Cardinality { min: 1, max: Some(99999) },
908
909 },
910
911
912 ahb_status: "Muss".to_string(),
913 map_key: None,
914 scope: EntityScope::Transaction,
915 fields: vec![
916 FieldRequirement {
917 bo4e_name: "produktIdentifikation.funktion".to_string(),
918 ahb_status: "X".to_string(),
919 field_type: "code".to_string(),
920 format: None,
921 enum_name: Some("Produktidentifikation".to_string()),
922 valid_codes: vec![CodeValue {
923 code: "5".to_string(),
924 meaning: "Produktidentifikation".to_string(),
925 enum_name: None,
926 }],
927 child_group: None,
928 ref_type: Bo4eRefType::Unknown,
929 },
930 FieldRequirement {
931 bo4e_name: "produktMerkmal.code".to_string(),
932 ahb_status: "X".to_string(),
933 field_type: "code".to_string(),
934 format: None,
935 enum_name: None,
936 valid_codes: vec![],
937 child_group: None,
938 ref_type: Bo4eRefType::Unknown,
939 },
940 ],
941 }],
942 };
943
944 let json = json!({
946 "produktpaketDaten": [{
947 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
948 "produktMerkmal": { "code": "ZH9" }
949 }]
950 });
951
952 let errors = validate_pid_json(&json, &reqs);
953 assert!(
954 errors.is_empty(),
955 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
956 );
957 }
958
959 #[test]
960 fn test_nested_dot_path_truly_missing() {
961 let reqs = PidRequirements {
962 pid: "55001".to_string(),
963 beschreibung: "Test nested paths missing".to_string(),
964 entities: vec![EntityRequirement {
965 entity: "ProduktpaketDaten".to_string(),
966 ref_type: Bo4eRefType::Object {
967
968 type_name: "ProduktpaketDaten".to_string(),
969
970 cardinality: Cardinality { min: 1, max: Some(99999) },
971
972 },
973
974
975 ahb_status: "Muss".to_string(),
976 map_key: None,
977 scope: EntityScope::Transaction,
978 fields: vec![FieldRequirement {
979 bo4e_name: "produktIdentifikation.funktion".to_string(),
980 ahb_status: "X".to_string(),
981 field_type: "data".to_string(),
982 format: None,
983 enum_name: None,
984 valid_codes: vec![],
985 child_group: None,
986 ref_type: Bo4eRefType::Unknown,
987 }],
988 }],
989 };
990
991 let json = json!({
993 "produktpaketDaten": [{
994 "produktIdentifikation": { "id": "123" }
995 }]
996 });
997
998 let errors = validate_pid_json(&json, &reqs);
999 assert_eq!(errors.len(), 1, "Should report missing nested field");
1000 match &errors[0] {
1001 PidValidationError::MissingField { field, .. } => {
1002 assert_eq!(field, "produktIdentifikation.funktion");
1003 }
1004 other => panic!("Expected MissingField, got: {other:?}"),
1005 }
1006 }
1007}