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;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PidRequirements {
pub pid: String,
pub beschreibung: String,
pub entities: Vec<EntityRequirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityRequirement {
pub entity: String,
pub bo4e_type: String,
pub companion_type: Option<String>,
pub ahb_status: String,
pub is_array: bool,
pub fields: Vec<FieldRequirement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub map_key: Option<EntityMapKeyInfo>,
}
#[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 is_companion: bool,
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>,
}
#[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>,
}
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,
companion_type: Option<String>,
ahb_status: String,
is_array: bool,
_shallowest_depth: usize,
fields: BTreeMap<String, FieldRequirement>,
map_key: Option<EntityMapKeyInfo>,
source_groups: BTreeSet<String>,
}
impl PidRequirements {
pub fn from_schema_and_definitions(schema: &Value, definitions: &[MappingDefinition]) -> 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 {
let source_path = def.meta.source_path.as_deref().unwrap_or("");
let group_json_owned: Value;
let group_json: &Value = if source_path.is_empty() {
if let Some(g) = resolve_schema_group_fuzzy(schema, &def.meta.source_group) {
group_json_owned = g;
&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)
{
group_json_owned = g;
&group_json_owned
} else {
continue;
}
}
}
};
if let Some(ref disc) = def.meta.discriminator {
if !discriminator_matches_schema_group(disc, group_json) {
continue;
}
}
let parent_ahb = 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 _is_depth1_group = source_parts.len() <= 2;
let max_reps = group_json
.get("max_reps")
.and_then(|v| v.as_i64())
.unwrap_or(1) as i32;
let depth = source_parts.len();
let is_message_level = depth == 1 && source_parts[0] != "SG4";
let contributes_array =
(is_message_level || depth == 2) && max_reps > 1;
let builder = entity_map
.entry(def.meta.entity.clone())
.or_insert_with(|| EntityBuilder {
bo4e_type: def.meta.bo4e_type.clone(),
companion_type: None,
ahb_status: parent_ahb.to_string(),
is_array: contributes_array,
_shallowest_depth: depth,
fields: BTreeMap::new(),
map_key: None,
source_groups: BTreeSet::new(),
});
builder.source_groups.insert(def.meta.source_group.clone());
if contributes_array {
builder.is_array = true;
}
if def.meta.companion_type.is_some() {
builder.companion_type.clone_from(&def.meta.companion_type);
}
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,
})
} else {
None
};
let fields_before: std::collections::BTreeSet<String> =
builder.fields.keys().cloned().collect();
process_field_section(
&def.fields,
group_json,
parent_ahb,
false,
&mut builder.fields,
);
if let Some(companion) = &def.companion_fields {
process_field_section(companion, group_json, parent_ahb, true, &mut builder.fields);
}
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());
}
}
}
}
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);
builder.is_array = true; }
}
let entities: Vec<EntityRequirement> = entity_map
.into_iter()
.map(|(name, builder)| EntityRequirement {
entity: name,
bo4e_type: builder.bo4e_type,
companion_type: builder.companion_type,
ahb_status: builder.ahb_status,
is_array: builder.is_array,
fields: builder.fields.into_values().collect(),
map_key: builder.map_key,
})
.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_matches_schema_group(disc: &str, group_json: &Value) -> bool {
let Some((_, disc_value)) = disc.split_once('=') else {
return true; };
let disc_value = disc_value.split('#').next().unwrap_or(disc_value);
let Some(schema_disc) = group_json.get("discriminator") else {
return true; };
let Some(disc_element) = schema_disc.get("element").and_then(|v| v.as_str()) else {
return true;
};
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; }
all_codes.iter().any(|c| c == disc_value)
}
fn resolve_schema_group_fuzzy(schema: &Value, source_group: &str) -> Option<Value> {
let parts: Vec<String> = source_group.split('.').map(|p| p.to_lowercase()).collect();
if parts.is_empty() {
return None;
}
let fields = schema.get("fields")?.as_object()?;
let initial = find_all_matching_values(fields, &parts[0]);
if initial.is_empty() {
return None;
}
let mut current_groups: Vec<&Value> = initial;
for part in &parts[1..] {
let mut next_groups = Vec::new();
for group in ¤t_groups {
if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
next_groups.extend(find_all_matching_values(children, part));
}
}
if next_groups.is_empty() {
return None;
}
current_groups = next_groups;
}
merge_groups_segments(¤t_groups)
}
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()) {
let already_present = target_elements.iter().any(|e| {
e.get("id").and_then(|v| v.as_str()) == Some(src_id) && e.get("composite").is_none()
});
if !already_present {
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)
{
if let Some(src_codes) = src_comp.get("codes").and_then(|v| v.as_array()) {
if let Some(tgt_codes) = tgt_comp.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_comp_obj) = tgt_comp.as_object_mut() {
tgt_comp_obj.insert("codes".to_string(), Value::Array(src_codes.clone()));
if src_comp.get("type").and_then(|v| v.as_str()) == Some("code") {
tgt_comp_obj
.insert("type".to_string(), Value::String("code".to_string()));
}
}
}
}
} else {
target_comps.push(src_comp.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,
is_companion: bool,
output: &mut BTreeMap<String, FieldRequirement>,
) {
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 field = if let Some(schema_elem) = find_schema_element(
group_json,
&seg_tag,
&composite_or_element,
component_id.as_deref(),
) {
build_field_requirement(&schema_elem, &bo4e_name, is_companion, parent_ahb)
} else {
FieldRequirement {
bo4e_name: bo4e_name.clone(),
ahb_status: if parent_ahb.is_empty() {
"unknown".to_string()
} else {
parent_ahb.to_string()
},
is_companion,
field_type: "data".to_string(),
format: None,
enum_name: None,
valid_codes: Vec::new(),
child_group: None,
}
};
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 find_schema_element(
group_json: &Value,
seg_tag: &str,
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();
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(components) =
elem.get("components").and_then(|v| v.as_array())
{
return components
.iter()
.find(|c| {
c.get("sub_index").and_then(|v| v.as_u64())
== Some(sub_idx as u64)
})
.cloned();
}
}
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()) {
return components
.iter()
.find(|c| {
c.get("sub_index").and_then(|v| v.as_u64()) == Some(sub_idx as u64)
})
.cloned();
}
}
}
return Some(elem.clone());
}
None
}
fn build_field_requirement(
schema_elem: &Value,
bo4e_name: &str,
is_companion: bool,
parent_ahb: &str,
) -> FieldRequirement {
let ahb_status = schema_elem
.get("ahb_status")
.and_then(|v| v.as_str())
.unwrap_or(parent_ahb)
.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,
is_companion,
field_type,
format,
enum_name,
valid_codes,
child_group: None,
}
}
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,
})
})
.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 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 malo_code_fields: Vec<_> = marktlokation
.fields
.iter()
.filter(|f| f.field_type == "code" && f.valid_codes.len() >= 2)
.collect();
assert!(
!malo_code_fields.is_empty(),
"Marktlokation should have code fields with >= 2 valid codes (haushaltskunde), fields: {:?}",
marktlokation
.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_is_array_from_max_reps() {
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().is_array,
"ProduktpaketDaten should be is_array=true (SG8 max_reps=99999)"
);
let gp = reqs
.entities
.iter()
.find(|e| e.entity == "Geschaeftspartner");
if let Some(gp) = gp {
assert!(
gp.is_array,
"Geschaeftspartner should be is_array (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(),
bo4e_type: "Test".to_string(),
companion_type: None,
ahb_status: "X".to_string(),
is_array: false,
fields: vec![],
map_key: None,
};
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(),
bo4e_type: "Marktteilnehmer".to_string(),
companion_type: None,
ahb_status: "X".to_string(),
is_array: true,
fields: 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(),
},
],
}),
};
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.is_array, "Marktteilnehmer should be an array");
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.is_array, "Geschaeftspartner should be an array");
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
);
}
}