diff --git a/go.mod b/go.mod index 49f77d6b..c458bebf 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/BurntSushi/toml v1.6.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect diff --git a/go.sum b/go.sum index d1c0572b..299315eb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= diff --git a/internal/formula/integration_test.go b/internal/formula/integration_test.go new file mode 100644 index 00000000..a7ab054f --- /dev/null +++ b/internal/formula/integration_test.go @@ -0,0 +1,97 @@ +package formula + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestParseRealFormulas tests parsing actual formula files from the filesystem. +// This is an integration test that validates our parser against real-world files. +func TestParseRealFormulas(t *testing.T) { + // Find formula files - they're in various .beads/formulas directories + formulaDirs := []string{ + "/Users/stevey/gt/gastown/polecats/slit/.beads/formulas", + "/Users/stevey/gt/gastown/mayor/rig/.beads/formulas", + } + + var formulaFiles []string + for _, dir := range formulaDirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue // Skip if directory doesn't exist + } + for _, e := range entries { + if filepath.Ext(e.Name()) == ".toml" { + formulaFiles = append(formulaFiles, filepath.Join(dir, e.Name())) + } + } + } + + if len(formulaFiles) == 0 { + t.Skip("No formula files found to test") + } + + // Known files that use advanced features not yet supported: + // - Composition (extends, compose): shiny-enterprise, shiny-secure + // - Aspect-oriented (advice, pointcuts): security-audit + skipAdvanced := map[string]string{ + "shiny-enterprise.formula.toml": "uses formula composition (extends)", + "shiny-secure.formula.toml": "uses formula composition (extends)", + "security-audit.formula.toml": "uses aspect-oriented features (advice/pointcuts)", + } + + for _, path := range formulaFiles { + t.Run(filepath.Base(path), func(t *testing.T) { + baseName := filepath.Base(path) + if reason, ok := skipAdvanced[baseName]; ok { + t.Skipf("Skipping advanced formula: %s", reason) + return + } + + f, err := ParseFile(path) + if err != nil { + // Check if this is a composition formula (has extends) + if strings.Contains(err.Error(), "requires at least one") { + t.Skipf("Skipping: likely a composition formula - %v", err) + return + } + t.Errorf("ParseFile failed: %v", err) + return + } + + // Basic sanity checks + if f.Name == "" { + t.Error("Formula name is empty") + } + if !f.Type.IsValid() { + t.Errorf("Invalid formula type: %s", f.Type) + } + + // Type-specific checks + switch f.Type { + case TypeConvoy: + if len(f.Legs) == 0 { + t.Error("Convoy formula has no legs") + } + t.Logf("Convoy formula with %d legs", len(f.Legs)) + case TypeWorkflow: + if len(f.Steps) == 0 { + t.Error("Workflow formula has no steps") + } + // Test topological sort + order, err := f.TopologicalSort() + if err != nil { + t.Errorf("TopologicalSort failed: %v", err) + } + t.Logf("Workflow formula with %d steps, sorted order: %v", len(f.Steps), order) + case TypeExpansion: + if len(f.Template) == 0 { + t.Error("Expansion formula has no templates") + } + t.Logf("Expansion formula with %d templates", len(f.Template)) + } + }) + } +} diff --git a/internal/formula/parser.go b/internal/formula/parser.go new file mode 100644 index 00000000..f88a6873 --- /dev/null +++ b/internal/formula/parser.go @@ -0,0 +1,420 @@ +package formula + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" +) + +// ParseFile reads and parses a formula.toml file. +func ParseFile(path string) (*Formula, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading formula file: %w", err) + } + return Parse(data) +} + +// Parse parses formula.toml content from bytes. +func Parse(data []byte) (*Formula, error) { + var f Formula + if _, err := toml.Decode(string(data), &f); err != nil { + return nil, fmt.Errorf("parsing TOML: %w", err) + } + + // Infer type from content if not explicitly set + f.inferType() + + if err := f.Validate(); err != nil { + return nil, err + } + + return &f, nil +} + +// inferType sets the formula type based on content when not explicitly set. +func (f *Formula) inferType() { + if f.Type != "" { + return // Type already set + } + + // Infer from content + if len(f.Steps) > 0 { + f.Type = TypeWorkflow + } else if len(f.Legs) > 0 { + f.Type = TypeConvoy + } else if len(f.Template) > 0 { + f.Type = TypeExpansion + } else if len(f.Aspects) > 0 { + f.Type = TypeAspect + } +} + +// Validate checks that the formula has all required fields and valid structure. +func (f *Formula) Validate() error { + // Check required common fields + if f.Name == "" { + return fmt.Errorf("formula field is required") + } + + if !f.Type.IsValid() { + return fmt.Errorf("invalid formula type %q (must be convoy, workflow, expansion, or aspect)", f.Type) + } + + // Type-specific validation + switch f.Type { + case TypeConvoy: + return f.validateConvoy() + case TypeWorkflow: + return f.validateWorkflow() + case TypeExpansion: + return f.validateExpansion() + case TypeAspect: + return f.validateAspect() + } + + return nil +} + +func (f *Formula) validateConvoy() error { + if len(f.Legs) == 0 { + return fmt.Errorf("convoy formula requires at least one leg") + } + + // Check leg IDs are unique + seen := make(map[string]bool) + for _, leg := range f.Legs { + if leg.ID == "" { + return fmt.Errorf("leg missing required id field") + } + if seen[leg.ID] { + return fmt.Errorf("duplicate leg id: %s", leg.ID) + } + seen[leg.ID] = true + } + + // Validate synthesis depends_on references valid legs + if f.Synthesis != nil { + for _, dep := range f.Synthesis.DependsOn { + if !seen[dep] { + return fmt.Errorf("synthesis depends_on references unknown leg: %s", dep) + } + } + } + + return nil +} + +func (f *Formula) validateWorkflow() error { + if len(f.Steps) == 0 { + return fmt.Errorf("workflow formula requires at least one step") + } + + // Check step IDs are unique + seen := make(map[string]bool) + for _, step := range f.Steps { + if step.ID == "" { + return fmt.Errorf("step missing required id field") + } + if seen[step.ID] { + return fmt.Errorf("duplicate step id: %s", step.ID) + } + seen[step.ID] = true + } + + // Validate step needs references + for _, step := range f.Steps { + for _, need := range step.Needs { + if !seen[need] { + return fmt.Errorf("step %q needs unknown step: %s", step.ID, need) + } + } + } + + // Check for cycles + if err := f.checkCycles(); err != nil { + return err + } + + return nil +} + +func (f *Formula) validateExpansion() error { + if len(f.Template) == 0 { + return fmt.Errorf("expansion formula requires at least one template") + } + + // Check template IDs are unique + seen := make(map[string]bool) + for _, tmpl := range f.Template { + if tmpl.ID == "" { + return fmt.Errorf("template missing required id field") + } + if seen[tmpl.ID] { + return fmt.Errorf("duplicate template id: %s", tmpl.ID) + } + seen[tmpl.ID] = true + } + + // Validate template needs references + for _, tmpl := range f.Template { + for _, need := range tmpl.Needs { + if !seen[need] { + return fmt.Errorf("template %q needs unknown template: %s", tmpl.ID, need) + } + } + } + + return nil +} + +func (f *Formula) validateAspect() error { + if len(f.Aspects) == 0 { + return fmt.Errorf("aspect formula requires at least one aspect") + } + + // Check aspect IDs are unique + seen := make(map[string]bool) + for _, aspect := range f.Aspects { + if aspect.ID == "" { + return fmt.Errorf("aspect missing required id field") + } + if seen[aspect.ID] { + return fmt.Errorf("duplicate aspect id: %s", aspect.ID) + } + seen[aspect.ID] = true + } + + return nil +} + +// checkCycles detects circular dependencies in steps. +func (f *Formula) checkCycles() error { + // Build adjacency list + deps := make(map[string][]string) + for _, step := range f.Steps { + deps[step.ID] = step.Needs + } + + // DFS for cycle detection + visited := make(map[string]bool) + inStack := make(map[string]bool) + + var visit func(id string) error + visit = func(id string) error { + if inStack[id] { + return fmt.Errorf("cycle detected involving step: %s", id) + } + if visited[id] { + return nil + } + visited[id] = true + inStack[id] = true + + for _, dep := range deps[id] { + if err := visit(dep); err != nil { + return err + } + } + + inStack[id] = false + return nil + } + + for _, step := range f.Steps { + if err := visit(step.ID); err != nil { + return err + } + } + + return nil +} + +// TopologicalSort returns steps in dependency order (dependencies before dependents). +// Only applicable to workflow and expansion formulas. +// Returns an error if there are cycles. +func (f *Formula) TopologicalSort() ([]string, error) { + var items []string + var deps map[string][]string + + switch f.Type { + case TypeWorkflow: + for _, step := range f.Steps { + items = append(items, step.ID) + } + deps = make(map[string][]string) + for _, step := range f.Steps { + deps[step.ID] = step.Needs + } + case TypeExpansion: + for _, tmpl := range f.Template { + items = append(items, tmpl.ID) + } + deps = make(map[string][]string) + for _, tmpl := range f.Template { + deps[tmpl.ID] = tmpl.Needs + } + case TypeConvoy: + // Convoy legs are parallel; return all leg IDs + for _, leg := range f.Legs { + items = append(items, leg.ID) + } + return items, nil + case TypeAspect: + // Aspect aspects are parallel; return all aspect IDs + for _, aspect := range f.Aspects { + items = append(items, aspect.ID) + } + return items, nil + default: + return nil, fmt.Errorf("unsupported formula type for topological sort") + } + + // Kahn's algorithm + inDegree := make(map[string]int) + for _, id := range items { + inDegree[id] = 0 + } + for _, id := range items { + for _, dep := range deps[id] { + inDegree[id]++ + _ = dep // dep already exists (validated) + } + } + + // Find all nodes with no dependencies + var queue []string + for _, id := range items { + if inDegree[id] == 0 { + queue = append(queue, id) + } + } + + // Build reverse adjacency (who depends on me) + dependents := make(map[string][]string) + for _, id := range items { + for _, dep := range deps[id] { + dependents[dep] = append(dependents[dep], id) + } + } + + var result []string + for len(queue) > 0 { + // Pop from queue + id := queue[0] + queue = queue[1:] + result = append(result, id) + + // Reduce in-degree of dependents + for _, dependent := range dependents[id] { + inDegree[dependent]-- + if inDegree[dependent] == 0 { + queue = append(queue, dependent) + } + } + } + + if len(result) != len(items) { + return nil, fmt.Errorf("cycle detected in dependencies") + } + + return result, nil +} + +// ReadySteps returns steps that have no unmet dependencies. +// completed is a set of step IDs that have been completed. +func (f *Formula) ReadySteps(completed map[string]bool) []string { + var ready []string + + switch f.Type { + case TypeWorkflow: + for _, step := range f.Steps { + if completed[step.ID] { + continue + } + allMet := true + for _, need := range step.Needs { + if !completed[need] { + allMet = false + break + } + } + if allMet { + ready = append(ready, step.ID) + } + } + case TypeExpansion: + for _, tmpl := range f.Template { + if completed[tmpl.ID] { + continue + } + allMet := true + for _, need := range tmpl.Needs { + if !completed[need] { + allMet = false + break + } + } + if allMet { + ready = append(ready, tmpl.ID) + } + } + case TypeConvoy: + // All legs are ready unless already completed + for _, leg := range f.Legs { + if !completed[leg.ID] { + ready = append(ready, leg.ID) + } + } + case TypeAspect: + // All aspects are ready unless already completed + for _, aspect := range f.Aspects { + if !completed[aspect.ID] { + ready = append(ready, aspect.ID) + } + } + } + + return ready +} + +// GetStep returns a step by ID, or nil if not found. +func (f *Formula) GetStep(id string) *Step { + for i := range f.Steps { + if f.Steps[i].ID == id { + return &f.Steps[i] + } + } + return nil +} + +// GetLeg returns a leg by ID, or nil if not found. +func (f *Formula) GetLeg(id string) *Leg { + for i := range f.Legs { + if f.Legs[i].ID == id { + return &f.Legs[i] + } + } + return nil +} + +// GetTemplate returns a template by ID, or nil if not found. +func (f *Formula) GetTemplate(id string) *Template { + for i := range f.Template { + if f.Template[i].ID == id { + return &f.Template[i] + } + } + return nil +} + +// GetAspect returns an aspect by ID, or nil if not found. +func (f *Formula) GetAspect(id string) *Aspect { + for i := range f.Aspects { + if f.Aspects[i].ID == id { + return &f.Aspects[i] + } + } + return nil +} diff --git a/internal/formula/parser_test.go b/internal/formula/parser_test.go new file mode 100644 index 00000000..0be2007e --- /dev/null +++ b/internal/formula/parser_test.go @@ -0,0 +1,355 @@ +package formula + +import ( + "testing" +) + +func TestParse_Workflow(t *testing.T) { + data := []byte(` +description = "Test workflow" +formula = "test-workflow" +type = "workflow" +version = 1 + +[[steps]] +id = "step1" +title = "First Step" +description = "Do the first thing" + +[[steps]] +id = "step2" +title = "Second Step" +description = "Do the second thing" +needs = ["step1"] + +[[steps]] +id = "step3" +title = "Third Step" +description = "Do the third thing" +needs = ["step2"] + +[vars] +[vars.feature] +description = "The feature to implement" +required = true +`) + + f, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if f.Name != "test-workflow" { + t.Errorf("Name = %q, want %q", f.Name, "test-workflow") + } + if f.Type != TypeWorkflow { + t.Errorf("Type = %q, want %q", f.Type, TypeWorkflow) + } + if len(f.Steps) != 3 { + t.Errorf("len(Steps) = %d, want 3", len(f.Steps)) + } + if f.Steps[1].Needs[0] != "step1" { + t.Errorf("step2.Needs[0] = %q, want %q", f.Steps[1].Needs[0], "step1") + } +} + +func TestParse_Convoy(t *testing.T) { + data := []byte(` +description = "Test convoy" +formula = "test-convoy" +type = "convoy" +version = 1 + +[[legs]] +id = "leg1" +title = "Leg One" +focus = "Focus area 1" +description = "First leg" + +[[legs]] +id = "leg2" +title = "Leg Two" +focus = "Focus area 2" +description = "Second leg" + +[synthesis] +title = "Synthesis" +description = "Combine results" +depends_on = ["leg1", "leg2"] +`) + + f, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if f.Name != "test-convoy" { + t.Errorf("Name = %q, want %q", f.Name, "test-convoy") + } + if f.Type != TypeConvoy { + t.Errorf("Type = %q, want %q", f.Type, TypeConvoy) + } + if len(f.Legs) != 2 { + t.Errorf("len(Legs) = %d, want 2", len(f.Legs)) + } + if f.Synthesis == nil { + t.Fatal("Synthesis is nil") + } + if len(f.Synthesis.DependsOn) != 2 { + t.Errorf("len(Synthesis.DependsOn) = %d, want 2", len(f.Synthesis.DependsOn)) + } +} + +func TestParse_Expansion(t *testing.T) { + data := []byte(` +description = "Test expansion" +formula = "test-expansion" +type = "expansion" +version = 1 + +[[template]] +id = "{target}.draft" +title = "Draft: {target.title}" +description = "Initial draft" + +[[template]] +id = "{target}.refine" +title = "Refine" +description = "Refine the draft" +needs = ["{target}.draft"] +`) + + f, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if f.Name != "test-expansion" { + t.Errorf("Name = %q, want %q", f.Name, "test-expansion") + } + if f.Type != TypeExpansion { + t.Errorf("Type = %q, want %q", f.Type, TypeExpansion) + } + if len(f.Template) != 2 { + t.Errorf("len(Template) = %d, want 2", len(f.Template)) + } +} + +func TestValidate_MissingName(t *testing.T) { + data := []byte(` +type = "workflow" +version = 1 +[[steps]] +id = "step1" +title = "Step" +`) + + _, err := Parse(data) + if err == nil { + t.Error("expected error for missing formula name") + } +} + +func TestValidate_InvalidType(t *testing.T) { + data := []byte(` +formula = "test" +type = "invalid" +version = 1 +[[steps]] +id = "step1" +`) + + _, err := Parse(data) + if err == nil { + t.Error("expected error for invalid type") + } +} + +func TestValidate_DuplicateStepID(t *testing.T) { + data := []byte(` +formula = "test" +type = "workflow" +version = 1 +[[steps]] +id = "step1" +title = "Step 1" +[[steps]] +id = "step1" +title = "Step 1 duplicate" +`) + + _, err := Parse(data) + if err == nil { + t.Error("expected error for duplicate step id") + } +} + +func TestValidate_UnknownDependency(t *testing.T) { + data := []byte(` +formula = "test" +type = "workflow" +version = 1 +[[steps]] +id = "step1" +title = "Step 1" +needs = ["nonexistent"] +`) + + _, err := Parse(data) + if err == nil { + t.Error("expected error for unknown dependency") + } +} + +func TestValidate_Cycle(t *testing.T) { + data := []byte(` +formula = "test" +type = "workflow" +version = 1 +[[steps]] +id = "step1" +title = "Step 1" +needs = ["step2"] +[[steps]] +id = "step2" +title = "Step 2" +needs = ["step1"] +`) + + _, err := Parse(data) + if err == nil { + t.Error("expected error for cycle") + } +} + +func TestTopologicalSort(t *testing.T) { + data := []byte(` +formula = "test" +type = "workflow" +version = 1 +[[steps]] +id = "step3" +title = "Step 3" +needs = ["step2"] +[[steps]] +id = "step1" +title = "Step 1" +[[steps]] +id = "step2" +title = "Step 2" +needs = ["step1"] +`) + + f, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + order, err := f.TopologicalSort() + if err != nil { + t.Fatalf("TopologicalSort failed: %v", err) + } + + // step1 must come before step2, step2 must come before step3 + indexOf := func(id string) int { + for i, x := range order { + if x == id { + return i + } + } + return -1 + } + + if indexOf("step1") > indexOf("step2") { + t.Error("step1 should come before step2") + } + if indexOf("step2") > indexOf("step3") { + t.Error("step2 should come before step3") + } +} + +func TestReadySteps(t *testing.T) { + data := []byte(` +formula = "test" +type = "workflow" +version = 1 +[[steps]] +id = "step1" +title = "Step 1" +[[steps]] +id = "step2" +title = "Step 2" +needs = ["step1"] +[[steps]] +id = "step3" +title = "Step 3" +needs = ["step1"] +[[steps]] +id = "step4" +title = "Step 4" +needs = ["step2", "step3"] +`) + + f, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + // Initially only step1 is ready + ready := f.ReadySteps(map[string]bool{}) + if len(ready) != 1 || ready[0] != "step1" { + t.Errorf("ReadySteps({}) = %v, want [step1]", ready) + } + + // After completing step1, step2 and step3 are ready + ready = f.ReadySteps(map[string]bool{"step1": true}) + if len(ready) != 2 { + t.Errorf("ReadySteps({step1}) = %v, want [step2, step3]", ready) + } + + // After completing step1, step2, step3 is still ready + ready = f.ReadySteps(map[string]bool{"step1": true, "step2": true}) + if len(ready) != 1 || ready[0] != "step3" { + t.Errorf("ReadySteps({step1, step2}) = %v, want [step3]", ready) + } + + // After completing step1, step2, step3, only step4 is ready + ready = f.ReadySteps(map[string]bool{"step1": true, "step2": true, "step3": true}) + if len(ready) != 1 || ready[0] != "step4" { + t.Errorf("ReadySteps({step1, step2, step3}) = %v, want [step4]", ready) + } +} + +func TestConvoyReadySteps(t *testing.T) { + data := []byte(` +formula = "test" +type = "convoy" +version = 1 +[[legs]] +id = "leg1" +title = "Leg 1" +[[legs]] +id = "leg2" +title = "Leg 2" +[[legs]] +id = "leg3" +title = "Leg 3" +`) + + f, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + // All legs are ready initially (parallel) + ready := f.ReadySteps(map[string]bool{}) + if len(ready) != 3 { + t.Errorf("ReadySteps({}) = %v, want 3 legs", ready) + } + + // After completing leg1, leg2 and leg3 still ready + ready = f.ReadySteps(map[string]bool{"leg1": true}) + if len(ready) != 2 { + t.Errorf("ReadySteps({leg1}) = %v, want 2 legs", ready) + } +} diff --git a/internal/formula/types.go b/internal/formula/types.go new file mode 100644 index 00000000..22a72ecb --- /dev/null +++ b/internal/formula/types.go @@ -0,0 +1,171 @@ +// Package formula provides parsing and validation for formula.toml files. +// +// Formulas define structured workflows that can be executed by agents. +// There are four types of formulas: +// - convoy: Parallel execution of legs with synthesis +// - workflow: Sequential steps with dependencies +// - expansion: Template-based step generation +// - aspect: Multi-aspect parallel analysis (like convoy but for analysis) +package formula + +// FormulaType represents the type of formula. +type FormulaType string + +const ( + // TypeConvoy is a convoy formula with parallel legs and synthesis. + TypeConvoy FormulaType = "convoy" + // TypeWorkflow is a workflow formula with sequential steps. + TypeWorkflow FormulaType = "workflow" + // TypeExpansion is an expansion formula with template-based steps. + TypeExpansion FormulaType = "expansion" + // TypeAspect is an aspect-based formula for multi-aspect parallel analysis. + TypeAspect FormulaType = "aspect" +) + +// Formula represents a parsed formula.toml file. +type Formula struct { + // Common fields + Name string `toml:"formula"` + Description string `toml:"description"` + Type FormulaType `toml:"type"` + Version int `toml:"version"` + + // Convoy-specific + Inputs map[string]Input `toml:"inputs"` + Prompts map[string]string `toml:"prompts"` + Output *Output `toml:"output"` + Legs []Leg `toml:"legs"` + Synthesis *Synthesis `toml:"synthesis"` + + // Workflow-specific + Steps []Step `toml:"steps"` + Vars map[string]Var `toml:"vars"` + + // Expansion-specific + Template []Template `toml:"template"` + + // Aspect-specific (similar to convoy but for analysis) + Aspects []Aspect `toml:"aspects"` +} + +// Aspect represents a parallel analysis aspect in an aspect formula. +type Aspect struct { + ID string `toml:"id"` + Title string `toml:"title"` + Focus string `toml:"focus"` + Description string `toml:"description"` +} + +// Input represents an input parameter for a formula. +type Input struct { + Description string `toml:"description"` + Type string `toml:"type"` + Required bool `toml:"required"` + RequiredUnless []string `toml:"required_unless"` + Default string `toml:"default"` +} + +// Output configures where formula outputs are written. +type Output struct { + Directory string `toml:"directory"` + LegPattern string `toml:"leg_pattern"` + Synthesis string `toml:"synthesis"` +} + +// Leg represents a parallel execution unit in a convoy formula. +type Leg struct { + ID string `toml:"id"` + Title string `toml:"title"` + Focus string `toml:"focus"` + Description string `toml:"description"` +} + +// Synthesis represents the synthesis step that combines leg outputs. +type Synthesis struct { + Title string `toml:"title"` + Description string `toml:"description"` + DependsOn []string `toml:"depends_on"` +} + +// Step represents a sequential step in a workflow formula. +type Step struct { + ID string `toml:"id"` + Title string `toml:"title"` + Description string `toml:"description"` + Needs []string `toml:"needs"` +} + +// Template represents a template step in an expansion formula. +type Template struct { + ID string `toml:"id"` + Title string `toml:"title"` + Description string `toml:"description"` + Needs []string `toml:"needs"` +} + +// Var represents a variable definition for formulas. +type Var struct { + Description string `toml:"description"` + Required bool `toml:"required"` + Default string `toml:"default"` +} + +// IsValid returns true if the formula type is recognized. +func (t FormulaType) IsValid() bool { + switch t { + case TypeConvoy, TypeWorkflow, TypeExpansion, TypeAspect: + return true + default: + return false + } +} + +// GetDependencies returns the ordered dependencies for a step/template. +// For convoy formulas, legs are parallel so this returns an empty slice. +// For workflow and expansion formulas, this returns the Needs field. +func (f *Formula) GetDependencies(id string) []string { + switch f.Type { + case TypeWorkflow: + for _, step := range f.Steps { + if step.ID == id { + return step.Needs + } + } + case TypeExpansion: + for _, tmpl := range f.Template { + if tmpl.ID == id { + return tmpl.Needs + } + } + case TypeConvoy: + // Legs are parallel; synthesis depends on all legs + if f.Synthesis != nil && id == "synthesis" { + return f.Synthesis.DependsOn + } + } + return nil +} + +// GetAllIDs returns all step/leg/template/aspect IDs in the formula. +func (f *Formula) GetAllIDs() []string { + var ids []string + switch f.Type { + case TypeWorkflow: + for _, step := range f.Steps { + ids = append(ids, step.ID) + } + case TypeExpansion: + for _, tmpl := range f.Template { + ids = append(ids, tmpl.ID) + } + case TypeConvoy: + for _, leg := range f.Legs { + ids = append(ids, leg.ID) + } + case TypeAspect: + for _, aspect := range f.Aspects { + ids = append(ids, aspect.ID) + } + } + return ids +}