Skip to main content

hl7_2/
validate.rs

1//! Checking a message against the dictionary it claims to speak.
2//!
3//! The sibling conversion crates are explicitly not validators, and this
4//! crate does not become one by accident: parsing stays fallback-first, and
5//! [`crate::Message::validate`] is a separate call that reports and never
6//! refuses. What makes it worth having here is that schema mode already
7//! requires the caller to state the shape of their messages — once that
8//! shape exists, "does this message match it?" is a question with an
9//! answer, and an ingest pipeline that wants the answer as a hard failure
10//! can ask for it with [`crate::Options::strict`].
11//!
12//! The two severities divide along whose problem it is:
13//!
14//! - [`Severity::Error`] — the message contradicts the dictionary it
15//!   claims: a required segment is missing, the segments do not fit the
16//!   structure, a numeric field holds letters. Strict mode rejects these.
17//! - [`Severity::Warning`] — the dictionary does not cover the message: an
18//!   unknown segment, a field past the end of the table, a structure this
19//!   crate has no grammar for. These are usually a local extension or a
20//!   coverage gap, not a malformed message, so strict mode allows them.
21
22use crate::Message;
23use crate::dictionary::{Dictionary, Item, VARIABLE};
24use er7::{Segment, Separators};
25use std::fmt;
26
27/// How much a diagnostic matters; see the module documentation.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub enum Severity {
30    /// The message contradicts its dictionary. Strict mode rejects these.
31    Error,
32    /// The dictionary does not describe part of the message.
33    Warning,
34}
35
36impl fmt::Display for Severity {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(match self {
39            Severity::Error => "error",
40            Severity::Warning => "warning",
41        })
42    }
43}
44
45/// What kind of problem a diagnostic reports, for callers that route on it
46/// rather than reading the text.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Kind {
49    /// A header field a message cannot do without is empty.
50    Header,
51    /// The dictionary has no grammar for this message structure.
52    StructureUnknown,
53    /// The segments do not fit the structure's grammar.
54    StructureMismatch,
55    /// A segment the structure requires is absent.
56    SegmentMissing,
57    /// A segment the dictionary does not define.
58    SegmentUnknown,
59    /// A field beyond the end of the segment's definition.
60    FieldUnknown,
61    /// A component beyond the end of the data type's definition.
62    ComponentUnknown,
63    /// A value that does not match its data type's format.
64    ValueFormat,
65}
66
67/// One finding: what, where, and how much it matters.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Diagnostic {
70    /// How much this matters.
71    pub severity: Severity,
72    /// What kind of problem this is.
73    pub kind: Kind,
74    /// Where it is, as an `er7` path — `OBX[2]-5[1].1` — or a segment name,
75    /// or empty for a whole-message finding.
76    pub path: String,
77    /// What is wrong, in a sentence.
78    pub detail: String,
79}
80
81impl fmt::Display for Diagnostic {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        if self.path.is_empty() {
84            write!(f, "{}: {}", self.severity, self.detail)
85        } else {
86            write!(f, "{}: {}: {}", self.severity, self.path, self.detail)
87        }
88    }
89}
90
91impl Diagnostic {
92    fn error(kind: Kind, path: impl Into<String>, detail: impl Into<String>) -> Diagnostic {
93        Diagnostic {
94            severity: Severity::Error,
95            kind,
96            path: path.into(),
97            detail: detail.into(),
98        }
99    }
100
101    fn warning(kind: Kind, path: impl Into<String>, detail: impl Into<String>) -> Diagnostic {
102        Diagnostic {
103            severity: Severity::Warning,
104            kind,
105            path: path.into(),
106            detail: detail.into(),
107        }
108    }
109}
110
111/// Check `message` against its dictionary. See [`crate::Message::validate`].
112#[must_use]
113pub fn validate(message: &Message) -> Vec<Diagnostic> {
114    let dictionary = message.dictionary();
115    let mut found = Vec::new();
116    header(message, &mut found);
117    structure(message, dictionary, &mut found);
118    let mut occurrences: std::collections::BTreeMap<&str, usize> =
119        std::collections::BTreeMap::default();
120    for segment in message.segments() {
121        let occurrence = occurrences.entry(segment.name.as_str()).or_default();
122        *occurrence += 1;
123        check_segment(
124            segment,
125            *occurrence,
126            dictionary,
127            message.separators(),
128            &mut found,
129        );
130    }
131    found
132}
133
134/// The header fields a receiver cannot route without.
135fn header(message: &Message, found: &mut Vec<Diagnostic>) {
136    for (path, what) in [
137        ("MSH-9.1", "the message type"),
138        ("MSH-10", "the message control ID"),
139    ] {
140        let empty = message
141            .get(path)
142            .ok()
143            .flatten()
144            .is_none_or(|value| value.trim().is_empty());
145        if empty {
146            found.push(Diagnostic::error(
147                Kind::Header,
148                path,
149                format!("{path} ({what}) is empty"),
150            ));
151        }
152    }
153    let declared = message.get("MSH-12.1").ok().flatten().unwrap_or_default();
154    if declared.trim().is_empty() {
155        found.push(Diagnostic::warning(
156            Kind::Header,
157            "MSH-12",
158            format!(
159                "MSH-12 (version ID) is empty; reading the message as v{}",
160                message.version()
161            ),
162        ));
163    } else if crate::Version::parse(declared.trim()).is_none() {
164        found.push(Diagnostic::warning(
165            Kind::Header,
166            "MSH-12",
167            format!(
168                "MSH-12 declares version {declared:?}, which this crate has no dictionary for; \
169                 reading the message as v{}",
170                message.version()
171            ),
172        ));
173    }
174}
175
176/// The message structure: is it known, and do the segments fit it?
177fn structure(message: &Message, dictionary: &Dictionary, found: &mut Vec<Diagnostic>) {
178    let id = message.structure_id();
179    let Some(items) = dictionary.structure(&id) else {
180        found.push(Diagnostic::warning(
181            Kind::StructureUnknown,
182            "MSH-9",
183            format!(
184                "dictionary {} has no grammar for message structure {id}; \
185                 segments are read flat and their order is not checked",
186                dictionary.name()
187            ),
188        ));
189        return;
190    };
191    // Name the specific missing segments before falling back to the general
192    // "does not fit": "MSA is missing" is actionable where "does not match
193    // the ACK structure" is a puzzle.
194    let mut missing = false;
195    for item in items {
196        if item.required() && !starts_present(item, message) {
197            missing = true;
198            found.push(Diagnostic::error(
199                Kind::SegmentMissing,
200                item.name(),
201                match item {
202                    Item::Segment { name, .. } => {
203                        format!("structure {id} requires a {name} segment")
204                    }
205                    Item::Group { name, .. } => {
206                        format!("structure {id} requires the {name} group")
207                    }
208                },
209            ));
210        }
211    }
212    if !missing && message.layout().is_none() {
213        // A local Z-segment is a legal extension that no standard structure
214        // describes, and most real interfaces carry one. If the standard
215        // segments fit on their own, the message is conformant and only the
216        // grouping suffers, so say that instead of rejecting it.
217        let standard: Vec<&str> = message
218            .segments()
219            .map(|segment| segment.name.as_str())
220            .filter(|name| !name.starts_with('Z'))
221            .collect();
222        let extensions = standard.len() < message.segments().count();
223        if extensions && crate::structure::group(items, &standard).is_some() {
224            found.push(Diagnostic::warning(
225                Kind::StructureMismatch,
226                "",
227                format!(
228                    "the standard segments fit structure {id}, but the message also carries \
229                     local Z-segments, which no structure describes; segments are read flat"
230                ),
231            ));
232        } else {
233            found.push(Diagnostic::error(
234                Kind::StructureMismatch,
235                "",
236                format!(
237                    "the segments do not fit structure {id}: an unexpected segment, or one out \
238                     of order, or one repeated where the structure does not allow it"
239                ),
240            ));
241        }
242    }
243}
244
245/// Is any segment that could begin `item` present in the message at all?
246fn starts_present(item: &Item, message: &Message) -> bool {
247    message
248        .segments()
249        .any(|segment| item.can_start(&segment.name))
250}
251
252/// One segment's fields, components, and values.
253fn check_segment(
254    segment: &Segment,
255    occurrence: usize,
256    dictionary: &Dictionary,
257    separators: &Separators,
258    found: &mut Vec<Diagnostic>,
259) {
260    let base = format!("{}[{occurrence}]", segment.name);
261    let Some(fields) = dictionary.segment_fields(&segment.name) else {
262        // A Z-segment is a local extension by definition — the standard
263        // says nothing about it, so neither does this.
264        if !segment.name.starts_with('Z') {
265            found.push(Diagnostic::warning(
266                Kind::SegmentUnknown,
267                &base,
268                format!(
269                    "dictionary {} does not define segment {}",
270                    dictionary.name(),
271                    segment.name
272                ),
273            ));
274        }
275        return;
276    };
277    let variable = dictionary.variable_type(segment).map(str::to_string);
278    let defined = fields.len();
279    for (index, field) in segment.fields.iter().enumerate() {
280        if field.is_empty() {
281            continue;
282        }
283        let number = index + 1;
284        if number > defined {
285            found.push(Diagnostic::warning(
286                Kind::FieldUnknown,
287                format!("{base}-{number}"),
288                format!(
289                    "{}-{number} is past the {defined} fields dictionary {} defines for {}",
290                    segment.name,
291                    dictionary.name(),
292                    segment.name
293                ),
294            ));
295            continue;
296        }
297        let data_type = match dictionary.field_type(&segment.name, number) {
298            Some(VARIABLE) => variable.as_deref(),
299            other => other,
300        };
301        let Some(data_type) = data_type else {
302            continue;
303        };
304        for (repeat, repetition) in field.repetitions.iter().enumerate() {
305            if repetition.is_empty() || repetition.is_null() {
306                continue;
307            }
308            let path = format!("{base}-{number}[{}]", repeat + 1);
309            match dictionary.composite_components(data_type) {
310                None => check_value(data_type, &repetition.to_text(separators), &path, found),
311                Some(components) => {
312                    for (index, component) in repetition.components.iter().enumerate() {
313                        if component.is_empty() || component.is_null() {
314                            continue;
315                        }
316                        let path = format!("{path}.{}", index + 1);
317                        let Some(component_type) = components.get(index) else {
318                            found.push(Diagnostic::warning(
319                                Kind::ComponentUnknown,
320                                &path,
321                                format!(
322                                    "component {} is past the {} components dictionary {} \
323                                     defines for {data_type}",
324                                    index + 1,
325                                    components.len(),
326                                    dictionary.name()
327                                ),
328                            ));
329                            continue;
330                        };
331                        // HL7 nests composites one level: a component's own
332                        // type may be composite, a subcomponent's is not.
333                        match dictionary.composite_components(component_type) {
334                            None => check_value(
335                                component_type,
336                                &component.to_text(separators),
337                                &path,
338                                found,
339                            ),
340                            Some(subtypes) => {
341                                for (index, subcomponent) in
342                                    component.subcomponents.iter().enumerate()
343                                {
344                                    if subcomponent.is_empty() || subcomponent.is_null() {
345                                        continue;
346                                    }
347                                    if let Some(subtype) = subtypes.get(index) {
348                                        check_value(
349                                            subtype,
350                                            &subcomponent.value(separators),
351                                            &format!("{path}.{}", index + 1),
352                                            found,
353                                        );
354                                    }
355                                }
356                            }
357                        }
358                    }
359                }
360            }
361        }
362    }
363}
364
365/// Does `text` look like a `data_type` value?
366///
367/// Only the types with a machine-checkable shape are checked. `ST`, `TX`,
368/// `ID`, `IS` and the rest are constrained by HL7 tables and by length,
369/// neither of which this crate models, so it says nothing about them rather
370/// than guessing.
371fn check_value(data_type: &str, text: &str, path: &str, found: &mut Vec<Diagnostic>) {
372    let value = text.trim();
373    if value.is_empty() {
374        return;
375    }
376    let ok = match data_type {
377        "SI" => value.bytes().all(|b| b.is_ascii_digit()),
378        "NM" => is_number(value),
379        "DT" => is_date(value),
380        "TM" => is_time(value),
381        "DTM" => is_datetime(value),
382        _ => return,
383    };
384    if !ok {
385        found.push(Diagnostic::error(
386            Kind::ValueFormat,
387            path,
388            format!("{value:?} is not a valid {data_type} value"),
389        ));
390    }
391}
392
393/// `NM`: an optional sign, digits, an optional fractional part.
394fn is_number(value: &str) -> bool {
395    let digits = value.strip_prefix(['+', '-']).unwrap_or(value);
396    let (whole, fraction) = match digits.split_once('.') {
397        Some((whole, fraction)) => (whole, Some(fraction)),
398        None => (digits, None),
399    };
400    !whole.is_empty()
401        && whole.bytes().all(|b| b.is_ascii_digit())
402        && fraction.is_none_or(|f| f.bytes().all(|b| b.is_ascii_digit()))
403}
404
405/// `DT`: `YYYY`, `YYYYMM`, or `YYYYMMDD`.
406fn is_date(value: &str) -> bool {
407    matches!(value.len(), 4 | 6 | 8) && value.bytes().all(|b| b.is_ascii_digit())
408}
409
410/// `TM`: `HH[MM[SS[.S[S[S[S]]]]]]` with an optional `+/-ZZZZ` offset.
411fn is_time(value: &str) -> bool {
412    let (value, offset) = split_offset(value);
413    if !offset {
414        return false;
415    }
416    let (whole, fraction) = match value.split_once('.') {
417        Some((whole, fraction)) => (whole, Some(fraction)),
418        None => (value, None),
419    };
420    matches!(whole.len(), 2 | 4 | 6)
421        && whole.bytes().all(|b| b.is_ascii_digit())
422        && fraction
423            .is_none_or(|f| (1..=4).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()))
424}
425
426/// `DTM`: a date, then optionally a time, then optionally an offset.
427fn is_datetime(value: &str) -> bool {
428    let (value, offset) = split_offset(value);
429    if !offset {
430        return false;
431    }
432    let (whole, fraction) = match value.split_once('.') {
433        Some((whole, fraction)) => (whole, Some(fraction)),
434        None => (value, None),
435    };
436    matches!(whole.len(), 4 | 6 | 8 | 10 | 12 | 14)
437        && whole.bytes().all(|b| b.is_ascii_digit())
438        && fraction
439            .is_none_or(|f| (1..=4).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()))
440}
441
442/// Split a trailing `+ZZZZ` / `-ZZZZ` offset off a time, reporting whether
443/// what was there (if anything) was well formed.
444fn split_offset(value: &str) -> (&str, bool) {
445    match value.rfind(['+', '-']) {
446        Some(index) if index > 0 => {
447            let offset = &value[index + 1..];
448            (
449                &value[..index],
450                offset.len() == 4 && offset.bytes().all(|b| b.is_ascii_digit()),
451            )
452        }
453        _ => (value, true),
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    fn diagnostics(text: &str) -> Vec<Diagnostic> {
462        crate::parse(text).unwrap().validate()
463    }
464
465    fn kinds(text: &str) -> Vec<Kind> {
466        diagnostics(text).into_iter().map(|d| d.kind).collect()
467    }
468
469    const ACK: &str = "MSH|^~\\&|A||||20240101||ACK^A01|1|P|2.5\rMSA|AA|1";
470
471    #[test]
472    fn a_conforming_message_reports_nothing() {
473        assert_eq!(diagnostics(ACK), []);
474    }
475
476    #[test]
477    fn names_the_missing_required_segment() {
478        let found = diagnostics("MSH|^~\\&|A||||20240101||ACK^A01|1|P|2.5");
479        assert_eq!(found.len(), 1, "{found:?}");
480        assert_eq!(found[0].kind, Kind::SegmentMissing);
481        assert_eq!(found[0].severity, Severity::Error);
482        assert!(found[0].detail.contains("MSA"), "{}", found[0]);
483    }
484
485    #[test]
486    fn reports_segments_out_of_order_as_a_mismatch() {
487        let found = diagnostics("MSH|^~\\&|A||||20240101||ACK^A01|1|P|2.5\rERR|x\rMSA|AA|1");
488        assert!(
489            found.iter().any(|d| d.kind == Kind::StructureMismatch),
490            "{found:?}"
491        );
492    }
493
494    #[test]
495    fn unknown_segments_and_fields_are_warnings_not_errors() {
496        let found = diagnostics(&format!("{ACK}\rZPD|anything"));
497        // A Z-segment is a local extension; nothing to say about it.
498        assert_eq!(
499            found
500                .iter()
501                .filter(|d| d.kind == Kind::SegmentUnknown)
502                .count(),
503            0
504        );
505        // But it does break the ACK structure, which is an error.
506        assert!(found.iter().any(|d| d.kind == Kind::StructureMismatch));
507
508        let found = diagnostics("MSH|^~\\&|A||||20240101||ACK^A01|1|P|2.5\rMSA|AA|1|||||||x");
509        let past_end: Vec<&Diagnostic> = found
510            .iter()
511            .filter(|d| d.kind == Kind::FieldUnknown)
512            .collect();
513        assert_eq!(past_end.len(), 1, "{found:?}");
514        assert_eq!(past_end[0].severity, Severity::Warning);
515        assert_eq!(past_end[0].path, "MSA[1]-9");
516    }
517
518    #[test]
519    fn checks_the_formats_that_have_one() {
520        let found = diagnostics("MSH|^~\\&|A||||NOT-A-DATE||ACK^A01|1|P|2.5\rMSA|AA|1||x");
521        let formats: Vec<&Diagnostic> = found
522            .iter()
523            .filter(|d| d.kind == Kind::ValueFormat)
524            .collect();
525        // MSH-7 is a TS whose first component is a DTM, and MSA-4 is an NM.
526        assert_eq!(formats.len(), 2, "{found:?}");
527        assert!(formats.iter().all(|d| d.severity == Severity::Error));
528        assert_eq!(formats[0].path, "MSH[1]-7[1].1");
529        assert_eq!(formats[1].path, "MSA[1]-4[1]");
530    }
531
532    #[test]
533    fn accepts_the_datetime_shapes_hl7_allows() {
534        assert!(is_datetime("2024"));
535        assert!(is_datetime("20240101"));
536        assert!(is_datetime("20240101093851"));
537        assert!(is_datetime("20240101093851.1234"));
538        assert!(is_datetime("20240101093851+0100"));
539        assert!(is_datetime("20240101093851.5-0500"));
540        assert!(!is_datetime("2024010"));
541        assert!(!is_datetime("2024-01-01"));
542        assert!(!is_datetime("20240101093851+01"));
543        assert!(is_time("0938"));
544        assert!(is_time("093851.25+0100"));
545        assert!(!is_time("9:38"));
546        assert!(is_number("-7.25"));
547        assert!(!is_number("7,25"));
548        assert!(is_date("202401"));
549        assert!(!is_date("20240"));
550    }
551
552    #[test]
553    fn an_unknown_structure_is_a_warning_about_the_dictionary() {
554        let found = diagnostics("MSH|^~\\&|A||||20240101||ZZZ^Z01|1|P|2.5");
555        assert_eq!(
556            kinds("MSH|^~\\&|A||||20240101||ZZZ^Z01|1|P|2.5"),
557            [Kind::StructureUnknown]
558        );
559        assert_eq!(found[0].severity, Severity::Warning);
560    }
561
562    #[test]
563    fn an_unmodelled_version_is_a_warning_and_the_message_still_reads() {
564        let found = diagnostics("MSH|^~\\&|A||||20240101||ACK^A01|1|P|2.5.2\rMSA|AA|1");
565        assert_eq!(found.len(), 1, "{found:?}");
566        assert_eq!(found[0].kind, Kind::Header);
567        assert!(found[0].detail.contains("2.5.1"), "{}", found[0]);
568    }
569
570    #[test]
571    fn an_empty_control_id_is_an_error() {
572        let found = diagnostics("MSH|^~\\&|A||||20240101||ACK^A01||P|2.5\rMSA|AA|1");
573        assert_eq!(found[0].kind, Kind::Header);
574        assert_eq!(found[0].severity, Severity::Error);
575        assert_eq!(found[0].path, "MSH-10");
576    }
577}