Skip to main content

mig_bo4e/
path_resolver.rs

1//! Resolves EDIFACT ID paths to numeric element indices (and vice versa).
2//!
3//! Built from a PID schema JSON. Used at TOML load time to normalize
4//! named paths (e.g., `loc.c517.d3225`) to numeric paths (`loc.1.0`).
5//! This keeps the engine hot path unchanged — all resolution happens once at load time.
6//!
7//! Also provides [`ReversePathResolver`] for converting numeric paths back to
8//! self-documenting EDIFACT ID paths (used by the `migrate-paths` CLI).
9
10use std::collections::HashMap;
11use std::path::Path;
12
13// ── Forward resolver: named → numeric ──
14
15/// Resolves EDIFACT ID paths to numeric element indices.
16///
17/// Built from a PID schema JSON. Used at TOML load time to normalize
18/// named paths (e.g., "loc.c517.d3225") to numeric paths ("loc.1.0").
19///
20/// Supports ordinal suffixes for duplicate IDs:
21/// - Duplicate composites per segment: `c556` (first), `c556_2` (second), `c556_3` (third)
22/// - Duplicate data elements per composite: `d3036` (first), `d3036_2` (second), etc.
23#[derive(Clone)]
24pub struct PathResolver {
25    /// (segment_tag_upper, composite_id_lower, data_element_id_lower) → (element_index, sub_index)
26    composite_elements: HashMap<(String, String, String), (usize, usize)>,
27    /// (segment_tag_upper, element_id_lower) → element_index for simple data elements
28    simple_elements: HashMap<(String, String), usize>,
29}
30
31impl PathResolver {
32    /// Build from a PID schema JSON (`serde_json::Value`).
33    ///
34    /// Walks all groups recursively, collecting segment element indices.
35    pub fn from_schema(schema: &serde_json::Value) -> Self {
36        let mut simple_elements = HashMap::new();
37        let mut composite_elements = HashMap::new();
38
39        if let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) {
40            for (_group_key, group_val) in fields {
41                collect_from_group(group_val, &mut simple_elements, &mut composite_elements);
42            }
43        }
44
45        // Also collect from root_segments (used by CONTRL, APERAK, etc.)
46        if let Some(root_segs) = schema.get("root_segments") {
47            let wrapper = serde_json::json!({ "segments": root_segs });
48            collect_from_group(&wrapper, &mut simple_elements, &mut composite_elements);
49        }
50
51        Self {
52            simple_elements,
53            composite_elements,
54        }
55    }
56
57    /// Build from all PID schema JSON files in a directory.
58    ///
59    /// Loads every `pid_*_schema.json` file and merges their element mappings.
60    /// This ensures comprehensive coverage across all PIDs.
61    pub fn from_schema_dir(dir: &Path) -> Self {
62        let mut resolver = Self {
63            simple_elements: HashMap::new(),
64            composite_elements: HashMap::new(),
65        };
66
67        let mut entries: Vec<_> = std::fs::read_dir(dir)
68            .into_iter()
69            .flatten()
70            .filter_map(|e| e.ok())
71            .collect();
72        entries.sort_by_key(|e| e.file_name());
73
74        for entry in entries {
75            let path = entry.path();
76            let is_schema = path
77                .file_name()
78                .and_then(|n| n.to_str())
79                .map(|n| n.starts_with("pid_") && n.ends_with("_schema.json"))
80                .unwrap_or(false);
81            if is_schema {
82                if let Ok(content) = std::fs::read_to_string(&path) {
83                    if let Ok(schema) = serde_json::from_str::<serde_json::Value>(&content) {
84                        resolver.merge_schema(&schema);
85                    }
86                }
87            }
88        }
89
90        resolver
91    }
92
93    /// Merge another PID schema into this resolver.
94    pub fn merge_schema(&mut self, schema: &serde_json::Value) {
95        if let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) {
96            for (_group_key, group_val) in fields {
97                collect_from_group(
98                    group_val,
99                    &mut self.simple_elements,
100                    &mut self.composite_elements,
101                );
102            }
103        }
104        if let Some(root_segs) = schema.get("root_segments") {
105            let wrapper = serde_json::json!({ "segments": root_segs });
106            collect_from_group(
107                &wrapper,
108                &mut self.simple_elements,
109                &mut self.composite_elements,
110            );
111        }
112    }
113
114    /// Resolve a single field path. Returns the numeric path if the input
115    /// is a named path; returns the input unchanged if already numeric.
116    ///
117    /// Examples:
118    /// - `"loc.c517.d3225"` → `"loc.1.0"`
119    /// - `"seq.d1229"` → `"seq.0"`
120    /// - `"cav[Z91].c889.d7111"` → `"cav[Z91].0.0"`
121    /// - `"sts.c556_2.d9013"` → `"sts.3.0"` (ordinal suffix for duplicate composite)
122    /// - `"nad.c080.d3036_2"` → `"nad.3.1"` (ordinal suffix for duplicate data element)
123    /// - `"loc.1.0"` → `"loc.1.0"` (unchanged)
124    pub fn resolve_path(&self, path: &str) -> String {
125        let parts: Vec<&str> = path.split('.').collect();
126        if parts.len() < 2 {
127            return path.to_string();
128        }
129
130        // Parse segment tag and optional qualifier: "cav[Z91]" → ("cav", "[Z91]")
131        let (seg_raw, qualifier_suffix) = split_qualifier(parts[0]);
132        let seg_upper = seg_raw.to_ascii_uppercase();
133
134        let rest = &parts[1..];
135
136        // Check if already numeric: first rest part starts with a digit
137        if rest[0]
138            .chars()
139            .next()
140            .map(|c| c.is_ascii_digit())
141            .unwrap_or(false)
142        {
143            return path.to_string();
144        }
145
146        // Try resolving as composite path: seg.cNNN.dNNN (with optional ordinal suffixes)
147        if rest.len() == 2 && is_edifact_id(rest[0]) && is_edifact_id(rest[1]) {
148            let composite_id = rest[0].to_ascii_lowercase();
149            let data_element_id = rest[1].to_ascii_lowercase();
150
151            if let Some(&(elem_idx, sub_idx)) =
152                self.composite_elements
153                    .get(&(seg_upper.clone(), composite_id, data_element_id))
154            {
155                return format!("{}{}.{}.{}", seg_raw, qualifier_suffix, elem_idx, sub_idx);
156            }
157        }
158
159        // Try resolving as simple element: seg.dNNN
160        if rest.len() == 1 && is_edifact_id(rest[0]) {
161            let element_id = rest[0].to_ascii_lowercase();
162
163            if let Some(&elem_idx) = self.simple_elements.get(&(seg_upper, element_id)) {
164                return format!("{}{}.{}", seg_raw, qualifier_suffix, elem_idx);
165            }
166        }
167
168        // Unresolved — return as-is
169        path.to_string()
170    }
171
172    /// Resolve a discriminator string to 3-part numeric format.
173    ///
174    /// The engine's `resolve_repetition` requires `TAG.N.M=VALUE` (3-part).
175    ///
176    /// Input formats:
177    /// - Named simple: `"SEQ.d1229=ZF0"` → `"SEQ.0.0=ZF0"`
178    /// - Named composite: `"STS.c556.d9013=E01"` → `"STS.2.0=E01"`
179    /// - Numeric 3-part: `"LOC.0.0=Z16"` → `"LOC.0.0=Z16"` (unchanged)
180    /// - Numeric 2-part: `"SEQ.0=ZF0"` → `"SEQ.0.0=ZF0"` (upgraded)
181    pub fn resolve_discriminator(&self, disc: &str) -> String {
182        let Some((path_part, value_part)) = disc.split_once('=') else {
183            return disc.to_string();
184        };
185
186        let parts: Vec<&str> = path_part.split('.').collect();
187
188        match parts.len() {
189            2 => {
190                let seg_upper = parts[0].to_ascii_uppercase();
191                let element_ref = parts[1];
192
193                // Check if already numeric — upgrade 2-part to 3-part
194                if element_ref
195                    .chars()
196                    .next()
197                    .map(|c| c.is_ascii_digit())
198                    .unwrap_or(false)
199                {
200                    return format!("{}.{}.0={}", parts[0], element_ref, value_part);
201                }
202
203                // Try simple element resolution
204                if is_edifact_id(element_ref) {
205                    let element_id = element_ref.to_ascii_lowercase();
206                    if let Some(&elem_idx) = self.simple_elements.get(&(seg_upper, element_id)) {
207                        return format!("{}.{}.0={}", parts[0], elem_idx, value_part);
208                    }
209                }
210
211                disc.to_string()
212            }
213            3 => {
214                let seg_upper = parts[0].to_ascii_uppercase();
215
216                // Check if already numeric
217                if parts[1]
218                    .chars()
219                    .next()
220                    .map(|c| c.is_ascii_digit())
221                    .unwrap_or(false)
222                {
223                    return disc.to_string();
224                }
225
226                // Try composite resolution: TAG.cNNN.dNNN=VALUE
227                if is_edifact_id(parts[1]) && is_edifact_id(parts[2]) {
228                    let composite_id = parts[1].to_ascii_lowercase();
229                    let data_element_id = parts[2].to_ascii_lowercase();
230
231                    if let Some(&(elem_idx, sub_idx)) =
232                        self.composite_elements
233                            .get(&(seg_upper, composite_id, data_element_id))
234                    {
235                        return format!("{}.{}.{}={}", parts[0], elem_idx, sub_idx, value_part);
236                    }
237                }
238
239                disc.to_string()
240            }
241            _ => disc.to_string(),
242        }
243    }
244}
245
246// ── Reverse resolver: numeric → named ──
247
248/// Converts numeric element paths back to self-documenting EDIFACT ID paths.
249///
250/// Used by the `migrate-paths` CLI to convert existing TOML files from
251/// opaque numeric paths (`loc.1.0`) to readable named paths (`loc.c517.d3225`).
252#[derive(Clone)]
253pub struct ReversePathResolver {
254    /// (seg_upper, elem_idx, sub_idx) → named suffix like "c517.d3225"
255    composite_reverse: HashMap<(String, usize, usize), String>,
256    /// (seg_upper, elem_idx) → named id like "d3227"
257    simple_reverse: HashMap<(String, usize), String>,
258    /// (seg_upper, elem_idx) → true if composite element
259    is_composite: HashMap<(String, usize), bool>,
260}
261
262impl ReversePathResolver {
263    /// Build from a PID schema JSON.
264    pub fn from_schema(schema: &serde_json::Value) -> Self {
265        let mut composite_reverse = HashMap::new();
266        let mut simple_reverse = HashMap::new();
267        let mut is_composite = HashMap::new();
268
269        if let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) {
270            for (_group_key, group_val) in fields {
271                collect_reverse_from_group(
272                    group_val,
273                    &mut composite_reverse,
274                    &mut simple_reverse,
275                    &mut is_composite,
276                );
277            }
278        }
279
280        if let Some(root_segs) = schema.get("root_segments") {
281            let wrapper = serde_json::json!({ "segments": root_segs });
282            collect_reverse_from_group(
283                &wrapper,
284                &mut composite_reverse,
285                &mut simple_reverse,
286                &mut is_composite,
287            );
288        }
289
290        Self {
291            composite_reverse,
292            simple_reverse,
293            is_composite,
294        }
295    }
296
297    /// Build from a parsed MIG schema (no generated schema JSON files needed).
298    ///
299    /// Produces the same `(seg, elem_idx[, sub_idx]) → named-id` maps as
300    /// [`from_schema`], sourced from the MIG's segment/composite structure. The
301    /// element naming is PID-independent, so building from the full (unfiltered)
302    /// MIG yields a correct superset — letting bundle-only consumers (e.g.
303    /// `edifact_mapper::Mapper::validate_edifact`) build a reverse resolver for
304    /// `bo4e_path` enrichment without the generated schema tree.
305    pub fn from_mig(mig: &mig_types::schema::mig::MigSchema) -> Self {
306        let mut composite_reverse = HashMap::new();
307        let mut simple_reverse = HashMap::new();
308        let mut is_composite = HashMap::new();
309        for seg in &mig.segments {
310            collect_reverse_from_mig_segment(
311                seg,
312                &mut composite_reverse,
313                &mut simple_reverse,
314                &mut is_composite,
315            );
316        }
317        for group in &mig.segment_groups {
318            collect_reverse_from_mig_group(
319                group,
320                &mut composite_reverse,
321                &mut simple_reverse,
322                &mut is_composite,
323            );
324        }
325        Self {
326            composite_reverse,
327            simple_reverse,
328            is_composite,
329        }
330    }
331
332    /// Build from all PID schema JSON files in a directory.
333    pub fn from_schema_dir(dir: &Path) -> Self {
334        let mut resolver = Self {
335            composite_reverse: HashMap::new(),
336            simple_reverse: HashMap::new(),
337            is_composite: HashMap::new(),
338        };
339
340        let mut entries: Vec<_> = std::fs::read_dir(dir)
341            .into_iter()
342            .flatten()
343            .filter_map(|e| e.ok())
344            .collect();
345        entries.sort_by_key(|e| e.file_name());
346
347        for entry in entries {
348            let path = entry.path();
349            let is_schema = path
350                .file_name()
351                .and_then(|n| n.to_str())
352                .map(|n| n.starts_with("pid_") && n.ends_with("_schema.json"))
353                .unwrap_or(false);
354            if is_schema {
355                if let Ok(content) = std::fs::read_to_string(&path) {
356                    if let Ok(schema) = serde_json::from_str::<serde_json::Value>(&content) {
357                        resolver.merge_schema(&schema);
358                    }
359                }
360            }
361        }
362
363        resolver
364    }
365
366    /// Merge another PID schema into this resolver.
367    pub fn merge_schema(&mut self, schema: &serde_json::Value) {
368        if let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) {
369            for (_group_key, group_val) in fields {
370                collect_reverse_from_group(
371                    group_val,
372                    &mut self.composite_reverse,
373                    &mut self.simple_reverse,
374                    &mut self.is_composite,
375                );
376            }
377        }
378        if let Some(root_segs) = schema.get("root_segments") {
379            let wrapper = serde_json::json!({ "segments": root_segs });
380            collect_reverse_from_group(
381                &wrapper,
382                &mut self.composite_reverse,
383                &mut self.simple_reverse,
384                &mut self.is_composite,
385            );
386        }
387    }
388
389    /// Convert a numeric path to a named EDIFACT ID path.
390    ///
391    /// Examples:
392    /// - `"loc.1.0"` → `"loc.c517.d3225"`
393    /// - `"loc.0"` → `"loc.d3227"` (simple element)
394    /// - `"sts.2"` → `"sts.c556.d9013"` (2-part → expands to first component)
395    /// - `"sts.3.0"` → `"sts.c556_2.d9013"` (ordinal suffix for duplicate composite)
396    /// - `"cav[Z91].0.1"` → `"cav[Z91].c889.d7110"` (preserves qualifier)
397    /// - `"loc.c517.d3225"` → `"loc.c517.d3225"` (already named, unchanged)
398    pub fn reverse_path(&self, path: &str) -> String {
399        let parts: Vec<&str> = path.split('.').collect();
400        if parts.len() < 2 {
401            return path.to_string();
402        }
403
404        let (seg_raw, qualifier_suffix) = split_qualifier(parts[0]);
405        let seg_upper = seg_raw.to_ascii_uppercase();
406        let rest = &parts[1..];
407
408        // If not numeric, already named — return as-is
409        if !rest[0]
410            .chars()
411            .next()
412            .map(|c| c.is_ascii_digit())
413            .unwrap_or(false)
414        {
415            return path.to_string();
416        }
417
418        match rest.len() {
419            1 => {
420                // 2-part: seg.N
421                let Ok(elem_idx) = rest[0].parse::<usize>() else {
422                    return path.to_string();
423                };
424
425                match self.is_composite.get(&(seg_upper.clone(), elem_idx)) {
426                    Some(true) => {
427                        // Composite — expand to first component: seg.cNNN.dNNN
428                        if let Some(named) = self.composite_reverse.get(&(seg_upper, elem_idx, 0)) {
429                            format!("{}{}.{}", seg_raw, qualifier_suffix, named)
430                        } else {
431                            path.to_string()
432                        }
433                    }
434                    Some(false) => {
435                        // Simple element: seg.dNNN
436                        if let Some(named) = self.simple_reverse.get(&(seg_upper, elem_idx)) {
437                            format!("{}{}.{}", seg_raw, qualifier_suffix, named)
438                        } else {
439                            path.to_string()
440                        }
441                    }
442                    None => path.to_string(),
443                }
444            }
445            2 => {
446                // 3-part: seg.N.M
447                let Ok(elem_idx) = rest[0].parse::<usize>() else {
448                    return path.to_string();
449                };
450                let Ok(sub_idx) = rest[1].parse::<usize>() else {
451                    return path.to_string();
452                };
453
454                if let Some(named) = self.composite_reverse.get(&(seg_upper, elem_idx, sub_idx)) {
455                    format!("{}{}.{}", seg_raw, qualifier_suffix, named)
456                } else {
457                    path.to_string()
458                }
459            }
460            _ => path.to_string(),
461        }
462    }
463
464    /// Convert a 3-part numeric discriminator to named EDIFACT ID format.
465    ///
466    /// Examples:
467    /// - `"LOC.0.0=Z16"` → `"LOC.d3227=Z16"` (simple element)
468    /// - `"STS.2.0=E01"` → `"STS.c556.d9013=E01"` (composite element)
469    /// - `"LOC.d3227=Z16"` → `"LOC.d3227=Z16"` (already named, unchanged)
470    pub fn reverse_discriminator(&self, disc: &str) -> String {
471        let Some((path_part, value_part)) = disc.split_once('=') else {
472            return disc.to_string();
473        };
474
475        let parts: Vec<&str> = path_part.split('.').collect();
476        if parts.len() != 3 {
477            return disc.to_string();
478        }
479
480        let seg_raw = parts[0];
481        let seg_upper = seg_raw.to_ascii_uppercase();
482
483        // Check if numeric
484        let Ok(elem_idx) = parts[1].parse::<usize>() else {
485            return disc.to_string(); // Already named
486        };
487        let Ok(sub_idx) = parts[2].parse::<usize>() else {
488            return disc.to_string();
489        };
490
491        // Check if it's a simple element (sub_idx 0 and element is not composite)
492        if sub_idx == 0 {
493            if let Some(false) = self.is_composite.get(&(seg_upper.clone(), elem_idx)) {
494                if let Some(named) = self.simple_reverse.get(&(seg_upper.clone(), elem_idx)) {
495                    return format!("{}.{}={}", seg_raw, named, value_part);
496                }
497            }
498        }
499
500        // Composite element
501        if let Some(named) = self
502            .composite_reverse
503            .get(&(seg_upper.clone(), elem_idx, sub_idx))
504        {
505            return format!("{}.{}={}", seg_raw, named, value_part);
506        }
507
508        disc.to_string()
509    }
510}
511
512// ── Helpers ──
513
514/// Check if a string looks like an EDIFACT ID: starts with 'c' or 'd' followed by digits,
515/// with an optional ordinal suffix (`_N`).
516///
517/// Matches: `c517`, `d3225`, `c556_2`, `d3036_3`
518fn is_edifact_id(s: &str) -> bool {
519    let mut chars = s.chars();
520    match chars.next() {
521        Some('c' | 'd' | 's' | 'C' | 'D' | 'S') => {
522            let rest: String = chars.collect();
523            if rest.is_empty() {
524                return false;
525            }
526            if let Some((base, suffix)) = rest.split_once('_') {
527                !base.is_empty()
528                    && base.chars().all(|c| c.is_ascii_digit())
529                    && !suffix.is_empty()
530                    && suffix.chars().all(|c| c.is_ascii_digit())
531            } else {
532                rest.chars().all(|c| c.is_ascii_digit())
533            }
534        }
535        _ => false,
536    }
537}
538
539/// Split qualifier from tag: `"cav[Z91]"` → `("cav", "[Z91]")`, `"loc"` → `("loc", "")`
540fn split_qualifier(tag: &str) -> (&str, &str) {
541    if let Some(bracket_pos) = tag.find('[') {
542        (&tag[..bracket_pos], &tag[bracket_pos..])
543    } else {
544        (tag, "")
545    }
546}
547
548/// Recursively collect forward element mappings from a group in the schema.
549///
550/// Tracks ordinal suffixes for duplicate composite IDs per segment and
551/// duplicate data element IDs per composite.
552fn collect_from_group(
553    group: &serde_json::Value,
554    simple: &mut HashMap<(String, String), usize>,
555    composite: &mut HashMap<(String, String, String), (usize, usize)>,
556) {
557    if let Some(segments) = group.get("segments").and_then(|s| s.as_array()) {
558        for seg in segments {
559            let seg_tag = seg
560                .get("id")
561                .and_then(|v| v.as_str())
562                .unwrap_or("")
563                .to_ascii_uppercase();
564
565            if let Some(elements) = seg.get("elements").and_then(|e| e.as_array()) {
566                // Track composite ID occurrences for ordinal suffixes
567                let mut composite_id_count: HashMap<String, usize> = HashMap::new();
568
569                for elem in elements {
570                    let elem_index =
571                        elem.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
572
573                    if let Some(composite_id) = elem.get("composite").and_then(|v| v.as_str()) {
574                        let base_composite = composite_id.to_ascii_lowercase();
575
576                        // Track occurrence for ordinal suffix
577                        let count = composite_id_count
578                            .entry(base_composite.clone())
579                            .or_insert(0);
580                        *count += 1;
581
582                        let composite_key = if *count == 1 {
583                            base_composite
584                        } else {
585                            format!("{}_{}", base_composite, count)
586                        };
587
588                        if let Some(components) = elem.get("components").and_then(|c| c.as_array())
589                        {
590                            // Track data element ID occurrences within this composite
591                            let mut data_elem_count: HashMap<String, usize> = HashMap::new();
592
593                            for comp in components {
594                                let comp_id = comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
595                                let sub_index =
596                                    comp.get("sub_index").and_then(|v| v.as_u64()).unwrap_or(0)
597                                        as usize;
598                                let base_data = format!("d{}", comp_id).to_ascii_lowercase();
599
600                                let dcount = data_elem_count.entry(base_data.clone()).or_insert(0);
601                                *dcount += 1;
602
603                                let data_key = if *dcount == 1 {
604                                    base_data
605                                } else {
606                                    format!("{}_{}", base_data, dcount)
607                                };
608
609                                composite
610                                    .entry((seg_tag.clone(), composite_key.clone(), data_key))
611                                    .or_insert((elem_index, sub_index));
612                            }
613                        }
614                    } else {
615                        // Simple data element
616                        let elem_id = elem.get("id").and_then(|v| v.as_str()).unwrap_or("");
617                        let elem_id_lower = format!("d{}", elem_id).to_ascii_lowercase();
618                        simple
619                            .entry((seg_tag.clone(), elem_id_lower))
620                            .or_insert(elem_index);
621                    }
622                }
623            }
624        }
625    }
626
627    // Recurse into children
628    if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
629        for (_child_key, child_val) in children {
630            collect_from_group(child_val, simple, composite);
631        }
632    }
633}
634
635/// Recursively collect reverse element mappings from a group in the schema.
636///
637/// Builds (seg, elem_idx, sub_idx) → named path mappings, with ordinal suffixes
638/// for duplicates.
639fn collect_reverse_from_group(
640    group: &serde_json::Value,
641    composite_reverse: &mut HashMap<(String, usize, usize), String>,
642    simple_reverse: &mut HashMap<(String, usize), String>,
643    is_composite: &mut HashMap<(String, usize), bool>,
644) {
645    if let Some(segments) = group.get("segments").and_then(|s| s.as_array()) {
646        for seg in segments {
647            let seg_tag = seg
648                .get("id")
649                .and_then(|v| v.as_str())
650                .unwrap_or("")
651                .to_ascii_uppercase();
652
653            if let Some(elements) = seg.get("elements").and_then(|e| e.as_array()) {
654                let mut composite_id_count: HashMap<String, usize> = HashMap::new();
655
656                for elem in elements {
657                    let elem_index =
658                        elem.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
659
660                    if let Some(composite_id) = elem.get("composite").and_then(|v| v.as_str()) {
661                        let base = composite_id.to_ascii_lowercase();
662
663                        let count = composite_id_count.entry(base.clone()).or_insert(0);
664                        *count += 1;
665
666                        let comp_key = if *count == 1 {
667                            base
668                        } else {
669                            format!("{}_{}", base, count)
670                        };
671
672                        is_composite
673                            .entry((seg_tag.clone(), elem_index))
674                            .or_insert(true);
675
676                        if let Some(components) = elem.get("components").and_then(|c| c.as_array())
677                        {
678                            let mut data_elem_count: HashMap<String, usize> = HashMap::new();
679
680                            for comp in components {
681                                let comp_id = comp.get("id").and_then(|v| v.as_str()).unwrap_or("");
682                                let sub_index =
683                                    comp.get("sub_index").and_then(|v| v.as_u64()).unwrap_or(0)
684                                        as usize;
685                                let base_data = format!("d{}", comp_id).to_ascii_lowercase();
686
687                                let dcount = data_elem_count.entry(base_data.clone()).or_insert(0);
688                                *dcount += 1;
689
690                                let data_key = if *dcount == 1 {
691                                    base_data
692                                } else {
693                                    format!("{}_{}", base_data, dcount)
694                                };
695
696                                composite_reverse
697                                    .entry((seg_tag.clone(), elem_index, sub_index))
698                                    .or_insert(format!("{}.{}", comp_key, data_key));
699                            }
700                        }
701                    } else {
702                        let elem_id = elem.get("id").and_then(|v| v.as_str()).unwrap_or("");
703                        let elem_id_lower = format!("d{}", elem_id).to_ascii_lowercase();
704
705                        is_composite
706                            .entry((seg_tag.clone(), elem_index))
707                            .or_insert(false);
708                        simple_reverse
709                            .entry((seg_tag.clone(), elem_index))
710                            .or_insert(elem_id_lower);
711                    }
712                }
713            }
714        }
715    }
716
717    if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
718        for (_child_key, child_val) in children {
719            collect_reverse_from_group(child_val, composite_reverse, simple_reverse, is_composite);
720        }
721    }
722}
723
724/// Recurse a MIG segment group (and its nested groups) for [`ReversePathResolver::from_mig`].
725fn collect_reverse_from_mig_group(
726    group: &mig_types::schema::mig::MigSegmentGroup,
727    composite_reverse: &mut HashMap<(String, usize, usize), String>,
728    simple_reverse: &mut HashMap<(String, usize), String>,
729    is_composite: &mut HashMap<(String, usize), bool>,
730) {
731    for seg in &group.segments {
732        collect_reverse_from_mig_segment(seg, composite_reverse, simple_reverse, is_composite);
733    }
734    for nested in &group.nested_groups {
735        collect_reverse_from_mig_group(nested, composite_reverse, simple_reverse, is_composite);
736    }
737}
738
739/// Collect reverse maps for one MIG segment, mirroring `collect_reverse_from_group`'s
740/// element handling (including the duplicate-id `_N` ordinal suffixes). Data elements
741/// and composites are ordered by their `position` so the suffixes match `from_schema`'s
742/// element order.
743fn collect_reverse_from_mig_segment(
744    seg: &mig_types::schema::mig::MigSegment,
745    composite_reverse: &mut HashMap<(String, usize, usize), String>,
746    simple_reverse: &mut HashMap<(String, usize), String>,
747    is_composite: &mut HashMap<(String, usize), bool>,
748) {
749    let seg_tag = seg.id.to_ascii_uppercase();
750
751    enum El<'a> {
752        Simple(&'a mig_types::schema::mig::MigDataElement),
753        Composite(&'a mig_types::schema::mig::MigComposite),
754    }
755    let mut elems: Vec<(usize, El)> = Vec::new();
756    for de in &seg.data_elements {
757        elems.push((de.position, El::Simple(de)));
758    }
759    for c in &seg.composites {
760        elems.push((c.position, El::Composite(c)));
761    }
762    elems.sort_by_key(|(pos, _)| *pos);
763
764    let mut composite_id_count: HashMap<String, usize> = HashMap::new();
765    for (elem_index, el) in elems {
766        match el {
767            El::Composite(c) => {
768                let base = c.id.to_ascii_lowercase();
769                let count = composite_id_count.entry(base.clone()).or_insert(0);
770                *count += 1;
771                let comp_key = if *count == 1 {
772                    base
773                } else {
774                    format!("{}_{}", base, count)
775                };
776                is_composite
777                    .entry((seg_tag.clone(), elem_index))
778                    .or_insert(true);
779                let mut data_elem_count: HashMap<String, usize> = HashMap::new();
780                for comp in &c.data_elements {
781                    let sub_index = comp.position;
782                    let base_data = format!("d{}", comp.id).to_ascii_lowercase();
783                    let dcount = data_elem_count.entry(base_data.clone()).or_insert(0);
784                    *dcount += 1;
785                    let data_key = if *dcount == 1 {
786                        base_data
787                    } else {
788                        format!("{}_{}", base_data, dcount)
789                    };
790                    composite_reverse
791                        .entry((seg_tag.clone(), elem_index, sub_index))
792                        .or_insert(format!("{}.{}", comp_key, data_key));
793                }
794            }
795            El::Simple(de) => {
796                let elem_id_lower = format!("d{}", de.id).to_ascii_lowercase();
797                is_composite
798                    .entry((seg_tag.clone(), elem_index))
799                    .or_insert(false);
800                simple_reverse
801                    .entry((seg_tag.clone(), elem_index))
802                    .or_insert(elem_id_lower);
803            }
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    fn test_schema() -> serde_json::Value {
813        serde_json::json!({
814            "beschreibung": "Test PID",
815            "fields": {
816                "sg4": {
817                    "segments": [
818                        {
819                            "id": "LOC",
820                            "name": "Lokation",
821                            "elements": [
822                                {
823                                    "id": "3227",
824                                    "index": 0,
825                                    "name": "Lokation, Qualifier",
826                                    "type": "code"
827                                },
828                                {
829                                    "composite": "C517",
830                                    "index": 1,
831                                    "name": "Lokationsidentifikation",
832                                    "components": [
833                                        {
834                                            "id": "3225",
835                                            "sub_index": 0,
836                                            "name": "MaLo-ID",
837                                            "type": "data"
838                                        },
839                                        {
840                                            "id": "1131",
841                                            "sub_index": 1,
842                                            "name": "Codeliste, Code",
843                                            "type": "data"
844                                        }
845                                    ]
846                                }
847                            ]
848                        },
849                        {
850                            "id": "SEQ",
851                            "name": "Reihenfolge",
852                            "elements": [
853                                {
854                                    "id": "1229",
855                                    "index": 0,
856                                    "name": "Handlung, Code",
857                                    "type": "code"
858                                },
859                                {
860                                    "composite": "C286",
861                                    "index": 1,
862                                    "name": "Information über eine Folge",
863                                    "components": [
864                                        {
865                                            "id": "1050",
866                                            "sub_index": 0,
867                                            "name": "Referenz auf Zeitraum-ID",
868                                            "type": "data"
869                                        }
870                                    ]
871                                }
872                            ]
873                        }
874                    ],
875                    "source_group": "SG4",
876                    "children": {
877                        "sg8_zf0": {
878                            "segments": [
879                                {
880                                    "id": "CAV",
881                                    "name": "Merkmal",
882                                    "elements": [
883                                        {
884                                            "composite": "C889",
885                                            "index": 0,
886                                            "name": "Merkmalswert",
887                                            "components": [
888                                                {
889                                                    "id": "7111",
890                                                    "sub_index": 0,
891                                                    "name": "Merkmalswert, Code",
892                                                    "type": "code"
893                                                },
894                                                {
895                                                    "id": "7110",
896                                                    "sub_index": 1,
897                                                    "name": "Merkmalswert",
898                                                    "type": "data"
899                                                }
900                                            ]
901                                        }
902                                    ]
903                                }
904                            ],
905                            "source_group": "SG8"
906                        }
907                    }
908                }
909            }
910        })
911    }
912
913    fn sts_schema() -> serde_json::Value {
914        serde_json::json!({
915            "beschreibung": "STS ordinal test",
916            "fields": {
917                "sg4": {
918                    "segments": [{
919                        "id": "STS",
920                        "name": "Status",
921                        "elements": [
922                            {
923                                "composite": "C601",
924                                "index": 0,
925                                "name": "Statuskategorie",
926                                "components": [{
927                                    "id": "9015",
928                                    "sub_index": 0,
929                                    "type": "code"
930                                }]
931                            },
932                            {
933                                "composite": "C555",
934                                "index": 1,
935                                "name": "Status",
936                                "components": [{
937                                    "id": "4405",
938                                    "sub_index": 0,
939                                    "type": "data"
940                                }]
941                            },
942                            {
943                                "composite": "C556",
944                                "index": 2,
945                                "name": "Statusanlaß",
946                                "components": [{
947                                    "id": "9013",
948                                    "sub_index": 0,
949                                    "type": "code"
950                                }]
951                            },
952                            {
953                                "composite": "C556",
954                                "index": 3,
955                                "name": "Statusanlaß",
956                                "components": [{
957                                    "id": "9013",
958                                    "sub_index": 0,
959                                    "type": "code"
960                                }]
961                            },
962                            {
963                                "composite": "C556",
964                                "index": 4,
965                                "name": "Statusanlaß",
966                                "components": [{
967                                    "id": "9013",
968                                    "sub_index": 0,
969                                    "type": "code"
970                                }]
971                            }
972                        ]
973                    }],
974                    "source_group": "SG4"
975                }
976            }
977        })
978    }
979
980    fn nad_schema() -> serde_json::Value {
981        serde_json::json!({
982            "beschreibung": "NAD ordinal test",
983            "fields": {
984                "sg12_z04": {
985                    "segments": [{
986                        "id": "NAD",
987                        "name": "Geschäftspartner",
988                        "elements": [
989                            {
990                                "id": "3229",
991                                "index": 0,
992                                "type": "code"
993                            },
994                            {
995                                "composite": "C082",
996                                "index": 1,
997                                "name": "Identifikation",
998                                "components": [
999                                    { "id": "3039", "sub_index": 0, "type": "data" },
1000                                    { "id": "1131", "sub_index": 1, "type": "data" },
1001                                    { "id": "3055", "sub_index": 2, "type": "code" }
1002                                ]
1003                            },
1004                            {
1005                                "composite": "C058",
1006                                "index": 2,
1007                                "name": "Zusatzinfo",
1008                                "components": [
1009                                    { "id": "3124", "sub_index": 0, "type": "data" },
1010                                    { "id": "3124", "sub_index": 1, "type": "data" },
1011                                    { "id": "3124", "sub_index": 2, "type": "data" }
1012                                ]
1013                            },
1014                            {
1015                                "composite": "C080",
1016                                "index": 3,
1017                                "name": "Name",
1018                                "components": [
1019                                    { "id": "3036", "sub_index": 0, "type": "data" },
1020                                    { "id": "3036", "sub_index": 1, "type": "data" },
1021                                    { "id": "3036", "sub_index": 2, "type": "data" },
1022                                    { "id": "3036", "sub_index": 3, "type": "data" },
1023                                    { "id": "3036", "sub_index": 4, "type": "data" },
1024                                    { "id": "3045", "sub_index": 5, "type": "code" }
1025                                ]
1026                            }
1027                        ]
1028                    }],
1029                    "source_group": "SG12"
1030                }
1031            }
1032        })
1033    }
1034
1035    // ── Forward resolver tests ──
1036
1037    #[test]
1038    fn resolve_composite_path() {
1039        let resolver = PathResolver::from_schema(&test_schema());
1040        assert_eq!(resolver.resolve_path("loc.c517.d3225"), "loc.1.0");
1041        assert_eq!(resolver.resolve_path("loc.c517.d1131"), "loc.1.1");
1042    }
1043
1044    #[test]
1045    fn resolve_simple_element_path() {
1046        let resolver = PathResolver::from_schema(&test_schema());
1047        assert_eq!(resolver.resolve_path("loc.d3227"), "loc.0");
1048        assert_eq!(resolver.resolve_path("seq.d1229"), "seq.0");
1049    }
1050
1051    #[test]
1052    fn resolve_nested_group_paths() {
1053        let resolver = PathResolver::from_schema(&test_schema());
1054        assert_eq!(resolver.resolve_path("cav.c889.d7111"), "cav.0.0");
1055        assert_eq!(resolver.resolve_path("cav.c889.d7110"), "cav.0.1");
1056    }
1057
1058    #[test]
1059    fn numeric_paths_unchanged() {
1060        let resolver = PathResolver::from_schema(&test_schema());
1061        assert_eq!(resolver.resolve_path("loc.1.0"), "loc.1.0");
1062        assert_eq!(resolver.resolve_path("loc.0"), "loc.0");
1063        assert_eq!(resolver.resolve_path("seq.0"), "seq.0");
1064    }
1065
1066    #[test]
1067    fn qualifier_paths() {
1068        let resolver = PathResolver::from_schema(&test_schema());
1069        assert_eq!(resolver.resolve_path("cav[Z91].c889.d7111"), "cav[Z91].0.0");
1070        assert_eq!(resolver.resolve_path("cav[Z91].0.1"), "cav[Z91].0.1");
1071    }
1072
1073    #[test]
1074    fn resolve_discriminator_named() {
1075        let resolver = PathResolver::from_schema(&test_schema());
1076        // Named → 3-part numeric
1077        assert_eq!(
1078            resolver.resolve_discriminator("SEQ.d1229=ZF0"),
1079            "SEQ.0.0=ZF0"
1080        );
1081        assert_eq!(
1082            resolver.resolve_discriminator("LOC.d3227=Z16"),
1083            "LOC.0.0=Z16"
1084        );
1085    }
1086
1087    #[test]
1088    fn resolve_discriminator_numeric() {
1089        let resolver = PathResolver::from_schema(&test_schema());
1090        // Already 3-part numeric — unchanged
1091        assert_eq!(resolver.resolve_discriminator("SEQ.0.0=ZF0"), "SEQ.0.0=ZF0");
1092        // 2-part numeric — upgraded to 3-part
1093        assert_eq!(resolver.resolve_discriminator("SEQ.0=ZF0"), "SEQ.0.0=ZF0");
1094    }
1095
1096    #[test]
1097    fn resolve_discriminator_composite() {
1098        let resolver = PathResolver::from_schema(&sts_schema());
1099        // Composite discriminator: TAG.cNNN.dNNN=VALUE → TAG.N.M=VALUE
1100        assert_eq!(
1101            resolver.resolve_discriminator("STS.c556.d9013=E01"),
1102            "STS.2.0=E01"
1103        );
1104        assert_eq!(
1105            resolver.resolve_discriminator("STS.c556_2.d9013=ZW4"),
1106            "STS.3.0=ZW4"
1107        );
1108    }
1109
1110    #[test]
1111    fn unresolved_paths_unchanged() {
1112        let resolver = PathResolver::from_schema(&test_schema());
1113        assert_eq!(resolver.resolve_path("xyz.d9999"), "xyz.d9999");
1114        assert_eq!(resolver.resolve_path("loc"), "loc");
1115    }
1116
1117    #[test]
1118    fn composite_id_case_insensitive() {
1119        let resolver = PathResolver::from_schema(&test_schema());
1120        assert_eq!(resolver.resolve_path("loc.c517.d3225"), "loc.1.0");
1121        assert_eq!(resolver.resolve_path("LOC.c517.d3225"), "LOC.1.0");
1122    }
1123
1124    #[test]
1125    fn seq_composite_path() {
1126        let resolver = PathResolver::from_schema(&test_schema());
1127        assert_eq!(resolver.resolve_path("seq.c286.d1050"), "seq.1.0");
1128    }
1129
1130    // ── Ordinal suffix tests ──
1131
1132    #[test]
1133    fn ordinal_suffix_duplicate_composites() {
1134        let resolver = PathResolver::from_schema(&sts_schema());
1135        // First C556 at index 2
1136        assert_eq!(resolver.resolve_path("sts.c556.d9013"), "sts.2.0");
1137        // Second C556 at index 3
1138        assert_eq!(resolver.resolve_path("sts.c556_2.d9013"), "sts.3.0");
1139        // Third C556 at index 4
1140        assert_eq!(resolver.resolve_path("sts.c556_3.d9013"), "sts.4.0");
1141        // Non-duplicate composites still work
1142        assert_eq!(resolver.resolve_path("sts.c601.d9015"), "sts.0.0");
1143        assert_eq!(resolver.resolve_path("sts.c555.d4405"), "sts.1.0");
1144    }
1145
1146    #[test]
1147    fn ordinal_suffix_duplicate_data_elements() {
1148        let resolver = PathResolver::from_schema(&nad_schema());
1149        // NAD C080: d3036×5 + d3045×1
1150        assert_eq!(resolver.resolve_path("nad.c080.d3036"), "nad.3.0");
1151        assert_eq!(resolver.resolve_path("nad.c080.d3036_2"), "nad.3.1");
1152        assert_eq!(resolver.resolve_path("nad.c080.d3036_3"), "nad.3.2");
1153        assert_eq!(resolver.resolve_path("nad.c080.d3036_4"), "nad.3.3");
1154        assert_eq!(resolver.resolve_path("nad.c080.d3036_5"), "nad.3.4");
1155        assert_eq!(resolver.resolve_path("nad.c080.d3045"), "nad.3.5");
1156        // C058: d3124×3
1157        assert_eq!(resolver.resolve_path("nad.c058.d3124"), "nad.2.0");
1158        assert_eq!(resolver.resolve_path("nad.c058.d3124_2"), "nad.2.1");
1159        assert_eq!(resolver.resolve_path("nad.c058.d3124_3"), "nad.2.2");
1160    }
1161
1162    #[test]
1163    fn is_edifact_id_with_suffix() {
1164        assert!(is_edifact_id("c556"));
1165        assert!(is_edifact_id("c556_2"));
1166        assert!(is_edifact_id("c556_3"));
1167        assert!(is_edifact_id("d3036"));
1168        assert!(is_edifact_id("d3036_2"));
1169        assert!(is_edifact_id("D3036_5"));
1170        assert!(is_edifact_id("s002"));
1171        assert!(is_edifact_id("S009"));
1172        assert!(is_edifact_id("s011"));
1173        assert!(!is_edifact_id("c"));
1174        assert!(!is_edifact_id("c_2"));
1175        assert!(!is_edifact_id("c556_"));
1176        assert!(!is_edifact_id("c556_a"));
1177        assert!(!is_edifact_id("abc"));
1178        assert!(!is_edifact_id("123"));
1179    }
1180
1181    // ── Reverse resolver tests ──
1182
1183    #[test]
1184    fn reverse_path_composite() {
1185        let resolver = ReversePathResolver::from_schema(&test_schema());
1186        assert_eq!(resolver.reverse_path("loc.1.0"), "loc.c517.d3225");
1187        assert_eq!(resolver.reverse_path("loc.1.1"), "loc.c517.d1131");
1188        assert_eq!(resolver.reverse_path("seq.1.0"), "seq.c286.d1050");
1189        assert_eq!(resolver.reverse_path("cav.0.0"), "cav.c889.d7111");
1190        assert_eq!(resolver.reverse_path("cav.0.1"), "cav.c889.d7110");
1191    }
1192
1193    #[test]
1194    fn reverse_path_simple() {
1195        let resolver = ReversePathResolver::from_schema(&test_schema());
1196        assert_eq!(resolver.reverse_path("loc.0"), "loc.d3227");
1197        assert_eq!(resolver.reverse_path("seq.0"), "seq.d1229");
1198    }
1199
1200    #[test]
1201    fn reverse_path_two_part_composite() {
1202        let resolver = ReversePathResolver::from_schema(&sts_schema());
1203        // 2-part numeric for composite → expands to first component
1204        assert_eq!(resolver.reverse_path("sts.0"), "sts.c601.d9015");
1205        assert_eq!(resolver.reverse_path("sts.1"), "sts.c555.d4405");
1206        assert_eq!(resolver.reverse_path("sts.2"), "sts.c556.d9013");
1207    }
1208
1209    #[test]
1210    fn reverse_path_ordinal_composites() {
1211        let resolver = ReversePathResolver::from_schema(&sts_schema());
1212        assert_eq!(resolver.reverse_path("sts.2.0"), "sts.c556.d9013");
1213        assert_eq!(resolver.reverse_path("sts.3.0"), "sts.c556_2.d9013");
1214        assert_eq!(resolver.reverse_path("sts.4.0"), "sts.c556_3.d9013");
1215    }
1216
1217    #[test]
1218    fn reverse_path_ordinal_data_elements() {
1219        let resolver = ReversePathResolver::from_schema(&nad_schema());
1220        assert_eq!(resolver.reverse_path("nad.3.0"), "nad.c080.d3036");
1221        assert_eq!(resolver.reverse_path("nad.3.1"), "nad.c080.d3036_2");
1222        assert_eq!(resolver.reverse_path("nad.3.2"), "nad.c080.d3036_3");
1223        assert_eq!(resolver.reverse_path("nad.3.3"), "nad.c080.d3036_4");
1224        assert_eq!(resolver.reverse_path("nad.3.4"), "nad.c080.d3036_5");
1225        assert_eq!(resolver.reverse_path("nad.3.5"), "nad.c080.d3045");
1226    }
1227
1228    #[test]
1229    fn reverse_path_qualifier() {
1230        let resolver = ReversePathResolver::from_schema(&test_schema());
1231        assert_eq!(resolver.reverse_path("cav[Z91].0.0"), "cav[Z91].c889.d7111");
1232        assert_eq!(resolver.reverse_path("cav[Z91].0.1"), "cav[Z91].c889.d7110");
1233    }
1234
1235    #[test]
1236    fn reverse_path_already_named() {
1237        let resolver = ReversePathResolver::from_schema(&test_schema());
1238        assert_eq!(resolver.reverse_path("loc.c517.d3225"), "loc.c517.d3225");
1239        assert_eq!(resolver.reverse_path("loc.d3227"), "loc.d3227");
1240    }
1241
1242    #[test]
1243    fn reverse_discriminator_simple() {
1244        let resolver = ReversePathResolver::from_schema(&test_schema());
1245        assert_eq!(
1246            resolver.reverse_discriminator("LOC.0.0=Z16"),
1247            "LOC.d3227=Z16"
1248        );
1249        assert_eq!(
1250            resolver.reverse_discriminator("SEQ.0.0=ZF0"),
1251            "SEQ.d1229=ZF0"
1252        );
1253    }
1254
1255    #[test]
1256    fn reverse_discriminator_composite() {
1257        let resolver = ReversePathResolver::from_schema(&sts_schema());
1258        assert_eq!(
1259            resolver.reverse_discriminator("STS.2.0=E01"),
1260            "STS.c556.d9013=E01"
1261        );
1262        assert_eq!(
1263            resolver.reverse_discriminator("STS.3.0=ZW4"),
1264            "STS.c556_2.d9013=ZW4"
1265        );
1266    }
1267
1268    #[test]
1269    fn reverse_discriminator_already_named() {
1270        let resolver = ReversePathResolver::from_schema(&test_schema());
1271        // Not numeric → unchanged
1272        assert_eq!(
1273            resolver.reverse_discriminator("LOC.d3227=Z16"),
1274            "LOC.d3227=Z16"
1275        );
1276    }
1277
1278    fn s_prefix_schema() -> serde_json::Value {
1279        serde_json::json!({
1280            "beschreibung": "CONTRL S-prefix test",
1281            "fields": {},
1282            "root_segments": [{
1283                "id": "UCI",
1284                "name": "Übertragungsprüfung",
1285                "elements": [
1286                    { "id": "0020", "index": 0, "type": "data" },
1287                    {
1288                        "composite": "S002",
1289                        "index": 1,
1290                        "name": "Absender",
1291                        "components": [
1292                            { "id": "0004", "sub_index": 0, "type": "data" },
1293                            { "id": "0007", "sub_index": 1, "type": "code" }
1294                        ]
1295                    },
1296                    {
1297                        "composite": "S003",
1298                        "index": 2,
1299                        "name": "Empfänger",
1300                        "components": [
1301                            { "id": "0010", "sub_index": 0, "type": "data" },
1302                            { "id": "0007", "sub_index": 1, "type": "code" }
1303                        ]
1304                    },
1305                    { "id": "0083", "index": 3, "type": "code" },
1306                    {
1307                        "composite": "S011",
1308                        "index": 6,
1309                        "name": "Datenelement-Identifikation",
1310                        "components": [
1311                            { "id": "0098", "sub_index": 0, "type": "data" },
1312                            { "id": "0104", "sub_index": 1, "type": "data" }
1313                        ]
1314                    }
1315                ]
1316            }]
1317        })
1318    }
1319
1320    #[test]
1321    fn resolve_s_prefix_composites() {
1322        let resolver = PathResolver::from_schema(&s_prefix_schema());
1323        assert_eq!(resolver.resolve_path("uci.s002.d0004"), "uci.1.0");
1324        assert_eq!(resolver.resolve_path("uci.s002.d0007"), "uci.1.1");
1325        assert_eq!(resolver.resolve_path("uci.s003.d0010"), "uci.2.0");
1326        assert_eq!(resolver.resolve_path("uci.s003.d0007"), "uci.2.1");
1327        assert_eq!(resolver.resolve_path("uci.s011.d0098"), "uci.6.0");
1328        assert_eq!(resolver.resolve_path("uci.s011.d0104"), "uci.6.1");
1329        assert_eq!(resolver.resolve_path("uci.d0020"), "uci.0");
1330        assert_eq!(resolver.resolve_path("uci.d0083"), "uci.3");
1331    }
1332
1333    #[test]
1334    fn reverse_s_prefix_composites() {
1335        let resolver = ReversePathResolver::from_schema(&s_prefix_schema());
1336        assert_eq!(resolver.reverse_path("uci.1.0"), "uci.s002.d0004");
1337        assert_eq!(resolver.reverse_path("uci.1.1"), "uci.s002.d0007");
1338        assert_eq!(resolver.reverse_path("uci.2.0"), "uci.s003.d0010");
1339        assert_eq!(resolver.reverse_path("uci.6.0"), "uci.s011.d0098");
1340        assert_eq!(resolver.reverse_path("uci.0"), "uci.d0020");
1341    }
1342
1343    #[test]
1344    fn forward_reverse_roundtrip() {
1345        let fwd = PathResolver::from_schema(&sts_schema());
1346        let rev = ReversePathResolver::from_schema(&sts_schema());
1347
1348        // Named → numeric → named
1349        let named = "sts.c556_2.d9013";
1350        let numeric = fwd.resolve_path(named);
1351        assert_eq!(numeric, "sts.3.0");
1352        let back = rev.reverse_path(&numeric);
1353        assert_eq!(back, named);
1354
1355        // Simple element roundtrip
1356        let named_simple = "nad.d3229";
1357        let fwd_nad = PathResolver::from_schema(&nad_schema());
1358        let rev_nad = ReversePathResolver::from_schema(&nad_schema());
1359        let numeric_simple = fwd_nad.resolve_path(named_simple);
1360        assert_eq!(numeric_simple, "nad.0");
1361        let back_simple = rev_nad.reverse_path(&numeric_simple);
1362        assert_eq!(back_simple, named_simple);
1363    }
1364
1365    #[test]
1366    fn discriminator_forward_reverse_roundtrip() {
1367        let fwd = PathResolver::from_schema(&test_schema());
1368        let rev = ReversePathResolver::from_schema(&test_schema());
1369
1370        let named = "LOC.d3227=Z16";
1371        let numeric = fwd.resolve_discriminator(named);
1372        assert_eq!(numeric, "LOC.0.0=Z16");
1373        let back = rev.reverse_discriminator(&numeric);
1374        assert_eq!(back, named);
1375    }
1376}