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, KindSelector, NestedElementKind, NestedListRequirement, PidRequirements,
VariantQualifier,
};
#[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> {
if let Some((list, rest)) = path.split_once("[].") {
return get_nested(json, list)?
.as_array()?
.iter()
.find_map(|item| get_nested(item, rest).filter(|v| !v.is_null()));
}
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);
}
}
}
validate_nested_lists(
&entity_req.entity,
entity_json,
&entity_req.nested,
"",
&|status, _| is_unconditionally_required(status).then_some(Severity::Error),
&|_| None,
errors,
);
}
pub fn validate_nested_lists(
entity: &str,
element: &Value,
lists: &[NestedListRequirement],
prefix: &str,
required: &dyn Fn(&str, &[(String, usize)]) -> Option<Severity>,
variant_holds: &dyn Fn(&VariantQualifier) -> Option<bool>,
errors: &mut Vec<PidValidationError>,
) {
nested_lists_at(
entity,
element,
lists,
prefix,
&mut Vec::new(),
required,
variant_holds,
errors,
);
}
#[allow(clippy::too_many_arguments)]
fn nested_lists_at(
entity: &str,
element: &Value,
lists: &[NestedListRequirement],
prefix: &str,
position: &mut Vec<(String, usize)>,
required: &dyn Fn(&str, &[(String, usize)]) -> Option<Severity>,
variant_holds: &dyn Fn(&VariantQualifier) -> Option<bool>,
errors: &mut Vec<PidValidationError>,
) {
let applies = |kind: &NestedElementKind| {
kind.transaction_variant
.as_ref()
.map_or(Some(true), variant_holds)
};
let mut fields: Vec<&str> = lists.iter().map(|l| l.field.as_str()).collect();
fields.sort_unstable();
fields.dedup();
for field in fields {
let candidates: Vec<&NestedListRequirement> = lists
.iter()
.filter(|l| l.field == field)
.filter(|l| {
l.parent_selector
.iter()
.all(|selector| selector_matches(selector, element))
})
.collect();
if candidates.is_empty() {
continue;
}
let items: &[Value] = get_nested(element, field)
.and_then(|v| v.as_array())
.map_or(&[], |a| a.as_slice());
let label = format!("{prefix}{field}");
let alone = candidates.len() == 1;
let chosen = match &candidates[..] {
[only] => Some(*only),
_ if items.is_empty() => None,
_ => {
let fitting: Vec<&&NestedListRequirement> = candidates
.iter()
.filter(|l| items.iter().all(|item| kind_of(l, item, false).is_some()))
.collect();
match fitting[..] {
[one] => Some(*one),
_ => None,
}
}
};
if let Some(list) = chosen {
for kind in &list.kinds {
if applies(kind) != Some(true) {
continue;
}
let present = if alone && list.kinds.len() == 1 {
!items.is_empty()
} else {
items.iter().any(|item| kind_matches(kind, item))
};
if present {
continue;
}
if let Some(severity) = required(&kind.ahb_status, position) {
let codes = kind.selector.first().map_or(&[][..], |s| &s.codes[..]);
let name = if alone && list.kinds.len() == 1 {
String::new()
} else {
codes
.first()
.map(|c| c.bo4e_value.clone().unwrap_or_else(|| c.code.clone()))
.unwrap_or_default()
};
errors.push(PidValidationError::MissingField {
entity: entity.to_string(),
field: format!("{label}[{name}]"),
ahb_status: kind.ahb_status.clone(),
rust_type: None,
valid_values: code_values_to_tuples(codes),
severity,
});
}
}
}
for (index, item) in items.iter().enumerate() {
position.push((field.to_string(), index));
let here = position.clone();
let required_here = |status: &str| required(status, &here);
let matching: Vec<(&NestedListRequirement, &NestedElementKind)> = match chosen {
Some(list) => kind_of(list, item, alone)
.filter(|k| applies(k) != Some(false))
.map(|k| (list, k))
.into_iter()
.collect(),
None => candidates
.iter()
.flat_map(|l| {
l.kinds
.iter()
.filter(|k| applies(k) != Some(false))
.filter(|k| kind_matches(k, item))
.map(move |k| (*l, k))
})
.collect(),
};
let best = matching
.iter()
.map(|(l, k)| {
(
*l,
k,
element_findings(entity, &label, k, item, &required_here),
)
})
.min_by_key(|(_, k, f)| (f.len(), k.selector.is_empty()))
.map(|(l, _, f)| (l, f));
if let Some((list, findings)) = best {
errors.extend(findings);
nested_lists_at(
entity,
item,
&list.nested,
&format!("{label}[]."),
position,
required,
variant_holds,
errors,
);
}
position.pop();
}
}
}
fn kind_of<'l>(
list: &'l NestedListRequirement,
item: &Value,
alone: bool,
) -> Option<&'l NestedElementKind> {
match &list.kinds[..] {
[only] if alone => Some(only),
kinds => kinds.iter().find(|k| kind_matches(k, item)),
}
}
fn kind_matches(kind: &NestedElementKind, item: &Value) -> bool {
kind.selector
.iter()
.all(|selector| selector_matches(selector, item))
}
fn selector_matches(selector: &KindSelector, item: &Value) -> bool {
get_nested(item, &selector.field)
.and_then(code_field_value)
.is_some_and(|value| {
selector
.codes
.iter()
.any(|c| c.code == value || c.bo4e_value.as_deref() == Some(value))
})
}
fn element_findings(
entity: &str,
label: &str,
kind: &NestedElementKind,
item: &Value,
required: &dyn Fn(&str) -> Option<Severity>,
) -> Vec<PidValidationError> {
let mut out = Vec::new();
for field_req in &kind.fields {
let name = format!("{label}[].{}", field_req.bo4e_name);
let values = all_nested(item, &field_req.bo4e_name);
if values.is_empty() {
if let Some(severity) = required(&field_req.ahb_status) {
out.push(PidValidationError::MissingField {
entity: entity.to_string(),
field: name,
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,
});
}
continue;
}
for value in values {
if let Some(value) = invalid_code_value(value, field_req) {
out.push(PidValidationError::InvalidCode {
entity: entity.to_string(),
field: name.clone(),
value,
valid_values: code_values_to_tuples(&field_req.valid_codes),
});
}
}
}
out
}
fn all_nested<'a>(json: &'a Value, path: &str) -> Vec<&'a Value> {
if let Some((list, rest)) = path.split_once("[].") {
return get_nested(json, list)
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.flat_map(|item| all_nested(item, rest))
.collect()
})
.unwrap_or_default();
}
get_nested(json, path)
.filter(|v| !v.is_null())
.into_iter()
.collect()
}
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::*;
#[test]
fn a_variant_bound_kind_is_demanded_only_in_its_variant() {
use crate::pid_requirements::{NestedElementKind, NestedListRequirement, VariantQualifier};
let lists = vec![NestedListRequirement {
field: "zuordnungen".to_string(),
bo4e_type: "Zuordnung".to_string(),
source_path: "sg4.sg8_z22.sg10".to_string(),
kinds: vec![NestedElementKind {
ahb_status: "Muss".to_string(),
selector: vec![],
fields: vec![],
transaction_variant: Some(VariantQualifier {
group: "SG4".to_string(),
segment: "IDE".to_string(),
codes: vec!["24".to_string()],
}),
}],
parent_selector: vec![],
nested: vec![],
}];
let demanded = |holds: Option<bool>| {
let mut errors = Vec::new();
validate_nested_lists(
"SummenzeitreihenDaten",
&serde_json::json!({}),
&lists,
"",
&|_, _| Some(Severity::Error),
&|_| holds,
&mut errors,
);
errors.len()
};
assert_eq!(demanded(Some(true)), 1);
assert_eq!(demanded(Some(false)), 0);
assert_eq!(demanded(None), 0);
}
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,
},
],
nested: Vec::new(),
},
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,
},
],
nested: Vec::new(),
},
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,
}],
nested: Vec::new(),
},
],
}
}
#[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,
},
],
nested: Vec::new(),
}],
};
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,
}],
nested: Vec::new(),
}],
};
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,
},
],
nested: Vec::new(),
}],
};
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,
}],
nested: Vec::new(),
}],
};
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![],
nested: Vec::new(),
}],
}
}
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"],
),
],
nested: Vec::new(),
}],
}
}
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);
}
}