use std::fmt;
use serde_json::Value;
use crate::pid_requirements::{
CodeValue, EntityRequirement, EntityScope, FieldRequirement, PidRequirements,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub enum PidValidationError {
MissingEntity {
entity: String,
ahb_status: String,
severity: Severity,
},
MissingField {
entity: String,
field: String,
ahb_status: String,
rust_type: Option<String>,
valid_values: Vec<(String, String)>,
severity: Severity,
},
InvalidCode {
entity: String,
field: String,
value: String,
valid_values: Vec<(String, String)>,
},
}
impl PidValidationError {
pub fn severity(&self) -> &Severity {
match self {
Self::MissingEntity { severity, .. } => severity,
Self::MissingField { severity, .. } => severity,
Self::InvalidCode { .. } => &Severity::Error,
}
}
pub fn is_error(&self) -> bool {
matches!(self.severity(), Severity::Error)
}
}
impl fmt::Display for PidValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PidValidationError::MissingEntity {
entity,
ahb_status,
severity,
} => {
let label = severity_label(severity);
write!(
f,
"{label}: missing entity '{entity}' (required: {ahb_status})"
)
}
PidValidationError::MissingField {
entity,
field,
ahb_status,
rust_type,
valid_values,
severity,
} => {
let label = severity_label(severity);
write!(
f,
"{label}: missing {entity}.{field} (required: {ahb_status})"
)?;
if let Some(rt) = rust_type {
write!(f, "\n → type: {rt}")?;
}
if !valid_values.is_empty() {
let codes: Vec<String> = valid_values
.iter()
.map(|(code, meaning)| {
if meaning.is_empty() {
code.clone()
} else {
format!("{code} ({meaning})")
}
})
.collect();
write!(f, "\n → valid: {}", codes.join(", "))?;
}
Ok(())
}
PidValidationError::InvalidCode {
entity,
field,
value,
valid_values,
} => {
write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
if !valid_values.is_empty() {
let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
write!(f, "\n → valid: {}", codes.join(", "))?;
}
Ok(())
}
}
}
}
fn severity_label(severity: &Severity) -> &'static str {
match severity {
Severity::Error => "ERROR",
Severity::Warning => "WARNING",
}
}
pub struct ValidationReport(pub Vec<PidValidationError>);
impl ValidationReport {
pub fn has_errors(&self) -> bool {
self.0.iter().any(|e| e.is_error())
}
pub fn errors(&self) -> Vec<&PidValidationError> {
self.0.iter().filter(|e| e.is_error()).collect()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, err) in self.0.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "{err}")?;
}
Ok(())
}
}
pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
validate_entities(json, &requirements.entities, None)
}
pub fn validate_pid_json_transaction(
json: &Value,
requirements: &PidRequirements,
) -> Vec<PidValidationError> {
validate_entities(
json,
&requirements.entities,
Some(EntityScope::Transaction),
)
}
fn validate_entities(
json: &Value,
entities: &[EntityRequirement],
scope_filter: Option<EntityScope>,
) -> Vec<PidValidationError> {
let mut errors = Vec::new();
for entity_req in entities {
if let Some(ref scope) = scope_filter {
if &entity_req.scope != scope {
continue;
}
}
let key = to_camel_case(&entity_req.entity);
match json.get(&key) {
None | Some(serde_json::Value::Null) => {
if is_unconditionally_required(&entity_req.ahb_status) {
errors.push(PidValidationError::MissingEntity {
entity: entity_req.entity.clone(),
ahb_status: entity_req.ahb_status.clone(),
severity: Severity::Error,
});
}
}
Some(val) => {
if entity_req.is_array {
if let Some(arr) = val.as_array() {
for element in arr {
validate_entity_fields(element, entity_req, &mut errors);
}
} else {
validate_entity_fields(val, entity_req, &mut errors);
}
} else {
validate_entity_fields(val, entity_req, &mut errors);
}
}
}
}
errors
}
fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = json;
for part in path.split('.') {
current = current.get(part).or_else(|| {
if part.contains('_') {
current.get(snake_to_camel_case(part))
} else {
None
}
})?;
}
Some(current)
}
fn validate_entity_fields(
entity_json: &Value,
entity_req: &EntityRequirement,
errors: &mut Vec<PidValidationError>,
) {
for field_req in &entity_req.fields {
let val = get_nested(entity_json, &field_req.bo4e_name).or_else(|| {
if field_req.is_companion {
if let Some(ref companion_type) = entity_req.companion_type {
let companion_key = to_camel_case(companion_type);
let companion_obj = entity_json.get(&companion_key)?;
get_nested(companion_obj, &field_req.bo4e_name)
} else {
None
}
} else {
None
}
});
let val = val.filter(|v| !v.is_null());
match val {
None => {
if is_unconditionally_required(&field_req.ahb_status) {
errors.push(PidValidationError::MissingField {
entity: entity_req.entity.clone(),
field: field_req.bo4e_name.clone(),
ahb_status: field_req.ahb_status.clone(),
rust_type: field_req.enum_name.clone(),
valid_values: code_values_to_tuples(&field_req.valid_codes),
severity: Severity::Error,
});
}
}
Some(val) => {
if !field_req.valid_codes.is_empty() {
validate_code_value(val, entity_req, field_req, errors);
}
}
}
}
}
fn validate_code_value(
val: &Value,
entity_req: &EntityRequirement,
field_req: &FieldRequirement,
errors: &mut Vec<PidValidationError>,
) {
let value_str = match val.as_str() {
Some(s) => s,
None => return, };
let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
if !is_valid {
errors.push(PidValidationError::InvalidCode {
entity: entity_req.entity.clone(),
field: field_req.bo4e_name.clone(),
value: value_str.to_string(),
valid_values: code_values_to_tuples(&field_req.valid_codes),
});
}
}
fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
codes
.iter()
.map(|cv| (cv.code.clone(), cv.meaning.clone()))
.collect()
}
fn to_camel_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = chars.next().unwrap();
let mut result = first.to_lowercase().to_string();
result.extend(chars);
result
}
fn snake_to_camel_case(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut capitalize_next = false;
for ch in s.chars() {
if ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.extend(ch.to_uppercase());
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
fn is_unconditionally_required(ahb_status: &str) -> bool {
matches!(ahb_status, "X" | "Muss" | "Soll")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pid_requirements::{
CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
};
use serde_json::json;
fn sample_requirements() -> PidRequirements {
PidRequirements {
pid: "55001".to_string(),
beschreibung: "Anmeldung verb. MaLo".to_string(),
entities: vec![
EntityRequirement {
entity: "Prozessdaten".to_string(),
bo4e_type: "Prozessdaten".to_string(),
companion_type: None,
ahb_status: "Muss".to_string(),
is_array: false,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![
FieldRequirement {
bo4e_name: "vorgangId".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
},
FieldRequirement {
bo4e_name: "transaktionsgrund".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "code".to_string(),
format: None,
enum_name: Some("Transaktionsgrund".to_string()),
valid_codes: vec![
CodeValue {
code: "E01".to_string(),
meaning: "Ein-/Auszug (Einzug)".to_string(),
enum_name: None,
},
CodeValue {
code: "E03".to_string(),
meaning: "Wechsel".to_string(),
enum_name: None,
},
],
child_group: None,
},
],
},
EntityRequirement {
entity: "Marktlokation".to_string(),
bo4e_type: "Marktlokation".to_string(),
companion_type: Some("MarktlokationEdifact".to_string()),
ahb_status: "Muss".to_string(),
is_array: false,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![
FieldRequirement {
bo4e_name: "marktlokationsId".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
},
FieldRequirement {
bo4e_name: "haushaltskunde".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "code".to_string(),
format: None,
enum_name: Some("Haushaltskunde".to_string()),
valid_codes: vec![
CodeValue {
code: "Z15".to_string(),
meaning: "Ja".to_string(),
enum_name: None,
},
CodeValue {
code: "Z18".to_string(),
meaning: "Nein".to_string(),
enum_name: None,
},
],
child_group: None,
},
],
},
EntityRequirement {
entity: "Geschaeftspartner".to_string(),
bo4e_type: "Geschaeftspartner".to_string(),
companion_type: Some("GeschaeftspartnerEdifact".to_string()),
ahb_status: "Muss".to_string(),
is_array: true,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![FieldRequirement {
bo4e_name: "identifikation".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
}],
},
],
}
}
#[test]
fn test_validate_complete_json() {
let reqs = sample_requirements();
let json = json!({
"prozessdaten": {
"vorgangId": "ABC123",
"transaktionsgrund": "E01"
},
"marktlokation": {
"marktlokationsId": "51234567890",
"haushaltskunde": "Z15"
},
"geschaeftspartner": [
{ "identifikation": "9900000000003" }
]
});
let errors = validate_pid_json(&json, &reqs);
assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
}
#[test]
fn test_validate_missing_entity() {
let reqs = sample_requirements();
let json = json!({
"prozessdaten": {
"vorgangId": "ABC123",
"transaktionsgrund": "E01"
},
"geschaeftspartner": [
{ "identifikation": "9900000000003" }
]
});
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1);
match &errors[0] {
PidValidationError::MissingEntity {
entity,
ahb_status,
severity,
} => {
assert_eq!(entity, "Marktlokation");
assert_eq!(ahb_status, "Muss");
assert_eq!(severity, &Severity::Error);
}
other => panic!("Expected MissingEntity, got: {other:?}"),
}
let msg = errors[0].to_string();
assert!(msg.contains("ERROR"));
assert!(msg.contains("Marktlokation"));
assert!(msg.contains("Muss"));
}
#[test]
fn test_validate_missing_field() {
let reqs = sample_requirements();
let json = json!({
"prozessdaten": {
"transaktionsgrund": "E01"
},
"marktlokation": {
"marktlokationsId": "51234567890",
"haushaltskunde": "Z15"
},
"geschaeftspartner": [
{ "identifikation": "9900000000003" }
]
});
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1);
match &errors[0] {
PidValidationError::MissingField {
entity,
field,
ahb_status,
severity,
..
} => {
assert_eq!(entity, "Prozessdaten");
assert_eq!(field, "vorgangId");
assert_eq!(ahb_status, "X");
assert_eq!(severity, &Severity::Error);
}
other => panic!("Expected MissingField, got: {other:?}"),
}
let msg = errors[0].to_string();
assert!(msg.contains("ERROR"));
assert!(msg.contains("Prozessdaten.vorgangId"));
}
#[test]
fn test_validate_invalid_code() {
let reqs = sample_requirements();
let json = json!({
"prozessdaten": {
"vorgangId": "ABC123",
"transaktionsgrund": "E01"
},
"marktlokation": {
"marktlokationsId": "51234567890",
"haushaltskunde": "Z99" },
"geschaeftspartner": [
{ "identifikation": "9900000000003" }
]
});
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1);
match &errors[0] {
PidValidationError::InvalidCode {
entity,
field,
value,
valid_values,
} => {
assert_eq!(entity, "Marktlokation");
assert_eq!(field, "haushaltskunde");
assert_eq!(value, "Z99");
assert_eq!(valid_values.len(), 2);
assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
}
other => panic!("Expected InvalidCode, got: {other:?}"),
}
let msg = errors[0].to_string();
assert!(msg.contains("INVALID"));
assert!(msg.contains("Z99"));
assert!(msg.contains("Z15"));
}
#[test]
fn test_validate_array_entity() {
let reqs = sample_requirements();
let json = json!({
"prozessdaten": {
"vorgangId": "ABC123",
"transaktionsgrund": "E01"
},
"marktlokation": {
"marktlokationsId": "51234567890",
"haushaltskunde": "Z15"
},
"geschaeftspartner": [
{ "identifikation": "9900000000003" },
{ } ]
});
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1);
match &errors[0] {
PidValidationError::MissingField { entity, field, .. } => {
assert_eq!(entity, "Geschaeftspartner");
assert_eq!(field, "identifikation");
}
other => panic!("Expected MissingField, got: {other:?}"),
}
}
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
assert_eq!(
to_camel_case("RuhendeMarktlokation"),
"ruhendeMarktlokation"
);
assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
assert_eq!(to_camel_case(""), "");
}
#[test]
fn test_snake_to_camel_case() {
assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
assert_eq!(snake_to_camel_case(""), "");
assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
}
#[test]
fn test_camel_case_fallback_for_snake_case_bo4e_name() {
let reqs = PidRequirements {
pid: "55077".to_string(),
beschreibung: "Test camelCase fallback".to_string(),
entities: vec![EntityRequirement {
entity: "Zuordnung".to_string(),
bo4e_type: "Zuordnung".to_string(),
companion_type: None,
ahb_status: "Muss".to_string(),
is_array: false,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![
FieldRequirement {
bo4e_name: "code_codepflege".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
},
FieldRequirement {
bo4e_name: "codeliste".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
},
],
}],
};
let json_camel = json!({
"zuordnung": {
"codeCodepflege": "DE_BDEW",
"codeliste": "6"
}
});
let errors = validate_pid_json(&json_camel, &reqs);
assert!(
errors.is_empty(),
"Expected no errors when field is present under camelCase key, got: {errors:?}"
);
let json_snake = json!({
"zuordnung": {
"code_codepflege": "DE_BDEW",
"codeliste": "6"
}
});
let errors = validate_pid_json(&json_snake, &reqs);
assert!(
errors.is_empty(),
"Expected no errors when field is present under snake_case key, got: {errors:?}"
);
let json_missing = json!({
"zuordnung": {
"codeliste": "6"
}
});
let errors = validate_pid_json(&json_missing, &reqs);
assert_eq!(errors.len(), 1);
match &errors[0] {
PidValidationError::MissingField { field, .. } => {
assert_eq!(field, "code_codepflege");
}
other => panic!("Expected MissingField, got: {other:?}"),
}
}
#[test]
fn test_is_unconditionally_required() {
assert!(is_unconditionally_required("X"));
assert!(is_unconditionally_required("Muss"));
assert!(is_unconditionally_required("Soll"));
assert!(!is_unconditionally_required("Kann"));
assert!(!is_unconditionally_required("[1]"));
assert!(!is_unconditionally_required(""));
}
#[test]
fn test_validation_report_display() {
let errors = vec![
PidValidationError::MissingEntity {
entity: "Marktlokation".to_string(),
ahb_status: "Muss".to_string(),
severity: Severity::Error,
},
PidValidationError::MissingField {
entity: "Prozessdaten".to_string(),
field: "vorgangId".to_string(),
ahb_status: "X".to_string(),
rust_type: None,
valid_values: vec![],
severity: Severity::Error,
},
];
let report = ValidationReport(errors);
assert!(report.has_errors());
assert_eq!(report.len(), 2);
assert!(!report.is_empty());
let display = report.to_string();
assert!(display.contains("missing entity 'Marktlokation'"));
assert!(display.contains("missing Prozessdaten.vorgangId"));
}
#[test]
fn test_missing_field_with_type_and_values_display() {
let err = PidValidationError::MissingField {
entity: "Marktlokation".to_string(),
field: "haushaltskunde".to_string(),
ahb_status: "Muss".to_string(),
rust_type: Some("Haushaltskunde".to_string()),
valid_values: vec![
("Z15".to_string(), "Ja".to_string()),
("Z18".to_string(), "Nein".to_string()),
],
severity: Severity::Error,
};
let msg = err.to_string();
assert!(msg.contains("type: Haushaltskunde"));
assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
}
#[test]
fn test_optional_fields_not_flagged() {
let reqs = PidRequirements {
pid: "99999".to_string(),
beschreibung: "Test".to_string(),
entities: vec![EntityRequirement {
entity: "Test".to_string(),
bo4e_type: "Test".to_string(),
companion_type: None,
ahb_status: "Kann".to_string(),
is_array: false,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![FieldRequirement {
bo4e_name: "optionalField".to_string(),
ahb_status: "Kann".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
}],
}],
};
let errors = validate_pid_json(&json!({}), &reqs);
assert!(errors.is_empty());
let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
assert!(errors.is_empty());
}
#[test]
fn test_nested_dot_path_fields_not_falsely_missing() {
let reqs = PidRequirements {
pid: "55001".to_string(),
beschreibung: "Test nested paths".to_string(),
entities: vec![EntityRequirement {
entity: "ProduktpaketDaten".to_string(),
bo4e_type: "ProduktpaketDaten".to_string(),
companion_type: None,
ahb_status: "Muss".to_string(),
is_array: true,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![
FieldRequirement {
bo4e_name: "produktIdentifikation.funktion".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "code".to_string(),
format: None,
enum_name: Some("Produktidentifikation".to_string()),
valid_codes: vec![CodeValue {
code: "5".to_string(),
meaning: "Produktidentifikation".to_string(),
enum_name: None,
}],
child_group: None,
},
FieldRequirement {
bo4e_name: "produktMerkmal.code".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "code".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
},
],
}],
};
let json = json!({
"produktpaketDaten": [{
"produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
"produktMerkmal": { "code": "ZH9" }
}]
});
let errors = validate_pid_json(&json, &reqs);
assert!(
errors.is_empty(),
"Nested dot-path fields should be found (issue #48), got: {errors:?}"
);
}
#[test]
fn test_nested_dot_path_truly_missing() {
let reqs = PidRequirements {
pid: "55001".to_string(),
beschreibung: "Test nested paths missing".to_string(),
entities: vec![EntityRequirement {
entity: "ProduktpaketDaten".to_string(),
bo4e_type: "ProduktpaketDaten".to_string(),
companion_type: None,
ahb_status: "Muss".to_string(),
is_array: true,
map_key: None,
scope: EntityScope::Transaction,
fields: vec![FieldRequirement {
bo4e_name: "produktIdentifikation.funktion".to_string(),
ahb_status: "X".to_string(),
is_companion: false,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
}],
}],
};
let json = json!({
"produktpaketDaten": [{
"produktIdentifikation": { "id": "123" }
}]
});
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1, "Should report missing nested field");
match &errors[0] {
PidValidationError::MissingField { field, .. } => {
assert_eq!(field, "produktIdentifikation.funktion");
}
other => panic!("Expected MissingField, got: {other:?}"),
}
}
}