hl7_2/version.rs
1//! Which release of HL7 v2 a message speaks, and which dictionary that
2//! selects.
3//!
4//! HL7 v2 is not one format but fourteen releases that share a syntax and
5//! disagree about the details — MSH-9 grew a third component, MSH-12 turned
6//! from a plain string into a composite, ERR grew from one field to twelve.
7//! A parser that assumes one release reads the others slightly wrong, so
8//! this crate carries the release around ([`crate::Message::version`]) and
9//! looks every field type up through it.
10//!
11//! The release comes from MSH-12.1. When it is missing, unreadable, or
12//! names a release this crate has no dictionary for, resolution falls back
13//! to the nearest older known release (see [`Version::nearest`]) rather
14//! than failing: a message that says `2.5.2` is far better read as 2.5.1
15//! than not at all.
16
17use crate::dictionary::Dictionary;
18use std::sync::{Arc, OnceLock};
19
20/// A published release of HL7 v2.
21///
22/// Ordering follows release order, so `Version::V2_3 < Version::V2_3_1`,
23/// which is what makes "nearest older release" a comparison rather than a
24/// table.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26#[allow(non_camel_case_types)]
27pub enum Version {
28 /// HL7 v2.1 (1990).
29 V2_1,
30 /// HL7 v2.2 (1994).
31 V2_2,
32 /// HL7 v2.3 (1997).
33 V2_3,
34 /// HL7 v2.3.1 (1999), which introduced MSH-9.3, the message structure.
35 V2_3_1,
36 /// HL7 v2.4 (2000).
37 V2_4,
38 /// HL7 v2.5 (2003). This crate's base dictionary and its default.
39 V2_5,
40 /// HL7 v2.5.1 (2007), the release most US interfaces still speak.
41 V2_5_1,
42 /// HL7 v2.6 (2007).
43 V2_6,
44 /// HL7 v2.7 (2011).
45 V2_7,
46 /// HL7 v2.7.1 (2012).
47 V2_7_1,
48 /// HL7 v2.8 (2014).
49 V2_8,
50 /// HL7 v2.8.1 (2014).
51 V2_8_1,
52 /// HL7 v2.8.2 (2015).
53 V2_8_2,
54 /// HL7 v2.9 (2019).
55 V2_9,
56}
57
58use Version::{
59 V2_1, V2_2, V2_3, V2_3_1, V2_4, V2_5, V2_5_1, V2_6, V2_7, V2_7_1, V2_8, V2_8_1, V2_8_2, V2_9,
60};
61
62/// Every release this crate knows, in release order.
63pub const ALL: &[Version] = &[
64 V2_1, V2_2, V2_3, V2_3_1, V2_4, V2_5, V2_5_1, V2_6, V2_7, V2_7_1, V2_8, V2_8_1, V2_8_2, V2_9,
65];
66
67/// The release assumed when a message does not say and the caller does not
68/// either. v2.5 is both this crate's complete base dictionary and the
69/// release the installed base clusters around.
70pub const DEFAULT: Version = V2_5;
71
72/// The bundled dictionary files, in the same order as [`FILES`]' contents.
73/// Several releases can share one file: a point release that changed
74/// nothing this crate models does not need a dictionary of its own.
75const FILES: &[(&str, &str)] = &[
76 ("2.1", include_str!("../schemas/v2.1.json")),
77 ("2.2", include_str!("../schemas/v2.2.json")),
78 ("2.3", include_str!("../schemas/v2.3.json")),
79 ("2.3.1", include_str!("../schemas/v2.3.1.json")),
80 ("2.4", include_str!("../schemas/v2.4.json")),
81 ("2.5", include_str!("../schemas/v2.5.json")),
82 ("2.5.1", include_str!("../schemas/v2.5.1.json")),
83 ("2.6", include_str!("../schemas/v2.6.json")),
84 ("2.7", include_str!("../schemas/v2.7.json")),
85 ("2.8", include_str!("../schemas/v2.8.json")),
86 ("2.9", include_str!("../schemas/v2.9.json")),
87];
88
89/// One lazily parsed dictionary per bundled file. Parsing v2.5 takes long
90/// enough (it is the largest file) that doing it once per process, not once
91/// per message, is worth the `OnceLock`.
92static LOADED: [OnceLock<Arc<Dictionary>>; FILES.len()] = [const { OnceLock::new() }; FILES.len()];
93
94impl Version {
95 /// The release string as MSH-12.1 spells it, e.g. `"2.5.1"`.
96 #[must_use]
97 pub fn as_str(self) -> &'static str {
98 match self {
99 V2_1 => "2.1",
100 V2_2 => "2.2",
101 V2_3 => "2.3",
102 V2_3_1 => "2.3.1",
103 V2_4 => "2.4",
104 V2_5 => "2.5",
105 V2_5_1 => "2.5.1",
106 V2_6 => "2.6",
107 V2_7 => "2.7",
108 V2_7_1 => "2.7.1",
109 V2_8 => "2.8",
110 V2_8_1 => "2.8.1",
111 V2_8_2 => "2.8.2",
112 V2_9 => "2.9",
113 }
114 }
115
116 /// The release named exactly by `text`, or `None`. Use
117 /// [`Version::nearest`] when reading a real message, where a version
118 /// string this crate does not know should degrade rather than fail.
119 #[must_use]
120 pub fn parse(text: &str) -> Option<Version> {
121 let text = text.trim();
122 ALL.iter().copied().find(|v| v.as_str() == text)
123 }
124
125 /// The best release to read `text` as: the exact match if there is one,
126 /// otherwise the newest known release no newer than `text`, otherwise
127 /// (for a version older than everything, or unreadable) `None`.
128 ///
129 /// Reading 2.5.2 as 2.5.1 is right far more often than it is wrong:
130 /// point releases are additive, so the older dictionary names what it
131 /// knows and the rest degrades to generic positional names, which is
132 /// exactly the fallback the unknown-segment case already takes.
133 #[must_use]
134 pub fn nearest(text: &str) -> Option<Version> {
135 if let Some(version) = Version::parse(text) {
136 return Some(version);
137 }
138 let wanted = numeric(text)?;
139 ALL.iter()
140 .copied()
141 .rfind(|v| numeric(v.as_str()).is_some_and(|known| known <= wanted))
142 }
143
144 /// The release a parsed message declares in MSH-12.1, resolved through
145 /// [`Version::nearest`]. `None` when MSH-12 is absent or unreadable.
146 #[must_use]
147 pub fn from_message(message: &er7::Message) -> Option<Version> {
148 Version::nearest(&message.version()?)
149 }
150
151 /// The bundled dictionary for this release.
152 ///
153 /// Parsed on first use and shared thereafter; the returned `Arc` is
154 /// cheap to clone and is what a [`crate::Message`] holds.
155 /// # Panics
156 ///
157 /// Never in practice: the bundled dictionaries are embedded at compile
158 /// time and parsed on first use, so a failure here would mean this
159 /// crate shipped a malformed one.
160 pub fn dictionary(self) -> Arc<Dictionary> {
161 let index = self.file_index();
162 LOADED[index]
163 .get_or_init(|| {
164 let (name, text) = FILES[index];
165 // A bundled dictionary that does not parse is a bug in this
166 // crate, caught by `bundled_dictionaries_all_load` below,
167 // not something a caller can act on.
168 let dictionary =
169 Dictionary::from_json_resolving(text, format!("v{name}"), |base| {
170 Version::parse(base).map(Version::dictionary)
171 })
172 .unwrap_or_else(|error| {
173 panic!("bundled dictionary v{name} is invalid: {error}")
174 });
175 Arc::new(dictionary)
176 })
177 .clone()
178 }
179
180 /// Which bundled file backs this release. Point releases that changed
181 /// nothing this crate models share their base release's file.
182 fn file_index(self) -> usize {
183 let name = match self {
184 V2_7_1 => "2.7",
185 V2_8_1 | V2_8_2 => "2.8",
186 other => other.as_str(),
187 };
188 FILES
189 .iter()
190 .position(|(file, _)| *file == name)
191 .expect("every release maps to a bundled file")
192 }
193}
194
195impl Default for Version {
196 fn default() -> Version {
197 DEFAULT
198 }
199}
200
201impl std::fmt::Display for Version {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.write_str(self.as_str())
204 }
205}
206
207impl std::str::FromStr for Version {
208 type Err = UnknownVersion;
209
210 fn from_str(text: &str) -> Result<Version, UnknownVersion> {
211 Version::parse(text).ok_or_else(|| UnknownVersion(text.to_string()))
212 }
213}
214
215/// The error from `"2.4.7".parse::<Version>()`: a version string that is
216/// not one of the releases this crate knows.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct UnknownVersion(pub String);
219
220impl std::fmt::Display for UnknownVersion {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 write!(
223 f,
224 "unknown HL7 version {:?}; known versions are {}",
225 self.0,
226 ALL.iter()
227 .map(|v| v.as_str())
228 .collect::<Vec<&str>>()
229 .join(", ")
230 )
231 }
232}
233
234impl std::error::Error for UnknownVersion {}
235
236/// A dotted version as comparable numbers: `"2.5.1"` becomes `[2, 5, 1]`,
237/// padded so `2.5` and `2.5.1` compare as `[2,5,0]` and `[2,5,1]`.
238/// `None` when the text does not begin with a number.
239fn numeric(text: &str) -> Option<[u32; 3]> {
240 let mut parts = [0u32; 3];
241 let mut any = false;
242 for (slot, part) in parts.iter_mut().zip(text.trim().split('.')) {
243 let digits: String = part.chars().take_while(char::is_ascii_digit).collect();
244 if digits.is_empty() {
245 break;
246 }
247 *slot = digits.parse().ok()?;
248 any = true;
249 }
250 any.then_some(parts)
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn round_trips_every_release_string() {
259 for &version in ALL {
260 assert_eq!(Version::parse(version.as_str()), Some(version));
261 assert_eq!(version.as_str().parse::<Version>(), Ok(version));
262 }
263 assert_eq!(Version::parse(" 2.5.1 "), Some(V2_5_1));
264 assert!("2.5.2".parse::<Version>().is_err());
265 }
266
267 #[test]
268 fn falls_back_to_the_nearest_older_release() {
269 // Point releases this crate does not model read as their base.
270 assert_eq!(Version::nearest("2.5.2"), Some(V2_5_1));
271 assert_eq!(Version::nearest("2.4.1"), Some(V2_4));
272 // A release newer than anything known reads as the newest known.
273 assert_eq!(Version::nearest("3.0"), Some(V2_9));
274 // A release older than anything known has no sensible answer.
275 assert_eq!(Version::nearest("2.0"), None);
276 assert_eq!(Version::nearest("HL7"), None);
277 assert_eq!(Version::nearest(""), None);
278 }
279
280 #[test]
281 fn reads_the_release_out_of_msh_12() {
282 let message = er7::parse("MSH|^~\\&|A||||1||ACK|1|P|2.3.1\rMSA|AA|1").unwrap();
283 assert_eq!(Version::from_message(&message), Some(V2_3_1));
284 let message = er7::parse("MSH|^~\\&|A||||1||ACK|1|P|\rMSA|AA|1").unwrap();
285 assert_eq!(Version::from_message(&message), None);
286 }
287
288 #[test]
289 fn bundled_dictionaries_all_load() {
290 // Every release resolves to a dictionary that knows MSH, whether it
291 // has a file of its own or shares its base release's.
292 for &version in ALL {
293 let dictionary = version.dictionary();
294 assert!(
295 dictionary.segment_fields("MSH").is_some(),
296 "v{version} has no MSH"
297 );
298 }
299 }
300}