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}