1use crate::Message;
23use crate::dictionary::{Dictionary, Item, VARIABLE};
24use er7::{Segment, Separators};
25use std::fmt;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub enum Severity {
30 Error,
32 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Kind {
49 Header,
51 StructureUnknown,
53 StructureMismatch,
55 SegmentMissing,
57 SegmentUnknown,
59 FieldUnknown,
61 ComponentUnknown,
63 ValueFormat,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Diagnostic {
70 pub severity: Severity,
72 pub kind: Kind,
74 pub path: String,
77 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#[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
134fn 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
176fn 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 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 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
245fn starts_present(item: &Item, message: &Message) -> bool {
247 message
248 .segments()
249 .any(|segment| item.can_start(&segment.name))
250}
251
252fn 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 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 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
365fn 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
393fn 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
405fn is_date(value: &str) -> bool {
407 matches!(value.len(), 4 | 6 | 8) && value.bytes().all(|b| b.is_ascii_digit())
408}
409
410fn 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
426fn 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
442fn 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 assert_eq!(
499 found
500 .iter()
501 .filter(|d| d.kind == Kind::SegmentUnknown)
502 .count(),
503 0
504 );
505 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 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}