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 => {
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.is_array {
228 if let Some(arr) = val.as_array() {
229 for element in arr {
230 validate_entity_fields(element, entity_req, &mut errors);
231 }
232 }
233 } else {
234 validate_entity_fields(val, entity_req, &mut errors);
235 }
236 }
237 }
238 }
239
240 errors
241}
242
243fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
246 let mut current = json;
247 for part in path.split('.') {
248 current = current.get(part).or_else(|| {
249 if part.contains('_') {
250 current.get(snake_to_camel_case(part))
251 } else {
252 None
253 }
254 })?;
255 }
256 Some(current)
257}
258
259fn validate_entity_fields(
261 entity_json: &Value,
262 entity_req: &EntityRequirement,
263 errors: &mut Vec<PidValidationError>,
264) {
265 for field_req in &entity_req.fields {
266 let val = get_nested(entity_json, &field_req.bo4e_name).or_else(|| {
272 if field_req.is_companion {
273 if let Some(ref companion_type) = entity_req.companion_type {
274 let companion_key = to_camel_case(companion_type);
275 let companion_obj = entity_json.get(&companion_key)?;
276 get_nested(companion_obj, &field_req.bo4e_name)
277 } else {
278 None
279 }
280 } else {
281 None
282 }
283 });
284
285 let val = val.filter(|v| !v.is_null());
287
288 match val {
289 None => {
290 if is_unconditionally_required(&field_req.ahb_status) {
291 errors.push(PidValidationError::MissingField {
292 entity: entity_req.entity.clone(),
293 field: field_req.bo4e_name.clone(),
294 ahb_status: field_req.ahb_status.clone(),
295 rust_type: field_req.enum_name.clone(),
296 valid_values: code_values_to_tuples(&field_req.valid_codes),
297 severity: Severity::Error,
298 });
299 }
300 }
301 Some(val) => {
302 if !field_req.valid_codes.is_empty() {
303 validate_code_value(val, entity_req, field_req, errors);
304 }
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 let value_str = match val.as_str() {
318 Some(s) => s,
319 None => return, };
321
322 let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
323 if !is_valid {
324 errors.push(PidValidationError::InvalidCode {
325 entity: entity_req.entity.clone(),
326 field: field_req.bo4e_name.clone(),
327 value: value_str.to_string(),
328 valid_values: code_values_to_tuples(&field_req.valid_codes),
329 });
330 }
331}
332
333fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
335 codes
336 .iter()
337 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
338 .collect()
339}
340
341fn to_camel_case(s: &str) -> String {
347 if s.is_empty() {
348 return String::new();
349 }
350 let mut chars = s.chars();
351 let first = chars.next().unwrap();
352 let mut result = first.to_lowercase().to_string();
353 result.extend(chars);
354 result
355}
356
357fn snake_to_camel_case(s: &str) -> String {
368 let mut result = String::with_capacity(s.len());
369 let mut capitalize_next = false;
370 for ch in s.chars() {
371 if ch == '_' {
372 capitalize_next = true;
373 } else if capitalize_next {
374 result.extend(ch.to_uppercase());
375 capitalize_next = false;
376 } else {
377 result.push(ch);
378 }
379 }
380 result
381}
382
383fn is_unconditionally_required(ahb_status: &str) -> bool {
385 matches!(ahb_status, "X" | "Muss" | "Soll")
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::pid_requirements::{
392 CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
393 };
394 use serde_json::json;
395
396 fn sample_requirements() -> PidRequirements {
397 PidRequirements {
398 pid: "55001".to_string(),
399 beschreibung: "Anmeldung verb. MaLo".to_string(),
400 entities: vec![
401 EntityRequirement {
402 entity: "Prozessdaten".to_string(),
403 bo4e_type: "Prozessdaten".to_string(),
404 companion_type: None,
405 ahb_status: "Muss".to_string(),
406 is_array: false,
407 map_key: None,
408 scope: EntityScope::Transaction,
409 fields: vec![
410 FieldRequirement {
411 bo4e_name: "vorgangId".to_string(),
412 ahb_status: "X".to_string(),
413 is_companion: false,
414 field_type: "data".to_string(),
415 format: None,
416 enum_name: None,
417 valid_codes: vec![],
418 child_group: None,
419 },
420 FieldRequirement {
421 bo4e_name: "transaktionsgrund".to_string(),
422 ahb_status: "X".to_string(),
423 is_companion: false,
424 field_type: "code".to_string(),
425 format: None,
426 enum_name: Some("Transaktionsgrund".to_string()),
427 valid_codes: vec![
428 CodeValue {
429 code: "E01".to_string(),
430 meaning: "Ein-/Auszug (Einzug)".to_string(),
431 enum_name: None,
432 },
433 CodeValue {
434 code: "E03".to_string(),
435 meaning: "Wechsel".to_string(),
436 enum_name: None,
437 },
438 ],
439 child_group: None,
440 },
441 ],
442 },
443 EntityRequirement {
444 entity: "Marktlokation".to_string(),
445 bo4e_type: "Marktlokation".to_string(),
446 companion_type: Some("MarktlokationEdifact".to_string()),
447 ahb_status: "Muss".to_string(),
448 is_array: false,
449 map_key: None,
450 scope: EntityScope::Transaction,
451 fields: vec![
452 FieldRequirement {
453 bo4e_name: "marktlokationsId".to_string(),
454 ahb_status: "X".to_string(),
455 is_companion: false,
456 field_type: "data".to_string(),
457 format: None,
458 enum_name: None,
459 valid_codes: vec![],
460 child_group: None,
461 },
462 FieldRequirement {
463 bo4e_name: "haushaltskunde".to_string(),
464 ahb_status: "X".to_string(),
465 is_companion: false,
466 field_type: "code".to_string(),
467 format: None,
468 enum_name: Some("Haushaltskunde".to_string()),
469 valid_codes: vec![
470 CodeValue {
471 code: "Z15".to_string(),
472 meaning: "Ja".to_string(),
473 enum_name: None,
474 },
475 CodeValue {
476 code: "Z18".to_string(),
477 meaning: "Nein".to_string(),
478 enum_name: None,
479 },
480 ],
481 child_group: None,
482 },
483 ],
484 },
485 EntityRequirement {
486 entity: "Geschaeftspartner".to_string(),
487 bo4e_type: "Geschaeftspartner".to_string(),
488 companion_type: Some("GeschaeftspartnerEdifact".to_string()),
489 ahb_status: "Muss".to_string(),
490 is_array: true,
491 map_key: None,
492 scope: EntityScope::Transaction,
493 fields: vec![FieldRequirement {
494 bo4e_name: "identifikation".to_string(),
495 ahb_status: "X".to_string(),
496 is_companion: false,
497 field_type: "data".to_string(),
498 format: None,
499 enum_name: None,
500 valid_codes: vec![],
501 child_group: None,
502 }],
503 },
504 ],
505 }
506 }
507
508 #[test]
509 fn test_validate_complete_json() {
510 let reqs = sample_requirements();
511 let json = json!({
512 "prozessdaten": {
513 "vorgangId": "ABC123",
514 "transaktionsgrund": "E01"
515 },
516 "marktlokation": {
517 "marktlokationsId": "51234567890",
518 "haushaltskunde": "Z15"
519 },
520 "geschaeftspartner": [
521 { "identifikation": "9900000000003" }
522 ]
523 });
524
525 let errors = validate_pid_json(&json, &reqs);
526 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
527 }
528
529 #[test]
530 fn test_validate_missing_entity() {
531 let reqs = sample_requirements();
532 let json = json!({
533 "prozessdaten": {
534 "vorgangId": "ABC123",
535 "transaktionsgrund": "E01"
536 },
537 "geschaeftspartner": [
538 { "identifikation": "9900000000003" }
539 ]
540 });
541 let errors = validate_pid_json(&json, &reqs);
544 assert_eq!(errors.len(), 1);
545 match &errors[0] {
546 PidValidationError::MissingEntity {
547 entity,
548 ahb_status,
549 severity,
550 } => {
551 assert_eq!(entity, "Marktlokation");
552 assert_eq!(ahb_status, "Muss");
553 assert_eq!(severity, &Severity::Error);
554 }
555 other => panic!("Expected MissingEntity, got: {other:?}"),
556 }
557
558 let msg = errors[0].to_string();
560 assert!(msg.contains("ERROR"));
561 assert!(msg.contains("Marktlokation"));
562 assert!(msg.contains("Muss"));
563 }
564
565 #[test]
566 fn test_validate_missing_field() {
567 let reqs = sample_requirements();
568 let json = json!({
569 "prozessdaten": {
570 "transaktionsgrund": "E01"
571 },
573 "marktlokation": {
574 "marktlokationsId": "51234567890",
575 "haushaltskunde": "Z15"
576 },
577 "geschaeftspartner": [
578 { "identifikation": "9900000000003" }
579 ]
580 });
581
582 let errors = validate_pid_json(&json, &reqs);
583 assert_eq!(errors.len(), 1);
584 match &errors[0] {
585 PidValidationError::MissingField {
586 entity,
587 field,
588 ahb_status,
589 severity,
590 ..
591 } => {
592 assert_eq!(entity, "Prozessdaten");
593 assert_eq!(field, "vorgangId");
594 assert_eq!(ahb_status, "X");
595 assert_eq!(severity, &Severity::Error);
596 }
597 other => panic!("Expected MissingField, got: {other:?}"),
598 }
599
600 let msg = errors[0].to_string();
601 assert!(msg.contains("ERROR"));
602 assert!(msg.contains("Prozessdaten.vorgangId"));
603 }
604
605 #[test]
606 fn test_validate_invalid_code() {
607 let reqs = sample_requirements();
608 let json = json!({
609 "prozessdaten": {
610 "vorgangId": "ABC123",
611 "transaktionsgrund": "E01"
612 },
613 "marktlokation": {
614 "marktlokationsId": "51234567890",
615 "haushaltskunde": "Z99" },
617 "geschaeftspartner": [
618 { "identifikation": "9900000000003" }
619 ]
620 });
621
622 let errors = validate_pid_json(&json, &reqs);
623 assert_eq!(errors.len(), 1);
624 match &errors[0] {
625 PidValidationError::InvalidCode {
626 entity,
627 field,
628 value,
629 valid_values,
630 } => {
631 assert_eq!(entity, "Marktlokation");
632 assert_eq!(field, "haushaltskunde");
633 assert_eq!(value, "Z99");
634 assert_eq!(valid_values.len(), 2);
635 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
636 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
637 }
638 other => panic!("Expected InvalidCode, got: {other:?}"),
639 }
640
641 let msg = errors[0].to_string();
642 assert!(msg.contains("INVALID"));
643 assert!(msg.contains("Z99"));
644 assert!(msg.contains("Z15"));
645 }
646
647 #[test]
648 fn test_validate_array_entity() {
649 let reqs = sample_requirements();
650 let json = json!({
651 "prozessdaten": {
652 "vorgangId": "ABC123",
653 "transaktionsgrund": "E01"
654 },
655 "marktlokation": {
656 "marktlokationsId": "51234567890",
657 "haushaltskunde": "Z15"
658 },
659 "geschaeftspartner": [
660 { "identifikation": "9900000000003" },
661 { } ]
663 });
664
665 let errors = validate_pid_json(&json, &reqs);
666 assert_eq!(errors.len(), 1);
667 match &errors[0] {
668 PidValidationError::MissingField { entity, field, .. } => {
669 assert_eq!(entity, "Geschaeftspartner");
670 assert_eq!(field, "identifikation");
671 }
672 other => panic!("Expected MissingField, got: {other:?}"),
673 }
674 }
675
676 #[test]
677 fn test_to_camel_case() {
678 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
679 assert_eq!(
680 to_camel_case("RuhendeMarktlokation"),
681 "ruhendeMarktlokation"
682 );
683 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
684 assert_eq!(to_camel_case(""), "");
685 }
686
687 #[test]
688 fn test_snake_to_camel_case() {
689 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
690 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
691 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
692 assert_eq!(snake_to_camel_case(""), "");
693 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
694 }
695
696 #[test]
699 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
700 let reqs = PidRequirements {
701 pid: "55077".to_string(),
702 beschreibung: "Test camelCase fallback".to_string(),
703 entities: vec![EntityRequirement {
704 entity: "Zuordnung".to_string(),
705 bo4e_type: "Zuordnung".to_string(),
706 companion_type: None,
707 ahb_status: "Muss".to_string(),
708 is_array: false,
709 map_key: None,
710 scope: EntityScope::Transaction,
711 fields: vec![
712 FieldRequirement {
713 bo4e_name: "code_codepflege".to_string(),
715 ahb_status: "X".to_string(),
716 is_companion: false,
717 field_type: "data".to_string(),
718 format: None,
719 enum_name: None,
720 valid_codes: vec![],
721 child_group: None,
722 },
723 FieldRequirement {
724 bo4e_name: "codeliste".to_string(),
725 ahb_status: "X".to_string(),
726 is_companion: false,
727 field_type: "data".to_string(),
728 format: None,
729 enum_name: None,
730 valid_codes: vec![],
731 child_group: None,
732 },
733 ],
734 }],
735 };
736
737 let json_camel = json!({
740 "zuordnung": {
741 "codeCodepflege": "DE_BDEW",
742 "codeliste": "6"
743 }
744 });
745
746 let errors = validate_pid_json(&json_camel, &reqs);
747 assert!(
748 errors.is_empty(),
749 "Expected no errors when field is present under camelCase key, got: {errors:?}"
750 );
751
752 let json_snake = json!({
754 "zuordnung": {
755 "code_codepflege": "DE_BDEW",
756 "codeliste": "6"
757 }
758 });
759
760 let errors = validate_pid_json(&json_snake, &reqs);
761 assert!(
762 errors.is_empty(),
763 "Expected no errors when field is present under snake_case key, got: {errors:?}"
764 );
765
766 let json_missing = json!({
768 "zuordnung": {
769 "codeliste": "6"
770 }
771 });
772
773 let errors = validate_pid_json(&json_missing, &reqs);
774 assert_eq!(errors.len(), 1);
775 match &errors[0] {
776 PidValidationError::MissingField { field, .. } => {
777 assert_eq!(field, "code_codepflege");
778 }
779 other => panic!("Expected MissingField, got: {other:?}"),
780 }
781 }
782
783 #[test]
784 fn test_is_unconditionally_required() {
785 assert!(is_unconditionally_required("X"));
786 assert!(is_unconditionally_required("Muss"));
787 assert!(is_unconditionally_required("Soll"));
788 assert!(!is_unconditionally_required("Kann"));
789 assert!(!is_unconditionally_required("[1]"));
790 assert!(!is_unconditionally_required(""));
791 }
792
793 #[test]
794 fn test_validation_report_display() {
795 let errors = vec![
796 PidValidationError::MissingEntity {
797 entity: "Marktlokation".to_string(),
798 ahb_status: "Muss".to_string(),
799 severity: Severity::Error,
800 },
801 PidValidationError::MissingField {
802 entity: "Prozessdaten".to_string(),
803 field: "vorgangId".to_string(),
804 ahb_status: "X".to_string(),
805 rust_type: None,
806 valid_values: vec![],
807 severity: Severity::Error,
808 },
809 ];
810 let report = ValidationReport(errors);
811 assert!(report.has_errors());
812 assert_eq!(report.len(), 2);
813 assert!(!report.is_empty());
814
815 let display = report.to_string();
816 assert!(display.contains("missing entity 'Marktlokation'"));
817 assert!(display.contains("missing Prozessdaten.vorgangId"));
818 }
819
820 #[test]
821 fn test_missing_field_with_type_and_values_display() {
822 let err = PidValidationError::MissingField {
823 entity: "Marktlokation".to_string(),
824 field: "haushaltskunde".to_string(),
825 ahb_status: "Muss".to_string(),
826 rust_type: Some("Haushaltskunde".to_string()),
827 valid_values: vec![
828 ("Z15".to_string(), "Ja".to_string()),
829 ("Z18".to_string(), "Nein".to_string()),
830 ],
831 severity: Severity::Error,
832 };
833 let msg = err.to_string();
834 assert!(msg.contains("type: Haushaltskunde"));
835 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
836 }
837
838 #[test]
839 fn test_optional_fields_not_flagged() {
840 let reqs = PidRequirements {
841 pid: "99999".to_string(),
842 beschreibung: "Test".to_string(),
843 entities: vec![EntityRequirement {
844 entity: "Test".to_string(),
845 bo4e_type: "Test".to_string(),
846 companion_type: None,
847 ahb_status: "Kann".to_string(),
848 is_array: false,
849 map_key: None,
850 scope: EntityScope::Transaction,
851 fields: vec![FieldRequirement {
852 bo4e_name: "optionalField".to_string(),
853 ahb_status: "Kann".to_string(),
854 is_companion: false,
855 field_type: "data".to_string(),
856 format: None,
857 enum_name: None,
858 valid_codes: vec![],
859 child_group: None,
860 }],
861 }],
862 };
863
864 let errors = validate_pid_json(&json!({}), &reqs);
866 assert!(errors.is_empty());
867
868 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
870 assert!(errors.is_empty());
871 }
872
873 #[test]
876 fn test_nested_dot_path_fields_not_falsely_missing() {
877 let reqs = PidRequirements {
878 pid: "55001".to_string(),
879 beschreibung: "Test nested paths".to_string(),
880 entities: vec![EntityRequirement {
881 entity: "ProduktpaketDaten".to_string(),
882 bo4e_type: "ProduktpaketDaten".to_string(),
883 companion_type: None,
884 ahb_status: "Muss".to_string(),
885 is_array: true,
886 map_key: None,
887 scope: EntityScope::Transaction,
888 fields: vec![
889 FieldRequirement {
890 bo4e_name: "produktIdentifikation.funktion".to_string(),
891 ahb_status: "X".to_string(),
892 is_companion: false,
893 field_type: "code".to_string(),
894 format: None,
895 enum_name: Some("Produktidentifikation".to_string()),
896 valid_codes: vec![CodeValue {
897 code: "5".to_string(),
898 meaning: "Produktidentifikation".to_string(),
899 enum_name: None,
900 }],
901 child_group: None,
902 },
903 FieldRequirement {
904 bo4e_name: "produktMerkmal.code".to_string(),
905 ahb_status: "X".to_string(),
906 is_companion: false,
907 field_type: "code".to_string(),
908 format: None,
909 enum_name: None,
910 valid_codes: vec![],
911 child_group: None,
912 },
913 ],
914 }],
915 };
916
917 let json = json!({
919 "produktpaketDaten": [{
920 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
921 "produktMerkmal": { "code": "ZH9" }
922 }]
923 });
924
925 let errors = validate_pid_json(&json, &reqs);
926 assert!(
927 errors.is_empty(),
928 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
929 );
930 }
931
932 #[test]
933 fn test_nested_dot_path_truly_missing() {
934 let reqs = PidRequirements {
935 pid: "55001".to_string(),
936 beschreibung: "Test nested paths missing".to_string(),
937 entities: vec![EntityRequirement {
938 entity: "ProduktpaketDaten".to_string(),
939 bo4e_type: "ProduktpaketDaten".to_string(),
940 companion_type: None,
941 ahb_status: "Muss".to_string(),
942 is_array: true,
943 map_key: None,
944 scope: EntityScope::Transaction,
945 fields: vec![FieldRequirement {
946 bo4e_name: "produktIdentifikation.funktion".to_string(),
947 ahb_status: "X".to_string(),
948 is_companion: false,
949 field_type: "data".to_string(),
950 format: None,
951 enum_name: None,
952 valid_codes: vec![],
953 child_group: None,
954 }],
955 }],
956 };
957
958 let json = json!({
960 "produktpaketDaten": [{
961 "produktIdentifikation": { "id": "123" }
962 }]
963 });
964
965 let errors = validate_pid_json(&json, &reqs);
966 assert_eq!(errors.len(), 1, "Should report missing nested field");
967 match &errors[0] {
968 PidValidationError::MissingField { field, .. } => {
969 assert_eq!(field, "produktIdentifikation.funktion");
970 }
971 other => panic!("Expected MissingField, got: {other:?}"),
972 }
973 }
974}