use mig_types::schema::mig::{MigSchema, MigSegment, MigSegmentGroup};
use crate::definition::{FieldMapping, MappingDefinition};
use crate::path_resolver::ReversePathResolver;
pub struct Bo4eFieldIndex {
entries: Vec<IndexEntry>,
}
struct IndexEntry {
edifact_prefix: String,
entity: String,
location: FieldLocation,
companion_type: Option<String>,
fields: Vec<FieldEntry>,
}
#[derive(Clone, Copy)]
enum FieldLocation {
Stammdaten,
}
struct FieldEntry {
edifact_path: String,
bo4e_field: String,
is_companion: bool,
qualifier: Option<String>,
}
impl Bo4eFieldIndex {
pub fn build(definitions: &[MappingDefinition], mig: &MigSchema) -> Self {
Self::build_inner(definitions, mig, None)
}
pub fn build_with_resolver(
definitions: &[MappingDefinition],
mig: &MigSchema,
resolver: &ReversePathResolver,
) -> Self {
Self::build_inner(definitions, mig, Some(resolver))
}
fn build_inner(
definitions: &[MappingDefinition],
mig: &MigSchema,
resolver: Option<&ReversePathResolver>,
) -> Self {
let mut entries = Vec::new();
for def in definitions {
let group_path = source_group_to_slash(&def.meta.source_group);
let location = classify_entity(&def.meta.entity);
let companion_type = def.meta.companion_type.clone();
let mut fields = Vec::new();
Self::collect_fields_inner(
&def.fields,
&group_path,
mig,
resolver,
false,
&mut fields,
);
if let Some(ref companion) = def.companion_fields {
Self::collect_fields_inner(
companion,
&group_path,
mig,
resolver,
true,
&mut fields,
);
}
if !fields.is_empty() {
entries.push(IndexEntry {
edifact_prefix: group_path.clone(),
entity: def.meta.entity.clone(),
location,
companion_type,
fields,
});
}
}
Self { entries }
}
pub fn resolve(&self, edifact_field_path: &str, hint: Option<&str>) -> Option<String> {
let mut exact_matches: Vec<(&IndexEntry, &FieldEntry)> = Vec::new();
for entry in &self.entries {
for field in &entry.fields {
if field.edifact_path == edifact_field_path {
exact_matches.push((entry, field));
}
}
}
if !exact_matches.is_empty() {
if let Some(hint) = hint {
if let Some((entry, field)) = exact_matches
.iter()
.find(|(_, f)| f.qualifier.as_deref() == Some(hint))
{
return Some(self.build_bo4e_path(entry, field));
}
if let Some((entry, field)) = exact_matches
.iter()
.find(|(_, f)| {
f.qualifier
.as_deref()
.is_some_and(|q| hint.contains(q) || q.contains(hint))
})
{
return Some(self.build_bo4e_path(entry, field));
}
}
let (entry, field) = exact_matches[0];
return Some(self.build_bo4e_path(entry, field));
}
if let Some(hint) = hint {
let composite_prefix = edifact_field_path.rsplit_once('/').map(|(p, _)| p);
if let Some(prefix) = composite_prefix {
for entry in &self.entries {
for field in &entry.fields {
if field.edifact_path.starts_with(prefix)
&& field.qualifier.as_deref() == Some(hint)
{
return Some(self.build_bo4e_path(entry, field));
}
}
}
}
}
let mut best: Option<&IndexEntry> = None;
for entry in &self.entries {
if !entry.edifact_prefix.is_empty()
&& edifact_field_path.starts_with(&entry.edifact_prefix)
&& best
.map(|b| entry.edifact_prefix.len() > b.edifact_prefix.len())
.unwrap_or(true)
{
best = Some(entry);
}
}
best.map(|entry| self.build_entity_path(entry))
}
pub fn debug_entries(&self) -> Vec<(String, String, String)> {
let mut out = Vec::new();
for entry in &self.entries {
for field in &entry.fields {
out.push((
field.edifact_path.clone(),
entry.entity.clone(),
field.bo4e_field.clone(),
));
}
}
out
}
fn collect_fields_inner(
field_map: &indexmap::IndexMap<String, FieldMapping>,
group_path: &str,
mig: &MigSchema,
resolver: Option<&ReversePathResolver>,
is_companion: bool,
out: &mut Vec<FieldEntry>,
) {
struct QualifierPath {
parsed: ParsedTomlPath,
}
let mut qualifier_paths: Vec<QualifierPath> = Vec::new();
let mut tag_qualifier_to_field: std::collections::HashMap<(String, String), String> =
std::collections::HashMap::new();
for (toml_path, mapping) in field_map {
let target = match mapping {
FieldMapping::Simple(s) => s.as_str(),
FieldMapping::Structured(s) => s.target.as_str(),
FieldMapping::Nested(_) => continue,
};
let parsed = match parse_toml_path(toml_path) {
Some(p) => p,
None => continue,
};
if target.is_empty() {
qualifier_paths.push(QualifierPath { parsed });
continue;
}
if let Some(ref q) = parsed.qualifier {
tag_qualifier_to_field
.entry((parsed.segment_tag.clone(), q.clone()))
.or_insert_with(|| target.to_string());
}
let edifact_path = resolver
.and_then(|r| resolve_edifact_path_via_resolver(group_path, &parsed, r))
.or_else(|| resolve_edifact_path(group_path, &parsed, mig));
if let Some(edifact_path) = edifact_path {
out.push(FieldEntry {
edifact_path,
bo4e_field: target.to_string(),
is_companion,
qualifier: parsed.qualifier.clone(),
});
}
}
for qp in &qualifier_paths {
if let Some(ref q) = qp.parsed.qualifier {
let key = (qp.parsed.segment_tag.clone(), q.clone());
if let Some(sibling_field) = tag_qualifier_to_field.get(&key)
{
let edifact_path = resolver
.and_then(|r| {
resolve_edifact_path_via_resolver(group_path, &qp.parsed, r)
})
.or_else(|| resolve_edifact_path(group_path, &qp.parsed, mig));
if let Some(edifact_path) = edifact_path {
out.push(FieldEntry {
edifact_path,
bo4e_field: sibling_field.clone(),
is_companion,
qualifier: qp.parsed.qualifier.clone(),
});
}
}
}
}
}
fn build_bo4e_path(&self, entry: &IndexEntry, field: &FieldEntry) -> String {
let location = match entry.location {
FieldLocation::Stammdaten => "stammdaten",
};
if field.is_companion {
if let Some(ref ct) = entry.companion_type {
format!(
"{}.{}.{}.{}",
location,
entry.entity,
to_camel_first_lower(ct),
field.bo4e_field
)
} else {
format!("{}.{}.{}", location, entry.entity, field.bo4e_field)
}
} else {
format!("{}.{}.{}", location, entry.entity, field.bo4e_field)
}
}
fn build_entity_path(&self, entry: &IndexEntry) -> String {
let location = match entry.location {
FieldLocation::Stammdaten => "stammdaten",
};
format!("{}.{}", location, entry.entity)
}
}
struct ParsedTomlPath {
segment_tag: String,
element_idx: usize,
component_idx: Option<usize>,
qualifier: Option<String>,
}
fn parse_toml_path(path: &str) -> Option<ParsedTomlPath> {
let parts: Vec<&str> = path.split('.').collect();
if parts.len() < 2 {
return None;
}
let raw_tag = parts[0];
let (tag, qualifier) = if let Some(bracket) = raw_tag.find('[') {
let end = raw_tag.find(']').unwrap_or(raw_tag.len());
let qual = &raw_tag[bracket + 1..end];
let qual = qual.split(',').next().unwrap_or(qual);
(&raw_tag[..bracket], Some(qual.to_string()))
} else {
(raw_tag, None)
};
let element_idx: usize = parts[1].parse().ok()?;
let component_idx = if parts.len() > 2 {
Some(parts[2].parse::<usize>().ok()?)
} else {
None
};
Some(ParsedTomlPath {
segment_tag: tag.to_uppercase(),
element_idx,
component_idx,
qualifier,
})
}
fn source_group_to_slash(source_group: &str) -> String {
source_group
.split('.')
.map(|part| {
if let Some(colon) = part.find(':') {
&part[..colon]
} else {
part
}
})
.collect::<Vec<_>>()
.join("/")
}
fn classify_entity(_entity: &str) -> FieldLocation {
FieldLocation::Stammdaten
}
fn resolve_edifact_path_via_resolver(
group_path: &str,
parsed: &ParsedTomlPath,
resolver: &ReversePathResolver,
) -> Option<String> {
let numeric_path = if let Some(ci) = parsed.component_idx {
format!(
"{}.{}.{}",
parsed.segment_tag.to_lowercase(),
parsed.element_idx,
ci
)
} else {
format!(
"{}.{}",
parsed.segment_tag.to_lowercase(),
parsed.element_idx
)
};
let named = resolver.reverse_path(&numeric_path);
if named == numeric_path {
return None;
}
let parts: Vec<&str> = named.split('.').collect();
let ahb_parts: Vec<String> = parts
.iter()
.map(|p| {
let clean = if let Some(bracket) = p.find('[') {
&p[..bracket]
} else {
p
};
if clean.len() > 1
&& (clean.starts_with('c') || clean.starts_with('C'))
&& clean[1..].chars().next().is_some_and(|c| c.is_ascii_digit())
{
clean.to_uppercase()
} else if clean.len() > 1
&& (clean.starts_with('d') || clean.starts_with('D'))
&& clean[1..].chars().next().is_some_and(|c| c.is_ascii_digit())
{
clean[1..].to_string()
} else {
clean.to_uppercase()
}
})
.collect();
let edifact_suffix = ahb_parts.join("/");
if group_path.is_empty() {
Some(edifact_suffix)
} else {
Some(format!("{}/{}", group_path, edifact_suffix))
}
}
fn resolve_edifact_path(
group_path: &str,
parsed: &ParsedTomlPath,
mig: &MigSchema,
) -> Option<String> {
let segment = find_segment_in_mig(mig, group_path, &parsed.segment_tag)?;
let resolved = resolve_element_at_position(segment, parsed.element_idx, parsed.component_idx)?;
let prefix = if group_path.is_empty() {
parsed.segment_tag.clone()
} else {
format!("{}/{}", group_path, parsed.segment_tag)
};
match resolved {
ResolvedElement::DataElement(id) => Some(format!("{}/{}", prefix, id)),
ResolvedElement::CompositeElement(composite_id, element_id) => {
Some(format!("{}/{}/{}", prefix, composite_id, element_id))
}
}
}
enum ResolvedElement {
DataElement(String),
CompositeElement(String, String),
}
fn find_segment_in_mig<'a>(
mig: &'a MigSchema,
group_path: &str,
segment_tag: &str,
) -> Option<&'a MigSegment> {
if group_path.is_empty() {
return mig
.segments
.iter()
.find(|s| s.id.eq_ignore_ascii_case(segment_tag));
}
let parts: Vec<&str> = group_path.split('/').collect();
let mut current_group = mig
.segment_groups
.iter()
.find(|g| g.id.eq_ignore_ascii_case(parts[0]))?;
for &part in &parts[1..] {
current_group = current_group
.nested_groups
.iter()
.find(|g| g.id.eq_ignore_ascii_case(part))?;
}
find_segment_in_group(current_group, segment_tag)
}
fn find_segment_in_group<'a>(
group: &'a MigSegmentGroup,
segment_tag: &str,
) -> Option<&'a MigSegment> {
group
.segments
.iter()
.find(|s| s.id.eq_ignore_ascii_case(segment_tag))
}
fn resolve_element_at_position(
segment: &MigSegment,
element_idx: usize,
component_idx: Option<usize>,
) -> Option<ResolvedElement> {
if let Some(composite) = segment
.composites
.iter()
.find(|c| c.position == element_idx)
{
let comp_idx = component_idx.unwrap_or(0);
let mut sub_elements: Vec<_> = composite.data_elements.iter().collect();
sub_elements.sort_by_key(|de| de.position);
let de = sub_elements.get(comp_idx)?;
return Some(ResolvedElement::CompositeElement(
composite.id.clone(),
de.id.clone(),
));
}
if let Some(de) = segment
.data_elements
.iter()
.find(|d| d.position == element_idx)
{
return Some(ResolvedElement::DataElement(de.id.clone()));
}
None
}
fn to_camel_first_lower(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_lowercase().to_string() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_group_to_slash() {
assert_eq!(source_group_to_slash("SG4.SG5"), "SG4/SG5");
assert_eq!(source_group_to_slash("SG4"), "SG4");
assert_eq!(source_group_to_slash("SG8:1.SG10"), "SG8/SG10");
assert_eq!(source_group_to_slash(""), "");
}
#[test]
fn test_parse_toml_path() {
let p = parse_toml_path("loc.1.0").unwrap();
assert_eq!(p.segment_tag, "LOC");
assert_eq!(p.element_idx, 1);
assert_eq!(p.component_idx, Some(0));
let p = parse_toml_path("ide.1").unwrap();
assert_eq!(p.segment_tag, "IDE");
assert_eq!(p.element_idx, 1);
assert_eq!(p.component_idx, None);
let p = parse_toml_path("dtm[92].0.1").unwrap();
assert_eq!(p.segment_tag, "DTM");
assert_eq!(p.element_idx, 0);
assert_eq!(p.component_idx, Some(1));
assert!(parse_toml_path("loc").is_none());
}
#[test]
fn test_classify_entity() {
assert!(matches!(
classify_entity("Prozessdaten"),
FieldLocation::Stammdaten
));
assert!(matches!(
classify_entity("Nachricht"),
FieldLocation::Stammdaten
));
assert!(matches!(
classify_entity("Marktlokation"),
FieldLocation::Stammdaten
));
assert!(matches!(
classify_entity("Marktteilnehmer"),
FieldLocation::Stammdaten
));
}
#[test]
fn test_to_camel_first_lower() {
assert_eq!(
to_camel_first_lower("MarktlokationEdifact"),
"marktlokationEdifact"
);
assert_eq!(to_camel_first_lower("Foo"), "foo");
assert_eq!(to_camel_first_lower(""), "");
}
#[test]
fn test_resolve_returns_none_for_unknown_path() {
let index = Bo4eFieldIndex { entries: vec![] };
assert!(index.resolve("SG99/UNKNOWN/9999", None).is_none());
}
}