#![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)
}
}
pub type ValidationResult = Vec<Issue>;
pub trait Validator {
fn validate(&self, msg: &Message) -> ValidationResult;
}
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()));
}
}