diff --git a/internal/formula/expand.go b/internal/formula/expand.go index 916f6155..2736b7ff 100644 --- a/internal/formula/expand.go +++ b/internal/formula/expand.go @@ -72,8 +72,11 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S return nil, fmt.Errorf("expand: %q has no template steps", rule.With) } + // Merge formula default vars with rule overrides (gt-8tmz.34) + vars := mergeVars(expFormula, rule.Vars) + // Expand the target step (start at depth 0) - expandedSteps, err := expandStep(targetStep, expFormula.Template, 0) + expandedSteps, err := expandStep(targetStep, expFormula.Template, 0, vars) if err != nil { return nil, fmt.Errorf("expand %q: %w", rule.Target, err) } @@ -112,6 +115,9 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S return nil, fmt.Errorf("map: %q has no template steps", rule.With) } + // Merge formula default vars with rule overrides (gt-8tmz.34) + vars := mergeVars(expFormula, rule.Vars) + // Find all matching steps (including nested children - gt-8tmz.33) // Rebuild stepMap to capture any changes from previous expansions stepMap = buildStepMap(result) @@ -124,7 +130,7 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S // Expand each matching step for _, targetStep := range toExpand { - expandedSteps, err := expandStep(targetStep, expFormula.Template, 0) + expandedSteps, err := expandStep(targetStep, expFormula.Template, 0, vars) if err != nil { return nil, fmt.Errorf("map %q -> %q: %w", rule.Select, targetStep.ID, err) } @@ -153,7 +159,8 @@ func ApplyExpansions(steps []*Step, compose *ComposeRules, parser *Parser) ([]*S // Returns the expanded steps with placeholders substituted. // The depth parameter tracks recursion depth for children; if it exceeds // DefaultMaxExpansionDepth, an error is returned. -func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) { +// The vars parameter provides variable values for {varname} substitution. +func expandStep(target *Step, template []*Step, depth int, vars map[string]string) ([]*Step, error) { if depth > DefaultMaxExpansionDepth { return nil, fmt.Errorf("expansion depth limit exceeded: max %d levels (currently at %d) - step %q", DefaultMaxExpansionDepth, depth, target.ID) @@ -163,12 +170,12 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) { for _, tmpl := range template { expanded := &Step{ - ID: substituteTargetPlaceholders(tmpl.ID, target), - Title: substituteTargetPlaceholders(tmpl.Title, target), - Description: substituteTargetPlaceholders(tmpl.Description, target), + ID: substituteVars(substituteTargetPlaceholders(tmpl.ID, target), vars), + Title: substituteVars(substituteTargetPlaceholders(tmpl.Title, target), vars), + Description: substituteVars(substituteTargetPlaceholders(tmpl.Description, target), vars), Type: tmpl.Type, Priority: tmpl.Priority, - Assignee: tmpl.Assignee, + Assignee: substituteVars(tmpl.Assignee, vars), SourceFormula: tmpl.SourceFormula, // Preserve source from template (gt-8tmz.18) SourceLocation: tmpl.SourceLocation, // Preserve source location (gt-8tmz.18) } @@ -177,7 +184,7 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) { if len(tmpl.Labels) > 0 { expanded.Labels = make([]string, len(tmpl.Labels)) for i, l := range tmpl.Labels { - expanded.Labels[i] = substituteTargetPlaceholders(l, target) + expanded.Labels[i] = substituteVars(substituteTargetPlaceholders(l, target), vars) } } @@ -185,20 +192,20 @@ func expandStep(target *Step, template []*Step, depth int) ([]*Step, error) { if len(tmpl.DependsOn) > 0 { expanded.DependsOn = make([]string, len(tmpl.DependsOn)) for i, d := range tmpl.DependsOn { - expanded.DependsOn[i] = substituteTargetPlaceholders(d, target) + expanded.DependsOn[i] = substituteVars(substituteTargetPlaceholders(d, target), vars) } } if len(tmpl.Needs) > 0 { expanded.Needs = make([]string, len(tmpl.Needs)) for i, n := range tmpl.Needs { - expanded.Needs[i] = substituteTargetPlaceholders(n, target) + expanded.Needs[i] = substituteVars(substituteTargetPlaceholders(n, target), vars) } } // Handle children recursively with depth tracking if len(tmpl.Children) > 0 { - children, err := expandStep(target, tmpl.Children, depth+1) + children, err := expandStep(target, tmpl.Children, depth+1, vars) if err != nil { return nil, err } @@ -232,6 +239,26 @@ func substituteTargetPlaceholders(s string, target *Step) string { return s } +// mergeVars merges formula default vars with rule overrides. +// Override values take precedence over defaults. +func mergeVars(formula *Formula, overrides map[string]string) map[string]string { + result := make(map[string]string) + + // Start with formula defaults + for name, def := range formula.Vars { + if def.Default != "" { + result[name] = def.Default + } + } + + // Apply overrides (these win) + for name, value := range overrides { + result[name] = value + } + + return result +} + // buildStepMap creates a map of step ID to step (recursive). func buildStepMap(steps []*Step) map[string]*Step { result := make(map[string]*Step) diff --git a/internal/formula/expand_test.go b/internal/formula/expand_test.go index 85ea82d9..62e3a31d 100644 --- a/internal/formula/expand_test.go +++ b/internal/formula/expand_test.go @@ -87,7 +87,7 @@ func TestExpandStep(t *testing.T) { }, } - result, err := expandStep(target, template, 0) + result, err := expandStep(target, template, 0, nil) if err != nil { t.Fatalf("expandStep failed: %v", err) } @@ -138,7 +138,7 @@ func TestExpandStepDepthLimit(t *testing.T) { // With depth 0 start, going to level 6 means 7 levels total (0-6) // DefaultMaxExpansionDepth is 5, so this should fail - _, err := expandStep(target, template, 0) + _, err := expandStep(target, template, 0, nil) if err == nil { t.Fatal("expected depth limit error, got nil") } @@ -159,7 +159,7 @@ func TestExpandStepDepthLimit(t *testing.T) { } shallowTemplate := []*Step{shallowChild} - result, err := expandStep(target, shallowTemplate, 0) + result, err := expandStep(target, shallowTemplate, 0, nil) if err != nil { t.Fatalf("expected shallow template to succeed, got: %v", err) } @@ -432,3 +432,223 @@ func getChildIDs(steps []*Step) []string { } return ids } + +func TestSubstituteVars(t *testing.T) { + tests := []struct { + name string + input string + vars map[string]string + expected string + }{ + { + name: "single var substitution", + input: "Deploy to {environment}", + vars: map[string]string{"environment": "production"}, + expected: "Deploy to production", + }, + { + name: "multiple var substitution", + input: "{component} v{version}", + vars: map[string]string{"component": "auth", "version": "2.0"}, + expected: "auth v2.0", + }, + { + name: "unmatched placeholder stays", + input: "{known} and {unknown}", + vars: map[string]string{"known": "replaced"}, + expected: "replaced and {unknown}", + }, + { + name: "empty vars map", + input: "no {change}", + vars: nil, + expected: "no {change}", + }, + { + name: "empty string", + input: "", + vars: map[string]string{"foo": "bar"}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := substituteVars(tt.input, tt.vars) + if result != tt.expected { + t.Errorf("substituteVars(%q, %v) = %q, want %q", tt.input, tt.vars, result, tt.expected) + } + }) + } +} + +func TestMergeVars(t *testing.T) { + formula := &Formula{ + Vars: map[string]*VarDef{ + "env": {Default: "staging"}, + "version": {Default: "1.0"}, + "name": {Required: true}, // No default + }, + } + + t.Run("overrides take precedence", func(t *testing.T) { + overrides := map[string]string{"env": "production"} + result := mergeVars(formula, overrides) + + if result["env"] != "production" { + t.Errorf("env = %q, want 'production'", result["env"]) + } + if result["version"] != "1.0" { + t.Errorf("version = %q, want '1.0'", result["version"]) + } + }) + + t.Run("override adds new var", func(t *testing.T) { + overrides := map[string]string{"custom": "value"} + result := mergeVars(formula, overrides) + + if result["custom"] != "value" { + t.Errorf("custom = %q, want 'value'", result["custom"]) + } + }) + + t.Run("nil overrides uses defaults", func(t *testing.T) { + result := mergeVars(formula, nil) + + if result["env"] != "staging" { + t.Errorf("env = %q, want 'staging'", result["env"]) + } + }) +} + +func TestApplyExpansionsWithVars(t *testing.T) { + // Create a temporary directory with an expansion formula that uses vars + tmpDir := t.TempDir() + + // Create an expansion formula with variables + envExpansion := `{ + "formula": "env-deploy", + "type": "expansion", + "version": 1, + "vars": { + "environment": {"default": "staging"}, + "replicas": {"default": "1"} + }, + "template": [ + {"id": "{target}.prepare-{environment}", "title": "Prepare {environment} for {target.title}"}, + {"id": "{target}.deploy-{environment}", "title": "Deploy to {environment} with {replicas} replicas", "needs": ["{target}.prepare-{environment}"]} + ] + }` + err := os.WriteFile(filepath.Join(tmpDir, "env-deploy.formula.json"), []byte(envExpansion), 0644) + if err != nil { + t.Fatal(err) + } + + parser := NewParser(tmpDir) + + t.Run("expand with var overrides", func(t *testing.T) { + steps := []*Step{ + {ID: "design", Title: "Design"}, + {ID: "release", Title: "Release v2"}, + {ID: "test", Title: "Test"}, + } + + compose := &ComposeRules{ + Expand: []*ExpandRule{ + { + Target: "release", + With: "env-deploy", + Vars: map[string]string{"environment": "production", "replicas": "3"}, + }, + }, + } + + result, err := ApplyExpansions(steps, compose, parser) + if err != nil { + t.Fatalf("ApplyExpansions failed: %v", err) + } + + if len(result) != 4 { + t.Fatalf("expected 4 steps, got %d", len(result)) + } + + // Check expanded step IDs include var substitution + expectedIDs := []string{"design", "release.prepare-production", "release.deploy-production", "test"} + for i, exp := range expectedIDs { + if result[i].ID != exp { + t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp) + } + } + + // Check title includes both target and var substitution + if result[2].Title != "Deploy to production with 3 replicas" { + t.Errorf("deploy title = %q, want 'Deploy to production with 3 replicas'", result[2].Title) + } + + // Check that needs was also substituted correctly + if len(result[2].Needs) != 1 || result[2].Needs[0] != "release.prepare-production" { + t.Errorf("deploy needs = %v, want [release.prepare-production]", result[2].Needs) + } + }) + + t.Run("expand with default vars", func(t *testing.T) { + steps := []*Step{ + {ID: "release", Title: "Release"}, + } + + compose := &ComposeRules{ + Expand: []*ExpandRule{ + {Target: "release", With: "env-deploy"}, + }, + } + + result, err := ApplyExpansions(steps, compose, parser) + if err != nil { + t.Fatalf("ApplyExpansions failed: %v", err) + } + + // Check that defaults are used + if result[0].ID != "release.prepare-staging" { + t.Errorf("result[0].ID = %q, want 'release.prepare-staging'", result[0].ID) + } + if result[1].Title != "Deploy to staging with 1 replicas" { + t.Errorf("deploy title = %q, want 'Deploy to staging with 1 replicas'", result[1].Title) + } + }) + + t.Run("map with var overrides", func(t *testing.T) { + steps := []*Step{ + {ID: "deploy.api", Title: "Deploy API"}, + {ID: "deploy.web", Title: "Deploy Web"}, + } + + compose := &ComposeRules{ + Map: []*MapRule{ + { + Select: "deploy.*", + With: "env-deploy", + Vars: map[string]string{"environment": "prod"}, + }, + }, + } + + result, err := ApplyExpansions(steps, compose, parser) + if err != nil { + t.Fatalf("ApplyExpansions failed: %v", err) + } + + // Each deploy.* step should expand with prod environment + expectedIDs := []string{ + "deploy.api.prepare-prod", "deploy.api.deploy-prod", + "deploy.web.prepare-prod", "deploy.web.deploy-prod", + } + if len(result) != len(expectedIDs) { + t.Fatalf("expected %d steps, got %d", len(expectedIDs), len(result)) + } + for i, exp := range expectedIDs { + if result[i].ID != exp { + t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, exp) + } + } + }) +}