mig-bo4e 0.9.0

Declarative TOML-based MIG-tree to BO4E mapping engine
Documentation
//! TOML mapping definition types.
//!
//! These types are deserialized from TOML mapping files
//! in the `mappings/{format_version}/{message_type}_{variant}/` directory.

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

use crate::path_resolver::PathResolver;

/// Root mapping definition — one per TOML file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MappingDefinition {
    pub meta: MappingMeta,
    /// Field mappings — uses IndexMap to preserve TOML file insertion order,
    /// which determines reverse-mapping segment ordering (e.g., DTM+Z05 before DTM+Z01).
    pub fields: IndexMap<String, FieldMapping>,
    pub complex_handlers: Option<Vec<ComplexHandlerRef>>,
}

/// Metadata about the entity being mapped.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MappingMeta {
    pub entity: String,
    pub bo4e_type: String,
    pub source_group: String,
    /// PID struct field path (e.g., "sg2", "sg4.sg8_z79").
    /// When present, the mapping engine can use PID-direct navigation
    /// instead of AssembledTree group resolution.
    #[serde(default)]
    pub source_path: Option<String>,
    pub discriminator: Option<String>,
    /// When set, the engine iterates over all segments matching this tag within the
    /// group instance and produces one array element per segment. Used for repeating
    /// segments like FTX that aren't in their own subgroup.
    pub repeat_on_tag: Option<String>,
    /// Embeds this child group's data in the object mapped from its **own
    /// parent group instance**, under this (array) field, instead of emitting
    /// a separate top-level entity.
    ///
    /// Requirements: `entity` equals the parent definition's entity, and
    /// `source_group` / `source_path` are exactly one group level below the
    /// parent definition's. Each child group instance is mapped to one array
    /// element (or one per `repeat_on_tag` segment) and appended to
    /// `parent[parent_field]`. On reverse, the elements of `parent[parent_field]`
    /// are emitted as child group(s) of the parent group repetition built from
    /// that same object — so placement follows from the BO4E JSON structure
    /// alone, never from positional side-channels.
    ///
    /// A qualifier on the parent part of `source_path` (e.g. `sg12_z08` in
    /// `sg4.sg12_z08.sg13`) restricts the definition to parent instances whose
    /// entry segment carries that qualifier, in both directions.
    ///
    /// The parent definition may itself be a `parent_field` definition, so a
    /// subtree nests level by level (IFTSTA SG15 → SG17 `betreiber[]` → SG18
    /// `kontakte[]`). Grandchildren of `repeat_on_tag` elements are not supported.
    ///
    /// Example: SG4.SG12.SG13 (CTA/COM) → `Geschaeftspartner.kontaktwege[]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_field: Option<String>,
    /// When set, this definition's output lands as **elements of a list-valued
    /// field** on `entity`, instead of being merged onto the entity object itself.
    ///
    /// One repetition of `source_group` produces one element, so
    /// `entity = "Marktlokation"` + `target_list = "zaehlwerke"` yields
    /// `{"marktlokation": {"zaehlwerke": [ {...}, {...} ]}}`.
    ///
    /// This exists because a target path can only ever build objects
    /// (`set_nested_value_json`), so before it there was no way to write a BO4E
    /// `Vec<T>` field at all — every list-shaped concept had to be flattened into
    /// invented scalars or promoted to a top-level entity of its own. The list is
    /// always emitted, even for a single repetition, because the BO4E field is a
    /// `Vec` regardless of how many elements the message happens to carry.
    #[serde(default)]
    pub target_list: Option<String>,
    /// Where this definition sits among its siblings when segments are emitted.
    ///
    /// Emission order matters — the MIG fixes the sequence of segments within a
    /// group — but until now the only way to express it was the filename, which
    /// is why 3022 of 6460 mapping files are called `_30_12_something.toml`.
    /// Ordering that lives in a filename cannot be validated, cannot be read
    /// from the loaded definition, and makes renaming a file a behavioural
    /// change.
    ///
    /// Definitions sort by `(order, filename)`. A definition that does not set
    /// it sorts after every definition that does, so adding the key to one file
    /// moves only that file — and a directory where nobody sets it keeps exactly
    /// the filename order it has today.
    ///
    /// Skipped when unset so adding the key changes no serialised cache byte
    /// until a definition actually uses it — which is what makes "this changes
    /// nothing yet" checkable rather than asserted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub order: Option<u32>,
}

/// A field mapping — either a simple string target or a structured mapping.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FieldMapping {
    /// Simple: "source_path" = "target_field"
    Simple(String),
    /// Structured: with optional transform, condition, etc.
    Structured(StructuredFieldMapping),
    /// Nested group mappings
    Nested(IndexMap<String, FieldMapping>),
}

/// A structured field mapping with optional transform and condition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredFieldMapping {
    pub target: String,
    pub transform: Option<String>,
    pub when: Option<String>,
    pub default: Option<String>,
    /// Bidirectional enum translation map (EDIFACT value → BO4E value).
    /// Forward: looks up extracted EDIFACT value to produce BO4E value.
    /// Reverse: reverse-looks up BO4E value to produce EDIFACT value.
    /// Uses BTreeMap for deterministic reverse lookup (first key alphabetically wins).
    pub enum_map: Option<BTreeMap<String, String>>,
    /// Name of a shared table in `mappings/code_lists.toml`, used instead of an
    /// inline `enum_map`. Same meaning, held once rather than once per rule --
    /// see `crate::code_lists`. An inline `enum_map` wins where both are given.
    ///
    /// Skipped when absent: the compiled cache writes every field of this
    /// struct, so without this each of the 3566 cache files would gain two
    /// `null`s and the whole cache would be rewritten by a change that no rule
    /// uses yet.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code_list: Option<String>,
    /// Conditional injection: only inject the default on reverse when ANY of these
    /// target field names has a value in the BO4E JSON (checked in both core and
    /// companion objects). Used for transport metadata (qualifiers, codelist codes)
    /// that should only appear when associated domain data exists.
    pub when_filled: Option<Vec<String>>,
    /// Second target for dual decomposition: one EDIFACT value → two companion fields.
    /// Used when a code encodes two concepts (e.g., NAD qualifier = partnerrolle + datenqualitaet).
    /// Forward: map raw value via `also_enum_map`, store as `also_target`.
    /// Reverse: joint lookup — find code where both `enum_map` and `also_enum_map` match.
    pub also_target: Option<String>,
    /// Enum map for the `also_target` field. Same code keys as `enum_map`.
    pub also_enum_map: Option<BTreeMap<String, String>>,
    /// `code_list` for the `also_target` field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub also_code_list: Option<String>,
}

/// Reference to a complex handler function.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplexHandlerRef {
    pub name: String,
    pub description: Option<String>,
}

/// Keys a structured field table (`[fields."path"]` or an inline table) may contain.
const STRUCTURED_FIELD_KEYS: &[&str] = &[
    "target",
    "transform",
    "when",
    "default",
    "enum_map",
    "code_list",
    "when_filled",
    "also_target",
    "also_enum_map",
    "also_code_list",
];

/// Top-level sections a mapping file may contain.
const TOP_LEVEL_SECTIONS: &[&str] = &["meta", "fields", "complex_handlers"];

impl MappingDefinition {
    /// Parse a mapping definition from TOML, rejecting keys serde would silently drop.
    ///
    /// TOML scopes every `key = value` line to the most recent table header, so a
    /// field mapping written after `[fields."nad.d3035"]` becomes a key of that
    /// structured table instead of a `[fields]` entry. Serde ignores unknown keys,
    /// which would drop the mapping without an error (issue #96).
    pub fn from_toml_str(content: &str) -> Result<Self, String> {
        let value: toml::Table = toml::from_str(content).map_err(|e| e.to_string())?;

        let mut problems = Vec::new();
        for section in value.keys() {
            if !TOP_LEVEL_SECTIONS.contains(&section.as_str()) {
                problems.push(format!("unknown top-level section [{section}]"));
            }
        }
        if let Some(fields) = value.get("fields").and_then(|f| f.as_table()) {
            for (path, mapping) in fields {
                let Some(table) = mapping.as_table() else {
                    continue;
                };
                if !table.contains_key("target") {
                    continue;
                }
                let unknown: Vec<&str> = table
                    .keys()
                    .map(String::as_str)
                    .filter(|k| !STRUCTURED_FIELD_KEYS.contains(k))
                    .collect();
                if !unknown.is_empty() {
                    problems.push(format!(
                        "structured field \"{path}\" has unknown keys {unknown:?} \
                         (lines after a [fields.\"{path}\"] header belong to that table; \
                         move them above the header)"
                    ));
                }
            }
        }
        if !problems.is_empty() {
            return Err(problems.join("; "));
        }

        // Deserialize from the string, not from `value`: `toml::Table` does not keep
        // insertion order, and field order drives reverse segment ordering.
        toml::from_str(content).map_err(|e: toml::de::Error| e.to_string())
    }

    /// Normalize all EDIFACT ID paths to numeric indices using the given resolver.
    ///
    /// Resolves named paths in field keys and discriminators.
    /// Already-numeric paths pass through unchanged.
    pub fn normalize_paths(&mut self, resolver: &PathResolver) {
        // Normalize discriminator
        if let Some(ref disc) = self.meta.discriminator {
            self.meta.discriminator = Some(resolver.resolve_discriminator(disc));
        }

        // Normalize field keys
        self.fields = self
            .fields
            .iter()
            .map(|(k, v)| (resolver.resolve_path(k), v.clone()))
            .collect();
    }

    /// Validate the definition for common misconfigurations.
    /// Returns a list of warning messages (empty if valid).
    pub fn validate(&self) -> Vec<String> {
        Vec::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_from_toml_str_rejects_stray_keys_in_structured_table() {
        // A `key = value` line after a `[fields."x"]` header belongs to that table,
        // not to `[fields]` — serde would silently drop it.
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG4.SG12"

[fields]
"nad.d3164" = "ort"

[fields."nad.d3035"]
target = "partnerrolle"
enum_map = { "Z04" = "korrespondenzKundeLf" }
"nad.c058.d3124" = "zusatzinfo"
"#;
        let err = MappingDefinition::from_toml_str(toml).unwrap_err();
        assert!(err.contains("nad.d3035"), "{err}");
        assert!(err.contains("nad.c058.d3124"), "{err}");
    }

    #[test]
    fn test_from_toml_str_rejects_unknown_top_level_section() {
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG4"

[fields]
"loc.d3227" = { target = "", default = "Z16" }

[companion_fields]
"loc.c517.d3225" = "id"
"#;
        let err = MappingDefinition::from_toml_str(toml).unwrap_err();
        assert!(err.contains("companion_fields"), "{err}");
    }

    #[test]
    fn test_from_toml_str_accepts_valid_structured_table() {
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG4.SG12"

[fields]
"nad.d3164" = "ort"

[fields."nad.d3035"]
target = "partnerrolle"
enum_map = { "Z04" = "korrespondenzKundeLf" }
also_target = "datenqualitaet"
also_enum_map = { "Z04" = "erwartet" }
"#;
        let def = MappingDefinition::from_toml_str(toml).unwrap();
        assert_eq!(def.fields.len(), 2);
    }

    #[test]
    fn test_from_toml_str_preserves_field_order() {
        // Field order determines reverse segment ordering, so it must survive parsing.
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG2"

[fields]
"ftx[ACD].d4451" = "b"
"ftx[ACB].d4451" = "a"
"#;
        let def = MappingDefinition::from_toml_str(toml).unwrap();
        let keys: Vec<&str> = def.fields.keys().map(String::as_str).collect();
        assert_eq!(keys, ["ftx[ACD].d4451", "ftx[ACB].d4451"]);
    }

    #[test]
    fn test_when_filled_deserialization() {
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG4.SG8.SG10"

[fields]
"cci.d7059" = { target = "", default = "Z83", when_filled = ["merkmal.code"] }
"cav.c889.d7111" = "merkmal.code"
"#;
        let def: MappingDefinition = toml::from_str(toml).unwrap();
        let cci = &def.fields["cci.d7059"];
        match cci {
            FieldMapping::Structured(s) => {
                assert_eq!(s.target, "");
                assert_eq!(s.default.as_deref(), Some("Z83"));
                let wf = s.when_filled.as_ref().unwrap();
                assert_eq!(wf, &vec!["merkmal.code".to_string()]);
            }
            _ => panic!("expected Structured variant"),
        }
    }

    #[test]
    fn test_validate_no_warnings_for_clean_def() {
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG4"

[fields]
"loc.1.0" = "marktlokationsId"
"loc.0.0" = { target = "", default = "Z16", when_filled = ["marktlokationsId"] }
"#;
        let def: MappingDefinition = toml::from_str(toml).unwrap();
        let warnings = def.validate();
        assert!(warnings.is_empty());
    }

    #[test]
    fn test_when_filled_absent_is_none() {
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
source_group = "SG4"

[fields]
"loc.d3227" = { target = "", default = "Z16" }
"#;
        let def: MappingDefinition = toml::from_str(toml).unwrap();
        let loc = &def.fields["loc.d3227"];
        match loc {
            FieldMapping::Structured(s) => {
                assert!(s.when_filled.is_none());
            }
            _ => panic!("expected Structured variant"),
        }
    }
}