Skip to main content

mig_bo4e/
definition.rs

1//! TOML mapping definition types.
2//!
3//! These types are deserialized from TOML mapping files
4//! in the `mappings/{format_version}/{message_type}_{variant}/` directory.
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10use crate::path_resolver::PathResolver;
11
12/// Root mapping definition — one per TOML file.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct MappingDefinition {
15    pub meta: MappingMeta,
16    /// Field mappings — uses IndexMap to preserve TOML file insertion order,
17    /// which determines reverse-mapping segment ordering (e.g., DTM+Z05 before DTM+Z01).
18    pub fields: IndexMap<String, FieldMapping>,
19    pub companion_fields: Option<IndexMap<String, FieldMapping>>,
20    pub complex_handlers: Option<Vec<ComplexHandlerRef>>,
21}
22
23/// Metadata about the entity being mapped.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct MappingMeta {
26    pub entity: String,
27    pub bo4e_type: String,
28    pub companion_type: Option<String>,
29    pub source_group: String,
30    /// PID struct field path (e.g., "sg2", "sg4.sg8_z79").
31    /// When present, the mapping engine can use PID-direct navigation
32    /// instead of AssembledTree group resolution.
33    #[serde(default)]
34    pub source_path: Option<String>,
35    pub discriminator: Option<String>,
36    /// When set, the engine iterates over all segments matching this tag within the
37    /// group instance and produces one array element per segment. Used for repeating
38    /// segments like FTX that aren't in their own subgroup.
39    pub repeat_on_tag: Option<String>,
40}
41
42/// A field mapping — either a simple string target or a structured mapping.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(untagged)]
45pub enum FieldMapping {
46    /// Simple: "source_path" = "target_field"
47    Simple(String),
48    /// Structured: with optional transform, condition, etc.
49    Structured(StructuredFieldMapping),
50    /// Nested group mappings
51    Nested(IndexMap<String, FieldMapping>),
52}
53
54/// A structured field mapping with optional transform and condition.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct StructuredFieldMapping {
57    pub target: String,
58    pub transform: Option<String>,
59    pub when: Option<String>,
60    pub default: Option<String>,
61    /// Bidirectional enum translation map (EDIFACT value → BO4E value).
62    /// Forward: looks up extracted EDIFACT value to produce BO4E value.
63    /// Reverse: reverse-looks up BO4E value to produce EDIFACT value.
64    /// Uses BTreeMap for deterministic reverse lookup (first key alphabetically wins).
65    pub enum_map: Option<BTreeMap<String, String>>,
66    /// Conditional injection: only inject the default on reverse when ANY of these
67    /// target field names has a value in the BO4E JSON (checked in both core and
68    /// companion objects). Used for transport metadata (qualifiers, codelist codes)
69    /// that should only appear when associated domain data exists.
70    pub when_filled: Option<Vec<String>>,
71    /// Second target for dual decomposition: one EDIFACT value → two companion fields.
72    /// Used when a code encodes two concepts (e.g., NAD qualifier = partnerrolle + datenqualitaet).
73    /// Forward: map raw value via `also_enum_map`, store as `also_target`.
74    /// Reverse: joint lookup — find code where both `enum_map` and `also_enum_map` match.
75    pub also_target: Option<String>,
76    /// Enum map for the `also_target` field. Same code keys as `enum_map`.
77    pub also_enum_map: Option<BTreeMap<String, String>>,
78}
79
80/// Reference to a complex handler function.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ComplexHandlerRef {
83    pub name: String,
84    pub description: Option<String>,
85}
86
87impl MappingDefinition {
88    /// Normalize all EDIFACT ID paths to numeric indices using the given resolver.
89    ///
90    /// Resolves named paths in field keys, companion_field keys, and discriminators.
91    /// Already-numeric paths pass through unchanged.
92    pub fn normalize_paths(&mut self, resolver: &PathResolver) {
93        // Normalize discriminator
94        if let Some(ref disc) = self.meta.discriminator {
95            self.meta.discriminator = Some(resolver.resolve_discriminator(disc));
96        }
97
98        // Normalize field keys
99        self.fields = self
100            .fields
101            .iter()
102            .map(|(k, v)| (resolver.resolve_path(k), v.clone()))
103            .collect();
104
105        // Normalize companion_fields keys
106        if let Some(ref cf) = self.companion_fields {
107            self.companion_fields = Some(
108                cf.iter()
109                    .map(|(k, v)| (resolver.resolve_path(k), v.clone()))
110                    .collect(),
111            );
112        }
113    }
114
115    /// Validate the definition for common misconfigurations.
116    /// Returns a list of warning messages (empty if valid).
117    pub fn validate(&self) -> Vec<String> {
118        let mut warnings = Vec::new();
119
120        // Collect non-empty target names from [fields]
121        let mut field_targets: Vec<&str> = Vec::new();
122        for fm in self.fields.values() {
123            if let Some(t) = extract_target_ref(fm) {
124                if !t.is_empty() {
125                    field_targets.push(t);
126                }
127            }
128        }
129
130        // Check companion_fields for duplicates
131        if let Some(ref cf) = self.companion_fields {
132            for fm in cf.values() {
133                if let Some(t) = extract_target_ref(fm) {
134                    if !t.is_empty() && field_targets.contains(&t) {
135                        warnings.push(format!(
136                            "field '{}' appears in both [fields] and [companion_fields] in entity '{}'",
137                            t, self.meta.entity
138                        ));
139                    }
140                }
141            }
142        }
143
144        warnings
145    }
146}
147
148/// Extract the target string reference from a FieldMapping.
149fn extract_target_ref(fm: &FieldMapping) -> Option<&str> {
150    match fm {
151        FieldMapping::Simple(t) => Some(t.as_str()),
152        FieldMapping::Structured(s) => Some(s.target.as_str()),
153        FieldMapping::Nested(_) => None,
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_when_filled_deserialization() {
163        let toml = r#"
164[meta]
165entity = "Test"
166bo4e_type = "Test"
167source_group = "SG4.SG8.SG10"
168
169[fields]
170
171[companion_fields]
172"cci.d7059" = { target = "", default = "Z83", when_filled = ["merkmal.code"] }
173"cav.c889.d7111" = "merkmal.code"
174"#;
175        let def: MappingDefinition = toml::from_str(toml).unwrap();
176        let cf = def.companion_fields.unwrap();
177        let cci = &cf["cci.d7059"];
178        match cci {
179            FieldMapping::Structured(s) => {
180                assert_eq!(s.target, "");
181                assert_eq!(s.default.as_deref(), Some("Z83"));
182                let wf = s.when_filled.as_ref().unwrap();
183                assert_eq!(wf, &vec!["merkmal.code".to_string()]);
184            }
185            _ => panic!("expected Structured variant"),
186        }
187    }
188
189    #[test]
190    fn test_validate_duplicate_target_warning() {
191        let toml = r#"
192[meta]
193entity = "Test"
194bo4e_type = "Test"
195companion_type = "TestEdifact"
196source_group = "SG4"
197
198[fields]
199"loc.1.0" = "someField"
200
201[companion_fields]
202"loc.0.0" = "someField"
203"#;
204        let def: MappingDefinition = toml::from_str(toml).unwrap();
205        let warnings = def.validate();
206        assert!(!warnings.is_empty());
207        assert!(warnings[0].contains("someField"));
208    }
209
210    #[test]
211    fn test_validate_no_warnings_for_clean_def() {
212        let toml = r#"
213[meta]
214entity = "Test"
215bo4e_type = "Test"
216source_group = "SG4"
217
218[fields]
219"loc.1.0" = "marktlokationsId"
220
221[companion_fields]
222"loc.0.0" = { target = "", default = "Z16", when_filled = ["marktlokationsId"] }
223"#;
224        let def: MappingDefinition = toml::from_str(toml).unwrap();
225        let warnings = def.validate();
226        assert!(warnings.is_empty());
227    }
228
229    #[test]
230    fn test_when_filled_absent_is_none() {
231        let toml = r#"
232[meta]
233entity = "Test"
234bo4e_type = "Test"
235source_group = "SG4"
236
237[fields]
238"loc.d3227" = { target = "", default = "Z16" }
239"#;
240        let def: MappingDefinition = toml::from_str(toml).unwrap();
241        let loc = &def.fields["loc.d3227"];
242        match loc {
243            FieldMapping::Structured(s) => {
244                assert!(s.when_filled.is_none());
245            }
246            _ => panic!("expected Structured variant"),
247        }
248    }
249}