diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index 90ef6086..95d0ef08 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -25,6 +25,18 @@ var cookCmd = &cobra.Command{ By default, cook outputs the resolved formula as JSON to stdout for ephemeral use. The output can be inspected, piped, or saved to a file. +Two cooking modes are available (gt-8tmz.23): + + COMPILE-TIME (default, --mode=compile): + Produces a proto with {{variable}} placeholders intact. + Use for: modeling, estimation, contractor handoff, planning. + Variables are NOT substituted - the output shows the template structure. + + RUNTIME (--mode=runtime or when --var flags provided): + Produces a fully-resolved proto with variables substituted. + Use for: final validation before pour, seeing exact output. + Requires all variables to have values (via --var or defaults). + Formulas are high-level workflow templates that support: - Variable definitions with defaults and validation - Step definitions that become issue hierarchies @@ -39,7 +51,9 @@ For most workflows, prefer ephemeral protos: pour and wisp commands accept formula names directly and cook inline (bd-rciw). Examples: - bd cook mol-feature.formula.json # Output JSON to stdout + bd cook mol-feature.formula.json # Compile-time: keep {{vars}} + bd cook mol-feature --var name=auth # Runtime: substitute vars + bd cook mol-feature --mode=runtime --var name=auth # Explicit runtime mode bd cook mol-feature --dry-run # Preview steps bd cook mol-release.formula.json --persist # Write to database bd cook mol-release.formula.json --persist --force # Replace existing @@ -72,6 +86,27 @@ func runCook(cmd *cobra.Command, args []string) { force, _ := cmd.Flags().GetBool("force") searchPaths, _ := cmd.Flags().GetStringSlice("search-path") prefix, _ := cmd.Flags().GetString("prefix") + varFlags, _ := cmd.Flags().GetStringSlice("var") + mode, _ := cmd.Flags().GetString("mode") + + // Parse variables (gt-8tmz.23) + inputVars := make(map[string]string) + for _, v := range varFlags { + parts := strings.SplitN(v, "=", 2) + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) + os.Exit(1) + } + inputVars[parts[0]] = parts[1] + } + + // Determine cooking mode (gt-8tmz.23) + // Runtime mode is triggered by: explicit --mode=runtime OR providing --var flags + runtimeMode := mode == "runtime" || len(inputVars) > 0 + if mode != "" && mode != "compile" && mode != "runtime" { + fmt.Fprintf(os.Stderr, "Error: invalid mode '%s', must be 'compile' or 'runtime'\n", mode) + os.Exit(1) + } // Only need store access if persisting if persist { @@ -168,19 +203,48 @@ func runCook(cmd *cobra.Command, args []string) { } if dryRun { - fmt.Printf("\nDry run: would cook formula %s as proto %s\n\n", resolved.Formula, protoID) - fmt.Printf("Steps (%d):\n", len(resolved.Steps)) + // Determine mode label for display + modeLabel := "compile-time" + if runtimeMode { + modeLabel = "runtime" + // Apply defaults for runtime mode display + for name, def := range resolved.Vars { + if _, provided := inputVars[name]; !provided && def.Default != "" { + inputVars[name] = def.Default + } + } + } + + fmt.Printf("\nDry run: would cook formula %s as proto %s (%s mode)\n\n", resolved.Formula, protoID, modeLabel) + + // In runtime mode, show substituted steps + if runtimeMode { + // Create a copy with substituted values for display + substituteFormulaVars(resolved, inputVars) + fmt.Printf("Steps (%d) [variables substituted]:\n", len(resolved.Steps)) + } else { + fmt.Printf("Steps (%d) [{{variables}} shown as placeholders]:\n", len(resolved.Steps)) + } printFormulaSteps(resolved.Steps, " ") if len(vars) > 0 { - fmt.Printf("\nVariables: %s\n", strings.Join(vars, ", ")) + fmt.Printf("\nVariables used: %s\n", strings.Join(vars, ", ")) } + + // Show variable values in runtime mode + if runtimeMode && len(inputVars) > 0 { + fmt.Printf("\nVariable values:\n") + for name, value := range inputVars { + fmt.Printf(" {{%s}} = %s\n", name, value) + } + } + if len(bondPoints) > 0 { fmt.Printf("Bond points: %s\n", strings.Join(bondPoints, ", ")) } - // Show variable definitions - if len(resolved.Vars) > 0 { + // Show variable definitions (more useful in compile-time mode) + if !runtimeMode && len(resolved.Vars) > 0 { fmt.Printf("\nVariable definitions:\n") for name, def := range resolved.Vars { attrs := []string{} @@ -205,6 +269,32 @@ func runCook(cmd *cobra.Command, args []string) { // Ephemeral mode (default): output resolved formula as JSON to stdout (bd-rciw) if !persist { + // Runtime mode (gt-8tmz.23): substitute variables before output + if runtimeMode { + // Apply defaults from formula variable definitions + for name, def := range resolved.Vars { + if _, provided := inputVars[name]; !provided && def.Default != "" { + inputVars[name] = def.Default + } + } + + // Check for missing required variables + var missingVars []string + for _, v := range vars { + if _, ok := inputVars[v]; !ok { + missingVars = append(missingVars, v) + } + } + if len(missingVars) > 0 { + fmt.Fprintf(os.Stderr, "Error: runtime mode requires all variables to have values\n") + fmt.Fprintf(os.Stderr, "Missing: %s\n", strings.Join(missingVars, ", ")) + fmt.Fprintf(os.Stderr, "Provide with: --var %s=\n", missingVars[0]) + os.Exit(1) + } + + // Substitute variables in the formula + substituteFormulaVars(resolved, inputVars) + } outputJSON(resolved) return } @@ -857,12 +947,35 @@ func printFormulaSteps(steps []*formula.Step, indent string) { } } +// substituteFormulaVars substitutes {{variable}} placeholders in a formula (gt-8tmz.23). +// This is used in runtime mode to fully resolve the formula before output. +func substituteFormulaVars(f *formula.Formula, vars map[string]string) { + // Substitute in top-level fields + f.Description = substituteVariables(f.Description, vars) + + // Substitute in all steps recursively + substituteStepVars(f.Steps, vars) +} + +// substituteStepVars recursively substitutes variables in step titles and descriptions. +func substituteStepVars(steps []*formula.Step, vars map[string]string) { + for _, step := range steps { + step.Title = substituteVariables(step.Title, vars) + step.Description = substituteVariables(step.Description, vars) + if len(step.Children) > 0 { + substituteStepVars(step.Children, vars) + } + } +} + func init() { cookCmd.Flags().Bool("dry-run", false, "Preview what would be created") cookCmd.Flags().Bool("persist", false, "Persist proto to database (legacy behavior)") cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists (requires --persist)") cookCmd.Flags().StringSlice("search-path", []string{}, "Additional paths to search for formula inheritance") cookCmd.Flags().String("prefix", "", "Prefix to prepend to proto ID (e.g., 'gt-' creates 'gt-mol-feature')") + cookCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value), enables runtime mode") + cookCmd.Flags().String("mode", "", "Cooking mode: compile (keep placeholders) or runtime (substitute vars)") rootCmd.AddCommand(cookCmd) } diff --git a/cmd/bd/cook_test.go b/cmd/bd/cook_test.go new file mode 100644 index 00000000..27353da3 --- /dev/null +++ b/cmd/bd/cook_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "testing" + + "github.com/steveyegge/beads/internal/formula" +) + +// ============================================================================= +// Cook Tests (gt-8tmz.23: Compile-time vs Runtime Cooking) +// ============================================================================= + +// TestSubstituteFormulaVars tests variable substitution in formulas +func TestSubstituteFormulaVars(t *testing.T) { + tests := []struct { + name string + formula *formula.Formula + vars map[string]string + wantDesc string + wantStepTitle string + }{ + { + name: "substitute single variable in description", + formula: &formula.Formula{ + Description: "Build {{feature}} feature", + Steps: []*formula.Step{}, + }, + vars: map[string]string{"feature": "auth"}, + wantDesc: "Build auth feature", + }, + { + name: "substitute variable in step title", + formula: &formula.Formula{ + Description: "Feature work", + Steps: []*formula.Step{ + {Title: "Implement {{name}}"}, + }, + }, + vars: map[string]string{"name": "login"}, + wantDesc: "Feature work", + wantStepTitle: "Implement login", + }, + { + name: "substitute multiple variables", + formula: &formula.Formula{ + Description: "Release {{version}} on {{date}}", + Steps: []*formula.Step{ + {Title: "Tag {{version}}"}, + {Title: "Deploy to {{env}}"}, + }, + }, + vars: map[string]string{ + "version": "1.0.0", + "date": "2024-01-15", + "env": "production", + }, + wantDesc: "Release 1.0.0 on 2024-01-15", + wantStepTitle: "Tag 1.0.0", + }, + { + name: "nested children substitution", + formula: &formula.Formula{ + Description: "Epic for {{project}}", + Steps: []*formula.Step{ + { + Title: "Phase 1: {{project}} design", + Children: []*formula.Step{ + {Title: "Design {{component}}"}, + }, + }, + }, + }, + vars: map[string]string{ + "project": "checkout", + "component": "cart", + }, + wantDesc: "Epic for checkout", + wantStepTitle: "Phase 1: checkout design", + }, + { + name: "unsubstituted variable left as-is", + formula: &formula.Formula{ + Description: "Build {{feature}} with {{extra}}", + Steps: []*formula.Step{}, + }, + vars: map[string]string{"feature": "auth"}, + wantDesc: "Build auth with {{extra}}", // {{extra}} unchanged + }, + { + name: "empty vars map", + formula: &formula.Formula{ + Description: "Keep {{placeholder}} intact", + Steps: []*formula.Step{}, + }, + vars: map[string]string{}, + wantDesc: "Keep {{placeholder}} intact", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + substituteFormulaVars(tt.formula, tt.vars) + + if tt.formula.Description != tt.wantDesc { + t.Errorf("Description = %q, want %q", tt.formula.Description, tt.wantDesc) + } + + if tt.wantStepTitle != "" && len(tt.formula.Steps) > 0 { + if tt.formula.Steps[0].Title != tt.wantStepTitle { + t.Errorf("Steps[0].Title = %q, want %q", tt.formula.Steps[0].Title, tt.wantStepTitle) + } + } + }) + } +} + +// TestSubstituteStepVarsRecursive tests deep nesting works correctly +func TestSubstituteStepVarsRecursive(t *testing.T) { + steps := []*formula.Step{ + { + Title: "Root: {{name}}", + Children: []*formula.Step{ + { + Title: "Level 1: {{name}}", + Children: []*formula.Step{ + { + Title: "Level 2: {{name}}", + Children: []*formula.Step{ + {Title: "Level 3: {{name}}"}, + }, + }, + }, + }, + }, + }, + } + + vars := map[string]string{"name": "test"} + substituteStepVars(steps, vars) + + // Check all levels got substituted + if steps[0].Title != "Root: test" { + t.Errorf("Root title = %q, want %q", steps[0].Title, "Root: test") + } + if steps[0].Children[0].Title != "Level 1: test" { + t.Errorf("Level 1 title = %q, want %q", steps[0].Children[0].Title, "Level 1: test") + } + if steps[0].Children[0].Children[0].Title != "Level 2: test" { + t.Errorf("Level 2 title = %q, want %q", steps[0].Children[0].Children[0].Title, "Level 2: test") + } + if steps[0].Children[0].Children[0].Children[0].Title != "Level 3: test" { + t.Errorf("Level 3 title = %q, want %q", steps[0].Children[0].Children[0].Children[0].Title, "Level 3: test") + } +} + +// TestCompileTimeVsRuntimeMode tests that compile-time preserves placeholders +// and runtime mode substitutes them +func TestCompileTimeVsRuntimeMode(t *testing.T) { + // Simulate compile-time mode (no variable substitution) + compileFormula := &formula.Formula{ + Description: "Feature: {{name}}", + Steps: []*formula.Step{ + {Title: "Implement {{name}}"}, + }, + } + + // In compile-time mode, don't call substituteFormulaVars + // Placeholders should remain intact + if compileFormula.Description != "Feature: {{name}}" { + t.Errorf("Compile-time: Description should preserve placeholder, got %q", compileFormula.Description) + } + + // Simulate runtime mode (with variable substitution) + runtimeFormula := &formula.Formula{ + Description: "Feature: {{name}}", + Steps: []*formula.Step{ + {Title: "Implement {{name}}"}, + }, + } + vars := map[string]string{"name": "auth"} + substituteFormulaVars(runtimeFormula, vars) + + if runtimeFormula.Description != "Feature: auth" { + t.Errorf("Runtime: Description = %q, want %q", runtimeFormula.Description, "Feature: auth") + } + if runtimeFormula.Steps[0].Title != "Implement auth" { + t.Errorf("Runtime: Steps[0].Title = %q, want %q", runtimeFormula.Steps[0].Title, "Implement auth") + } +}