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 }