automapper-validation 0.5.0

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Build an [`AhbWorkflow`] from an AHB-enriched PID schema JSON value.
//!
//! This is the runtime-agnostic half of the former `automapper-api` validation
//! bridge: it needs only `serde_json` + the condition parser + the shared
//! `ahb-types`, so it lives here (a published crate). `automapper-generator`
//! calls it at `compile-mappings` time to bake the workflow into the data bundle,
//! and `automapper-api` re-exports it. The `AhbSchema`-based builders stay in
//! `automapper-api` (they depend on `automapper-generator`).

use std::collections::BTreeMap;

use crate::expr::{ConditionExpr, ConditionParser};
use crate::{AhbCodeRule, AhbFieldRule, AhbWorkflow};

/// Build an [`AhbWorkflow`] from an enriched PID schema JSON value.
///
/// The schema must contain `ahb_status` fields on elements/components (produced by
/// the generator with AHB enrichment enabled). Returns `None` if the schema is
/// missing required top-level fields (`pid`, `beschreibung`).
pub fn ahb_workflow_from_pid_schema(schema: &serde_json::Value) -> Option<AhbWorkflow> {
    let pid = schema.get("pid")?.as_str()?;
    let beschreibung = schema.get("beschreibung")?.as_str().unwrap_or("");
    let kommunikation_von = schema
        .get("kommunikation_von")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let mut fields = Vec::new();

    // Walk groups in "fields" object
    if let Some(groups) = schema.get("fields").and_then(|v| v.as_object()) {
        for (_group_name, group) in groups {
            let source_group = group
                .get("source_group")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let group_ahb_status = group
                .get("ahb_status")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            collect_fields_from_group(&mut fields, group, source_group, &group_ahb_status);
        }
    }

    // Walk root_segments (outside any group)
    if let Some(root_segs) = schema.get("root_segments").and_then(|v| v.as_array()) {
        for seg in root_segs {
            collect_fields_from_segment(&mut fields, seg, "", &None);
        }
    }

    // Extract UB (Unterbedingung) definitions from the schema and parse them
    // into ConditionExpr trees. These are composite condition expressions like
    // UB1 = "([931] ∧ [932] [490]) ⊻ ([931] ∧ [933] [491])".
    let ub_definitions: BTreeMap<String, ConditionExpr> = schema
        .get("ub_definitions")
        .and_then(|v| v.as_object())
        .map(|obj| {
            obj.iter()
                .filter_map(|(k, v)| {
                    let expr_str = v.as_str()?;
                    ConditionParser::parse(expr_str)
                        .ok()
                        .flatten()
                        .map(|expr| (k.clone(), expr))
                })
                .collect()
        })
        .unwrap_or_default();

    // Strip group-level boundary entries (paths like "SG2", "SG4/SG8") emitted
    // by the AHB parser for group status tracking. They don't represent actual
    // EDIFACT fields and would cause misleading validation errors.
    fields.retain(|f| !f.segment_path.split('/').all(|p| p.starts_with("SG")));

    Some(AhbWorkflow {
        pruefidentifikator: pid.to_string(),
        description: beschreibung.to_string(),
        communication_direction: kommunikation_von,
        fields,
        ub_definitions,
    })
}

/// Recursively collect AHB field rules from a group and its children.
fn collect_fields_from_group(
    fields: &mut Vec<AhbFieldRule>,
    group: &serde_json::Value,
    group_path: &str,
    parent_group_ahb_status: &Option<String>,
) {
    // Collect from segments in this group
    if let Some(segments) = group.get("segments").and_then(|v| v.as_array()) {
        for seg in segments {
            collect_fields_from_segment(fields, seg, group_path, parent_group_ahb_status);
        }
    }

    // Recurse into children
    if let Some(children) = group.get("children").and_then(|v| v.as_object()) {
        for (_child_name, child) in children {
            let child_source = child
                .get("source_group")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let child_path = if group_path.is_empty() {
                child_source.to_string()
            } else {
                format!("{}/{}", group_path, child_source)
            };
            let child_group_status = child
                .get("ahb_status")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            collect_fields_from_group(fields, child, &child_path, &child_group_status);
        }
    }
}

/// Collect AHB field rules from a single segment's elements.
fn collect_fields_from_segment(
    fields: &mut Vec<AhbFieldRule>,
    seg: &serde_json::Value,
    group_path: &str,
    parent_group_ahb_status: &Option<String>,
) {
    let seg_id = seg.get("id").and_then(|v| v.as_str()).unwrap_or("");
    let seg_mig_number = seg
        .get("mig_number")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let seg_ahb_status = seg
        .get("ahb_status")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    if let Some(elements) = seg.get("elements").and_then(|v| v.as_array()) {
        for el in elements {
            // Direct data element (has "id" but no "composite")
            if el.get("composite").is_none() {
                if let Some(de_id) = el.get("id").and_then(|v| v.as_str()) {
                    if let Some(ahb_status) = el.get("ahb_status").and_then(|v| v.as_str()) {
                        let segment_path = if group_path.is_empty() {
                            format!("{}/{}", seg_id, de_id)
                        } else {
                            format!("{}/{}/{}", group_path, seg_id, de_id)
                        };
                        // Prefer the element's parent_group_ahb_status only if it
                        // contains conditions (brackets).  A plain "Muss" from the
                        // segment level (S_LOC AHB_Status="Muss") masks the group's
                        // conditional status (G_SG5 AHB_Status="Muss [2061] ∧ [96]"),
                        // causing false mandatory errors for optional group variants.
                        let el_parent_status = el
                            .get("parent_group_ahb_status")
                            .and_then(|v| v.as_str())
                            .filter(|s| s.contains('['))
                            .map(|s| s.to_string())
                            .or_else(|| parent_group_ahb_status.clone());
                        let name = el
                            .get("name")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let codes = collect_code_rules(el);
                        let element_index =
                            el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
                        fields.push(AhbFieldRule {
                            segment_path,
                            name,
                            ahb_status: ahb_status.to_string(),
                            codes,
                            parent_group_ahb_status: el_parent_status,
                            segment_ahb_status: seg_ahb_status.clone(),
                            element_index,
                            component_index: None,
                            mig_number: seg_mig_number.clone(),
                        });
                    }
                }
            }

            // Composite element — walk components
            if let Some(composite_id) = el.get("composite").and_then(|v| v.as_str()) {
                if let Some(components) = el.get("components").and_then(|v| v.as_array()) {
                    for comp in components {
                        let comp_id = comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
                        if let Some(ahb_status) = comp.get("ahb_status").and_then(|v| v.as_str()) {
                            let segment_path = if group_path.is_empty() {
                                format!("{}/{}/{}", seg_id, composite_id, comp_id)
                            } else {
                                format!("{}/{}/{}/{}", group_path, seg_id, composite_id, comp_id)
                            };
                            let comp_parent_status = comp
                                .get("parent_group_ahb_status")
                                .and_then(|v| v.as_str())
                                .filter(|s| s.contains('['))
                                .map(|s| s.to_string())
                                .or_else(|| parent_group_ahb_status.clone());
                            let name = comp
                                .get("name")
                                .and_then(|v| v.as_str())
                                .unwrap_or("")
                                .to_string();
                            let codes = collect_code_rules(comp);
                            let element_index =
                                el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
                            let component_index = comp
                                .get("sub_index")
                                .and_then(|v| v.as_u64())
                                .map(|v| v as usize);
                            fields.push(AhbFieldRule {
                                segment_path,
                                name,
                                ahb_status: ahb_status.to_string(),
                                codes,
                                parent_group_ahb_status: comp_parent_status,
                                segment_ahb_status: seg_ahb_status.clone(),
                                element_index,
                                component_index,
                                mig_number: seg_mig_number.clone(),
                            });
                        }
                    }
                }
            }
        }
    }
}

/// Collect code rules from an element or component's "codes" array.
fn collect_code_rules(el: &serde_json::Value) -> Vec<AhbCodeRule> {
    let Some(codes) = el.get("codes").and_then(|v| v.as_array()) else {
        return Vec::new();
    };
    codes
        .iter()
        .map(|c| AhbCodeRule {
            value: c
                .get("value")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            description: c
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            ahb_status: c
                .get("ahb_status")
                .and_then(|v| v.as_str())
                .unwrap_or("X")
                .to_string(),
        })
        .collect()
}