Skip to main content

devops_validate/rules/
engine.rs

1//! Rule engine for evaluating semantic validation rules
2//!
3//! Uses JSONPath expressions to match conditions against YAML data.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use devops_models::models::validation::{Diagnostic, Severity};
9
10/// A single validation rule
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Rule {
13    /// Unique rule identifier (e.g., "k8s/replicas-1")
14    pub id: String,
15    /// JSONPath condition expression
16    pub condition: String,
17    /// Severity level
18    pub severity: String,
19    /// Human-readable message (supports {{placeholder}} interpolation)
20    pub message: String,
21}
22
23/// Parsed rule condition — internal representation used by [`RuleEngine`].
24#[allow(missing_docs)]
25#[derive(Debug, Clone)]
26pub enum RuleCondition {
27    /// JSONPath expression that should return true
28    JsonPath(String),
29    /// Simple equality check (path == value)
30    Equals { path: String, value: Value },
31    /// Simple comparison (path > value, path < value)
32    Comparison { path: String, op: CompareOp, value: Value },
33    /// Null check (path == null or path != null)
34    NullCheck { path: String, is_null: bool },
35    /// Contains check (string contains substring)
36    Contains { path: String, substring: String },
37}
38
39/// Comparison operator used in [`RuleCondition::Comparison`].
40#[allow(missing_docs)]
41#[derive(Debug, Clone, Copy)]
42pub enum CompareOp {
43    Eq,
44    Ne,
45    Gt,
46    Gte,
47    Lt,
48    Lte,
49}
50
51/// Rule engine that evaluates rules against YAML data
52pub struct RuleEngine {
53    rules: Vec<Rule>,
54}
55
56impl Default for RuleEngine {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl RuleEngine {
63    /// Create an empty rule engine
64    pub fn new() -> Self {
65        Self { rules: Vec::new() }
66    }
67
68    /// Create a rule engine with predefined rules
69    pub fn with_rules(rules: Vec<Rule>) -> Self {
70        Self { rules }
71    }
72
73    /// Add a rule to the engine
74    pub fn add_rule(&mut self, rule: Rule) {
75        self.rules.push(rule);
76    }
77
78    /// Evaluate all rules against data and return diagnostics
79    pub fn evaluate(&self, data: &Value) -> Vec<Diagnostic> {
80        self.rules
81            .iter()
82            .filter_map(|rule| self.evaluate_rule(rule, data))
83            .collect()
84    }
85
86    /// Evaluate a single rule
87    fn evaluate_rule(&self, rule: &Rule, data: &Value) -> Option<Diagnostic> {
88        let condition = parse_condition(&rule.condition)?;
89        let matches = evaluate_condition(&condition, data);
90
91        if matches {
92            Some(Diagnostic {
93                severity: parse_severity(&rule.severity),
94                message: interpolate_message(&rule.message, data),
95                path: extract_path_from_condition(&condition),
96            })
97        } else {
98            None
99        }
100    }
101
102    /// Get rule count
103    pub fn rule_count(&self) -> usize {
104        self.rules.len()
105    }
106}
107
108/// Parse a condition string into a RuleCondition
109fn parse_condition(condition: &str) -> Option<RuleCondition> {
110    let condition = condition.trim();
111
112    // Handle null checks: path == null or path != null
113    if condition.ends_with("== null") {
114        let path = condition.strip_suffix("== null")?.trim().strip_prefix('$')?;
115        return Some(RuleCondition::NullCheck {
116            path: path.to_string(),
117            is_null: true,
118        });
119    }
120    if condition.ends_with("!= null") {
121        let path = condition.strip_suffix("!= null")?.trim().strip_prefix('$')?;
122        return Some(RuleCondition::NullCheck {
123            path: path.to_string(),
124            is_null: false,
125        });
126    }
127
128    // Handle contains: path contains "substring"
129    if let Some(rest) = condition.strip_prefix('$')
130        && let Some(pos) = rest.find(" contains ")
131    {
132        let path = rest[..pos].trim();
133        let substring = rest[pos + 10..].trim().trim_matches('"');
134        return Some(RuleCondition::Contains {
135            path: path.to_string(),
136            substring: substring.to_string(),
137        });
138    }
139
140    // Handle simple equality: $.path == value
141    for op_str in ["==", "!=", ">=", "<=", ">", "<"] {
142        if let Some(pos) = condition.find(op_str) {
143            let left = condition[..pos].trim().strip_prefix('$')?;
144            let right = condition[pos + op_str.len()..].trim();
145
146            let value = parse_value(right)?;
147
148            let op = match op_str {
149                "==" => CompareOp::Eq,
150                "!=" => CompareOp::Ne,
151                ">=" => CompareOp::Gte,
152                "<=" => CompareOp::Lte,
153                ">" => CompareOp::Gt,
154                "<" => CompareOp::Lt,
155                _ => return None,
156            };
157
158            return Some(RuleCondition::Comparison {
159                path: left.to_string(),
160                op,
161                value,
162            });
163        }
164    }
165
166    // Fallback to raw JSONPath
167    Some(RuleCondition::JsonPath(condition.to_string()))
168}
169
170/// Parse a value string into a JSON value
171fn parse_value(s: &str) -> Option<Value> {
172    let s = s.trim();
173
174    // String (quoted)
175    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
176        return Some(Value::String(s[1..s.len() - 1].to_string()));
177    }
178
179    // Boolean
180    if s == "true" {
181        return Some(Value::Bool(true));
182    }
183    if s == "false" {
184        return Some(Value::Bool(false));
185    }
186
187    // Number
188    if let Ok(n) = s.parse::<i64>() {
189        return Some(Value::Number(n.into()));
190    }
191    if let Ok(n) = s.parse::<f64>() {
192        return Some(Value::Number(serde_json::Number::from_f64(n)?));
193    }
194
195    // Null
196    if s == "null" {
197        return Some(Value::Null);
198    }
199
200    None
201}
202
203/// Evaluate a condition against data
204fn evaluate_condition(condition: &RuleCondition, data: &Value) -> bool {
205    match condition {
206        RuleCondition::JsonPath(_expr) => {
207            // For now, return false - full JSONPath support requires jsonpath-rust
208            // This will be implemented in a future iteration
209            false
210        }
211        RuleCondition::Equals { path, value } => {
212            let actual = get_value_at_path(data, path);
213            actual.as_ref() == Some(value)
214        }
215        RuleCondition::Comparison { path, op, value } => {
216            let actual = match get_value_at_path(data, path) {
217                Some(v) => v,
218                None => return false,
219            };
220            compare_values(&actual, *op, value)
221        }
222        RuleCondition::NullCheck { path, is_null } => {
223            let actual = get_value_at_path(data, path);
224            let is_actually_null = actual.as_ref().is_none_or(|v| v.is_null());
225            is_actually_null == *is_null
226        }
227        RuleCondition::Contains { path, substring } => {
228            let actual = match get_value_at_path(data, path) {
229                Some(v) => v,
230                None => return false,
231            };
232            actual
233                .as_str()
234                .map(|s| s.contains(substring))
235                .unwrap_or(false)
236        }
237    }
238}
239
240/// Get value at JSONPath (simplified - supports dot notation only)
241fn get_value_at_path(data: &Value, path: &str) -> Option<Value> {
242    let mut current = data;
243
244    for segment in path.split('.') {
245        // Skip empty segments
246        if segment.is_empty() {
247            continue;
248        }
249
250        // Handle array index: containers[0]
251        if segment.ends_with(']') {
252            let open_bracket = segment.find('[')?;
253            let field = &segment[..open_bracket];
254            let index_str = &segment[open_bracket + 1..segment.len() - 1];
255            let index: usize = index_str.parse().ok()?;
256
257            current = current.get(field)?.get(index)?;
258        } else {
259            current = current.get(segment)?;
260        }
261    }
262
263    Some(current.clone())
264}
265
266/// Compare two values with an operator
267fn compare_values(actual: &Value, op: CompareOp, expected: &Value) -> bool {
268    match (actual, expected) {
269        (Value::Number(a), Value::Number(b)) => {
270            let a_val = a.as_f64().unwrap_or(0.0);
271            let b_val = b.as_f64().unwrap_or(0.0);
272            match op {
273                CompareOp::Eq => (a_val - b_val).abs() < f64::EPSILON,
274                CompareOp::Ne => (a_val - b_val).abs() >= f64::EPSILON,
275                CompareOp::Gt => a_val > b_val,
276                CompareOp::Gte => a_val >= b_val,
277                CompareOp::Lt => a_val < b_val,
278                CompareOp::Lte => a_val <= b_val,
279            }
280        }
281        (Value::String(a), Value::String(b)) => match op {
282            CompareOp::Eq => a == b,
283            CompareOp::Ne => a != b,
284            _ => false,
285        },
286        (Value::Bool(a), Value::Bool(b)) => match op {
287            CompareOp::Eq => a == b,
288            CompareOp::Ne => a != b,
289            _ => false,
290        },
291        _ => false,
292    }
293}
294
295/// Parse severity string
296fn parse_severity(s: &str) -> Severity {
297    match s.to_lowercase().as_str() {
298        "error" => Severity::Error,
299        "warning" => Severity::Warning,
300        "info" => Severity::Info,
301        "hint" => Severity::Hint,
302        _ => Severity::Warning,
303    }
304}
305
306/// Interpolate placeholders in message
307fn interpolate_message(message: &str, _data: &Value) -> String {
308    // TODO: Support {{placeholder}} interpolation from data
309    message.to_string()
310}
311
312/// Extract path from condition for diagnostic
313fn extract_path_from_condition(condition: &RuleCondition) -> Option<String> {
314    match condition {
315        RuleCondition::JsonPath(_) => None,
316        RuleCondition::Equals { path, .. } => Some(path.clone()),
317        RuleCondition::Comparison { path, .. } => Some(path.clone()),
318        RuleCondition::NullCheck { path, .. } => Some(path.clone()),
319        RuleCondition::Contains { path, .. } => Some(path.clone()),
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serde_json::json;
327
328    #[test]
329    fn test_null_check() {
330        let condition = parse_condition("$.spec.replicas == null").unwrap();
331        matches!(condition, RuleCondition::NullCheck { is_null: true, .. });
332    }
333
334    #[test]
335    fn test_equality() {
336        let data = json!({ "spec": { "replicas": 1 } });
337        let condition = parse_condition("$.spec.replicas == 1").unwrap();
338        assert!(evaluate_condition(&condition, &data));
339
340        let condition = parse_condition("$.spec.replicas == 2").unwrap();
341        assert!(!evaluate_condition(&condition, &data));
342    }
343
344    #[test]
345    fn test_contains() {
346        let data = json!({ "image": "nginx:latest" });
347        let condition = parse_condition("$.image contains :latest").unwrap();
348        assert!(evaluate_condition(&condition, &data));
349    }
350
351    #[test]
352    fn test_rule_engine() {
353        let mut engine = RuleEngine::new();
354        engine.add_rule(Rule {
355            id: "test/replicas-1".to_string(),
356            condition: "$.spec.replicas == 1".to_string(),
357            severity: "warning".to_string(),
358            message: "Single replica".to_string(),
359        });
360
361        let data = json!({ "spec": { "replicas": 1 } });
362        let diagnostics = engine.evaluate(&data);
363        assert_eq!(diagnostics.len(), 1);
364        assert_eq!(diagnostics[0].message, "Single replica");
365    }
366
367    #[test]
368    fn test_get_value_at_path() {
369        let data = json!({
370            "spec": {
371                "template": {
372                    "spec": {
373                        "containers": [
374                            { "name": "app", "image": "nginx" }
375                        ]
376                    }
377                }
378            }
379        });
380
381        let value = get_value_at_path(&data, ".spec.template.spec.containers[0].name");
382        assert_eq!(value, Some(Value::String("app".to_string())));
383    }
384}