edifact-mapper 0.20.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
}

/// A nested-list element: its entity element, and its position in the lists
/// below it (`[("zuordnungen", 3)]`).
pub type NestedKey = (ElementKey, Vec<(String, usize)>);

/// The group instance each element of a nested list becomes: a
/// `zuordnungen` element its SG10, a `mengen` element its SG9 — so "im selben
/// CCI" ([438]) reads that element's CCI, not any in the message.
///
/// Found by removal, as for [`entity_element_scopes`], for every array of
/// objects in an entity element and, recursively, in its elements. An element
/// whose removal takes away no repetition (`werte[]`: the CAVs of one SG10)
/// or several gets no entry.
pub fn nested_element_scopes(
    engine: &MappingEngine,
    json: &Value,
    tree: &AssembledTree,
) -> HashMap<NestedKey, InstancePath> {
    let mut scopes = HashMap::new();
    let Some(entities) = json.as_object() else {
        return scopes;
    };
    for (key, value) in entities {
        let elements: Vec<(Option<usize>, &Value)> = match value.as_array() {
            Some(items) => items
                .iter()
                .enumerate()
                .map(|(i, v)| (Some(i), v))
                .collect(),
            None => vec![(None, value)],
        };
        for (index, element) in elements {
            let mut pointer = format!("/{}", escape(key));
            if let Some(i) = index {
                pointer.push_str(&format!("/{i}"));
            }
            let element_key: ElementKey = (key.clone(), index);
            collect_nested(
                engine,
                json,
                tree,
                element,
                &pointer,
                &mut Vec::new(),
                &element_key,
                &mut scopes,
            );
        }
    }
    scopes
}

/// The scopes of the list elements in `value` (at JSON `pointer`), recursively.
#[allow(clippy::too_many_arguments)]
fn collect_nested(
    engine: &MappingEngine,
    json: &Value,
    tree: &AssembledTree,
    value: &Value,
    pointer: &str,
    position: &mut Vec<(String, usize)>,
    element_key: &ElementKey,
    scopes: &mut HashMap<NestedKey, InstancePath>,
) {
    let Some(fields) = value.as_object() else {
        return;
    };
    for (field, list) in fields {
        let Some(items) = list.as_array() else {
            continue;
        };
        if !items.iter().any(Value::is_object) {
            continue;
        }
        let list_pointer = format!("{pointer}/{}", escape(field));
        for (j, item) in items.iter().enumerate() {
            let mut without = json.clone();
            if let Some(Value::Array(target)) = without.pointer_mut(&list_pointer) {
                target.remove(j);
            }
            let reduced = engine.map_all_reverse(&without, None);
            let mut removed = Vec::new();
            removed_instances(&tree.groups, &reduced.groups, &mut Vec::new(), &mut removed);
            position.push((field.clone(), j));
            if let [path] = &removed[..] {
                scopes.insert((element_key.clone(), position.clone()), path.clone());
            }
            collect_nested(
                engine,
                json,
                tree,
                item,
                &format!("{list_pointer}/{j}"),
                position,
                element_key,
                scopes,
            );
            position.pop();
        }
    }
}

/// A key as a JSON pointer token.
fn escape(key: &str) -> String {
    key.replace('~', "~0").replace('/', "~1")
}

/// 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:?}");
    }
}