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    // The packages' conditions: `"11": "[68]"`.
72    let package_conditions: BTreeMap<u32, ConditionExpr> = schema
73        .get("package_conditions")
74        .and_then(|v| v.as_object())
75        .map(|obj| {
76            obj.iter()
77                .filter_map(|(k, v)| {
78                    let id = k.parse().ok()?;
79                    ConditionParser::parse(v.as_str()?)
80                        .ok()
81                        .flatten()
82                        .map(|expr| (id, expr))
83                })
84                .collect()
85        })
86        .unwrap_or_default();
87
88    // Strip group-level boundary entries (paths like "SG2", "SG4/SG8") emitted
89    // by the AHB parser for group status tracking. They don't represent actual
90    // EDIFACT fields and would cause misleading validation errors.
91    fields.retain(|f| !f.segment_path.split('/').all(|p| p.starts_with("SG")));
92
93    Some(AhbWorkflow {
94        pruefidentifikator: pid.to_string(),
95        description: beschreibung.to_string(),
96        communication_direction: kommunikation_von,
97        fields,
98        ub_definitions,
99        package_conditions,
100    })
101}
102
103/// Recursively collect AHB field rules from a group and its children.
104fn collect_fields_from_group(
105    fields: &mut Vec<AhbFieldRule>,
106    group: &serde_json::Value,
107    group_path: &str,
108    parent_group_ahb_status: &Option<String>,
109) {
110    // Collect from segments in this group
111    if let Some(segments) = group.get("segments").and_then(|v| v.as_array()) {
112        for seg in segments {
113            collect_fields_from_segment(fields, seg, group_path, parent_group_ahb_status);
114        }
115    }
116
117    // Recurse into children
118    if let Some(children) = group.get("children").and_then(|v| v.as_object()) {
119        for (_child_name, child) in children {
120            let child_source = child
121                .get("source_group")
122                .and_then(|v| v.as_str())
123                .unwrap_or("");
124            let child_path = if group_path.is_empty() {
125                child_source.to_string()
126            } else {
127                format!("{}/{}", group_path, child_source)
128            };
129            let child_group_status = child
130                .get("ahb_status")
131                .and_then(|v| v.as_str())
132                .map(|s| s.to_string());
133            collect_fields_from_group(fields, child, &child_path, &child_group_status);
134        }
135    }
136}
137
138/// Collect AHB field rules from a single segment's elements.
139fn collect_fields_from_segment(
140    fields: &mut Vec<AhbFieldRule>,
141    seg: &serde_json::Value,
142    group_path: &str,
143    parent_group_ahb_status: &Option<String>,
144) {
145    let seg_id = seg.get("id").and_then(|v| v.as_str()).unwrap_or("");
146    let seg_mig_number = seg
147        .get("mig_number")
148        .and_then(|v| v.as_str())
149        .map(|s| s.to_string());
150    let seg_ahb_status = seg
151        .get("ahb_status")
152        .and_then(|v| v.as_str())
153        .map(|s| s.to_string());
154
155    if let Some(elements) = seg.get("elements").and_then(|v| v.as_array()) {
156        for el in elements {
157            // Direct data element (has "id" but no "composite")
158            if el.get("composite").is_none() {
159                if let Some(de_id) = el.get("id").and_then(|v| v.as_str()) {
160                    if let Some(ahb_status) = el.get("ahb_status").and_then(|v| v.as_str()) {
161                        let segment_path = if group_path.is_empty() {
162                            format!("{}/{}", seg_id, de_id)
163                        } else {
164                            format!("{}/{}/{}", group_path, seg_id, de_id)
165                        };
166                        // Prefer the element's parent_group_ahb_status only if it
167                        // contains conditions (brackets).  A plain "Muss" from the
168                        // segment level (S_LOC AHB_Status="Muss") masks the group's
169                        // conditional status (G_SG5 AHB_Status="Muss [2061] ∧ [96]"),
170                        // causing false mandatory errors for optional group variants.
171                        let el_parent_status = el
172                            .get("parent_group_ahb_status")
173                            .and_then(|v| v.as_str())
174                            .filter(|s| s.contains('['))
175                            .map(|s| s.to_string())
176                            .or_else(|| parent_group_ahb_status.clone());
177                        let name = el
178                            .get("name")
179                            .and_then(|v| v.as_str())
180                            .unwrap_or("")
181                            .to_string();
182                        let codes = collect_code_rules(el);
183                        let element_index =
184                            el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
185                        fields.push(AhbFieldRule {
186                            segment_path,
187                            name,
188                            ahb_status: ahb_status.to_string(),
189                            codes,
190                            parent_group_ahb_status: el_parent_status,
191                            segment_ahb_status: seg_ahb_status.clone(),
192                            element_index,
193                            component_index: None,
194                            mig_number: seg_mig_number.clone(),
195                            format: el.get("format").and_then(|v| v.as_str()).map(String::from),
196                        });
197                    }
198                }
199            }
200
201            // Composite element — walk components
202            if let Some(composite_id) = el.get("composite").and_then(|v| v.as_str()) {
203                if let Some(components) = el.get("components").and_then(|v| v.as_array()) {
204                    for comp in components {
205                        let comp_id = comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
206                        if let Some(ahb_status) = comp.get("ahb_status").and_then(|v| v.as_str()) {
207                            let segment_path = if group_path.is_empty() {
208                                format!("{}/{}/{}", seg_id, composite_id, comp_id)
209                            } else {
210                                format!("{}/{}/{}/{}", group_path, seg_id, composite_id, comp_id)
211                            };
212                            let comp_parent_status = comp
213                                .get("parent_group_ahb_status")
214                                .and_then(|v| v.as_str())
215                                .filter(|s| s.contains('['))
216                                .map(|s| s.to_string())
217                                .or_else(|| parent_group_ahb_status.clone());
218                            let name = comp
219                                .get("name")
220                                .and_then(|v| v.as_str())
221                                .unwrap_or("")
222                                .to_string();
223                            let codes = collect_code_rules(comp);
224                            let element_index =
225                                el.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
226                            let component_index = comp
227                                .get("sub_index")
228                                .and_then(|v| v.as_u64())
229                                .map(|v| v as usize);
230                            fields.push(AhbFieldRule {
231                                segment_path,
232                                name,
233                                ahb_status: ahb_status.to_string(),
234                                codes,
235                                parent_group_ahb_status: comp_parent_status,
236                                segment_ahb_status: seg_ahb_status.clone(),
237                                element_index,
238                                component_index,
239                                mig_number: seg_mig_number.clone(),
240                                format: comp
241                                    .get("format")
242                                    .and_then(|v| v.as_str())
243                                    .map(String::from),
244                            });
245                        }
246                    }
247                }
248            }
249        }
250    }
251}
252
253/// Collect code rules from an element or component's "codes" array.
254fn collect_code_rules(el: &serde_json::Value) -> Vec<AhbCodeRule> {
255    let Some(codes) = el.get("codes").and_then(|v| v.as_array()) else {
256        return Vec::new();
257    };
258    codes
259        .iter()
260        .map(|c| AhbCodeRule {
261            value: c
262                .get("value")
263                .and_then(|v| v.as_str())
264                .unwrap_or("")
265                .to_string(),
266            description: c
267                .get("name")
268                .and_then(|v| v.as_str())
269                .unwrap_or("")
270                .to_string(),
271            ahb_status: c
272                .get("ahb_status")
273                .and_then(|v| v.as_str())
274                .unwrap_or("X")
275                .to_string(),
276        })
277        .collect()
278}