Skip to main content

hl7_2_from_er7_into_xml/
lib.rs

1//! Convert HL7 v2.5 messages from ER7 (pipe-delimited) encoding to the
2//! HL7 v2.xml XML representation (`urn:hl7-org:v2xml`).
3//!
4//! The ER7 encoding itself — parsing, delimiters, escape sequences — comes
5//! from the [`er7`] crate. This crate adds the layer above it: the HL7 v2.5
6//! data-type tables that name XML elements, the message-structure grammars
7//! that group segments, and the XML renderer. See `spec/index.md` for the
8//! exact conversion rules (source of truth).
9//!
10//! ```
11//! let er7 = "MSH|^~\\&|hphis||EPIC||20131011093851||ORM^O01|14AAACVDD|P|2.5\r\
12//!            PID|1||241900||MEDIANO^FOUAZ\r\
13//!            ORC|NW|ORD1";
14//! let xml = hl7_2_from_er7_into_xml::convert(er7).unwrap();
15//! assert!(xml.contains("<ORM_O01 xmlns=\"urn:hl7-org:v2xml\">"));
16//! assert!(xml.contains("<XPN.1>"));
17//! ```
18
19#![warn(missing_docs, clippy::pedantic)]
20// XML literals keep their `r#"..."#` delimiters even where no `"` currently
21// forces them: these are documents, and adding a quoted attribute to one
22// should not also mean changing its delimiter.
23#![allow(clippy::needless_raw_string_hashes)]
24
25pub mod structure;
26pub mod xml;
27
28/// The ER7 encoding layer this crate is built on, re-exported so callers can
29/// name [`er7::Message`], [`er7::Separators`], and the rest without adding
30/// their own dependency.
31///
32/// Until version 0.2.0 this was a module inside this crate. It is now the
33/// standalone `er7` crate, which owns the encoding and guarantees a
34/// byte-for-byte round trip; the type names changed slightly in the move
35/// (`Segment::id` is now `Segment::name`, `Repeat` is now
36/// [`er7::Repetition`], and subcomponents are [`er7::Subcomponent`] values
37/// that decode on demand rather than pre-decoded `String`s).
38pub use er7;
39
40use std::fmt;
41
42/// Errors that can occur while turning ER7 text into a [`er7::Message`]
43/// or, in turn, into v2.xml.
44///
45/// Parsing is deliberately lenient below the MSH header: unknown segments,
46/// unknown data types, and structure mismatches never produce an error, they
47/// degrade to generic names or a flat rendering (see the crate docs and
48/// `spec/index.md`). Only a message that has no usable MSH header fails.
49///
50/// This is a distinct type from [`er7::Error`] rather than a re-export,
51/// because the `er7` crate can also report a malformed HL7 path — something
52/// this crate never asks it for. Converting is automatic via `?`.
53#[derive(Debug)]
54pub enum Hl7Error {
55    /// Input contained no segments.
56    Empty,
57    /// The first segment is not MSH.
58    MissingMsh,
59    /// The MSH header line is malformed.
60    BadMshHeader(String),
61}
62
63impl fmt::Display for Hl7Error {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Hl7Error::Empty => write!(f, "input contains no HL7 segments"),
67            Hl7Error::MissingMsh => write!(f, "message does not start with an MSH segment"),
68            Hl7Error::BadMshHeader(detail) => write!(f, "malformed MSH header: {detail}"),
69        }
70    }
71}
72
73impl std::error::Error for Hl7Error {}
74
75impl From<er7::Error> for Hl7Error {
76    /// Map an `er7` parse failure onto this crate's narrower error type.
77    ///
78    /// `er7::Error::BadPath` cannot arise here: it comes from the path-query
79    /// API, and this crate reads the message through its own accessors. It
80    /// is mapped to [`Hl7Error::BadMshHeader`] so the detail survives rather
81    /// than being swallowed by a panic that could never fire.
82    fn from(error: er7::Error) -> Hl7Error {
83        match error {
84            er7::Error::Empty => Hl7Error::Empty,
85            er7::Error::MissingHeader(_) => Hl7Error::MissingMsh,
86            er7::Error::BadHeader(detail) | er7::Error::BadPath(detail) => {
87                Hl7Error::BadMshHeader(detail)
88            }
89        }
90    }
91}
92
93/// Options controlling how a message is converted; see [`convert_with_options`].
94#[derive(Debug, Clone, Copy, Default)]
95pub struct Options {
96    /// Always emit segments flat under the root element, never grouped into
97    /// message-structure groups such as `ORM_O01.PATIENT`.
98    pub flat: bool,
99    /// Treat the dictionary as a schema that describes the document's exact
100    /// shape, rather than as a table of what things mean.
101    ///
102    /// Off, the message decides which elements appear: every field it
103    /// carries is written, every repetition becomes its own element, and a
104    /// field it leaves empty is absent. That is the right reading for the
105    /// bundled releases, whose tables say what a field *is* and nothing
106    /// about how often it may appear.
107    ///
108    /// On, the dictionary decides. A field it marks `required` is written
109    /// even when the message leaves it empty, so the position stays visible;
110    /// a field it does not mark `repeats` keeps its repetition separator as
111    /// ordinary text instead of becoming several elements; and no element is
112    /// written for a field the dictionary does not declare. Use it with a
113    /// dictionary generated from XML Schema — one produced by
114    /// `hl7-2-from-xsd-into-json-dictionary` — where the answer to all
115    /// three questions came from the schema the output is validated against.
116    /// See `spec/index.md` §4.
117    pub schema_shape: bool,
118}
119
120/// Convert one ER7 message to a v2.xml document with default options.
121/// # Errors
122///
123/// [`Hl7Error`] when the input has no usable MSH header: no segments at
124/// all, a first segment that is not MSH, or a header whose delimiters
125/// cannot be read. Everything below the header degrades rather than
126/// failing.
127pub fn convert(er7_text: &str) -> Result<String, Hl7Error> {
128    convert_with_options(er7_text, Options::default())
129}
130
131/// Convert one ER7 message to a v2.xml document, using the bundled HL7 v2.5
132/// dictionary.
133///
134/// v2.5 is used whatever MSH-12 says, which is what this crate has always
135/// done; pass a dictionary to [`convert_with_dictionary`] to convert against
136/// another release or a vendor dialect.
137/// # Errors
138///
139/// [`Hl7Error`] when the input has no usable MSH header: no segments at
140/// all, a first segment that is not MSH, or a header whose delimiters
141/// cannot be read. Everything below the header degrades rather than
142/// failing.
143pub fn convert_with_options(er7_text: &str, options: Options) -> Result<String, Hl7Error> {
144    convert_with_dictionary(er7_text, &hl7_2::Version::V2_5.dictionary(), options)
145}
146
147/// Convert one ER7 message to a v2.xml document against a given dictionary.
148///
149/// The dictionary supplies everything this crate used to hard-code: which
150/// data type each field carries, what a composite type is made of, and how
151/// the message's segments group. A dictionary built from a vendor's own XML
152/// Schema therefore produces that vendor's document shape rather than the
153/// standard's — which is the point, since the output is usually validated
154/// against those same schemas.
155///
156/// ```
157/// let dictionary = hl7_2::Version::V2_5.dictionary();
158/// let xml = hl7_2_from_er7_into_xml::convert_with_dictionary(
159///     "MSH|^~\\&|APP||||1||ACK|1|P|2.5\rMSA|AA|1",
160///     &dictionary,
161///     Default::default(),
162/// ).unwrap();
163/// assert!(xml.contains("<ACK xmlns=\"urn:hl7-org:v2xml\">"));
164/// ```
165/// # Errors
166///
167/// [`Hl7Error`] when the input has no usable MSH header: no segments at
168/// all, a first segment that is not MSH, or a header whose delimiters
169/// cannot be read. Everything below the header degrades rather than
170/// failing.
171pub fn convert_with_dictionary(
172    er7_text: &str,
173    dictionary: &hl7_2::Dictionary,
174    options: Options,
175) -> Result<String, Hl7Error> {
176    let message = er7::parse(&normalize(er7_text))?;
177    let root_name = root_name(&message, dictionary);
178    let separators = &message.separators;
179    let seg_nodes: Vec<(String, xml::Node)> = message
180        .segments
181        .iter()
182        .map(|s| {
183            (
184                s.name.clone(),
185                xml::segment_to_node(s, separators, dictionary, options.schema_shape),
186            )
187        })
188        .collect();
189    let grouped = if options.flat {
190        None
191    } else {
192        dictionary
193            .structure(&root_name)
194            .and_then(|items| structure::group_segments(&root_name, items, &seg_nodes))
195    };
196    let mut root = xml::Node::group(xml::xml_name(&root_name));
197    root.kids = grouped.unwrap_or_else(|| seg_nodes.into_iter().map(|(_, n)| n).collect());
198    Ok(xml::render_document(&root))
199}
200
201/// Tidy input into the shape `spec/index.md` §2.1 describes, before handing
202/// it to the `er7` parser: split on either terminator, trim each line, drop
203/// blank ones, and rejoin with `\r`.
204///
205/// `er7` deliberately trims nothing, because it guarantees that a message it
206/// parses can be written back byte for byte and it cannot know whether a
207/// trailing space is data (`er7` spec §4.1). This crate makes no such
208/// promise — it renders XML, where stray whitespace around a segment is
209/// noise, and where an indented first line would otherwise turn a readable
210/// message into a `MissingMsh` error. So the trimming this crate has always
211/// documented happens here instead.
212fn normalize(text: &str) -> String {
213    text.trim_start_matches('\u{feff}')
214        .split(['\r', '\n'])
215        .map(str::trim)
216        .filter(|line| !line.is_empty())
217        .collect::<Vec<&str>>()
218        .join("\r")
219}
220
221/// Split input that may hold several messages (or an HL7 batch file) into
222/// individual ER7 messages, one per MSH segment. Batch envelope segments
223/// (FHS, BHS, BTS, FTS) are dropped.
224pub fn split_messages(text: &str) -> Vec<String> {
225    // Normalize first: `er7::split_messages` identifies a segment by its
226    // leading run of letters and digits, so a line indented for readability
227    // would not be recognized as the `MSH` that starts a message.
228    let normalized = normalize(text);
229    er7::split_messages(&normalized)
230        .into_iter()
231        .map(str::to_string)
232        .collect()
233}
234
235/// Derive the message structure ID (and root element name) from MSH-9:
236/// MSH-9.3 when present, otherwise the structure the dictionary says carries
237/// this message code and trigger event.
238///
239/// Which trigger events share a structure is dictionary knowledge — an A04
240/// admit and an A08 update are both carried by `ADT_A01` — so it comes from
241/// the `"aliases"` section rather than from a match arm here.
242fn root_name(message: &er7::Message, dictionary: &hl7_2::Dictionary) -> String {
243    if let Some(structure_id) = message.message_structure() {
244        return structure_id;
245    }
246    dictionary.structure_id(
247        &message.message_code().unwrap_or_default(),
248        &message.trigger_event().unwrap_or_default(),
249    )
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn normalizes_before_parsing() {
258        // The crate's own §2.1: any terminator, trimmed lines, no blanks.
259        assert_eq!(normalize("MSH|A\r\n\r\n  PID|1  \n"), "MSH|A\rPID|1");
260        assert_eq!(normalize("\u{feff}MSH|A"), "MSH|A");
261        // Which means an indented message still converts, where the `er7`
262        // parser alone would report a missing header.
263        assert!(convert("  MSH|^~\\&|APP||||1||ACK|1|P|2.5\r  MSA|AA|1").is_ok());
264    }
265
266    #[test]
267    fn maps_er7_errors_onto_this_crates_type() {
268        assert!(matches!(convert(""), Err(Hl7Error::Empty)));
269        assert!(matches!(convert("PID|1"), Err(Hl7Error::MissingMsh)));
270        match convert("MSH") {
271            Err(Hl7Error::BadMshHeader(detail)) => assert!(!detail.is_empty()),
272            other => panic!("expected a bad-header error, got {other:?}"),
273        }
274    }
275}