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                            format: el.get("format").and_then(|v| v.as_str()).map(String::from),
178                        });
179                    }
180                }
181            }
182
183            // Composite element — walk components
184            if let Some(composite_id) = el.get("composite").and_then(|v| v.as_str()) {
185                if let Some(components) = el.get("components").and_then(|v| v.as_array()) {
186                    for comp in components {
187                        let comp_id = comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
188                        if let Some(ahb_status) = comp.get("ahb_status").and_then(|v| v.as_str()) {
189                            let segment_path = if group_path.is_empty() {
190                                format!("{}/{}/{}", seg_id, composite_id, comp_id)
191                            } else {
192                                format!("{}/{}/{}/{}", group_path, seg_id, composite_id, comp_id)
193                            };
194                            let comp_parent_status = comp
195                                .get("parent_group_ahb_status")
196                                .and_then(|v| v.as_str())
197                                .filter(|s| s.contains('['))
198                                .map(|s| s.to_string())
199                                .or_else(|| parent_group_ahb_status.clone());
200                            let name = comp
201                                .get("name")
202                                .and_then(|v| v.as_str())
203                                .unwrap_or("")
204                                .to_string();
205                            let codes = collect_code_rules(comp);
206                            let element_index =
207                                el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
208                            let component_index = comp
209                                .get("sub_index")
210                                .and_then(|v| v.as_u64())
211                                .map(|v| v as usize);
212                            fields.push(AhbFieldRule {
213                                segment_path,
214                                name,
215                                ahb_status: ahb_status.to_string(),
216                                codes,
217                                parent_group_ahb_status: comp_parent_status,
218                                segment_ahb_status: seg_ahb_status.clone(),
219                                element_index,
220                                component_index,
221                                mig_number: seg_mig_number.clone(),
222                                format: comp
223                                    .get("format")
224                                    .and_then(|v| v.as_str())
225                                    .map(String::from),
226                            });
227                        }
228                    }
229                }
230            }
231        }
232    }
233}
234
235/// Collect code rules from an element or component's "codes" array.
236fn collect_code_rules(el: &serde_json::Value) -> Vec<AhbCodeRule> {
237    let Some(codes) = el.get("codes").and_then(|v| v.as_array()) else {
238        return Vec::new();
239    };
240    codes
241        .iter()
242        .map(|c| AhbCodeRule {
243            value: c
244                .get("value")
245                .and_then(|v| v.as_str())
246                .unwrap_or("")
247                .to_string(),
248            description: c
249                .get("name")
250                .and_then(|v| v.as_str())
251                .unwrap_or("")
252                .to_string(),
253            ahb_status: c
254                .get("ahb_status")
255                .and_then(|v| v.as_str())
256                .unwrap_or("X")
257                .to_string(),
258        })
259        .collect()
260}