Files
beads/internal/formula/stepcondition.go
beads/crew/emma 6893eb6080 feat: add negation support for step conditions (!{{var}})
Adds "!{{var}}" syntax for negated truthy checks in Step.Condition.
Useful for "skip this step if feature is enabled" patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 00:37:59 -08:00

154 lines
4.3 KiB
Go

// Package formula provides Step.Condition evaluation for compile-time step filtering.
//
// Step.Condition is simpler than the runtime condition evaluation in condition.go.
// It evaluates at cook/pour time to include or exclude steps based on formula variables.
//
// Supported formats:
// - "{{var}}" - truthy check (non-empty, non-"false", non-"0")
// - "!{{var}}" - negated truthy check (include if var is falsy)
// - "{{var}} == value" - equality check
// - "{{var}} != value" - inequality check
package formula
import (
"fmt"
"regexp"
"strings"
)
// Step condition patterns
var (
// {{var}} - simple variable reference for truthy check
stepCondVarPattern = regexp.MustCompile(`^\{\{(\w+)\}\}$`)
// !{{var}} - negated truthy check
stepCondNegatedVarPattern = regexp.MustCompile(`^!\{\{(\w+)\}\}$`)
// {{var}} == value or {{var}} != value
stepCondComparePattern = regexp.MustCompile(`^\{\{(\w+)\}\}\s*(==|!=)\s*(.+)$`)
)
// EvaluateStepCondition evaluates a step's condition against variable values.
// Returns true if the step should be included, false if it should be skipped.
//
// Condition formats:
// - "" (empty) - always include
// - "{{var}}" - include if var is truthy (non-empty, non-"false", non-"0")
// - "!{{var}}" - include if var is NOT truthy (negated)
// - "{{var}} == value" - include if var equals value
// - "{{var}} != value" - include if var does not equal value
func EvaluateStepCondition(condition string, vars map[string]string) (bool, error) {
condition = strings.TrimSpace(condition)
// Empty condition means always include
if condition == "" {
return true, nil
}
// Try truthy pattern: {{var}}
if m := stepCondVarPattern.FindStringSubmatch(condition); m != nil {
varName := m[1]
value := vars[varName]
return isTruthy(value), nil
}
// Try negated truthy pattern: !{{var}}
if m := stepCondNegatedVarPattern.FindStringSubmatch(condition); m != nil {
varName := m[1]
value := vars[varName]
return !isTruthy(value), nil
}
// Try comparison pattern: {{var}} == value or {{var}} != value
if m := stepCondComparePattern.FindStringSubmatch(condition); m != nil {
varName := m[1]
operator := m[2]
expected := strings.TrimSpace(m[3])
// Remove quotes from expected value if present
expected = unquoteValue(expected)
actual := vars[varName]
switch operator {
case "==":
return actual == expected, nil
case "!=":
return actual != expected, nil
}
}
return false, fmt.Errorf("invalid step condition format: %q (expected {{var}} or {{var}} == value)", condition)
}
// isTruthy returns true if a value is considered "truthy" for step conditions.
// Falsy values: empty string, "false", "0", "no", "off"
// All other values are truthy.
func isTruthy(value string) bool {
if value == "" {
return false
}
lower := strings.ToLower(value)
switch lower {
case "false", "0", "no", "off":
return false
}
return true
}
// unquoteValue removes surrounding quotes from a value if present.
func unquoteValue(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
// FilterStepsByCondition filters a list of steps based on their Condition field.
// Steps with conditions that evaluate to false are excluded from the result.
// Children of excluded steps are also excluded.
//
// Parameters:
// - steps: the steps to filter
// - vars: variable values for condition evaluation
//
// Returns the filtered steps and any error encountered during evaluation.
func FilterStepsByCondition(steps []*Step, vars map[string]string) ([]*Step, error) {
if vars == nil {
vars = make(map[string]string)
}
result := make([]*Step, 0, len(steps))
for _, step := range steps {
// Evaluate step condition
include, err := EvaluateStepCondition(step.Condition, vars)
if err != nil {
return nil, fmt.Errorf("step %q: %w", step.ID, err)
}
if !include {
// Skip this step and all its children
continue
}
// Clone the step to avoid mutating input
clone := cloneStep(step)
// Recursively filter children
if len(step.Children) > 0 {
filteredChildren, err := FilterStepsByCondition(step.Children, vars)
if err != nil {
return nil, err
}
clone.Children = filteredChildren
}
result = append(result, clone)
}
return result, nil
}