Skip to main content

hl7probe/
lib.rs

1//! Read and check HL7 v2 messages.
2//!
3//! A message is decomposed into named fields and checked against the HL7
4//! dictionary: required fields, data types, code tables and cross-field
5//! consistency. Parsing borrows the text it was given rather than copying it,
6//! so a message costs a few hundred bytes on top of the string it came from.
7//!
8//! ```
9//! let text = "MSH|^~\\&|HIS|MERCY|LIS|LAB|20240115143200||ADT^A01|MSG1|P|2.5.1\r\
10//!             PID|1||123456^^^MERCY^MR||Smith^John||19850312|M\r";
11//!
12//! let message = hl7probe::parse(text)?;
13//! assert_eq!(message.version(), "2.5.1");
14//! assert_eq!(message.type_label(), "ADT^A01");
15//!
16//! // PID-5.1 is the patient's family name.
17//! let pid = message.first("PID").expect("the message has a PID");
18//! assert_eq!(pid.comp(5, 1), "Smith");
19//!
20//! let report = hl7probe::validate(&message);
21//! println!("{} errors, {} warnings", report.errors(), report.warnings());
22//! # Ok::<(), hl7probe::ParseError>(())
23//! ```
24//!
25//! Messages borrow the text they were read from, so keep it alive for as long
26//! as the [`Message`]. Use [`parser::split_messages`] and
27//! [`parser::parse_message`] directly to walk a file holding several messages.
28
29pub mod datetime;
30pub mod parser;
31pub mod spec;
32pub mod validate;
33
34// The command line's own presentation, not part of the library API.
35mod render;
36mod text;
37mod tui;
38mod view;
39
40#[doc(hidden)]
41pub mod cli;
42
43pub use parser::{Component, Field, Message, ParseError, Repetition, Segment, Separators};
44pub use validate::{Category, Finding, Report, Severity};
45
46/// Parses the first message in `text`.
47///
48/// Leading batch wrappers and MLLP framing bytes are skipped, and CR, LF or
49/// CRLF line endings are all accepted. For a file holding several messages,
50/// use [`parser::split_messages`].
51///
52/// # Errors
53///
54/// Returns [`ParseError`] when `text` holds no MSH segment, or when the first
55/// message's MSH does not declare a usable set of delimiters.
56pub fn parse(text: &str) -> Result<Message<'_>, ParseError> {
57    let (raws, _notes) = parser::split_messages(text);
58    let Some(first) = raws.first() else {
59        return Err(ParseError::no_message());
60    };
61    parser::parse_message(first)
62}
63
64/// Checks a parsed message against the HL7 dictionary.
65#[must_use]
66pub fn validate(message: &Message<'_>) -> Report {
67    validate::validate(message)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::{parse, validate, ParseError, Severity};
73
74    const ADT: &str = "MSH|^~\\&|HIS|MERCY|LIS|LAB|20240115143200||ADT^A01|MSG1|P|2.5.1\r\
75                       EVN|A01|20240115143200\r\
76                       PID|1||123456^^^MERCY^MR||Smith^John||19850312|M\r\
77                       PV1|1|I|ER^101^A\r";
78
79    #[test]
80    fn parse_reads_the_first_message() {
81        let message = parse(ADT).expect("the fixture parses");
82        assert_eq!(message.version(), "2.5.1");
83        assert_eq!(message.type_label(), "ADT^A01");
84        assert_eq!(message.control_id(), "MSG1");
85        assert_eq!(message.segments.len(), 4);
86        assert_eq!(
87            message.first("PID").expect("PID is present").comp(5, 1),
88            "Smith"
89        );
90    }
91
92    #[test]
93    fn parse_takes_the_first_of_several() {
94        let batch = format!("{ADT}{}", ADT.replace("MSG1", "MSG2"));
95        assert_eq!(parse(&batch).expect("parses").control_id(), "MSG1");
96    }
97
98    #[test]
99    fn parse_rejects_text_that_holds_no_message() {
100        for text in ["", "not hl7 at all", "PID|1||x\r"] {
101            let error = parse(text).expect_err("there is no message here");
102            assert!(error.to_string().contains("MSH"), "{error}");
103        }
104    }
105
106    #[test]
107    fn parse_rejects_a_message_whose_delimiters_are_unusable() {
108        let error = parse("MSHzzzz\rPID|1\r").expect_err("z cannot be a separator");
109        assert!(error.to_string().contains("separator"), "{error}");
110    }
111
112    /// The error type carries a line number and composes with `?`.
113    #[test]
114    fn parse_errors_are_std_errors() {
115        fn read(text: &str) -> Result<String, Box<dyn std::error::Error>> {
116            Ok(parse(text)?.type_label())
117        }
118        assert_eq!(read(ADT).expect("ok"), "ADT^A01");
119        assert!(read("nothing").is_err());
120
121        let error: ParseError = parse("MSHzzzz\r").expect_err("unusable");
122        assert_eq!(error.line, 1);
123        assert!(!error.message.is_empty());
124    }
125
126    #[test]
127    fn validate_reports_on_a_parsed_message() {
128        let clean = validate(&parse(ADT).expect("parses"));
129        assert_eq!(clean.errors(), 0, "{:?}", clean.findings);
130
131        // A date that does not exist, and no PV1 at all.
132        let broken = ADT
133            .replace("19850312", "19850332")
134            .replace("PV1|1|I|ER^101^A\r", "");
135        let report = validate(&parse(&broken).expect("still parses"));
136        assert!(report.errors() > 0);
137        assert_eq!(report.worst(), Some(Severity::Error));
138        assert!(
139            report
140                .findings
141                .iter()
142                .any(|f| f.location == "PID-7" && f.severity == Severity::Error),
143            "{:?}",
144            report.findings
145        );
146    }
147}