Skip to main content

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}