1use anyhow::Result;
12use rhai::{Dynamic, Engine, Map, Scope};
13use std::collections::HashMap;
14
15pub fn evaluate_condition(
25 condition: &str,
26 variables: &HashMap<String, String>,
27) -> Result<bool> {
28 let expression = if condition.trim().starts_with("{{") && condition.trim().ends_with("}}") {
30 condition
31 .trim()
32 .strip_prefix("{{")
33 .unwrap()
34 .strip_suffix("}}")
35 .unwrap()
36 .trim()
37 } else {
38 condition.trim()
39 };
40
41 let mut rendered = expression.to_string();
43 for (key, value) in variables {
44 rendered = rendered.replace(key, value);
45 }
46
47 fn strip_quotes(s: &str) -> String {
48 let s = s.trim();
49 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
50 s[1..s.len() - 1].to_string()
51 } else {
52 s.to_string()
53 }
54 }
55
56 if rendered.contains("==") {
57 let parts: Vec<&str> = rendered.split("==").map(|s| s.trim()).collect();
58 if parts.len() == 2 {
59 return Ok(strip_quotes(parts[0]) == strip_quotes(parts[1]));
60 }
61 }
62
63 if rendered.contains("!=") {
64 let parts: Vec<&str> = rendered.split("!=").map(|s| s.trim()).collect();
65 if parts.len() == 2 {
66 return Ok(strip_quotes(parts[0]) != strip_quotes(parts[1]));
67 }
68 }
69
70 if rendered.contains(" in ") {
72 let parts: Vec<&str> = rendered.split(" in ").map(|s| s.trim()).collect();
73 if parts.len() == 2 {
74 let needle = strip_quotes(parts[0]);
75 let haystack = strip_quotes(parts[1]);
76 return Ok(haystack.contains(&needle));
77 }
78 }
79
80 let value = strip_quotes(&rendered);
82 Ok(!value.is_empty() && value != "false" && value != "0")
83}
84
85pub fn evaluate_rhai_condition(
92 code: &str,
93 variables: &HashMap<String, String>,
94) -> Result<bool> {
95 let mut engine = Engine::new();
96 let mut scope = Scope::new();
97
98 let mut workload_map = Map::new();
100 for (key, value) in variables {
101 if key.starts_with("workload.") {
102 let short_key = key.strip_prefix("workload.").unwrap_or(key);
103 workload_map.insert(short_key.to_string().into(), Dynamic::from(value.clone()));
104 }
105 }
106 scope.push("workload", workload_map);
107
108 let mut vars_map = Map::new();
110 for (key, value) in variables {
111 if key.starts_with("vars.") {
112 let short_key = key.strip_prefix("vars.").unwrap_or(key);
113 vars_map.insert(short_key.to_string().into(), Dynamic::from(value.clone()));
114 }
115 }
116 scope.push("vars", vars_map);
117
118 for (key, value) in variables {
120 if !key.starts_with("workload.") && !key.starts_with("vars.") && key.contains('.') {
121 let parts: Vec<&str> = key.splitn(2, '.').collect();
122 if parts.len() == 2 {
123 let step_name = parts[0];
124 let field_name = parts[1];
125
126 if !scope.contains(step_name) {
127 scope.push(step_name.to_string(), Map::new());
128 }
129
130 if let Some(step_map) = scope.get_mut(step_name) {
131 if let Some(map) = step_map.clone().try_cast::<Map>() {
132 let mut map = map;
133 map.insert(field_name.to_string().into(), Dynamic::from(value.clone()));
134 *step_map = Dynamic::from(map);
135 }
136 }
137 }
138 }
139 }
140
141 engine.register_fn("eq", |a: &str, b: &str| -> bool { a == b });
143 engine.register_fn("ne", |a: &str, b: &str| -> bool { a != b });
144 engine.register_fn("contains", |haystack: &str, needle: &str| -> bool {
145 haystack.contains(needle)
146 });
147
148 let result = engine
149 .eval_with_scope::<Dynamic>(&mut scope, code)
150 .map_err(|e| anyhow::anyhow!("Rhai condition error: {}", e))?;
151
152 if result.is_bool() {
153 Ok(result.as_bool().unwrap_or(false))
154 } else if result.is_int() {
155 Ok(result.as_int().unwrap_or(0) != 0)
156 } else if result.is_string() {
157 let s = result.into_string().unwrap_or_default();
158 Ok(!s.is_empty() && s != "false" && s != "0")
159 } else {
160 Ok(!result.is_unit())
161 }
162}
163
164use noetl_tools::context::ExecutionContext as ToolsExecutionContext;
186use noetl_tools::template::TemplateEngine;
187use serde::{Deserialize, Serialize};
188
189#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
194#[serde(rename_all = "snake_case")]
195pub enum Operator {
196 #[default]
198 Eq,
199 Ne,
201 Gt,
203 Lt,
205 Gte,
207 Lte,
209 Contains,
211 Matches,
213 Truthy,
215 Falsy,
217 In,
219 NotIn,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Condition {
228 pub left: String,
233
234 #[serde(default)]
236 pub op: Operator,
237
238 #[serde(default)]
242 pub right: Option<serde_json::Value>,
243}
244
245pub fn evaluate_structured_condition(
262 condition: &Condition,
263 ctx: &ToolsExecutionContext,
264 result: Option<&serde_json::Value>,
265) -> Result<bool> {
266 let template_engine = TemplateEngine::new();
267 let left = resolve_value(&condition.left, ctx, result, &template_engine)?;
268 let right = condition
269 .right
270 .as_ref()
271 .map(|r| resolve_json_value(r, ctx, &template_engine))
272 .transpose()?;
273
274 match condition.op {
275 Operator::Eq => Ok(left == right.unwrap_or(serde_json::Value::Null)),
276 Operator::Ne => Ok(left != right.unwrap_or(serde_json::Value::Null)),
277 Operator::Gt => compare_numeric(&left, &right, |a, b| a > b),
278 Operator::Lt => compare_numeric(&left, &right, |a, b| a < b),
279 Operator::Gte => compare_numeric(&left, &right, |a, b| a >= b),
280 Operator::Lte => compare_numeric(&left, &right, |a, b| a <= b),
281 Operator::Contains => {
282 let left_str = left.as_str().unwrap_or("");
283 let right_str = right.as_ref().and_then(|r| r.as_str()).unwrap_or("");
284 Ok(left_str.contains(right_str))
285 }
286 Operator::Matches => {
287 let left_str = left.as_str().unwrap_or("");
288 let pattern = right.as_ref().and_then(|r| r.as_str()).unwrap_or("");
289 let re = regex::Regex::new(pattern)
290 .map_err(|e| anyhow::anyhow!("Invalid regex: {}", e))?;
291 Ok(re.is_match(left_str))
292 }
293 Operator::Truthy => Ok(is_truthy(&left)),
294 Operator::Falsy => Ok(!is_truthy(&left)),
295 Operator::In => {
296 if let Some(serde_json::Value::Array(arr)) = &right {
297 Ok(arr.contains(&left))
298 } else {
299 Ok(false)
300 }
301 }
302 Operator::NotIn => {
303 if let Some(serde_json::Value::Array(arr)) = &right {
304 Ok(!arr.contains(&left))
305 } else {
306 Ok(true)
307 }
308 }
309 }
310}
311
312fn resolve_value(
315 value: &str,
316 ctx: &ToolsExecutionContext,
317 result: Option<&serde_json::Value>,
318 template_engine: &TemplateEngine,
319) -> Result<serde_json::Value> {
320 if let Some(path) = value.strip_prefix("result.") {
321 if let Some(res) = result {
322 return Ok(json_path(res, path)
323 .cloned()
324 .unwrap_or(serde_json::Value::Null));
325 }
326 return Ok(serde_json::Value::Null);
327 }
328
329 if value == "result" {
330 return Ok(result.cloned().unwrap_or(serde_json::Value::Null));
331 }
332
333 if let Some(var) = ctx.get_variable(value) {
334 return Ok(var.clone());
335 }
336
337 if TemplateEngine::is_template(value) {
338 let template_ctx = ctx.to_template_context();
339 let rendered = template_engine
340 .render(value, &template_ctx)
341 .map_err(|e| anyhow::anyhow!(e))?;
342 return Ok(serde_json::from_str(&rendered).unwrap_or(serde_json::json!(rendered)));
343 }
344
345 Ok(serde_json::json!(value))
346}
347
348fn resolve_json_value(
350 value: &serde_json::Value,
351 ctx: &ToolsExecutionContext,
352 template_engine: &TemplateEngine,
353) -> Result<serde_json::Value> {
354 let template_ctx = ctx.to_template_context();
355 template_engine
356 .render_value(value, &template_ctx)
357 .map_err(|e| anyhow::anyhow!(e))
358}
359
360fn json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
363 let mut current = value;
364 for segment in path.split('.') {
365 match current {
366 serde_json::Value::Object(obj) => {
367 current = obj.get(segment)?;
368 }
369 serde_json::Value::Array(arr) => {
370 let idx: usize = segment.parse().ok()?;
371 current = arr.get(idx)?;
372 }
373 _ => return None,
374 }
375 }
376 Some(current)
377}
378
379fn compare_numeric<F>(
381 left: &serde_json::Value,
382 right: &Option<serde_json::Value>,
383 cmp: F,
384) -> Result<bool>
385where
386 F: Fn(f64, f64) -> bool,
387{
388 let left_num = value_to_f64(left)?;
389 let right_num = value_to_f64(right.as_ref().unwrap_or(&serde_json::Value::Null))?;
390 Ok(cmp(left_num, right_num))
391}
392
393fn is_truthy(value: &serde_json::Value) -> bool {
395 match value {
396 serde_json::Value::Null => false,
397 serde_json::Value::Bool(b) => *b,
398 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
399 serde_json::Value::String(s) => !s.is_empty(),
400 serde_json::Value::Array(a) => !a.is_empty(),
401 serde_json::Value::Object(o) => !o.is_empty(),
402 }
403}
404
405fn value_to_f64(value: &serde_json::Value) -> Result<f64> {
408 match value {
409 serde_json::Value::Number(n) => n
410 .as_f64()
411 .ok_or_else(|| anyhow::anyhow!("Invalid number")),
412 serde_json::Value::String(s) => s
413 .parse()
414 .map_err(|_| anyhow::anyhow!("Cannot parse '{s}' as number")),
415 serde_json::Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
416 serde_json::Value::Null => Ok(0.0),
417 _ => Err(anyhow::anyhow!("Cannot convert {value:?} to number")),
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
426 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
427 }
428
429 #[test]
430 fn evaluate_condition_equality() {
431 let v = HashMap::new();
432 assert!(evaluate_condition("'test' == 'test'", &v).unwrap());
433 assert!(!evaluate_condition("'test' == 'other'", &v).unwrap());
434 }
435
436 #[test]
437 fn evaluate_condition_inequality() {
438 let v = HashMap::new();
439 assert!(evaluate_condition("'test' != 'other'", &v).unwrap());
440 assert!(!evaluate_condition("'test' != 'test'", &v).unwrap());
441 }
442
443 #[test]
444 fn evaluate_condition_in_operator() {
445 let v = HashMap::new();
446 assert!(evaluate_condition("'foo' in 'foobar'", &v).unwrap());
447 assert!(!evaluate_condition("'baz' in 'foobar'", &v).unwrap());
448 }
449
450 #[test]
451 fn evaluate_condition_substitutes_variables() {
452 let v = vars(&[("workload.action", "deploy")]);
453 assert!(evaluate_condition("workload.action == 'deploy'", &v).unwrap());
454 assert!(!evaluate_condition("workload.action == 'undeploy'", &v).unwrap());
455 }
456
457 #[test]
458 fn evaluate_rhai_condition_workload_field() {
459 let v = vars(&[("workload.count", "5")]);
460 assert!(evaluate_rhai_condition("workload.count == \"5\"", &v).unwrap());
461 assert!(!evaluate_rhai_condition("workload.count == \"6\"", &v).unwrap());
462 }
463
464 #[test]
465 fn evaluate_rhai_condition_helpers() {
466 let v = vars(&[("workload.action", "DEPLOY")]);
467 assert!(evaluate_rhai_condition("eq(workload.action, \"DEPLOY\")", &v).unwrap());
468 assert!(evaluate_rhai_condition(
469 "contains(workload.action, \"DEP\")",
470 &v
471 )
472 .unwrap());
473 }
474
475 fn tools_ctx_with(pairs: &[(&str, serde_json::Value)]) -> ToolsExecutionContext {
478 let mut ctx = ToolsExecutionContext::default();
479 for (k, v) in pairs {
480 ctx.set_variable(*k, v.clone());
481 }
482 ctx
483 }
484
485 #[test]
486 fn structured_eq_against_variable() {
487 let ctx = tools_ctx_with(&[("status", serde_json::json!("success"))]);
488 let cond = Condition {
489 left: "status".into(),
490 op: Operator::Eq,
491 right: Some(serde_json::json!("success")),
492 };
493 assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
494 let cond_fail = Condition {
495 left: "status".into(),
496 op: Operator::Eq,
497 right: Some(serde_json::json!("failed")),
498 };
499 assert!(!evaluate_structured_condition(&cond_fail, &ctx, None).unwrap());
500 }
501
502 #[test]
503 fn structured_ne_inverts_eq() {
504 let ctx = tools_ctx_with(&[("status", serde_json::json!("ok"))]);
505 let cond = Condition {
506 left: "status".into(),
507 op: Operator::Ne,
508 right: Some(serde_json::json!("error")),
509 };
510 assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
511 }
512
513 #[test]
514 fn structured_numeric_comparisons() {
515 let ctx = tools_ctx_with(&[("count", serde_json::json!(10))]);
516 for (op, rhs, expected) in [
517 (Operator::Gt, 5, true),
518 (Operator::Gt, 10, false),
519 (Operator::Gte, 10, true),
520 (Operator::Lt, 100, true),
521 (Operator::Lte, 10, true),
522 ] {
523 let cond = Condition {
524 left: "count".into(),
525 op,
526 right: Some(serde_json::json!(rhs)),
527 };
528 assert_eq!(
529 evaluate_structured_condition(&cond, &ctx, None).unwrap(),
530 expected,
531 "op {:?} vs {} expected {}",
532 cond.op,
533 rhs,
534 expected
535 );
536 }
537 }
538
539 #[test]
540 fn structured_contains_matches_strings() {
541 let ctx = tools_ctx_with(&[("msg", serde_json::json!("hello world"))]);
542 let cond = Condition {
543 left: "msg".into(),
544 op: Operator::Contains,
545 right: Some(serde_json::json!("world")),
546 };
547 assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
548 let cond_no = Condition {
549 left: "msg".into(),
550 op: Operator::Contains,
551 right: Some(serde_json::json!("zzz")),
552 };
553 assert!(!evaluate_structured_condition(&cond_no, &ctx, None).unwrap());
554 }
555
556 #[test]
557 fn structured_matches_regex() {
558 let ctx = tools_ctx_with(&[("user", serde_json::json!("alice@example.com"))]);
559 let cond = Condition {
560 left: "user".into(),
561 op: Operator::Matches,
562 right: Some(serde_json::json!(r"^\w+@\w+\.com$")),
563 };
564 assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
565 }
566
567 #[test]
568 fn structured_truthy_falsy() {
569 let ctx = tools_ctx_with(&[
570 ("on", serde_json::json!(true)),
571 ("zero", serde_json::json!(0)),
572 ("empty", serde_json::json!("")),
573 ("nonempty", serde_json::json!("x")),
574 ]);
575 let truthy_on = Condition {
576 left: "on".into(),
577 op: Operator::Truthy,
578 right: None,
579 };
580 assert!(evaluate_structured_condition(&truthy_on, &ctx, None).unwrap());
581 let falsy_zero = Condition {
582 left: "zero".into(),
583 op: Operator::Falsy,
584 right: None,
585 };
586 assert!(evaluate_structured_condition(&falsy_zero, &ctx, None).unwrap());
587 let falsy_empty = Condition {
588 left: "empty".into(),
589 op: Operator::Falsy,
590 right: None,
591 };
592 assert!(evaluate_structured_condition(&falsy_empty, &ctx, None).unwrap());
593 let truthy_x = Condition {
594 left: "nonempty".into(),
595 op: Operator::Truthy,
596 right: None,
597 };
598 assert!(evaluate_structured_condition(&truthy_x, &ctx, None).unwrap());
599 }
600
601 #[test]
602 fn structured_in_and_not_in() {
603 let ctx = tools_ctx_with(&[("role", serde_json::json!("admin"))]);
604 let in_cond = Condition {
605 left: "role".into(),
606 op: Operator::In,
607 right: Some(serde_json::json!(["admin", "ops", "dev"])),
608 };
609 assert!(evaluate_structured_condition(&in_cond, &ctx, None).unwrap());
610 let not_in_cond = Condition {
611 left: "role".into(),
612 op: Operator::NotIn,
613 right: Some(serde_json::json!(["guest", "viewer"])),
614 };
615 assert!(evaluate_structured_condition(¬_in_cond, &ctx, None).unwrap());
616 }
617
618 #[test]
619 fn structured_left_resolves_result_path() {
620 let ctx = ToolsExecutionContext::default();
621 let result = serde_json::json!({
622 "status": "ok",
623 "data": {"count": 42}
624 });
625 let cond = Condition {
626 left: "result.data.count".into(),
627 op: Operator::Eq,
628 right: Some(serde_json::json!(42)),
629 };
630 assert!(evaluate_structured_condition(&cond, &ctx, Some(&result)).unwrap());
631 }
632
633 #[test]
634 fn structured_left_resolves_bare_result() {
635 let ctx = ToolsExecutionContext::default();
636 let result = serde_json::json!("hello");
637 let cond = Condition {
638 left: "result".into(),
639 op: Operator::Eq,
640 right: Some(serde_json::json!("hello")),
641 };
642 assert!(evaluate_structured_condition(&cond, &ctx, Some(&result)).unwrap());
643 }
644
645 #[test]
646 fn structured_operator_serializes_snake_case() {
647 let cond = Condition {
648 left: "x".into(),
649 op: Operator::NotIn,
650 right: None,
651 };
652 let s = serde_json::to_string(&cond).unwrap();
653 assert!(s.contains("\"not_in\""), "got: {s}");
654 let parsed: Condition = serde_json::from_str(&s).unwrap();
655 assert!(matches!(parsed.op, Operator::NotIn));
656 }
657
658 #[test]
659 fn structured_in_returns_false_when_right_not_array() {
660 let ctx = tools_ctx_with(&[("x", serde_json::json!(1))]);
661 let cond = Condition {
662 left: "x".into(),
663 op: Operator::In,
664 right: Some(serde_json::json!("not an array")),
665 };
666 assert!(!evaluate_structured_condition(&cond, &ctx, None).unwrap());
667 }
668}