Tutorial

A lossless round trip

ER7 to XML and back, ER7 to JSON and back — as a shell pipeline, and as a test in your own crate. It is the smoke test the conversion crates use on themselves, and it is the fastest way to find out whether a change broke something.

Why the round trip is the test

The four conversion crates split into two forward crates that need the HL7 v2.5 dictionary, and two reverse crates that need none — because every name a forward crate writes carries its own position. That makes the pair tightly coupled in one specific way: a change to the forward crate's naming rules silently breaks the reverse crate's assumptions.

So the round trip is not a nice-to-have. It is the assertion that the two halves still agree, and it costs one shell pipeline to run.

1. From the shell

sh
# ER7 → v2.xml → ER7
hl7-2-from-er7-into-xml samples/orm_o01.hl7 \
  | hl7-2-from-xml-into-er7

# The whole assertion, in one line
hl7-2-from-er7-into-xml samples/orm_o01.hl7 \
  | hl7-2-from-xml-into-er7 \
  | diff - <(hl7-2-from-er7-into-xml samples/orm_o01.hl7 \
             | hl7-2-from-xml-into-er7) && echo "stable"

If you are working inside the workspace rather than with installed binaries, the same thing runs as cargo run -p hl7-2-from-er7-into-xml -- samples/orm_o01.hl7 | cargo run -p hl7-2-from-xml-into-er7 -- — both crates live in one workspace, so no cd is needed.

2. Canonicalized, not identical

sh
# The output is the ORIGINAL message, canonicalized. Compare against a
# canonicalized original rather than the raw file:
hl7-v2 --er7 samples/orm_o01.hl7 > canonical.hl7

hl7-2-from-er7-into-xml samples/orm_o01.hl7 \
  | hl7-2-from-xml-into-er7 \
  | diff - canonical.hl7 && echo "round trip is lossless"

Each crate's spec/index.md §2.1 defines precisely what canonicalization means.

3. The same through JSON

sh
hl7-2-from-er7-into-json samples/orm_o01.hl7 \
  | hl7-2-from-json-into-er7 \
  | diff - canonical.hl7

Both pipelines should land on the same canonical text, which is a useful property in itself: the two mappings are independent implementations of the same idea, and if they disagree, one of them has a bug.

4. As a test in your own crate

Worth having if you depend on any of the four, and essential if you have written a vendor dictionary — a dictionary that names a field wrongly will still round-trip, but a dictionary that names it inconsistently will not.

rust
#[test]
fn er7_survives_a_trip_through_xml() {
    let original = include_str!("../samples/orm_o01.hl7");

    let xml = hl7_2_from_er7_into_xml::convert(original).expect("into xml");
    let back = hl7_2_from_xml_into_er7::convert(&xml).expect("back into er7");

    // Canonicalize the original the same way the pipeline does, so the
    // comparison is about the conversion rather than about whitespace.
    let canonical = hl7_2::parse(original).expect("parse").to_er7();
    assert_eq!(back, canonical);
}

And over every sample you have, in both directions:

rust
#[test]
fn every_sample_survives_both_round_trips() {
    for entry in std::fs::read_dir("samples").expect("samples directory") {
        let path = entry.expect("entry").path();
        if path.extension().and_then(|e| e.to_str()) != Some("hl7") {
            continue;
        }
        let original = std::fs::read_to_string(&path).expect("read");

        for message in hl7_2_from_er7_into_xml::split_messages(&original) {
            let canonical = hl7_2::parse(&message).expect("parse").to_er7();

            let xml = hl7_2_from_er7_into_xml::convert(&message).expect("into xml");
            assert_eq!(
                hl7_2_from_xml_into_er7::convert(&xml).expect("back"),
                canonical,
                "xml round trip failed for {}", path.display(),
            );

            let json = hl7_2_from_er7_into_json::convert(&message).expect("into json");
            assert_eq!(
                hl7_2_from_json_into_er7::convert(&json).expect("back"),
                canonical,
                "json round trip failed for {}", path.display(),
            );
        }
    }
}

Note split_messages: a sample file may hold one message, several, or an HL7 batch. Round-tripping the file as a unit would compare the wrong things.

5. Where it is genuinely lossy

Two cases will not round-trip, and both are properties of the forward encoding rather than defects in the reversal. Knowing them saves an afternoon of debugging.

A blank repetition

er7
# A repetition that is present but entirely blank:
PID|1||111~~333

# The middle repetition carries no value at all, so the forward encoding
# has nothing to write for it — and the reverse cannot invent it back.
# The explicit null is different, and DOES survive:
PID|1||111~""~333

A non-adjacent repeating segment, in JSON

er7
# JSON only. A segment name that repeats NON-ADJACENTLY:
NTE|1||first
OBX|1|NM|...
NTE|2||second

# A JSON object cannot carry the key "NTE" twice, so the second NTE is
# grouped with the first — and the OBX that sat between them loses its
# place in the sequence. Adjacent repeats, and repeats inside a group the
# grammar knows, are unaffected.

The XML mapping does not have this problem, because repeated sibling elements keep their document order. It is the one place the two formats diverge in a way that costs information rather than only looking different — see Where XML and JSON deliberately diverge.

6. Over a directory

Useful in CI, or as a one-off against a corpus of real messages before trusting a conversion in production.

sh
#!/bin/sh
# Round-trip every message in a directory and report the ones that drift.
status=0
for message in samples/*.hl7; do
    canonical=$(hl7-v2 --er7 "$message")
    actual=$(hl7-2-from-er7-into-xml "$message" | hl7-2-from-xml-into-er7)
    if [ "$canonical" != "$actual" ]; then
        echo "drift: $message"
        status=1
    fi
done
exit $status

Run it against real traffic, not against the samples in the repository. The samples are the cases the crates already handle; your corpus is where the interesting failures are, and every one you find is worth reporting — see Support and contributing.