1use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use devops_models::models::validation::{Diagnostic, Severity};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Rule {
13 pub id: String,
15 pub condition: String,
17 pub severity: String,
19 pub message: String,
21}
22
23#[allow(missing_docs)]
25#[derive(Debug, Clone)]
26pub enum RuleCondition {
27 JsonPath(String),
29 Equals { path: String, value: Value },
31 Comparison { path: String, op: CompareOp, value: Value },
33 NullCheck { path: String, is_null: bool },
35 Contains { path: String, substring: String },
37}
38
39#[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
51pub 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 pub fn new() -> Self {
65 Self { rules: Vec::new() }
66 }
67
68 pub fn with_rules(rules: Vec<Rule>) -> Self {
70 Self { rules }
71 }
72
73 pub fn add_rule(&mut self, rule: Rule) {
75 self.rules.push(rule);
76 }
77
78 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 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 pub fn rule_count(&self) -> usize {
104 self.rules.len()
105 }
106}
107
108fn parse_condition(condition: &str) -> Option<RuleCondition> {
110 let condition = condition.trim();
111
112 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 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 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 Some(RuleCondition::JsonPath(condition.to_string()))
168}
169
170fn parse_value(s: &str) -> Option<Value> {
172 let s = s.trim();
173
174 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 if s == "true" {
181 return Some(Value::Bool(true));
182 }
183 if s == "false" {
184 return Some(Value::Bool(false));
185 }
186
187 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 if s == "null" {
197 return Some(Value::Null);
198 }
199
200 None
201}
202
203fn evaluate_condition(condition: &RuleCondition, data: &Value) -> bool {
205 match condition {
206 RuleCondition::JsonPath(_expr) => {
207 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
240fn get_value_at_path(data: &Value, path: &str) -> Option<Value> {
242 let mut current = data;
243
244 for segment in path.split('.') {
245 if segment.is_empty() {
247 continue;
248 }
249
250 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
266fn 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
295fn 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
306fn interpolate_message(message: &str, _data: &Value) -> String {
308 message.to_string()
310}
311
312fn 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}