diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..43c240d --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,189 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// RunOptions controls a single engine run. +type RunOptions struct { + Selected []string // module IDs to run; empty = all + DryRun bool // Detect + Plan only, no Apply/Verify + HaltOnError bool // stop at first failure instead of continuing siblings + Reporter module.Reporter +} + +// ModuleStatus is the terminal state of a module within a run. +type ModuleStatus string + +const ( + StatusSucceeded ModuleStatus = "succeeded" + StatusFailed ModuleStatus = "failed" + StatusSkipped ModuleStatus = "skipped" + StatusPlanned ModuleStatus = "planned" // dry-run only +) + +// ModuleResult captures what happened for one module. +type ModuleResult struct { + ID string + Status ModuleStatus + Plan module.Plan + Err error +} + +// Summary aggregates results. +type Summary struct { + Results []ModuleResult +} + +// Counts returns (succeeded, failed, skipped). +func (s Summary) Counts() (int, int, int) { + var ok, fail, skip int + for _, r := range s.Results { + switch r.Status { + case StatusSucceeded, StatusPlanned: + ok++ + case StatusFailed: + fail++ + case StatusSkipped: + skip++ + } + } + return ok, fail, skip +} + +// Run drives the selected modules through the four phases in DAG order. +// Parallelism is intentionally omitted in this foundation; a later plan +// will introduce it. Sequential execution is a correct baseline. +func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) { + mods, err := resolveSelection(reg, opts.Selected) + if err != nil { + return Summary{}, err + } + + metas := make([]module.Meta, 0, len(mods)) + for _, m := range mods { + metas = append(metas, m.Meta()) + } + order, err := TopoSort(metas) + if err != nil { + return Summary{}, fmt.Errorf("module graph: %w", err) + } + + byID := make(map[string]module.Module, len(mods)) + for _, m := range mods { + byID[m.Meta().ID] = m + } + + results := make(map[string]*ModuleResult, len(mods)) + failed := make(map[string]bool) + skipped := make(map[string]bool) + + for _, id := range order { + if skipped[id] { + results[id] = &ModuleResult{ID: id, Status: StatusSkipped, Err: errors.New("skipped: upstream failed")} + continue + } + m := byID[id] + res := &ModuleResult{ID: id} + results[id] = res + + state, err := m.Detect(ctx) + if err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("detect: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + + plan, err := m.Plan(ctx, state) + if err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("plan: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + res.Plan = plan + + if opts.DryRun { + res.Status = StatusPlanned + continue + } + + if err := m.Apply(ctx, plan, opts.Reporter); err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("apply: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + if err := m.Verify(ctx); err != nil { + res.Status = StatusFailed + res.Err = fmt.Errorf("verify: %w", err) + markFailed(metas, id, failed, skipped) + if opts.HaltOnError { + break + } + continue + } + res.Status = StatusSucceeded + } + + summary := Summary{} + // Emit results in topo order for stable output. + ids := make([]string, 0, len(results)) + for id := range results { + ids = append(ids, id) + } + sort.SliceStable(ids, func(i, j int) bool { + return indexOf(order, ids[i]) < indexOf(order, ids[j]) + }) + for _, id := range ids { + summary.Results = append(summary.Results, *results[id]) + } + return summary, nil +} + +func resolveSelection(reg *module.Registry, selected []string) ([]module.Module, error) { + all := reg.All() + if len(selected) == 0 { + return all, nil + } + out := make([]module.Module, 0, len(selected)) + for _, id := range selected { + m, ok := reg.Get(id) + if !ok { + return nil, fmt.Errorf("unknown module %q", id) + } + out = append(out, m) + } + return out, nil +} + +func markFailed(metas []module.Meta, id string, failed, skipped map[string]bool) { + failed[id] = true + for d := range Descendants(metas, id) { + skipped[d] = true + } +} + +func indexOf(s []string, v string) int { + for i, x := range s { + if x == v { + return i + } + } + return -1 +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..7a72b82 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,146 @@ +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// scriptedModule lets tests control what each phase returns. +type scriptedModule struct { + id string + deps []string + detect func() (module.State, error) + plan func() (module.Plan, error) + apply func() error + verify func() error +} + +func (s scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} } +func (s scriptedModule) Detect(ctx context.Context) (module.State, error) { + if s.detect != nil { + return s.detect() + } + return nil, nil +} +func (s scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) { + if s.plan != nil { + return s.plan() + } + return module.Plan{}, nil +} +func (s scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error { + if s.apply != nil { + return s.apply() + } + return nil +} +func (s scriptedModule) Verify(ctx context.Context) error { + if s.verify != nil { + return s.verify() + } + return nil +} + +func newReg(t *testing.T, mods ...module.Module) *module.Registry { + t.Helper() + r := module.NewRegistry() + for _, m := range mods { + if err := r.Register(m); err != nil { + t.Fatal(err) + } + } + return r +} + +func TestRunAllSucceed(t *testing.T) { + reg := newReg(t, + scriptedModule{id: "a"}, + scriptedModule{id: "b", deps: []string{"a"}}, + ) + sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + ok, fail, skip := sum.Counts() + if ok != 2 || fail != 0 || skip != 0 { + t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip) + } + if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" { + t.Fatalf("order = %v", sum.Results) + } +} + +func TestRunDryRunDoesNotApply(t *testing.T) { + applied := false + reg := newReg(t, scriptedModule{ + id: "a", + apply: func() error { applied = true; return nil }, + }) + sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + if applied { + t.Fatal("Apply was called during dry-run") + } + if sum.Results[0].Status != StatusPlanned { + t.Fatalf("status = %v", sum.Results[0].Status) + } +} + +func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) { + reg := newReg(t, + scriptedModule{id: "root"}, + scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }}, + scriptedModule{id: "child", deps: []string{"fails"}}, + scriptedModule{id: "sibling", deps: []string{"root"}}, + ) + sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + status := map[string]ModuleStatus{} + for _, r := range sum.Results { + status[r.ID] = r.Status + } + if status["root"] != StatusSucceeded { + t.Errorf("root = %v", status["root"]) + } + if status["fails"] != StatusFailed { + t.Errorf("fails = %v", status["fails"]) + } + if status["child"] != StatusSkipped { + t.Errorf("child = %v", status["child"]) + } + if status["sibling"] != StatusSucceeded { + t.Errorf("sibling = %v (should have run)", status["sibling"]) + } +} + +func TestRunHaltOnError(t *testing.T) { + reg := newReg(t, + scriptedModule{id: "a", apply: func() error { return errors.New("nope") }}, + scriptedModule{id: "b"}, // independent + ) + sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()}) + if err != nil { + t.Fatal(err) + } + // "b" should not appear in results because we halted before reaching it. + for _, r := range sum.Results { + if r.ID == "b" { + t.Fatalf("b was run despite HaltOnError") + } + } +} + +func TestRunUnknownModule(t *testing.T) { + reg := module.NewRegistry() + _, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}}) + if err == nil { + t.Fatal("expected error for unknown module") + } +}