use crate::error::{QcError, QcIssue, QcResult, Severity};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum QcValue {
Number(f64),
Text(String),
}
impl QcValue {
#[must_use]
pub const fn as_number(&self) -> Option<f64> {
match self {
Self::Number(n) => Some(*n),
Self::Text(_) => None,
}
}
#[must_use]
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s.as_str()),
Self::Number(_) => None,
}
}
}
impl From<f64> for QcValue {
fn from(value: f64) -> Self {
Self::Number(value)
}
}
impl From<String> for QcValue {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for QcValue {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
type CustomRuleFn = Box<dyn Fn(&HashMap<String, QcValue>) -> bool + Send + Sync>;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct QualityRule {
pub id: String,
pub name: String,
pub description: String,
pub category: RuleCategory,
pub severity: Severity,
pub priority: i32,
pub rule_type: RuleType,
pub config: RuleConfig,
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RuleCategory {
Raster,
Vector,
Metadata,
Topology,
Attribution,
General,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum RuleType {
Threshold {
field: String,
operator: ComparisonOperator,
value: f64,
},
Range {
field: String,
min: f64,
max: f64,
},
Enumeration {
field: String,
allowed_values: Vec<String>,
},
Pattern {
field: String,
pattern: String,
},
Custom {
function_name: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ComparisonOperator {
Equal,
NotEqual,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct RuleConfig {
pub parameters: HashMap<String, String>,
pub pass_threshold: Option<f64>,
pub fail_threshold: Option<f64>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RuleSet {
pub name: String,
pub description: String,
pub version: String,
pub rules: Vec<QualityRule>,
}
impl RuleSet {
#[must_use]
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
version: "1.0".to_string(),
rules: Vec::new(),
}
}
pub fn add_rule(&mut self, rule: QualityRule) {
self.rules.push(rule);
}
pub fn from_toml_file(path: impl AsRef<std::path::Path>) -> QcResult<Self> {
let content = std::fs::read_to_string(path).map_err(QcError::Io)?;
let ruleset: RuleSet = toml::from_str(&content)?;
Ok(ruleset)
}
pub fn to_toml_file(&self, path: impl AsRef<std::path::Path>) -> QcResult<()> {
let content = toml::to_string_pretty(self).map_err(|e| {
QcError::InvalidConfiguration(format!("Failed to serialize rule set: {}", e))
})?;
std::fs::write(path, content).map_err(QcError::Io)?;
Ok(())
}
#[must_use]
pub fn get_enabled_rules(&self) -> Vec<&QualityRule> {
let mut rules: Vec<&QualityRule> = self.rules.iter().filter(|r| r.enabled).collect();
rules.sort_by_key(|x| std::cmp::Reverse(x.priority));
rules
}
#[must_use]
pub fn get_rules_by_category(&self, category: RuleCategory) -> Vec<&QualityRule> {
self.rules
.iter()
.filter(|r| r.category == category)
.collect()
}
}
pub struct RulesEngine {
rule_set: RuleSet,
custom_fns: HashMap<String, CustomRuleFn>,
}
impl RulesEngine {
#[must_use]
pub fn new(rule_set: RuleSet) -> Self {
Self {
rule_set,
custom_fns: HashMap::new(),
}
}
pub fn from_toml_file(path: impl AsRef<std::path::Path>) -> QcResult<Self> {
let rule_set = RuleSet::from_toml_file(path)?;
Ok(Self::new(rule_set))
}
pub fn register_custom_fn<F>(&mut self, function_name: impl Into<String>, handler: F)
where
F: Fn(&HashMap<String, QcValue>) -> bool + Send + Sync + 'static,
{
self.custom_fns
.insert(function_name.into(), Box::new(handler));
}
pub fn execute_rule(
&self,
rule: &QualityRule,
data: &HashMap<String, QcValue>,
) -> QcResult<Option<QcIssue>> {
if !rule.enabled {
return Ok(None);
}
let violated = match &rule.rule_type {
RuleType::Threshold {
field,
operator,
value,
} => match data.get(field).and_then(QcValue::as_number) {
Some(field_value) => !self.compare_values(field_value, *value, *operator),
None => true, },
RuleType::Range { field, min, max } => {
match data.get(field).and_then(QcValue::as_number) {
Some(field_value) => field_value < *min || field_value > *max,
None => true, }
}
RuleType::Enumeration {
field,
allowed_values,
} => match data.get(field).and_then(QcValue::as_text) {
Some(text) => !allowed_values.iter().any(|allowed| allowed == text),
None => true, },
RuleType::Pattern { field, pattern } => {
match data.get(field).and_then(QcValue::as_text) {
Some(text) => {
let re = regex::Regex::new(pattern).map_err(|e| {
QcError::InvalidConfiguration(format!(
"Rule '{}': invalid pattern '{}': {e}",
rule.id, pattern
))
})?;
!re.is_match(text)
}
None => true, }
}
RuleType::Custom { function_name } => match self.custom_fns.get(function_name) {
Some(handler) => handler(data),
None => {
return Err(QcError::InvalidConfiguration(format!(
"Rule '{}': custom function '{}' is not registered",
rule.id, function_name
)));
}
},
};
if violated {
Ok(Some(
QcIssue::new(
rule.severity,
format!("{:?}", rule.category).to_lowercase(),
&rule.name,
format!("{}: Rule violated", rule.description),
)
.with_rule_id(&rule.id),
))
} else {
Ok(None)
}
}
pub fn execute_all(&self, data: &HashMap<String, QcValue>) -> QcResult<Vec<QcIssue>> {
let mut issues = Vec::new();
for rule in self.rule_set.get_enabled_rules() {
if let Some(issue) = self.execute_rule(rule, data)? {
issues.push(issue);
}
}
Ok(issues)
}
pub fn execute_category(
&self,
category: RuleCategory,
data: &HashMap<String, QcValue>,
) -> QcResult<Vec<QcIssue>> {
let mut issues = Vec::new();
for rule in self.rule_set.get_rules_by_category(category) {
if let Some(issue) = self.execute_rule(rule, data)? {
issues.push(issue);
}
}
Ok(issues)
}
#[must_use]
pub const fn rule_set(&self) -> &RuleSet {
&self.rule_set
}
fn compare_values(&self, a: f64, b: f64, op: ComparisonOperator) -> bool {
match op {
ComparisonOperator::Equal => (a - b).abs() < f64::EPSILON,
ComparisonOperator::NotEqual => (a - b).abs() >= f64::EPSILON,
ComparisonOperator::GreaterThan => a > b,
ComparisonOperator::GreaterThanOrEqual => a >= b,
ComparisonOperator::LessThan => a < b,
ComparisonOperator::LessThanOrEqual => a <= b,
}
}
}
pub struct RuleBuilder {
rule: QualityRule,
}
impl RuleBuilder {
#[must_use]
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
rule: QualityRule {
id: id.into(),
name: name.into(),
description: String::new(),
category: RuleCategory::General,
severity: Severity::Warning,
priority: 0,
rule_type: RuleType::Custom {
function_name: "default".to_string(),
},
config: RuleConfig::default(),
enabled: true,
},
}
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.rule.description = description.into();
self
}
#[must_use]
pub const fn category(mut self, category: RuleCategory) -> Self {
self.rule.category = category;
self
}
#[must_use]
pub const fn severity(mut self, severity: Severity) -> Self {
self.rule.severity = severity;
self
}
#[must_use]
pub const fn priority(mut self, priority: i32) -> Self {
self.rule.priority = priority;
self
}
#[must_use]
pub fn threshold(
mut self,
field: impl Into<String>,
operator: ComparisonOperator,
value: f64,
) -> Self {
self.rule.rule_type = RuleType::Threshold {
field: field.into(),
operator,
value,
};
self
}
#[must_use]
pub fn range(mut self, field: impl Into<String>, min: f64, max: f64) -> Self {
self.rule.rule_type = RuleType::Range {
field: field.into(),
min,
max,
};
self
}
#[must_use]
pub fn enumeration(mut self, field: impl Into<String>, allowed_values: Vec<String>) -> Self {
self.rule.rule_type = RuleType::Enumeration {
field: field.into(),
allowed_values,
};
self
}
#[must_use]
pub fn pattern(mut self, field: impl Into<String>, pattern: impl Into<String>) -> Self {
self.rule.rule_type = RuleType::Pattern {
field: field.into(),
pattern: pattern.into(),
};
self
}
#[must_use]
pub fn custom(mut self, function_name: impl Into<String>) -> Self {
self.rule.rule_type = RuleType::Custom {
function_name: function_name.into(),
};
self
}
#[must_use]
pub fn build(self) -> QualityRule {
self.rule
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rule_builder() {
let rule = RuleBuilder::new("TEST-001", "Test Rule")
.description("Test description")
.category(RuleCategory::Raster)
.severity(Severity::Major)
.priority(10)
.threshold("field1", ComparisonOperator::GreaterThan, 100.0)
.build();
assert_eq!(rule.id, "TEST-001");
assert_eq!(rule.name, "Test Rule");
assert_eq!(rule.category, RuleCategory::Raster);
assert_eq!(rule.severity, Severity::Major);
assert_eq!(rule.priority, 10);
}
#[test]
fn test_rule_set() {
let mut ruleset = RuleSet::new("Test Rules", "Test rule set");
let rule = RuleBuilder::new("R001", "Rule 1")
.threshold("value", ComparisonOperator::LessThan, 50.0)
.build();
ruleset.add_rule(rule);
assert_eq!(ruleset.rules.len(), 1);
}
#[test]
fn test_rules_engine() {
let mut ruleset = RuleSet::new("Test", "Test");
let rule = RuleBuilder::new("R001", "Max Value Check")
.threshold("max_value", ComparisonOperator::LessThanOrEqual, 100.0)
.severity(Severity::Major)
.build();
ruleset.add_rule(rule);
let engine = RulesEngine::new(ruleset);
let mut data = HashMap::new();
data.insert("max_value".to_string(), QcValue::Number(150.0));
let result = engine.execute_all(&data);
assert!(result.is_ok());
let issues = result.ok().unwrap_or_default();
assert_eq!(issues.len(), 1);
}
#[test]
fn test_enumeration_rule_pass_and_violate() {
let mut ruleset = RuleSet::new("Test", "Test");
let rule = RuleBuilder::new("R-ENUM", "Land Cover Enum")
.enumeration(
"land_cover",
vec!["forest".to_string(), "water".to_string()],
)
.severity(Severity::Minor)
.build();
ruleset.add_rule(rule);
let engine = RulesEngine::new(ruleset);
let mut passing = HashMap::new();
passing.insert(
"land_cover".to_string(),
QcValue::Text("forest".to_string()),
);
let issues = engine
.execute_all(&passing)
.expect("enumeration rule should execute for a valid value");
assert!(
issues.is_empty(),
"an allowed enumeration value must not raise an issue"
);
let mut violating = HashMap::new();
violating.insert("land_cover".to_string(), QcValue::Text("urban".to_string()));
let issues = engine
.execute_all(&violating)
.expect("enumeration rule should execute for an invalid value");
assert_eq!(
issues.len(),
1,
"a value outside the allowed enumeration must raise an issue"
);
let empty: HashMap<String, QcValue> = HashMap::new();
let issues = engine
.execute_all(&empty)
.expect("enumeration rule should execute when the field is missing");
assert_eq!(issues.len(), 1, "a missing field must raise an issue");
}
#[test]
fn test_pattern_rule_match_and_violate() {
let mut ruleset = RuleSet::new("Test", "Test");
let rule = RuleBuilder::new("R-PATTERN", "ID Format")
.pattern("station_id", r"^ST-\d{3}$")
.severity(Severity::Major)
.build();
ruleset.add_rule(rule);
let engine = RulesEngine::new(ruleset);
let mut passing = HashMap::new();
passing.insert(
"station_id".to_string(),
QcValue::Text("ST-042".to_string()),
);
let issues = engine
.execute_all(&passing)
.expect("pattern rule should execute for a matching value");
assert!(
issues.is_empty(),
"a matching pattern must not raise an issue"
);
let mut violating = HashMap::new();
violating.insert(
"station_id".to_string(),
QcValue::Text("bad-id".to_string()),
);
let issues = engine
.execute_all(&violating)
.expect("pattern rule should execute for a non-matching value");
assert_eq!(
issues.len(),
1,
"a value not matching the pattern must raise an issue"
);
}
#[test]
fn test_pattern_rule_invalid_regex_is_error() {
let mut ruleset = RuleSet::new("Test", "Test");
let rule = RuleBuilder::new("R-BAD-PATTERN", "Broken Pattern")
.pattern("field", "(unterminated")
.build();
ruleset.add_rule(rule);
let engine = RulesEngine::new(ruleset);
let mut data = HashMap::new();
data.insert("field".to_string(), QcValue::Text("x".to_string()));
let result = engine.execute_all(&data);
assert!(
result.is_err(),
"an invalid regex pattern must surface as an error"
);
}
#[test]
fn test_custom_rule_registered_and_unregistered() {
let mut ruleset = RuleSet::new("Test", "Test");
let rule = RuleBuilder::new("R-CUSTOM", "Custom Rule")
.custom("always_violate")
.build();
ruleset.add_rule(rule);
let engine = RulesEngine::new(ruleset.clone());
let data: HashMap<String, QcValue> = HashMap::new();
let result = engine.execute_all(&data);
assert!(
result.is_err(),
"an unregistered custom function must be reported as an error"
);
let mut engine = RulesEngine::new(ruleset);
engine.register_custom_fn("always_violate", |_data| true);
let issues = engine
.execute_all(&data)
.expect("registered custom function should execute successfully");
assert_eq!(
issues.len(),
1,
"the registered custom handler's result must be honored"
);
let mut engine_pass = RulesEngine::new(engine.rule_set().clone());
engine_pass.register_custom_fn("always_violate", |_data| false);
let issues = engine_pass
.execute_all(&data)
.expect("registered custom function should execute successfully");
assert!(
issues.is_empty(),
"a custom handler returning false must not raise an issue"
);
}
#[test]
fn test_comparison_operators() {
let engine = RulesEngine::new(RuleSet::new("Test", "Test"));
assert!(engine.compare_values(10.0, 5.0, ComparisonOperator::GreaterThan));
assert!(engine.compare_values(5.0, 10.0, ComparisonOperator::LessThan));
assert!(engine.compare_values(10.0, 10.0, ComparisonOperator::Equal));
assert!(engine.compare_values(10.0, 5.0, ComparisonOperator::NotEqual));
}
}