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