mig-assembly 0.1.60

MIG-guided EDIFACT tree assembly — parse RawSegments into typed MIG trees
Documentation
//! EDIFACT string renderer from disassembled segments.
//!
//! Converts a list of `DisassembledSegment` values back into a valid
//! EDIFACT string using the provided delimiters.

use crate::disassembler::DisassembledSegment;
use edifact_primitives::EdifactDelimiters;

/// Render a list of disassembled segments into an EDIFACT string.
///
/// Follows the same rendering rules as `RawSegment::to_raw_string`:
/// - Elements separated by element separator (`+`)
/// - Components separated by component separator (`:`)
/// - Trailing empty elements are trimmed
/// - Each segment terminated by segment terminator (`'`)
pub fn render_edifact(segments: &[DisassembledSegment], delimiters: &EdifactDelimiters) -> String {
    let mut out = String::new();

    for seg in segments {
        render_segment(seg, delimiters, &mut out);
    }

    out
}

fn render_segment(seg: &DisassembledSegment, delimiters: &EdifactDelimiters, out: &mut String) {
    let elem_sep = delimiters.element as char;
    let comp_sep = delimiters.component as char;
    let seg_term = delimiters.segment as char;

    out.push_str(&seg.tag);

    for element in &seg.elements {
        out.push(elem_sep);
        // Preserve ALL components including trailing empty ones for roundtrip fidelity.
        for (j, component) in element.iter().enumerate() {
            if j > 0 {
                out.push(comp_sep);
            }
            escape_component(component, delimiters, out);
        }
    }

    out.push(seg_term);
}

/// Write a component value, escaping any delimiter characters with the release character.
///
/// Characters that must be escaped: element separator (`+`), component separator (`:`),
/// segment terminator (`'`), and the release character itself (`?`).
///
/// All values — whether parsed from EDIFACT or synthetically created — are stored
/// without escape sequences. The tokenizer unescapes during `OwnedSegment` creation,
/// and the renderer re-escapes here when writing EDIFACT output.
fn escape_component(value: &str, delimiters: &EdifactDelimiters, out: &mut String) {
    let release = delimiters.release;
    let special = [
        delimiters.element,
        delimiters.component,
        delimiters.segment,
        delimiters.release,
    ];

    let needs_escape = value.bytes().any(|b| special.contains(&b));
    if !needs_escape {
        out.push_str(value);
        return;
    }

    for b in value.bytes() {
        if special.contains(&b) {
            out.push(release as char);
        }
        out.push(b as char);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_render_segments_to_edifact() {
        let segments = vec![
            DisassembledSegment {
                tag: "UNH".to_string(),
                elements: vec![
                    vec!["1".to_string()],
                    vec!["UTILMD".to_string(), "D".to_string(), "11A".to_string()],
                ],
            },
            DisassembledSegment {
                tag: "BGM".to_string(),
                elements: vec![vec!["E01".to_string()]],
            },
        ];

        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&segments, &delimiters);

        assert_eq!(rendered, "UNH+1+UTILMD:D:11A'BGM+E01'");
    }

    #[test]
    fn test_render_empty_segments() {
        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&[], &delimiters);
        assert_eq!(rendered, "");
    }

    #[test]
    fn test_render_segment_with_empty_components() {
        let segments = vec![DisassembledSegment {
            tag: "CAV".to_string(),
            elements: vec![vec![
                "SA".to_string(),
                String::new(),
                String::new(),
                String::new(),
            ]],
        }];

        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&segments, &delimiters);

        // Trailing empty components should be preserved
        assert_eq!(rendered, "CAV+SA:::'");
    }

    #[test]
    fn test_render_multiple_elements() {
        let segments = vec![DisassembledSegment {
            tag: "DTM".to_string(),
            elements: vec![vec![
                "137".to_string(),
                "20250101".to_string(),
                "102".to_string(),
            ]],
        }];

        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&segments, &delimiters);

        assert_eq!(rendered, "DTM+137:20250101:102'");
    }

    #[test]
    fn test_render_escapes_delimiter_chars_in_values() {
        // Timestamps with timezone offset contain '+' which must be escaped as '?+'
        let segments = vec![DisassembledSegment {
            tag: "DTM".to_string(),
            elements: vec![vec![
                "137".to_string(),
                "202603021433+00".to_string(),
                "303".to_string(),
            ]],
        }];

        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&segments, &delimiters);

        assert_eq!(rendered, "DTM+137:202603021433?+00:303'");
    }

    #[test]
    fn test_render_escapes_multiple_special_chars() {
        // Value with ':', '+', and '?' all needing escape
        let segments = vec![DisassembledSegment {
            tag: "FTX".to_string(),
            elements: vec![
                vec!["ABO".to_string()],
                vec![],
                vec![],
                vec!["hello?+world:test".to_string()],
            ],
        }];

        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&segments, &delimiters);

        assert_eq!(rendered, "FTX+ABO+++hello???+world?:test'");
    }

    #[test]
    fn test_render_no_escape_needed_for_plain_values() {
        let segments = vec![DisassembledSegment {
            tag: "BGM".to_string(),
            elements: vec![vec!["312".to_string()], vec!["DOC001".to_string()]],
        }];

        let delimiters = EdifactDelimiters::default();
        let rendered = render_edifact(&segments, &delimiters);

        assert_eq!(rendered, "BGM+312+DOC001'");
    }
}