#![expect(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
clippy::string_slice,
clippy::uninlined_format_args,
reason = "Pre-existing validation implementation debt moved during module collapse; cleanup is separate from this behavior-preserving change."
)]
use crate::model::Message;
use chrono::{NaiveDate, NaiveDateTime};
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Severity {
#[default]
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Issue {
pub code: String,
pub severity: Severity,
pub path: Option<String>,
pub detail: String,
}
impl Issue {
pub fn new(code: &str, severity: Severity, path: Option<String>, detail: String) -> Self {
Issue {
code: code.to_string(),
severity,
path,
detail,
}
}
pub fn error(code: &str, path: Option<String>, detail: String) -> Self {
Issue::new(code, Severity::Error, path, detail)
}
pub fn warning(code: &str, path: Option<String>, detail: String) -> Self {
Issue::new(code, Severity::Warning, path, detail)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ValidationReportSeverity {
Error,
Warning,
}
impl ValidationReportSeverity {
pub const fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
}
}
}
impl From<&Severity> for ValidationReportSeverity {
fn from(value: &Severity) -> Self {
match value {
Severity::Error => Self::Error,
Severity::Warning => Self::Warning,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationReport {
pub valid: bool,
pub message_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
pub segment_count: usize,
pub issue_count: usize,
pub issues: Vec<ValidationReportIssue>,
}
impl ValidationReport {
pub fn from_issues(message: &Message, profile: Option<String>, issues: Vec<Issue>) -> Self {
let report_issues: Vec<ValidationReportIssue> = issues
.into_iter()
.map(|issue| ValidationReportIssue::from_issue(message, issue))
.collect();
let valid = report_issues
.iter()
.all(|issue| issue.severity != ValidationReportSeverity::Error);
Self {
valid,
message_type: message_type(message),
profile,
segment_count: message.segments.len(),
issue_count: report_issues.len(),
issues: report_issues,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationReportIssue {
pub code: String,
pub severity: ValidationReportSeverity,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub field_index: Option<usize>,
}
impl ValidationReportIssue {
pub fn from_issue(message: &Message, issue: Issue) -> Self {
let code = stable_issue_code(&issue.code);
let (segment_index, field_index) = issue.path.as_deref().map_or((None, None), |path| {
(
segment_index_for_path(message, path),
field_index_for_path(path),
)
});
Self {
rule_id: if code.is_empty() {
None
} else {
Some(code.clone())
},
code,
severity: ValidationReportSeverity::from(&issue.severity),
path: issue.path,
message: issue.detail,
segment_index,
field_index,
}
}
}
pub type ValidationResult = Vec<Issue>;
pub trait Validator {
fn validate(&self, msg: &Message) -> ValidationResult;
}
fn stable_issue_code(code: &str) -> String {
let mut output = String::new();
let mut previous_was_separator = false;
for character in code.chars() {
if character.is_ascii_alphanumeric() {
output.push(character.to_ascii_lowercase());
previous_was_separator = false;
} else if !previous_was_separator && !output.is_empty() {
output.push('_');
previous_was_separator = true;
}
}
if output.ends_with('_') {
output.pop();
}
output
}
fn message_type(message: &Message) -> String {
let message_code = crate::query::get(message, "MSH.9.1")
.or_else(|| crate::query::get(message, "MSH.9"))
.unwrap_or("UNKNOWN");
let trigger_event = crate::query::get(message, "MSH.9.2");
trigger_event.map_or_else(
|| message_code.to_string(),
|event| format!("{}^{}", message_code, event),
)
}
fn segment_index_for_path(message: &Message, path: &str) -> Option<usize> {
let segment_id = path.split('.').next()?;
message
.segments
.iter()
.position(|segment| segment.id_str() == segment_id)
}
fn field_index_for_path(path: &str) -> Option<usize> {
let field_part = path.split('.').nth(1)?;
let field_index = field_part.split('[').next()?;
field_index.parse().ok()
}
pub fn validate_data_type(value: &str, datatype: &str) -> bool {
match datatype {
"ST" => is_string(value), "ID" => is_identifier(value), "DT" => is_date(value), "TM" => is_time(value), "TS" => is_timestamp(value), "NM" => is_numeric(value), "SI" => is_sequence_id(value), "TX" => is_text_data(value), "FT" => is_formatted_text(value), "IS" => is_coded_value(value), "PN" => is_person_name(value), "CX" => is_extended_id(value), "HD" => is_hierarchic_designator(value), _ => true, }
}
pub fn is_string(_value: &str) -> bool {
true
}
pub fn is_identifier(value: &str) -> bool {
value.chars().all(|c| c.is_ascii() && !c.is_control())
}
pub fn is_date(value: &str) -> bool {
if value.len() != 8 {
return false;
}
if !value.chars().all(|c| c.is_ascii_digit()) {
return false;
}
let _year = &value[0..4];
let month = &value[4..6];
let day = &value[6..8];
if !("01"..="12").contains(&month) {
return false;
}
if !("01"..="31").contains(&day) {
return false;
}
true
}
pub fn is_time(value: &str) -> bool {
if value.is_empty() || value.len() > 16 {
return false;
}
if !value.chars().all(|c| c.is_ascii_digit() || c == '.') {
return false;
}
if value.len() < 4 {
return false;
}
let hour = &value[0..2];
let minute = &value[2..4];
if hour > "23" {
return false;
}
if minute > "59" {
return false;
}
if value.len() >= 6 {
let second = &value[4..6];
if second > "59" {
return false;
}
}
true
}
pub fn is_timestamp(value: &str) -> bool {
if value.len() < 8 {
return false;
}
if !value.is_ascii() {
return false;
}
let date_part = &value[0..8];
if !is_date(date_part) {
return false;
}
if value.len() > 8 {
let time_part = &value[8..];
if !is_time(time_part) {
return false;
}
}
true
}
pub fn is_numeric(value: &str) -> bool {
value.parse::<f64>().is_ok()
}
pub fn is_sequence_id(value: &str) -> bool {
match value.parse::<u32>() {
Ok(num) => num > 0,
Err(_) => false,
}
}
pub fn is_text_data(_value: &str) -> bool {
true
}
pub fn is_formatted_text(_value: &str) -> bool {
true
}
pub fn is_coded_value(value: &str) -> bool {
value.chars().all(|c| c.is_ascii() && !c.is_control())
}
pub fn is_person_name(value: &str) -> bool {
value
.chars()
.all(|c| c.is_alphabetic() || c.is_whitespace() || c == '-' || c == '\'' || c == '.')
}
pub fn is_extended_id(value: &str) -> bool {
is_identifier(value)
}
pub fn is_hierarchic_designator(value: &str) -> bool {
is_identifier(value)
}
pub fn is_phone_number(value: &str) -> bool {
let cleaned: String = value.chars().filter(char::is_ascii_digit).collect();
cleaned.len() >= 7 && cleaned.len() <= 15 && cleaned.chars().all(|c| c.is_ascii_digit())
}
pub fn is_email(value: &str) -> bool {
if !value.contains('@') {
return false;
}
let parts: Vec<&str> = value.split('@').collect();
if parts.len() != 2 {
return false;
}
let local_part = parts[0];
let domain_part = parts[1];
if local_part.is_empty() || domain_part.is_empty() {
return false;
}
if !domain_part.contains('.') {
return false;
}
true
}
pub fn is_ssn(value: &str) -> bool {
let cleaned: String = value.chars().filter(char::is_ascii_digit).collect();
if cleaned.len() != 9 {
return false;
}
let area = &cleaned[0..3];
if area == "000" || area == "666" || area.starts_with('9') {
return false;
}
let group = &cleaned[3..5];
if group == "00" {
return false;
}
let serial = &cleaned[5..9];
if serial == "0000" {
return false;
}
true
}
pub fn is_valid_birth_date(value: &str) -> bool {
if !is_date(value) {
return false;
}
let current_date = chrono::Utc::now().format("%Y%m%d").to_string();
value <= current_date.as_str()
}
pub fn is_valid_age_range(birth_date: &str, reference_date: &str) -> bool {
if !is_date(birth_date) || !is_date(reference_date) {
return false;
}
birth_date <= reference_date
}
pub fn is_within_range(value: &str, min: &str, max: &str) -> bool {
let val: f64 = match value.parse() {
Ok(n) => n,
Err(_) => return false,
};
let min_val: f64 = match min.parse() {
Ok(n) => n,
Err(_) => return false,
};
let max_val: f64 = match max.parse() {
Ok(n) => n,
Err(_) => return false,
};
val >= min_val && val <= max_val
}
pub fn matches_complex_pattern(value: &str, patterns: &[&str]) -> bool {
patterns.iter().all(|pattern| {
if let Ok(regex) = Regex::new(pattern) {
regex.is_match(value)
} else {
false
}
})
}
pub fn validate_mathematical_relationship(value1: &str, value2: &str, operator: &str) -> bool {
let num1: f64 = match value1.parse() {
Ok(n) => n,
Err(_) => return false,
};
let num2: f64 = match value2.parse() {
Ok(n) => n,
Err(_) => return false,
};
match operator {
"gt" => num1 > num2,
"lt" => num1 < num2,
"ge" => num1 >= num2,
"le" => num1 <= num2,
"eq" => (num1 - num2).abs() < f64::EPSILON,
"ne" => (num1 - num2).abs() >= f64::EPSILON,
_ => false,
}
}
pub fn validate_checksum(value: &str, algorithm: &str) -> bool {
match algorithm {
"luhn" => validate_luhn_checksum(value),
"mod10" => validate_mod10_checksum(value),
_ => true, }
}
pub fn validate_luhn_checksum(value: &str) -> bool {
let digits: String = value.chars().filter(char::is_ascii_digit).collect();
if digits.len() < 2 {
return false;
}
let mut sum = 0;
let mut double = false;
for digit_char in digits.chars().rev() {
let digit = digit_char.to_digit(10).unwrap_or(0);
if double {
let doubled = digit * 2;
sum += if doubled > 9 { doubled - 9 } else { doubled };
} else {
sum += digit;
}
double = !double;
}
sum % 10 == 0
}
pub fn validate_mod10_checksum(value: &str) -> bool {
validate_luhn_checksum(value)
}
pub fn matches_format(value: &str, format: &str, datatype: &str) -> bool {
match (datatype, format) {
("DT", "YYYY-MM-DD") => {
if value.len() != 10 {
return false;
}
let parts: Vec<&str> = value.split('-').collect();
if parts.len() != 3 {
return false;
}
if parts[0].len() != 4 || !parts[0].chars().all(|c| c.is_ascii_digit()) {
return false;
}
if parts[1].len() != 2 || !parts[1].chars().all(|c| c.is_ascii_digit()) {
return false;
}
let month: u32 = parts[1].parse().unwrap_or(0);
if !(1..=12).contains(&month) {
return false;
}
if parts[2].len() != 2 || !parts[2].chars().all(|c| c.is_ascii_digit()) {
return false;
}
let day: u32 = parts[2].parse().unwrap_or(0);
if !(1..=31).contains(&day) {
return false;
}
true
}
("TM", "HH:MM:SS") => {
if value.len() != 8 {
return false;
}
let parts: Vec<&str> = value.split(':').collect();
if parts.len() != 3 {
return false;
}
if parts[0].len() != 2 || !parts[0].chars().all(|c| c.is_ascii_digit()) {
return false;
}
let hour: u32 = parts[0].parse().unwrap_or(0);
if hour > 23 {
return false;
}
if parts[1].len() != 2 || !parts[1].chars().all(|c| c.is_ascii_digit()) {
return false;
}
let minute: u32 = parts[1].parse().unwrap_or(0);
if minute > 59 {
return false;
}
if parts[2].len() != 2 || !parts[2].chars().all(|c| c.is_ascii_digit()) {
return false;
}
let second: u32 = parts[2].parse().unwrap_or(0);
if second > 59 {
return false;
}
true
}
_ => true, }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TimestampPrecision {
Year,
Month,
Day,
Hour,
Minute,
Second,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTimestamp {
pub datetime: NaiveDateTime,
pub precision: TimestampPrecision,
}
pub fn parse_hl7_ts(s: &str) -> Option<NaiveDateTime> {
let s = s.trim();
let fmts = &[
"%Y%m%d%H%M%S", "%Y%m%d%H%M", "%Y%m%d%H", ];
for f in fmts {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, f) {
return Some(dt);
}
}
if s.len() == 8
&& let Ok(d) = NaiveDate::parse_from_str(s, "%Y%m%d")
{
return d.and_hms_opt(0, 0, 0);
}
None
}
pub fn parse_hl7_ts_with_precision(s: &str) -> Option<ParsedTimestamp> {
let s = s.trim();
let formats = &[
("%Y%m%d%H%M%S", TimestampPrecision::Second), ("%Y%m%d%H%M", TimestampPrecision::Minute), ("%Y%m%d%H", TimestampPrecision::Hour), ];
for (format, precision) in formats {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, format) {
return Some(ParsedTimestamp {
datetime: dt,
precision: *precision,
});
}
}
if s.len() == 8
&& let Ok(date) = NaiveDate::parse_from_str(s, "%Y%m%d")
{
return Some(ParsedTimestamp {
datetime: date.and_hms_opt(0, 0, 0)?,
precision: TimestampPrecision::Day,
});
}
if s.len() == 6
&& let Ok(date) = NaiveDate::parse_from_str(&format!("{}01", s), "%Y%m%d")
{
return Some(ParsedTimestamp {
datetime: date.and_hms_opt(0, 0, 0)?,
precision: TimestampPrecision::Month,
});
}
if s.len() == 4
&& let Ok(date) = NaiveDate::parse_from_str(&format!("{}0101", s), "%Y%m%d")
{
return Some(ParsedTimestamp {
datetime: date.and_hms_opt(0, 0, 0)?,
precision: TimestampPrecision::Year,
});
}
None
}
pub fn compare_timestamps_for_before(a: &ParsedTimestamp, b: &ParsedTimestamp) -> bool {
if a.precision == b.precision {
return a.datetime < b.datetime;
}
let min_precision = std::cmp::min(a.precision, b.precision);
let truncated_a = truncate_to_precision(&a.datetime, min_precision);
let truncated_b = truncate_to_precision(&b.datetime, min_precision);
truncated_a < truncated_b
}
pub fn truncate_to_precision(dt: &NaiveDateTime, precision: TimestampPrecision) -> NaiveDateTime {
use chrono::{Datelike, Timelike};
match precision {
TimestampPrecision::Year => NaiveDate::from_ymd_opt(dt.year(), 1, 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.unwrap_or(*dt),
TimestampPrecision::Month => NaiveDate::from_ymd_opt(dt.year(), dt.month(), 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.unwrap_or(*dt),
TimestampPrecision::Day => dt.date().and_hms_opt(0, 0, 0).unwrap_or(*dt),
TimestampPrecision::Hour => dt
.with_minute(0)
.and_then(|d| d.with_second(0))
.unwrap_or(*dt),
TimestampPrecision::Minute => dt.with_second(0).unwrap_or(*dt),
TimestampPrecision::Second => *dt,
}
}
pub fn parse_datetime(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
if value.len() == 14
&& let Ok(dt) = chrono::NaiveDateTime::parse_from_str(value, "%Y%m%d%H%M%S")
{
return Some(dt.and_utc());
}
if value.len() == 8
&& let Ok(date) = chrono::NaiveDate::parse_from_str(value, "%Y%m%d")
{
return Some(date.and_hms_opt(0, 0, 0)?.and_utc());
}
if value.len() == 10
&& let Ok(date) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
{
return Some(date.and_hms_opt(0, 0, 0)?.and_utc());
}
None
}
#[inline]
pub fn get_nonempty<'a>(msg: &'a Message, path: &str) -> Option<&'a str> {
crate::query::get(msg, path).and_then(|s| {
let t = s.trim();
if t.is_empty() { None } else { Some(t) }
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub enum ConditionOperator {
#[default]
Eq,
Ne,
Gt,
Lt,
Ge,
Le,
In,
Contains,
Exists,
Missing,
MatchesRegex,
IsDate,
Before,
WithinRange,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleCondition {
pub field: String,
pub operator: String,
#[serde(default)]
pub value: Option<String>,
#[serde(default)]
pub values: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleAction {
pub field: String,
pub action: String,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub datatype: Option<String>,
#[serde(default)]
pub valueset: Option<String>,
}
pub fn check_rule_condition(msg: &Message, condition: &RuleCondition) -> bool {
let lhs = get_nonempty(msg, &condition.field);
let rhs_first = condition.value.as_deref();
let rhs_list: Vec<&str> = condition.values.as_ref().map_or(Vec::new(), |v| {
v.iter().map(std::string::String::as_str).collect()
});
match condition.operator.as_str() {
"eq" => match (lhs, rhs_first) {
(Some(l), Some(r)) => l == r,
(None, Some(r)) => r.is_empty(), (Some(l), None) => l.is_empty(),
(None, None) => true,
},
"ne" => match (lhs, rhs_first) {
(Some(l), Some(r)) => l != r,
(None, Some(r)) => !r.is_empty(),
(Some(l), None) => !l.is_empty(),
(None, None) => false,
},
"contains" => {
let needle = rhs_first.unwrap_or_default();
lhs.map(|l| l.contains(needle)).unwrap_or(false)
}
"in" => lhs.map(|l| rhs_list.contains(&l)).unwrap_or(false),
"matches_regex" => {
if let (Some(l), Some(pat)) = (lhs, rhs_first) {
Regex::new(pat).map(|re| re.is_match(l)).unwrap_or(false)
} else {
false
}
}
"exists" => lhs.is_some(),
"not_exists" => lhs.is_none(),
"is_date" => lhs.and_then(parse_hl7_ts_with_precision).is_some(),
"before" => {
if let Some(lhs_ts) = lhs.and_then(parse_hl7_ts_with_precision) {
let rhs_value = if let Some(rhs_field) = rhs_first {
if let Some(rhs_val) = get_nonempty(msg, rhs_field) {
Some(rhs_val)
} else {
Some(rhs_field)
}
} else {
None
};
if let Some(rhs_ts) = rhs_value.and_then(parse_hl7_ts_with_precision) {
compare_timestamps_for_before(&lhs_ts, &rhs_ts)
} else {
false
}
} else {
false
}
}
"within_range" => {
if rhs_list.len() != 2 {
return false;
}
let a = rhs_list[0];
let b = rhs_list[1];
if let (Some(l), Some(lo), Some(hi)) =
(lhs.and_then(parse_hl7_ts), parse_hl7_ts(a), parse_hl7_ts(b))
{
return l >= lo && l <= hi;
}
if let (Some(l), Ok(lo), Ok(hi)) = (lhs, a.parse::<i64>(), b.parse::<i64>())
&& let Ok(li) = l.parse::<i64>()
{
return li >= lo && li <= hi;
}
false
}
_ => {
false
}
}
}
#[cfg(test)]
pub mod tests;
#[cfg(test)]
mod legacy_tests {
use super::*;
#[test]
fn test_is_date() {
assert!(is_date("20230101"));
assert!(is_date("19991231"));
assert!(!is_date("20231301")); assert!(!is_date("20230132")); assert!(!is_date("2023010")); assert!(!is_date("202301011")); }
#[test]
fn test_is_time() {
assert!(is_time("1200"));
assert!(is_time("235959"));
assert!(is_time("0000"));
assert!(!is_time("2400")); assert!(!is_time("1260")); assert!(!is_time("123")); }
#[test]
fn test_is_timestamp() {
assert!(is_timestamp("20230101"));
assert!(is_timestamp("202301011200"));
assert!(is_timestamp("20230101120000"));
assert!(!is_timestamp("2023")); }
#[test]
fn test_is_numeric() {
assert!(is_numeric("123"));
assert!(is_numeric("123.45"));
assert!(is_numeric("-123"));
assert!(!is_numeric("abc"));
}
#[test]
fn test_is_email() {
assert!(is_email("test@example.com"));
assert!(is_email("user.name@domain.org"));
assert!(!is_email("invalid"));
assert!(!is_email("@domain.com"));
assert!(!is_email("user@"));
}
#[test]
fn test_is_ssn() {
assert!(is_ssn("123456789"));
assert!(is_ssn("123-45-6789"));
assert!(!is_ssn("000123456")); assert!(!is_ssn("666123456")); assert!(!is_ssn("123450000")); }
#[test]
fn test_validate_luhn_checksum() {
assert!(validate_luhn_checksum("4532015112830366")); assert!(!validate_luhn_checksum("4532015112830367")); }
#[test]
fn test_parse_hl7_ts() {
assert!(parse_hl7_ts("20230101").is_some());
assert!(parse_hl7_ts("202301011200").is_some());
assert!(parse_hl7_ts("20230101120000").is_some());
assert!(parse_hl7_ts("invalid").is_none());
}
#[test]
fn test_issue_creation() {
let issue = Issue::error(
"TEST_CODE",
Some("PID.5".to_string()),
"Test detail".to_string(),
);
assert_eq!(issue.code, "TEST_CODE");
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.path, Some("PID.5".to_string()));
}
#[test]
fn validation_report_normalizes_issue_contract() {
let message = crate::parser::parse(
b"MSH|^~\\&|SENDAPP|SENDFAC|RECVAPP|RECVFAC|202605030101||ADT^A01|CTRL123|P|2.5\rPID|1||\r",
)
.unwrap_or_default();
let report = ValidationReport::from_issues(
&message,
Some("adt_a01.yaml".to_string()),
vec![Issue::error(
"MISSING_REQUIRED_FIELD",
Some("PID.3".to_string()),
"PID.3 is required".to_string(),
)],
);
assert!(!report.valid);
assert_eq!(report.message_type, "ADT^A01");
assert_eq!(report.profile.as_deref(), Some("adt_a01.yaml"));
assert_eq!(report.issue_count, 1);
assert_eq!(report.issues[0].code, "missing_required_field");
assert_eq!(
report.issues[0].rule_id.as_deref(),
Some("missing_required_field")
);
assert_eq!(report.issues[0].severity, ValidationReportSeverity::Error);
assert_eq!(report.issues[0].path.as_deref(), Some("PID.3"));
assert_eq!(report.issues[0].segment_index, Some(1));
assert_eq!(report.issues[0].field_index, Some(3));
}
#[test]
fn validation_report_severity_serializes_lowercase() {
let serialized =
serde_json::to_string(&ValidationReportSeverity::Warning).unwrap_or_default();
assert_eq!(serialized, "\"warning\"");
}
}