use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::definition::{FieldMapping, MappingDefinition};
use crate::error::MappingError;
use crate::pid_schema_index::PidSchemaIndex;
use crate::MappingEngine;
pub const UNCAPPED_SENTINEL: u32 = 9999;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cardinality {
pub min: u32,
pub max: Option<u32>,
}
impl Cardinality {
pub const REQUIRED: Self = Self {
min: 1,
max: Some(1),
};
pub const OPTIONAL: Self = Self {
min: 0,
max: Some(1),
};
pub const LIST: Self = Self { min: 0, max: None };
pub const NON_EMPTY: Self = Self { min: 1, max: None };
pub fn unbounded(self) -> bool {
self.max.is_none()
}
pub fn max_or(self, fallback: u32) -> u32 {
self.max.unwrap_or(fallback)
}
pub fn is_list(self) -> bool {
self.max.map(|m| m > 1).unwrap_or(true)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Bo4ePrimitive {
String,
Integer,
Decimal,
Boolean,
Date,
DateTime,
Null,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Bo4eRefType {
Primitive {
primitive: Bo4ePrimitive,
cardinality: Cardinality,
},
Enum {
type_name: String,
cardinality: Cardinality,
},
Object {
type_name: String,
cardinality: Cardinality,
},
Unknown,
}
impl Bo4eRefType {
pub fn cardinality(&self) -> Option<Cardinality> {
match self {
Self::Primitive { cardinality, .. }
| Self::Enum { cardinality, .. }
| Self::Object { cardinality, .. } => Some(*cardinality),
Self::Unknown => None,
}
}
pub fn object_type_name(&self) -> Option<&str> {
if let Self::Object { type_name, .. } = self {
Some(type_name)
} else {
None
}
}
pub fn enum_type_name(&self) -> Option<&str> {
if let Self::Enum { type_name, .. } = self {
Some(type_name)
} else {
None
}
}
}
pub(crate) fn cardinality_from_reps(min: u32, max: u32) -> Cardinality {
Cardinality {
min,
max: if max >= UNCAPPED_SENTINEL {
None
} else {
Some(max)
},
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PidRequirements {
pub pid: String,
pub beschreibung: String,
pub entities: Vec<EntityRequirement>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntityScope {
Message,
Transaction,
}
#[derive(Debug, Clone, Serialize)]
pub struct EntityRequirement {
pub entity: String,
pub ref_type: Bo4eRefType,
pub ahb_status: String,
pub fields: Vec<FieldRequirement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub map_key: Option<EntityMapKeyInfo>,
#[serde(default = "default_scope")]
pub scope: EntityScope,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub variants: Vec<EntityVariantRequirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<EntityGroupRequirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityGroupRequirement {
pub source_path: String,
pub ahb_status: String,
pub fields: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityVariantRequirement {
pub discriminator_field: String,
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bo4e_value: Option<String>,
#[serde(default)]
pub source_paths: Vec<String>,
pub fields: Vec<FieldRequirement>,
}
impl<'de> serde::Deserialize<'de> for EntityRequirement {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
struct Helper {
entity: String,
#[serde(default)]
ref_type: Option<Bo4eRefType>,
#[serde(default)]
bo4e_type: Option<String>,
#[serde(default)]
min_reps: Option<u32>,
#[serde(default)]
max_reps: Option<u32>,
#[serde(default)]
ahb_status: String,
#[serde(default)]
fields: Vec<FieldRequirement>,
#[serde(default)]
map_key: Option<EntityMapKeyInfo>,
#[serde(default = "default_scope")]
scope: EntityScope,
#[serde(default)]
variants: Vec<EntityVariantRequirement>,
#[serde(default)]
groups: Vec<EntityGroupRequirement>,
}
let h = Helper::deserialize(d)?;
let ref_type = h.ref_type.unwrap_or_else(|| {
let min = h.min_reps.unwrap_or(0);
let max = h.max_reps.unwrap_or(1);
Bo4eRefType::Object {
type_name: h.bo4e_type.unwrap_or_default(),
cardinality: cardinality_from_reps(min, max),
}
});
Ok(EntityRequirement {
entity: h.entity,
ref_type,
ahb_status: h.ahb_status,
fields: h.fields,
map_key: h.map_key,
scope: h.scope,
variants: h.variants,
groups: h.groups,
})
}
}
impl EntityRequirement {
pub fn bo4e_type(&self) -> &str {
self.ref_type.object_type_name().unwrap_or("")
}
pub fn cardinality(&self) -> Cardinality {
self.ref_type.cardinality().unwrap_or(Cardinality::REQUIRED)
}
}
fn default_scope() -> EntityScope {
EntityScope::Transaction
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityMapKeyInfo {
pub field: String,
pub values: Vec<EntityMapKeyValue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityMapKeyValue {
pub code: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldRequirement {
pub bo4e_name: String,
pub ahb_status: String,
pub field_type: String,
pub format: Option<String>,
pub enum_name: Option<String>,
pub valid_codes: Vec<CodeValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub child_group: Option<ChildGroupInfo>,
#[serde(default = "default_unknown_ref")]
pub ref_type: Bo4eRefType,
}
fn default_unknown_ref() -> Bo4eRefType {
Bo4eRefType::Unknown
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChildGroupInfo {
pub name: String,
pub max_reps: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeValue {
pub code: String,
pub meaning: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bo4e_value: Option<String>,
}
pub fn load_definitions_for_pid(
common_dir: &Path,
pid_dir: &Path,
message_dir: &Path,
schema: &Value,
) -> Result<Vec<MappingDefinition>, MappingError> {
let mut all_defs = Vec::new();
if message_dir.exists() {
let msg_engine = MappingEngine::load(message_dir)?;
all_defs.extend(msg_engine.definitions().to_vec());
}
let schema_index = PidSchemaIndex::from_json(schema);
if common_dir.exists() && pid_dir.exists() {
let tx_engine = MappingEngine::load_with_common(common_dir, pid_dir, &schema_index)?;
all_defs.extend(tx_engine.definitions().to_vec());
} else if common_dir.exists() {
let common_engine = MappingEngine::load_common_only(common_dir, &schema_index)?;
all_defs.extend(common_engine.definitions().to_vec());
} else if pid_dir.exists() {
let pid_engine = MappingEngine::load(pid_dir)?;
all_defs.extend(pid_engine.definitions().to_vec());
}
Ok(all_defs)
}
struct EntityBuilder {
bo4e_type: String,
ahb_status: String,
min_reps: u32,
max_reps: u32,
fields: BTreeMap<String, FieldRequirement>,
map_key: Option<EntityMapKeyInfo>,
source_groups: BTreeSet<String>,
scope: EntityScope,
variants: BTreeMap<(String, String), VariantBuilder>,
groups: BTreeMap<String, (String, BTreeSet<String>)>,
}
#[derive(Default)]
struct VariantBuilder {
bo4e_value: Option<String>,
source_paths: BTreeSet<String>,
fields: BTreeMap<String, FieldRequirement>,
}
impl PidRequirements {
pub fn from_schema_and_definitions(schema: &Value, definitions: &[MappingDefinition]) -> Self {
Self::from_schema_definitions_and_code_lists(
schema,
definitions,
&crate::code_lists::CodeLists::default(),
)
}
pub fn from_schema_definitions_and_code_lists(
schema: &Value,
definitions: &[MappingDefinition],
code_lists: &crate::code_lists::CodeLists,
) -> Self {
let pid = schema
.get("pid")
.and_then(|v| {
v.as_u64()
.map(|n| n.to_string())
.or_else(|| v.as_str().map(|s| s.to_string()))
})
.unwrap_or_default();
let beschreibung = schema
.get("beschreibung")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let root_group_json = schema
.get("root_segments")
.map(|rs| serde_json::json!({ "segments": rs }));
let mut entity_map: BTreeMap<String, EntityBuilder> = BTreeMap::new();
for def in definitions {
if def.meta.parent_field.is_some() {
continue;
}
let source_path = def.meta.source_path.as_deref().unwrap_or("");
let group_json_owned: Value;
let mut fuzzy_resolved = false;
let group_json: &Value = if source_path.is_empty() {
if let Some(g) = resolve_schema_group_fuzzy(schema, &def.meta.source_group, None) {
group_json_owned = g;
fuzzy_resolved = true;
&group_json_owned
} else {
match &root_group_json {
Some(g) => g,
None => continue,
}
}
} else {
match resolve_schema_group(schema, source_path) {
Some(g) => g,
None => {
if let Some(g) =
resolve_schema_group_fuzzy(schema, &def.meta.source_group, None)
{
group_json_owned = g;
fuzzy_resolved = true;
&group_json_owned
} else {
continue;
}
}
}
};
if let Some(ref disc) = def.meta.discriminator {
let declarations = if fuzzy_resolved {
fuzzy_group_variants(schema, &def.meta.source_group)
} else {
Vec::new()
};
if !discriminator_matches_schema_group(disc, group_json, &declarations) {
continue;
}
}
let parent_ahb = if def.meta.source_group.is_empty() {
"Muss"
} else {
group_json
.get("ahb_status")
.and_then(|v| v.as_str())
.unwrap_or("")
};
let source_parts: Vec<&str> = def.meta.source_group.split('.').collect();
let raw_max_reps = group_json
.get("max_reps")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32;
let depth = source_parts.len();
let is_message_level = depth == 1 && source_parts[0] != "SG4";
let contributes_cardinality = is_message_level || depth == 2;
let builder = entity_map
.entry(def.meta.entity.clone())
.or_insert_with(|| {
let scope = if source_parts.first().map(|s| s.to_uppercase())
== Some("SG4".to_string())
{
EntityScope::Transaction
} else {
EntityScope::Message
};
EntityBuilder {
bo4e_type: def.meta.bo4e_type.clone(),
ahb_status: parent_ahb.to_string(),
min_reps: if parent_ahb.trim() == "X" { 1 } else { 0 },
max_reps: if contributes_cardinality {
raw_max_reps
} else {
1
},
fields: BTreeMap::new(),
map_key: None,
source_groups: BTreeSet::new(),
scope,
variants: BTreeMap::new(),
groups: BTreeMap::new(),
}
});
builder.source_groups.insert(def.meta.source_group.clone());
if contributes_cardinality {
builder.max_reps = builder.max_reps.max(raw_max_reps);
}
if !parent_ahb.is_empty() && builder.ahb_status.is_empty() {
builder.ahb_status = parent_ahb.to_string();
}
let child_group_info = if source_parts.len() >= 3 {
let child_name = source_parts.last().unwrap().to_lowercase();
Some(ChildGroupInfo {
name: child_name,
max_reps: raw_max_reps.min(i32::MAX as u32) as i32,
})
} else {
None
};
let fields_before: std::collections::BTreeSet<String> =
builder.fields.keys().cloned().collect();
let group_key = if source_path.is_empty() {
def.meta.source_group.clone()
} else {
source_path.to_string()
};
let (group_status, group_fields) = builder
.groups
.entry(group_key)
.or_insert_with(|| (parent_ahb.to_string(), BTreeSet::new()));
if group_status.is_empty() {
*group_status = parent_ahb.to_string();
}
group_fields.extend(
def.fields
.values()
.filter_map(extract_target_name)
.filter(|name| !name.is_empty()),
);
let field_group_owned = if fuzzy_resolved {
resolve_schema_group_fuzzy(
schema,
&def.meta.source_group,
def.meta.discriminator.as_deref(),
)
} else {
None
};
let field_group = field_group_owned.as_ref().unwrap_or(group_json);
process_field_section(
&def.fields,
field_group,
parent_ahb,
&mut builder.fields,
true,
code_lists,
def.meta.discriminator.as_deref(),
);
if let Some(ref cg) = child_group_info {
for (key, field) in builder.fields.iter_mut() {
if !fields_before.contains(key) && field.child_group.is_none() {
field.child_group = Some(cg.clone());
}
}
}
if fuzzy_resolved {
add_variant_requirements(
schema,
def,
child_group_info.as_ref(),
&mut builder.variants,
code_lists,
);
}
}
for (entity_name, builder) in entity_map.iter_mut() {
if builder.map_key.is_some() {
continue;
}
let detected = detect_entity_map_key(entity_name, &builder.source_groups, schema);
if let Some(mk) = detected {
builder.map_key = Some(mk);
if builder.max_reps <= 1 {
builder.max_reps = 2; }
}
}
let entities: Vec<EntityRequirement> = entity_map
.into_iter()
.map(|(name, builder)| {
let cardinality = cardinality_from_reps(builder.min_reps, builder.max_reps);
EntityRequirement {
entity: name,
ref_type: Bo4eRefType::Object {
type_name: builder.bo4e_type,
cardinality,
},
ahb_status: builder.ahb_status,
fields: builder.fields.into_values().collect(),
map_key: builder.map_key,
scope: builder.scope,
variants: builder
.variants
.into_iter()
.map(|((field, code), vb)| EntityVariantRequirement {
discriminator_field: field,
code,
bo4e_value: vb.bo4e_value,
source_paths: vb.source_paths.into_iter().collect(),
fields: vb.fields.into_values().collect(),
})
.collect(),
groups: if builder.groups.len() > 1 {
builder
.groups
.into_iter()
.map(
|(source_path, (ahb_status, fields))| EntityGroupRequirement {
source_path,
ahb_status,
fields: fields.into_iter().collect(),
},
)
.collect()
} else {
Vec::new()
},
}
})
.collect();
PidRequirements {
pid,
beschreibung,
entities,
}
}
}
const MAP_KEY_RULES: &[(&str, &str, &str)] = &[
("Marktteilnehmer", "SG2", "marktrolle"),
("Geschaeftspartner", "SG12", "nad_qualifier"),
("Kontakt", "SG3", "ctaFunctionCode"),
];
fn detect_entity_map_key(
entity_name: &str,
source_groups: &BTreeSet<String>,
schema: &Value,
) -> Option<EntityMapKeyInfo> {
let (_, sg_pattern, key_field) = MAP_KEY_RULES.iter().find(|(ent, sg_pat, _)| {
*ent == entity_name
&& source_groups
.iter()
.any(|sg| sg == *sg_pat || sg.contains(sg_pat))
})?;
let fields = schema.get("fields")?.as_object()?;
let sg_lower = sg_pattern.to_lowercase();
if let Some(group_json) = fields.get(&sg_lower) {
if let Some(info) = detect_map_key_from_group(group_json, key_field) {
return Some(info);
}
}
let info = aggregate_split_child_codes(fields, &sg_lower, key_field);
if info.is_some() {
return info;
}
for sg in source_groups {
let parts: Vec<&str> = sg.split('.').collect();
if parts.len() >= 2 {
let parent_lower = parts[0].to_lowercase();
let child_prefix = parts[1].to_lowercase();
if child_prefix.starts_with(&sg_lower) || sg_lower.starts_with(&child_prefix) {
if let Some(parent) = find_group_in_fields(fields, &parent_lower) {
if let Some(children) = parent.get("children").and_then(|c| c.as_object()) {
let info = aggregate_codes_from_children(children, &sg_lower, key_field);
if info.is_some() {
return info;
}
}
}
}
}
}
None
}
fn find_group_in_fields<'a>(
fields: &'a serde_json::Map<String, Value>,
group_lower: &str,
) -> Option<&'a Value> {
if let Some(v) = fields.get(group_lower) {
return Some(v);
}
let prefix = format!("{group_lower}_");
for (k, v) in fields {
if k.starts_with(&prefix) {
return Some(v);
}
}
None
}
fn detect_map_key_from_group(group_json: &Value, key_field: &str) -> Option<EntityMapKeyInfo> {
let disc = group_json.get("discriminator")?;
let disc_element = disc.get("element")?.as_str()?;
let disc_segment = disc.get("segment")?.as_str()?;
let mut values: Vec<EntityMapKeyValue> = Vec::new();
let mut seen_codes: BTreeSet<String> = BTreeSet::new();
if let Some(segments) = group_json.get("segments").and_then(|s| s.as_array()) {
for seg in segments {
let seg_id = seg.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if seg_id != disc_segment {
continue;
}
if let Some(elements) = seg.get("elements").and_then(|e| e.as_array()) {
for el in elements {
let el_id = el.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if el_id != disc_element {
continue;
}
collect_codes_from_element(el, &mut seen_codes, &mut values);
}
}
}
}
if let Some(disc_values) = disc.get("values").and_then(|v| v.as_array()) {
for val in disc_values {
if let Some(code) = val.as_str() {
if seen_codes.insert(code.to_string()) {
values.push(EntityMapKeyValue {
code: code.to_string(),
name: String::new(),
});
}
}
}
}
if values.len() >= 2 {
Some(EntityMapKeyInfo {
field: key_field.to_string(),
values,
})
} else {
None
}
}
fn collect_codes_from_element(
el: &Value,
seen_codes: &mut BTreeSet<String>,
values: &mut Vec<EntityMapKeyValue>,
) {
if let Some(codes) = el.get("codes").and_then(|c| c.as_array()) {
for code_obj in codes {
if let Some(code) = code_obj.get("value").and_then(|v| v.as_str()) {
if seen_codes.insert(code.to_string()) {
let name = code_obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
values.push(EntityMapKeyValue {
code: code.to_string(),
name,
});
}
}
}
}
if let Some(components) = el.get("components").and_then(|c| c.as_array()) {
for comp in components {
collect_codes_from_element(comp, seen_codes, values);
}
}
}
fn aggregate_split_child_codes(
fields: &serde_json::Map<String, Value>,
sg_lower: &str,
key_field: &str,
) -> Option<EntityMapKeyInfo> {
aggregate_codes_from_children(fields, sg_lower, key_field)
}
fn aggregate_codes_from_children(
children: &serde_json::Map<String, Value>,
prefix: &str,
key_field: &str,
) -> Option<EntityMapKeyInfo> {
let prefix_underscore = format!("{prefix}_");
let mut values: Vec<EntityMapKeyValue> = Vec::new();
let mut seen_codes: BTreeSet<String> = BTreeSet::new();
for (k, child) in children {
if !k.starts_with(&prefix_underscore) && k != prefix {
continue;
}
if let Some(suffix) = k.strip_prefix(&prefix_underscore) {
let code = suffix.to_uppercase();
if seen_codes.insert(code.clone()) {
let name = child
.get("beschreibung")
.or_else(|| child.get("name"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
values.push(EntityMapKeyValue { code, name });
}
}
if let Some(disc) = child.get("discriminator") {
if let Some(disc_values) = disc.get("values").and_then(|v| v.as_array()) {
for val in disc_values {
if let Some(code) = val.as_str() {
if seen_codes.insert(code.to_string()) {
values.push(EntityMapKeyValue {
code: code.to_string(),
name: String::new(),
});
}
}
}
}
}
}
if values.len() >= 2 {
Some(EntityMapKeyInfo {
field: key_field.to_string(),
values,
})
} else {
None
}
}
fn discriminator_site(disc: &str, group_json: &Value) -> Option<(u64, u64)> {
let lhs = disc.split('=').next()?;
let mut parts = lhs.split('.');
let seg_tag = parts.next()?;
let rest: Vec<&str> = parts.collect();
if let [el_index, sub_index] = rest[..] {
if let (Ok(el_index), Ok(sub_index)) = (el_index.parse(), sub_index.parse()) {
return Some((el_index, sub_index));
}
}
let element_id = rest
.last()?
.strip_prefix('d')
.filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))?;
group_json
.get("segments")?
.as_array()?
.iter()
.filter(|seg| {
seg.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id.eq_ignore_ascii_case(seg_tag))
})
.find_map(|seg| {
for el in seg.get("elements")?.as_array()? {
let index = el.get("index").and_then(|v| v.as_u64())?;
match el.get("components").and_then(|c| c.as_array()) {
Some(components) => {
for c in components {
if c.get("id").and_then(|v| v.as_str()) == Some(element_id) {
return Some((index, c.get("sub_index")?.as_u64()?));
}
}
}
None if el.get("id").and_then(|v| v.as_str()) == Some(element_id) => {
return Some((index, 0))
}
None => {}
}
}
None
})
}
fn discriminator_element(disc: &str, group_json: &Value) -> Option<String> {
let lhs = disc.split('=').next()?;
let seg_id = lhs.split('.').next()?;
if let Some(id) = lhs
.rsplit('.')
.next()
.and_then(|last| last.strip_prefix('d'))
.filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
{
return Some(id.to_string());
}
let (el_index, sub_index) = discriminator_site(disc, group_json)?;
let segments = group_json.get("segments")?.as_array()?;
segments
.iter()
.filter(|seg| seg.get("id").and_then(|v| v.as_str()) == Some(seg_id))
.filter_map(|seg| {
let el = seg
.get("elements")?
.as_array()?
.iter()
.find(|el| el.get("index").and_then(|v| v.as_u64()) == Some(el_index))?;
match el.get("components").and_then(|v| v.as_array()) {
Some(components) => components
.iter()
.find(|c| c.get("sub_index").and_then(|v| v.as_u64()) == Some(sub_index))
.and_then(|c| c.get("id")),
None if sub_index == 0 => el.get("id"),
None => None,
}
.and_then(|v| v.as_str())
.map(str::to_string)
})
.next()
}
fn discriminator_matches_schema_group(
disc: &str,
group_json: &Value,
declarations: &[&Value],
) -> bool {
let Some((_, disc_value)) = disc.split_once('=') else {
return true; };
let Some(schema_disc) = group_json.get("discriminator") else {
return discriminator_names_a_declaration(declarations, disc).unwrap_or(true);
};
let Some(disc_element) = discriminator_element(disc, group_json).or_else(|| {
schema_disc
.get("element")
.and_then(|v| v.as_str())
.map(str::to_string)
}) else {
return true;
};
let disc_element = disc_element.as_str();
let Some(segments) = group_json.get("segments").and_then(|v| v.as_array()) else {
return true;
};
let mut found_element = false;
let mut all_codes: Vec<String> = Vec::new();
for seg in segments {
let elements = match seg.get("elements").and_then(|v| v.as_array()) {
Some(e) => e,
None => continue,
};
for el in elements {
let mut collect_codes = |element: &Value| -> bool {
let Some(id) = element.get("id").and_then(|v| v.as_str()) else {
return false;
};
if id != disc_element {
return false;
}
if let Some(codes) = element.get("codes").and_then(|v| v.as_array()) {
for c in codes {
if let Some(v) = c.get("value").and_then(|v| v.as_str()) {
all_codes.push(v.to_string());
}
}
}
true
};
if collect_codes(el) {
found_element = true;
}
if let Some(components) = el.get("components").and_then(|v| v.as_array()) {
for comp in components {
if collect_codes(comp) {
found_element = true;
}
}
}
}
}
if !found_element {
return true; }
discriminator_values(disc_value).any(|v| all_codes.iter().any(|c| c == v))
}
fn resolve_schema_group_fuzzy(
schema: &Value,
source_group: &str,
discriminator: Option<&str>,
) -> Option<Value> {
let mut current_groups = fuzzy_group_variants(schema, source_group);
if current_groups.is_empty() {
return None;
}
if let Some(disc) = discriminator {
let chosen: Vec<&Value> = current_groups
.iter()
.copied()
.filter(|g| group_carries_discriminator(g, disc))
.collect();
if !chosen.is_empty() {
current_groups = chosen;
}
}
merge_groups_segments(¤t_groups)
}
fn fuzzy_group_variants<'a>(schema: &'a Value, source_group: &str) -> Vec<&'a Value> {
let parts: Vec<String> = source_group.split('.').map(|p| p.to_lowercase()).collect();
let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) else {
return Vec::new();
};
let Some((first, rest)) = parts.split_first() else {
return Vec::new();
};
let mut current: Vec<&Value> = find_all_matching_values(fields, first);
for part in rest {
let mut next = Vec::new();
for group in ¤t {
if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
next.extend(find_all_matching_values(children, part));
}
}
if next.is_empty() {
return Vec::new();
}
current = next;
}
current
}
fn group_carries_discriminator(group: &Value, discriminator: &str) -> bool {
discriminator_names_a_declaration(&[group], discriminator) == Some(true)
}
fn discriminator_names_a_declaration(declarations: &[&Value], discriminator: &str) -> Option<bool> {
let (lhs, rhs) = discriminator.split_once('=')?;
let tag = lhs.split('.').next()?;
let mut enumerated = false;
for group in declarations {
let site = discriminator_site(discriminator, group);
let Some(segments) = group.get("segments").and_then(|s| s.as_array()) else {
continue;
};
for seg in segments {
let same_tag = seg
.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id.eq_ignore_ascii_case(tag));
if !same_tag {
continue;
}
let Some(codes) = codes_at(seg, site) else {
continue;
};
enumerated = true;
if discriminator_values(rhs).any(|v| codes.contains(&v)) {
return Some(true);
}
}
}
enumerated.then_some(false)
}
fn discriminator_values(rhs: &str) -> impl Iterator<Item = &str> {
rhs.split('|')
.map(|v| v.split('#').next().unwrap_or(v))
.filter(|v| !v.is_empty())
}
fn find_all_matching_values<'a>(
obj: &'a serde_json::Map<String, Value>,
prefix: &str,
) -> Vec<&'a Value> {
let mut results = Vec::new();
if let Some(v) = obj.get(prefix) {
results.push(v);
return results;
}
let prefix_underscore = format!("{prefix}_");
for (k, v) in obj {
if k.starts_with(&prefix_underscore) {
results.push(v);
}
}
results
}
fn merge_groups_segments(groups: &[&Value]) -> Option<Value> {
let mut merged_segments: Vec<Value> = Vec::new();
let mut ahb_status = String::new();
for group in groups {
if ahb_status.is_empty() {
if let Some(s) = group.get("ahb_status").and_then(|v| v.as_str()) {
ahb_status = s.to_string();
}
}
if let Some(segments) = group.get("segments").and_then(|v| v.as_array()) {
for seg in segments {
let seg_id = seg.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(existing) = merged_segments
.iter_mut()
.find(|s| s.get("id").and_then(|v| v.as_str()).unwrap_or("") == seg_id)
{
merge_segment_elements(existing, seg);
} else {
merged_segments.push(seg.clone());
}
}
}
}
if merged_segments.is_empty() {
return None;
}
let mut result = serde_json::json!({ "segments": merged_segments });
if !ahb_status.is_empty() {
result["ahb_status"] = Value::String(ahb_status);
}
let max_reps = groups
.iter()
.filter_map(|g| g.get("max_reps").and_then(|v| v.as_i64()))
.max();
if let Some(max_reps) = max_reps {
result["max_reps"] = serde_json::Value::Number(max_reps.into());
}
Some(result)
}
fn merge_segment_elements(target: &mut Value, source: &Value) {
let source_elements = match source.get("elements").and_then(|v| v.as_array()) {
Some(e) => e,
None => return,
};
let target_elements = match target.get_mut("elements").and_then(|v| v.as_array_mut()) {
Some(e) => e,
None => return,
};
for src_elem in source_elements {
if let Some(src_composite) = src_elem.get("composite").and_then(|v| v.as_str()) {
if let Some(tgt_elem) = target_elements
.iter_mut()
.find(|e| e.get("composite").and_then(|v| v.as_str()) == Some(src_composite))
{
merge_composite_components(tgt_elem, src_elem);
} else {
target_elements.push(src_elem.clone());
}
} else if let Some(src_id) = src_elem.get("id").and_then(|v| v.as_str()) {
if let Some(tgt_elem) = target_elements.iter_mut().find(|e| {
e.get("id").and_then(|v| v.as_str()) == Some(src_id) && e.get("composite").is_none()
}) {
merge_element_codes(tgt_elem, src_elem);
} else {
target_elements.push(src_elem.clone());
}
}
}
}
fn merge_composite_components(target: &mut Value, source: &Value) {
let source_comps = match source.get("components").and_then(|v| v.as_array()) {
Some(c) => c,
None => return,
};
let target_comps = match target.get_mut("components").and_then(|v| v.as_array_mut()) {
Some(c) => c,
None => return,
};
for src_comp in source_comps {
let src_id = src_comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(tgt_comp) = target_comps
.iter_mut()
.find(|c| c.get("id").and_then(|v| v.as_str()).unwrap_or("") == src_id)
{
merge_element_codes(tgt_comp, src_comp);
} else {
target_comps.push(src_comp.clone());
}
}
}
fn merge_element_codes(target: &mut Value, source: &Value) {
let Some(src_codes) = source.get("codes").and_then(|v| v.as_array()) else {
return;
};
if let Some(tgt_codes) = target.get_mut("codes").and_then(|v| v.as_array_mut()) {
for code in src_codes {
let code_val = code.get("value").and_then(|v| v.as_str()).unwrap_or("");
let already = tgt_codes
.iter()
.any(|c| c.get("value").and_then(|v| v.as_str()).unwrap_or("") == code_val);
if !already {
tgt_codes.push(code.clone());
}
}
} else if let Some(tgt_obj) = target.as_object_mut() {
tgt_obj.insert("codes".to_string(), Value::Array(src_codes.clone()));
if source.get("type").and_then(|v| v.as_str()) == Some("code") {
tgt_obj.insert("type".to_string(), Value::String("code".to_string()));
}
}
}
struct DiscriminatedGroup<'a> {
path: String,
group: &'a Value,
segment: String,
element: String,
codes: Vec<String>,
}
fn discriminated_groups<'a>(schema: &'a Value, source_group: &str) -> Vec<DiscriminatedGroup<'a>> {
let parts: Vec<String> = source_group.split('.').map(|p| p.to_lowercase()).collect();
let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) else {
return Vec::new();
};
let Some((first, rest)) = parts.split_first() else {
return Vec::new();
};
let mut current: Vec<(String, &Value)> = find_all_matching_entries(fields, first);
for part in rest {
let mut next = Vec::new();
for (path, group) in ¤t {
if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
for (key, child) in find_all_matching_entries(children, part) {
next.push((format!("{path}.{key}"), child));
}
}
}
current = next;
}
current
.into_iter()
.filter_map(|(path, group)| {
let disc = group.get("discriminator")?;
let segment = disc.get("segment")?.as_str()?.to_uppercase();
let element = disc.get("element")?.as_str()?.to_uppercase();
let codes: Vec<String> = disc
.get("values")?
.as_array()?
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
if codes.is_empty() {
return None;
}
Some(DiscriminatedGroup {
path,
group,
segment,
element,
codes,
})
})
.collect()
}
fn find_all_matching_entries<'a>(
obj: &'a serde_json::Map<String, Value>,
prefix: &str,
) -> Vec<(String, &'a Value)> {
if let Some(v) = obj.get(prefix) {
return vec![(prefix.to_string(), v)];
}
let prefix_underscore = format!("{prefix}_");
obj.iter()
.filter(|(k, _)| k.starts_with(&prefix_underscore))
.map(|(k, v)| (k.clone(), v))
.collect()
}
fn field_is_discriminator(path: &str, dg: &DiscriminatedGroup<'_>) -> bool {
let Some((seg_tag, composite_or_element, component_id)) = parse_toml_field_path(path) else {
return false;
};
if path.contains('[') || seg_tag.to_uppercase() != dg.segment {
return false;
}
let Some(elem) = find_schema_element(
dg.group,
&seg_tag,
&SegmentChoice::default(),
&composite_or_element,
component_id.as_deref(),
) else {
return false;
};
if elem
.get("id")
.and_then(|v| v.as_str())
.map(str::to_uppercase)
!= Some(dg.element.clone())
{
return false;
}
let elem_codes: BTreeSet<&str> = elem
.get("codes")
.and_then(|c| c.as_array())
.map(|codes| {
codes
.iter()
.filter_map(|c| c.get("value").and_then(|v| v.as_str()))
.collect()
})
.unwrap_or_default();
dg.codes.iter().all(|c| elem_codes.contains(c.as_str()))
}
fn add_variant_requirements(
schema: &Value,
def: &MappingDefinition,
child_group_info: Option<&ChildGroupInfo>,
variants: &mut BTreeMap<(String, String), VariantBuilder>,
code_lists: &crate::code_lists::CodeLists,
) {
let mut groups = discriminated_groups(schema, &def.meta.source_group);
if let Some(disc) = def.meta.discriminator.as_deref() {
if groups
.iter()
.any(|g| group_carries_discriminator(g.group, disc))
{
groups.retain(|g| group_carries_discriminator(g.group, disc));
}
}
let distinct_codes: BTreeSet<&str> = groups
.iter()
.flat_map(|g| g.codes.iter().map(String::as_str))
.collect();
if groups.len() < 2 || distinct_codes.len() < 2 {
return;
}
let Some((disc_target, disc_enum_map)) = def.fields.iter().find_map(|(path, fm)| {
let target = extract_target_name(fm).filter(|t| !t.is_empty())?;
if !groups.iter().all(|g| field_is_discriminator(path, g)) {
return None;
}
let enum_map = match fm {
FieldMapping::Structured(s) => s.enum_map.clone().or_else(|| {
s.code_list
.as_deref()
.and_then(|n| code_lists.get(n))
.cloned()
}),
_ => None,
};
Some((target, enum_map))
}) else {
return;
};
for code in distinct_codes {
let members: Vec<&DiscriminatedGroup<'_>> = groups
.iter()
.filter(|g| g.codes.iter().any(|c| c == code))
.collect();
let member_values: Vec<&Value> = members.iter().map(|g| g.group).collect();
let Some(merged) = merge_groups_segments(&member_values) else {
continue;
};
let parent_ahb = merged
.get("ahb_status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let vb = variants
.entry((disc_target.clone(), code.to_string()))
.or_default();
if vb.bo4e_value.is_none() {
vb.bo4e_value = disc_enum_map.as_ref().and_then(|m| m.get(code).cloned());
}
vb.source_paths
.extend(members.iter().map(|g| g.path.clone()));
let before: BTreeSet<String> = vb.fields.keys().cloned().collect();
process_field_section(
&def.fields,
&merged,
&parent_ahb,
&mut vb.fields,
false,
code_lists,
def.meta.discriminator.as_deref(),
);
if let Some(cg) = child_group_info {
for (key, field) in vb.fields.iter_mut() {
if !before.contains(key) && field.child_group.is_none() {
field.child_group = Some(cg.clone());
}
}
}
}
}
fn resolve_schema_group<'a>(schema: &'a Value, source_path: &str) -> Option<&'a Value> {
let parts: Vec<&str> = source_path.split('.').collect();
if parts.is_empty() {
return None;
}
let mut current = schema.get("fields")?.get(parts[0])?;
for part in &parts[1..] {
current = current.get("children")?.get(*part)?;
}
Some(current)
}
fn process_field_section(
section: &indexmap::IndexMap<String, FieldMapping>,
group_json: &Value,
parent_ahb: &str,
output: &mut BTreeMap<String, FieldRequirement>,
include_unresolved: bool,
code_lists: &crate::code_lists::CodeLists,
discriminator: Option<&str>,
) {
for (path, field_mapping) in section {
let bo4e_name = match extract_target_name(field_mapping) {
Some(name) if !name.is_empty() => name,
_ => continue, };
if output.contains_key(&bo4e_name) {
continue;
}
let parsed = match parse_toml_field_path(path) {
Some(p) => p,
None => continue,
};
let (seg_tag, composite_or_element, component_id) = parsed;
let choice: SegmentChoice = match field_path_qualifier(path) {
Some(q) => SegmentChoice {
codes: vec![q],
site: None,
},
None => discriminator_qualifiers(discriminator, &seg_tag, group_json),
};
let field = if let Some(schema_elem) = find_schema_element(
group_json,
&seg_tag,
&choice,
&composite_or_element,
component_id.as_deref(),
) {
let mut req = build_field_requirement(&schema_elem, &bo4e_name, parent_ahb);
merge_codes_from_all_segments(
group_json,
&seg_tag,
&choice,
&composite_or_element,
component_id.as_deref(),
&mut req.valid_codes,
);
if let FieldMapping::Structured(s) = field_mapping {
let map = s
.enum_map
.as_ref()
.or_else(|| s.code_list.as_deref().and_then(|n| code_lists.get(n)));
if let Some(map) = map {
for cv in &mut req.valid_codes {
if cv.bo4e_value.is_none() {
cv.bo4e_value = map.get(&cv.code).cloned();
}
}
}
}
relax_status_inside_optional_segment(
group_json,
&seg_tag,
&choice,
&mut req.ahb_status,
);
relax_status_of_shared_qualifier(group_json, &seg_tag, &choice, &mut req.ahb_status);
if choice.is_empty() {
scope_status_to_holding_segments(
group_json,
&seg_tag,
&composite_or_element,
component_id.as_deref(),
&mut req.ahb_status,
);
}
req
} else if !include_unresolved {
continue;
} else {
FieldRequirement {
bo4e_name: bo4e_name.clone(),
ahb_status: String::new(),
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: Vec::new(),
child_group: None,
ref_type: Bo4eRefType::Unknown,
}
};
output.insert(bo4e_name, field);
}
}
fn extract_target_name(fm: &FieldMapping) -> Option<String> {
match fm {
FieldMapping::Simple(s) => {
if s.is_empty() {
None
} else {
Some(s.clone())
}
}
FieldMapping::Structured(s) => {
if s.target.is_empty() {
None
} else {
Some(s.target.clone())
}
}
FieldMapping::Nested(_) => None,
}
}
fn parse_toml_field_path(path: &str) -> Option<(String, String, Option<String>)> {
let parts: Vec<&str> = path.split('.').collect();
if parts.len() < 2 || parts.len() > 3 {
return None;
}
let seg_tag = parts[0].split('[').next().unwrap_or(parts[0]).to_string();
let second = parts[1].to_string();
let third = if parts.len() == 3 {
Some(parts[2].to_string())
} else {
None
};
Some((seg_tag, second, third))
}
fn discriminator_qualifiers<'a>(
discriminator: Option<&'a str>,
seg_tag: &str,
group_json: &Value,
) -> SegmentChoice<'a> {
let empty = SegmentChoice::default();
let Some(disc) = discriminator else {
return empty;
};
let Some((lhs, rhs)) = disc.split_once('=') else {
return empty;
};
if !lhs
.split('.')
.next()
.is_some_and(|tag| tag.eq_ignore_ascii_case(seg_tag))
{
return empty;
}
let Some(segments) = group_json.get("segments").and_then(|v| v.as_array()) else {
return empty;
};
let site = discriminator_site(disc, group_json);
let same_tag: Vec<&Value> = segments
.iter()
.filter(|s| {
s.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id.eq_ignore_ascii_case(seg_tag))
})
.collect();
SegmentChoice {
codes: discriminator_values(rhs)
.filter(|value| {
same_tag
.iter()
.any(|s| codes_at(s, site).is_some_and(|codes| codes.contains(value)))
})
.collect(),
site,
}
}
fn field_path_qualifier(path: &str) -> Option<&str> {
let tag_part = path.split('.').next()?;
let inner = tag_part.split_once('[')?.1.trim_end_matches(']');
let qualifier = inner.split(',').next().unwrap_or(inner);
(!qualifier.is_empty() && qualifier != "*").then_some(qualifier)
}
fn codes_at(segment: &Value, site: Option<(u64, u64)>) -> Option<Vec<&str>> {
let (el_index, sub_index) = site.unwrap_or((0, 0));
let element = segment
.get("elements")
.and_then(|e| e.as_array())?
.iter()
.find(|el| el.get("index").and_then(|v| v.as_u64()) == Some(el_index))?;
let deciding = match element.get("components").and_then(|c| c.as_array()) {
Some(components) => components
.iter()
.find(|c| c.get("sub_index").and_then(|v| v.as_u64()) == Some(sub_index))?,
None if sub_index == 0 => element,
None => return None,
};
let codes: Vec<&str> = deciding
.get("codes")
.and_then(|c| c.as_array())?
.iter()
.filter_map(|c| c.get("value").and_then(|v| v.as_str()))
.collect();
(!codes.is_empty()).then_some(codes)
}
#[derive(Default)]
struct SegmentChoice<'a> {
codes: Vec<&'a str>,
site: Option<(u64, u64)>,
}
impl SegmentChoice<'_> {
fn is_empty(&self) -> bool {
self.codes.is_empty()
}
fn codes_of<'s>(&self, segment: &'s Value) -> Option<Vec<&'s str>> {
codes_at(segment, self.site)
}
}
fn relax_status_inside_optional_segment(
group_json: &Value,
seg_tag: &str,
choice: &SegmentChoice<'_>,
ahb_status: &mut String,
) {
let required = matches!(status_keyword(ahb_status), "X" | "Muss" | "Soll");
if !required {
return;
}
let Some(segments) = group_json.get("segments").and_then(|v| v.as_array()) else {
return;
};
let seg_tag_upper = seg_tag.to_uppercase();
let matching: Vec<&Value> = segments
.iter()
.filter(|s| {
s.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id.to_uppercase() == seg_tag_upper)
})
.collect();
let matching = select_segments_for_qualifier(matching, choice);
if matching.is_empty() {
return;
}
let all_optional = matching.iter().all(|s| {
s.get("ahb_status")
.and_then(|v| v.as_str())
.is_some_and(|status| {
let head = status_keyword(status);
head == "Kann" || head == "K"
})
});
if !all_optional {
return;
}
let condition = ahb_status
.find(['[', '('])
.map(|i| format!(" {}", ahb_status[i..].trim()))
.unwrap_or_default();
*ahb_status = format!("Kann{condition}");
}
fn scope_status_to_holding_segments(
group_json: &Value,
seg_tag: &str,
composite_or_element: &str,
component_id: Option<&str>,
ahb_status: &mut String,
) {
if !matches!(status_keyword(ahb_status), "X" | "Muss" | "Soll") {
return;
}
let Some(segments) = group_json.get("segments").and_then(|v| v.as_array()) else {
return;
};
let seg_tag_upper = seg_tag.to_uppercase();
let same_tag: Vec<&Value> = segments
.iter()
.filter(|s| {
s.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id.to_uppercase() == seg_tag_upper)
})
.collect();
if same_tag.len() < 2 {
return;
}
let holders: Vec<&Value> = same_tag
.iter()
.copied()
.filter(|seg| {
let alone = serde_json::json!({ "segments": [seg] });
find_schema_element(
&alone,
seg_tag,
&SegmentChoice::default(),
composite_or_element,
component_id,
)
.is_some()
})
.collect();
if holders.is_empty() || holders.len() == same_tag.len() {
return;
}
let condition_of = |status: &str| -> Option<String> {
status
.find(['[', '('])
.map(|i| status[i..].trim().to_string())
};
let own = condition_of(ahb_status);
if let [holder] = holders[..] {
let seg_status = holder
.get("ahb_status")
.and_then(|v| v.as_str())
.unwrap_or("");
match status_keyword(seg_status) {
"Kann" | "K" => {}
_ if condition_of(seg_status).is_none() => return,
_ => {
let seg_cond = condition_of(seg_status).unwrap_or_default();
let keyword = status_keyword(ahb_status).to_string();
*ahb_status = match own {
Some(own) => format!("{keyword} ({seg_cond}) ∧ ({own})"),
None => format!("{keyword} {seg_cond}"),
};
return;
}
}
}
*ahb_status = match own {
Some(own) => format!("Kann {own}"),
None => "Kann".to_string(),
};
}
fn status_keyword(status: &str) -> &str {
let s = status.trim_start();
let end = s.find(|c: char| !c.is_alphabetic()).unwrap_or(s.len());
&s[..end]
}
fn relax_status_of_shared_qualifier(
group_json: &Value,
seg_tag: &str,
choice: &SegmentChoice<'_>,
ahb_status: &mut String,
) {
if choice.is_empty() {
return;
}
let required = matches!(status_keyword(ahb_status), "X" | "Muss" | "Soll");
if !required {
return;
}
let Some(segments) = group_json.get("segments").and_then(|v| v.as_array()) else {
return;
};
let seg_tag_upper = seg_tag.to_uppercase();
let exact: Vec<&Value> = segments
.iter()
.filter(|s| {
s.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id.to_uppercase() == seg_tag_upper)
})
.filter(|s| {
choice
.codes_of(s)
.is_some_and(|codes| choice.codes.iter().any(|q| codes.contains(q)))
})
.collect();
let shared = !exact.is_empty()
&& exact.iter().all(|s| {
choice
.codes_of(s)
.is_some_and(|codes| codes.iter().any(|c| !choice.codes.contains(c)))
});
if !shared {
return;
}
let condition = ahb_status
.find(['[', '('])
.map(|i| format!(" {}", ahb_status[i..].trim()))
.unwrap_or_default();
*ahb_status = format!("Kann{condition}");
}
fn select_segments_for_qualifier<'a>(
segments: Vec<&'a Value>,
choice: &SegmentChoice<'_>,
) -> Vec<&'a Value> {
if choice.is_empty() {
return segments;
}
let exact: Vec<&Value> = segments
.iter()
.copied()
.filter(|s| {
choice
.codes_of(s)
.is_some_and(|codes| choice.codes.iter().any(|q| codes.contains(q)))
})
.collect();
if !exact.is_empty() {
return exact;
}
segments
.into_iter()
.filter(|s| choice.codes_of(s).is_none())
.collect()
}
fn merge_codes_from_all_segments(
group_json: &Value,
seg_tag: &str,
choice: &SegmentChoice<'_>,
composite_or_element: &str,
component_id: Option<&str>,
valid_codes: &mut Vec<CodeValue>,
) {
let Some(segments) = group_json.get("segments").and_then(|v| v.as_array()) else {
return;
};
let seg_tag_upper = seg_tag.to_uppercase();
let matching_segs: Vec<&Value> = segments
.iter()
.filter(|s| {
s.get("id")
.and_then(|v| v.as_str())
.map(|id| id.to_uppercase() == seg_tag_upper)
.unwrap_or(false)
})
.collect();
let matching_segs = select_segments_for_qualifier(matching_segs, choice);
let existing: std::collections::HashSet<String> =
valid_codes.iter().map(|c| c.code.clone()).collect();
for seg in &matching_segs {
if let Some(elem) = find_element_in_segment(seg, composite_or_element, component_id) {
if let Some(codes) = elem.get("codes").and_then(|v| v.as_array()) {
for code_val in codes {
let code = code_val.get("value").and_then(|v| v.as_str()).unwrap_or("");
if !code.is_empty() && !existing.contains(code) {
let meaning = code_val
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let enum_name = code_val
.get("enum_name")
.or_else(|| code_val.get("enum"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
valid_codes.push(CodeValue {
code: code.to_string(),
meaning,
enum_name,
bo4e_value: None,
});
}
}
}
}
}
}
fn find_element_in_segment(
seg: &Value,
composite_or_element: &str,
component_id: Option<&str>,
) -> Option<Value> {
let elements = seg.get("elements")?.as_array()?;
let is_numeric = composite_or_element.chars().all(|c| c.is_ascii_digit());
if is_numeric {
let elem_idx: usize = composite_or_element.parse().ok()?;
let elem = elements
.iter()
.find(|e| e.get("index").and_then(|v| v.as_u64()) == Some(elem_idx as u64))?;
if let Some(comp_str) = component_id {
if let Ok(sub_idx) = comp_str.parse::<usize>() {
let components = elem.get("components")?.as_array()?;
return components
.iter()
.find(|c| c.get("sub_index").and_then(|v| v.as_u64()) == Some(sub_idx as u64))
.cloned();
}
}
if elem.get("composite").is_none() {
return Some(elem.clone());
}
let components = elem.get("components")?.as_array()?;
return components.first().cloned();
}
let normalized_id = normalize_edifact_id(composite_or_element);
let is_composite = normalized_id.starts_with('C');
if is_composite {
for elem in elements {
let cid = elem.get("composite").and_then(|v| v.as_str()).unwrap_or("");
if cid.to_uppercase() == normalized_id {
if let Some(comp_id) = component_id {
let comp_normalized = normalize_edifact_id(comp_id);
let components = elem.get("components")?.as_array()?;
return components
.iter()
.find(|c| {
c.get("id")
.and_then(|v| v.as_str())
.map(|id| id.to_uppercase() == comp_normalized)
.unwrap_or(false)
})
.cloned();
}
return Some(elem.clone());
}
}
} else {
for elem in elements {
if elem.get("composite").is_some() {
continue;
}
let eid = elem.get("id").and_then(|v| v.as_str()).unwrap_or("");
if eid.to_uppercase() == normalized_id {
return Some(elem.clone());
}
}
}
None
}
fn find_schema_element(
group_json: &Value,
seg_tag: &str,
choice: &SegmentChoice<'_>,
composite_or_element: &str,
component_id: Option<&str>,
) -> Option<Value> {
let segments = group_json.get("segments")?.as_array()?;
let seg_tag_upper = seg_tag.to_uppercase();
let matching_segs: Vec<&Value> = segments
.iter()
.filter(|s| {
s.get("id")
.and_then(|v| v.as_str())
.map(|id| id.to_uppercase() == seg_tag_upper)
.unwrap_or(false)
})
.collect();
let matching_segs = select_segments_for_qualifier(matching_segs, choice);
if matching_segs.is_empty() {
return None;
}
let is_numeric = composite_or_element.chars().all(|c| c.is_ascii_digit());
if is_numeric {
return find_schema_element_by_index(&matching_segs, composite_or_element, component_id);
}
let normalized_id = normalize_edifact_id(composite_or_element);
let is_composite = normalized_id.starts_with('C');
for seg in &matching_segs {
let elements = seg.get("elements")?.as_array()?;
if is_composite {
for elem in elements {
let composite_id = elem.get("composite").and_then(|v| v.as_str()).unwrap_or("");
if composite_id.to_uppercase() == normalized_id {
if let Some(comp_id) = component_id {
let comp_stripped = strip_ordinal_suffix(comp_id);
if comp_stripped.chars().all(|c| c.is_ascii_digit()) {
let sub_idx: usize = comp_stripped.parse().ok()?;
if let Some(found) = elem
.get("components")
.and_then(|v| v.as_array())
.and_then(|components| {
components.iter().find(|c| {
c.get("sub_index").and_then(|v| v.as_u64())
== Some(sub_idx as u64)
})
})
{
return Some(found.clone());
}
continue;
}
let comp_normalized = normalize_edifact_id(comp_id);
let ordinal = extract_ordinal(comp_id);
if let Some(components) = elem.get("components").and_then(|v| v.as_array())
{
let mut count = 0usize;
for component in components {
let cid =
component.get("id").and_then(|v| v.as_str()).unwrap_or("");
if cid.to_uppercase() == comp_normalized {
if count == ordinal {
return Some(component.clone());
}
count += 1;
}
}
}
} else {
return Some(elem.clone());
}
}
}
} else {
let ordinal = extract_ordinal(composite_or_element);
let mut count = 0usize;
for elem in elements {
if elem.get("composite").is_some() {
continue;
}
let eid = elem.get("id").and_then(|v| v.as_str()).unwrap_or("");
if eid.to_uppercase() == normalized_id {
if count == ordinal {
return Some(elem.clone());
}
count += 1;
}
}
}
}
None
}
fn find_schema_element_by_index(
matching_segs: &[&Value],
element_index_str: &str,
component_sub_index_str: Option<&str>,
) -> Option<Value> {
let elem_idx: usize = element_index_str.parse().ok()?;
for seg in matching_segs {
let elements = match seg.get("elements").and_then(|v| v.as_array()) {
Some(e) => e,
None => continue,
};
let elem = match elements
.iter()
.find(|e| e.get("index").and_then(|v| v.as_u64()) == Some(elem_idx as u64))
{
Some(e) => e,
None => continue,
};
if let Some(comp_str) = component_sub_index_str {
let comp_stripped = strip_ordinal_suffix(comp_str);
if let Ok(sub_idx) = comp_stripped.parse::<usize>() {
if let Some(components) = elem.get("components").and_then(|v| v.as_array()) {
match components.iter().find(|c| {
c.get("sub_index").and_then(|v| v.as_u64()) == Some(sub_idx as u64)
}) {
Some(c) => return Some(c.clone()),
None => continue,
}
}
}
}
return Some(elem.clone());
}
None
}
fn build_field_requirement(
schema_elem: &Value,
bo4e_name: &str,
_parent_ahb: &str,
) -> FieldRequirement {
let ahb_status = schema_elem
.get("ahb_status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let field_type = schema_elem
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("data")
.to_string();
let format = schema_elem
.get("format")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let enum_name = schema_elem
.get("codes")
.and_then(|v| v.as_array())
.and_then(|codes| {
codes
.first()
.and_then(|c| c.get("enum").and_then(|v| v.as_str()))
.map(to_pascal_case)
});
let valid_codes = extract_valid_codes(schema_elem);
FieldRequirement {
bo4e_name: bo4e_name.to_string(),
ahb_status,
field_type,
format,
enum_name,
valid_codes,
child_group: None,
ref_type: Bo4eRefType::Unknown,
}
}
fn extract_valid_codes(element: &Value) -> Vec<CodeValue> {
element
.get("codes")
.and_then(|v| v.as_array())
.map(|codes| {
codes
.iter()
.filter_map(|c| {
let code = c.get("value").and_then(|v| v.as_str())?.to_string();
let meaning = c
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let enum_name = c
.get("enum")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(CodeValue {
code,
meaning,
enum_name,
bo4e_value: None,
})
})
.collect()
})
.unwrap_or_default()
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(c) => {
let upper = c.to_uppercase().to_string();
let lower: String = chars.map(|c| c.to_ascii_lowercase()).collect();
format!("{upper}{lower}")
}
None => String::new(),
}
})
.collect()
}
fn normalize_edifact_id(id: &str) -> String {
let stripped = strip_ordinal_suffix(id);
let upper = stripped.to_uppercase();
if upper.starts_with('C') && upper.len() > 1 && upper[1..].chars().all(|c| c.is_ascii_digit()) {
return upper;
}
if upper.starts_with('D') && upper.len() > 1 && upper[1..].chars().all(|c| c.is_ascii_digit()) {
return upper[1..].to_string();
}
upper
}
fn strip_ordinal_suffix(id: &str) -> &str {
if let Some(pos) = id.rfind('_') {
let suffix = &id[pos + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
return &id[..pos];
}
}
id
}
fn extract_ordinal(id: &str) -> usize {
if let Some(pos) = id.rfind('_') {
let suffix = &id[pos + 1..];
if let Ok(n) = suffix.parse::<usize>() {
return n.saturating_sub(1);
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("HAUSHALTSKUNDE"), "Haushaltskunde");
assert_eq!(to_pascal_case("HAUSHALTSKUNDE_ENWG"), "HaushaltskundeEnwg");
assert_eq!(to_pascal_case("EMAIL"), "Email");
}
#[test]
fn test_parse_toml_field_path() {
let (tag, comp, elem) = parse_toml_field_path("loc.c517.d3225").unwrap();
assert_eq!(tag, "loc");
assert_eq!(comp, "c517");
assert_eq!(elem.as_deref(), Some("d3225"));
let (tag, elem, none) = parse_toml_field_path("nad.d3035").unwrap();
assert_eq!(tag, "nad");
assert_eq!(elem, "d3035");
assert!(none.is_none());
let (tag, comp, elem) = parse_toml_field_path("cav[Z91].c889.d7111").unwrap();
assert_eq!(tag, "cav");
assert_eq!(comp, "c889");
assert_eq!(elem.as_deref(), Some("d7111"));
}
#[test]
fn test_normalize_edifact_id() {
assert_eq!(normalize_edifact_id("c517"), "C517");
assert_eq!(normalize_edifact_id("d3225"), "3225");
assert_eq!(normalize_edifact_id("d3036_2"), "3036");
assert_eq!(normalize_edifact_id("c556_2"), "C556");
}
#[test]
fn test_extract_ordinal() {
assert_eq!(extract_ordinal("d3036"), 0);
assert_eq!(extract_ordinal("d3036_2"), 1);
assert_eq!(extract_ordinal("c556_3"), 2);
}
#[test]
fn qualified_field_path_uses_its_own_segment_variant() {
let rff = |qual: &str, id: Value| {
serde_json::json!({"id": "RFF", "elements": [{"index": 0, "composite": "C506", "components": [
{"sub_index": 0, "id": "1153", "type": "code", "ahb_status": "X",
"codes": [{"value": qual, "name": qual, "ahb_status": "X"}]},
id,
]}]})
};
let data = serde_json::json!({"sub_index": 1, "id": "1154", "type": "data", "ahb_status": "X", "format": "an..70"});
let schema = serde_json::json!({"pid": "21037", "beschreibung": "", "fields": {"sg14": {
"ahb_status": "Muss", "segments": [], "children": {"sg15": {"ahb_status": "Muss", "segments": [
rff("Z13", serde_json::json!({"sub_index": 1, "id": "1154", "type": "code", "ahb_status": "X",
"codes": [{"value": "21037", "name": "RD / NB-Bewertung", "ahb_status": "X"}]})),
rff("ACW", data.clone()),
rff("ACE", data),
]}}}}});
let def = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "Status"
bo4e_type = "Status"
source_group = "SG14.SG15"
source_path = "sg14.sg15"
[fields]
"rff[Z13].c506.d1154" = "pruefidentifikator"
"rff[ACW].c506.d1153" = "acwQualifier"
"rff[ACW].c506.d1154" = "referenz"
"rff[ACE].0.1" = "gegenvorschlagReferenz"
"#,
)
.unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &[def]);
let status = reqs.entities.iter().find(|e| e.entity == "Status").unwrap();
let field = |name: &str| status.fields.iter().find(|f| f.bo4e_name == name).unwrap();
let codes = |name: &str| {
field(name)
.valid_codes
.iter()
.map(|c| c.code.as_str())
.collect::<Vec<_>>()
};
for free in ["referenz", "gegenvorschlagReferenz"] {
assert_eq!(field(free).field_type, "data", "{free}");
assert!(codes(free).is_empty(), "{free}: {:?}", codes(free));
}
assert_eq!(codes("pruefidentifikator"), ["21037"]);
assert_eq!(codes("acwQualifier"), ["ACW"]);
}
#[test]
fn variant_enumeration_ignoring_the_discriminator_ships_a_neighbours_code() {
let sg6 = |qual: &str, name: &str| {
serde_json::json!({
"ahb_status": "Muss",
"discriminator": {"segment": "RFF", "element": "1153", "values": [qual]},
"segments": [{"id": "RFF", "ahb_status": "Muss", "elements": [{
"index": 0, "composite": "C506", "components": [
{"sub_index": 0, "id": "1153", "type": "code", "ahb_status": "X",
"codes": [{"value": qual, "name": name, "ahb_status": "X"}]},
{"sub_index": 1, "id": "1154", "type": "data", "ahb_status": "X",
"format": "an..70"},
]}]}]
})
};
let schema = serde_json::json!({"pid": "55109", "beschreibung": "", "fields": {"sg4": {
"ahb_status": "Muss", "segments": [], "children": {
"sg6_z13": sg6("Z13", "Prüfidentifikator"),
"sg6_z49": sg6("Z49", "Gültige Daten"),
}}}});
let def = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "Zeitscheibe"
bo4e_type = "Zeitscheibe"
source_group = "SG4.SG6"
source_path = "sg4.sg6"
discriminator = "RFF.0.0=Z49"
[fields]
"rff.c506.d1153" = { target = "datenqualitaet", enum_map = { "Z49" = "gueltigeDaten" } }
"rff.c506.d1154" = "zeitraumId"
"#,
)
.unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &[def]);
let zs = reqs
.entities
.iter()
.find(|e| e.entity == "Zeitscheibe")
.expect("Zeitscheibe requirement");
let dq = zs
.fields
.iter()
.find(|f| f.bo4e_name == "datenqualitaet")
.unwrap();
let codes: Vec<&str> = dq.valid_codes.iter().map(|c| c.code.as_str()).collect();
assert_eq!(codes, ["Z49"]);
let variant_codes: Vec<&str> = zs.variants.iter().map(|v| v.code.as_str()).collect();
assert!(
!variant_codes.contains(&"Z13"),
"the Z13 declaration is the neighbour's, not this definition's: {variant_codes:?}"
);
assert!(zs.variants.is_empty(), "{variant_codes:?}");
}
#[test]
fn a_definition_pinning_another_element_than_the_schema_discriminator_survives() {
let cci_7037 = |code: &str| {
serde_json::json!({"id": "CCI", "ahb_status": "Muss", "elements": [
{"index": 2, "composite": "C240", "components": [
{"sub_index": 0, "id": "7037", "type": "code", "ahb_status": "X",
"codes": [{"value": code, "name": code, "ahb_status": "X"}]},
]},
]})
};
let cci_7059 = |code: &str, characteristic: &str| {
serde_json::json!({"id": "CCI", "ahb_status": "Muss", "elements": [
{"index": 0, "id": "7059", "type": "code", "ahb_status": "X",
"codes": [{"value": code, "name": code, "ahb_status": "X"}]},
{"index": 2, "composite": "C240", "components": [
{"sub_index": 0, "id": "7037", "type": "code", "ahb_status": "X",
"codes": [{"value": characteristic, "name": characteristic,
"ahb_status": "X"}]},
]},
]})
};
let schema = serde_json::json!({"pid": "55126", "beschreibung": "", "fields": {"sg4": {
"ahb_status": "Muss", "segments": [], "children": {"sg8": {
"ahb_status": "Muss", "segments": [], "children": {"sg10": {
"source_group": "SG10",
"ahb_status": "Muss",
"discriminator": {"segment": "CCI", "element": "7059"},
"segments": [
cci_7037("ZB3"),
cci_7059("Z18", "Z21"),
cci_7059("Z19", "ZA8"),
],
}}}}}}});
let def = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "MarktlokationDaten"
bo4e_type = "Marktlokation"
source_group = "SG4.SG8.SG10"
source_path = "sg4.sg8.sg10"
discriminator = "CCI.c240.d7037=ZB3"
[fields]
"cci.c240.d7037" = "zuordnungstyp"
"#,
)
.unwrap();
let codes_of = |reqs: &PidRequirements| -> Option<Vec<String>> {
let e = reqs
.entities
.iter()
.find(|e| e.entity == "MarktlokationDaten")?;
let f = e.fields.iter().find(|f| f.bo4e_name == "zuordnungstyp")?;
Some(f.valid_codes.iter().map(|c| c.code.clone()).collect())
};
let reqs = PidRequirements::from_schema_and_definitions(&schema, &[def]);
let names: Vec<&str> = reqs.entities.iter().map(|e| e.entity.as_str()).collect();
assert!(
names.contains(&"MarktlokationDaten"),
"the definition pins CCI/7037=ZB3, which the group does declare — \
it must not be dropped because the schema names CCI/7059: {names:?}"
);
assert_eq!(
codes_of(&reqs).as_deref(),
Some(&["ZB3".to_string()][..]),
"the field must carry only its own declaration's code"
);
let compiled = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "MarktlokationDaten"
bo4e_type = "Marktlokation"
source_group = "SG4.SG8.SG10"
source_path = "sg4.sg8.sg10"
discriminator = "CCI.2.0=ZB3"
[fields]
"cci.2.0" = "zuordnungstyp"
"#,
)
.unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &[compiled]);
let names: Vec<&str> = reqs.entities.iter().map(|e| e.entity.as_str()).collect();
assert!(
names.contains(&"MarktlokationDaten"),
"the compiled positional form addresses the same element: {names:?}"
);
assert_eq!(
codes_of(&reqs).as_deref(),
Some(&["ZB3".to_string()][..]),
"the compiled form must narrow the codes just as the authored one does"
);
let unknown = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "MarktlokationDaten"
bo4e_type = "Marktlokation"
source_group = "SG4.SG8.SG10"
source_path = "sg4.sg8.sg10"
discriminator = "CCI.c240.d7037=ZZZ"
[fields]
"cci.c240.d7037" = "zuordnungstyp"
"#,
)
.unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &[unknown]);
let names: Vec<&str> = reqs.entities.iter().map(|e| e.entity.as_str()).collect();
assert!(
names.is_empty(),
"no declaration of this group carries CCI/7037=ZZZ: {names:?}"
);
}
#[test]
fn a_rule_claiming_every_code_of_its_segment_keeps_its_element_required() {
let schema = serde_json::json!({"pid": "55690", "beschreibung": "", "fields": {"sg4": {
"ahb_status": "Muss", "segments": [], "children": {"sg6_z49": {
"ahb_status": "Muss [707]",
"discriminator": {"segment": "RFF", "element": "1153", "values": ["Z49"]},
"segments": [{"id": "RFF", "ahb_status": "Muss", "elements": [{
"index": 0, "composite": "C506", "components": [
{"sub_index": 0, "id": "1153", "type": "code", "ahb_status": "X",
"codes": [{"value": "Z49", "name": "Z49", "ahb_status": "X"},
{"value": "Z53", "name": "Z53", "ahb_status": "X"}]},
{"sub_index": 2, "id": "1156", "type": "data", "ahb_status": "X [914]",
"format": "an..35"},
]}]}]}}}}});
let def = |disc: &str, path: &str| {
MappingDefinition::from_toml_str(&format!(
r#"
[meta]
entity = "Zeitscheibe"
bo4e_type = "Zeitscheibe"
source_group = "SG4.SG6"
source_path = "sg4.sg6"
discriminator = "{disc}"
[fields]
"{path}" = "zeitraumId"
"#
))
.unwrap()
};
let status = |d: MappingDefinition| {
let reqs = PidRequirements::from_schema_and_definitions(&schema, &[d]);
reqs.entities
.iter()
.find(|e| e.entity == "Zeitscheibe")
.and_then(|e| e.fields.iter().find(|f| f.bo4e_name == "zeitraumId"))
.map(|f| f.ahb_status.clone())
.expect("zeitraumId")
};
assert_eq!(
status(def("RFF.0.0=Z49|Z53", "rff.c506.d1156")),
"X [914]",
"the rule claims both codes, so no other qualifier can stand in for it"
);
assert_eq!(
status(def("RFF.0.0=Z49", "rff[Z49].c506.d1156")),
"Kann [914]"
);
}
#[test]
fn test_from_schema_and_definitions_pid_55001() {
let schema_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
));
if !schema_path.exists() {
eprintln!("Schema file not found, skipping test");
return;
}
let schema: Value =
serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
let base = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../mappings/FV2504/UTILMD_Strom"
));
let common_dir = base.join("common");
let pid_dir = base.join("pid_55001");
let message_dir = base.join("message");
let defs = load_definitions_for_pid(&common_dir, &pid_dir, &message_dir, &schema).unwrap();
assert!(!defs.is_empty(), "should have loaded definitions");
let reqs = PidRequirements::from_schema_and_definitions(&schema, &defs);
assert_eq!(reqs.pid, "55001");
assert_eq!(reqs.beschreibung, "Anmeldung verb. MaLo");
assert!(!reqs.entities.is_empty(), "should have entities");
let prozessdaten = reqs
.entities
.iter()
.find(|e| e.entity == "Prozessdaten")
.expect("Prozessdaten entity should exist");
let pruefid = prozessdaten
.fields
.iter()
.find(|f| f.bo4e_name == "pruefidentifikator")
.expect("pruefidentifikator field should exist");
assert_eq!(pruefid.ahb_status, "X");
assert_eq!(pruefid.field_type, "code");
assert!(
pruefid.valid_codes.iter().any(|c| c.code == "55001"),
"should have 55001 as valid code"
);
let marktlokation = reqs
.entities
.iter()
.find(|e| e.entity == "Marktlokation")
.expect("Marktlokation entity should exist");
assert!(
marktlokation
.fields
.iter()
.any(|f| f.bo4e_name == "marktlokationsId"),
"Marktlokation should have marktlokationsId field"
);
let marktlokation_daten = reqs
.entities
.iter()
.find(|e| e.entity == "MarktlokationDaten")
.expect("MarktlokationDaten entity should exist");
let malo_code_fields: Vec<_> = marktlokation_daten
.fields
.iter()
.filter(|f| f.field_type == "code" && f.valid_codes.len() >= 2)
.collect();
assert!(
!malo_code_fields.is_empty(),
"MarktlokationDaten should have code fields with >= 2 valid codes (haushaltskunde), fields: {:?}",
marktlokation_daten
.fields
.iter()
.map(|f| (&f.bo4e_name, &f.field_type, f.valid_codes.len()))
.collect::<Vec<_>>()
);
let geschaeftspartner = reqs
.entities
.iter()
.find(|e| e.entity == "Geschaeftspartner");
assert!(
geschaeftspartner.is_some(),
"Geschaeftspartner entity should exist"
);
}
#[test]
fn test_extract_valid_codes_includes_enum_name() {
let element = serde_json::json!({
"codes": [
{"value": "ZF9", "name": "Der Code ist anzuwenden wenn...", "enum": "ENFG_VERRINGERUNG_UMLAGE"},
{"value": "ZG0", "name": "Der Code ist anzuwenden wenn...", "enum": "ENFG_KEINE_VERRINGERUNG_UMLAGE"},
{"value": "ZG1", "name": "Der Code ist anzuwenden wenn..."}
]
});
let codes = extract_valid_codes(&element);
assert_eq!(codes.len(), 3);
assert_eq!(codes[0].code, "ZF9");
assert_eq!(
codes[0].enum_name.as_deref(),
Some("ENFG_VERRINGERUNG_UMLAGE")
);
assert_eq!(codes[1].code, "ZG0");
assert_eq!(
codes[1].enum_name.as_deref(),
Some("ENFG_KEINE_VERRINGERUNG_UMLAGE")
);
assert_eq!(codes[2].code, "ZG1");
assert_eq!(codes[2].enum_name, None, "missing enum should be None");
}
#[test]
fn test_enum_name_roundtrips_through_pid_requirements() {
let schema_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
));
if !schema_path.exists() {
eprintln!("Schema file not found, skipping test");
return;
}
let schema: Value =
serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
let base = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../mappings/FV2504/UTILMD_Strom"
));
let defs = load_definitions_for_pid(
&base.join("common"),
&base.join("pid_55001"),
&base.join("message"),
&schema,
)
.unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &defs);
let code_field = reqs
.entities
.iter()
.flat_map(|e| &e.fields)
.find(|f| f.field_type == "code" && !f.valid_codes.is_empty())
.expect("should have at least one code field");
let has_enum_name = code_field.valid_codes.iter().any(|c| c.enum_name.is_some());
assert!(
has_enum_name,
"code field {:?} should have at least one code with enum_name set, codes: {:?}",
code_field.bo4e_name, code_field.valid_codes
);
}
#[test]
fn test_pid_55001_max_reps_from_group_json() {
let schema_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
));
if !schema_path.exists() {
eprintln!("Schema file not found, skipping test");
return;
}
let schema: Value =
serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
let base = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../mappings/FV2504/UTILMD_Strom"
));
let defs = load_definitions_for_pid(
&base.join("common"),
&base.join("pid_55001"),
&base.join("message"),
&schema,
)
.unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &defs);
let produktpaket = reqs
.entities
.iter()
.find(|e| e.entity == "ProduktpaketDaten");
assert!(produktpaket.is_some(), "ProduktpaketDaten should exist");
assert!(
produktpaket.unwrap().cardinality().is_list(),
"ProduktpaketDaten should have max_reps > 1 (SG8 max_reps=99999)"
);
let gp = reqs
.entities
.iter()
.find(|e| e.entity == "Geschaeftspartner");
if let Some(gp) = gp {
assert!(
gp.cardinality().is_list(),
"Geschaeftspartner should have max_reps > 1 (SG12 repeats)"
);
}
let produktpaket = reqs
.entities
.iter()
.find(|e| e.entity == "ProduktpaketDaten")
.unwrap();
let sg10_field = produktpaket.fields.iter().find(|f| {
f.bo4e_name.contains("produktMerkmal") || f.bo4e_name.contains("produktWert")
});
assert!(
sg10_field.is_some(),
"ProduktpaketDaten should have SG10-sourced fields"
);
let cg = &sg10_field.unwrap().child_group;
assert!(
cg.is_some(),
"SG10-sourced field should have child_group set, field: {:?}",
sg10_field.unwrap().bo4e_name
);
assert_eq!(cg.as_ref().unwrap().name, "sg10");
}
#[test]
fn test_entity_map_key_info_serde_roundtrip() {
let info = EntityMapKeyInfo {
field: "marktrolle".to_string(),
values: vec![
EntityMapKeyValue {
code: "MS".to_string(),
name: "Sender".to_string(),
},
EntityMapKeyValue {
code: "MR".to_string(),
name: "Recipient".to_string(),
},
],
};
let json = serde_json::to_string(&info).unwrap();
let roundtripped: EntityMapKeyInfo = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.field, "marktrolle");
assert_eq!(roundtripped.values.len(), 2);
assert_eq!(roundtripped.values[0].code, "MS");
assert_eq!(roundtripped.values[1].code, "MR");
}
#[test]
fn test_entity_requirement_map_key_skip_serializing_if_none() {
let req = EntityRequirement {
entity: "Test".to_string(),
ref_type: Bo4eRefType::Object {
type_name: "Test".to_string(),
cardinality: Cardinality::OPTIONAL,
},
ahb_status: "X".to_string(),
fields: vec![],
variants: vec![],
groups: vec![],
map_key: None,
scope: EntityScope::Transaction,
};
let json = serde_json::to_string(&req).unwrap();
assert!(
!json.contains("map_key"),
"map_key should be omitted when None: {json}"
);
}
#[test]
fn test_entity_requirement_map_key_serialized_when_present() {
let req = EntityRequirement {
entity: "Marktteilnehmer".to_string(),
ref_type: Bo4eRefType::Object {
type_name: "Marktteilnehmer".to_string(),
cardinality: Cardinality {
min: 1,
max: Some(2),
},
},
ahb_status: "X".to_string(),
fields: vec![],
variants: vec![],
groups: vec![],
map_key: Some(EntityMapKeyInfo {
field: "marktrolle".to_string(),
values: vec![
EntityMapKeyValue {
code: "MS".to_string(),
name: "Sender".to_string(),
},
EntityMapKeyValue {
code: "MR".to_string(),
name: "Recipient".to_string(),
},
],
}),
scope: EntityScope::Transaction,
};
let json = serde_json::to_string(&req).unwrap();
assert!(
json.contains("map_key"),
"map_key should be present: {json}"
);
assert!(
json.contains("marktrolle"),
"field name should be present: {json}"
);
assert!(json.contains("MS"), "MS code should be present: {json}");
assert!(json.contains("MR"), "MR code should be present: {json}");
let roundtripped: EntityRequirement = serde_json::from_str(&json).unwrap();
let mk = roundtripped.map_key.unwrap();
assert_eq!(mk.field, "marktrolle");
assert_eq!(mk.values.len(), 2);
}
#[test]
fn test_pid_55001_marktteilnehmer_has_map_key() {
let schema_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
));
if !schema_path.exists() {
eprintln!("Skipping: schema not found");
return;
}
let schema: Value =
serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
let base = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../mappings/FV2504/UTILMD_Strom"
));
let common_dir = base.join("common");
let pid_dir = base.join("pid_55001");
let message_dir = base.join("message");
let defs = load_definitions_for_pid(&common_dir, &pid_dir, &message_dir, &schema).unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &defs);
let mt = reqs.entities.iter().find(|e| e.entity == "Marktteilnehmer");
assert!(mt.is_some(), "should have Marktteilnehmer entity");
let mt = mt.unwrap();
assert!(
mt.cardinality().is_list(),
"Marktteilnehmer should have max_reps > 1"
);
assert!(
mt.map_key.is_some(),
"Marktteilnehmer should have map_key for marktrolle"
);
let mk = mt.map_key.as_ref().unwrap();
assert_eq!(mk.field, "marktrolle");
assert!(
mk.values.len() >= 2,
"Should have at least MS and MR, got {} values",
mk.values.len()
);
let codes: Vec<&str> = mk.values.iter().map(|v| v.code.as_str()).collect();
assert!(
codes.contains(&"MS"),
"Should contain MS code, got: {:?}",
codes
);
assert!(
codes.contains(&"MR"),
"Should contain MR code, got: {:?}",
codes
);
}
#[test]
fn test_detect_entity_map_key_geschaeftspartner_sg12() {
let schema_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mig-types/src/generated/fv2504/utilmd/pids/pid_55013_schema.json"
));
if !schema_path.exists() {
eprintln!("Skipping: schema not found");
return;
}
let schema: Value =
serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
let base = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../mappings/FV2504/UTILMD_Strom"
));
let common_dir = base.join("common");
let pid_dir = base.join("pid_55013");
let message_dir = base.join("message");
let defs = load_definitions_for_pid(&common_dir, &pid_dir, &message_dir, &schema).unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &defs);
let gp = reqs
.entities
.iter()
.find(|e| e.entity == "Geschaeftspartner");
assert!(gp.is_some(), "should have Geschaeftspartner entity");
let gp = gp.unwrap();
assert!(
gp.cardinality().is_list(),
"Geschaeftspartner should have max_reps > 1"
);
assert!(
gp.map_key.is_some(),
"Geschaeftspartner should have map_key for nad_qualifier"
);
let mk = gp.map_key.as_ref().unwrap();
assert_eq!(mk.field, "nad_qualifier");
let codes: BTreeSet<&str> = mk.values.iter().map(|v| v.code.as_str()).collect();
let expected = ["Z63", "Z65", "Z66", "Z67", "Z68", "Z69", "Z70"];
for code in &expected {
assert!(
codes.contains(code),
"Should contain {code} code, got: {:?}",
codes
);
}
assert_eq!(
codes.len(),
expected.len(),
"Should have exactly {} codes, got {:?}",
expected.len(),
codes
);
}
#[test]
fn test_issue_52_pid_55001_fv2604_false_positives() {
let schema_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mig-types/src/generated/fv2604/utilmd/pids/pid_55001_schema.json"
));
if !schema_path.exists() {
eprintln!("FV2604 schema not found, skipping");
return;
}
let schema: Value =
serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
let base = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../mappings/FV2604/UTILMD_Strom"
));
let common_dir = base.join("common");
let pid_dir = base.join("pid_55001");
let message_dir = base.join("message");
let defs = load_definitions_for_pid(&common_dir, &pid_dir, &message_dir, &schema).unwrap();
let reqs = PidRequirements::from_schema_and_definitions(&schema, &defs);
let prozessdaten = reqs
.entities
.iter()
.find(|e| e.entity == "Prozessdaten")
.expect("Prozessdaten should exist");
assert!(
!prozessdaten
.fields
.iter()
.any(|f| f.bo4e_name == "transaktionsnummer"),
"transaktionsnummer should NOT be in PID 55001 (SG6 has only RFF+Z13, not RFF+TN)"
);
let kontakt = reqs.entities.iter().find(|e| e.entity == "Kontakt");
if let Some(kontakt) = kontakt {
assert_eq!(
kontakt.scope,
EntityScope::Message,
"Kontakt should be Message-scoped (SG2.SG3), not Transaction"
);
}
let tx_errors =
crate::pid_validation::validate_pid_json_transaction(&serde_json::json!({}), &reqs);
assert!(
!tx_errors.iter().any(|e| matches!(
e,
crate::pid_validation::PidValidationError::MissingEntity { entity, .. }
if entity == "Kontakt"
)),
"Transaction-scoped validation should NOT report missing Kontakt"
);
let ansprechpartner = reqs
.entities
.iter()
.find(|e| e.entity == "Ansprechpartner")
.expect("Ansprechpartner should exist");
for name in &["namenszusatz1", "namenszusatz2"] {
if let Some(field) = ansprechpartner.fields.iter().find(|f| f.bo4e_name == *name) {
assert!(
!matches!(field.ahb_status.as_str(), "X" | "Muss" | "Soll"),
"{name} should NOT be required (has no explicit AHB status in schema), \
but got ahb_status={:?}",
field.ahb_status
);
}
}
let marktlokation_daten = reqs
.entities
.iter()
.find(|e| e.entity == "MarktlokationDaten")
.expect("MarktlokationDaten should exist");
let haushaltskunde = marktlokation_daten
.fields
.iter()
.find(|f| f.bo4e_name == "haushaltskunde");
assert!(
haushaltskunde.is_some(),
"haushaltskunde field should exist on MarktlokationDaten"
);
}
#[test]
fn deserializes_legacy_entity_requirement_shape() {
let json = serde_json::json!({
"entity": "Marktlokation",
"bo4e_type": "Marktlokation",
"ahb_status": "Muss",
"min_reps": 1,
"max_reps": 1,
"fields": [],
"scope": "Transaction"
});
let req: EntityRequirement = serde_json::from_value(json).unwrap();
assert_eq!(req.bo4e_type(), "Marktlokation");
assert_eq!(req.cardinality(), Cardinality::REQUIRED);
match &req.ref_type {
Bo4eRefType::Object {
type_name,
cardinality,
} => {
assert_eq!(type_name, "Marktlokation");
assert_eq!(*cardinality, Cardinality::REQUIRED);
}
other => panic!("expected Object, got {other:?}"),
}
}
#[test]
fn legacy_entity_with_unbounded_max_reps_becomes_unbounded_cardinality() {
let json = serde_json::json!({
"entity": "Geschaeftspartner",
"bo4e_type": "Geschaeftspartner",
"ahb_status": "Kann",
"min_reps": 0,
"max_reps": 99999,
"fields": []
});
let req: EntityRequirement = serde_json::from_value(json).unwrap();
assert!(req.cardinality().unbounded());
assert_eq!(req.cardinality().min, 0);
}
#[test]
fn merge_segment_elements_unions_simple_element_codes() {
let nad = |code: &str, extra: Option<&str>| {
let mut elements = vec![serde_json::json!({
"id": "3035", "index": 0, "type": "code",
"codes": [{ "value": code, "name": format!("name {code}") }]
})];
if let Some(id) = extra {
elements.push(serde_json::json!({ "id": id, "index": 5, "type": "data" }));
}
serde_json::json!({ "id": "NAD", "elements": elements })
};
let mut target = nad("Z03", Some("3164"));
merge_segment_elements(&mut target, &nad("Z07", None));
merge_segment_elements(&mut target, &nad("Z03", None));
let elements = target["elements"].as_array().unwrap();
assert_eq!(elements.len(), 2, "no duplicate DE3035: {elements:?}");
let codes: Vec<&str> = elements[0]["codes"]
.as_array()
.unwrap()
.iter()
.map(|c| c["value"].as_str().unwrap())
.collect();
assert_eq!(codes, ["Z03", "Z07"]);
}
#[test]
fn an_unsplit_alternation_discriminator_unions_the_neighbouring_declaration() {
for fv in ["FV2504", "FV2604"] {
for resolve_paths in [false, true] {
let ctx = format!("{fv} resolve_paths={resolve_paths}");
let reqs = load_utilmd_strom_requirements(fv, "55109", resolve_paths);
let zs = reqs
.entities
.iter()
.find(|e| e.entity == "Zeitscheibe")
.unwrap_or_else(|| panic!("{ctx}: Zeitscheibe requirement"));
let dq = zs
.fields
.iter()
.find(|f| f.bo4e_name == "datenqualitaet")
.unwrap_or_else(|| panic!("{ctx}: datenqualitaet"));
let codes: Vec<&str> = dq.valid_codes.iter().map(|c| c.code.as_str()).collect();
assert_eq!(
codes,
["Z49", "Z53"],
"{ctx}: Z13 is the neighbouring RFF+Z13 declaration's code"
);
}
}
}
#[test]
fn a_discriminator_no_declaration_of_this_pid_carries_takes_a_neighbours_code_list() {
for fv in ["FV2504", "FV2604"] {
for resolve_paths in [false, true] {
let ctx = format!("{fv} resolve_paths={resolve_paths}");
let reqs = load_utilmd_strom_requirements(fv, "55109", resolve_paths);
let pd = reqs
.entities
.iter()
.find(|e| e.entity == "Prozessdaten")
.unwrap_or_else(|| panic!("{ctx}: Prozessdaten requirement"));
let tn = pd
.fields
.iter()
.find(|f| f.bo4e_name == "transaktionsnummer");
assert!(
tn.is_none(),
"{ctx}: 55109 declares no RFF+TN, so transaktionsnummer must not be \
derived from the neighbouring RFF+Z13: {:?}",
tn.map(|f| (
f.ahb_status.clone(),
f.valid_codes
.iter()
.map(|c| c.code.clone())
.collect::<Vec<_>>()
))
);
let pi = pd
.fields
.iter()
.find(|f| f.bo4e_name == "pruefidentifikator")
.unwrap_or_else(|| panic!("{ctx}: pruefidentifikator requirement"));
let codes: Vec<&str> = pi.valid_codes.iter().map(|c| c.code.as_str()).collect();
assert_eq!(codes, ["55109"], "{ctx}: RFF+Z13's own D_1154");
}
}
}
#[test]
fn an_alternation_discriminator_on_an_exactly_resolved_group_drops_the_whole_definition() {
for fv in ["FV2504", "FV2604"] {
for resolve_paths in [false, true] {
let ctx = format!("{fv} resolve_paths={resolve_paths}");
let reqs = load_utilmd_strom_requirements(fv, "55156", resolve_paths);
let md = reqs
.entities
.iter()
.find(|e| e.entity == "MarktlokationDatenErwVorh")
.unwrap_or_else(|| panic!("{ctx}: MarktlokationDatenErwVorh requirement"));
let names: Vec<&str> = md.fields.iter().map(|f| f.bo4e_name.as_str()).collect();
for wanted in ["datenqualitaet", "zeitraumIdReferenz"] {
assert!(
names.contains(&wanted),
"{ctx}: SEQ.d1229=Z80|Z81 names the sg8_z80_z81 declaration of this \
PID, so {wanted} must not be dropped: {names:?}"
);
}
for wanted in ["zugeordneterMp", "zugeordneterMpId"] {
assert!(
names.contains(&wanted),
"{ctx}: CCI.c240.d7037=ZB3 is declared by this PID, so \
{wanted} must not be dropped over the group's d7059: {names:?}"
);
}
}
}
}
fn load_55042_requirements(fv: &str, resolve_paths: bool) -> PidRequirements {
load_utilmd_strom_requirements(fv, "55042", resolve_paths)
}
fn load_utilmd_strom_requirements(fv: &str, pid: &str, resolve_paths: bool) -> PidRequirements {
let schema_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!(
"../mig-types/src/generated/{}/utilmd/pids",
fv.to_lowercase()
));
let schema: Value = serde_json::from_str(
&std::fs::read_to_string(schema_dir.join(format!("pid_{pid}_schema.json"))).unwrap(),
)
.unwrap();
let base =
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../mappings/{fv}/UTILMD_Strom"));
let mut defs = load_definitions_for_pid(
&base.join("common"),
&base.join(format!("pid_{pid}")),
&base.join("message"),
&schema,
)
.unwrap();
let code_lists = crate::code_lists::CodeLists::discover(&base);
if resolve_paths {
let resolver = crate::path_resolver::PathResolver::from_schema_dir(&schema_dir);
defs = MappingEngine::from_definitions_with_code_lists(
std::sync::Arc::clone(&code_lists),
defs,
)
.with_path_resolver(resolver)
.definitions()
.to_vec();
}
PidRequirements::from_schema_definitions_and_code_lists(&schema, &defs, &code_lists)
}
#[test]
fn issue_104_pid_55042_geschaeftspartner_variants() {
for fv in ["FV2604", "FV2610"] {
for resolve_paths in [false, true] {
let ctx = format!("{fv} resolve_paths={resolve_paths}");
let reqs = load_55042_requirements(fv, resolve_paths);
let gp = reqs
.entities
.iter()
.find(|e| e.entity == "Geschaeftspartner")
.unwrap_or_else(|| panic!("{ctx}: Geschaeftspartner requirement"));
let partnerrolle = gp
.fields
.iter()
.find(|f| f.bo4e_name == "partnerrolle")
.unwrap();
let codes: Vec<(&str, Option<&str>)> = partnerrolle
.valid_codes
.iter()
.map(|c| (c.code.as_str(), c.bo4e_value.as_deref()))
.collect();
assert_eq!(
codes,
[
("Z03", Some("messlokationsadresse")),
("Z05", Some("ablesekarte")),
("Z07", Some("kundeMsb")),
("Z08", Some("korrespondenzKundeMsb")),
],
"{ctx}"
);
let variant = |code: &str| {
gp.variants
.iter()
.find(|v| v.code == code)
.unwrap_or_else(|| panic!("{ctx}: variant {code}: {:?}", gp.variants))
};
assert_eq!(gp.variants.len(), 4, "{ctx}");
let z07 = variant("Z07");
assert_eq!(z07.discriminator_field, "partnerrolle", "{ctx}");
assert_eq!(z07.bo4e_value.as_deref(), Some("kundeMsb"), "{ctx}");
assert_eq!(z07.source_paths, ["sg4.sg12_z07"], "{ctx}");
let z07_names: Vec<&str> =
z07.fields.iter().map(|f| f.bo4e_name.as_str()).collect();
assert!(z07_names.contains(&"name1"), "{ctx}: {z07_names:?}");
assert!(
!z07_names.contains(&"adresse.ort"),
"{ctx}: NAD+Z07 carries no address: {z07_names:?}"
);
let z07_role = z07
.fields
.iter()
.find(|f| f.bo4e_name == "partnerrolle")
.unwrap();
assert_eq!(z07_role.valid_codes.len(), 1, "{ctx}");
assert_eq!(z07_role.valid_codes[0].code, "Z07", "{ctx}");
let z03 = variant("Z03");
let z03_ort = z03
.fields
.iter()
.find(|f| f.bo4e_name == "adresse.ort")
.unwrap_or_else(|| panic!("{ctx}: Z03 has adresse.ort"));
assert_eq!(z03_ort.ahb_status, "X", "{ctx}");
assert!(
!z03.fields.iter().any(|f| f.bo4e_name == "name1"),
"{ctx}: NAD+Z03 has no name"
);
}
}
}
#[test]
fn a_required_element_of_an_optional_segment_is_not_required() {
for fv in ["FV2604", "FV2610"] {
for resolve_paths in [false, true] {
let ctx = format!("{fv} resolve_paths={resolve_paths}");
let reqs = load_55042_requirements(fv, resolve_paths);
let gp = reqs
.entities
.iter()
.find(|e| e.entity == "Geschaeftspartner")
.unwrap_or_else(|| panic!("{ctx}: Geschaeftspartner requirement"));
let referenz = gp
.fields
.iter()
.find(|f| f.bo4e_name == "messlokationsId")
.unwrap_or_else(|| panic!("{ctx}: the SG12 RFF is mapped"));
assert_eq!(
referenz.ahb_status, "Kann [951]",
"{ctx}: RFF is Kann, so its X component is conditional on the \
segment; the format condition is kept"
);
let name = gp.fields.iter().find(|f| f.bo4e_name == "name1").unwrap();
assert_eq!(name.ahb_status, "X", "{ctx}");
}
}
}
#[test]
fn the_relaxation_reads_the_segment_the_field_belongs_to() {
let group = serde_json::json!({
"segments": [
{ "id": "NAD", "ahb_status": "Muss", "elements": [] },
{ "id": "RFF", "ahb_status": "Kann", "elements": [] },
{ "id": "DTM", "elements": [] },
]
});
let mut status = "X [951]".to_string();
relax_status_inside_optional_segment(&group, "rff", &SegmentChoice::default(), &mut status);
assert_eq!(status, "Kann [951]");
let mut status = "Muss".to_string();
relax_status_inside_optional_segment(&group, "nad", &SegmentChoice::default(), &mut status);
assert_eq!(status, "Muss", "a Muss segment changes nothing");
let mut status = "X".to_string();
relax_status_inside_optional_segment(&group, "dtm", &SegmentChoice::default(), &mut status);
assert_eq!(status, "X");
let mut status = "Kann".to_string();
relax_status_inside_optional_segment(&group, "rff", &SegmentChoice::default(), &mut status);
assert_eq!(status, "Kann");
}
#[test]
fn entity_requirement_variants_are_optional_in_serde() {
let json = serde_json::json!({
"entity": "Geschaeftspartner",
"ahb_status": "Muss",
"fields": [],
});
let req: EntityRequirement = serde_json::from_value(json).unwrap();
assert!(req.variants.is_empty());
let out = serde_json::to_string(&req).unwrap();
assert!(!out.contains("variants"), "{out}");
assert!(!out.contains("bo4e_value"), "{out}");
}
}