automapper-validation 0.1.59

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! EBD answer-code cluster lookup.
//!
//! Resolves UTILMD/ORDRSP/IFTSTA conditions that classify STS response
//! codes by "Cluster Zustimmung" or "Cluster Ablehnung". The cluster is
//! a per-EBD attribute — the same A-code can sit in a different cluster
//! depending on which EBD (C556/1131 qualifier) it belongs to.
//!
//! Data source: extracted from `mako_prozesse` (YAML per EBD) via
//! `scripts/extract_ebd_clusters.py`.

use super::context::EvaluationContext;
use super::evaluator::ConditionResult;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;

const EMBEDDED_JSON: &str = include_str!("../../data/ebd_cluster_map.json");

#[derive(Debug, Deserialize)]
struct RawFile {
    ebds: HashMap<String, HashMap<String, String>>,
}

/// Cluster classifier derived from the `Cluster:` hint in an EBD YAML.
///
/// Primary clusters (Zustimmung, Ablehnung) and the three Ablehnung
/// sub-levels referenced by REMADV/INVOIC conditions have named
/// variants; anything else (Änderung, Korrekturliste, Abweisung, ...)
/// falls into `Other` preserving the raw label for future match-ups.
///
/// `is_ablehnung` returns true for both the plain `Ablehnung` variant
/// and all three sub-level variants, so UTILMD_Strom conditions like
/// `[359]` keep matching sub-level codes transparently.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cluster {
    Zustimmung,
    Ablehnung,
    AblehnungKopfebene,
    AblehnungPositionsebene,
    AblehnungSummenebene,
    /// Preserves the raw cluster label (e.g. "Korrekturliste wegen Ablehnung",
    /// "Änderung der Daten", "Abweisung") so future conditions can match
    /// on it without regenerating the data file.
    Other(String),
}

impl Cluster {
    pub fn from_token(token: &str) -> Self {
        match token {
            "Zustimmung" => Cluster::Zustimmung,
            "Ablehnung" => Cluster::Ablehnung,
            "Ablehnung auf Kopfebene" => Cluster::AblehnungKopfebene,
            "Ablehnung auf Positionsebene" => Cluster::AblehnungPositionsebene,
            "Ablehnung auf Summenebene" => Cluster::AblehnungSummenebene,
            _ => Cluster::Other(token.to_owned()),
        }
    }

    pub fn is_zustimmung(&self) -> bool {
        matches!(self, Cluster::Zustimmung)
    }

    /// Any Ablehnung variant — plain or Kopf/Positions/Summenebene.
    pub fn is_ablehnung(&self) -> bool {
        matches!(
            self,
            Cluster::Ablehnung
                | Cluster::AblehnungKopfebene
                | Cluster::AblehnungPositionsebene
                | Cluster::AblehnungSummenebene
        )
    }

    pub fn is_ablehnung_kopfebene(&self) -> bool {
        matches!(self, Cluster::AblehnungKopfebene)
    }

    pub fn is_ablehnung_positionsebene(&self) -> bool {
        matches!(self, Cluster::AblehnungPositionsebene)
    }

    pub fn is_ablehnung_summenebene(&self) -> bool {
        matches!(self, Cluster::AblehnungSummenebene)
    }
}

#[derive(Debug)]
pub struct EbdClusterLookup {
    clusters: HashMap<(String, String), Cluster>,
}

impl EbdClusterLookup {
    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
        let raw: RawFile = serde_json::from_str(json)?;
        let clusters = raw
            .ebds
            .into_iter()
            .flat_map(|(ebd, codes)| {
                codes
                    .into_iter()
                    .map(move |(code, token)| ((ebd.clone(), code), Cluster::from_token(&token)))
            })
            .collect();
        Ok(Self { clusters })
    }

    pub fn embedded() -> &'static Self {
        static CELL: OnceLock<EbdClusterLookup> = OnceLock::new();
        CELL.get_or_init(|| {
            EbdClusterLookup::from_json(EMBEDDED_JSON)
                .expect("embedded ebd_cluster_map.json is malformed")
        })
    }

    /// Looks up the cluster for `code` within `ebd`. Returns `None` when
    /// the EBD isn't in the data set or the code isn't an answer-code
    /// leaf of that EBD.
    pub fn cluster(&self, ebd: &str, code: &str) -> Option<&Cluster> {
        self.clusters.get(&(ebd.to_owned(), code.to_owned()))
    }
}

// -----------------------------------------------------------------------
// Shared evaluator helpers for cluster conditions.
//
// These sit on the ctx + cluster lookup and are used by per-message
// generated condition files (UTILMD_Strom, ORDRSP, UTILTS, REMADV).
// Putting them here keeps the HAND-EDITED hot spots in one place so the
// codegen prompt (Task B6) only needs to learn about this one module.

/// STS+E01 layout helper: extract `(code, ebd)` from element[2].
/// Returns None when either component is missing or empty.
fn sts_e01_code_ebd(seg: &mig_types::segment::OwnedSegment) -> Option<(&str, &str)> {
    let c556 = seg.elements.get(2)?;
    let code = c556.first().filter(|v| !v.is_empty())?.as_str();
    let ebd = c556.get(1).filter(|v| !v.is_empty())?.as_str();
    Some((code, ebd))
}

/// AJT layout helper (REMADV): extract `(code, ebd)` from `AJT`.
/// AJT has DE4465 at element[0][0] and DE1082 (EBD qualifier like
/// `E_0403`) at element[1][0].
fn ajt_code_ebd(seg: &mig_types::segment::OwnedSegment) -> Option<(&str, &str)> {
    let code = seg
        .elements
        .first()?
        .first()
        .filter(|v| !v.is_empty())?
        .as_str();
    let ebd = seg
        .elements
        .get(1)?
        .first()
        .filter(|v| !v.is_empty())?
        .as_str();
    Some((code, ebd))
}

/// `[UTILMD_Strom 359/360]` — every `STS+E01` carries a code whose
/// cluster under its referenced EBD matches `predicate`.
///
/// `Unknown` when no `STS+E01` is present (insufficient context);
/// `True`/`False` when all segments agree / any segment disagrees.
pub fn all_sts_e01_in_cluster(
    ctx: &EvaluationContext,
    predicate: impl Fn(&Cluster) -> bool,
) -> ConditionResult {
    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
    if sts_segs.is_empty() {
        return ConditionResult::Unknown;
    }
    let all = sts_segs.iter().all(|s| {
        let Some((code, ebd)) = sts_e01_code_ebd(s) else {
            return false;
        };
        ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
    });
    ConditionResult::from(all)
}

/// `[UTILMD_Strom 366/368]` — every `STS+E01` references `required_ebd`
/// and its code is Ablehnung and not in `excluded_codes`.
pub fn ebd_ablehnung_except(
    ctx: &EvaluationContext,
    required_ebd: &str,
    excluded_codes: &[&str],
) -> ConditionResult {
    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
    if sts_segs.is_empty() {
        return ConditionResult::Unknown;
    }
    let all = sts_segs.iter().all(|s| {
        let Some((code, ebd)) = sts_e01_code_ebd(s) else {
            return false;
        };
        ebd == required_ebd
            && !excluded_codes.contains(&code)
            && ctx
                .ebd_clusters
                .cluster(ebd, code)
                .is_some_and(Cluster::is_ablehnung)
    });
    ConditionResult::from(all)
}

/// `[UTILTS 61]` — any `STS+E01` carries an Ablehnung-cluster code.
/// Existential rather than universal — matches the AHB wording
/// "Wenn in einem STS+E01 ... ein Antwortcode aus dem Cluster Ablehnung
/// vorhanden ist". Returns `False` rather than `Unknown` when no
/// `STS+E01` is present (the premise is absent).
pub fn any_sts_e01_in_cluster(
    ctx: &EvaluationContext,
    predicate: impl Fn(&Cluster) -> bool,
) -> ConditionResult {
    let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
    let found = sts_segs.iter().any(|s| {
        sts_e01_code_ebd(s)
            .is_some_and(|(code, ebd)| ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate))
    });
    ConditionResult::from(found)
}

/// `[ORDRSP 17/18]` — resolved STS code is in the given cluster under
/// the same segment's referenced EBD. Uses `ctx.resolved_value` +
/// `ctx.resolved_segment` which are populated by the tree validator
/// when visiting a specific field.
///
/// Returns `Unknown` when the resolved context is absent — so bulk
/// `evaluate(17, ctx)` calls without a resolved field get a safe
/// three-valued answer.
pub fn resolved_sts_code_in_cluster(
    ctx: &EvaluationContext,
    predicate: impl Fn(&Cluster) -> bool,
) -> ConditionResult {
    let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
        return ConditionResult::Unknown;
    };
    if code.is_empty() {
        return ConditionResult::Unknown;
    }
    let ebd = segment
        .get(2)
        .and_then(|e| e.get(1))
        .map(|s| s.as_str())
        .filter(|s| !s.is_empty());
    let Some(ebd) = ebd else {
        return ConditionResult::Unknown;
    };
    ctx.ebd_clusters
        .cluster(ebd, code)
        .map(|c| ConditionResult::from(predicate(c)))
        .unwrap_or(ConditionResult::Unknown)
}

/// `[REMADV 14/15/16]` — resolved AJT DE4465 code is in the given
/// cluster under the segment's referenced EBD (DE1082, element[1][0]).
pub fn resolved_ajt_code_in_cluster(
    ctx: &EvaluationContext,
    predicate: impl Fn(&Cluster) -> bool,
) -> ConditionResult {
    let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
        return ConditionResult::Unknown;
    };
    if code.is_empty() {
        return ConditionResult::Unknown;
    }
    let ebd = segment
        .get(1)
        .and_then(|e| e.first())
        .map(|s| s.as_str())
        .filter(|s| !s.is_empty());
    let Some(ebd) = ebd else {
        return ConditionResult::Unknown;
    };
    ctx.ebd_clusters
        .cluster(ebd, code)
        .map(|c| ConditionResult::from(predicate(c)))
        .unwrap_or(ConditionResult::Unknown)
}

/// `[REMADV 517/518]` — any AJT in the current scope (falls back to
/// message-wide when no scope is attached) carries a code whose
/// cluster matches `predicate` and whose A-code is not in
/// `excluded_codes`.
pub fn any_scoped_ajt_in_cluster(
    ctx: &EvaluationContext,
    excluded_codes: &[&str],
    predicate: impl Fn(&Cluster) -> bool,
) -> ConditionResult {
    let ajt_segs = ctx.scoped_find_segments("AJT");
    let found = ajt_segs.iter().any(|s| {
        ajt_code_ebd(s).is_some_and(|(code, ebd)| {
            !excluded_codes.contains(&code)
                && ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
        })
    });
    ConditionResult::from(found)
}

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

    #[test]
    fn a36_in_e0624_is_zustimmung() {
        let lookup = EbdClusterLookup::embedded();
        assert_eq!(lookup.cluster("E_0624", "A36"), Some(&Cluster::Zustimmung));
    }

    #[test]
    fn a30_in_e0624_is_ablehnung() {
        let lookup = EbdClusterLookup::embedded();
        assert_eq!(lookup.cluster("E_0624", "A30"), Some(&Cluster::Ablehnung));
    }

    #[test]
    fn unknown_code_returns_none() {
        let lookup = EbdClusterLookup::embedded();
        assert_eq!(lookup.cluster("E_0624", "Z99"), None);
    }

    #[test]
    fn unknown_ebd_returns_none() {
        let lookup = EbdClusterLookup::embedded();
        assert_eq!(lookup.cluster("E_9999", "A01"), None);
    }

    #[test]
    fn same_code_can_differ_across_ebds() {
        // A01 is a common outcome code across many EBDs; at minimum make
        // sure the (ebd, code) key shape works — both should resolve.
        let lookup = EbdClusterLookup::embedded();
        assert!(lookup.cluster("E_0014", "A01").is_some());
        assert!(lookup.cluster("E_0049", "A01").is_some());
    }

    #[test]
    fn korrekturliste_wegen_ablehnung_is_preserved_as_other() {
        let lookup = EbdClusterLookup::embedded();
        // E_0014 A04 has hint "Cluster: Korrekturliste wegen Ablehnung ...".
        // Not in the named enum variants, so it stays in Other with the
        // full multi-word label.
        match lookup.cluster("E_0014", "A04") {
            Some(Cluster::Other(s)) => assert_eq!(s, "Korrekturliste wegen Ablehnung"),
            other => {
                panic!("expected Cluster::Other(\"Korrekturliste wegen Ablehnung\"), got {other:?}")
            }
        }
    }

    #[test]
    fn sub_level_ablehnung_variants_still_count_as_ablehnung() {
        assert!(Cluster::Ablehnung.is_ablehnung());
        assert!(Cluster::AblehnungKopfebene.is_ablehnung());
        assert!(Cluster::AblehnungPositionsebene.is_ablehnung());
        assert!(Cluster::AblehnungSummenebene.is_ablehnung());
        assert!(!Cluster::Zustimmung.is_ablehnung());
        assert!(!Cluster::Other("Abweisung".into()).is_ablehnung());
    }
}