Skip to main content

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}