Skip to main content

ahb_types/
lib.rs

1//! Serializable AHB rulebook data types shared across the workspace.
2//!
3//! These are pure data types — no parsing or evaluation logic. The mapping crate
4//! (`mig-bo4e`) embeds [`AhbWorkflow`] in its distribution bundle, and the validation
5//! crate (`automapper-validation`) interprets it. Keeping the types here lets both
6//! depend on the data without a mapping ↔ validation crate edge. This mirrors how
7//! `PidRequirements` (the BO4E-side rulebook) already lives in the mapping crate.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13/// A parsed AHB condition expression tree.
14///
15/// Represents boolean combinations of condition references like `[1] ∧ [2]` or
16/// `([3] ∨ [4]) ⊻ [5]`.
17///
18/// # Examples
19///
20/// A single condition reference:
21/// ```
22/// use ahb_types::ConditionExpr;
23/// let expr = ConditionExpr::Ref(931);
24/// assert_eq!(expr.condition_ids(), [931].into());
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub enum ConditionExpr {
28    /// A leaf reference to a single condition by number, e.g., `[931]`.
29    Ref(u32),
30
31    /// Boolean AND of one or more expressions. All must be true.
32    /// Invariant: `exprs.len() >= 2`.
33    And(Vec<ConditionExpr>),
34
35    /// Boolean OR of one or more expressions. At least one must be true.
36    /// Invariant: `exprs.len() >= 2`.
37    Or(Vec<ConditionExpr>),
38
39    /// Boolean XOR of exactly two expressions. Exactly one must be true.
40    Xor(Box<ConditionExpr>, Box<ConditionExpr>),
41
42    /// Boolean NOT of an expression.
43    Not(Box<ConditionExpr>),
44
45    /// Package cardinality constraint: [NP_min..max]
46    Package { id: u32, min: u32, max: u32 },
47}
48
49impl ConditionExpr {
50    /// Extracts all condition IDs referenced in this expression tree.
51    pub fn condition_ids(&self) -> BTreeSet<u32> {
52        let mut ids = BTreeSet::new();
53        self.collect_ids(&mut ids);
54        ids
55    }
56
57    fn collect_ids(&self, ids: &mut BTreeSet<u32>) {
58        match self {
59            ConditionExpr::Ref(id) => {
60                ids.insert(*id);
61            }
62            ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
63                for expr in exprs {
64                    expr.collect_ids(ids);
65                }
66            }
67            ConditionExpr::Xor(left, right) => {
68                left.collect_ids(ids);
69                right.collect_ids(ids);
70            }
71            ConditionExpr::Not(inner) => {
72                inner.collect_ids(ids);
73            }
74            ConditionExpr::Package { .. } => {
75                // Package constraints are structural, not condition references
76            }
77        }
78    }
79}
80
81impl std::fmt::Display for ConditionExpr {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            ConditionExpr::Ref(id) => write!(f, "[{id}]"),
85            ConditionExpr::And(exprs) => {
86                let parts: Vec<String> = exprs.iter().map(|e| format!("{e}")).collect();
87                write!(f, "({})", parts.join(" ∧ "))
88            }
89            ConditionExpr::Or(exprs) => {
90                let parts: Vec<String> = exprs.iter().map(|e| format!("{e}")).collect();
91                write!(f, "({})", parts.join(" ∨ "))
92            }
93            ConditionExpr::Xor(left, right) => write!(f, "({left} ⊻ {right})"),
94            ConditionExpr::Not(inner) => write!(f, "NOT {inner}"),
95            ConditionExpr::Package { id, min, max } => write!(f, "[{id}P{min}..{max}]"),
96        }
97    }
98}
99
100/// An allowed code value within an AHB field rule.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct AhbCodeRule {
103    /// The code value (e.g., "E01", "Z33").
104    pub value: String,
105
106    /// Description of the code (e.g., "Anmeldung").
107    pub description: String,
108
109    /// AHB status for this code (e.g., "X", "Muss").
110    pub ahb_status: String,
111}
112
113/// AHB field definition for validation.
114///
115/// Represents a single field in an AHB rule table with its status
116/// and allowed codes for a specific Pruefidentifikator.
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct AhbFieldRule {
119    /// Segment path (e.g., "SG2/NAD/C082/3039").
120    pub segment_path: String,
121
122    /// Human-readable field name (e.g., "MP-ID des MSB").
123    pub name: String,
124
125    /// AHB status (e.g., "Muss [182] ∧ [152]", "X", "Kann").
126    pub ahb_status: String,
127
128    /// Allowed code values with their AHB status.
129    pub codes: Vec<AhbCodeRule>,
130
131    /// AHB status of the innermost parent group (e.g., "Kann", "Muss", "Soll [46]").
132    ///
133    /// When the parent group is optional ("Kann") and its qualifier variant is
134    /// absent from the message, mandatory checks for child fields are skipped.
135    pub parent_group_ahb_status: Option<String>,
136
137    /// AHB status of the containing segment (e.g., "Kann", "Muss", "Muss [10]").
138    ///
139    /// When the containing segment is optional ("Kann") and absent from the
140    /// instance, AHB001 missing-field errors on its sub-fields are suppressed:
141    /// "X" on a Kann segment's sub-element means "required IF segment present".
142    pub segment_ahb_status: Option<String>,
143
144    /// Element index within the segment (0-based). Used to locate the correct
145    /// element when checking presence and code values. `None` defaults to 0.
146    pub element_index: Option<usize>,
147
148    /// Component sub-index within a composite element (0-based). Used to locate
149    /// the correct component. `None` defaults to 0.
150    pub component_index: Option<usize>,
151
152    /// MIG `Number` attribute of the parent segment. Links this AHB field to
153    /// the corresponding `AssembledSegment::mig_number` for tree-based joining.
154    pub mig_number: Option<String>,
155
156    /// The MIG data format, e.g. `"an..512"`, `"n13"`. `None` when unknown.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub format: Option<String>,
159
160    /// The innermost enclosing group's own AHB status, as the AHB writes it
161    /// (`Muss [81]` on 55035's SG10 Zugeordnete Definition), where
162    /// `parent_group_ahb_status` ANDs every conditional ancestor's condition
163    /// under `Muss` (`Muss ([166] ∧ [205]) ∧ ([81])`). Whether an absent
164    /// child group is required is its own status, judged in its present
165    /// parent. `None` for workflows built before it existed.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub group_ahb_status: Option<String>,
168}
169
170/// AHB workflow definition for a specific Pruefidentifikator.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct AhbWorkflow {
173    /// The Pruefidentifikator (e.g., "11001", "55001").
174    pub pruefidentifikator: String,
175
176    /// Description of the workflow.
177    pub description: String,
178
179    /// Communication direction (e.g., "NB an LF").
180    pub communication_direction: Option<String>,
181
182    /// All field rules for this workflow.
183    pub fields: Vec<AhbFieldRule>,
184
185    /// UB (Unterbedingung) definitions parsed from the AHB XML.
186    ///
187    /// Maps UB IDs (e.g., "UB1") to their parsed condition expressions.
188    /// These are expanded inline when evaluating condition expressions
189    /// that reference UB conditions.
190    pub ub_definitions: BTreeMap<String, ConditionExpr>,
191
192    /// The conditions of the AHB's packages (its `<Pakete>` section): package
193    /// number to condition, `[11P]` → `[68]` ("Wenn SG8 SEQ+ZH0 mehr als
194    /// zweimal vorhanden"). A code's `[11P1..1]` binds only where its package's
195    /// condition holds. Packages without a condition (`[1P]`, `--`) are absent.
196    /// Not skipped when empty: the cache serializes workflows with bincode,
197    /// which has no field names to tell a skipped field from the next value.
198    #[serde(default)]
199    pub package_conditions: BTreeMap<u32, ConditionExpr>,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_ref_condition_ids() {
208        let expr = ConditionExpr::Ref(931);
209        assert_eq!(expr.condition_ids(), [931].into());
210    }
211
212    #[test]
213    fn test_nested_condition_ids() {
214        // (([1] ∧ [2]) ∨ ([3] ∧ [4])) ⊻ [5]
215        let expr = ConditionExpr::Xor(
216            Box::new(ConditionExpr::Or(vec![
217                ConditionExpr::And(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)]),
218                ConditionExpr::And(vec![ConditionExpr::Ref(3), ConditionExpr::Ref(4)]),
219            ])),
220            Box::new(ConditionExpr::Ref(5)),
221        );
222        assert_eq!(expr.condition_ids(), [1, 2, 3, 4, 5].into());
223    }
224
225    #[test]
226    fn test_display_complex() {
227        let expr = ConditionExpr::Xor(
228            Box::new(ConditionExpr::And(vec![
229                ConditionExpr::Ref(102),
230                ConditionExpr::Ref(2006),
231            ])),
232            Box::new(ConditionExpr::And(vec![
233                ConditionExpr::Ref(103),
234                ConditionExpr::Ref(2005),
235            ])),
236        );
237        assert_eq!(format!("{expr}"), "(([102] ∧ [2006]) ⊻ ([103] ∧ [2005]))");
238    }
239
240    #[test]
241    fn test_package_condition_ids() {
242        let expr = ConditionExpr::Package {
243            id: 4,
244            min: 0,
245            max: 1,
246        };
247        assert!(expr.condition_ids().is_empty());
248    }
249
250    /// The whole point of this crate: the workflow must survive a serde round-trip
251    /// so it can be baked into the distribution bundle (bincode) and read back.
252    #[test]
253    fn ahb_workflow_json_roundtrip() {
254        let wf = AhbWorkflow {
255            pruefidentifikator: "55001".into(),
256            description: "Anmeldung MaLo".into(),
257            communication_direction: Some("NB an LF".into()),
258            fields: vec![AhbFieldRule {
259                segment_path: "SG2/NAD/3035".into(),
260                name: "Partnerrolle".into(),
261                ahb_status: "Muss [182] ∧ [152]".into(),
262                codes: vec![AhbCodeRule {
263                    value: "MS".into(),
264                    description: "Messstellenbetreiber".into(),
265                    ahb_status: "X".into(),
266                }],
267                mig_number: Some("0042".into()),
268                ..Default::default()
269            }],
270            ub_definitions: BTreeMap::from([(
271                "UB1".to_string(),
272                ConditionExpr::Xor(
273                    Box::new(ConditionExpr::Ref(931)),
274                    Box::new(ConditionExpr::Ref(932)),
275                ),
276            )]),
277            package_conditions: Default::default(),
278        };
279
280        let json = serde_json::to_string(&wf).unwrap();
281        let back: AhbWorkflow = serde_json::from_str(&json).unwrap();
282
283        assert_eq!(back.pruefidentifikator, "55001");
284        assert_eq!(back.fields.len(), 1);
285        assert_eq!(back.fields[0].codes[0].value, "MS");
286        assert_eq!(back.fields[0].ahb_status, "Muss [182] ∧ [152]");
287        assert_eq!(
288            back.ub_definitions["UB1"].condition_ids(),
289            [931, 932].into()
290        );
291    }
292}