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 match entity_json.get(&field_req.bo4e_name) {
214 None => {
215 if is_unconditionally_required(&field_req.ahb_status) {
216 errors.push(PidValidationError::MissingField {
217 entity: entity_req.entity.clone(),
218 field: field_req.bo4e_name.clone(),
219 ahb_status: field_req.ahb_status.clone(),
220 rust_type: field_req.enum_name.clone(),
221 valid_values: code_values_to_tuples(&field_req.valid_codes),
222 severity: Severity::Error,
223 });
224 }
225 }
226 Some(val) => {
227 if !field_req.valid_codes.is_empty() {
228 validate_code_value(val, entity_req, field_req, errors);
229 }
230 }
231 }
232 }
233}
234
235fn validate_code_value(
237 val: &Value,
238 entity_req: &EntityRequirement,
239 field_req: &FieldRequirement,
240 errors: &mut Vec<PidValidationError>,
241) {
242 let value_str = match val.as_str() {
243 Some(s) => s,
244 None => return, };
246
247 let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
248 if !is_valid {
249 errors.push(PidValidationError::InvalidCode {
250 entity: entity_req.entity.clone(),
251 field: field_req.bo4e_name.clone(),
252 value: value_str.to_string(),
253 valid_values: code_values_to_tuples(&field_req.valid_codes),
254 });
255 }
256}
257
258fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
260 codes
261 .iter()
262 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
263 .collect()
264}
265
266fn to_camel_case(s: &str) -> String {
272 if s.is_empty() {
273 return String::new();
274 }
275 let mut chars = s.chars();
276 let first = chars.next().unwrap();
277 let mut result = first.to_lowercase().to_string();
278 result.extend(chars);
279 result
280}
281
282fn is_unconditionally_required(ahb_status: &str) -> bool {
284 matches!(ahb_status, "X" | "Muss" | "Soll")
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::pid_requirements::{
291 CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
292 };
293 use serde_json::json;
294
295 fn sample_requirements() -> PidRequirements {
296 PidRequirements {
297 pid: "55001".to_string(),
298 beschreibung: "Anmeldung verb. MaLo".to_string(),
299 entities: vec![
300 EntityRequirement {
301 entity: "Prozessdaten".to_string(),
302 bo4e_type: "Prozessdaten".to_string(),
303 companion_type: None,
304 ahb_status: "Muss".to_string(),
305 is_array: false,
306 fields: vec![
307 FieldRequirement {
308 bo4e_name: "vorgangId".to_string(),
309 ahb_status: "X".to_string(),
310 is_companion: false,
311 field_type: "data".to_string(),
312 format: None,
313 enum_name: None,
314 valid_codes: vec![],
315 },
316 FieldRequirement {
317 bo4e_name: "transaktionsgrund".to_string(),
318 ahb_status: "X".to_string(),
319 is_companion: false,
320 field_type: "code".to_string(),
321 format: None,
322 enum_name: Some("Transaktionsgrund".to_string()),
323 valid_codes: vec![
324 CodeValue {
325 code: "E01".to_string(),
326 meaning: "Ein-/Auszug (Einzug)".to_string(),
327 },
328 CodeValue {
329 code: "E03".to_string(),
330 meaning: "Wechsel".to_string(),
331 },
332 ],
333 },
334 ],
335 },
336 EntityRequirement {
337 entity: "Marktlokation".to_string(),
338 bo4e_type: "Marktlokation".to_string(),
339 companion_type: Some("MarktlokationEdifact".to_string()),
340 ahb_status: "Muss".to_string(),
341 is_array: false,
342 fields: vec![
343 FieldRequirement {
344 bo4e_name: "marktlokationsId".to_string(),
345 ahb_status: "X".to_string(),
346 is_companion: false,
347 field_type: "data".to_string(),
348 format: None,
349 enum_name: None,
350 valid_codes: vec![],
351 },
352 FieldRequirement {
353 bo4e_name: "haushaltskunde".to_string(),
354 ahb_status: "X".to_string(),
355 is_companion: false,
356 field_type: "code".to_string(),
357 format: None,
358 enum_name: Some("Haushaltskunde".to_string()),
359 valid_codes: vec![
360 CodeValue {
361 code: "Z15".to_string(),
362 meaning: "Ja".to_string(),
363 },
364 CodeValue {
365 code: "Z18".to_string(),
366 meaning: "Nein".to_string(),
367 },
368 ],
369 },
370 ],
371 },
372 EntityRequirement {
373 entity: "Geschaeftspartner".to_string(),
374 bo4e_type: "Geschaeftspartner".to_string(),
375 companion_type: Some("GeschaeftspartnerEdifact".to_string()),
376 ahb_status: "Muss".to_string(),
377 is_array: true,
378 fields: vec![FieldRequirement {
379 bo4e_name: "identifikation".to_string(),
380 ahb_status: "X".to_string(),
381 is_companion: false,
382 field_type: "data".to_string(),
383 format: None,
384 enum_name: None,
385 valid_codes: vec![],
386 }],
387 },
388 ],
389 }
390 }
391
392 #[test]
393 fn test_validate_complete_json() {
394 let reqs = sample_requirements();
395 let json = json!({
396 "prozessdaten": {
397 "vorgangId": "ABC123",
398 "transaktionsgrund": "E01"
399 },
400 "marktlokation": {
401 "marktlokationsId": "51234567890",
402 "haushaltskunde": "Z15"
403 },
404 "geschaeftspartner": [
405 { "identifikation": "9900000000003" }
406 ]
407 });
408
409 let errors = validate_pid_json(&json, &reqs);
410 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
411 }
412
413 #[test]
414 fn test_validate_missing_entity() {
415 let reqs = sample_requirements();
416 let json = json!({
417 "prozessdaten": {
418 "vorgangId": "ABC123",
419 "transaktionsgrund": "E01"
420 },
421 "geschaeftspartner": [
422 { "identifikation": "9900000000003" }
423 ]
424 });
425 let errors = validate_pid_json(&json, &reqs);
428 assert_eq!(errors.len(), 1);
429 match &errors[0] {
430 PidValidationError::MissingEntity {
431 entity,
432 ahb_status,
433 severity,
434 } => {
435 assert_eq!(entity, "Marktlokation");
436 assert_eq!(ahb_status, "Muss");
437 assert_eq!(severity, &Severity::Error);
438 }
439 other => panic!("Expected MissingEntity, got: {other:?}"),
440 }
441
442 let msg = errors[0].to_string();
444 assert!(msg.contains("ERROR"));
445 assert!(msg.contains("Marktlokation"));
446 assert!(msg.contains("Muss"));
447 }
448
449 #[test]
450 fn test_validate_missing_field() {
451 let reqs = sample_requirements();
452 let json = json!({
453 "prozessdaten": {
454 "transaktionsgrund": "E01"
455 },
457 "marktlokation": {
458 "marktlokationsId": "51234567890",
459 "haushaltskunde": "Z15"
460 },
461 "geschaeftspartner": [
462 { "identifikation": "9900000000003" }
463 ]
464 });
465
466 let errors = validate_pid_json(&json, &reqs);
467 assert_eq!(errors.len(), 1);
468 match &errors[0] {
469 PidValidationError::MissingField {
470 entity,
471 field,
472 ahb_status,
473 severity,
474 ..
475 } => {
476 assert_eq!(entity, "Prozessdaten");
477 assert_eq!(field, "vorgangId");
478 assert_eq!(ahb_status, "X");
479 assert_eq!(severity, &Severity::Error);
480 }
481 other => panic!("Expected MissingField, got: {other:?}"),
482 }
483
484 let msg = errors[0].to_string();
485 assert!(msg.contains("ERROR"));
486 assert!(msg.contains("Prozessdaten.vorgangId"));
487 }
488
489 #[test]
490 fn test_validate_invalid_code() {
491 let reqs = sample_requirements();
492 let json = json!({
493 "prozessdaten": {
494 "vorgangId": "ABC123",
495 "transaktionsgrund": "E01"
496 },
497 "marktlokation": {
498 "marktlokationsId": "51234567890",
499 "haushaltskunde": "Z99" },
501 "geschaeftspartner": [
502 { "identifikation": "9900000000003" }
503 ]
504 });
505
506 let errors = validate_pid_json(&json, &reqs);
507 assert_eq!(errors.len(), 1);
508 match &errors[0] {
509 PidValidationError::InvalidCode {
510 entity,
511 field,
512 value,
513 valid_values,
514 } => {
515 assert_eq!(entity, "Marktlokation");
516 assert_eq!(field, "haushaltskunde");
517 assert_eq!(value, "Z99");
518 assert_eq!(valid_values.len(), 2);
519 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
520 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
521 }
522 other => panic!("Expected InvalidCode, got: {other:?}"),
523 }
524
525 let msg = errors[0].to_string();
526 assert!(msg.contains("INVALID"));
527 assert!(msg.contains("Z99"));
528 assert!(msg.contains("Z15"));
529 }
530
531 #[test]
532 fn test_validate_array_entity() {
533 let reqs = sample_requirements();
534 let json = json!({
535 "prozessdaten": {
536 "vorgangId": "ABC123",
537 "transaktionsgrund": "E01"
538 },
539 "marktlokation": {
540 "marktlokationsId": "51234567890",
541 "haushaltskunde": "Z15"
542 },
543 "geschaeftspartner": [
544 { "identifikation": "9900000000003" },
545 { } ]
547 });
548
549 let errors = validate_pid_json(&json, &reqs);
550 assert_eq!(errors.len(), 1);
551 match &errors[0] {
552 PidValidationError::MissingField { entity, field, .. } => {
553 assert_eq!(entity, "Geschaeftspartner");
554 assert_eq!(field, "identifikation");
555 }
556 other => panic!("Expected MissingField, got: {other:?}"),
557 }
558 }
559
560 #[test]
561 fn test_to_camel_case() {
562 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
563 assert_eq!(
564 to_camel_case("RuhendeMarktlokation"),
565 "ruhendeMarktlokation"
566 );
567 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
568 assert_eq!(to_camel_case(""), "");
569 }
570
571 #[test]
572 fn test_is_unconditionally_required() {
573 assert!(is_unconditionally_required("X"));
574 assert!(is_unconditionally_required("Muss"));
575 assert!(is_unconditionally_required("Soll"));
576 assert!(!is_unconditionally_required("Kann"));
577 assert!(!is_unconditionally_required("[1]"));
578 assert!(!is_unconditionally_required(""));
579 }
580
581 #[test]
582 fn test_validation_report_display() {
583 let errors = vec![
584 PidValidationError::MissingEntity {
585 entity: "Marktlokation".to_string(),
586 ahb_status: "Muss".to_string(),
587 severity: Severity::Error,
588 },
589 PidValidationError::MissingField {
590 entity: "Prozessdaten".to_string(),
591 field: "vorgangId".to_string(),
592 ahb_status: "X".to_string(),
593 rust_type: None,
594 valid_values: vec![],
595 severity: Severity::Error,
596 },
597 ];
598 let report = ValidationReport(errors);
599 assert!(report.has_errors());
600 assert_eq!(report.len(), 2);
601 assert!(!report.is_empty());
602
603 let display = report.to_string();
604 assert!(display.contains("missing entity 'Marktlokation'"));
605 assert!(display.contains("missing Prozessdaten.vorgangId"));
606 }
607
608 #[test]
609 fn test_missing_field_with_type_and_values_display() {
610 let err = PidValidationError::MissingField {
611 entity: "Marktlokation".to_string(),
612 field: "haushaltskunde".to_string(),
613 ahb_status: "Muss".to_string(),
614 rust_type: Some("Haushaltskunde".to_string()),
615 valid_values: vec![
616 ("Z15".to_string(), "Ja".to_string()),
617 ("Z18".to_string(), "Nein".to_string()),
618 ],
619 severity: Severity::Error,
620 };
621 let msg = err.to_string();
622 assert!(msg.contains("type: Haushaltskunde"));
623 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
624 }
625
626 #[test]
627 fn test_optional_fields_not_flagged() {
628 let reqs = PidRequirements {
629 pid: "99999".to_string(),
630 beschreibung: "Test".to_string(),
631 entities: vec![EntityRequirement {
632 entity: "Test".to_string(),
633 bo4e_type: "Test".to_string(),
634 companion_type: None,
635 ahb_status: "Kann".to_string(),
636 is_array: false,
637 fields: vec![FieldRequirement {
638 bo4e_name: "optionalField".to_string(),
639 ahb_status: "Kann".to_string(),
640 is_companion: false,
641 field_type: "data".to_string(),
642 format: None,
643 enum_name: None,
644 valid_codes: vec![],
645 }],
646 }],
647 };
648
649 let errors = validate_pid_json(&json!({}), &reqs);
651 assert!(errors.is_empty());
652
653 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
655 assert!(errors.is_empty());
656 }
657}