Support logical when expressions

This commit is contained in:
Virgil 2026-04-01 22:07:30 +00:00
parent d969cc9205
commit 8130be049a
4 changed files with 162 additions and 0 deletions

View file

@ -222,6 +222,7 @@ The `evaluateWhen` method processes `when:` clauses. It supports:
- Boolean literals: `true`, `false`, `True`, `False`
- Negation: `not <condition>`
- 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)

View file

@ -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)

View file

@ -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{

View file

@ -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