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//! ## 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}