use crate::datetime::{self, Precision};
use crate::parser::{Message, Repetition, Segment};
use crate::spec::{self, Use};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Error,
}
impl Severity {
#[must_use]
pub const fn glyph(self) -> &'static str {
match self {
Self::Error => "\u{2717}", Self::Warning => "\u{26a0}", Self::Info => "\u{2139}", }
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
Self::Info => "info",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
Structure,
Required,
DataType,
CodeTable,
Consistency,
}
impl Category {
#[must_use]
pub const fn title(self) -> &'static str {
match self {
Self::Structure => "Structure",
Self::Required => "Required fields",
Self::DataType => "Data types",
Self::CodeTable => "Code tables",
Self::Consistency => "Consistency",
}
}
pub const ALL: [Self; 5] = [
Self::Structure,
Self::Required,
Self::DataType,
Self::CodeTable,
Self::Consistency,
];
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub category: Category,
pub location: String,
pub line: usize,
pub segment_index: Option<usize>,
pub summary: String,
pub detail: String,
}
impl Finding {
fn new(
severity: Severity,
category: Category,
location: impl Into<String>,
summary: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
severity,
category,
location: location.into(),
line: 0,
segment_index: None,
summary: summary.into(),
detail: detail.into(),
}
}
const fn at(mut self, seg: &Segment<'_>, index: usize) -> Self {
self.line = seg.line;
self.segment_index = Some(index);
self
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub findings: Vec<Finding>,
pub structure: Option<&'static spec::MessageSpec>,
}
impl Report {
#[must_use]
pub fn count(&self, sev: Severity) -> usize {
self.findings.iter().filter(|f| f.severity == sev).count()
}
#[must_use]
pub fn errors(&self) -> usize {
self.count(Severity::Error)
}
#[must_use]
pub fn warnings(&self) -> usize {
self.count(Severity::Warning)
}
#[must_use]
pub fn worst(&self) -> Option<Severity> {
self.findings.iter().map(|f| f.severity).max()
}
#[must_use]
pub fn segment_severity(&self, index: usize, include_info: bool) -> Option<Severity> {
self.findings
.iter()
.filter(|f| f.segment_index == Some(index))
.filter(|f| include_info || f.severity != Severity::Info)
.map(|f| f.severity)
.max()
}
#[must_use]
pub fn for_segment(&self, index: usize) -> Vec<&Finding> {
self.findings
.iter()
.filter(|f| f.segment_index == Some(index))
.collect()
}
}
pub fn validate(msg: &Message<'_>) -> Report {
let dictionary = spec::Dictionary::for_version(msg.version());
let mut findings: Vec<Finding> = Vec::new();
for rule in RULES {
rule.check(msg, dictionary, &mut findings);
}
findings.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then(a.line.cmp(&b.line))
.then(field_seq(&a.location).cmp(&field_seq(&b.location)))
.then(a.location.cmp(&b.location))
});
let (code, trigger, _) = msg.message_type();
Report {
findings,
structure: spec::message_spec(code, trigger),
}
}
const MSH_INDEX: usize = 0;
trait Rule {
fn check(&self, msg: &Message<'_>, dictionary: spec::Dictionary, out: &mut Vec<Finding>);
}
const RULES: &[&dyn Rule] = &[
&StructureRule,
&FieldUsageRule,
&ValueTypeRule,
&CodeTableRule,
&MessageHeaderRule,
&EventRule,
&PatientRule,
&VisitRule,
&ObservationRule,
&SetIdRule,
];
struct FieldRepetition<'a> {
seg: &'a Segment<'a>,
seg_index: usize,
spec: spec::FieldSpec,
rep: Repetition<'a>,
repetition: Option<usize>,
}
impl FieldRepetition<'_> {
fn location(&self) -> String {
match self.repetition {
Some(n) => format!("{}-{}[{}]", self.seg.name, self.spec.seq, n),
None => format!("{}-{}", self.seg.name, self.spec.seq),
}
}
}
fn visit_repetitions(
msg: &Message<'_>,
dictionary: spec::Dictionary,
mut visit: impl FnMut(FieldRepetition<'_>),
) {
for (seg_index, seg) in msg.segments.iter().enumerate() {
if seg.is_custom() {
continue;
}
let Some(segment_spec) = spec::segment_spec(seg.name) else {
continue;
};
for field_spec in dictionary.fields_of(seg.name, segment_spec.fields) {
let Some(field) = seg.field(field_spec.seq) else {
continue;
};
if field.is_empty() || field.is_null() {
continue;
}
let repeats = field.rep_count() > 1;
for (index, rep) in field.reps().enumerate() {
if rep.is_empty() {
continue;
}
visit(FieldRepetition {
seg,
seg_index,
spec: field_spec,
rep,
repetition: repeats.then_some(index + 1),
});
}
}
}
}
struct StructureRule;
impl Rule for StructureRule {
fn check(&self, msg: &Message<'_>, _dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
let (code, trigger, structure_id) = msg.message_type();
let structure = spec::message_spec(code, trigger);
check_structure(msg, code, trigger, structure_id, structure, out);
}
}
fn check_structure(
msg: &Message<'_>,
code: &str,
trigger: &str,
structure_id: &str,
structure: Option<&'static spec::MessageSpec>,
out: &mut Vec<Finding>,
) {
if code.is_empty() {
out.push(Finding::new(
Severity::Error,
Category::Structure,
"MSH-9",
"message type missing",
"MSH-9.1 carries no message code, so no structure can be validated",
));
} else if spec::code_meaning("0076", code).is_none() {
out.push(Finding::new(
Severity::Warning,
Category::Structure,
"MSH-9.1",
format!("unknown message code {code:?}"),
"not listed in HL7 table 0076 (Message Type)",
));
}
let Some(structure) = structure else {
if !code.is_empty() {
out.push(Finding::new(
Severity::Info,
Category::Structure,
"MSH-9",
format!("no structure definition for {code}^{trigger}"),
"segment order and required segments were not checked; field-level checks still ran",
));
}
return;
};
if !structure_id.is_empty() && !trigger.is_empty() && !structure_id.starts_with(code) {
let expected = format!("{code}_{trigger}");
if structure_id != expected {
out.push(Finding::new(
Severity::Warning,
Category::Structure,
"MSH-9.3",
format!("structure {structure_id:?} does not match {code}^{trigger}"),
format!("expected {expected:?}"),
));
}
}
for expected in structure.segments.iter().filter(|s| s.required) {
if msg.first(expected.name).is_none() {
out.push(Finding::new(
Severity::Error,
Category::Structure,
expected.name,
"required segment is missing",
format!(
"{} requires {} ({})",
structure.id,
expected.name,
spec::segment_desc(expected.name).unwrap_or("no description")
),
));
}
}
let mut furthest = 0usize;
let mut order_reported = false;
for (i, seg) in msg.segments.iter().enumerate() {
if seg.is_custom() {
continue;
}
let slots: Vec<usize> = structure
.segments
.iter()
.enumerate()
.filter(|(_, s)| s.name == seg.name)
.map(|(i, _)| i)
.collect();
if slots.is_empty() {
let known = spec::segment_desc(seg.name).is_some();
let (sev, detail) = if known {
(
Severity::Warning,
format!("{} is not part of the {} structure", seg.name, structure.id),
)
} else {
(
Severity::Warning,
format!("{} is not a recognised HL7 segment", seg.name),
)
};
out.push(
Finding::new(
sev,
Category::Structure,
seg.name,
"unexpected segment",
detail,
)
.at(seg, i),
);
continue;
}
match slots.iter().copied().find(|p| *p >= furthest) {
Some(slot) => furthest = slot,
None if !order_reported => {
order_reported = true;
out.push(
Finding::new(
Severity::Warning,
Category::Structure,
seg.name,
"segment appears out of order",
format!(
"{} expects {} earlier in the message",
structure.id, seg.name
),
)
.at(seg, i),
);
}
None => {}
}
}
if msg.segments.len() == 1 {
out.push(Finding::new(
Severity::Warning,
Category::Structure,
"",
"message contains only an MSH segment",
"no payload segments were found after the header",
));
}
}
struct FieldUsageRule;
impl Rule for FieldUsageRule {
fn check(&self, msg: &Message<'_>, dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
for (index, seg) in msg.segments.iter().enumerate() {
check_segment_fields(seg, index, dictionary, out);
}
}
}
struct ValueTypeRule;
impl Rule for ValueTypeRule {
fn check(&self, msg: &Message<'_>, dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
visit_repetitions(msg, dictionary, |field| check_datatype(&field, out));
}
}
struct CodeTableRule;
impl Rule for CodeTableRule {
fn check(&self, msg: &Message<'_>, dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
visit_repetitions(msg, dictionary, |field| {
let Some(table_id) = field.spec.table else {
return;
};
check_table(table_id, &field, out);
});
}
}
fn check_segment_fields(
seg: &Segment<'_>,
index: usize,
dictionary: spec::Dictionary,
out: &mut Vec<Finding>,
) {
if seg.is_custom() {
return;
}
let Some(segment_spec) = spec::segment_spec(seg.name) else {
return;
};
let mut max_defined = 0;
for fs in dictionary.fields_of(seg.name, segment_spec.fields) {
max_defined = max_defined.max(fs.seq);
let present = seg.has(fs.seq);
let loc = || format!("{}-{}", seg.name, fs.seq);
if !present {
match fs.usage {
Use::Required => out.push(
Finding::new(
Severity::Error,
Category::Required,
loc(),
"missing",
format!("{} is required by the {} segment", fs.name, seg.name),
)
.at(seg, index),
),
Use::Recommended => out.push(
Finding::new(
Severity::Warning,
Category::Required,
loc(),
"missing",
format!("{} should be populated when the value is known", fs.name),
)
.at(seg, index),
),
_ => {}
}
continue;
}
let Some(field) = seg.field(fs.seq) else {
continue;
};
if field.is_null() {
continue; }
if fs.usage == Use::Backward {
out.push(
Finding::new(
Severity::Info,
Category::Consistency,
loc(),
"deprecated field is populated",
format!(
"{} is deprecated; prefer the current equivalent field",
fs.name
),
)
.at(seg, index),
);
}
if !fs.repeats && field.rep_count() > 1 {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
loc(),
"repeats but is defined as non-repeating",
format!(
"{} repetitions found in a single-value field",
field.rep_count()
),
)
.at(seg, index),
);
}
if field.rep_count() > 1 {
for (ri, rep) in field.reps().enumerate() {
if rep.is_empty() {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
loc(),
"has an empty repetition",
format!("repetition {} carries no value", ri + 1),
)
.at(seg, index),
);
}
}
}
}
let last = seg.last_populated();
if last > max_defined {
let loc = format!("{}-{}", seg.name, last);
out.push(
Finding::new(
Severity::Warning,
Category::Structure,
loc,
format!("data beyond the last defined field ({}-{})", seg.name, max_defined),
format!(
"{} defines fields up to {}-{}; extra fields are usually a delimiter or alignment problem",
seg.name, seg.name, max_defined
),
)
.at(seg, index),
);
}
}
#[allow(clippy::collapsible_match)]
fn check_datatype(field: &FieldRepetition<'_>, out: &mut Vec<Finding>) {
let (fs, rep, seg, index) = (field.spec, field.rep, field.seg, field.seg_index);
let c1 = rep.comp_text(1);
let mut bad = |summary: String, detail: String| {
out.push(
Finding::new(
Severity::Error,
Category::DataType,
field.location(),
summary,
detail,
)
.at(seg, index),
);
};
match fs.dt {
"DTM" | "TS" => {
if let Err(e) = datetime::parse_ts(c1) {
bad("not a valid date/time".to_string(), e);
}
}
"DT" => {
if let Err(e) = datetime::parse_date(c1) {
bad("not a valid date".to_string(), e);
}
}
"TM" => {
if let Err(e) = datetime::parse_time(c1) {
bad("not a valid time".to_string(), e);
}
}
"NM" => {
if c1.parse::<f64>().is_err() {
bad(
"not numeric".to_string(),
format!("{c1:?} cannot be read as a number"),
);
}
}
"SI" => match c1.parse::<u32>() {
Ok(_) => {}
Err(_) => bad(
"not a valid sequence ID".to_string(),
format!("{c1:?} is not a non-negative integer"),
),
},
"CX" => {
if c1.is_empty() {
bad(
"no ID value".to_string(),
"component 1 (ID Number) is empty while the field is populated".to_string(),
);
}
}
"HD" => {
let universal_id = rep.comp_text(2);
let id_type = rep.comp_text(3);
let missing = match (universal_id.is_empty(), id_type.is_empty()) {
(false, true) => Some((
"universal ID without an ID type",
"component 2 names an identifier but component 3 does not say \
what kind it is, so it cannot be interpreted",
)),
(true, false) => Some((
"ID type without a universal ID",
"component 3 declares an identifier scheme but component 2 \
carries no identifier",
)),
_ => None,
};
if let Some((summary, detail)) = missing {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
field.location(),
summary,
detail,
)
.at(seg, index),
);
}
}
"EI" => {
if c1.is_empty() {
bad(
"no entity identifier".to_string(),
"component 1 is empty while the field is populated".to_string(),
);
}
}
"XPN" | "XCN" => {
let family = if fs.dt == "XCN" { rep.comp_text(2) } else { c1 };
let given = if fs.dt == "XCN" {
rep.comp_text(3)
} else {
rep.comp_text(2)
};
if family.is_empty() && !given.is_empty() {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
field.location(),
"given name without a family name",
"the family name component is empty",
)
.at(seg, index),
);
}
if fs.dt == "XCN" && c1.is_empty() && family.is_empty() {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
field.location(),
"neither an ID nor a name",
"components 1 (ID) and 2 (family name) are both empty",
)
.at(seg, index),
);
}
}
"PL" => {
let point_of_care = c1;
let filled = rep.filled_comps();
if point_of_care.is_empty() && filled > 0 {
bad(
"invalid location".to_string(),
"component 1 (point of care) is empty while later components are populated"
.to_string(),
);
} else if !rep.comp_text(3).is_empty() && rep.comp_text(2).is_empty() {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
field.location(),
"bed without a room",
"component 3 (bed) is populated but component 2 (room) is empty",
)
.at(seg, index),
);
}
}
"MSG" => {
if rep.comp_text(2).is_empty() {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
field.location(),
"no trigger event",
"component 2 (trigger event) is empty",
)
.at(seg, index),
);
}
}
"VID" => {
if !spec::is_known_version(c1) {
bad(
"not a known HL7 version".to_string(),
format!("{c1:?} is not listed in HL7 table 0104"),
);
}
}
"CE" | "CWE" => {
if !c1.is_empty() && rep.comp_text(3).is_empty() && fs.table.is_none() {
out.push(
Finding::new(
Severity::Info,
Category::CodeTable,
field.location(),
"no coding system",
format!("code {c1:?} is sent without component 3 (name of coding system)"),
)
.at(seg, index),
);
}
}
_ => {}
}
}
fn check_table(table_id: &str, field: &FieldRepetition<'_>, out: &mut Vec<Finding>) {
let (field_name, seg, index) = (field.spec.name, field.seg, field.seg_index);
let value = field.rep.comp_text(1);
if value.is_empty() {
return;
}
let Some(def) = spec::table(table_id) else {
return;
};
if def.meaning(value).is_some() {
return;
}
let severity = if def.closed {
Severity::Error
} else {
Severity::Warning
};
let hint = if def.closed {
format!(
"permitted values: {}",
def.codes
.iter()
.map(|(c, _)| *c)
.collect::<Vec<_>>()
.join(", ")
)
} else {
format!("not listed in HL7 table {} ({})", def.id, def.name)
};
out.push(
Finding::new(
severity,
Category::CodeTable,
field.location(),
format!("unrecognised code {value:?}"),
format!("{field_name}: {hint}"),
)
.at(seg, index),
);
}
struct MessageHeaderRule;
impl Rule for MessageHeaderRule {
fn check(&self, msg: &Message<'_>, dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
let (ty, tm, td) = datetime::today();
let msh = msg.msh();
let control = msg.control_id();
let declared = msg.version();
if !dictionary.is_exact(declared) && spec::version_rank(declared).is_some() {
out.push(
Finding::new(
Severity::Info,
Category::Structure,
"MSH-12",
format!("checked against HL7 {}", dictionary.version()),
format!(
"this build carries no field definitions for {declared}, so \
the {} ones were used; field numbering, usage and code \
tables differ between versions, and some findings may \
not apply",
dictionary.version()
),
)
.at(msh, MSH_INDEX),
);
}
if control.chars().count() > 20 {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
"MSH-10",
"message control ID is longer than 20 characters",
format!(
"{} characters; receivers may truncate it",
control.chars().count()
),
)
.at(msh, MSH_INDEX),
);
}
if let Ok(ts) = datetime::parse_ts(msh.comp(7, 1)) {
if ts.days_after(ty, tm, td) > 1 {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"MSH-7",
"message date/time is in the future",
format!("{} is later than today", ts.display()),
)
.at(msh, MSH_INDEX),
);
}
if ts.precision < Precision::Minute {
out.push(
Finding::new(
Severity::Warning,
Category::DataType,
"MSH-7",
"message date/time has no time component",
"MSH-7 should carry at least YYYYMMDDHHMM",
)
.at(msh, MSH_INDEX),
);
}
}
if msh.comp(18, 1).is_empty() && msg.segments.iter().any(|s| !s.raw.is_ascii()) {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"MSH-18",
"non-ASCII characters sent without a declared character set",
"populate MSH-18 (e.g. UNICODE UTF-8) so the receiver decodes correctly",
)
.at(msh, MSH_INDEX),
);
}
}
}
struct EventRule;
impl Rule for EventRule {
fn check(&self, msg: &Message<'_>, _dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
let (_, trigger, _) = msg.message_type();
if let Some((i, evn)) = msg
.segments
.iter()
.enumerate()
.find(|(_, s)| s.name == "EVN")
{
let evn_type = evn.comp(1, 1);
if !evn_type.is_empty() && !trigger.is_empty() && evn_type != trigger {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"EVN-1",
format!("event type {evn_type:?} does not match MSH-9 trigger {trigger:?}"),
"the event type code should repeat the trigger event from MSH-9.2",
)
.at(evn, i),
);
}
if let (Ok(recorded), Ok(occurred)) = (
datetime::parse_ts(evn.comp(2, 1)),
datetime::parse_ts(evn.comp(6, 1)),
) {
if recorded.days_after(occurred.year, occurred.month, occurred.day) < 0 {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"EVN-2",
"event was recorded before it occurred",
format!(
"EVN-2 {} precedes EVN-6 {}",
recorded.display(),
occurred.display()
),
)
.at(evn, i),
);
}
}
}
}
}
struct PatientRule;
impl Rule for PatientRule {
fn check(&self, msg: &Message<'_>, _dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
let (ty, tm, td) = datetime::today();
if let Some((i, pid)) = msg
.segments
.iter()
.enumerate()
.find(|(_, s)| s.name == "PID")
{
if let Ok(dob) = datetime::parse_ts(pid.comp(7, 1)) {
if dob.days_after(ty, tm, td) > 0 {
out.push(
Finding::new(
Severity::Error,
Category::Consistency,
"PID-7",
"date of birth is in the future",
format!("{} is later than today", dob.date_string()),
)
.at(pid, i),
);
} else if dob.years_until(ty, tm, td) > 130 {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"PID-7",
"date of birth implies an implausible age",
format!("{} years old", dob.years_until(ty, tm, td)),
)
.at(pid, i),
);
}
}
let death_date = pid.comp(29, 1);
let death_flag = pid.comp(30, 1);
if !death_date.is_empty() && death_flag != "Y" {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"PID-30",
"death date is present but the death indicator is not Y",
format!("PID-29 is {death_date:?} while PID-30 is {death_flag:?}"),
)
.at(pid, i),
);
}
if death_flag == "Y" && death_date.is_empty() {
out.push(
Finding::new(
Severity::Info,
Category::Consistency,
"PID-29",
"patient is flagged as deceased without a death date",
"PID-30 is Y but PID-29 is empty",
)
.at(pid, i),
);
}
if let Some(field) = pid.field(3) {
let ids: Vec<String> = field
.reps()
.map(|r| format!("{}|{}", r.comp_text(1), r.comp_text(4)))
.collect();
for (n, id) in ids.iter().enumerate() {
if !id.starts_with('|') && ids[..n].contains(id) {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"PID-3",
"patient identifier list contains a duplicate",
format!("repetition {} repeats an earlier identifier", n + 1),
)
.at(pid, i),
);
break;
}
}
}
}
}
}
struct VisitRule;
impl Rule for VisitRule {
fn check(&self, msg: &Message<'_>, _dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
let (code, trigger, _) = msg.message_type();
if let Some((i, pv1)) = msg
.segments
.iter()
.enumerate()
.find(|(_, s)| s.name == "PV1")
{
let class = pv1.comp(2, 1);
if matches!(class, "I" | "E" | "B") && !pv1.has(3) {
out.push(
Finding::new(
Severity::Error,
Category::Consistency,
"PV1-3",
"assigned patient location is missing",
format!(
"patient class {:?} ({}) requires a location in PV1-3",
class,
spec::code_meaning("0004", class).unwrap_or("unknown class")
),
)
.at(pv1, i),
);
}
let admit = datetime::parse_ts(pv1.comp(44, 1));
let discharge = datetime::parse_ts(pv1.comp(45, 1));
if let (Ok(a), Ok(d)) = (&admit, &discharge) {
if d.days_after(a.year, a.month, a.day) < 0 {
out.push(
Finding::new(
Severity::Error,
Category::Consistency,
"PV1-45",
"discharge date/time precedes admit date/time",
format!("PV1-44 {}, PV1-45 {}", a.display(), d.display()),
)
.at(pv1, i),
);
}
}
if code == "ADT" && trigger == "A03" && discharge.is_err() {
out.push(
Finding::new(
Severity::Error,
Category::Consistency,
"PV1-45",
"discharge date/time is missing",
"ADT^A03 (Discharge / End Visit) must carry PV1-45",
)
.at(pv1, i),
);
}
}
}
}
struct ObservationRule;
impl Rule for ObservationRule {
fn check(&self, msg: &Message<'_>, _dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
for (i, obx) in msg
.segments
.iter()
.enumerate()
.filter(|(_, s)| s.name == "OBX")
{
let vt = obx.comp(2, 1);
let value = obx.text(5);
if vt.is_empty() && !value.is_empty() {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
"OBX-2",
"observation value sent without a value type",
"OBX-2 must say how OBX-5 should be interpreted",
)
.at(obx, i),
);
continue;
}
if value.is_empty() {
continue;
}
let first = obx.comp(5, 1);
let mismatch = match vt {
"NM" => first
.parse::<f64>()
.is_err()
.then(|| "not a number".to_string()),
"DT" => datetime::parse_date(first).err(),
"TS" | "DTM" => datetime::parse_ts(first).err(),
"TM" => datetime::parse_time(first).err(),
"SN" => {
let num = obx.comp(5, 2);
(!num.is_empty() && num.parse::<f64>().is_err())
.then(|| "component 2 is not a number".to_string())
}
_ => None,
};
if let Some(reason) = mismatch {
out.push(
Finding::new(
Severity::Error,
Category::Consistency,
"OBX-5",
format!("value does not match declared type {vt}"),
format!("{first:?}: {reason}"),
)
.at(obx, i),
);
}
}
}
}
struct SetIdRule;
impl Rule for SetIdRule {
fn check(&self, msg: &Message<'_>, _dictionary: spec::Dictionary, out: &mut Vec<Finding>) {
check_set_ids(msg, out);
}
}
fn check_set_ids(msg: &Message<'_>, out: &mut Vec<Finding>) {
const SET_ID_SEGMENTS: &[&str] = &[
"NK1", "AL1", "DG1", "PR1", "GT1", "IN1", "OBX", "FT1", "IAM", "RGS", "AIS", "AIL", "AIP",
"NTE",
];
for name in SET_ID_SEGMENTS {
let occurrences: Vec<(usize, &Segment<'_>)> = msg
.segments
.iter()
.enumerate()
.filter(|(_, s)| s.name == *name)
.collect();
if occurrences.len() < 2 {
continue;
}
for (n, (i, seg)) in occurrences.iter().enumerate() {
let raw = seg.comp(1, 1);
if raw.is_empty() {
continue;
}
let Ok(value) = raw.parse::<usize>() else {
continue;
};
if value != n + 1 {
out.push(
Finding::new(
Severity::Warning,
Category::Consistency,
format!("{name}-1"),
"set ID is out of sequence",
format!("occurrence {} carries set ID {}", n + 1, value),
)
.at(seg, *i),
);
break;
}
}
}
}
fn field_seq(location: &str) -> usize {
location.split_once('-').map_or(0, |(_, rest)| {
rest.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.unwrap_or(0)
})
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "panicking is the failure mode a test wants"
)]
use super::*;
use crate::parser::parse_str;
fn report(text: &str) -> Report {
validate(&parse_str(text))
}
fn find<'a>(r: &'a Report, location: &str) -> Option<&'a Finding> {
r.findings.iter().find(|f| f.location == location)
}
fn located(r: &Report, location: &str) -> Severity {
find(r, location)
.unwrap_or_else(|| panic!("expected a finding at {}: {:#?}", location, r.findings))
.severity
}
const HEADER: &str =
"MSH|^~\\&|HIS|MERCY|LIS|LAB|20240115143200||ADT^A01^ADT_A01|MSG1|P|2.5.1\r";
fn segment(name: &str, fields: &[(usize, &str)]) -> String {
let top = fields.iter().map(|(i, _)| *i).max().unwrap_or(0);
let mut parts = vec![String::new(); top];
for (i, value) in fields {
parts[i - 1] = (*value).to_string();
}
format!("{}|{}\r", name, parts.join("|"))
}
fn adt(extra_pv1: &[(usize, &str)]) -> String {
let mut pv1: Vec<(usize, &str)> = vec![
(1, "1"),
(2, "I"),
(3, "ER^101^A"),
(7, "1^Adams^Alice"),
(19, "V1"),
(44, "20240115143000"),
];
pv1.retain(|(i, _)| !extra_pv1.iter().any(|(j, _)| j == i));
pv1.extend_from_slice(extra_pv1);
format!(
"{HEADER}EVN|A01|20240115143200||||20240115143000\r\
PID|1||123456^^^MERCY^MR||Smith^John||19850312|M|||1 Oak St^^Springfield^IL^62704\r{}",
segment("PV1", &pv1)
)
}
#[test]
fn every_rule_stands_on_its_own() {
let messages = [
adt(&[]),
HEADER.to_string(),
format!("{HEADER}PID|1\rOBX|1|NM|X^Y^L||text\r"),
"MSH|^~\\&|A|B|C|D|20240115143200||ACK|1|P|2.5.1\rMSA|AA|1\r".to_string(),
];
for text in messages {
let msg = parse_str(&text);
let dictionary = spec::Dictionary::for_version(msg.version());
for rule in RULES {
let mut findings = Vec::new();
rule.check(&msg, dictionary, &mut findings);
}
}
}
#[test]
fn validate_collects_from_every_rule() {
let text = adt(&[]);
let msg = parse_str(&text);
let combined = validate(&msg).findings.len();
let dictionary = spec::Dictionary::for_version(msg.version());
let separate: usize = RULES
.iter()
.map(|rule| {
let mut findings = Vec::new();
rule.check(&msg, dictionary, &mut findings);
findings.len()
})
.sum();
assert_eq!(
combined, separate,
"validate() must run each rule exactly once"
);
}
#[test]
fn a_complete_message_passes() {
let r = report(&adt(&[]));
let blocking: Vec<&Finding> = r
.findings
.iter()
.filter(|f| f.severity == Severity::Error)
.collect();
assert!(blocking.is_empty(), "unexpected errors: {blocking:#?}");
}
#[test]
fn missing_required_segment_is_an_error() {
let r = report(&format!("{HEADER}PID|1||1^^^A^MR||Smith^John\r"));
assert_eq!(located(&r, "EVN"), Severity::Error);
assert_eq!(located(&r, "PV1"), Severity::Error);
}
#[test]
fn missing_required_field_is_an_error_and_recommended_is_a_warning() {
let r = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1\rPV1|1|I|ER^101\r"
));
assert_eq!(
located(&r, "PID-3"),
Severity::Error,
"identifier list is required"
);
assert_eq!(
located(&r, "PID-5"),
Severity::Error,
"patient name is required"
);
assert_eq!(
located(&r, "PID-11"),
Severity::Warning,
"address is recommended"
);
}
#[test]
fn invalid_dates_are_reported_against_their_field() {
let r = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||19850332|M|||1 St^^X^IL^1\rPV1|1|I|ER^101\r"
));
let f = find(&r, "PID-7").unwrap();
assert_eq!(f.severity, Severity::Error);
assert!(f.detail.contains("day 32"), "{}", f.detail);
}
#[test]
fn future_birth_date_is_rejected() {
let (y, _, _) = datetime::today();
let text = format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||{}0312|M|||1 St^^X^IL^1\rPV1|1|I|ER^101\r",
y + 2
);
assert_eq!(located(&report(&text), "PID-7"), Severity::Error);
}
#[test]
fn location_without_a_point_of_care_is_invalid() {
let r = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||19850312|M|||1 St^^X^IL^1\rPV1|1|I|^101^A\r"
));
let f = find(&r, "PV1-3").unwrap();
assert_eq!(f.severity, Severity::Error);
assert_eq!(f.summary, "invalid location");
}
#[test]
fn inpatient_without_a_location_is_an_error() {
let r = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||19850312|M|||1 St^^X^IL^1\rPV1|1|I\r"
));
let f = find(&r, "PV1-3").unwrap();
assert_eq!(f.severity, Severity::Error);
assert!(f.detail.contains("Inpatient"), "{}", f.detail);
}
#[test]
fn unknown_table_codes_warn_and_closed_tables_fail() {
let r = report(&adt(&[]));
assert!(find(&r, "PV1-2").is_none());
let bad_sex = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||19850312|Q|||1 St^^X^IL^1\rPV1|1|I|ER^101\r"
));
assert_eq!(located(&bad_sex, "PID-8"), Severity::Warning);
let bad_processing = "MSH|^~\\&|A|B|C|D|20240115143200||ACK|1|X|2.5.1\rMSA|AA|1\r";
assert_eq!(located(&report(bad_processing), "MSH-11"), Severity::Error);
}
#[test]
fn unknown_version_is_an_error() {
let text = "MSH|^~\\&|A|B|C|D|20240115143200||ACK|1|P|9.9\rMSA|AA|1\r";
assert_eq!(located(&report(text), "MSH-12"), Severity::Error);
}
#[test]
fn observation_value_must_match_its_declared_type() {
let numeric = "MSH|^~\\&|A|B|C|D|20240115143200||ORU^R01|1|P|2.5.1\r\
OBR|1|P1|F1|CBC^Complete Blood Count^L|||20240115150000\r\
OBX|1|NM|718-7^Hemoglobin^LN||thirteen|g/dL|||||F|||20240115150000\r";
let numeric_report = report(numeric);
let f = find(&numeric_report, "OBX-5").unwrap();
assert_eq!(f.severity, Severity::Error);
let ok = numeric.replace("thirteen", "13.4");
assert!(find(&report(&ok), "OBX-5").is_none());
}
#[test]
fn discharge_before_admit_is_an_error() {
let r = report(&adt(&[(44, "20240115143000"), (45, "20240114120000")]));
assert_eq!(located(&r, "PV1-45"), Severity::Error);
}
#[test]
fn discharge_message_needs_a_discharge_time() {
let text = "MSH|^~\\&|HIS|MERCY|LIS|LAB|20240115143200||ADT^A03^ADT_A03|MSG1|P|2.5.1\r\
EVN|A03|20240115143200||||20240115143000\r\
PID|1||1^^^A^MR||Smith^John||19850312|M|||1 St^^X^IL^1\r\
PV1|1|I|ER^101|||1^Adams^Alice||||||||||V1\r";
assert_eq!(located(&report(text), "PV1-45"), Severity::Error);
}
#[test]
fn out_of_order_segments_warn_but_legal_repeats_do_not() {
let jumbled = format!(
"{HEADER}PID|1||1^^^A^MR||Smith^John||19850312|M|||1 St^^X^IL^1\r\
EVN|A01|20240115143200||||20240115143000\rPV1|1|I|ER^101|||1^Adams^Alice||||||||||V1\r"
);
assert_eq!(located(&report(&jumbled), "EVN"), Severity::Warning);
let oru = "MSH|^~\\&|A|B|C|D|20240115143200||ORU^R01|1|P|2.5.1\r\
OBR|1|P1|F1|CBC^Complete Blood Count^L|||20240115150000\r\
OBX|1|NM|718-7^Hgb^LN||13.4|g/dL|||||F|||20240115150000\r\
NTE|1|L|Sample slightly haemolysed\r";
assert!(find(&report(oru), "NTE").is_none());
}
#[test]
fn unexpected_and_custom_segments() {
let r = report(&format!("{}ZPD|1|local data\r", adt(&[])));
assert!(find(&r, "ZPD").is_none(), "Z segments are site-defined");
let r = report(&format!("{}MSA|AA|1\r", adt(&[])));
assert_eq!(located(&r, "MSA"), Severity::Warning);
}
#[test]
fn set_ids_must_count_up() {
let text = format!(
"{}AL1|1|DA|PEN^Penicillin^L\rAL1|3|DA|SUL^Sulfa^L\r",
adt(&[])
);
assert_eq!(located(&report(&text), "AL1-1"), Severity::Warning);
}
#[test]
fn repetition_in_a_single_value_field_warns() {
let r = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||19850312|M~F|||1 St^^X^IL^1\rPV1|1|I|ER^101\r"
));
assert_eq!(located(&r, "PID-8"), Severity::Warning);
}
#[test]
fn data_past_the_segment_definition_warns() {
let r = report(&format!("{}NTE|1|L|note|type|extra\r", adt(&[])));
assert!(r
.findings
.iter()
.any(|f| f.summary.contains("beyond the last defined field")));
}
#[test]
fn trigger_event_must_agree_with_evn() {
let text = format!(
"{HEADER}EVN|A04|20240115143200||||20240115143000\r\
PID|1||1^^^A^MR||Smith^John||19850312|M|||1 St^^X^IL^1\rPV1|1|I|ER^101|||1^A^B||||||||||V1\r"
);
assert_eq!(located(&report(&text), "EVN-1"), Severity::Warning);
}
#[test]
fn explicit_null_satisfies_a_populated_field() {
let r = report(&format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||\"\"|M|||1 St^^X^IL^1\rPV1|1|I|ER^101\r"
));
assert!(
find(&r, "PID-7").is_none(),
"\"\" is a deliberate null, not bad data"
);
}
#[test]
fn severity_rolls_up_per_segment_and_message() {
let text = format!(
"{HEADER}EVN|A01|20240115143200\rPID|1||1^^^A^MR||Smith^John||19850332|M|||1 St^^X^IL^1\rPV1|1|I|ER^101|||1^A^B||||||||||V1\r"
);
let msg = parse_str(&text);
let r = validate(&msg);
let pid = msg.segments.iter().position(|s| s.name == "PID").unwrap();
assert_eq!(r.segment_severity(pid, false), Some(Severity::Error));
assert_eq!(r.worst(), Some(Severity::Error));
assert!(r.errors() >= 1);
}
#[test]
fn a_message_with_no_type_cannot_be_structurally_checked() {
let r = report("MSH|^~\\&|A|B|C|D|20240115143200|||MSG1|P|2.5.1\rEVN|A01|20240115143200\r");
assert_eq!(located(&r, "MSH-9"), Severity::Error);
assert!(find(&r, "MSH-9").unwrap().summary.contains("type missing"));
}
#[test]
fn an_unknown_message_code_is_reported_but_does_not_stop_the_checks() {
let r = report(
"MSH|^~\\&|A|B|C|D|20240115143200||ZQQ^A01|MSG1|P|2.5.1\rEVN|A01|20240115143200\r",
);
assert_eq!(located(&r, "MSH-9.1"), Severity::Warning);
assert!(find(&r, "MSH-9.1")
.unwrap()
.summary
.contains("unknown message code"));
}
#[test]
fn a_known_code_with_no_structure_says_what_was_skipped() {
let r = report(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A99|MSG1|P|2.5.1\rEVN|A99|20240115143200\r",
);
let f = find(&r, "MSH-9").expect("a note about the missing structure");
assert_eq!(f.severity, Severity::Info);
assert!(f.detail.contains("field-level checks still ran"), "{f:?}");
}
#[test]
fn a_structure_id_that_contradicts_the_message_type_is_reported() {
let r = report(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01^ORU_R01|MSG1|P|2.5.1\rEVN|A01|20240115143200\r",
);
assert_eq!(located(&r, "MSH-9.3"), Severity::Warning);
assert!(find(&r, "MSH-9.3").unwrap().detail.contains("ADT_A01"));
}
#[test]
fn each_scalar_type_reports_a_value_it_cannot_read() {
let bad_date = report(&format!(
"{HEADER}{}",
segment(
"PID",
&[(1, "1"), (3, "1^^^A^MR"), (5, "S^J"), (7, "19851399")]
)
));
assert_eq!(located(&bad_date, "PID-7"), Severity::Error);
assert!(find(&bad_date, "PID-7").unwrap().summary.contains("date"));
let bad_si = report(&format!(
"{HEADER}{}",
segment("PID", &[(1, "x"), (3, "1^^^A^MR"), (5, "S^J")])
));
assert_eq!(located(&bad_si, "PID-1"), Severity::Error);
assert!(find(&bad_si, "PID-1")
.unwrap()
.summary
.contains("sequence ID"));
}
#[test]
fn a_non_numeric_value_in_a_numeric_field_is_reported() {
let r = report(&format!(
"{HEADER}{}",
segment(
"OBX",
&[(1, "1"), (2, "NM"), (3, "GLU^Glucose"), (5, "not-a-number")]
)
));
assert!(
r.findings.iter().any(|f| f.location.starts_with("OBX-5")),
"{:#?}",
r.findings
);
}
#[test]
fn an_identifier_field_with_no_identifier_is_reported() {
let r = report(&format!(
"{HEADER}{}",
segment("PID", &[(1, "1"), (3, "^^^MERCY^MR"), (5, "S^J")])
));
assert_eq!(located(&r, "PID-3"), Severity::Error);
assert!(find(&r, "PID-3").unwrap().summary.contains("no ID value"));
}
#[test]
fn a_given_name_without_a_family_name_is_reported() {
let r = report(&format!(
"{HEADER}{}",
segment("PID", &[(1, "1"), (3, "1^^^A^MR"), (5, "^John")])
));
assert_eq!(located(&r, "PID-5"), Severity::Warning);
assert!(find(&r, "PID-5").unwrap().summary.contains("family name"));
}
#[test]
fn a_patient_location_with_no_point_of_care_is_reported() {
let r = report(&adt(&[(3, "^101^A")]));
assert_eq!(located(&r, "PV1-3"), Severity::Error);
assert!(find(&r, "PV1-3").unwrap().summary.contains("location"));
}
#[test]
fn an_over_long_control_id_is_reported() {
let long = "M".repeat(21);
let r = report(&format!(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01^ADT_A01|{long}|P|2.5.1\rEVN|A01|20240115143200\r"
));
assert_eq!(located(&r, "MSH-10"), Severity::Warning);
assert!(find(&r, "MSH-10")
.unwrap()
.summary
.contains("longer than 20"));
}
#[test]
fn a_message_dated_in_the_future_is_reported() {
let (y, _, _) = datetime::today();
let r = report(&format!(
"MSH|^~\\&|A|B|C|D|{}0115143200||ADT^A01^ADT_A01|MSG1|P|2.5.1\rEVN|A01|20240115143200\r",
y + 2
));
assert_eq!(located(&r, "MSH-7"), Severity::Warning);
assert!(find(&r, "MSH-7").unwrap().summary.contains("future"));
}
#[test]
fn a_message_date_without_a_time_is_reported() {
let r = report(
"MSH|^~\\&|A|B|C|D|20240115||ADT^A01^ADT_A01|MSG1|P|2.5.1\rEVN|A01|20240115143200\r",
);
assert_eq!(located(&r, "MSH-7"), Severity::Warning);
assert!(find(&r, "MSH-7").unwrap().summary.contains("time"));
}
#[test]
fn accented_text_without_a_declared_character_set_is_reported() {
let r = report(&format!(
"{HEADER}{}",
segment("PID", &[(1, "1"), (3, "1^^^A^MR"), (5, "Bj\u{f6}rk^Anna")])
));
assert_eq!(located(&r, "MSH-18"), Severity::Warning);
assert!(find(&r, "MSH-18").unwrap().summary.contains("non-ASCII"));
}
#[test]
fn an_event_recorded_before_it_happened_is_reported() {
let evn = segment(
"EVN",
&[(1, "A01"), (2, "20240115143200"), (6, "20240116080000")],
);
let r = report(&format!("{HEADER}{evn}"));
assert_eq!(located(&r, "EVN-2"), Severity::Warning);
assert!(find(&r, "EVN-2")
.unwrap()
.summary
.contains("before it occurred"));
}
#[test]
fn a_birth_date_in_the_future_is_reported() {
let (y, _, _) = datetime::today();
let pid = segment(
"PID",
&[
(1, "1"),
(3, "1^^^A^MR"),
(5, "S^J"),
(7, &format!("{}0115", y + 2)),
],
);
let r = report(&format!("{HEADER}{pid}"));
assert!(find(&r, "PID-7").unwrap().summary.contains("future"));
}
#[test]
fn an_implausible_age_is_reported() {
let pid = segment(
"PID",
&[(1, "1"), (3, "1^^^A^MR"), (5, "S^J"), (7, "17000101")],
);
let r = report(&format!("{HEADER}{pid}"));
assert!(find(&r, "PID-7").unwrap().summary.contains("age"));
}
#[test]
fn a_death_date_and_its_indicator_must_agree() {
let dated = segment(
"PID",
&[
(1, "1"),
(3, "1^^^A^MR"),
(5, "S^J"),
(7, "19850312"),
(29, "20240101"),
],
);
let r = report(&format!("{HEADER}{dated}"));
assert!(find(&r, "PID-30")
.unwrap()
.summary
.contains("death indicator"));
let flagged = segment(
"PID",
&[
(1, "1"),
(3, "1^^^A^MR"),
(5, "S^J"),
(7, "19850312"),
(30, "Y"),
],
);
let r = report(&format!("{HEADER}{flagged}"));
assert!(find(&r, "PID-29")
.unwrap()
.summary
.contains("without a death date"));
}
#[test]
fn an_observation_without_a_value_type_is_reported() {
let obx = segment("OBX", &[(1, "1"), (3, "GLU^Glucose"), (5, "6.2")]);
let r = report(&format!("{HEADER}{obx}"));
assert!(
r.findings
.iter()
.any(|f| f.location == "OBX-2" && f.summary.contains("value type")),
"{:#?}",
r.findings
);
}
#[test]
fn an_empty_repetition_is_reported() {
let pid = segment("PID", &[(1, "1"), (3, "1^^^A^MR~~2^^^B^MR"), (5, "S^J")]);
let r = report(&format!("{HEADER}{pid}"));
assert!(
r.findings
.iter()
.any(|f| f.summary.contains("empty repetition")),
"{:#?}",
r.findings
);
}
#[test]
fn an_entity_identifier_with_no_value_is_reported() {
let r = report(
"MSH|^~\\&|A|B|C|D|20240115143200||ADT^A01^ADT_A01|MSG1|P|2.5.1|||||||||^ns^uid^ISO\rEVN|A01|20240115143200\r",
);
assert!(
r.findings
.iter()
.any(|f| f.summary.contains("entity identifier")),
"{:#?}",
r.findings
);
}
#[test]
fn a_clinician_field_with_neither_id_nor_name_is_reported() {
let r = report(&adt(&[(7, "^^Alice")]));
assert!(
r.findings
.iter()
.any(|f| f.summary.contains("neither an ID nor a name")),
"{:#?}",
r.findings
);
}
#[test]
fn a_repeating_field_names_the_repetition_that_is_wrong() {
let pid = segment("PID", &[(1, "1"), (3, "1^^^A^MR~^^^B^PI"), (5, "S^J")]);
let r = report(&format!("{HEADER}{pid}"));
assert!(
r.findings.iter().any(|f| f.location == "PID-3[2]"),
"{:#?}",
r.findings
);
}
#[test]
fn a_duplicate_identifier_in_pid_3_is_reported() {
let pid = segment("PID", &[(1, "1"), (3, "1^^^A^MR~1^^^A^MR"), (5, "S^J")]);
let r = report(&format!("{HEADER}{pid}"));
assert!(
r.findings
.iter()
.any(|f| f.detail.contains("repeats an earlier identifier")),
"{:#?}",
r.findings
);
}
#[test]
fn a_date_only_field_rejects_a_time() {
let ok = segment(
"NK1",
&[(1, "1"), (2, "Smith^Jane"), (3, "SPO"), (8, "20240115")],
);
let r = report(&format!("{HEADER}{ok}"));
assert!(
find(&r, "NK1-8").is_none(),
"a plain date is fine: {:#?}",
r.findings
);
let with_time = segment(
"NK1",
&[
(1, "1"),
(2, "Smith^Jane"),
(3, "SPO"),
(8, "20240115143000"),
],
);
let r = report(&format!("{HEADER}{with_time}"));
assert_eq!(located(&r, "NK1-8"), Severity::Error);
assert!(find(&r, "NK1-8").unwrap().summary.contains("date"));
}
#[test]
fn an_observation_declares_the_type_its_value_must_match() {
for (vt, value, wrong) in [("NM", "6.2", "six"), ("DT", "20240115", "not-a-date")] {
let good = segment("OBX", &[(1, "1"), (2, vt), (3, "X^Y"), (5, value)]);
let r = report(&format!("{HEADER}{good}"));
assert!(
!r.findings.iter().any(|f| f.location.starts_with("OBX-5")),
"{vt} {value} should be accepted: {:#?}",
r.findings
);
let bad = segment("OBX", &[(1, "1"), (2, vt), (3, "X^Y"), (5, wrong)]);
let r = report(&format!("{HEADER}{bad}"));
assert!(
r.findings.iter().any(|f| f.location.starts_with("OBX-5")),
"{vt} {wrong} should be reported: {:#?}",
r.findings
);
}
}
}