use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde_json::Value;
use crate::pid_requirements::{
CodeValue, EntityGroupRequirement, EntityRequirement, EntityScope, EntityVariantRequirement,
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.cardinality().is_list() {
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
}
pub 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>,
) {
let fields = effective_field_requirements(entity_req, entity_json);
let exempt =
fields_of_unrequired_absent_groups(entity_req, entity_json, is_unconditionally_required);
for field_req in fields.iter() {
let val = get_nested(entity_json, &field_req.bo4e_name);
let val = val.filter(|v| !v.is_null());
match val {
None if exempt.contains(field_req.bo4e_name.as_str()) => {}
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) => {
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>,
) {
if let Some(value) = invalid_code_value(val, field_req) {
errors.push(PidValidationError::InvalidCode {
entity: entity_req.entity.clone(),
field: field_req.bo4e_name.clone(),
value,
valid_values: code_values_to_tuples(&field_req.valid_codes),
});
}
}
pub fn code_field_value(val: &Value) -> Option<&str> {
val.as_str()
.or_else(|| val.get("code").and_then(|c| c.as_str()))
}
pub fn invalid_code_value(val: &Value, field_req: &FieldRequirement) -> Option<String> {
if field_req.valid_codes.is_empty() {
return None;
}
let value = code_field_value(val)?;
let is_valid = field_req
.valid_codes
.iter()
.any(|cv| cv.code == value || cv.bo4e_value.as_deref() == Some(value));
(!is_valid).then(|| value.to_string())
}
pub fn effective_field_requirements<'a>(
entity_req: &'a EntityRequirement,
element: &Value,
) -> Cow<'a, [FieldRequirement]> {
if entity_req.variants.is_empty() {
return Cow::Borrowed(&entity_req.fields);
}
let mut by_field: BTreeMap<&str, Vec<&EntityVariantRequirement>> = BTreeMap::new();
for v in &entity_req.variants {
by_field
.entry(v.discriminator_field.as_str())
.or_default()
.push(v);
}
let mut candidates: Vec<&EntityVariantRequirement> = Vec::new();
for (field, group) in by_field {
let value = get_nested(element, field).and_then(code_field_value);
let matched: Vec<&EntityVariantRequirement> = group
.iter()
.copied()
.filter(|v| value.is_some_and(|s| v.code == s || v.bo4e_value.as_deref() == Some(s)))
.collect();
candidates.extend(if matched.is_empty() { group } else { matched });
}
let mut owned: Vec<&str> = Vec::new();
let mut owned_set: BTreeSet<&str> = BTreeSet::new();
for v in &entity_req.variants {
for f in &v.fields {
if owned_set.insert(f.bo4e_name.as_str()) {
owned.push(f.bo4e_name.as_str());
}
}
}
let mut combined: BTreeMap<&str, FieldRequirement> = BTreeMap::new();
for name in owned {
let reqs: Vec<&FieldRequirement> = candidates
.iter()
.filter_map(|v| v.fields.iter().find(|f| f.bo4e_name == name))
.collect();
let Some((first, rest)) = reqs.split_first() else {
continue; };
let mut field = (*first).clone();
let mut statuses_agree = reqs.len() == candidates.len();
for r in rest {
if r.ahb_status != field.ahb_status {
statuses_agree = false;
}
for cv in &r.valid_codes {
if !field.valid_codes.iter().any(|c| c.code == cv.code) {
field.valid_codes.push(cv.clone());
}
}
}
if !statuses_agree {
field.ahb_status = String::new();
}
combined.insert(name, field);
}
let mut result: Vec<FieldRequirement> = Vec::with_capacity(entity_req.fields.len());
for f in &entity_req.fields {
if owned_set.contains(f.bo4e_name.as_str()) {
if let Some(c) = combined.remove(f.bo4e_name.as_str()) {
result.push(c);
}
} else {
result.push(f.clone());
}
}
result.extend(combined.into_values());
Cow::Owned(result)
}
pub fn absent_groups<'a>(
entity_req: &'a EntityRequirement,
element: &'a Value,
) -> impl Iterator<Item = &'a EntityGroupRequirement> + 'a {
entity_req.groups.iter().filter(move |g| {
!g.fields
.iter()
.any(|f| get_nested(element, f).is_some_and(|v| !v.is_null()))
})
}
pub fn fields_of_unrequired_absent_groups<'a>(
entity_req: &'a EntityRequirement,
element: &'a Value,
mut group_required: impl FnMut(&str) -> bool,
) -> BTreeSet<&'a str> {
let exempt: Vec<&EntityGroupRequirement> = absent_groups(entity_req, element)
.filter(|g| !group_required(&g.ahb_status))
.collect();
let mut fields = BTreeSet::new();
for g in &exempt {
for f in &g.fields {
let kept_elsewhere = entity_req.groups.iter().any(|other| {
other.fields.contains(f) && !exempt.iter().any(|e| std::ptr::eq(*e, other))
});
if !kept_elsewhere {
fields.insert(f.as_str());
}
}
}
fields
}
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::{
Bo4eRefType, Cardinality, 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(),
ref_type: Bo4eRefType::Object {
type_name: "Prozessdaten".to_string(),
cardinality: Cardinality::REQUIRED,
},
ahb_status: "Muss".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![
FieldRequirement {
bo4e_name: "vorgangId".to_string(),
ahb_status: "X".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
FieldRequirement {
bo4e_name: "transaktionsgrund".to_string(),
ahb_status: "X".to_string(),
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,
bo4e_value: None,
},
CodeValue {
code: "E03".to_string(),
meaning: "Wechsel".to_string(),
enum_name: None,
bo4e_value: None,
},
],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
],
},
EntityRequirement {
entity: "Marktlokation".to_string(),
ref_type: Bo4eRefType::Object {
type_name: "Marktlokation".to_string(),
cardinality: Cardinality::REQUIRED,
},
ahb_status: "Muss".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![
FieldRequirement {
bo4e_name: "marktlokationsId".to_string(),
ahb_status: "X".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
FieldRequirement {
bo4e_name: "haushaltskunde".to_string(),
ahb_status: "X".to_string(),
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,
bo4e_value: None,
},
CodeValue {
code: "Z18".to_string(),
meaning: "Nein".to_string(),
enum_name: None,
bo4e_value: None,
},
],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
],
},
EntityRequirement {
entity: "Geschaeftspartner".to_string(),
ref_type: Bo4eRefType::Object {
type_name: "Geschaeftspartner".to_string(),
cardinality: Cardinality {
min: 1,
max: Some(7),
},
},
ahb_status: "Muss".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![FieldRequirement {
bo4e_name: "identifikation".to_string(),
ahb_status: "X".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
}],
},
],
}
}
#[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(),
ref_type: Bo4eRefType::Object {
type_name: "Zuordnung".to_string(),
cardinality: Cardinality::REQUIRED,
},
ahb_status: "Muss".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![
FieldRequirement {
bo4e_name: "code_codepflege".to_string(),
ahb_status: "X".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
FieldRequirement {
bo4e_name: "codeliste".to_string(),
ahb_status: "X".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
],
}],
};
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(),
ref_type: Bo4eRefType::Object {
type_name: "Test".to_string(),
cardinality: Cardinality::OPTIONAL,
},
ahb_status: "Kann".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![FieldRequirement {
bo4e_name: "optionalField".to_string(),
ahb_status: "Kann".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
}],
}],
};
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(),
ref_type: Bo4eRefType::Object {
type_name: "ProduktpaketDaten".to_string(),
cardinality: Cardinality {
min: 1,
max: Some(99999),
},
},
ahb_status: "Muss".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![
FieldRequirement {
bo4e_name: "produktIdentifikation.funktion".to_string(),
ahb_status: "X".to_string(),
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,
bo4e_value: None,
}],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
FieldRequirement {
bo4e_name: "produktMerkmal.code".to_string(),
ahb_status: "X".to_string(),
field_type: "code".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
},
],
}],
};
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(),
ref_type: Bo4eRefType::Object {
type_name: "ProduktpaketDaten".to_string(),
cardinality: Cardinality {
min: 1,
max: Some(99999),
},
},
ahb_status: "Muss".to_string(),
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![],
fields: vec![FieldRequirement {
bo4e_name: "produktIdentifikation.funktion".to_string(),
ahb_status: "X".to_string(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: vec![],
child_group: None,
ref_type: Bo4eRefType::Unknown,
}],
}],
};
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:?}"),
}
}
fn field(name: &str, status: &str, codes: &[(&str, &str)]) -> FieldRequirement {
FieldRequirement {
bo4e_name: name.to_string(),
ahb_status: status.to_string(),
field_type: if codes.is_empty() { "data" } else { "code" }.to_string(),
format: None,
enum_name: None,
valid_codes: codes
.iter()
.map(|(code, mapped)| CodeValue {
code: code.to_string(),
meaning: String::new(),
enum_name: None,
bo4e_value: Some(mapped.to_string()),
})
.collect(),
child_group: None,
ref_type: Bo4eRefType::Unknown,
}
}
fn multi_variant_requirements() -> PidRequirements {
let z03 = ("Z03", "messlokationsadresse");
let z07 = ("Z07", "kundeMsb");
PidRequirements {
pid: "55042".to_string(),
beschreibung: String::new(),
entities: vec![EntityRequirement {
entity: "Geschaeftspartner".to_string(),
ref_type: Bo4eRefType::Object {
type_name: "Geschaeftspartner".to_string(),
cardinality: Cardinality {
min: 1,
max: Some(99),
},
},
ahb_status: "Muss".to_string(),
fields: vec![
field("adresse.ort", "X", &[]),
field("name1", "X", &[]),
field("partnerrolle", "X", &[z03, z07]),
],
map_key: None,
scope: EntityScope::Transaction,
variants: vec![
EntityVariantRequirement {
discriminator_field: "partnerrolle".to_string(),
code: "Z03".to_string(),
bo4e_value: Some("messlokationsadresse".to_string()),
source_paths: vec!["sg4.sg12_z03".to_string()],
fields: vec![
field("adresse.ort", "X", &[]),
field("partnerrolle", "X", &[z03]),
],
},
EntityVariantRequirement {
discriminator_field: "partnerrolle".to_string(),
code: "Z07".to_string(),
bo4e_value: Some("kundeMsb".to_string()),
source_paths: vec!["sg4.sg12_z07".to_string()],
fields: vec![field("name1", "X", &[]), field("partnerrolle", "X", &[z07])],
},
],
groups: vec![],
}],
}
}
fn multi_group_requirements(z22_status: &str) -> PidRequirements {
let group = |path: &str, status: &str, fields: &[&str]| EntityGroupRequirement {
source_path: path.to_string(),
ahb_status: status.to_string(),
fields: fields.iter().map(|f| f.to_string()).collect(),
};
PidRequirements {
pid: "55043".to_string(),
beschreibung: String::new(),
entities: vec![EntityRequirement {
entity: "Marktlokation".to_string(),
ref_type: Bo4eRefType::Object {
type_name: "Marktlokation".to_string(),
cardinality: Cardinality::REQUIRED,
},
ahb_status: "Muss".to_string(),
fields: vec![
field("marktlokationsId", "X", &[]),
field("ruhendeMarktlokationsId", "X", &[]),
field("ruhendeMarktlokationZeitraumId", "Kann", &[]),
],
map_key: None,
scope: EntityScope::Transaction,
variants: vec![],
groups: vec![
group("sg4.sg5_z16", "Muss", &["marktlokationsId"]),
group(
"sg4.sg5_z22",
z22_status,
&["ruhendeMarktlokationsId", "ruhendeMarktlokationZeitraumId"],
),
],
}],
}
}
fn missing_fields(errors: &[PidValidationError]) -> Vec<&str> {
errors
.iter()
.filter_map(|e| match e {
PidValidationError::MissingField { field, .. } => Some(field.as_str()),
_ => None,
})
.collect()
}
#[test]
fn an_absent_optional_groups_fields_are_not_demanded() {
let reqs = multi_group_requirements("Soll [2003]");
let errors = validate_pid_json(&json!({ "marktlokation": {} }), &reqs);
assert_eq!(missing_fields(&errors), vec!["marktlokationsId"]);
}
#[test]
fn a_filled_groups_fields_are_demanded() {
let reqs = multi_group_requirements("Soll [2003]");
let json = json!({ "marktlokation": {
"marktlokationsId": "51238696781",
"ruhendeMarktlokationZeitraumId": "1"
}});
let errors = validate_pid_json(&json, &reqs);
assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
}
#[test]
fn an_absent_required_groups_fields_are_demanded() {
let reqs = multi_group_requirements("Muss");
let json = json!({ "marktlokation": { "marktlokationsId": "51238696781" } });
let errors = validate_pid_json(&json, &reqs);
assert_eq!(missing_fields(&errors), vec!["ruhendeMarktlokationsId"]);
}
#[test]
fn multi_variant_entity_uses_the_elements_own_variant() {
let reqs = multi_variant_requirements();
let json = json!({
"geschaeftspartner": [
{ "partnerrolle": "Z03", "adresse": { "ort": "Berlin" } },
{ "partnerrolle": "kundeMsb", "name1": "Muster" },
{ "partnerrolle": { "code": "messlokationsadresse", "meaning": "x" },
"adresse": { "ort": "Köln" } },
{ "partnerrolle": { "code": "Z07" }, "name1": "Beispiel" },
]
});
let errors = validate_pid_json(&json, &reqs);
assert!(errors.is_empty(), "{}", ValidationReport(errors));
}
#[test]
fn multi_variant_entity_reports_variant_required_fields() {
let reqs = multi_variant_requirements();
let json = json!({ "geschaeftspartner": [{ "partnerrolle": "kundeMsb" }] });
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1, "{}", ValidationReport(errors));
assert!(matches!(
&errors[0],
PidValidationError::MissingField { field, .. } if field == "name1"
));
}
#[test]
fn multi_variant_entity_unknown_qualifier_is_invalid_code_only() {
let reqs = multi_variant_requirements();
for bad in [json!("Z99"), json!("bogus"), json!({ "code": "Z99" })] {
let json = json!({ "geschaeftspartner": [{ "partnerrolle": bad }] });
let errors = validate_pid_json(&json, &reqs);
assert_eq!(errors.len(), 1, "{bad}: {}", ValidationReport(errors));
match &errors[0] {
PidValidationError::InvalidCode {
field,
valid_values,
..
} => {
assert_eq!(field, "partnerrolle");
let codes: Vec<&str> = valid_values.iter().map(|(c, _)| c.as_str()).collect();
assert_eq!(codes, ["Z03", "Z07"]);
}
other => panic!("expected InvalidCode, got {other:?}"),
}
}
}
#[test]
fn code_objects_and_enum_mapped_names_are_code_checked() {
let f = field("partnerrolle", "X", &[("Z07", "kundeMsb")]);
assert_eq!(invalid_code_value(&json!("Z07"), &f), None);
assert_eq!(invalid_code_value(&json!("kundeMsb"), &f), None);
assert_eq!(invalid_code_value(&json!({ "code": "kundeMsb" }), &f), None);
assert_eq!(invalid_code_value(&json!({ "code": "Z07" }), &f), None);
assert_eq!(
invalid_code_value(&json!({ "code": "Z99", "meaning": null }), &f),
Some("Z99".to_string())
);
assert_eq!(
invalid_code_value(&json!("kundeLf"), &f),
Some("kundeLf".to_string())
);
assert_eq!(invalid_code_value(&json!(7), &f), None);
assert_eq!(invalid_code_value(&json!({ "meaning": "x" }), &f), None);
}
}