Skip to main content

automapper_validation/
workflow_schema.rs

1//! Build an [`AhbWorkflow`] from an AHB-enriched PID schema JSON value.
2//!
3//! This is the runtime-agnostic half of the former `automapper-api` validation
4//! bridge: it needs only `serde_json` + the condition parser + the shared
5//! `ahb-types`, so it lives here (a published crate). `automapper-generator`
6//! calls it at `compile-mappings` time to bake the workflow into the data bundle,
7//! and `automapper-api` re-exports it. The `AhbSchema`-based builders stay in
8//! `automapper-api` (they depend on `automapper-generator`).
9
10use std::collections::BTreeMap;
11
12use crate::expr::{ConditionExpr, ConditionParser};
13use crate::{AhbCodeRule, AhbFieldRule, AhbWorkflow};
14
15/// Build an [`AhbWorkflow`] from an enriched PID schema JSON value.
16///
17/// The schema must contain `ahb_status` fields on elements/components (produced by
18/// the generator with AHB enrichment enabled). Returns `None` if the schema is
19/// missing required top-level fields (`pid`, `beschreibung`).
20pub fn ahb_workflow_from_pid_schema(schema: &serde_json::Value) -> Option<AhbWorkflow> {
21    let pid = schema.get("pid")?.as_str()?;
22    let beschreibung = schema.get("beschreibung")?.as_str().unwrap_or("");
23    let kommunikation_von = schema
24        .get("kommunikation_von")
25        .and_then(|v| v.as_str())
26        .map(|s| s.to_string());
27
28    let mut fields = Vec::new();
29
30    // Walk groups in "fields" object
31    if let Some(groups) = schema.get("fields").and_then(|v| v.as_object()) {
32        for (_group_name, group) in groups {
33            let source_group = group
34                .get("source_group")
35                .and_then(|v| v.as_str())
36                .unwrap_or("");
37            let group_ahb_status = group
38                .get("ahb_status")
39                .and_then(|v| v.as_str())
40                .map(|s| s.to_string());
41            collect_fields_from_group(&mut fields, group, source_group, &group_ahb_status);
42        }
43    }
44
45    // Walk root_segments (outside any group)
46    if let Some(root_segs) = schema.get("root_segments").and_then(|v| v.as_array()) {
47        for seg in root_segs {
48            collect_fields_from_segment(&mut fields, seg, "", &None);
49        }
50    }
51
52    // Extract UB (Unterbedingung) definitions from the schema and parse them
53    // into ConditionExpr trees. These are composite condition expressions like
54    // UB1 = "([931] ∧ [932] [490]) ⊻ ([931] ∧ [933] [491])".
55    let ub_definitions: BTreeMap<String, ConditionExpr> = schema
56        .get("ub_definitions")
57        .and_then(|v| v.as_object())
58        .map(|obj| {
59            obj.iter()
60                .filter_map(|(k, v)| {
61                    let expr_str = v.as_str()?;
62                    ConditionParser::parse(expr_str)
63                        .ok()
64                        .flatten()
65                        .map(|expr| (k.clone(), expr))
66                })
67                .collect()
68        })
69        .unwrap_or_default();
70
71    // Strip group-level boundary entries (paths like "SG2", "SG4/SG8") emitted
72    // by the AHB parser for group status tracking. They don't represent actual
73    // EDIFACT fields and would cause misleading validation errors.
74    fields.retain(|f| !f.segment_path.split('/').all(|p| p.starts_with("SG")));
75
76    Some(AhbWorkflow {
77        pruefidentifikator: pid.to_string(),
78        description: beschreibung.to_string(),
79        communication_direction: kommunikation_von,
80        fields,
81        ub_definitions,
82    })
83}
84
85/// Recursively collect AHB field rules from a group and its children.
86fn collect_fields_from_group(
87    fields: &mut Vec<AhbFieldRule>,
88    group: &serde_json::Value,
89    group_path: &str,
90    parent_group_ahb_status: &Option<String>,
91) {
92    // Collect from segments in this group
93    if let Some(segments) = group.get("segments").and_then(|v| v.as_array()) {
94        for seg in segments {
95            collect_fields_from_segment(fields, seg, group_path, parent_group_ahb_status);
96        }
97    }
98
99    // Recurse into children
100    if let Some(children) = group.get("children").and_then(|v| v.as_object()) {
101        for (_child_name, child) in children {
102            let child_source = child
103                .get("source_group")
104                .and_then(|v| v.as_str())
105                .unwrap_or("");
106            let child_path = if group_path.is_empty() {
107                child_source.to_string()
108            } else {
109                format!("{}/{}", group_path, child_source)
110            };
111            let child_group_status = child
112                .get("ahb_status")
113                .and_then(|v| v.as_str())
114                .map(|s| s.to_string());
115            collect_fields_from_group(fields, child, &child_path, &child_group_status);
116        }
117    }
118}
119
120/// Collect AHB field rules from a single segment's elements.
121fn collect_fields_from_segment(
122    fields: &mut Vec<AhbFieldRule>,
123    seg: &serde_json::Value,
124    group_path: &str,
125    parent_group_ahb_status: &Option<String>,
126) {
127    let seg_id = seg.get("id").and_then(|v| v.as_str()).unwrap_or("");
128    let seg_mig_number = seg
129        .get("mig_number")
130        .and_then(|v| v.as_str())
131        .map(|s| s.to_string());
132    let seg_ahb_status = seg
133        .get("ahb_status")
134        .and_then(|v| v.as_str())
135        .map(|s| s.to_string());
136
137    if let Some(elements) = seg.get("elements").and_then(|v| v.as_array()) {
138        for el in elements {
139            // Direct data element (has "id" but no "composite")
140            if el.get("composite").is_none() {
141                if let Some(de_id) = el.get("id").and_then(|v| v.as_str()) {
142                    if let Some(ahb_status) = el.get("ahb_status").and_then(|v| v.as_str()) {
143                        let segment_path = if group_path.is_empty() {
144                            format!("{}/{}", seg_id, de_id)
145                        } else {
146                            format!("{}/{}/{}", group_path, seg_id, de_id)
147                        };
148                        // Prefer the element's parent_group_ahb_status only if it
149                        // contains conditions (brackets).  A plain "Muss" from the
150                        // segment level (S_LOC AHB_Status="Muss") masks the group's
151                        // conditional status (G_SG5 AHB_Status="Muss [2061] ∧ [96]"),
152                        // causing false mandatory errors for optional group variants.
153                        let el_parent_status = el
154                            .get("parent_group_ahb_status")
155                            .and_then(|v| v.as_str())
156                            .filter(|s| s.contains('['))
157                            .map(|s| s.to_string())
158                            .or_else(|| parent_group_ahb_status.clone());
159                        let name = el
160                            .get("name")
161                            .and_then(|v| v.as_str())
162                            .unwrap_or("")
163                            .to_string();
164                        let codes = collect_code_rules(el);
165                        let element_index =
166                            el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
167                        fields.push(AhbFieldRule {
168                            segment_path,
169                            name,
170                            ahb_status: ahb_status.to_string(),
171                            codes,
172                            parent_group_ahb_status: el_parent_status,
173                            segment_ahb_status: seg_ahb_status.clone(),
174                            element_index,
175                            component_index: None,
176                            mig_number: seg_mig_number.clone(),
177                        });
178                    }
179                }
180            }
181
182            // Composite element — walk components
183            if let Some(composite_id) = el.get("composite").and_then(|v| v.as_str()) {
184                if let Some(components) = el.get("components").and_then(|v| v.as_array()) {
185                    for comp in components {
186                        let comp_id = comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
187                        if let Some(ahb_status) = comp.get("ahb_status").and_then(|v| v.as_str()) {
188                            let segment_path = if group_path.is_empty() {
189                                format!("{}/{}/{}", seg_id, composite_id, comp_id)
190                            } else {
191                                format!("{}/{}/{}/{}", group_path, seg_id, composite_id, comp_id)
192                            };
193                            let comp_parent_status = comp
194                                .get("parent_group_ahb_status")
195                                .and_then(|v| v.as_str())
196                                .filter(|s| s.contains('['))
197                                .map(|s| s.to_string())
198                                .or_else(|| parent_group_ahb_status.clone());
199                            let name = comp
200                                .get("name")
201                                .and_then(|v| v.as_str())
202                                .unwrap_or("")
203                                .to_string();
204                            let codes = collect_code_rules(comp);
205                            let element_index =
206                                el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
207                            let component_index = comp
208                                .get("sub_index")
209                                .and_then(|v| v.as_u64())
210                                .map(|v| v as usize);
211                            fields.push(AhbFieldRule {
212                                segment_path,
213                                name,
214                                ahb_status: ahb_status.to_string(),
215                                codes,
216                                parent_group_ahb_status: comp_parent_status,
217                                segment_ahb_status: seg_ahb_status.clone(),
218                                element_index,
219                                component_index,
220                                mig_number: seg_mig_number.clone(),
221                            });
222                        }
223                    }
224                }
225            }
226        }
227    }
228}
229
230/// Collect code rules from an element or component's "codes" array.
231fn collect_code_rules(el: &serde_json::Value) -> Vec<AhbCodeRule> {
232    let Some(codes) = el.get("codes").and_then(|v| v.as_array()) else {
233        return Vec::new();
234    };
235    codes
236        .iter()
237        .map(|c| AhbCodeRule {
238            value: c
239                .get("value")
240                .and_then(|v| v.as_str())
241                .unwrap_or("")
242                .to_string(),
243            description: c
244                .get("name")
245                .and_then(|v| v.as_str())
246                .unwrap_or("")
247                .to_string(),
248            ahb_status: c
249                .get("ahb_status")
250                .and_then(|v| v.as_str())
251                .unwrap_or("X")
252                .to_string(),
253        })
254        .collect()
255}