mig-bo4e 0.1.37

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 companion_fields: Option<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 companion_type: Option<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>,
}

/// 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>>,
    /// 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>>,
}

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

impl MappingDefinition {
    /// Normalize all EDIFACT ID paths to numeric indices using the given resolver.
    ///
    /// Resolves named paths in field keys, companion_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();

        // Normalize companion_fields keys
        if let Some(ref cf) = self.companion_fields {
            self.companion_fields = Some(
                cf.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> {
        let mut warnings = Vec::new();

        // Collect non-empty target names from [fields]
        let mut field_targets: Vec<&str> = Vec::new();
        for fm in self.fields.values() {
            if let Some(t) = extract_target_ref(fm) {
                if !t.is_empty() {
                    field_targets.push(t);
                }
            }
        }

        // Check companion_fields for duplicates
        if let Some(ref cf) = self.companion_fields {
            for fm in cf.values() {
                if let Some(t) = extract_target_ref(fm) {
                    if !t.is_empty() && field_targets.contains(&t) {
                        warnings.push(format!(
                            "field '{}' appears in both [fields] and [companion_fields] in entity '{}'",
                            t, self.meta.entity
                        ));
                    }
                }
            }
        }

        warnings
    }
}

/// Extract the target string reference from a FieldMapping.
fn extract_target_ref(fm: &FieldMapping) -> Option<&str> {
    match fm {
        FieldMapping::Simple(t) => Some(t.as_str()),
        FieldMapping::Structured(s) => Some(s.target.as_str()),
        FieldMapping::Nested(_) => None,
    }
}

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

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

[fields]

[companion_fields]
"cci.d7059" = { target = "", default = "Z83", when_filled = ["merkmal.code"] }
"cav.c889.d7111" = "merkmal.code"
"#;
        let def: MappingDefinition = toml::from_str(toml).unwrap();
        let cf = def.companion_fields.unwrap();
        let cci = &cf["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_duplicate_target_warning() {
        let toml = r#"
[meta]
entity = "Test"
bo4e_type = "Test"
companion_type = "TestEdifact"
source_group = "SG4"

[fields]
"loc.1.0" = "someField"

[companion_fields]
"loc.0.0" = "someField"
"#;
        let def: MappingDefinition = toml::from_str(toml).unwrap();
        let warnings = def.validate();
        assert!(!warnings.is_empty());
        assert!(warnings[0].contains("someField"));
    }

    #[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"

[companion_fields]
"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"),
        }
    }
}