Skip to main content

mig_assembly/
renderer.rs

1//! EDIFACT string renderer from disassembled segments.
2//!
3//! Converts a list of `DisassembledSegment` values back into a valid
4//! EDIFACT string using the provided delimiters.
5
6use crate::disassembler::DisassembledSegment;
7use edifact_primitives::EdifactDelimiters;
8
9/// Render a list of disassembled segments into an EDIFACT string.
10///
11/// Follows the same rendering rules as `RawSegment::to_raw_string`:
12/// - Elements separated by element separator (`+`)
13/// - Components separated by component separator (`:`)
14/// - Trailing empty elements are trimmed
15/// - Each segment terminated by segment terminator (`'`)
16pub fn render_edifact(segments: &[DisassembledSegment], delimiters: &EdifactDelimiters) -> String {
17    let mut out = String::new();
18
19    for seg in segments {
20        render_segment(seg, delimiters, &mut out);
21    }
22
23    out
24}
25
26fn render_segment(seg: &DisassembledSegment, delimiters: &EdifactDelimiters, out: &mut String) {
27    let elem_sep = delimiters.element as char;
28    let comp_sep = delimiters.component as char;
29    let seg_term = delimiters.segment as char;
30
31    out.push_str(&seg.tag);
32
33    for element in &seg.elements {
34        out.push(elem_sep);
35        // Preserve ALL components including trailing empty ones for roundtrip fidelity.
36        for (j, component) in element.iter().enumerate() {
37            if j > 0 {
38                out.push(comp_sep);
39            }
40            escape_component(component, delimiters, out);
41        }
42    }
43
44    out.push(seg_term);
45}
46
47/// Write a component value, escaping any delimiter characters with the release character.
48///
49/// Characters that must be escaped: element separator (`+`), component separator (`:`),
50/// segment terminator (`'`), and the release character itself (`?`).
51///
52/// All values — whether parsed from EDIFACT or synthetically created — are stored
53/// without escape sequences. The tokenizer unescapes during `OwnedSegment` creation,
54/// and the renderer re-escapes here when writing EDIFACT output.
55fn escape_component(value: &str, delimiters: &EdifactDelimiters, out: &mut String) {
56    let release = delimiters.release;
57    let special = [
58        delimiters.element,
59        delimiters.component,
60        delimiters.segment,
61        delimiters.release,
62    ];
63
64    let needs_escape = value.bytes().any(|b| special.contains(&b));
65    if !needs_escape {
66        out.push_str(value);
67        return;
68    }
69
70    for b in value.bytes() {
71        if special.contains(&b) {
72            out.push(release as char);
73        }
74        out.push(b as char);
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn test_render_segments_to_edifact() {
84        let segments = vec![
85            DisassembledSegment {
86                tag: "UNH".to_string(),
87                elements: vec![
88                    vec!["1".to_string()],
89                    vec!["UTILMD".to_string(), "D".to_string(), "11A".to_string()],
90                ],
91            },
92            DisassembledSegment {
93                tag: "BGM".to_string(),
94                elements: vec![vec!["E01".to_string()]],
95            },
96        ];
97
98        let delimiters = EdifactDelimiters::default();
99        let rendered = render_edifact(&segments, &delimiters);
100
101        assert_eq!(rendered, "UNH+1+UTILMD:D:11A'BGM+E01'");
102    }
103
104    #[test]
105    fn test_render_empty_segments() {
106        let delimiters = EdifactDelimiters::default();
107        let rendered = render_edifact(&[], &delimiters);
108        assert_eq!(rendered, "");
109    }
110
111    #[test]
112    fn test_render_segment_with_empty_components() {
113        let segments = vec![DisassembledSegment {
114            tag: "CAV".to_string(),
115            elements: vec![vec![
116                "SA".to_string(),
117                String::new(),
118                String::new(),
119                String::new(),
120            ]],
121        }];
122
123        let delimiters = EdifactDelimiters::default();
124        let rendered = render_edifact(&segments, &delimiters);
125
126        // Trailing empty components should be preserved
127        assert_eq!(rendered, "CAV+SA:::'");
128    }
129
130    #[test]
131    fn test_render_multiple_elements() {
132        let segments = vec![DisassembledSegment {
133            tag: "DTM".to_string(),
134            elements: vec![vec![
135                "137".to_string(),
136                "20250101".to_string(),
137                "102".to_string(),
138            ]],
139        }];
140
141        let delimiters = EdifactDelimiters::default();
142        let rendered = render_edifact(&segments, &delimiters);
143
144        assert_eq!(rendered, "DTM+137:20250101:102'");
145    }
146
147    #[test]
148    fn test_render_escapes_delimiter_chars_in_values() {
149        // Timestamps with timezone offset contain '+' which must be escaped as '?+'
150        let segments = vec![DisassembledSegment {
151            tag: "DTM".to_string(),
152            elements: vec![vec![
153                "137".to_string(),
154                "202603021433+00".to_string(),
155                "303".to_string(),
156            ]],
157        }];
158
159        let delimiters = EdifactDelimiters::default();
160        let rendered = render_edifact(&segments, &delimiters);
161
162        assert_eq!(rendered, "DTM+137:202603021433?+00:303'");
163    }
164
165    #[test]
166    fn test_render_escapes_multiple_special_chars() {
167        // Value with ':', '+', and '?' all needing escape
168        let segments = vec![DisassembledSegment {
169            tag: "FTX".to_string(),
170            elements: vec![
171                vec!["ABO".to_string()],
172                vec![],
173                vec![],
174                vec!["hello?+world:test".to_string()],
175            ],
176        }];
177
178        let delimiters = EdifactDelimiters::default();
179        let rendered = render_edifact(&segments, &delimiters);
180
181        assert_eq!(rendered, "FTX+ABO+++hello???+world?:test'");
182    }
183
184    #[test]
185    fn test_render_no_escape_needed_for_plain_values() {
186        let segments = vec![DisassembledSegment {
187            tag: "BGM".to_string(),
188            elements: vec![vec!["312".to_string()], vec!["DOC001".to_string()]],
189        }];
190
191        let delimiters = EdifactDelimiters::default();
192        let rendered = render_edifact(&segments, &delimiters);
193
194        assert_eq!(rendered, "BGM+312+DOC001'");
195    }
196}