edifact-mapper 0.16.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! Which group instance each BO4E entity element becomes.
//!
//! Condition-aware BO4E validation evaluates AHB conditions against the tree
//! the JSON reverse-maps to. "In dieser SG8" needs the SG8 an element *is*,
//! and the JSON does not say: the reverse merges definitions and orders
//! repetitions by MIG variant, so array index and repetition index differ.

use std::collections::HashMap;

use mig_assembly::assembler::{AssembledGroup, AssembledGroupInstance, AssembledTree};
use mig_bo4e::MappingEngine;
use serde_json::Value;

/// An element of the BO4E JSON: its entity key, and its index when the
/// entity is an array (`None` for a single object).
pub type ElementKey = (String, Option<usize>);

/// A group instance, by its repetition at every level from the root.
pub type InstancePath = Vec<(String, usize)>;

/// The group instance each entity element of `json` becomes in `tree` (the
/// reverse mapping of `json` by `engine`).
///
/// Found by removal: the element is taken out of the JSON, the rest reverse-
/// mapped again, and the repetition that is no longer there is the element's.
/// Only an element whose removal takes away exactly one repetition (with the
/// groups nested in it) gets an entry. One that spans several — or none, when
/// it only contributes fields to a repetition other elements also fill — gets
/// none, and its conditions are evaluated message-wide, as without a scope.
pub fn entity_element_scopes(
    engine: &MappingEngine,
    json: &Value,
    tree: &AssembledTree,
) -> HashMap<ElementKey, InstancePath> {
    let mut scopes = HashMap::new();
    let Some(entities) = json.as_object() else {
        return scopes;
    };
    for (key, value) in entities {
        let indices: Vec<Option<usize>> = match value.as_array() {
            Some(elements) => (0..elements.len()).map(Some).collect(),
            None => vec![None],
        };
        for index in indices {
            let mut without = json.clone();
            match index {
                Some(i) => {
                    without[key.as_str()]
                        .as_array_mut()
                        .expect("array")
                        .remove(i);
                }
                None => {
                    without.as_object_mut().expect("object").remove(key);
                }
            }
            let reduced = engine.map_all_reverse(&without, None);
            let mut removed = Vec::new();
            removed_instances(&tree.groups, &reduced.groups, &mut Vec::new(), &mut removed);
            if let [path] = &removed[..] {
                scopes.insert((key.clone(), index), path.clone());
            }
        }
    }
    scopes
}

/// The repetitions of `full` that `reduced` no longer has, by path.
///
/// Where a group has as many repetitions in both, they are paired in order and
/// the pairs that differ are searched for a removal further down. Where
/// `reduced` has fewer, they are aligned in order: a repetition of `full` that
/// matches the next one of `reduced` is kept, the others are removed. Groups
/// nested in a removed repetition are not listed separately.
fn removed_instances(
    full: &[AssembledGroup],
    reduced: &[AssembledGroup],
    prefix: &mut InstancePath,
    out: &mut Vec<InstancePath>,
) {
    for group in full {
        let remaining: &[AssembledGroupInstance] = reduced
            .iter()
            .find(|g| g.group_id == group.group_id)
            .map_or(&[], |g| &g.repetitions);
        let repetitions = &group.repetitions;
        if repetitions.len() == remaining.len() {
            for (i, (a, b)) in repetitions.iter().zip(remaining).enumerate() {
                if a != b {
                    prefix.push((group.group_id.clone(), i));
                    removed_instances(&a.child_groups, &b.child_groups, prefix, out);
                    prefix.pop();
                }
            }
            continue;
        }
        // If the rest does not line up (a repetition changed as well), more
        // than `full - reduced` repetitions go unmatched — at least two — so a
        // misalignment never passes for a single removal.
        let mut next = 0;
        for (i, repetition) in repetitions.iter().enumerate() {
            if remaining.get(next) == Some(repetition) {
                next += 1;
            } else {
                let mut path = prefix.clone();
                path.push((group.group_id.clone(), i));
                out.push(path);
            }
        }
    }
}

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

    fn rep(seq: &str) -> AssembledGroupInstance {
        AssembledGroupInstance {
            segments: vec![AssembledSegment {
                tag: "SEQ".to_string(),
                elements: vec![vec![seq.to_string()]],
                mig_number: None,
                segment_number: None,
            }],
            child_groups: vec![],
            entry_mig_number: None,
            variant_mig_numbers: vec![],
            skipped_segments: vec![],
            skipped_positions: Vec::new(),
        }
    }

    fn sg8(seqs: &[&str]) -> Vec<AssembledGroup> {
        vec![AssembledGroup {
            group_id: "SG8".to_string(),
            repetitions: seqs.iter().map(|s| rep(s)).collect(),
        }]
    }

    fn removed(full: &[&str], reduced: &[&str]) -> Vec<InstancePath> {
        let mut out = Vec::new();
        removed_instances(&sg8(full), &sg8(reduced), &mut Vec::new(), &mut out);
        out
    }

    #[test]
    fn the_missing_repetition_is_found_by_order() {
        assert_eq!(
            removed(&["Z01", "Z03", "Z20"], &["Z01", "Z20"]),
            [vec![("SG8".to_string(), 1)]]
        );
    }

    #[test]
    fn a_reduced_tree_that_does_not_line_up_names_no_single_instance() {
        let out = removed(&["Z01", "Z03", "Z20"], &["Z01", "Z99"]);
        assert_ne!(out.len(), 1, "{out:?}");
    }
}