#![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};
use std::collections::{HashMap, HashSet};
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileLintReport {
pub valid: bool,
pub error_count: usize,
pub warning_count: usize,
pub issue_count: usize,
pub issues: Vec<ProfileLintIssue>,
}
impl ProfileLintReport {
fn from_issues(issues: Vec<ProfileLintIssue>) -> Self {
let error_count = issues
.iter()
.filter(|issue| issue.severity == ProfileLintSeverity::Error)
.count();
let warning_count = issues
.iter()
.filter(|issue| issue.severity == ProfileLintSeverity::Warning)
.count();
Self {
valid: error_count == 0,
error_count,
warning_count,
issue_count: issues.len(),
issues,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileLintIssue {
pub code: String,
pub severity: ProfileLintSeverity,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub message: String,
}
impl ProfileLintIssue {
fn error(code: &str, path: Option<String>, message: String) -> Self {
Self {
code: code.to_string(),
severity: ProfileLintSeverity::Error,
path,
message,
}
}
fn warning(code: &str, path: Option<String>, message: String) -> Self {
Self {
code: code.to_string(),
severity: ProfileLintSeverity::Warning,
path,
message,
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileLintSeverity {
Error,
Warning,
}
impl ProfileLintSeverity {
pub fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
}
}
}
pub fn lint_profile_yaml(yaml: &str) -> ProfileLintReport {
let root = match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
Ok(root) => root,
Err(err) => {
return ProfileLintReport::from_issues(vec![ProfileLintIssue::error(
"yaml_parse_error",
None,
err.to_string(),
)]);
}
};
let mut issues = Vec::new();
lint_unknown_profile_keys(&root, &mut issues);
let profile = match serde_yaml::from_value::<Profile>(root) {
Ok(profile) => profile,
Err(err) => {
issues.push(ProfileLintIssue::error(
"profile_load_error",
None,
err.to_string(),
));
return ProfileLintReport::from_issues(issues);
}
};
lint_profile(&profile, &mut issues);
ProfileLintReport::from_issues(issues)
}
fn lint_unknown_profile_keys(root: &serde_yaml::Value, issues: &mut Vec<ProfileLintIssue>) {
let Some(mapping) = root.as_mapping() else {
return;
};
let known_keys = [
"message_structure",
"version",
"message_type",
"parent",
"description",
"segments",
"constraints",
"lengths",
"valuesets",
"datatypes",
"advanced_datatypes",
"cross_field_rules",
"temporal_rules",
"contextual_rules",
"custom_rules",
"hl7_tables",
"table_precedence",
"expression_guardrails",
];
for key in mapping.keys().filter_map(serde_yaml::Value::as_str) {
if !known_keys.contains(&key) {
issues.push(ProfileLintIssue::warning(
"unknown_top_level_key",
Some(key.to_string()),
format!("top-level key '{key}' is ignored by the profile loader"),
));
}
}
}
fn lint_profile(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
lint_profile_identity(profile, issues);
lint_segments(profile, issues);
lint_constraints(profile, issues);
lint_lengths(profile, issues);
lint_value_sets(profile, issues);
lint_datatypes(profile, issues);
lint_rules(profile, issues);
lint_tables(profile, issues);
lint_custom_rules(profile, issues);
}
fn lint_profile_identity(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
if profile.message_structure.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_message_structure",
Some("message_structure".to_string()),
"message_structure must not be empty".to_string(),
));
}
if profile.version.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_version",
Some("version".to_string()),
"version must not be empty".to_string(),
));
}
}
fn lint_segments(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
let mut seen = HashSet::new();
for (index, segment) in profile.segments.iter().enumerate() {
let path = format!("segments[{index}].id");
if segment.id.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_segment_id",
Some(path),
"segment id must not be empty".to_string(),
));
} else if !seen.insert(segment.id.as_str()) {
issues.push(ProfileLintIssue::warning(
"duplicate_segment_id",
Some(path),
format!("segment '{}' is listed more than once", segment.id),
));
}
}
}
fn lint_constraints(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
for (index, constraint) in profile.constraints.iter().enumerate() {
lint_hl7_path(
&constraint.path,
format!("constraints[{index}].path"),
issues,
);
if let Some(components) = &constraint.components
&& let (Some(min), Some(max)) = (components.min, components.max)
&& min > max
{
issues.push(ProfileLintIssue::error(
"component_min_exceeds_max",
Some(format!("constraints[{index}].components")),
format!("component minimum {min} exceeds maximum {max}"),
));
}
if let Some(pattern) = &constraint.pattern {
lint_regex(
pattern,
format!("constraints[{index}].pattern"),
"invalid_constraint_pattern",
issues,
);
}
if let Some(condition) = &constraint.when {
lint_condition(condition, format!("constraints[{index}].when"), issues);
}
}
}
fn lint_condition(condition: &Condition, base_path: String, issues: &mut Vec<ProfileLintIssue>) {
if let Some(eq_conditions) = &condition.eq {
if eq_conditions.len() != 2 {
issues.push(ProfileLintIssue::error(
"invalid_condition_eq",
Some(format!("{base_path}.eq")),
"condition eq must contain exactly [field_path, expected_value]".to_string(),
));
} else if let Some(field_path) = eq_conditions.first() {
lint_hl7_path(field_path, format!("{base_path}.eq[0]"), issues);
}
}
if let Some(nested_conditions) = &condition.any {
for (index, nested) in nested_conditions.iter().enumerate() {
lint_condition(nested, format!("{base_path}.any[{index}]"), issues);
}
}
}
fn lint_lengths(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
for (index, length) in profile.lengths.iter().enumerate() {
lint_hl7_path(&length.path, format!("lengths[{index}].path"), issues);
if let Some(policy) = &length.policy
&& policy != "no-truncate"
&& policy != "may-truncate"
{
issues.push(ProfileLintIssue::warning(
"unknown_length_policy",
Some(format!("lengths[{index}].policy")),
format!("length policy '{policy}' is not recognized"),
));
}
}
}
fn lint_value_sets(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
let table_ids: HashSet<&str> = profile
.hl7_tables
.iter()
.map(|table| table.id.as_str())
.collect();
let mut names = HashSet::new();
for (index, valueset) in profile.valuesets.iter().enumerate() {
lint_hl7_path(&valueset.path, format!("valuesets[{index}].path"), issues);
if valueset.name.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_valueset_name",
Some(format!("valuesets[{index}].name")),
"value set name must not be empty".to_string(),
));
} else if !names.insert(valueset.name.as_str()) {
issues.push(ProfileLintIssue::warning(
"duplicate_valueset_name",
Some(format!("valuesets[{index}].name")),
format!("value set '{}' is defined more than once", valueset.name),
));
}
if valueset.codes.is_empty() && !table_ids.contains(valueset.name.as_str()) {
issues.push(ProfileLintIssue::warning(
"empty_valueset_without_table",
Some(format!("valuesets[{index}]")),
format!(
"value set '{}' has no inline codes and does not reference an hl7_tables id",
valueset.name
),
));
}
}
}
fn lint_datatypes(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
for (index, datatype) in profile.datatypes.iter().enumerate() {
lint_hl7_path(&datatype.path, format!("datatypes[{index}].path"), issues);
if datatype.r#type.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_datatype",
Some(format!("datatypes[{index}].type")),
"datatype must not be empty".to_string(),
));
}
}
for (index, datatype) in profile.advanced_datatypes.iter().enumerate() {
lint_hl7_path(
&datatype.path,
format!("advanced_datatypes[{index}].path"),
issues,
);
if datatype.r#type.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_advanced_datatype",
Some(format!("advanced_datatypes[{index}].type")),
"advanced datatype must not be empty".to_string(),
));
}
if let (Some(min), Some(max)) = (datatype.min_length, datatype.max_length)
&& min > max
{
issues.push(ProfileLintIssue::error(
"datatype_min_length_exceeds_max",
Some(format!("advanced_datatypes[{index}]")),
format!("minimum length {min} exceeds maximum length {max}"),
));
}
if let Some(pattern) = &datatype.pattern {
lint_regex(
pattern,
format!("advanced_datatypes[{index}].pattern"),
"invalid_datatype_pattern",
issues,
);
}
if let Some(checksum) = &datatype.checksum
&& checksum != "luhn"
&& checksum != "mod10"
{
issues.push(ProfileLintIssue::warning(
"unknown_checksum_algorithm",
Some(format!("advanced_datatypes[{index}].checksum")),
format!("checksum algorithm '{checksum}' is ignored by validation"),
));
}
}
}
fn lint_rules(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
let valueset_names: HashSet<&str> = profile
.valuesets
.iter()
.map(|valueset| valueset.name.as_str())
.collect();
lint_cross_field_rules(profile, &valueset_names, issues);
lint_temporal_rules(profile, issues);
lint_contextual_rules(profile, &valueset_names, issues);
}
fn lint_cross_field_rules(
profile: &Profile,
valueset_names: &HashSet<&str>,
issues: &mut Vec<ProfileLintIssue>,
) {
let mut ids = HashSet::new();
for (index, rule) in profile.cross_field_rules.iter().enumerate() {
let base_path = format!("cross_field_rules[{index}]");
lint_rule_id(rule.id.as_str(), &mut ids, &base_path, issues);
if rule.validation_mode != "conditional" && rule.validation_mode != "assert" {
issues.push(ProfileLintIssue::error(
"unknown_cross_field_validation_mode",
Some(format!("{base_path}.validation_mode")),
format!(
"cross-field validation mode '{}' is not supported",
rule.validation_mode
),
));
}
for (condition_index, condition) in rule.conditions.iter().enumerate() {
lint_rule_condition(
condition,
format!("{base_path}.conditions[{condition_index}]"),
issues,
);
}
for (action_index, action) in rule.actions.iter().enumerate() {
lint_rule_action(
action,
format!("{base_path}.actions[{action_index}]"),
valueset_names,
issues,
);
}
}
}
fn lint_temporal_rules(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
let mut ids = HashSet::new();
for (index, rule) in profile.temporal_rules.iter().enumerate() {
let base_path = format!("temporal_rules[{index}]");
lint_rule_id(rule.id.as_str(), &mut ids, &base_path, issues);
lint_hl7_path(&rule.before, format!("{base_path}.before"), issues);
lint_hl7_path(&rule.after, format!("{base_path}.after"), issues);
}
}
fn lint_contextual_rules(
profile: &Profile,
valueset_names: &HashSet<&str>,
issues: &mut Vec<ProfileLintIssue>,
) {
let mut ids = HashSet::new();
for (index, rule) in profile.contextual_rules.iter().enumerate() {
let base_path = format!("contextual_rules[{index}]");
lint_rule_id(rule.id.as_str(), &mut ids, &base_path, issues);
lint_hl7_path(
&rule.context_field,
format!("{base_path}.context_field"),
issues,
);
lint_hl7_path(
&rule.target_field,
format!("{base_path}.target_field"),
issues,
);
match rule.validation_type.as_str() {
"require" | "prohibit" | "validate_datatype" | "validate_valueset" => {}
validation_type => issues.push(ProfileLintIssue::error(
"unknown_contextual_validation_type",
Some(format!("{base_path}.validation_type")),
format!("contextual validation type '{validation_type}' is not supported"),
)),
}
if rule.validation_type == "validate_datatype" && !rule.parameters.contains_key("datatype")
{
issues.push(ProfileLintIssue::error(
"missing_contextual_datatype_parameter",
Some(format!("{base_path}.parameters.datatype")),
"validate_datatype requires a datatype parameter".to_string(),
));
}
if rule.validation_type == "validate_valueset" {
match rule.parameters.get("valueset") {
Some(valueset) if valueset_names.contains(valueset.as_str()) => {}
Some(valueset) => issues.push(ProfileLintIssue::error(
"unknown_contextual_valueset",
Some(format!("{base_path}.parameters.valueset")),
format!("contextual rule references undefined value set '{valueset}'"),
)),
None => issues.push(ProfileLintIssue::error(
"missing_contextual_valueset_parameter",
Some(format!("{base_path}.parameters.valueset")),
"validate_valueset requires a valueset parameter".to_string(),
)),
}
}
}
}
fn lint_rule_id(
id: &str,
seen: &mut HashSet<String>,
base_path: &str,
issues: &mut Vec<ProfileLintIssue>,
) {
if id.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_rule_id",
Some(format!("{base_path}.id")),
"rule id must not be empty".to_string(),
));
} else if !seen.insert(id.to_string()) {
issues.push(ProfileLintIssue::warning(
"duplicate_rule_id",
Some(format!("{base_path}.id")),
format!("rule id '{id}' is defined more than once in this rule family"),
));
}
}
fn lint_rule_condition(
condition: &RuleCondition,
base_path: String,
issues: &mut Vec<ProfileLintIssue>,
) {
lint_hl7_path(&condition.field, format!("{base_path}.field"), issues);
match condition.operator.as_str() {
"eq" | "ne" | "contains" | "in" | "exists" | "not_exists" | "is_date" | "before"
| "within_range" => {}
"matches_regex" => match &condition.value {
Some(pattern) => lint_regex(
pattern,
format!("{base_path}.value"),
"invalid_rule_condition_regex",
issues,
),
None => issues.push(ProfileLintIssue::error(
"missing_rule_condition_regex",
Some(format!("{base_path}.value")),
"matches_regex requires a regex pattern in value".to_string(),
)),
},
operator => issues.push(ProfileLintIssue::error(
"unknown_rule_condition_operator",
Some(format!("{base_path}.operator")),
format!("rule condition operator '{operator}' is not supported"),
)),
}
if condition.operator == "within_range" && condition.values.as_ref().map_or(0, Vec::len) != 2 {
issues.push(ProfileLintIssue::error(
"invalid_within_range_values",
Some(format!("{base_path}.values")),
"within_range requires exactly two values".to_string(),
));
}
}
fn lint_rule_action(
action: &RuleAction,
base_path: String,
valueset_names: &HashSet<&str>,
issues: &mut Vec<ProfileLintIssue>,
) {
lint_hl7_path(&action.field, format!("{base_path}.field"), issues);
match action.action.as_str() {
"require" | "prohibit" | "validate" => {}
action_type => issues.push(ProfileLintIssue::error(
"unknown_rule_action",
Some(format!("{base_path}.action")),
format!("rule action '{action_type}' is not supported"),
)),
}
if let Some(valueset) = &action.valueset
&& !valueset_names.contains(valueset.as_str())
{
issues.push(ProfileLintIssue::error(
"unknown_action_valueset",
Some(format!("{base_path}.valueset")),
format!("rule action references undefined value set '{valueset}'"),
));
}
}
fn lint_tables(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
let mut ids = HashSet::new();
let mut table_by_id: HashMap<&str, usize> = HashMap::new();
for (index, table) in profile.hl7_tables.iter().enumerate() {
if table.id.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_table_id",
Some(format!("hl7_tables[{index}].id")),
"HL7 table id must not be empty".to_string(),
));
} else {
table_by_id.insert(table.id.as_str(), index);
if !ids.insert(table.id.as_str()) {
issues.push(ProfileLintIssue::warning(
"duplicate_table_id",
Some(format!("hl7_tables[{index}].id")),
format!("HL7 table '{}' is defined more than once", table.id),
));
}
}
if table.name.trim().is_empty() {
issues.push(ProfileLintIssue::warning(
"empty_table_name",
Some(format!("hl7_tables[{index}].name")),
format!("HL7 table '{}' has an empty name", table.id),
));
}
if table.version.trim().is_empty() {
issues.push(ProfileLintIssue::warning(
"empty_table_version",
Some(format!("hl7_tables[{index}].version")),
format!("HL7 table '{}' has an empty version", table.id),
));
}
}
for (index, table_id) in profile.table_precedence.iter().enumerate() {
if !table_by_id.contains_key(table_id.as_str()) {
issues.push(ProfileLintIssue::error(
"unknown_table_precedence_entry",
Some(format!("table_precedence[{index}]")),
format!("table precedence references undefined HL7 table '{table_id}'"),
));
}
}
}
fn lint_custom_rules(profile: &Profile, issues: &mut Vec<ProfileLintIssue>) {
let mut ids = HashSet::new();
for (index, rule) in profile.custom_rules.iter().enumerate() {
let base_path = format!("custom_rules[{index}]");
lint_rule_id(rule.id.as_str(), &mut ids, &base_path, issues);
if rule.script.trim().is_empty() {
issues.push(ProfileLintIssue::error(
"empty_custom_rule_script",
Some(format!("{base_path}.script")),
format!("custom rule '{}' has an empty script", rule.id),
));
}
}
if !profile.custom_rules.is_empty() && !profile.expression_guardrails.allow_custom_scripts {
issues.push(ProfileLintIssue::warning(
"custom_rules_without_script_guardrail",
Some("expression_guardrails.allow_custom_scripts".to_string()),
"custom_rules are present but expression_guardrails.allow_custom_scripts is false"
.to_string(),
));
}
}
fn lint_hl7_path(path: &str, location: String, issues: &mut Vec<ProfileLintIssue>) {
if let Err(err) = crate::query::path::parse_path(path) {
issues.push(ProfileLintIssue::error(
"invalid_hl7_path",
Some(location),
format!("'{path}' is not a valid HL7 field path: {err}"),
));
}
}
fn lint_regex(pattern: &str, location: String, code: &str, issues: &mut Vec<ProfileLintIssue>) {
if let Err(err) = Regex::new(pattern) {
issues.push(ProfileLintIssue::error(
code,
Some(location),
format!("regex '{pattern}' failed to compile: {err}"),
));
}
}
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;