use anyhow::Result;
use rhai::{Dynamic, Engine, Map, Scope};
use std::collections::HashMap;
pub fn evaluate_condition(
condition: &str,
variables: &HashMap<String, String>,
) -> Result<bool> {
let expression = if condition.trim().starts_with("{{") && condition.trim().ends_with("}}") {
condition
.trim()
.strip_prefix("{{")
.unwrap()
.strip_suffix("}}")
.unwrap()
.trim()
} else {
condition.trim()
};
let mut rendered = expression.to_string();
for (key, value) in variables {
rendered = rendered.replace(key, value);
}
fn strip_quotes(s: &str) -> String {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
if rendered.contains("==") {
let parts: Vec<&str> = rendered.split("==").map(|s| s.trim()).collect();
if parts.len() == 2 {
return Ok(strip_quotes(parts[0]) == strip_quotes(parts[1]));
}
}
if rendered.contains("!=") {
let parts: Vec<&str> = rendered.split("!=").map(|s| s.trim()).collect();
if parts.len() == 2 {
return Ok(strip_quotes(parts[0]) != strip_quotes(parts[1]));
}
}
if rendered.contains(" in ") {
let parts: Vec<&str> = rendered.split(" in ").map(|s| s.trim()).collect();
if parts.len() == 2 {
let needle = strip_quotes(parts[0]);
let haystack = strip_quotes(parts[1]);
return Ok(haystack.contains(&needle));
}
}
let value = strip_quotes(&rendered);
Ok(!value.is_empty() && value != "false" && value != "0")
}
pub fn evaluate_rhai_condition(
code: &str,
variables: &HashMap<String, String>,
) -> Result<bool> {
let mut engine = Engine::new();
let mut scope = Scope::new();
let mut workload_map = Map::new();
for (key, value) in variables {
if key.starts_with("workload.") {
let short_key = key.strip_prefix("workload.").unwrap_or(key);
workload_map.insert(short_key.to_string().into(), Dynamic::from(value.clone()));
}
}
scope.push("workload", workload_map);
let mut vars_map = Map::new();
for (key, value) in variables {
if key.starts_with("vars.") {
let short_key = key.strip_prefix("vars.").unwrap_or(key);
vars_map.insert(short_key.to_string().into(), Dynamic::from(value.clone()));
}
}
scope.push("vars", vars_map);
for (key, value) in variables {
if !key.starts_with("workload.") && !key.starts_with("vars.") && key.contains('.') {
let parts: Vec<&str> = key.splitn(2, '.').collect();
if parts.len() == 2 {
let step_name = parts[0];
let field_name = parts[1];
if !scope.contains(step_name) {
scope.push(step_name.to_string(), Map::new());
}
if let Some(step_map) = scope.get_mut(step_name) {
if let Some(map) = step_map.clone().try_cast::<Map>() {
let mut map = map;
map.insert(field_name.to_string().into(), Dynamic::from(value.clone()));
*step_map = Dynamic::from(map);
}
}
}
}
}
engine.register_fn("eq", |a: &str, b: &str| -> bool { a == b });
engine.register_fn("ne", |a: &str, b: &str| -> bool { a != b });
engine.register_fn("contains", |haystack: &str, needle: &str| -> bool {
haystack.contains(needle)
});
let result = engine
.eval_with_scope::<Dynamic>(&mut scope, code)
.map_err(|e| anyhow::anyhow!("Rhai condition error: {}", e))?;
if result.is_bool() {
Ok(result.as_bool().unwrap_or(false))
} else if result.is_int() {
Ok(result.as_int().unwrap_or(0) != 0)
} else if result.is_string() {
let s = result.into_string().unwrap_or_default();
Ok(!s.is_empty() && s != "false" && s != "0")
} else {
Ok(!result.is_unit())
}
}
use noetl_tools::context::ExecutionContext as ToolsExecutionContext;
use noetl_tools::template::TemplateEngine;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Operator {
#[default]
Eq,
Ne,
Gt,
Lt,
Gte,
Lte,
Contains,
Matches,
Truthy,
Falsy,
In,
NotIn,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Condition {
pub left: String,
#[serde(default)]
pub op: Operator,
#[serde(default)]
pub right: Option<serde_json::Value>,
}
pub fn evaluate_structured_condition(
condition: &Condition,
ctx: &ToolsExecutionContext,
result: Option<&serde_json::Value>,
) -> Result<bool> {
let template_engine = TemplateEngine::new();
let left = resolve_value(&condition.left, ctx, result, &template_engine)?;
let right = condition
.right
.as_ref()
.map(|r| resolve_json_value(r, ctx, &template_engine))
.transpose()?;
match condition.op {
Operator::Eq => Ok(left == right.unwrap_or(serde_json::Value::Null)),
Operator::Ne => Ok(left != right.unwrap_or(serde_json::Value::Null)),
Operator::Gt => compare_numeric(&left, &right, |a, b| a > b),
Operator::Lt => compare_numeric(&left, &right, |a, b| a < b),
Operator::Gte => compare_numeric(&left, &right, |a, b| a >= b),
Operator::Lte => compare_numeric(&left, &right, |a, b| a <= b),
Operator::Contains => {
let left_str = left.as_str().unwrap_or("");
let right_str = right.as_ref().and_then(|r| r.as_str()).unwrap_or("");
Ok(left_str.contains(right_str))
}
Operator::Matches => {
let left_str = left.as_str().unwrap_or("");
let pattern = right.as_ref().and_then(|r| r.as_str()).unwrap_or("");
let re = regex::Regex::new(pattern)
.map_err(|e| anyhow::anyhow!("Invalid regex: {}", e))?;
Ok(re.is_match(left_str))
}
Operator::Truthy => Ok(is_truthy(&left)),
Operator::Falsy => Ok(!is_truthy(&left)),
Operator::In => {
if let Some(serde_json::Value::Array(arr)) = &right {
Ok(arr.contains(&left))
} else {
Ok(false)
}
}
Operator::NotIn => {
if let Some(serde_json::Value::Array(arr)) = &right {
Ok(!arr.contains(&left))
} else {
Ok(true)
}
}
}
}
fn resolve_value(
value: &str,
ctx: &ToolsExecutionContext,
result: Option<&serde_json::Value>,
template_engine: &TemplateEngine,
) -> Result<serde_json::Value> {
if let Some(path) = value.strip_prefix("result.") {
if let Some(res) = result {
return Ok(json_path(res, path)
.cloned()
.unwrap_or(serde_json::Value::Null));
}
return Ok(serde_json::Value::Null);
}
if value == "result" {
return Ok(result.cloned().unwrap_or(serde_json::Value::Null));
}
if let Some(var) = ctx.get_variable(value) {
return Ok(var.clone());
}
if TemplateEngine::is_template(value) {
let template_ctx = ctx.to_template_context();
let rendered = template_engine
.render(value, &template_ctx)
.map_err(|e| anyhow::anyhow!(e))?;
return Ok(serde_json::from_str(&rendered).unwrap_or(serde_json::json!(rendered)));
}
Ok(serde_json::json!(value))
}
fn resolve_json_value(
value: &serde_json::Value,
ctx: &ToolsExecutionContext,
template_engine: &TemplateEngine,
) -> Result<serde_json::Value> {
let template_ctx = ctx.to_template_context();
template_engine
.render_value(value, &template_ctx)
.map_err(|e| anyhow::anyhow!(e))
}
fn json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut current = value;
for segment in path.split('.') {
match current {
serde_json::Value::Object(obj) => {
current = obj.get(segment)?;
}
serde_json::Value::Array(arr) => {
let idx: usize = segment.parse().ok()?;
current = arr.get(idx)?;
}
_ => return None,
}
}
Some(current)
}
fn compare_numeric<F>(
left: &serde_json::Value,
right: &Option<serde_json::Value>,
cmp: F,
) -> Result<bool>
where
F: Fn(f64, f64) -> bool,
{
let left_num = value_to_f64(left)?;
let right_num = value_to_f64(right.as_ref().unwrap_or(&serde_json::Value::Null))?;
Ok(cmp(left_num, right_num))
}
fn is_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
serde_json::Value::String(s) => !s.is_empty(),
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Object(o) => !o.is_empty(),
}
}
fn value_to_f64(value: &serde_json::Value) -> Result<f64> {
match value {
serde_json::Value::Number(n) => n
.as_f64()
.ok_or_else(|| anyhow::anyhow!("Invalid number")),
serde_json::Value::String(s) => s
.parse()
.map_err(|_| anyhow::anyhow!("Cannot parse '{s}' as number")),
serde_json::Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
serde_json::Value::Null => Ok(0.0),
_ => Err(anyhow::anyhow!("Cannot convert {value:?} to number")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn evaluate_condition_equality() {
let v = HashMap::new();
assert!(evaluate_condition("'test' == 'test'", &v).unwrap());
assert!(!evaluate_condition("'test' == 'other'", &v).unwrap());
}
#[test]
fn evaluate_condition_inequality() {
let v = HashMap::new();
assert!(evaluate_condition("'test' != 'other'", &v).unwrap());
assert!(!evaluate_condition("'test' != 'test'", &v).unwrap());
}
#[test]
fn evaluate_condition_in_operator() {
let v = HashMap::new();
assert!(evaluate_condition("'foo' in 'foobar'", &v).unwrap());
assert!(!evaluate_condition("'baz' in 'foobar'", &v).unwrap());
}
#[test]
fn evaluate_condition_substitutes_variables() {
let v = vars(&[("workload.action", "deploy")]);
assert!(evaluate_condition("workload.action == 'deploy'", &v).unwrap());
assert!(!evaluate_condition("workload.action == 'undeploy'", &v).unwrap());
}
#[test]
fn evaluate_rhai_condition_workload_field() {
let v = vars(&[("workload.count", "5")]);
assert!(evaluate_rhai_condition("workload.count == \"5\"", &v).unwrap());
assert!(!evaluate_rhai_condition("workload.count == \"6\"", &v).unwrap());
}
#[test]
fn evaluate_rhai_condition_helpers() {
let v = vars(&[("workload.action", "DEPLOY")]);
assert!(evaluate_rhai_condition("eq(workload.action, \"DEPLOY\")", &v).unwrap());
assert!(evaluate_rhai_condition(
"contains(workload.action, \"DEP\")",
&v
)
.unwrap());
}
fn tools_ctx_with(pairs: &[(&str, serde_json::Value)]) -> ToolsExecutionContext {
let mut ctx = ToolsExecutionContext::default();
for (k, v) in pairs {
ctx.set_variable(*k, v.clone());
}
ctx
}
#[test]
fn structured_eq_against_variable() {
let ctx = tools_ctx_with(&[("status", serde_json::json!("success"))]);
let cond = Condition {
left: "status".into(),
op: Operator::Eq,
right: Some(serde_json::json!("success")),
};
assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
let cond_fail = Condition {
left: "status".into(),
op: Operator::Eq,
right: Some(serde_json::json!("failed")),
};
assert!(!evaluate_structured_condition(&cond_fail, &ctx, None).unwrap());
}
#[test]
fn structured_ne_inverts_eq() {
let ctx = tools_ctx_with(&[("status", serde_json::json!("ok"))]);
let cond = Condition {
left: "status".into(),
op: Operator::Ne,
right: Some(serde_json::json!("error")),
};
assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
}
#[test]
fn structured_numeric_comparisons() {
let ctx = tools_ctx_with(&[("count", serde_json::json!(10))]);
for (op, rhs, expected) in [
(Operator::Gt, 5, true),
(Operator::Gt, 10, false),
(Operator::Gte, 10, true),
(Operator::Lt, 100, true),
(Operator::Lte, 10, true),
] {
let cond = Condition {
left: "count".into(),
op,
right: Some(serde_json::json!(rhs)),
};
assert_eq!(
evaluate_structured_condition(&cond, &ctx, None).unwrap(),
expected,
"op {:?} vs {} expected {}",
cond.op,
rhs,
expected
);
}
}
#[test]
fn structured_contains_matches_strings() {
let ctx = tools_ctx_with(&[("msg", serde_json::json!("hello world"))]);
let cond = Condition {
left: "msg".into(),
op: Operator::Contains,
right: Some(serde_json::json!("world")),
};
assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
let cond_no = Condition {
left: "msg".into(),
op: Operator::Contains,
right: Some(serde_json::json!("zzz")),
};
assert!(!evaluate_structured_condition(&cond_no, &ctx, None).unwrap());
}
#[test]
fn structured_matches_regex() {
let ctx = tools_ctx_with(&[("user", serde_json::json!("alice@example.com"))]);
let cond = Condition {
left: "user".into(),
op: Operator::Matches,
right: Some(serde_json::json!(r"^\w+@\w+\.com$")),
};
assert!(evaluate_structured_condition(&cond, &ctx, None).unwrap());
}
#[test]
fn structured_truthy_falsy() {
let ctx = tools_ctx_with(&[
("on", serde_json::json!(true)),
("zero", serde_json::json!(0)),
("empty", serde_json::json!("")),
("nonempty", serde_json::json!("x")),
]);
let truthy_on = Condition {
left: "on".into(),
op: Operator::Truthy,
right: None,
};
assert!(evaluate_structured_condition(&truthy_on, &ctx, None).unwrap());
let falsy_zero = Condition {
left: "zero".into(),
op: Operator::Falsy,
right: None,
};
assert!(evaluate_structured_condition(&falsy_zero, &ctx, None).unwrap());
let falsy_empty = Condition {
left: "empty".into(),
op: Operator::Falsy,
right: None,
};
assert!(evaluate_structured_condition(&falsy_empty, &ctx, None).unwrap());
let truthy_x = Condition {
left: "nonempty".into(),
op: Operator::Truthy,
right: None,
};
assert!(evaluate_structured_condition(&truthy_x, &ctx, None).unwrap());
}
#[test]
fn structured_in_and_not_in() {
let ctx = tools_ctx_with(&[("role", serde_json::json!("admin"))]);
let in_cond = Condition {
left: "role".into(),
op: Operator::In,
right: Some(serde_json::json!(["admin", "ops", "dev"])),
};
assert!(evaluate_structured_condition(&in_cond, &ctx, None).unwrap());
let not_in_cond = Condition {
left: "role".into(),
op: Operator::NotIn,
right: Some(serde_json::json!(["guest", "viewer"])),
};
assert!(evaluate_structured_condition(¬_in_cond, &ctx, None).unwrap());
}
#[test]
fn structured_left_resolves_result_path() {
let ctx = ToolsExecutionContext::default();
let result = serde_json::json!({
"status": "ok",
"data": {"count": 42}
});
let cond = Condition {
left: "result.data.count".into(),
op: Operator::Eq,
right: Some(serde_json::json!(42)),
};
assert!(evaluate_structured_condition(&cond, &ctx, Some(&result)).unwrap());
}
#[test]
fn structured_left_resolves_bare_result() {
let ctx = ToolsExecutionContext::default();
let result = serde_json::json!("hello");
let cond = Condition {
left: "result".into(),
op: Operator::Eq,
right: Some(serde_json::json!("hello")),
};
assert!(evaluate_structured_condition(&cond, &ctx, Some(&result)).unwrap());
}
#[test]
fn structured_operator_serializes_snake_case() {
let cond = Condition {
left: "x".into(),
op: Operator::NotIn,
right: None,
};
let s = serde_json::to_string(&cond).unwrap();
assert!(s.contains("\"not_in\""), "got: {s}");
let parsed: Condition = serde_json::from_str(&s).unwrap();
assert!(matches!(parsed.op, Operator::NotIn));
}
#[test]
fn structured_in_returns_false_when_right_not_array() {
let ctx = tools_ctx_with(&[("x", serde_json::json!(1))]);
let cond = Condition {
left: "x".into(),
op: Operator::In,
right: Some(serde_json::json!("not an array")),
};
assert!(!evaluate_structured_condition(&cond, &ctx, None).unwrap());
}
}