feat: implement Step.Condition evaluation in bd cook (bd-7zka.1)

Add compile-time step filtering based on formula variables:
- New EvaluateStepCondition function for {{var}} truthy and equality checks
- FilterStepsByCondition to exclude steps based on conditions
- Integration into pour, wisp, and mol bond commands
- Supports: {{var}}, {{var}} == value, {{var}} != value

Steps with conditions that evaluate to false are excluded from the
cooked formula, along with their children.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beads/crew/emma
2025-12-31 00:33:11 -08:00
committed by Steve Yegge
parent 82611834df
commit ee34a74e90
7 changed files with 513 additions and 8 deletions

View File

@@ -0,0 +1,141 @@
// 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}} == 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}} == 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}} == 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 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
}

View File

@@ -0,0 +1,331 @@
package formula
import (
"testing"
)
func TestEvaluateStepCondition(t *testing.T) {
tests := []struct {
name string
condition string
vars map[string]string
want bool
wantErr bool
}{
// Empty condition - always include
{
name: "empty condition",
condition: "",
vars: nil,
want: true,
wantErr: false,
},
// Truthy checks: {{var}}
{
name: "truthy - non-empty value",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "yes"},
want: true,
wantErr: false,
},
{
name: "truthy - empty value",
condition: "{{enabled}}",
vars: map[string]string{"enabled": ""},
want: false,
wantErr: false,
},
{
name: "truthy - missing variable",
condition: "{{enabled}}",
vars: map[string]string{},
want: false,
wantErr: false,
},
{
name: "truthy - false string",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "false"},
want: false,
wantErr: false,
},
{
name: "truthy - FALSE string",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "FALSE"},
want: false,
wantErr: false,
},
{
name: "truthy - 0 string",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "0"},
want: false,
wantErr: false,
},
{
name: "truthy - no string",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "no"},
want: false,
wantErr: false,
},
{
name: "truthy - off string",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "off"},
want: false,
wantErr: false,
},
{
name: "truthy - true string",
condition: "{{enabled}}",
vars: map[string]string{"enabled": "true"},
want: true,
wantErr: false,
},
// Equality checks: {{var}} == value
{
name: "equality - match",
condition: "{{env}} == staging",
vars: map[string]string{"env": "staging"},
want: true,
wantErr: false,
},
{
name: "equality - no match",
condition: "{{env}} == production",
vars: map[string]string{"env": "staging"},
want: false,
wantErr: false,
},
{
name: "equality - quoted value match",
condition: "{{env}} == 'staging'",
vars: map[string]string{"env": "staging"},
want: true,
wantErr: false,
},
{
name: "equality - double quoted value match",
condition: `{{env}} == "staging"`,
vars: map[string]string{"env": "staging"},
want: true,
wantErr: false,
},
// Inequality checks: {{var}} != value
{
name: "inequality - different value",
condition: "{{env}} != production",
vars: map[string]string{"env": "staging"},
want: true,
wantErr: false,
},
{
name: "inequality - same value",
condition: "{{env}} != staging",
vars: map[string]string{"env": "staging"},
want: false,
wantErr: false,
},
// Invalid conditions
{
name: "invalid - no variable braces",
condition: "env == staging",
vars: map[string]string{"env": "staging"},
want: false,
wantErr: true,
},
{
name: "invalid - random text",
condition: "something random",
vars: map[string]string{},
want: false,
wantErr: true,
},
// Edge cases
{
name: "whitespace in condition",
condition: " {{env}} == staging ",
vars: map[string]string{"env": "staging"},
want: true,
wantErr: false,
},
{
name: "value with spaces",
condition: "{{msg}} == 'hello world'",
vars: map[string]string{"msg": "hello world"},
want: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := EvaluateStepCondition(tt.condition, tt.vars)
if (err != nil) != tt.wantErr {
t.Errorf("EvaluateStepCondition() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("EvaluateStepCondition() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsTruthy(t *testing.T) {
tests := []struct {
value string
want bool
}{
{"", false},
{"false", false},
{"False", false},
{"FALSE", false},
{"0", false},
{"no", false},
{"No", false},
{"NO", false},
{"off", false},
{"Off", false},
{"OFF", false},
{"true", true},
{"True", true},
{"TRUE", true},
{"1", true},
{"yes", true},
{"on", true},
{"anything", true},
{"enabled", true},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
if got := isTruthy(tt.value); got != tt.want {
t.Errorf("isTruthy(%q) = %v, want %v", tt.value, got, tt.want)
}
})
}
}
func TestFilterStepsByCondition(t *testing.T) {
tests := []struct {
name string
steps []*Step
vars map[string]string
wantIDs []string // Expected step IDs in result
wantErr bool
}{
{
name: "no conditions - all included",
steps: []*Step{
{ID: "step1", Title: "Step 1"},
{ID: "step2", Title: "Step 2"},
},
vars: nil,
wantIDs: []string{"step1", "step2"},
},
{
name: "truthy condition - included",
steps: []*Step{
{ID: "step1", Title: "Step 1", Condition: "{{enabled}}"},
},
vars: map[string]string{"enabled": "true"},
wantIDs: []string{"step1"},
},
{
name: "truthy condition - excluded",
steps: []*Step{
{ID: "step1", Title: "Step 1", Condition: "{{enabled}}"},
},
vars: map[string]string{"enabled": "false"},
wantIDs: []string{},
},
{
name: "mixed conditions",
steps: []*Step{
{ID: "step1", Title: "Step 1"},
{ID: "step2", Title: "Step 2", Condition: "{{run_tests}}"},
{ID: "step3", Title: "Step 3", Condition: "{{env}} == production"},
},
vars: map[string]string{"run_tests": "yes", "env": "staging"},
wantIDs: []string{"step1", "step2"},
},
{
name: "children inherit parent filter",
steps: []*Step{
{
ID: "parent",
Title: "Parent",
Condition: "{{include_parent}}",
Children: []*Step{
{ID: "child1", Title: "Child 1"},
{ID: "child2", Title: "Child 2"},
},
},
},
vars: map[string]string{"include_parent": "false"},
wantIDs: []string{}, // Parent excluded, children go with it
},
{
name: "child with own condition",
steps: []*Step{
{
ID: "parent",
Title: "Parent",
Children: []*Step{
{ID: "child1", Title: "Child 1"},
{ID: "child2", Title: "Child 2", Condition: "{{include_child2}}"},
},
},
},
vars: map[string]string{"include_child2": "no"},
wantIDs: []string{"parent", "child1"},
},
{
name: "equality condition",
steps: []*Step{
{ID: "deploy-staging", Title: "Deploy Staging", Condition: "{{env}} == staging"},
{ID: "deploy-prod", Title: "Deploy Prod", Condition: "{{env}} == production"},
},
vars: map[string]string{"env": "staging"},
wantIDs: []string{"deploy-staging"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := FilterStepsByCondition(tt.steps, tt.vars)
if (err != nil) != tt.wantErr {
t.Errorf("FilterStepsByCondition() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Collect all IDs (including children) from result
gotIDs := collectStepIDsForTest(result)
if len(gotIDs) != len(tt.wantIDs) {
t.Errorf("FilterStepsByCondition() got %d steps %v, want %d steps %v",
len(gotIDs), gotIDs, len(tt.wantIDs), tt.wantIDs)
return
}
for i, wantID := range tt.wantIDs {
if i >= len(gotIDs) || gotIDs[i] != wantID {
t.Errorf("FilterStepsByCondition() step[%d] = %v, want %v", i, gotIDs, tt.wantIDs)
return
}
}
})
}
}
// collectStepIDsForTest collects all step IDs (including children) in order.
func collectStepIDsForTest(steps []*Step) []string {
var ids []string
for _, s := range steps {
ids = append(ids, s.ID)
ids = append(ids, collectStepIDsForTest(s.Children)...)
}
return ids
}

View File

@@ -175,8 +175,8 @@ type Step struct {
ExpandVars map[string]string `json:"expand_vars,omitempty"`
// Condition makes this step optional based on a variable.
// Format: "{{var}}" (truthy) or "{{var}} == value".
// TODO(bd-7zka): Not yet implemented in bd cook. Filed as future work.
// Format: "{{var}}" (truthy) or "{{var}} == value" or "{{var}} != value".
// Evaluated at cook/pour time via FilterStepsByCondition.
Condition string `json:"condition,omitempty"`
// Children are nested steps (for creating epic hierarchies).