edifact-mapper 0.8.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! Condition-aware PID validation.
//!
//! Extends the basic [`validate_pid_json`](mig_bo4e::pid_validation::validate_pid_json)
//! by evaluating AHB condition expressions to determine whether fields are truly
//! required based on the actual EDIFACT data context.

use automapper_validation::{
    AbsentTarget, ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
};
use mig_bo4e::pid_requirements::{CodeValue, EntityRequirement, PidRequirements};
use mig_bo4e::pid_validation::{
    effective_field_requirements, fields_of_unrequired_absent_groups, get_nested,
    invalid_code_value, PidValidationError, Severity,
};
use serde_json::Value;

/// Validate a BO4E JSON value against PID requirements with AHB condition awareness.
///
/// Unlike [`validate_pid_json`](mig_bo4e::pid_validation::validate_pid_json), this
/// evaluates AHB condition expressions (e.g., `[1]`, `[2] ∧ [3]`) using the provided
/// evaluator and context to determine whether conditional fields are actually required.
///
/// - `X`, `Muss`, `Soll` are always required (Error if missing)
/// - `Kann`, empty string are never required (skipped)
/// - Condition expressions are evaluated: True = Error, False = skip, Unknown = Warning
///
/// Only something missing is ever judged, so the message type's "Wenn
/// vorhanden" condition is `False` throughout (see [`AbsentTarget`]): an
/// absent `Soll [166]` group is not missing. In an entity fed by several
/// segment groups, the fields of a group the element has no data for are
/// demanded only if that group's own status evaluates to `True`.
pub fn validate_with_conditions<E: ConditionEvaluator>(
    json: &Value,
    requirements: &PidRequirements,
    expr_eval: &ConditionExprEvaluator<'_, E>,
    ctx: &EvaluationContext,
) -> Vec<PidValidationError> {
    let absent = AbsentTarget(expr_eval.evaluator());
    let expr_eval = &ConditionExprEvaluator::new(&absent);
    let mut errors = Vec::new();

    for entity_req in &requirements.entities {
        let key = to_camel_case(&entity_req.entity);

        match json.get(&key) {
            None => {
                if let Some(severity) =
                    evaluate_ahb_requirement(&entity_req.ahb_status, expr_eval, ctx)
                {
                    errors.push(PidValidationError::MissingEntity {
                        entity: entity_req.entity.clone(),
                        ahb_status: entity_req.ahb_status.clone(),
                        severity,
                    });
                }
            }
            Some(val) => {
                if entity_req.cardinality().is_list() {
                    if let Some(arr) = val.as_array() {
                        for element in arr {
                            validate_entity_fields_with_conditions(
                                element,
                                entity_req,
                                expr_eval,
                                ctx,
                                &mut errors,
                            );
                        }
                    } else {
                        // Single object where array is expected — validate as
                        // a single rep rather than silently skipping fields.
                        validate_entity_fields_with_conditions(
                            val,
                            entity_req,
                            expr_eval,
                            ctx,
                            &mut errors,
                        );
                    }
                } else {
                    validate_entity_fields_with_conditions(
                        val,
                        entity_req,
                        expr_eval,
                        ctx,
                        &mut errors,
                    );
                }
            }
        }
    }

    errors
}

/// Validate fields within a single entity using condition-aware evaluation.
fn validate_entity_fields_with_conditions<E: ConditionEvaluator>(
    entity_json: &Value,
    entity_req: &EntityRequirement,
    expr_eval: &ConditionExprEvaluator<'_, E>,
    ctx: &EvaluationContext,
    errors: &mut Vec<PidValidationError>,
) {
    // Multi-variant entities (e.g. Geschaeftspartner from several SG12 groups) are
    // checked against the variant selected by the element's qualifier.
    let fields = effective_field_requirements(entity_req, entity_json);
    // Unknown counts as not required, as for a group condition in `EdifactValidator`.
    let exempt = fields_of_unrequired_absent_groups(entity_req, entity_json, |status| {
        evaluate_ahb_requirement(status, expr_eval, ctx) == Some(Severity::Error)
    });
    for field_req in fields.iter() {
        // Dot paths (e.g. "adresse.ort") address nested objects; null = not provided.
        match get_nested(entity_json, &field_req.bo4e_name).filter(|v| !v.is_null()) {
            None if exempt.contains(field_req.bo4e_name.as_str()) => {}
            None => {
                if let Some(severity) =
                    evaluate_ahb_requirement(&field_req.ahb_status, expr_eval, ctx)
                {
                    errors.push(PidValidationError::MissingField {
                        entity: entity_req.entity.clone(),
                        field: field_req.bo4e_name.clone(),
                        ahb_status: field_req.ahb_status.clone(),
                        rust_type: field_req.enum_name.clone(),
                        valid_values: code_values_to_tuples(&field_req.valid_codes),
                        severity,
                    });
                }
            }
            Some(val) => {
                // Code validation happens unconditionally — valid codes don't depend
                // on conditions. Plain strings and `{code, …}` objects are both
                // checked, in raw-code and enum_map-translated spelling.
                if let Some(value) = invalid_code_value(val, field_req) {
                    errors.push(PidValidationError::InvalidCode {
                        entity: entity_req.entity.clone(),
                        field: field_req.bo4e_name.clone(),
                        value,
                        valid_values: code_values_to_tuples(&field_req.valid_codes),
                    });
                }
            }
        }
    }
}

/// Evaluate an AHB status string to determine whether a field is required.
///
/// Returns:
/// - `Some(Severity::Error)` if unconditionally required or condition evaluates to True
/// - `Some(Severity::Warning)` if condition evaluates to Unknown
/// - `None` if not required (Kann/empty) or condition evaluates to False
fn evaluate_ahb_requirement<E: ConditionEvaluator>(
    ahb_status: &str,
    expr_eval: &ConditionExprEvaluator<'_, E>,
    ctx: &EvaluationContext,
) -> Option<Severity> {
    match ahb_status.trim() {
        "X" | "Muss" | "Soll" => Some(Severity::Error),
        "Kann" | "K" | "" => None,
        // "Kann [n]" / "K [n]": optional whatever the condition says.
        status if status.starts_with("Kann") || status.starts_with("K ") => None,
        status => {
            // Use the expr_eval's built-in status evaluation which handles
            // parsing condition expressions like "[1]", "[2] ∧ [3]", etc.
            match expr_eval.evaluate_status(status, ctx) {
                ConditionResult::True => Some(Severity::Error),
                ConditionResult::False => None,
                ConditionResult::Unknown => Some(Severity::Warning),
            }
        }
    }
}

/// Convert PascalCase entity name to camelCase JSON key.
fn to_camel_case(s: &str) -> String {
    if s.is_empty() {
        return String::new();
    }
    let mut chars = s.chars();
    let first = chars.next().unwrap();
    let mut result = first.to_lowercase().to_string();
    result.extend(chars);
    result
}

/// Convert CodeValue vec to (code, meaning) tuples.
fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
    codes
        .iter()
        .map(|cv| (cv.code.clone(), cv.meaning.clone()))
        .collect()
}

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

    #[test]
    fn optional_statuses_are_never_required() {
        use automapper_validation::MapExternalProvider;

        struct NoConditions;
        impl ConditionEvaluator for NoConditions {
            fn evaluate(&self, _: u32, _: &EvaluationContext) -> ConditionResult {
                ConditionResult::True
            }
            fn is_external(&self, _: u32) -> bool {
                false
            }
            fn message_type(&self) -> &str {
                "UTILMD"
            }
            fn format_version(&self) -> &str {
                "FV2604"
            }
        }

        let evaluator = NoConditions;
        let expr_eval = ConditionExprEvaluator::new(&evaluator);
        let external = MapExternalProvider::new(Default::default());
        let ctx = EvaluationContext::new("55042", &external, &[]);
        for status in ["K", "Kann", "", "Kann [1]", "K [1]"] {
            assert_eq!(
                evaluate_ahb_requirement(status, &expr_eval, &ctx),
                None,
                "{status:?} must be optional"
            );
        }
        assert_eq!(
            evaluate_ahb_requirement("X", &expr_eval, &ctx),
            Some(Severity::Error)
        );
    }

    #[test]
    fn test_to_camel_case() {
        assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
        assert_eq!(
            to_camel_case("RuhendeMarktlokation"),
            "ruhendeMarktlokation"
        );
        assert_eq!(to_camel_case(""), "");
    }
}