From 8130be049ad13eb820fba157c1251916d23e7d52 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:07:30 +0000 Subject: [PATCH] Support logical when expressions --- docs/architecture.md | 1 + executor.go | 139 +++++++++++++++++++++++++++++++++++++++++++ executor_test.go | 21 +++++++ kb/Executor.md | 1 + 4 files changed, 162 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 0d446b5..15e2a71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -222,6 +222,7 @@ The `evaluateWhen` method processes `when:` clauses. It supports: - Boolean literals: `true`, `false`, `True`, `False` - Negation: `not ` +- Inline boolean expressions with `and`, `or`, and parentheses - Registered variable checks: `result is defined`, `result is success`, `result is failed`, `result is changed`, `result is skipped` - Variable truthiness: checks `vars` map for the condition as a key, evaluating booleans, non-empty strings, and non-zero integers - Default filter handling: `var | default(value)` always evaluates to true (permissive) diff --git a/executor.go b/executor.go index 2027f95..7d1ed3b 100644 --- a/executor.go +++ b/executor.go @@ -1343,6 +1343,20 @@ func (e *Executor) evalCondition(cond string, host string) bool { func (e *Executor) evalConditionWithLocals(cond string, host string, task *Task, locals map[string]any) bool { cond = corexTrimSpace(cond) + if cond == "" { + return true + } + + if inner, ok := stripOuterParens(cond); ok { + return e.evalConditionWithLocals(inner, host, task, locals) + } + + if left, right, ok := splitLogicalCondition(cond, "or"); ok { + return e.evalConditionWithLocals(left, host, task, locals) || e.evalConditionWithLocals(right, host, task, locals) + } + if left, right, ok := splitLogicalCondition(cond, "and"); ok { + return e.evalConditionWithLocals(left, host, task, locals) && e.evalConditionWithLocals(right, host, task, locals) + } // Handle negation if corexHasPrefix(cond, "not ") { @@ -1476,6 +1490,131 @@ func (e *Executor) evalConditionWithLocals(cond string, host string, task *Task, return true } +func stripOuterParens(cond string) (string, bool) { + cond = corexTrimSpace(cond) + if len(cond) < 2 || cond[0] != '(' || cond[len(cond)-1] != ')' { + return "", false + } + + depth := 0 + inSingle := false + inDouble := false + escaped := false + for i := 0; i < len(cond); i++ { + ch := cond[i] + if escaped { + escaped = false + continue + } + if ch == '\\' && (inSingle || inDouble) { + escaped = true + continue + } + if ch == '\'' && !inDouble { + inSingle = !inSingle + continue + } + if ch == '"' && !inSingle { + inDouble = !inDouble + continue + } + if inSingle || inDouble { + continue + } + switch ch { + case '(': + depth++ + case ')': + depth-- + if depth == 0 && i != len(cond)-1 { + return "", false + } + if depth < 0 { + return "", false + } + } + } + + if depth != 0 { + return "", false + } + + return corexTrimSpace(cond[1 : len(cond)-1]), true +} + +func splitLogicalCondition(cond, op string) (string, string, bool) { + depth := 0 + inSingle := false + inDouble := false + escaped := false + for i := 0; i <= len(cond)-len(op); i++ { + ch := cond[i] + if escaped { + escaped = false + continue + } + if ch == '\\' && (inSingle || inDouble) { + escaped = true + continue + } + if ch == '\'' && !inDouble { + inSingle = !inSingle + continue + } + if ch == '"' && !inSingle { + inDouble = !inDouble + continue + } + if inSingle || inDouble { + continue + } + switch ch { + case '(': + depth++ + continue + case ')': + if depth > 0 { + depth-- + } + continue + } + + if depth != 0 || !strings.HasPrefix(cond[i:], op) { + continue + } + + if i > 0 { + prev := cond[i-1] + if !isConditionBoundary(prev) { + continue + } + } + if end := i + len(op); end < len(cond) { + if !isConditionBoundary(cond[end]) { + continue + } + } + + left := corexTrimSpace(cond[:i]) + right := corexTrimSpace(cond[i+len(op):]) + if left == "" || right == "" { + continue + } + return left, right, true + } + + return "", "", false +} + +func isConditionBoundary(ch byte) bool { + switch ch { + case ' ', '\t', '\n', '\r', '(', ')': + return true + default: + return false + } +} + func (e *Executor) lookupConditionValue(name string, host string, task *Task, locals map[string]any) (any, bool) { name = corexTrimSpace(name) diff --git a/executor_test.go b/executor_test.go index f104c1b..705ded6 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1024,6 +1024,27 @@ func TestExecutor_EvaluateWhen_Good_MultipleConditions(t *testing.T) { assert.False(t, e.evaluateWhen([]any{"true", "false"}, "host1", nil)) } +func TestExecutor_EvaluateWhen_Good_LogicalAndOrExpressions(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["enabled"] = true + e.vars["maintenance"] = false + + assert.True(t, e.evaluateWhen("enabled and not maintenance", "host1", nil)) + assert.False(t, e.evaluateWhen("enabled and maintenance", "host1", nil)) + assert.True(t, e.evaluateWhen("enabled or maintenance", "host1", nil)) + assert.False(t, e.evaluateWhen("maintenance or false", "host1", nil)) +} + +func TestExecutor_EvaluateWhen_Good_LogicalExpressionParentheses(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["enabled"] = true + e.vars["maintenance"] = false + e.vars["deployed"] = true + + assert.True(t, e.evaluateWhen("(enabled and not maintenance) or deployed", "host1", nil)) + assert.False(t, e.evaluateWhen("enabled and (maintenance or false)", "host1", nil)) +} + func TestExecutor_ApplyTaskResultConditions_Good_ChangedWhen(t *testing.T) { e := NewExecutor("/tmp") task := &Task{ diff --git a/kb/Executor.md b/kb/Executor.md index 9e1627e..b513313 100644 --- a/kb/Executor.md +++ b/kb/Executor.md @@ -36,6 +36,7 @@ Jinja2-like `{{ var }}` syntax is supported: `when` supports: - Boolean literals: `true`, `false` +- Inline boolean expressions with `and`, `or`, and parentheses - Registered variable checks: `result is success`, `result is failed`, `result is changed`, `result is defined` - Negation: `not condition` - Variable truthiness checks