hl7_2_from_er7_into_json/lib.rs
1//! Convert HL7 v2.5 messages from ER7 (pipe-delimited) encoding to a typed
2//! JSON representation.
3//!
4//! This is the JSON sibling of `hl7-2-from-er7-into-xml`: both read ER7
5//! through the [`er7`] crate and use the same HL7 v2.5 data-type tables to
6//! name output keys, but this one renders JSON instead of the official
7//! v2.xml XML. See `spec/index.md` for the exact mapping rules (source of
8//! truth).
9//!
10//! The ER7 encoding itself — parsing, delimiters, escape sequences — comes
11//! from [`er7`]. This crate adds the layer above it: the v2.5 data-type
12//! tables, the message-structure grammars, and the JSON renderer.
13//!
14//! ```
15//! let er7 = "MSH|^~\\&|hphis||EPIC||20131011093851||ORM^O01|14AAACVDD|P|2.5\r\
16//! PID|1||241900||MEDIANO^FOUAZ\r\
17//! ORC|NW|ORD1";
18//! let json = hl7_2_from_er7_into_json::convert(er7).unwrap();
19//! assert!(json.contains("\"ORM_O01\""));
20//! assert!(json.contains("\"XPN.1\""));
21//! ```
22
23#![warn(missing_docs, clippy::pedantic)]
24
25pub mod json;
26pub mod structure;
27pub mod types;
28
29/// The ER7 encoding layer this crate is built on, re-exported so callers can
30/// name [`er7::Message`], [`er7::Separators`], and the rest without adding
31/// their own dependency.
32///
33/// Until version 0.2.0 this was a module inside this crate. It is now the
34/// standalone `er7` crate, which owns the encoding and guarantees a
35/// byte-for-byte round trip; the type names changed slightly in the move
36/// (`Segment::id` is now `Segment::name`, `Repeat` is now
37/// [`er7::Repetition`], and subcomponents are [`er7::Subcomponent`] values
38/// that decode on demand rather than pre-decoded `String`s).
39pub use er7;
40
41use std::fmt;
42
43/// Errors that can occur while turning ER7 text into a [`er7::Message`]
44/// or, in turn, into JSON.
45///
46/// Parsing is deliberately lenient below the MSH header: unknown segments,
47/// unknown data types, and structure mismatches never produce an error,
48/// they degrade to generic key names or a flat rendering (see the crate
49/// docs and `spec/index.md`). Only a message that has no usable MSH header
50/// fails.
51///
52/// This is a distinct type from [`er7::Error`] rather than a re-export,
53/// because the `er7` crate can also report a malformed HL7 path — something
54/// this crate never asks it for. Converting is automatic via `?`.
55#[derive(Debug)]
56pub enum Hl7Error {
57 /// Input contained no segments.
58 Empty,
59 /// The first segment is not MSH.
60 MissingMsh,
61 /// The MSH header line is malformed.
62 BadMshHeader(String),
63}
64
65impl fmt::Display for Hl7Error {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Hl7Error::Empty => write!(f, "input contains no HL7 segments"),
69 Hl7Error::MissingMsh => write!(f, "message does not start with an MSH segment"),
70 Hl7Error::BadMshHeader(detail) => write!(f, "malformed MSH header: {detail}"),
71 }
72 }
73}
74
75impl std::error::Error for Hl7Error {}
76
77impl From<er7::Error> for Hl7Error {
78 /// Map an `er7` parse failure onto this crate's narrower error type.
79 ///
80 /// `er7::Error::BadPath` cannot arise here: it comes from the path-query
81 /// API, and this crate reads the message through its own accessors. It
82 /// is mapped to [`Hl7Error::BadMshHeader`] so the detail survives rather
83 /// than being swallowed by a panic that could never fire.
84 fn from(error: er7::Error) -> Hl7Error {
85 match error {
86 er7::Error::Empty => Hl7Error::Empty,
87 er7::Error::MissingHeader(_) => Hl7Error::MissingMsh,
88 er7::Error::BadHeader(detail) | er7::Error::BadPath(detail) => {
89 Hl7Error::BadMshHeader(detail)
90 }
91 }
92 }
93}
94
95/// Options controlling how a message is converted; see [`convert_with_options`].
96#[derive(Debug, Clone, Copy, Default)]
97pub struct Options {
98 /// Always emit segments flat under the root object, never grouped into
99 /// message-structure groups such as `ORM_O01.PATIENT`.
100 pub flat: bool,
101 /// Emit compact (single-line, no insignificant whitespace) JSON instead
102 /// of the default two-space-indented pretty printing.
103 pub compact: bool,
104}
105
106/// Convert one ER7 message to JSON with default options.
107/// # Errors
108///
109/// [`Hl7Error`] when the input has no usable MSH header: no segments at
110/// all, a first segment that is not MSH, or a header whose delimiters
111/// cannot be read. Everything below the header degrades rather than
112/// failing.
113pub fn convert(er7_text: &str) -> Result<String, Hl7Error> {
114 convert_with_options(er7_text, Options::default())
115}
116
117/// Convert one ER7 message to JSON.
118/// # Errors
119///
120/// [`Hl7Error`] when the input has no usable MSH header: no segments at
121/// all, a first segment that is not MSH, or a header whose delimiters
122/// cannot be read. Everything below the header degrades rather than
123/// failing.
124pub fn convert_with_options(er7_text: &str, options: Options) -> Result<String, Hl7Error> {
125 let message = er7::parse(&normalize(er7_text))?;
126 let root_name = root_name(&message);
127 let separators = &message.separators;
128 let seg_nodes: Vec<(String, json::Node)> = message
129 .segments
130 .iter()
131 .map(|s| (s.name.clone(), json::segment_to_node(s, separators)))
132 .collect();
133 let grouped = if options.flat {
134 None
135 } else {
136 structure::structure_for(&root_name)
137 .and_then(|items| structure::group_segments(&root_name, items, &seg_nodes))
138 };
139 let top_level: Vec<json::Node> =
140 grouped.unwrap_or_else(|| seg_nodes.into_iter().map(|(_, n)| n).collect());
141 let root = json::Value::Object(vec![(root_name, json::nodes_to_object(&top_level))]);
142 Ok(if options.compact {
143 json::render_compact(&root)
144 } else {
145 json::render_pretty(&root)
146 })
147}
148
149/// Tidy input into the shape `spec/index.md` §2.1 describes, before handing
150/// it to the `er7` parser: split on either terminator, trim each line, drop
151/// blank ones, and rejoin with `\r`.
152///
153/// `er7` deliberately trims nothing, because it guarantees that a message it
154/// parses can be written back byte for byte and it cannot know whether a
155/// trailing space is data (`er7` spec §4.1). This crate makes no such
156/// promise — it renders JSON, where stray whitespace around a segment is
157/// noise, and where an indented first line would otherwise turn a readable
158/// message into a `MissingMsh` error. So the trimming this crate has always
159/// documented happens here instead.
160fn normalize(text: &str) -> String {
161 text.trim_start_matches('\u{feff}')
162 .split(['\r', '\n'])
163 .map(str::trim)
164 .filter(|line| !line.is_empty())
165 .collect::<Vec<&str>>()
166 .join("\r")
167}
168
169/// Split input that may hold several messages (or an HL7 batch file) into
170/// individual ER7 messages, one per MSH segment. Batch envelope segments
171/// (FHS, BHS, BTS, FTS) are dropped.
172pub fn split_messages(text: &str) -> Vec<String> {
173 // Normalize first: `er7::split_messages` identifies a segment by its
174 // leading run of letters and digits, so a line indented for readability
175 // would not be recognized as the `MSH` that starts a message.
176 let normalized = normalize(text);
177 er7::split_messages(&normalized)
178 .into_iter()
179 .map(str::to_string)
180 .collect()
181}
182
183/// Derive the message structure ID (and root object key) from MSH-9:
184/// MSH-9.3 when present, otherwise `MSG.1_MSG.2` with the trigger-event
185/// aliases resolved for the structures this crate knows about.
186fn root_name(message: &er7::Message) -> String {
187 if let Some(structure_id) = message.message_structure() {
188 return structure_id;
189 }
190 let code = message.message_code().unwrap_or_default();
191 let trigger = message.trigger_event().unwrap_or_default();
192 match (code.as_str(), trigger.as_str()) {
193 ("", _) => "HL7Message".to_string(),
194 ("ACK", _) => "ACK".to_string(),
195 ("ADT", "A01" | "A04" | "A08" | "A13") => "ADT_A01".to_string(),
196 (code, "") => code.to_string(),
197 (code, trigger) => format!("{code}_{trigger}"),
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn normalizes_before_parsing() {
207 // The crate's own §2.1: any terminator, trimmed lines, no blanks.
208 assert_eq!(normalize("MSH|A\r\n\r\n PID|1 \n"), "MSH|A\rPID|1");
209 assert_eq!(normalize("\u{feff}MSH|A"), "MSH|A");
210 // Which means an indented message still converts, where the `er7`
211 // parser alone would report a missing header.
212 assert!(convert(" MSH|^~\\&|APP||||1||ACK|1|P|2.5\r MSA|AA|1").is_ok());
213 }
214
215 #[test]
216 fn maps_er7_errors_onto_this_crates_type() {
217 assert!(matches!(convert(""), Err(Hl7Error::Empty)));
218 assert!(matches!(convert("PID|1"), Err(Hl7Error::MissingMsh)));
219 match convert("MSH") {
220 Err(Hl7Error::BadMshHeader(detail)) => assert!(!detail.is_empty()),
221 other => panic!("expected a bad-header error, got {other:?}"),
222 }
223 }
224}