1use std::fmt;
4
5use serde_json::Value;
6
7use crate::pid_requirements::{CodeValue, EntityRequirement, FieldRequirement, PidRequirements};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12 Error,
14 Warning,
16}
17
18#[derive(Debug, Clone)]
20pub enum PidValidationError {
21 MissingEntity {
23 entity: String,
24 ahb_status: String,
25 severity: Severity,
26 },
27 MissingField {
29 entity: String,
30 field: String,
31 ahb_status: String,
32 rust_type: Option<String>,
33 valid_values: Vec<(String, String)>,
34 severity: Severity,
35 },
36 InvalidCode {
38 entity: String,
39 field: String,
40 value: String,
41 valid_values: Vec<(String, String)>,
42 },
43}
44
45impl PidValidationError {
46 pub fn severity(&self) -> &Severity {
47 match self {
48 Self::MissingEntity { severity, .. } => severity,
49 Self::MissingField { severity, .. } => severity,
50 Self::InvalidCode { .. } => &Severity::Error,
51 }
52 }
53
54 pub fn is_error(&self) -> bool {
55 matches!(self.severity(), Severity::Error)
56 }
57}
58
59impl fmt::Display for PidValidationError {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 PidValidationError::MissingEntity {
63 entity,
64 ahb_status,
65 severity,
66 } => {
67 let label = severity_label(severity);
68 write!(
69 f,
70 "{label}: missing entity '{entity}' (required: {ahb_status})"
71 )
72 }
73 PidValidationError::MissingField {
74 entity,
75 field,
76 ahb_status,
77 rust_type,
78 valid_values,
79 severity,
80 } => {
81 let label = severity_label(severity);
82 write!(
83 f,
84 "{label}: missing {entity}.{field} (required: {ahb_status})"
85 )?;
86 if let Some(rt) = rust_type {
87 write!(f, "\n → type: {rt}")?;
88 }
89 if !valid_values.is_empty() {
90 let codes: Vec<String> = valid_values
91 .iter()
92 .map(|(code, meaning)| {
93 if meaning.is_empty() {
94 code.clone()
95 } else {
96 format!("{code} ({meaning})")
97 }
98 })
99 .collect();
100 write!(f, "\n → valid: {}", codes.join(", "))?;
101 }
102 Ok(())
103 }
104 PidValidationError::InvalidCode {
105 entity,
106 field,
107 value,
108 valid_values,
109 } => {
110 write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
111 if !valid_values.is_empty() {
112 let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
113 write!(f, "\n → valid: {}", codes.join(", "))?;
114 }
115 Ok(())
116 }
117 }
118 }
119}
120
121fn severity_label(severity: &Severity) -> &'static str {
122 match severity {
123 Severity::Error => "ERROR",
124 Severity::Warning => "WARNING",
125 }
126}
127
128pub struct ValidationReport(pub Vec<PidValidationError>);
130
131impl ValidationReport {
132 pub fn has_errors(&self) -> bool {
134 self.0.iter().any(|e| e.is_error())
135 }
136
137 pub fn errors(&self) -> Vec<&PidValidationError> {
139 self.0.iter().filter(|e| e.is_error()).collect()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.0.is_empty()
145 }
146
147 pub fn len(&self) -> usize {
149 self.0.len()
150 }
151}
152
153impl fmt::Display for ValidationReport {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 for (i, err) in self.0.iter().enumerate() {
156 if i > 0 {
157 writeln!(f)?;
158 }
159 write!(f, "{err}")?;
160 }
161 Ok(())
162 }
163}
164
165pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
174 let mut errors = Vec::new();
175
176 for entity_req in &requirements.entities {
177 let key = to_camel_case(&entity_req.entity);
178
179 match json.get(&key) {
180 None => {
181 if is_unconditionally_required(&entity_req.ahb_status) {
182 errors.push(PidValidationError::MissingEntity {
183 entity: entity_req.entity.clone(),
184 ahb_status: entity_req.ahb_status.clone(),
185 severity: Severity::Error,
186 });
187 }
188 }
189 Some(val) => {
190 if entity_req.is_array {
191 if let Some(arr) = val.as_array() {
192 for element in arr {
193 validate_entity_fields(element, entity_req, &mut errors);
194 }
195 }
196 } else {
197 validate_entity_fields(val, entity_req, &mut errors);
198 }
199 }
200 }
201 }
202
203 errors
204}
205
206fn validate_entity_fields(
208 entity_json: &Value,
209 entity_req: &EntityRequirement,
210 errors: &mut Vec<PidValidationError>,
211) {
212 for field_req in &entity_req.fields {
213 let val = entity_json.get(&field_req.bo4e_name).or_else(|| {
218 if field_req.bo4e_name.contains('_') {
219 entity_json.get(&snake_to_camel_case(&field_req.bo4e_name))
220 } else {
221 None
222 }
223 });
224
225 match val {
226 None => {
227 if is_unconditionally_required(&field_req.ahb_status) {
228 errors.push(PidValidationError::MissingField {
229 entity: entity_req.entity.clone(),
230 field: field_req.bo4e_name.clone(),
231 ahb_status: field_req.ahb_status.clone(),
232 rust_type: field_req.enum_name.clone(),
233 valid_values: code_values_to_tuples(&field_req.valid_codes),
234 severity: Severity::Error,
235 });
236 }
237 }
238 Some(val) => {
239 if !field_req.valid_codes.is_empty() {
240 validate_code_value(val, entity_req, field_req, errors);
241 }
242 }
243 }
244 }
245}
246
247fn validate_code_value(
249 val: &Value,
250 entity_req: &EntityRequirement,
251 field_req: &FieldRequirement,
252 errors: &mut Vec<PidValidationError>,
253) {
254 let value_str = match val.as_str() {
255 Some(s) => s,
256 None => return, };
258
259 let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
260 if !is_valid {
261 errors.push(PidValidationError::InvalidCode {
262 entity: entity_req.entity.clone(),
263 field: field_req.bo4e_name.clone(),
264 value: value_str.to_string(),
265 valid_values: code_values_to_tuples(&field_req.valid_codes),
266 });
267 }
268}
269
270fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
272 codes
273 .iter()
274 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
275 .collect()
276}
277
278fn to_camel_case(s: &str) -> String {
284 if s.is_empty() {
285 return String::new();
286 }
287 let mut chars = s.chars();
288 let first = chars.next().unwrap();
289 let mut result = first.to_lowercase().to_string();
290 result.extend(chars);
291 result
292}
293
294fn snake_to_camel_case(s: &str) -> String {
305 let mut result = String::with_capacity(s.len());
306 let mut capitalize_next = false;
307 for ch in s.chars() {
308 if ch == '_' {
309 capitalize_next = true;
310 } else if capitalize_next {
311 result.extend(ch.to_uppercase());
312 capitalize_next = false;
313 } else {
314 result.push(ch);
315 }
316 }
317 result
318}
319
320fn is_unconditionally_required(ahb_status: &str) -> bool {
322 matches!(ahb_status, "X" | "Muss" | "Soll")
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::pid_requirements::{
329 CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
330 };
331 use serde_json::json;
332
333 fn sample_requirements() -> PidRequirements {
334 PidRequirements {
335 pid: "55001".to_string(),
336 beschreibung: "Anmeldung verb. MaLo".to_string(),
337 entities: vec![
338 EntityRequirement {
339 entity: "Prozessdaten".to_string(),
340 bo4e_type: "Prozessdaten".to_string(),
341 companion_type: None,
342 ahb_status: "Muss".to_string(),
343 is_array: false,
344 map_key: None,
345 fields: vec![
346 FieldRequirement {
347 bo4e_name: "vorgangId".to_string(),
348 ahb_status: "X".to_string(),
349 is_companion: false,
350 field_type: "data".to_string(),
351 format: None,
352 enum_name: None,
353 valid_codes: vec![],
354 child_group: None,
355 },
356 FieldRequirement {
357 bo4e_name: "transaktionsgrund".to_string(),
358 ahb_status: "X".to_string(),
359 is_companion: false,
360 field_type: "code".to_string(),
361 format: None,
362 enum_name: Some("Transaktionsgrund".to_string()),
363 valid_codes: vec![
364 CodeValue {
365 code: "E01".to_string(),
366 meaning: "Ein-/Auszug (Einzug)".to_string(),
367 enum_name: None,
368 },
369 CodeValue {
370 code: "E03".to_string(),
371 meaning: "Wechsel".to_string(),
372 enum_name: None,
373 },
374 ],
375 child_group: None,
376 },
377 ],
378 },
379 EntityRequirement {
380 entity: "Marktlokation".to_string(),
381 bo4e_type: "Marktlokation".to_string(),
382 companion_type: Some("MarktlokationEdifact".to_string()),
383 ahb_status: "Muss".to_string(),
384 is_array: false,
385 map_key: None,
386 fields: vec![
387 FieldRequirement {
388 bo4e_name: "marktlokationsId".to_string(),
389 ahb_status: "X".to_string(),
390 is_companion: false,
391 field_type: "data".to_string(),
392 format: None,
393 enum_name: None,
394 valid_codes: vec![],
395 child_group: None,
396 },
397 FieldRequirement {
398 bo4e_name: "haushaltskunde".to_string(),
399 ahb_status: "X".to_string(),
400 is_companion: false,
401 field_type: "code".to_string(),
402 format: None,
403 enum_name: Some("Haushaltskunde".to_string()),
404 valid_codes: vec![
405 CodeValue {
406 code: "Z15".to_string(),
407 meaning: "Ja".to_string(),
408 enum_name: None,
409 },
410 CodeValue {
411 code: "Z18".to_string(),
412 meaning: "Nein".to_string(),
413 enum_name: None,
414 },
415 ],
416 child_group: None,
417 },
418 ],
419 },
420 EntityRequirement {
421 entity: "Geschaeftspartner".to_string(),
422 bo4e_type: "Geschaeftspartner".to_string(),
423 companion_type: Some("GeschaeftspartnerEdifact".to_string()),
424 ahb_status: "Muss".to_string(),
425 is_array: true,
426 map_key: None,
427 fields: vec![FieldRequirement {
428 bo4e_name: "identifikation".to_string(),
429 ahb_status: "X".to_string(),
430 is_companion: false,
431 field_type: "data".to_string(),
432 format: None,
433 enum_name: None,
434 valid_codes: vec![],
435 child_group: None,
436 }],
437 },
438 ],
439 }
440 }
441
442 #[test]
443 fn test_validate_complete_json() {
444 let reqs = sample_requirements();
445 let json = json!({
446 "prozessdaten": {
447 "vorgangId": "ABC123",
448 "transaktionsgrund": "E01"
449 },
450 "marktlokation": {
451 "marktlokationsId": "51234567890",
452 "haushaltskunde": "Z15"
453 },
454 "geschaeftspartner": [
455 { "identifikation": "9900000000003" }
456 ]
457 });
458
459 let errors = validate_pid_json(&json, &reqs);
460 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
461 }
462
463 #[test]
464 fn test_validate_missing_entity() {
465 let reqs = sample_requirements();
466 let json = json!({
467 "prozessdaten": {
468 "vorgangId": "ABC123",
469 "transaktionsgrund": "E01"
470 },
471 "geschaeftspartner": [
472 { "identifikation": "9900000000003" }
473 ]
474 });
475 let errors = validate_pid_json(&json, &reqs);
478 assert_eq!(errors.len(), 1);
479 match &errors[0] {
480 PidValidationError::MissingEntity {
481 entity,
482 ahb_status,
483 severity,
484 } => {
485 assert_eq!(entity, "Marktlokation");
486 assert_eq!(ahb_status, "Muss");
487 assert_eq!(severity, &Severity::Error);
488 }
489 other => panic!("Expected MissingEntity, got: {other:?}"),
490 }
491
492 let msg = errors[0].to_string();
494 assert!(msg.contains("ERROR"));
495 assert!(msg.contains("Marktlokation"));
496 assert!(msg.contains("Muss"));
497 }
498
499 #[test]
500 fn test_validate_missing_field() {
501 let reqs = sample_requirements();
502 let json = json!({
503 "prozessdaten": {
504 "transaktionsgrund": "E01"
505 },
507 "marktlokation": {
508 "marktlokationsId": "51234567890",
509 "haushaltskunde": "Z15"
510 },
511 "geschaeftspartner": [
512 { "identifikation": "9900000000003" }
513 ]
514 });
515
516 let errors = validate_pid_json(&json, &reqs);
517 assert_eq!(errors.len(), 1);
518 match &errors[0] {
519 PidValidationError::MissingField {
520 entity,
521 field,
522 ahb_status,
523 severity,
524 ..
525 } => {
526 assert_eq!(entity, "Prozessdaten");
527 assert_eq!(field, "vorgangId");
528 assert_eq!(ahb_status, "X");
529 assert_eq!(severity, &Severity::Error);
530 }
531 other => panic!("Expected MissingField, got: {other:?}"),
532 }
533
534 let msg = errors[0].to_string();
535 assert!(msg.contains("ERROR"));
536 assert!(msg.contains("Prozessdaten.vorgangId"));
537 }
538
539 #[test]
540 fn test_validate_invalid_code() {
541 let reqs = sample_requirements();
542 let json = json!({
543 "prozessdaten": {
544 "vorgangId": "ABC123",
545 "transaktionsgrund": "E01"
546 },
547 "marktlokation": {
548 "marktlokationsId": "51234567890",
549 "haushaltskunde": "Z99" },
551 "geschaeftspartner": [
552 { "identifikation": "9900000000003" }
553 ]
554 });
555
556 let errors = validate_pid_json(&json, &reqs);
557 assert_eq!(errors.len(), 1);
558 match &errors[0] {
559 PidValidationError::InvalidCode {
560 entity,
561 field,
562 value,
563 valid_values,
564 } => {
565 assert_eq!(entity, "Marktlokation");
566 assert_eq!(field, "haushaltskunde");
567 assert_eq!(value, "Z99");
568 assert_eq!(valid_values.len(), 2);
569 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
570 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
571 }
572 other => panic!("Expected InvalidCode, got: {other:?}"),
573 }
574
575 let msg = errors[0].to_string();
576 assert!(msg.contains("INVALID"));
577 assert!(msg.contains("Z99"));
578 assert!(msg.contains("Z15"));
579 }
580
581 #[test]
582 fn test_validate_array_entity() {
583 let reqs = sample_requirements();
584 let json = json!({
585 "prozessdaten": {
586 "vorgangId": "ABC123",
587 "transaktionsgrund": "E01"
588 },
589 "marktlokation": {
590 "marktlokationsId": "51234567890",
591 "haushaltskunde": "Z15"
592 },
593 "geschaeftspartner": [
594 { "identifikation": "9900000000003" },
595 { } ]
597 });
598
599 let errors = validate_pid_json(&json, &reqs);
600 assert_eq!(errors.len(), 1);
601 match &errors[0] {
602 PidValidationError::MissingField { entity, field, .. } => {
603 assert_eq!(entity, "Geschaeftspartner");
604 assert_eq!(field, "identifikation");
605 }
606 other => panic!("Expected MissingField, got: {other:?}"),
607 }
608 }
609
610 #[test]
611 fn test_to_camel_case() {
612 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
613 assert_eq!(
614 to_camel_case("RuhendeMarktlokation"),
615 "ruhendeMarktlokation"
616 );
617 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
618 assert_eq!(to_camel_case(""), "");
619 }
620
621 #[test]
622 fn test_snake_to_camel_case() {
623 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
624 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
625 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
626 assert_eq!(snake_to_camel_case(""), "");
627 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
628 }
629
630 #[test]
633 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
634 let reqs = PidRequirements {
635 pid: "55077".to_string(),
636 beschreibung: "Test camelCase fallback".to_string(),
637 entities: vec![EntityRequirement {
638 entity: "Zuordnung".to_string(),
639 bo4e_type: "Zuordnung".to_string(),
640 companion_type: None,
641 ahb_status: "Muss".to_string(),
642 is_array: false,
643 map_key: None,
644 fields: vec![
645 FieldRequirement {
646 bo4e_name: "code_codepflege".to_string(),
648 ahb_status: "X".to_string(),
649 is_companion: false,
650 field_type: "data".to_string(),
651 format: None,
652 enum_name: None,
653 valid_codes: vec![],
654 child_group: None,
655 },
656 FieldRequirement {
657 bo4e_name: "codeliste".to_string(),
658 ahb_status: "X".to_string(),
659 is_companion: false,
660 field_type: "data".to_string(),
661 format: None,
662 enum_name: None,
663 valid_codes: vec![],
664 child_group: None,
665 },
666 ],
667 }],
668 };
669
670 let json_camel = json!({
673 "zuordnung": {
674 "codeCodepflege": "DE_BDEW",
675 "codeliste": "6"
676 }
677 });
678
679 let errors = validate_pid_json(&json_camel, &reqs);
680 assert!(
681 errors.is_empty(),
682 "Expected no errors when field is present under camelCase key, got: {errors:?}"
683 );
684
685 let json_snake = json!({
687 "zuordnung": {
688 "code_codepflege": "DE_BDEW",
689 "codeliste": "6"
690 }
691 });
692
693 let errors = validate_pid_json(&json_snake, &reqs);
694 assert!(
695 errors.is_empty(),
696 "Expected no errors when field is present under snake_case key, got: {errors:?}"
697 );
698
699 let json_missing = json!({
701 "zuordnung": {
702 "codeliste": "6"
703 }
704 });
705
706 let errors = validate_pid_json(&json_missing, &reqs);
707 assert_eq!(errors.len(), 1);
708 match &errors[0] {
709 PidValidationError::MissingField { field, .. } => {
710 assert_eq!(field, "code_codepflege");
711 }
712 other => panic!("Expected MissingField, got: {other:?}"),
713 }
714 }
715
716 #[test]
717 fn test_is_unconditionally_required() {
718 assert!(is_unconditionally_required("X"));
719 assert!(is_unconditionally_required("Muss"));
720 assert!(is_unconditionally_required("Soll"));
721 assert!(!is_unconditionally_required("Kann"));
722 assert!(!is_unconditionally_required("[1]"));
723 assert!(!is_unconditionally_required(""));
724 }
725
726 #[test]
727 fn test_validation_report_display() {
728 let errors = vec![
729 PidValidationError::MissingEntity {
730 entity: "Marktlokation".to_string(),
731 ahb_status: "Muss".to_string(),
732 severity: Severity::Error,
733 },
734 PidValidationError::MissingField {
735 entity: "Prozessdaten".to_string(),
736 field: "vorgangId".to_string(),
737 ahb_status: "X".to_string(),
738 rust_type: None,
739 valid_values: vec![],
740 severity: Severity::Error,
741 },
742 ];
743 let report = ValidationReport(errors);
744 assert!(report.has_errors());
745 assert_eq!(report.len(), 2);
746 assert!(!report.is_empty());
747
748 let display = report.to_string();
749 assert!(display.contains("missing entity 'Marktlokation'"));
750 assert!(display.contains("missing Prozessdaten.vorgangId"));
751 }
752
753 #[test]
754 fn test_missing_field_with_type_and_values_display() {
755 let err = PidValidationError::MissingField {
756 entity: "Marktlokation".to_string(),
757 field: "haushaltskunde".to_string(),
758 ahb_status: "Muss".to_string(),
759 rust_type: Some("Haushaltskunde".to_string()),
760 valid_values: vec![
761 ("Z15".to_string(), "Ja".to_string()),
762 ("Z18".to_string(), "Nein".to_string()),
763 ],
764 severity: Severity::Error,
765 };
766 let msg = err.to_string();
767 assert!(msg.contains("type: Haushaltskunde"));
768 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
769 }
770
771 #[test]
772 fn test_optional_fields_not_flagged() {
773 let reqs = PidRequirements {
774 pid: "99999".to_string(),
775 beschreibung: "Test".to_string(),
776 entities: vec![EntityRequirement {
777 entity: "Test".to_string(),
778 bo4e_type: "Test".to_string(),
779 companion_type: None,
780 ahb_status: "Kann".to_string(),
781 is_array: false,
782 map_key: None,
783 fields: vec![FieldRequirement {
784 bo4e_name: "optionalField".to_string(),
785 ahb_status: "Kann".to_string(),
786 is_companion: false,
787 field_type: "data".to_string(),
788 format: None,
789 enum_name: None,
790 valid_codes: vec![],
791 child_group: None,
792 }],
793 }],
794 };
795
796 let errors = validate_pid_json(&json!({}), &reqs);
798 assert!(errors.is_empty());
799
800 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
802 assert!(errors.is_empty());
803 }
804}