use serde::{Deserialize, Serialize};
use serde_json::Value;
use devops_models::models::validation::{Diagnostic, Severity};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: String,
pub condition: String,
pub severity: String,
pub message: String,
}
#[allow(missing_docs)]
#[derive(Debug, Clone)]
pub enum RuleCondition {
JsonPath(String),
Equals { path: String, value: Value },
Comparison { path: String, op: CompareOp, value: Value },
NullCheck { path: String, is_null: bool },
Contains { path: String, substring: String },
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy)]
pub enum CompareOp {
Eq,
Ne,
Gt,
Gte,
Lt,
Lte,
}
pub struct RuleEngine {
rules: Vec<Rule>,
}
impl Default for RuleEngine {
fn default() -> Self {
Self::new()
}
}
impl RuleEngine {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn with_rules(rules: Vec<Rule>) -> Self {
Self { rules }
}
pub fn add_rule(&mut self, rule: Rule) {
self.rules.push(rule);
}
pub fn evaluate(&self, data: &Value) -> Vec<Diagnostic> {
self.rules
.iter()
.filter_map(|rule| self.evaluate_rule(rule, data))
.collect()
}
fn evaluate_rule(&self, rule: &Rule, data: &Value) -> Option<Diagnostic> {
let condition = parse_condition(&rule.condition)?;
let matches = evaluate_condition(&condition, data);
if matches {
Some(Diagnostic {
severity: parse_severity(&rule.severity),
message: interpolate_message(&rule.message, data),
path: extract_path_from_condition(&condition),
})
} else {
None
}
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
}
fn parse_condition(condition: &str) -> Option<RuleCondition> {
let condition = condition.trim();
if condition.ends_with("== null") {
let path = condition.strip_suffix("== null")?.trim().strip_prefix('$')?;
return Some(RuleCondition::NullCheck {
path: path.to_string(),
is_null: true,
});
}
if condition.ends_with("!= null") {
let path = condition.strip_suffix("!= null")?.trim().strip_prefix('$')?;
return Some(RuleCondition::NullCheck {
path: path.to_string(),
is_null: false,
});
}
if let Some(rest) = condition.strip_prefix('$')
&& let Some(pos) = rest.find(" contains ")
{
let path = rest[..pos].trim();
let substring = rest[pos + 10..].trim().trim_matches('"');
return Some(RuleCondition::Contains {
path: path.to_string(),
substring: substring.to_string(),
});
}
for op_str in ["==", "!=", ">=", "<=", ">", "<"] {
if let Some(pos) = condition.find(op_str) {
let left = condition[..pos].trim().strip_prefix('$')?;
let right = condition[pos + op_str.len()..].trim();
let value = parse_value(right)?;
let op = match op_str {
"==" => CompareOp::Eq,
"!=" => CompareOp::Ne,
">=" => CompareOp::Gte,
"<=" => CompareOp::Lte,
">" => CompareOp::Gt,
"<" => CompareOp::Lt,
_ => return None,
};
return Some(RuleCondition::Comparison {
path: left.to_string(),
op,
value,
});
}
}
Some(RuleCondition::JsonPath(condition.to_string()))
}
fn parse_value(s: &str) -> Option<Value> {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
return Some(Value::String(s[1..s.len() - 1].to_string()));
}
if s == "true" {
return Some(Value::Bool(true));
}
if s == "false" {
return Some(Value::Bool(false));
}
if let Ok(n) = s.parse::<i64>() {
return Some(Value::Number(n.into()));
}
if let Ok(n) = s.parse::<f64>() {
return Some(Value::Number(serde_json::Number::from_f64(n)?));
}
if s == "null" {
return Some(Value::Null);
}
None
}
fn evaluate_condition(condition: &RuleCondition, data: &Value) -> bool {
match condition {
RuleCondition::JsonPath(_expr) => {
false
}
RuleCondition::Equals { path, value } => {
let actual = get_value_at_path(data, path);
actual.as_ref() == Some(value)
}
RuleCondition::Comparison { path, op, value } => {
let actual = match get_value_at_path(data, path) {
Some(v) => v,
None => return false,
};
compare_values(&actual, *op, value)
}
RuleCondition::NullCheck { path, is_null } => {
let actual = get_value_at_path(data, path);
let is_actually_null = actual.as_ref().is_none_or(|v| v.is_null());
is_actually_null == *is_null
}
RuleCondition::Contains { path, substring } => {
let actual = match get_value_at_path(data, path) {
Some(v) => v,
None => return false,
};
actual
.as_str()
.map(|s| s.contains(substring))
.unwrap_or(false)
}
}
}
fn get_value_at_path(data: &Value, path: &str) -> Option<Value> {
let mut current = data;
for segment in path.split('.') {
if segment.is_empty() {
continue;
}
if segment.ends_with(']') {
let open_bracket = segment.find('[')?;
let field = &segment[..open_bracket];
let index_str = &segment[open_bracket + 1..segment.len() - 1];
let index: usize = index_str.parse().ok()?;
current = current.get(field)?.get(index)?;
} else {
current = current.get(segment)?;
}
}
Some(current.clone())
}
fn compare_values(actual: &Value, op: CompareOp, expected: &Value) -> bool {
match (actual, expected) {
(Value::Number(a), Value::Number(b)) => {
let a_val = a.as_f64().unwrap_or(0.0);
let b_val = b.as_f64().unwrap_or(0.0);
match op {
CompareOp::Eq => (a_val - b_val).abs() < f64::EPSILON,
CompareOp::Ne => (a_val - b_val).abs() >= f64::EPSILON,
CompareOp::Gt => a_val > b_val,
CompareOp::Gte => a_val >= b_val,
CompareOp::Lt => a_val < b_val,
CompareOp::Lte => a_val <= b_val,
}
}
(Value::String(a), Value::String(b)) => match op {
CompareOp::Eq => a == b,
CompareOp::Ne => a != b,
_ => false,
},
(Value::Bool(a), Value::Bool(b)) => match op {
CompareOp::Eq => a == b,
CompareOp::Ne => a != b,
_ => false,
},
_ => false,
}
}
fn parse_severity(s: &str) -> Severity {
match s.to_lowercase().as_str() {
"error" => Severity::Error,
"warning" => Severity::Warning,
"info" => Severity::Info,
"hint" => Severity::Hint,
_ => Severity::Warning,
}
}
fn interpolate_message(message: &str, _data: &Value) -> String {
message.to_string()
}
fn extract_path_from_condition(condition: &RuleCondition) -> Option<String> {
match condition {
RuleCondition::JsonPath(_) => None,
RuleCondition::Equals { path, .. } => Some(path.clone()),
RuleCondition::Comparison { path, .. } => Some(path.clone()),
RuleCondition::NullCheck { path, .. } => Some(path.clone()),
RuleCondition::Contains { path, .. } => Some(path.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_null_check() {
let condition = parse_condition("$.spec.replicas == null").unwrap();
matches!(condition, RuleCondition::NullCheck { is_null: true, .. });
}
#[test]
fn test_equality() {
let data = json!({ "spec": { "replicas": 1 } });
let condition = parse_condition("$.spec.replicas == 1").unwrap();
assert!(evaluate_condition(&condition, &data));
let condition = parse_condition("$.spec.replicas == 2").unwrap();
assert!(!evaluate_condition(&condition, &data));
}
#[test]
fn test_contains() {
let data = json!({ "image": "nginx:latest" });
let condition = parse_condition("$.image contains :latest").unwrap();
assert!(evaluate_condition(&condition, &data));
}
#[test]
fn test_rule_engine() {
let mut engine = RuleEngine::new();
engine.add_rule(Rule {
id: "test/replicas-1".to_string(),
condition: "$.spec.replicas == 1".to_string(),
severity: "warning".to_string(),
message: "Single replica".to_string(),
});
let data = json!({ "spec": { "replicas": 1 } });
let diagnostics = engine.evaluate(&data);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message, "Single replica");
}
#[test]
fn test_get_value_at_path() {
let data = json!({
"spec": {
"template": {
"spec": {
"containers": [
{ "name": "app", "image": "nginx" }
]
}
}
}
});
let value = get_value_at_path(&data, ".spec.template.spec.containers[0].name");
assert_eq!(value, Some(Value::String("app".to_string())));
}
}