devops-validate 0.1.0

YAML validation and auto-repair engine for DevOps configuration files: Kubernetes, Docker Compose, GitLab CI, GitHub Actions, Prometheus, Alertmanager, Helm, and Ansible.
Documentation
//! Rule engine for evaluating semantic validation rules
//!
//! Uses JSONPath expressions to match conditions against YAML data.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use devops_models::models::validation::{Diagnostic, Severity};

/// A single validation rule
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
    /// Unique rule identifier (e.g., "k8s/replicas-1")
    pub id: String,
    /// JSONPath condition expression
    pub condition: String,
    /// Severity level
    pub severity: String,
    /// Human-readable message (supports {{placeholder}} interpolation)
    pub message: String,
}

/// Parsed rule condition — internal representation used by [`RuleEngine`].
#[allow(missing_docs)]
#[derive(Debug, Clone)]
pub enum RuleCondition {
    /// JSONPath expression that should return true
    JsonPath(String),
    /// Simple equality check (path == value)
    Equals { path: String, value: Value },
    /// Simple comparison (path > value, path < value)
    Comparison { path: String, op: CompareOp, value: Value },
    /// Null check (path == null or path != null)
    NullCheck { path: String, is_null: bool },
    /// Contains check (string contains substring)
    Contains { path: String, substring: String },
}

/// Comparison operator used in [`RuleCondition::Comparison`].
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy)]
pub enum CompareOp {
    Eq,
    Ne,
    Gt,
    Gte,
    Lt,
    Lte,
}

/// Rule engine that evaluates rules against YAML data
pub struct RuleEngine {
    rules: Vec<Rule>,
}

impl Default for RuleEngine {
    fn default() -> Self {
        Self::new()
    }
}

impl RuleEngine {
    /// Create an empty rule engine
    pub fn new() -> Self {
        Self { rules: Vec::new() }
    }

    /// Create a rule engine with predefined rules
    pub fn with_rules(rules: Vec<Rule>) -> Self {
        Self { rules }
    }

    /// Add a rule to the engine
    pub fn add_rule(&mut self, rule: Rule) {
        self.rules.push(rule);
    }

    /// Evaluate all rules against data and return diagnostics
    pub fn evaluate(&self, data: &Value) -> Vec<Diagnostic> {
        self.rules
            .iter()
            .filter_map(|rule| self.evaluate_rule(rule, data))
            .collect()
    }

    /// Evaluate a single rule
    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
        }
    }

    /// Get rule count
    pub fn rule_count(&self) -> usize {
        self.rules.len()
    }
}

/// Parse a condition string into a RuleCondition
fn parse_condition(condition: &str) -> Option<RuleCondition> {
    let condition = condition.trim();

    // Handle null checks: path == null or path != null
    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,
        });
    }

    // Handle contains: path contains "substring"
    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(),
        });
    }

    // Handle simple equality: $.path == value
    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,
            });
        }
    }

    // Fallback to raw JSONPath
    Some(RuleCondition::JsonPath(condition.to_string()))
}

/// Parse a value string into a JSON value
fn parse_value(s: &str) -> Option<Value> {
    let s = s.trim();

    // String (quoted)
    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
        return Some(Value::String(s[1..s.len() - 1].to_string()));
    }

    // Boolean
    if s == "true" {
        return Some(Value::Bool(true));
    }
    if s == "false" {
        return Some(Value::Bool(false));
    }

    // Number
    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)?));
    }

    // Null
    if s == "null" {
        return Some(Value::Null);
    }

    None
}

/// Evaluate a condition against data
fn evaluate_condition(condition: &RuleCondition, data: &Value) -> bool {
    match condition {
        RuleCondition::JsonPath(_expr) => {
            // For now, return false - full JSONPath support requires jsonpath-rust
            // This will be implemented in a future iteration
            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)
        }
    }
}

/// Get value at JSONPath (simplified - supports dot notation only)
fn get_value_at_path(data: &Value, path: &str) -> Option<Value> {
    let mut current = data;

    for segment in path.split('.') {
        // Skip empty segments
        if segment.is_empty() {
            continue;
        }

        // Handle array index: containers[0]
        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())
}

/// Compare two values with an operator
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,
    }
}

/// Parse severity string
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,
    }
}

/// Interpolate placeholders in message
fn interpolate_message(message: &str, _data: &Value) -> String {
    // TODO: Support {{placeholder}} interpolation from data
    message.to_string()
}

/// Extract path from condition for diagnostic
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())));
    }
}