ahb-types 0.12.0

Serializable AHB rulebook data types (AhbWorkflow, condition expression AST) shared by the mapping and validation engines.
Documentation
//! Serializable AHB rulebook data types shared across the workspace.
//!
//! These are pure data types — no parsing or evaluation logic. The mapping crate
//! (`mig-bo4e`) embeds [`AhbWorkflow`] in its distribution bundle, and the validation
//! crate (`automapper-validation`) interprets it. Keeping the types here lets both
//! depend on the data without a mapping ↔ validation crate edge. This mirrors how
//! `PidRequirements` (the BO4E-side rulebook) already lives in the mapping crate.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

/// A parsed AHB condition expression tree.
///
/// Represents boolean combinations of condition references like `[1] ∧ [2]` or
/// `([3] ∨ [4]) ⊻ [5]`.
///
/// # Examples
///
/// A single condition reference:
/// ```
/// use ahb_types::ConditionExpr;
/// let expr = ConditionExpr::Ref(931);
/// assert_eq!(expr.condition_ids(), [931].into());
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConditionExpr {
    /// A leaf reference to a single condition by number, e.g., `[931]`.
    Ref(u32),

    /// Boolean AND of one or more expressions. All must be true.
    /// Invariant: `exprs.len() >= 2`.
    And(Vec<ConditionExpr>),

    /// Boolean OR of one or more expressions. At least one must be true.
    /// Invariant: `exprs.len() >= 2`.
    Or(Vec<ConditionExpr>),

    /// Boolean XOR of exactly two expressions. Exactly one must be true.
    Xor(Box<ConditionExpr>, Box<ConditionExpr>),

    /// Boolean NOT of an expression.
    Not(Box<ConditionExpr>),

    /// Package cardinality constraint: [NP_min..max]
    Package { id: u32, min: u32, max: u32 },
}

impl ConditionExpr {
    /// Extracts all condition IDs referenced in this expression tree.
    pub fn condition_ids(&self) -> BTreeSet<u32> {
        let mut ids = BTreeSet::new();
        self.collect_ids(&mut ids);
        ids
    }

    fn collect_ids(&self, ids: &mut BTreeSet<u32>) {
        match self {
            ConditionExpr::Ref(id) => {
                ids.insert(*id);
            }
            ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
                for expr in exprs {
                    expr.collect_ids(ids);
                }
            }
            ConditionExpr::Xor(left, right) => {
                left.collect_ids(ids);
                right.collect_ids(ids);
            }
            ConditionExpr::Not(inner) => {
                inner.collect_ids(ids);
            }
            ConditionExpr::Package { .. } => {
                // Package constraints are structural, not condition references
            }
        }
    }
}

impl std::fmt::Display for ConditionExpr {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ConditionExpr::Ref(id) => write!(f, "[{id}]"),
            ConditionExpr::And(exprs) => {
                let parts: Vec<String> = exprs.iter().map(|e| format!("{e}")).collect();
                write!(f, "({})", parts.join(" ∧ "))
            }
            ConditionExpr::Or(exprs) => {
                let parts: Vec<String> = exprs.iter().map(|e| format!("{e}")).collect();
                write!(f, "({})", parts.join(" ∨ "))
            }
            ConditionExpr::Xor(left, right) => write!(f, "({left} ⊻ {right})"),
            ConditionExpr::Not(inner) => write!(f, "NOT {inner}"),
            ConditionExpr::Package { id, min, max } => write!(f, "[{id}P{min}..{max}]"),
        }
    }
}

/// An allowed code value within an AHB field rule.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AhbCodeRule {
    /// The code value (e.g., "E01", "Z33").
    pub value: String,

    /// Description of the code (e.g., "Anmeldung").
    pub description: String,

    /// AHB status for this code (e.g., "X", "Muss").
    pub ahb_status: String,
}

/// AHB field definition for validation.
///
/// Represents a single field in an AHB rule table with its status
/// and allowed codes for a specific Pruefidentifikator.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AhbFieldRule {
    /// Segment path (e.g., "SG2/NAD/C082/3039").
    pub segment_path: String,

    /// Human-readable field name (e.g., "MP-ID des MSB").
    pub name: String,

    /// AHB status (e.g., "Muss [182] ∧ [152]", "X", "Kann").
    pub ahb_status: String,

    /// Allowed code values with their AHB status.
    pub codes: Vec<AhbCodeRule>,

    /// AHB status of the innermost parent group (e.g., "Kann", "Muss", "Soll [46]").
    ///
    /// When the parent group is optional ("Kann") and its qualifier variant is
    /// absent from the message, mandatory checks for child fields are skipped.
    pub parent_group_ahb_status: Option<String>,

    /// AHB status of the containing segment (e.g., "Kann", "Muss", "Muss [10]").
    ///
    /// When the containing segment is optional ("Kann") and absent from the
    /// instance, AHB001 missing-field errors on its sub-fields are suppressed:
    /// "X" on a Kann segment's sub-element means "required IF segment present".
    pub segment_ahb_status: Option<String>,

    /// Element index within the segment (0-based). Used to locate the correct
    /// element when checking presence and code values. `None` defaults to 0.
    pub element_index: Option<usize>,

    /// Component sub-index within a composite element (0-based). Used to locate
    /// the correct component. `None` defaults to 0.
    pub component_index: Option<usize>,

    /// MIG `Number` attribute of the parent segment. Links this AHB field to
    /// the corresponding `AssembledSegment::mig_number` for tree-based joining.
    pub mig_number: Option<String>,
}

/// AHB workflow definition for a specific Pruefidentifikator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AhbWorkflow {
    /// The Pruefidentifikator (e.g., "11001", "55001").
    pub pruefidentifikator: String,

    /// Description of the workflow.
    pub description: String,

    /// Communication direction (e.g., "NB an LF").
    pub communication_direction: Option<String>,

    /// All field rules for this workflow.
    pub fields: Vec<AhbFieldRule>,

    /// UB (Unterbedingung) definitions parsed from the AHB XML.
    ///
    /// Maps UB IDs (e.g., "UB1") to their parsed condition expressions.
    /// These are expanded inline when evaluating condition expressions
    /// that reference UB conditions.
    pub ub_definitions: BTreeMap<String, ConditionExpr>,
}

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

    #[test]
    fn test_ref_condition_ids() {
        let expr = ConditionExpr::Ref(931);
        assert_eq!(expr.condition_ids(), [931].into());
    }

    #[test]
    fn test_nested_condition_ids() {
        // (([1] ∧ [2]) ∨ ([3] ∧ [4])) ⊻ [5]
        let expr = ConditionExpr::Xor(
            Box::new(ConditionExpr::Or(vec![
                ConditionExpr::And(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)]),
                ConditionExpr::And(vec![ConditionExpr::Ref(3), ConditionExpr::Ref(4)]),
            ])),
            Box::new(ConditionExpr::Ref(5)),
        );
        assert_eq!(expr.condition_ids(), [1, 2, 3, 4, 5].into());
    }

    #[test]
    fn test_display_complex() {
        let expr = ConditionExpr::Xor(
            Box::new(ConditionExpr::And(vec![
                ConditionExpr::Ref(102),
                ConditionExpr::Ref(2006),
            ])),
            Box::new(ConditionExpr::And(vec![
                ConditionExpr::Ref(103),
                ConditionExpr::Ref(2005),
            ])),
        );
        assert_eq!(format!("{expr}"), "(([102] ∧ [2006]) ⊻ ([103] ∧ [2005]))");
    }

    #[test]
    fn test_package_condition_ids() {
        let expr = ConditionExpr::Package {
            id: 4,
            min: 0,
            max: 1,
        };
        assert!(expr.condition_ids().is_empty());
    }

    /// The whole point of this crate: the workflow must survive a serde round-trip
    /// so it can be baked into the distribution bundle (bincode) and read back.
    #[test]
    fn ahb_workflow_json_roundtrip() {
        let wf = AhbWorkflow {
            pruefidentifikator: "55001".into(),
            description: "Anmeldung MaLo".into(),
            communication_direction: Some("NB an LF".into()),
            fields: vec![AhbFieldRule {
                segment_path: "SG2/NAD/3035".into(),
                name: "Partnerrolle".into(),
                ahb_status: "Muss [182] ∧ [152]".into(),
                codes: vec![AhbCodeRule {
                    value: "MS".into(),
                    description: "Messstellenbetreiber".into(),
                    ahb_status: "X".into(),
                }],
                mig_number: Some("0042".into()),
                ..Default::default()
            }],
            ub_definitions: BTreeMap::from([(
                "UB1".to_string(),
                ConditionExpr::Xor(
                    Box::new(ConditionExpr::Ref(931)),
                    Box::new(ConditionExpr::Ref(932)),
                ),
            )]),
        };

        let json = serde_json::to_string(&wf).unwrap();
        let back: AhbWorkflow = serde_json::from_str(&json).unwrap();

        assert_eq!(back.pruefidentifikator, "55001");
        assert_eq!(back.fields.len(), 1);
        assert_eq!(back.fields[0].codes[0].value, "MS");
        assert_eq!(back.fields[0].ahb_status, "Muss [182] ∧ [152]");
        assert_eq!(
            back.ub_definitions["UB1"].condition_ids(),
            [931, 932].into()
        );
    }
}