#![expect(
clippy::collapsible_if,
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
clippy::manual_let_else,
clippy::map_err_ignore,
clippy::missing_errors_doc,
clippy::string_slice,
clippy::uninlined_format_args,
reason = "Pre-existing profile implementation debt moved from hl7v2-prof; cleanup is separate from this behavior-preserving module collapse."
)]
pub use super::validation::{
Issue, ParsedTimestamp, RuleAction, RuleCondition, Severity, TimestampPrecision,
ValidationResult, Validator, check_rule_condition, compare_timestamps_for_before, get_nonempty,
is_coded_value, is_date, is_email, is_extended_id, is_formatted_text, is_hierarchic_designator,
is_identifier, is_numeric, is_person_name, is_phone_number, is_sequence_id, is_ssn, is_string,
is_text_data, is_time, is_timestamp, is_valid_age_range, is_valid_birth_date, is_within_range,
matches_complex_pattern, matches_format, parse_datetime, parse_hl7_ts,
parse_hl7_ts_with_precision, truncate_to_precision, validate_checksum, validate_data_type,
validate_luhn_checksum, validate_mathematical_relationship, validate_mod10_checksum,
};
use crate::model::{Error, Message};
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum ProfileLoadError {
#[error("YAML parse error: {0}")]
YamlParse(String),
#[error("Missing required field: {field}")]
MissingField {
field: String,
},
#[error("Invalid value for field '{field}': {details}")]
InvalidValue {
field: String,
details: String,
},
#[error("IO error: {0}")]
Io(String),
#[error("Profile inheritance cycle detected: {0}")]
InheritanceCycle(String),
#[error("Parent profile not found: {0}")]
ParentNotFound(String),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Profile not found: {0}")]
NotFound(String),
#[error("Invalid URL scheme: {0}. Only http and https are supported.")]
InvalidScheme(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("Core error: {0}")]
Core(String),
}
impl From<serde_yaml::Error> for ProfileLoadError {
fn from(err: serde_yaml::Error) -> Self {
ProfileLoadError::YamlParse(err.to_string())
}
}
impl From<std::io::Error> for ProfileLoadError {
fn from(err: std::io::Error) -> Self {
ProfileLoadError::Io(err.to_string())
}
}
impl From<Error> for ProfileLoadError {
fn from(err: Error) -> Self {
ProfileLoadError::Core(err.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Profile {
pub message_structure: String,
pub version: String,
#[serde(default)]
pub message_type: Option<String>,
#[serde(default)]
pub parent: Option<String>,
pub segments: Vec<SegmentSpec>,
#[serde(default)]
pub constraints: Vec<Constraint>,
#[serde(default)]
pub lengths: Vec<LengthConstraint>,
#[serde(default)]
pub valuesets: Vec<ValueSet>,
#[serde(default)]
pub datatypes: Vec<DataTypeConstraint>,
#[serde(default)]
pub advanced_datatypes: Vec<AdvancedDataTypeConstraint>,
#[serde(default)]
pub cross_field_rules: Vec<CrossFieldRule>,
#[serde(default)]
pub temporal_rules: Vec<TemporalRule>,
#[serde(default)]
pub contextual_rules: Vec<ContextualRule>,
#[serde(default)]
pub custom_rules: Vec<CustomRule>,
#[serde(default)]
pub hl7_tables: Vec<HL7Table>,
#[serde(default)]
pub table_precedence: Vec<String>,
#[serde(default)]
pub expression_guardrails: ExpressionGuardrails,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SegmentSpec {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Constraint {
pub path: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub components: Option<ComponentConstraint>,
#[serde(default)]
pub r#in: Option<Vec<String>>,
#[serde(default)]
pub when: Option<Condition>,
#[serde(default)]
pub pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentConstraint {
pub min: Option<usize>,
pub max: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Condition {
#[serde(default)]
pub eq: Option<Vec<String>>,
#[serde(default)]
pub any: Option<Vec<Condition>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LengthConstraint {
pub path: String,
pub max: Option<usize>,
pub policy: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValueSet {
pub path: String,
pub name: String,
#[serde(default)]
pub codes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataTypeConstraint {
pub path: String,
pub r#type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdvancedDataTypeConstraint {
pub path: String,
pub r#type: String,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub min_length: Option<usize>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub checksum: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalRule {
pub id: String,
pub description: String,
pub before: String,
pub after: String,
#[serde(default)]
pub allow_equal: bool,
#[serde(default)]
pub tolerance: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextualRule {
pub id: String,
pub description: String,
pub context_field: String,
pub context_value: String,
pub target_field: String,
pub validation_type: String,
#[serde(default)]
pub parameters: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HL7Table {
pub id: String,
pub name: String,
pub version: String,
pub codes: Vec<HL7TableEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HL7TableEntry {
pub value: String,
pub description: String,
#[serde(default)]
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossFieldRule {
pub id: String,
pub description: String,
#[serde(default = "default_validation_mode")]
pub validation_mode: String,
pub conditions: Vec<RuleCondition>,
pub actions: Vec<RuleAction>,
}
fn default_validation_mode() -> String {
"conditional".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomRule {
pub id: String,
pub description: String,
pub script: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ExpressionGuardrails {
#[serde(default)]
pub max_depth: Option<usize>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub allow_custom_scripts: bool,
}
pub fn load_profile(yaml: &str) -> Result<Profile, ProfileLoadError> {
serde_yaml::from_str(yaml).map_err(ProfileLoadError::from)
}
pub fn load_profile_checked(yaml: &str) -> Result<Profile, ProfileLoadError> {
serde_yaml::from_str(yaml).map_err(ProfileLoadError::from)
}
pub async fn load_profile_from_file(path: &str) -> Result<Profile, ProfileLoadError> {
let content = tokio::fs::read_to_string(path).await?;
load_profile_checked(&content)
}
pub fn load_profile_with_inheritance<F>(
yaml: &str,
profile_loader: F,
) -> Result<Profile, ProfileLoadError>
where
F: Fn(&str) -> Result<Profile, ProfileLoadError>,
{
let profile = load_profile_checked(yaml)?;
if let Some(parent_name) = &profile.parent {
let parent_profile = load_profile_with_inheritance_recursive(parent_name, &profile_loader)?;
return Ok(merge_profiles(parent_profile, profile));
}
Ok(profile)
}
fn load_profile_with_inheritance_recursive<F>(
parent_name: &str,
profile_loader: &F,
) -> Result<Profile, ProfileLoadError>
where
F: Fn(&str) -> Result<Profile, ProfileLoadError>,
{
let parent_profile = profile_loader(parent_name)?;
if let Some(grandparent_name) = &parent_profile.parent {
let grandparent_profile =
load_profile_with_inheritance_recursive(grandparent_name, profile_loader)?;
return Ok(merge_profiles(grandparent_profile, parent_profile));
}
Ok(parent_profile)
}
fn merge_profiles(parent: Profile, child: Profile) -> Profile {
Profile {
message_structure: child.message_structure,
version: child.version,
message_type: child.message_type.or(parent.message_type),
parent: child.parent, segments: merge_segment_specs(parent.segments, child.segments),
constraints: merge_constraints(parent.constraints, child.constraints),
lengths: merge_length_constraints(parent.lengths, child.lengths),
valuesets: merge_valuesets(parent.valuesets, child.valuesets),
datatypes: merge_datatype_constraints(parent.datatypes, child.datatypes),
advanced_datatypes: merge_advanced_datatype_constraints(
parent.advanced_datatypes,
child.advanced_datatypes,
),
cross_field_rules: merge_cross_field_rules(
parent.cross_field_rules,
child.cross_field_rules,
),
temporal_rules: merge_temporal_rules(parent.temporal_rules, child.temporal_rules),
contextual_rules: merge_contextual_rules(parent.contextual_rules, child.contextual_rules),
custom_rules: merge_custom_rules(parent.custom_rules, child.custom_rules),
hl7_tables: merge_hl7_tables(parent.hl7_tables, child.hl7_tables),
table_precedence: if child.table_precedence.is_empty() {
parent.table_precedence
} else {
child.table_precedence
},
expression_guardrails: if child.expression_guardrails == ExpressionGuardrails::default() {
parent.expression_guardrails
} else {
child.expression_guardrails
},
}
}
fn merge_segment_specs(parent: Vec<SegmentSpec>, child: Vec<SegmentSpec>) -> Vec<SegmentSpec> {
let mut result: Vec<SegmentSpec> = parent;
for child_segment in child {
if !result.iter().any(|s| s.id == child_segment.id) {
result.push(child_segment);
}
}
result
}
fn merge_constraints(parent: Vec<Constraint>, child: Vec<Constraint>) -> Vec<Constraint> {
let mut result: Vec<Constraint> = parent;
for child_constraint in child {
if let Some(pos) = result.iter().position(|c| c.path == child_constraint.path) {
result[pos] = child_constraint;
} else {
result.push(child_constraint);
}
}
result
}
fn merge_length_constraints(
parent: Vec<LengthConstraint>,
child: Vec<LengthConstraint>,
) -> Vec<LengthConstraint> {
let mut result: Vec<LengthConstraint> = parent;
for child_constraint in child {
if let Some(pos) = result.iter().position(|c| c.path == child_constraint.path) {
result[pos] = child_constraint;
} else {
result.push(child_constraint);
}
}
result
}
fn merge_valuesets(parent: Vec<ValueSet>, child: Vec<ValueSet>) -> Vec<ValueSet> {
let mut result: Vec<ValueSet> = parent;
for child_valueset in child {
if let Some(pos) = result.iter().position(|v| v.path == child_valueset.path) {
result[pos] = child_valueset;
} else {
result.push(child_valueset);
}
}
result
}
fn merge_datatype_constraints(
parent: Vec<DataTypeConstraint>,
child: Vec<DataTypeConstraint>,
) -> Vec<DataTypeConstraint> {
let mut result: Vec<DataTypeConstraint> = parent;
for child_constraint in child {
if let Some(pos) = result.iter().position(|d| d.path == child_constraint.path) {
result[pos] = child_constraint;
} else {
result.push(child_constraint);
}
}
result
}
fn merge_advanced_datatype_constraints(
parent: Vec<AdvancedDataTypeConstraint>,
child: Vec<AdvancedDataTypeConstraint>,
) -> Vec<AdvancedDataTypeConstraint> {
let mut result: Vec<AdvancedDataTypeConstraint> = parent;
for child_constraint in child {
if let Some(pos) = result.iter().position(|d| d.path == child_constraint.path) {
result[pos] = child_constraint;
} else {
result.push(child_constraint);
}
}
result
}
fn merge_cross_field_rules(
parent: Vec<CrossFieldRule>,
child: Vec<CrossFieldRule>,
) -> Vec<CrossFieldRule> {
let mut result: Vec<CrossFieldRule> = parent;
for child_rule in child {
if let Some(pos) = result.iter().position(|r| r.id == child_rule.id) {
result[pos] = child_rule;
} else {
result.push(child_rule);
}
}
result
}
fn merge_temporal_rules(parent: Vec<TemporalRule>, child: Vec<TemporalRule>) -> Vec<TemporalRule> {
let mut result: Vec<TemporalRule> = parent;
for child_rule in child {
if let Some(pos) = result.iter().position(|r| r.id == child_rule.id) {
result[pos] = child_rule;
} else {
result.push(child_rule);
}
}
result
}
fn merge_contextual_rules(
parent: Vec<ContextualRule>,
child: Vec<ContextualRule>,
) -> Vec<ContextualRule> {
let mut result: Vec<ContextualRule> = parent;
for child_rule in child {
if let Some(pos) = result.iter().position(|r| r.id == child_rule.id) {
result[pos] = child_rule;
} else {
result.push(child_rule);
}
}
result
}
fn merge_custom_rules(parent: Vec<CustomRule>, child: Vec<CustomRule>) -> Vec<CustomRule> {
let mut result: Vec<CustomRule> = parent;
for child_rule in child {
if let Some(pos) = result.iter().position(|r| r.id == child_rule.id) {
result[pos] = child_rule;
} else {
result.push(child_rule);
}
}
result
}
fn merge_hl7_tables(parent: Vec<HL7Table>, child: Vec<HL7Table>) -> Vec<HL7Table> {
let mut result: Vec<HL7Table> = parent;
for child_table in child {
if let Some(pos) = result.iter().position(|t| t.id == child_table.id) {
result[pos] = child_table;
} else {
result.push(child_table);
}
}
result
}
pub fn validate(msg: &Message, profile: &Profile) -> Vec<Issue> {
let mut issues = Vec::new();
for constraint in &profile.constraints {
if should_validate_constraint(msg, constraint) {
if constraint.required {
if let Some(path) = &constraint.path.strip_prefix("MSH.") {
validate_msh_field_required(msg, path, &mut issues);
} else {
validate_field_required(msg, &constraint.path, &mut issues);
}
}
if let Some(allowed_values) = &constraint.r#in {
validate_field_in_constraint(msg, &constraint.path, allowed_values, &mut issues);
}
}
}
for valueset in &profile.valuesets {
validate_value_set(msg, valueset, &mut issues);
}
for datatype in &profile.datatypes {
validate_data_type_constraint(msg, datatype, &mut issues);
}
for datatype in &profile.advanced_datatypes {
validate_advanced_data_type(msg, datatype, &mut issues);
}
for length in &profile.lengths {
validate_length_constraint(msg, length, &mut issues);
}
if !profile.hl7_tables.is_empty() || !profile.valuesets.is_empty() {
validate_hl7_tables_with_precedence(msg, profile, &mut issues);
}
for rule in &profile.cross_field_rules {
validate_cross_field_rule(msg, rule, profile, &mut issues);
}
for rule in &profile.temporal_rules {
validate_temporal_rule(msg, rule, &mut issues);
}
for rule in &profile.contextual_rules {
validate_contextual_rule(msg, rule, profile, &mut issues);
}
for rule in &profile.custom_rules {
validate_custom_rule(msg, rule, &mut issues);
}
issues
}
fn validate_field_required(msg: &Message, path: &str, issues: &mut Vec<Issue>) {
if let Some(value) = crate::query::get(msg, path) {
if value.is_empty() {
issues.push(Issue::error(
"MISSING_REQUIRED_FIELD",
Some(path.to_string()),
format!("Required field {} is missing", path),
));
}
} else {
issues.push(Issue::error(
"MISSING_REQUIRED_FIELD",
Some(path.to_string()),
format!("Required field {} is missing", path),
));
}
}
fn should_validate_constraint(msg: &Message, constraint: &Constraint) -> bool {
let condition = match &constraint.when {
Some(cond) => cond,
None => return true,
};
check_condition(msg, condition)
}
fn check_condition(msg: &Message, condition: &Condition) -> bool {
if let Some(eq_conditions) = &condition.eq {
if eq_conditions.len() == 2 {
let field_path = &eq_conditions[0];
let expected_value = &eq_conditions[1];
if let Some(actual_value) = crate::query::get(msg, field_path) {
return actual_value == expected_value;
}
return false;
}
}
if let Some(any_conditions) = &condition.any {
for cond in any_conditions {
if check_condition(msg, cond) {
return true;
}
}
return false;
}
false
}
fn validate_msh_field_required(msg: &Message, path: &str, issues: &mut Vec<Issue>) {
let full_path = format!("MSH.{}", path);
if crate::query::get(msg, &full_path).is_none() {
issues.push(Issue::error(
"MISSING_REQUIRED_FIELD",
Some(full_path),
format!("Required MSH field {} is missing", path),
));
}
}
fn validate_field_in_constraint(
msg: &Message,
path: &str,
allowed_values: &[String],
issues: &mut Vec<Issue>,
) {
if let Some(value) = crate::query::get(msg, path) {
if !allowed_values.contains(&value.to_string()) {
issues.push(Issue::error(
"VALUE_NOT_IN_CONSTRAINT",
Some(path.to_string()),
format!(
"Value '{}' for {} is not in allowed constraint values: {:?}",
value, path, allowed_values
),
));
}
}
}
fn validate_value_set(msg: &Message, valueset: &ValueSet, issues: &mut Vec<Issue>) {
if valueset.codes.is_empty() {
return;
}
if let Some(value) = crate::query::get(msg, &valueset.path) {
if !valueset.codes.contains(&value.to_string()) {
issues.push(Issue::error(
"VALUE_NOT_IN_SET",
Some(valueset.path.clone()),
format!(
"Value '{}' for {} is not in allowed set: {:?}",
value, valueset.path, valueset.codes
),
));
}
}
}
fn validate_data_type_constraint(
msg: &Message,
datatype: &DataTypeConstraint,
issues: &mut Vec<Issue>,
) {
if let Some(value) = crate::query::get(msg, &datatype.path) {
if !validate_data_type(value, &datatype.r#type) {
issues.push(Issue::error(
"INVALID_DATA_TYPE",
Some(datatype.path.clone()),
format!(
"Value '{}' for {} does not match expected data type {}",
value, datatype.path, datatype.r#type
),
));
}
}
}
fn validate_advanced_data_type(
msg: &Message,
datatype: &AdvancedDataTypeConstraint,
issues: &mut Vec<Issue>,
) {
if let Some(value) = crate::query::get(msg, &datatype.path) {
if !validate_data_type(value, &datatype.r#type) {
issues.push(Issue::error(
"INVALID_DATA_TYPE",
Some(datatype.path.clone()),
format!(
"Value '{}' for {} does not match expected data type {}",
value, datatype.path, datatype.r#type
),
));
return;
}
if let Some(min_length) = datatype.min_length {
if value.len() < min_length {
issues.push(Issue::error(
"VALUE_TOO_SHORT",
Some(datatype.path.clone()),
format!(
"Value '{}' for {} is shorter than minimum length of {} characters",
value, datatype.path, min_length
),
));
}
}
if let Some(max_length) = datatype.max_length {
if value.len() > max_length {
issues.push(Issue::error(
"VALUE_TOO_LONG",
Some(datatype.path.clone()),
format!(
"Value '{}' for {} exceeds maximum length of {} characters",
value, datatype.path, max_length
),
));
}
}
if let Some(pattern) = &datatype.pattern {
if let Ok(regex) = Regex::new(pattern) {
if !regex.is_match(value) {
issues.push(Issue::error(
"PATTERN_MISMATCH",
Some(datatype.path.clone()),
format!(
"Value '{}' for {} does not match required pattern '{}'",
value, datatype.path, pattern
),
));
}
}
}
if let Some(format) = &datatype.format {
if !matches_format(value, format, &datatype.r#type) {
issues.push(Issue::error(
"FORMAT_MISMATCH",
Some(datatype.path.clone()),
format!(
"Value '{}' for {} does not match required format '{}'",
value, datatype.path, format
),
));
}
}
if let Some(checksum) = &datatype.checksum {
if !validate_checksum(value, checksum) {
issues.push(Issue::error(
"CHECKSUM_MISMATCH",
Some(datatype.path.clone()),
format!("Checksum validation failed for {}", datatype.path),
));
}
}
}
}
fn validate_hl7_tables_with_precedence(msg: &Message, profile: &Profile, issues: &mut Vec<Issue>) {
let mut table_map: std::collections::HashMap<&str, &HL7Table> =
std::collections::HashMap::new();
for table in &profile.hl7_tables {
table_map.insert(&table.id, table);
}
for valueset in &profile.valuesets {
if let Some(table_id) = table_map.get(valueset.name.as_str()) {
if let Some(value) = crate::query::get(msg, &valueset.path) {
if !value.is_empty() {
let is_valid = table_id.codes.iter().any(|entry| {
entry.value == value
&& (entry.status.is_empty()
|| entry.status == "A"
|| entry.status == "active")
});
if !is_valid {
issues.push(Issue::error(
"VALUE_NOT_IN_HL7_TABLE",
Some(valueset.path.clone()),
format!(
"Value '{}' for {} is not in HL7 table {} ({})",
value, valueset.path, table_id.id, table_id.name
),
));
}
}
}
}
}
}
fn validate_length_constraint(msg: &Message, length: &LengthConstraint, issues: &mut Vec<Issue>) {
if let Some(value) = crate::query::get(msg, &length.path) {
if let Some(max_length) = length.max {
if value.len() > max_length {
issues.push(Issue::error(
"VALUE_TOO_LONG",
Some(length.path.clone()),
format!(
"Value '{}' for {} exceeds maximum length of {} characters",
value, length.path, max_length
),
));
}
}
}
}
#[expect(
dead_code,
reason = "Legacy table validator is retained for compatibility while the profile implementation is collapsed."
)]
fn validate_hl7_table(msg: &Message, table: &HL7Table, profile: &Profile, issues: &mut Vec<Issue>) {
for valueset in &profile.valuesets {
if valueset.name == table.id {
if let Some(value) = crate::query::get(msg, &valueset.path) {
if !value.is_empty() {
let is_valid = table.codes.iter().any(|entry| {
entry.value == value
&& (entry.status.is_empty()
|| entry.status == "A"
|| entry.status == "active")
});
if !is_valid {
issues.push(Issue::error(
"VALUE_NOT_IN_HL7_TABLE",
Some(valueset.path.clone()),
format!(
"Value '{}' for {} is not in HL7 table {} ({})",
value, valueset.path, table.id, table.name
),
));
}
}
}
}
}
}
fn validate_temporal_rule(msg: &Message, rule: &TemporalRule, issues: &mut Vec<Issue>) {
if let (Some(before_value), Some(after_value)) = (
crate::query::get(msg, &rule.before),
crate::query::get(msg, &rule.after),
) {
if let (Some(before_time), Some(after_time)) =
(parse_datetime(before_value), parse_datetime(after_value))
{
let is_valid = if rule.allow_equal {
before_time <= after_time
} else {
before_time < after_time
};
if !is_valid {
issues.push(Issue::error(
"TEMPORAL_RULE_VIOLATION",
Some(rule.before.clone()),
format!(
"Value '{}' for {} should be before {} for {}",
before_value, rule.before, after_value, rule.after
),
));
}
} else {
issues.push(Issue::error(
"INVALID_DATETIME",
Some(rule.before.clone()),
format!(
"Invalid date/time value for {} or {}",
rule.before, rule.after
),
));
}
}
}
fn validate_custom_rule(msg: &Message, rule: &CustomRule, issues: &mut Vec<Issue>) {
if let Err(_e) = evaluate_custom_rule_script(msg, rule, issues) {
evaluate_custom_rule_simple(msg, rule, issues);
}
}
fn evaluate_custom_rule_script(
msg: &Message,
rule: &CustomRule,
issues: &mut Vec<Issue>,
) -> Result<(), ()> {
let script = &rule.script;
if script.contains(".length() > ") {
let re = Regex::new(r"field\(([^)]+)\)\.length\(\)\s*>\s*(\d+)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
let required_length: usize = captures[2].parse().map_err(|_| ())?;
if let Some(value) = crate::query::get(msg, path) {
if value.len() <= required_length {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} length {} is not greater than {}",
path,
value.len(),
required_length
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(" in [") {
let re = Regex::new(r"field\(([^)]+)\)\s+in\s+\[([^\]]+)\]").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
let values_str = &captures[2];
if let Some(value) = crate::query::get(msg, path) {
let allowed_values: Vec<&str> = values_str
.split(',')
.map(str::trim)
.map(|s| s.trim_matches('\''))
.collect();
if !allowed_values.contains(&value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' is not in allowed set {:?}",
path, value, allowed_values
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".matches_regex(") {
let re = Regex::new(r"field\(([^)]+)\)\.matches_regex\('([^']+)'\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
let pattern = &captures[2];
if let Some(value) = crate::query::get(msg, path) {
let regex = Regex::new(pattern).map_err(|_| ())?;
if !regex.is_match(value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' does not match pattern '{}'",
path, value, pattern
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".starts_with(") {
let re = Regex::new(r"field\(([^)]+)\)\.starts_with\('([^']+)'\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
let prefix = &captures[2];
if let Some(value) = crate::query::get(msg, path) {
if !value.starts_with(prefix) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' does not start with '{}'",
path, value, prefix
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".ends_with(") {
let re = Regex::new(r"field\(([^)]+)\)\.ends_with\('([^']+)'\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
let suffix = &captures[2];
if let Some(value) = crate::query::get(msg, path) {
if !value.ends_with(suffix) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' does not end with '{}'",
path, value, suffix
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".is_numeric()") {
let re = Regex::new(r"field\(([^)]+)\)\.is_numeric\(\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
if let Some(value) = crate::query::get(msg, path) {
if !value.chars().all(|c| c.is_ascii_digit()) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!("Field {} value '{}' is not numeric", path, value)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(" == field(") {
let re = Regex::new(r"field\(([^)]+)\)\s*==\s*field\(([^)]+)\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path1 = &captures[1];
let path2 = &captures[2];
if let (Some(value1), Some(value2)) =
(crate::query::get(msg, path1), crate::query::get(msg, path2))
{
if value1 != value2 {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path1.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' does not equal field {} value '{}'",
path1, value1, path2, value2
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".is_phone_number()") {
let re = Regex::new(r"field\(([^)]+)\)\.is_phone_number\(\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
if let Some(value) = crate::query::get(msg, path) {
if !is_phone_number(value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' is not a valid phone number",
path, value
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".is_email()") {
let re = Regex::new(r"field\(([^)]+)\)\.is_email\(\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
if let Some(value) = crate::query::get(msg, path) {
if !is_email(value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' is not a valid email address",
path, value
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".is_ssn()") {
let re = Regex::new(r"field\(([^)]+)\)\.is_ssn\(\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
if let Some(value) = crate::query::get(msg, path) {
if !is_ssn(value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!("Field {} value '{}' is not a valid SSN", path, value)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(".is_valid_birth_date()") {
let re = Regex::new(r"field\(([^)]+)\)\.is_valid_birth_date\(\)").map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
if let Some(value) = crate::query::get(msg, path) {
if !is_valid_birth_date(value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!("Field {} value '{}' is not a valid birth date", path, value)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains("is_valid_age_range(") {
let re = Regex::new(r"is_valid_age_range\(field\(([^)]+)\),\s*field\(([^)]+)\)\)")
.map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path1 = &captures[1];
let path2 = &captures[2];
if let (Some(value1), Some(value2)) =
(crate::query::get(msg, path1), crate::query::get(msg, path2))
{
if !is_valid_age_range(value1, value2) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path1.to_string()),
if rule.description.is_empty() {
format!("Age range between {} and {} is not valid", path1, path2)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
if script.contains(" between ") && script.contains(" and ") {
let re = Regex::new(r"field\(([^)]+)\)\s+between\s+([^\s]+)\s+and\s+([^\s]+)")
.map_err(|_| ())?;
if let Some(captures) = re.captures(script) {
let path = &captures[1];
let min_val = &captures[2];
let max_val = &captures[3];
if let Some(value) = crate::query::get(msg, path) {
if !is_within_range(value, min_val, max_val) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' is not between {} and {}",
path, value, min_val, max_val
)
} else {
rule.description.clone()
},
));
}
}
return Ok(());
}
}
Err(())
}
fn evaluate_custom_rule_simple(msg: &Message, rule: &CustomRule, issues: &mut Vec<Issue>) {
if rule.script.starts_with("field(") && rule.script.contains(").length() > ") {
if let Some(path_end) = rule.script.find(").length() > ") {
let path = &rule.script[6..path_end];
if let Some(value) = crate::query::get(msg, path) {
let length_str = &rule.script[path_end + 13..];
if let Ok(required_length) = length_str.parse::<usize>() {
if value.len() <= required_length {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} length {} is not greater than {}",
path,
value.len(),
required_length
)
} else {
rule.description.clone()
},
));
}
}
}
}
} else if rule.script.starts_with("field(") && rule.script.contains(") in [") {
if let Some(path_end) = rule.script.find(") in [") {
let path = &rule.script[6..path_end];
if let Some(value) = crate::query::get(msg, path) {
let values_part = &rule.script[path_end + 7..];
if let Some(values_str) = values_part.strip_suffix("]") {
let allowed_values: Vec<&str> = values_str
.split(',')
.map(str::trim)
.map(|s| s.trim_matches('\''))
.collect();
if !allowed_values.contains(&value) {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' is not in allowed set {:?}",
path, value, allowed_values
)
} else {
rule.description.clone()
},
));
}
}
}
}
} else if rule.script.starts_with("field(") && rule.script.contains(").matches_regex(") {
if let Some(path_end) = rule.script.find(").matches_regex(") {
let path = &rule.script[6..path_end];
if let Some(value) = crate::query::get(msg, path) {
let pattern_part = &rule.script[path_end + 15..];
if pattern_part.starts_with('\'') && pattern_part.ends_with("')") {
let pattern = &pattern_part[1..pattern_part.len() - 2];
if !value.contains(pattern) && pattern != ".*" {
issues.push(Issue::error(
"CUSTOM_RULE_VIOLATION",
Some(path.to_string()),
if rule.description.is_empty() {
format!(
"Field {} value '{}' does not match pattern '{}'",
path, value, pattern
)
} else {
rule.description.clone()
},
));
}
}
}
}
}
}
fn validate_cross_field_rule(
msg: &Message,
rule: &CrossFieldRule,
profile: &Profile,
issues: &mut Vec<Issue>,
) {
let conditions_met = rule
.conditions
.iter()
.all(|condition| check_rule_condition(msg, condition));
match rule.validation_mode.as_str() {
"assert" => {
if !conditions_met {
issues.push(Issue::error(
"CROSS_FIELD_ASSERTION_FAILED",
None,
format!(
"Cross-field assertion failed: {} ({})",
rule.description, rule.id
),
));
}
}
_ => {
if conditions_met {
for action in &rule.actions {
execute_rule_action(msg, action, rule, profile, issues);
}
}
}
}
}
fn execute_rule_action(
msg: &Message,
action: &RuleAction,
rule: &CrossFieldRule,
profile: &Profile,
issues: &mut Vec<Issue>,
) {
match action.action.as_str() {
"require" => {
if let Some(value) = crate::query::get(msg, &action.field) {
if value.is_empty() {
issues.push(Issue::error(
"CROSS_FIELD_VALIDATION_ERROR",
Some(action.field.clone()),
action.message.clone().unwrap_or_else(|| {
format!(
"Field {} is required by cross-field rule {}",
action.field, rule.id
)
}),
));
}
} else {
issues.push(Issue::error(
"CROSS_FIELD_VALIDATION_ERROR",
Some(action.field.clone()),
action.message.clone().unwrap_or_else(|| {
format!(
"Field {} is required by cross-field rule {}",
action.field, rule.id
)
}),
));
}
}
"prohibit" => {
if let Some(value) = crate::query::get(msg, &action.field) {
if !value.is_empty() {
issues.push(Issue::error(
"CROSS_FIELD_VALIDATION_ERROR",
Some(action.field.clone()),
action.message.clone().unwrap_or_else(|| {
format!(
"Field {} is prohibited by cross-field rule {}",
action.field, rule.id
)
}),
));
}
}
}
"validate" => {
if let Some(value) = crate::query::get(msg, &action.field) {
if !value.is_empty() {
if let Some(datatype) = &action.datatype {
if !validate_data_type(value, datatype) {
issues.push(Issue::error(
"CROSS_FIELD_VALIDATION_ERROR",
Some(action.field.clone()),
action.message.clone().unwrap_or_else(||
format!("Field {} does not match data type {} required by cross-field rule {}",
action.field, datatype, rule.id)),
));
}
}
if let Some(valueset_name) = &action.valueset {
if let Some(valueset) = find_valueset_by_name(profile, valueset_name) {
if !valueset.codes.contains(&value.to_string()) {
issues.push(Issue::error(
"CROSS_FIELD_VALIDATION_ERROR",
Some(action.field.clone()),
action.message.clone().unwrap_or_else(||
format!("Value '{}' for {} is not in value set {} required by cross-field rule {}",
value, action.field, valueset_name, rule.id)),
));
}
}
}
}
}
}
_ => {
}
}
}
fn validate_contextual_rule(
msg: &Message,
rule: &ContextualRule,
profile: &Profile,
issues: &mut Vec<Issue>,
) {
if let Some(context_value) = crate::query::get(msg, &rule.context_field) {
if context_value == rule.context_value {
match rule.validation_type.as_str() {
"require" => {
if let Some(value) = crate::query::get(msg, &rule.target_field) {
if value.is_empty() {
issues.push(Issue::error(
"CONTEXTUAL_VALIDATION_ERROR",
Some(rule.target_field.clone()),
if rule.description.is_empty() {
format!(
"Field {} is required when {} equals {}",
rule.target_field, rule.context_field, rule.context_value
)
} else {
rule.description.clone()
},
));
}
} else {
issues.push(Issue::error(
"CONTEXTUAL_VALIDATION_ERROR",
Some(rule.target_field.clone()),
if rule.description.is_empty() {
format!(
"Field {} is required when {} equals {}",
rule.target_field, rule.context_field, rule.context_value
)
} else {
rule.description.clone()
},
));
}
}
"prohibit" => {
if let Some(value) = crate::query::get(msg, &rule.target_field) {
if !value.is_empty() {
issues.push(Issue::error(
"CONTEXTUAL_VALIDATION_ERROR",
Some(rule.target_field.clone()),
if rule.description.is_empty() {
format!(
"Field {} is prohibited when {} equals {}",
rule.target_field, rule.context_field, rule.context_value
)
} else {
rule.description.clone()
},
));
}
}
}
"validate_datatype" => {
if let Some(datatype) = rule.parameters.get("datatype") {
if let Some(value) = crate::query::get(msg, &rule.target_field) {
if !validate_data_type(value, datatype) {
issues.push(Issue::error(
"CONTEXTUAL_VALIDATION_ERROR",
Some(rule.target_field.clone()),
if rule.description.is_empty() {
format!("Field {} does not match data type {} required when {} equals {}",
rule.target_field, datatype, rule.context_field, rule.context_value)
} else {
rule.description.clone()
},
));
}
}
}
}
"validate_valueset" => {
if let Some(valueset_name) = rule.parameters.get("valueset") {
if let Some(value) = crate::query::get(msg, &rule.target_field) {
if let Some(valueset) = find_valueset_by_name(profile, valueset_name) {
if !valueset.codes.contains(&value.to_string()) {
issues.push(Issue::error(
"CONTEXTUAL_VALIDATION_ERROR",
Some(rule.target_field.clone()),
if rule.description.is_empty() {
format!("Value '{}' for {} is not in value set {} required when {} equals {}",
value, rule.target_field, valueset_name, rule.context_field, rule.context_value)
} else {
rule.description.clone()
},
));
}
}
}
}
}
_ => {
}
}
}
}
}
fn find_valueset_by_name<'a>(profile: &'a Profile, name: &str) -> Option<&'a ValueSet> {
profile
.valuesets
.iter()
.find(|valueset| valueset.name == name)
}
pub mod loader;
#[cfg(feature = "persistent-cache")]
pub mod persistent_cache;
#[cfg(test)]
mod tests;