Skip to main content

mig_bo4e/
error_mapping.rs

1//! Maps EDIFACT validation paths to BO4E field paths.
2//!
3//! When validation runs on EDIFACT produced by reverse-mapping BO4E JSON,
4//! the resulting `ValidationIssue`s contain EDIFACT segment paths like
5//! `SG4/SG5/LOC/C517/3225`. This module resolves those back to BO4E paths
6//! like `stammdaten.Marktlokation.marktlokationsId` so users can find
7//! the source of the problem in their BO4E input.
8
9use mig_types::schema::mig::{MigSchema, MigSegment, MigSegmentGroup};
10
11use crate::definition::{FieldMapping, MappingDefinition};
12
13/// Maps EDIFACT segment paths from validation errors to BO4E field paths.
14pub struct Bo4eFieldIndex {
15    entries: Vec<IndexEntry>,
16}
17
18struct IndexEntry {
19    /// EDIFACT group+segment prefix: "SG4/SG5/LOC", "SG2/NAD", "SG4/IDE", etc.
20    edifact_prefix: String,
21    /// BO4E entity name from TOML meta: "Marktlokation", "Prozessdaten"
22    entity: String,
23    /// Whether this entity is in stammdaten or transaktionsdaten.
24    location: FieldLocation,
25    /// Optional companion type (for companion_fields entries).
26    companion_type: Option<String>,
27    /// Individual field mappings within this segment.
28    fields: Vec<FieldEntry>,
29}
30
31#[derive(Clone, Copy)]
32enum FieldLocation {
33    Stammdaten,
34}
35
36struct FieldEntry {
37    /// Full EDIFACT field_path this matches (e.g., "SG4/SG5/LOC/C517/3225").
38    edifact_path: String,
39    /// BO4E target field name (e.g., "marktlokationsId").
40    bo4e_field: String,
41    /// Whether this is a companion field.
42    is_companion: bool,
43}
44
45impl Bo4eFieldIndex {
46    /// Build the index from TOML mapping definitions and a MIG schema.
47    ///
48    /// For each field in each definition, resolves the TOML numeric path
49    /// (e.g., `loc.1.0`) to an AHB-style EDIFACT path (e.g., `SG4/SG5/LOC/C517/3225`)
50    /// using the MIG schema for element ID lookup.
51    pub fn build(definitions: &[MappingDefinition], mig: &MigSchema) -> Self {
52        let mut entries = Vec::new();
53
54        for def in definitions {
55            let group_path = source_group_to_slash(&def.meta.source_group);
56            let location = classify_entity(&def.meta.entity);
57            let companion_type = def.meta.companion_type.clone();
58
59            let mut fields = Vec::new();
60
61            // Process [fields]
62            Self::collect_fields(&def.fields, &group_path, mig, false, &mut fields);
63
64            // Process [companion_fields]
65            if let Some(ref companion) = def.companion_fields {
66                Self::collect_fields(companion, &group_path, mig, true, &mut fields);
67            }
68
69            if !fields.is_empty() {
70                entries.push(IndexEntry {
71                    edifact_prefix: group_path.clone(),
72                    entity: def.meta.entity.clone(),
73                    location,
74                    companion_type,
75                    fields,
76                });
77            }
78        }
79
80        Self { entries }
81    }
82
83    /// Given an EDIFACT field_path from a ValidationIssue, return the BO4E path.
84    pub fn resolve(&self, edifact_field_path: &str) -> Option<String> {
85        // Exact match on field entries
86        for entry in &self.entries {
87            for field in &entry.fields {
88                if field.edifact_path == edifact_field_path {
89                    return Some(self.build_bo4e_path(entry, field));
90                }
91            }
92        }
93        // Prefix match for code/qualifier paths — longest prefix wins
94        let mut best: Option<&IndexEntry> = None;
95        for entry in &self.entries {
96            if !entry.edifact_prefix.is_empty()
97                && edifact_field_path.starts_with(&entry.edifact_prefix)
98                && best
99                    .map(|b| entry.edifact_prefix.len() > b.edifact_prefix.len())
100                    .unwrap_or(true)
101            {
102                best = Some(entry);
103            }
104        }
105        best.map(|entry| self.build_entity_path(entry))
106    }
107
108    fn collect_fields(
109        field_map: &indexmap::IndexMap<String, FieldMapping>,
110        group_path: &str,
111        mig: &MigSchema,
112        is_companion: bool,
113        out: &mut Vec<FieldEntry>,
114    ) {
115        for (toml_path, mapping) in field_map {
116            let target = match mapping {
117                FieldMapping::Simple(s) => s.as_str(),
118                FieldMapping::Structured(s) => s.target.as_str(),
119                FieldMapping::Nested(_) => continue,
120            };
121
122            // Skip qualifiers/defaults with empty target
123            if target.is_empty() {
124                continue;
125            }
126
127            // Parse TOML path: "loc.1.0" → ("loc", Some(1), Some(0))
128            //                   "ide.1"  → ("ide", Some(1), None)
129            let parsed = match parse_toml_path(toml_path) {
130                Some(p) => p,
131                None => continue,
132            };
133
134            // Resolve via MIG to get the AHB-style EDIFACT path
135            if let Some(edifact_path) = resolve_edifact_path(group_path, &parsed, mig) {
136                out.push(FieldEntry {
137                    edifact_path,
138                    bo4e_field: target.to_string(),
139                    is_companion,
140                });
141            }
142        }
143    }
144
145    fn build_bo4e_path(&self, entry: &IndexEntry, field: &FieldEntry) -> String {
146        let location = match entry.location {
147            FieldLocation::Stammdaten => "stammdaten",
148        };
149        if field.is_companion {
150            if let Some(ref ct) = entry.companion_type {
151                format!(
152                    "{}.{}.{}.{}",
153                    location,
154                    entry.entity,
155                    to_camel_first_lower(ct),
156                    field.bo4e_field
157                )
158            } else {
159                format!("{}.{}.{}", location, entry.entity, field.bo4e_field)
160            }
161        } else {
162            format!("{}.{}.{}", location, entry.entity, field.bo4e_field)
163        }
164    }
165
166    fn build_entity_path(&self, entry: &IndexEntry) -> String {
167        let location = match entry.location {
168            FieldLocation::Stammdaten => "stammdaten",
169        };
170        format!("{}.{}", location, entry.entity)
171    }
172}
173
174/// Parsed TOML field path components.
175struct ParsedTomlPath {
176    /// Segment tag in uppercase (e.g., "LOC", "DTM").
177    segment_tag: String,
178    /// Element index (e.g., 1 in "loc.1.0").
179    element_idx: usize,
180    /// Optional component sub-index (e.g., 0 in "loc.1.0").
181    component_idx: Option<usize>,
182}
183
184/// Parse a TOML field path like "loc.1.0" or "dtm[92].0.1".
185fn parse_toml_path(path: &str) -> Option<ParsedTomlPath> {
186    let parts: Vec<&str> = path.split('.').collect();
187    if parts.len() < 2 {
188        return None;
189    }
190
191    // Strip qualifier from tag: "dtm[92]" → "DTM"
192    let raw_tag = parts[0];
193    let tag = if let Some(bracket) = raw_tag.find('[') {
194        &raw_tag[..bracket]
195    } else {
196        raw_tag
197    };
198
199    let element_idx: usize = parts[1].parse().ok()?;
200    let component_idx = if parts.len() > 2 {
201        Some(parts[2].parse::<usize>().ok()?)
202    } else {
203        None
204    };
205
206    Some(ParsedTomlPath {
207        segment_tag: tag.to_uppercase(),
208        element_idx,
209        component_idx,
210    })
211}
212
213/// Convert source_group dot notation to slash notation, stripping `:N` suffixes.
214/// "SG4.SG5" → "SG4/SG5", "SG8:1.SG10" → "SG8/SG10"
215fn source_group_to_slash(source_group: &str) -> String {
216    source_group
217        .split('.')
218        .map(|part| {
219            if let Some(colon) = part.find(':') {
220                &part[..colon]
221            } else {
222                part
223            }
224        })
225        .collect::<Vec<_>>()
226        .join("/")
227}
228
229/// Classify entity location. All entities are now in stammdaten
230/// (the transaktionsdaten split has been removed).
231fn classify_entity(_entity: &str) -> FieldLocation {
232    FieldLocation::Stammdaten
233}
234
235/// Resolve a parsed TOML path to an AHB-style EDIFACT path using the MIG.
236fn resolve_edifact_path(
237    group_path: &str,
238    parsed: &ParsedTomlPath,
239    mig: &MigSchema,
240) -> Option<String> {
241    // Find the segment in the MIG
242    let segment = find_segment_in_mig(mig, group_path, &parsed.segment_tag)?;
243
244    // Build a unified list of (position, element_kind) sorted by position
245    let resolved = resolve_element_at_position(segment, parsed.element_idx, parsed.component_idx)?;
246
247    let prefix = if group_path.is_empty() {
248        parsed.segment_tag.clone()
249    } else {
250        format!("{}/{}", group_path, parsed.segment_tag)
251    };
252
253    match resolved {
254        ResolvedElement::DataElement(id) => Some(format!("{}/{}", prefix, id)),
255        ResolvedElement::CompositeElement(composite_id, element_id) => {
256            Some(format!("{}/{}/{}", prefix, composite_id, element_id))
257        }
258    }
259}
260
261enum ResolvedElement {
262    /// A standalone data element: just the element ID.
263    DataElement(String),
264    /// A component within a composite: (composite_id, data_element_id).
265    CompositeElement(String, String),
266}
267
268/// Find a segment by tag within a group path in the MIG.
269fn find_segment_in_mig<'a>(
270    mig: &'a MigSchema,
271    group_path: &str,
272    segment_tag: &str,
273) -> Option<&'a MigSegment> {
274    if group_path.is_empty() {
275        // Root-level segment
276        return mig
277            .segments
278            .iter()
279            .find(|s| s.id.eq_ignore_ascii_case(segment_tag));
280    }
281
282    let parts: Vec<&str> = group_path.split('/').collect();
283
284    // Find the first group
285    let mut current_group = mig
286        .segment_groups
287        .iter()
288        .find(|g| g.id.eq_ignore_ascii_case(parts[0]))?;
289
290    // Navigate nested groups
291    for &part in &parts[1..] {
292        current_group = current_group
293            .nested_groups
294            .iter()
295            .find(|g| g.id.eq_ignore_ascii_case(part))?;
296    }
297
298    find_segment_in_group(current_group, segment_tag)
299}
300
301/// Find a segment by tag within a group (checking the group and its nested groups).
302fn find_segment_in_group<'a>(
303    group: &'a MigSegmentGroup,
304    segment_tag: &str,
305) -> Option<&'a MigSegment> {
306    group
307        .segments
308        .iter()
309        .find(|s| s.id.eq_ignore_ascii_case(segment_tag))
310}
311
312/// Resolve an element at a given position within a MIG segment.
313///
314/// Builds a unified position list from data_elements and composites,
315/// then finds what's at element_idx. If it's a composite and component_idx
316/// is provided, returns the sub-element.
317fn resolve_element_at_position(
318    segment: &MigSegment,
319    element_idx: usize,
320    component_idx: Option<usize>,
321) -> Option<ResolvedElement> {
322    // Check composites first — they have a position field
323    if let Some(composite) = segment
324        .composites
325        .iter()
326        .find(|c| c.position == element_idx)
327    {
328        let comp_idx = component_idx.unwrap_or(0);
329        // Find the data element at the component sub-index by sorting by position
330        let mut sub_elements: Vec<_> = composite.data_elements.iter().collect();
331        sub_elements.sort_by_key(|de| de.position);
332        let de = sub_elements.get(comp_idx)?;
333        return Some(ResolvedElement::CompositeElement(
334            composite.id.clone(),
335            de.id.clone(),
336        ));
337    }
338
339    // Check standalone data elements
340    if let Some(de) = segment
341        .data_elements
342        .iter()
343        .find(|d| d.position == element_idx)
344    {
345        return Some(ResolvedElement::DataElement(de.id.clone()));
346    }
347
348    None
349}
350
351/// Convert PascalCase to camelCase (first char lowercase).
352fn to_camel_first_lower(s: &str) -> String {
353    let mut chars = s.chars();
354    match chars.next() {
355        None => String::new(),
356        Some(c) => c.to_lowercase().to_string() + chars.as_str(),
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_source_group_to_slash() {
366        assert_eq!(source_group_to_slash("SG4.SG5"), "SG4/SG5");
367        assert_eq!(source_group_to_slash("SG4"), "SG4");
368        assert_eq!(source_group_to_slash("SG8:1.SG10"), "SG8/SG10");
369        assert_eq!(source_group_to_slash(""), "");
370    }
371
372    #[test]
373    fn test_parse_toml_path() {
374        let p = parse_toml_path("loc.1.0").unwrap();
375        assert_eq!(p.segment_tag, "LOC");
376        assert_eq!(p.element_idx, 1);
377        assert_eq!(p.component_idx, Some(0));
378
379        let p = parse_toml_path("ide.1").unwrap();
380        assert_eq!(p.segment_tag, "IDE");
381        assert_eq!(p.element_idx, 1);
382        assert_eq!(p.component_idx, None);
383
384        let p = parse_toml_path("dtm[92].0.1").unwrap();
385        assert_eq!(p.segment_tag, "DTM");
386        assert_eq!(p.element_idx, 0);
387        assert_eq!(p.component_idx, Some(1));
388
389        assert!(parse_toml_path("loc").is_none());
390    }
391
392    #[test]
393    fn test_classify_entity() {
394        // All entities are now classified as Stammdaten (no more transaktionsdaten split)
395        assert!(matches!(
396            classify_entity("Prozessdaten"),
397            FieldLocation::Stammdaten
398        ));
399        assert!(matches!(
400            classify_entity("Nachricht"),
401            FieldLocation::Stammdaten
402        ));
403        assert!(matches!(
404            classify_entity("Marktlokation"),
405            FieldLocation::Stammdaten
406        ));
407        assert!(matches!(
408            classify_entity("Marktteilnehmer"),
409            FieldLocation::Stammdaten
410        ));
411    }
412
413    #[test]
414    fn test_to_camel_first_lower() {
415        assert_eq!(
416            to_camel_first_lower("MarktlokationEdifact"),
417            "marktlokationEdifact"
418        );
419        assert_eq!(to_camel_first_lower("Foo"), "foo");
420        assert_eq!(to_camel_first_lower(""), "");
421    }
422
423    #[test]
424    fn test_resolve_returns_none_for_unknown_path() {
425        let index = Bo4eFieldIndex { entries: vec![] };
426        assert!(index.resolve("SG99/UNKNOWN/9999").is_none());
427    }
428}