hl7_2/lib.rs
1//! Parse, navigate, validate, modify, and write HL7 v2 messages, in three
2//! modes that share one set of internals.
3//!
4//! HL7 v2 is the format most healthcare data still moves in, and most of
5//! the difficulty in reading it is not the syntax — that is pipes and
6//! carets, and the [`er7`] crate this one is built on already handles it —
7//! but knowing what the pipes and carets *mean* in the release the sender
8//! speaks. This crate owns that knowledge: the per-release data-type
9//! tables, the message structures, and the three ways to apply them.
10//!
11//! Published standalone as `hl7-2`; most users get it through the `hl7`
12//! umbrella crate instead, which re-exports this crate as `hl7::v2`:
13//!
14//! ```toml
15//! [dependencies]
16//! hl7 = "0.1"
17//! ```
18//!
19//! ## Three modes
20//!
21//! **Generic** — for the vendor whose messages you have never seen and need
22//! to explore. Parse anything into a navigable tree; nothing is rejected
23//! and nothing is dropped.
24//!
25//! ```
26//! let message = hl7_2::parse("MSH|^~\\&|LAB||EPIC||20240101||ORU^R01|1|P|2.5\r\
27//! PID|1||241900||SMITH^JOHN\r\
28//! OBR|1||X|GLU\r\
29//! OBX|1|NM|GLU^Glucose||7.4|mmol/L")?;
30//! let tree = message.tree();
31//! assert_eq!(tree.name(), "ORU_R01");
32//! assert_eq!(tree.find("XPN.1").unwrap().text(), "SMITH");
33//! assert_eq!(message.get("OBX-5")?.as_deref(), Some("7.4"));
34//! # Ok::<(), hl7_2::Error>(())
35//! ```
36//!
37//! **Schema-based** — for the vendor whose quirks you have learned but
38//! whose format is not frozen. Write the shape as JSON, load it at runtime,
39//! and adding a field needs no recompile.
40//!
41//! ```
42//! use std::sync::Arc;
43//!
44//! let dictionary = hl7_2::Dictionary::from_json(r#"{
45//! "inherits": "2.5",
46//! "segments": { "ZPD": ["ST", "XPN"] }
47//! }"#, "acme")?;
48//! let options = hl7_2::Options::new().with_dictionary(Arc::new(dictionary));
49//! let message = hl7_2::parse_with_options(
50//! "MSH|^~\\&|ACME||||1||ADT^A01|1|P|2.5\rZPD|7|SMITH^JOHN",
51//! &options,
52//! )?;
53//! // The vendor's own segment now reads like any standard one.
54//! assert_eq!(message.tree().find("XPN.2").unwrap().text(), "JOHN");
55//! # Ok::<(), hl7_2::Error>(())
56//! ```
57//!
58//! **Struct-based** — for the stable, long-lived feed where you want the
59//! compiler's help. See [`typed`] for the derive macros, and for the [`Raw`]
60//! field that keeps the generic escape hatch open on the same object.
61//!
62//! ## What this crate is, and is not
63//!
64//! It is the HL7 v2 dictionary layer: releases 2.1 through 2.9, data types,
65//! message structures, three modes, mutation, and validation. It is not the
66//! ER7 encoding layer — parsing, delimiters, escape sequences, and
67//! byte-for-byte rendering all belong to [`er7`], which is this crate's
68//! only runtime dependency and has none of its own. It is also not a
69//! transport: MLLP, files, and queues are the caller's business.
70//!
71//! `spec/index.md` in the repository is the normative specification of
72//! everything above; where this documentation and that document disagree,
73//! that document is right.
74
75#![warn(missing_docs, clippy::pedantic)]
76
77pub mod builder;
78pub mod dictionary;
79pub mod generic;
80pub mod json;
81pub mod message;
82pub mod structure;
83pub mod typed;
84pub mod validate;
85pub mod version;
86
87pub use builder::Builder;
88pub use dictionary::Dictionary;
89pub use generic::Node;
90pub use message::Message;
91pub use typed::{FromHl7, FromHl7Text, FromHl7Value, Raw, ToHl7, ToHl7Text, ToHl7Value};
92pub use validate::{Diagnostic, Severity};
93pub use version::Version;
94
95/// The `#[derive(FromHl7)]` and `#[derive(ToHl7)]` macros, re-exported so
96/// the `hl7-2-derive` crate does not have to be named as a dependency.
97/// Requires the `derive` feature.
98#[cfg(feature = "derive")]
99pub use hl7_2_derive::{FromHl7, ToHl7};
100
101/// The ER7 encoding layer this crate is built on, re-exported so callers
102/// can name [`er7::Message`], [`er7::Separators`], [`er7::Path`] and the
103/// rest without adding their own dependency.
104pub use er7;
105
106use std::fmt;
107use std::sync::Arc;
108
109/// What can go wrong.
110///
111/// Reading is deliberately lenient below the MSH header: unknown segments,
112/// unknown data types, and structure mismatches are never errors — they
113/// degrade to positional names and a flat reading, and are reported by
114/// [`Message::validate`] if the caller wants to know. Only a message with
115/// no usable header, a path that is not a path, a dictionary that will not
116/// load, and (in struct mode) a value that does not fit its Rust type will
117/// fail a call.
118#[derive(Debug, Clone, PartialEq)]
119pub enum Error {
120 /// Input contained no segments.
121 Empty,
122 /// The first segment is not MSH, so the message never declared its
123 /// delimiters.
124 MissingMsh,
125 /// The MSH header is malformed: no delimiters, or an unusable set.
126 BadMshHeader(String),
127 /// A path such as `PID-5.1` could not be read; carries the reason.
128 Path(String),
129 /// A write named a segment the message does not have. Add it with
130 /// [`Message::append_segment`] or build the message with [`Builder`].
131 NoSuchSegment {
132 /// The segment name that was asked for.
133 name: String,
134 /// Which occurrence of it.
135 occurrence: usize,
136 },
137 /// A write named something that cannot be written — a whole segment,
138 /// or a repeating value without a field.
139 UnwritablePath(String),
140 /// Struct mode: a non-optional field's path names nothing in the
141 /// message.
142 MissingField {
143 /// The path that was empty.
144 path: String,
145 },
146 /// Struct mode: a value is present but does not fit the Rust type.
147 BadValue {
148 /// Where the value is.
149 path: String,
150 /// What was expected there.
151 expected: String,
152 /// The text that was found.
153 found: String,
154 },
155 /// A dictionary could not be loaded; see [`dictionary::Error`].
156 Dictionary(dictionary::Error),
157 /// [`Options::strict`] was set and the message did not pass
158 /// validation. Carries every [`Severity::Error`] diagnostic.
159 Invalid(Vec<Diagnostic>),
160}
161
162impl fmt::Display for Error {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 Error::Empty => write!(f, "input contains no HL7 segments"),
166 Error::MissingMsh => write!(f, "message does not start with an MSH segment"),
167 Error::BadMshHeader(detail) => write!(f, "malformed MSH header: {detail}"),
168 Error::Path(detail) => write!(f, "invalid HL7 path: {detail}"),
169 Error::NoSuchSegment { name, occurrence } => {
170 write!(
171 f,
172 "message has no {name} segment at occurrence {occurrence}"
173 )
174 }
175 Error::UnwritablePath(detail) => write!(f, "cannot write to this path: {detail}"),
176 Error::MissingField { path } => write!(f, "{path}: required value is missing"),
177 Error::BadValue {
178 path,
179 expected,
180 found,
181 } => write!(f, "{path}: expected {expected}, found {found:?}"),
182 Error::Dictionary(error) => write!(f, "invalid dictionary: {error}"),
183 Error::Invalid(diagnostics) => {
184 write!(f, "message failed validation:")?;
185 for diagnostic in diagnostics {
186 write!(f, "\n {diagnostic}")?;
187 }
188 Ok(())
189 }
190 }
191 }
192}
193
194impl std::error::Error for Error {}
195
196impl From<er7::Error> for Error {
197 fn from(error: er7::Error) -> Error {
198 match error {
199 er7::Error::Empty => Error::Empty,
200 er7::Error::MissingHeader(_) => Error::MissingMsh,
201 er7::Error::BadHeader(detail) => Error::BadMshHeader(detail),
202 er7::Error::BadPath(detail) => Error::Path(detail),
203 }
204 }
205}
206
207impl From<dictionary::Error> for Error {
208 fn from(error: dictionary::Error) -> Error {
209 Error::Dictionary(error)
210 }
211}
212
213/// How to read a message: which release, which dictionary, and whether
214/// validation failures are fatal.
215///
216/// ```
217/// let options = hl7_2::Options::new()
218/// .with_version(hl7_2::Version::V2_3_1) // ignore what MSH-12 says
219/// .strict(); // reject what does not conform
220/// # let _ = options;
221/// ```
222#[derive(Debug, Clone, Default)]
223pub struct Options {
224 /// Read every message as this release, whatever MSH-12 declares. Use
225 /// it for a sender known to mislabel its version.
226 pub version: Option<Version>,
227 /// Read through this dictionary instead of the bundled one for the
228 /// release — schema mode.
229 pub dictionary: Option<Arc<Dictionary>>,
230 /// Fail the parse when validation reports a [`Severity::Error`], rather
231 /// than returning a message the caller must remember to check.
232 pub strict: bool,
233}
234
235impl Options {
236 /// Default options: release from MSH-12, bundled dictionary, lenient.
237 #[must_use]
238 pub fn new() -> Options {
239 Options::default()
240 }
241
242 /// Read as `version` whatever MSH-12 says.
243 #[must_use]
244 pub fn with_version(mut self, version: Version) -> Options {
245 self.version = Some(version);
246 self
247 }
248
249 /// Read through `dictionary` — schema mode.
250 #[must_use]
251 pub fn with_dictionary(mut self, dictionary: Arc<Dictionary>) -> Options {
252 self.dictionary = Some(dictionary);
253 self
254 }
255
256 /// Reject a message that does not conform; see [`Options::strict`].
257 #[must_use]
258 pub fn strict(mut self) -> Options {
259 self.strict = true;
260 self
261 }
262}
263
264/// Parse one message, reading the release from MSH-12.
265/// # Errors
266///
267/// [`Error`] when the message has no usable MSH header: no segments,
268/// a first segment that is not MSH, or delimiters that cannot be read.
269pub fn parse(text: &str) -> Result<Message, Error> {
270 Message::parse(text, &Options::default())
271}
272
273/// Parse one message under `options`.
274/// # Errors
275///
276/// [`Error`] when the message has no usable MSH header: no segments,
277/// a first segment that is not MSH, or delimiters that cannot be read.
278pub fn parse_with_options(text: &str, options: &Options) -> Result<Message, Error> {
279 Message::parse(text, options)
280}
281
282/// Split input that may hold several messages, or an HL7 batch file, into
283/// individual messages — one per MSH segment. Batch envelope segments
284/// (FHS, BHS, BTS, FTS) are dropped.
285pub fn split_messages(text: &str) -> Vec<String> {
286 // Normalize first: `er7::split_messages` identifies a segment by its
287 // leading run of letters and digits, so a line indented for readability
288 // would not be recognized as the MSH that starts a message.
289 er7::split_messages(&normalize(text))
290 .into_iter()
291 .map(str::to_string)
292 .collect()
293}
294
295/// Tidy input before parsing: drop a byte-order mark, split on either
296/// terminator, trim each line, drop blank ones, and rejoin with `\r`.
297///
298/// `er7` deliberately trims nothing, because it guarantees a message it
299/// parses can be written back byte for byte and it cannot know whether a
300/// trailing space is data. This crate makes the same round-trip promise for
301/// messages it did not modify, but only after this normalization — an
302/// indented first line would otherwise be a missing header rather than a
303/// message.
304pub(crate) fn normalize(text: &str) -> String {
305 text.trim_start_matches('\u{feff}')
306 .split(['\r', '\n'])
307 .map(str::trim)
308 .filter(|line| !line.is_empty())
309 .collect::<Vec<&str>>()
310 .join("\r")
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn normalizes_before_parsing() {
319 assert_eq!(normalize("MSH|A\r\n\r\n PID|1 \n"), "MSH|A\rPID|1");
320 assert_eq!(normalize("\u{feff}MSH|A"), "MSH|A");
321 // Which means an indented message still parses, where the `er7`
322 // parser alone would report a missing header.
323 assert!(parse(" MSH|^~\\&|APP||||1||ACK|1|P|2.5\r MSA|AA|1").is_ok());
324 }
325
326 #[test]
327 fn maps_er7_errors_onto_this_crates_type() {
328 assert!(matches!(parse(""), Err(Error::Empty)));
329 assert!(matches!(parse("PID|1"), Err(Error::MissingMsh)));
330 assert!(matches!(parse("MSH"), Err(Error::BadMshHeader(_))));
331 }
332
333 #[test]
334 fn strict_mode_turns_diagnostics_into_a_failure() {
335 let text = "MSH|^~\\&|A||||20240101||ACK^A01|1|P|2.5"; // no MSA
336 assert!(parse(text).is_ok(), "lenient by default");
337 let strict = Options::new().strict();
338 match parse_with_options(text, &strict) {
339 Err(Error::Invalid(diagnostics)) => {
340 assert_eq!(diagnostics.len(), 1);
341 assert_eq!(diagnostics[0].kind, validate::Kind::SegmentMissing);
342 }
343 other => panic!("expected a validation failure, got {other:?}"),
344 }
345 // Warnings alone do not fail: an unknown structure is this crate's
346 // gap, not the sender's error.
347 let text = "MSH|^~\\&|A||||20240101||ZZZ^Z01|1|P|2.5";
348 assert!(parse_with_options(text, &strict).is_ok());
349 }
350
351 #[test]
352 fn forcing_a_version_overrides_the_header() {
353 let text = "MSH|^~\\&|A||||1||ACK^A01|1|P|2.5\rMSA|AA|1";
354 let options = Options::new().with_version(Version::V2_3);
355 let message = parse_with_options(text, &options).unwrap();
356 assert_eq!(message.version(), Version::V2_3);
357 // v2.3's ERR has one field where v2.5's has twelve.
358 assert_eq!(message.dictionary().segment_fields("ERR").unwrap().len(), 1);
359 }
360
361 #[test]
362 fn splits_batches_into_messages() {
363 let batch = "FHS|^~\\&|A\rBHS|^~\\&|A\r\
364 MSH|^~\\&|A||||1||ACK|1|P|2.5\rMSA|AA|1\r\
365 MSH|^~\\&|A||||2||ACK|2|P|2.5\rMSA|AA|2\r\
366 BTS|2\rFTS|1";
367 let messages = split_messages(batch);
368 assert_eq!(messages.len(), 2);
369 assert!(messages[1].contains("MSA|AA|2"));
370 assert!(messages.iter().all(|text| parse(text).is_ok()));
371 }
372}