diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 88cc398..84e4387 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -145,6 +145,8 @@ fixed dependency relation required by the feature roadmap. ## Stage 2 — Shared Bounded Plan Selection +**Status: Completed** + ### Goal Give the application one validated representation of a contiguous canonical diff --git a/internal/app/planner.go b/internal/app/planner.go index fb4bf89..9a7a704 100644 --- a/internal/app/planner.go +++ b/internal/app/planner.go @@ -2,13 +2,134 @@ package app import ( "fmt" + "strings" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) +// BoundedPlan is one validated inclusive range of the canonical pipeline. +// It owns effective endpoints and membership so command, runner, and +// composition callers do not independently interpret range bounds. +type BoundedPlan struct { + stages []stage.Stage + canonicalNames []string + startIndex int + endIndex int + explicitFrom bool + explicitThrough bool +} + +// BuildBoundedPlan selects an inclusive contiguous range of the canonical +// pipeline. Empty endpoints default to the beginning or end respectively. +func BuildBoundedPlan(from, through string) (BoundedPlan, error) { + registry := stage.All() + names := make([]string, len(registry)) + indices := make(map[string]int, len(registry)) + for index, candidate := range registry { + if candidate == nil { + return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage %d is nil", index) + } + name := candidate.Name() + if _, duplicate := indices[name]; duplicate { + return BoundedPlan{}, fmt.Errorf("build bounded plan: duplicate canonical stage %q", name) + } + names[index] = name + indices[name] = index + } + if len(registry) == 0 { + return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage registry is empty") + } + + start := 0 + if from != "" { + var ok bool + start, ok = indices[from] + if !ok { + return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown from stage %q; valid stages: %s", from, strings.Join(names, ", ")) + } + } + end := len(registry) - 1 + if through != "" { + var ok bool + end, ok = indices[through] + if !ok { + return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown through stage %q; valid stages: %s", through, strings.Join(names, ", ")) + } + } + if start > end { + return BoundedPlan{}, fmt.Errorf("build bounded plan: from stage %q occurs after through stage %q; valid stages: %s", from, through, strings.Join(names, ", ")) + } + + return BoundedPlan{ + stages: append([]stage.Stage(nil), registry[start:end+1]...), + canonicalNames: append([]string(nil), names...), + startIndex: start, + endIndex: end, + explicitFrom: from != "", + explicitThrough: through != "", + }, nil +} + +// Stages returns a copy of the selected canonical stages. +func (p BoundedPlan) Stages() []stage.Stage { + return append([]stage.Stage(nil), p.stages...) +} + +// Names returns selected stage names in canonical order. +func (p BoundedPlan) Names() []string { + out := make([]string, 0, len(p.stages)) + for _, candidate := range p.stages { + out = append(out, candidate.Name()) + } + return out +} + +// From returns the effective inclusive start stage. +func (p BoundedPlan) From() string { + if len(p.canonicalNames) == 0 || p.startIndex < 0 || p.startIndex >= len(p.canonicalNames) { + return "" + } + return p.canonicalNames[p.startIndex] +} + +// Through returns the effective inclusive end stage. +func (p BoundedPlan) Through() string { + if len(p.canonicalNames) == 0 || p.endIndex < 0 || p.endIndex >= len(p.canonicalNames) { + return "" + } + return p.canonicalNames[p.endIndex] +} + +// Contains reports whether a canonical stage is selected by the range. +func (p BoundedPlan) Contains(name string) bool { + for _, candidate := range p.stages { + if candidate.Name() == name { + return true + } + } + return false +} + +// PrefixNames returns canonical stages excluded before the selected start. +func (p BoundedPlan) PrefixNames() []string { + if p.startIndex <= 0 || p.startIndex > len(p.canonicalNames) { + return nil + } + return append([]string(nil), p.canonicalNames[:p.startIndex]...) +} + +// HasExplicitBounds reports whether either endpoint was supplied by the caller. +func (p BoundedPlan) HasExplicitBounds() bool { + return p.explicitFrom || p.explicitThrough +} + // BuildFullPlan returns the canonical full stage list in deterministic order. func BuildFullPlan() []stage.Stage { - return stage.All() + plan, err := BuildBoundedPlan("", "") + if err != nil { + panic(err) + } + return plan.Stages() } // BuildSingleStagePlan returns a one-stage plan for an exact stage name. diff --git a/internal/app/planner_test.go b/internal/app/planner_test.go index 88da2dd..80314bb 100644 --- a/internal/app/planner_test.go +++ b/internal/app/planner_test.go @@ -1,6 +1,12 @@ package app -import "testing" +import ( + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/stage" +) func TestBuildFullPlanOrder(t *testing.T) { got := BuildFullPlan() @@ -34,3 +40,113 @@ func TestBuildSingleStagePlanUnknown(t *testing.T) { t.Fatal("expected error for unknown stage, got nil") } } + +func TestBuildBoundedPlanEndpoints(t *testing.T) { + canonical := stageNames(BuildFullPlan()) + for index, name := range canonical { + t.Run(name, func(t *testing.T) { + one, err := BuildBoundedPlan(name, name) + if err != nil { + t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", name, name, err) + } + if got := one.Names(); !reflect.DeepEqual(got, []string{name}) { + t.Fatalf("one-stage names = %#v, want %q", got, name) + } + if one.From() != name || one.Through() != name || !one.Contains(name) || !one.HasExplicitBounds() { + t.Fatalf("one-stage plan endpoints or membership = %#v", one) + } + + from, err := BuildBoundedPlan(name, "") + if err != nil { + t.Fatalf("BuildBoundedPlan(%q, empty) error = %v", name, err) + } + if got := from.Names(); !reflect.DeepEqual(got, canonical[index:]) { + t.Fatalf("from names = %#v, want %#v", got, canonical[index:]) + } + + through, err := BuildBoundedPlan("", name) + if err != nil { + t.Fatalf("BuildBoundedPlan(empty, %q) error = %v", name, err) + } + if got := through.Names(); !reflect.DeepEqual(got, canonical[:index+1]) { + t.Fatalf("through names = %#v, want %#v", got, canonical[:index+1]) + } + }) + } +} + +func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) { + plan, err := BuildBoundedPlan("", "") + if err != nil { + t.Fatalf("BuildBoundedPlan() error = %v", err) + } + want := stageNames(BuildFullPlan()) + if got := plan.Names(); !reflect.DeepEqual(got, want) { + t.Fatalf("bounded names = %#v, want %#v", got, want) + } + if plan.From() != want[0] || plan.Through() != want[len(want)-1] || plan.HasExplicitBounds() { + t.Fatalf("default endpoints = %q through %q explicit=%t", plan.From(), plan.Through(), plan.HasExplicitBounds()) + } + if got := plan.PrefixNames(); len(got) != 0 { + t.Fatalf("default prefix = %#v, want empty", got) + } +} + +func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) { + tests := []struct { + name string + from string + through string + want []string + }{ + {name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}}, + {name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}}, + {name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := BuildBoundedPlan(test.from, test.through) + if err == nil { + t.Fatal("BuildBoundedPlan() error = nil") + } + for _, fragment := range test.want { + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error = %q, want fragment %q", err, fragment) + } + } + }) + } +} + +func TestBoundedPlanIsContiguousAndCannotMutateRegistry(t *testing.T) { + before := stageNames(BuildFullPlan()) + plan, err := BuildBoundedPlan("trim", "analyze") + if err != nil { + t.Fatalf("BuildBoundedPlan() error = %v", err) + } + want := []string{"trim", "render", "extract", "analyze"} + if got := plan.Names(); !reflect.DeepEqual(got, want) { + t.Fatalf("names = %#v, want contiguous %#v", got, want) + } + if got := plan.PrefixNames(); !reflect.DeepEqual(got, before[:5]) { + t.Fatalf("prefix = %#v, want %#v", got, before[:5]) + } + stages := plan.Stages() + stages[0] = nil + names := plan.Names() + names[0] = "changed" + if got := plan.Names(); !reflect.DeepEqual(got, want) { + t.Fatalf("mutated plan names = %#v, want %#v", got, want) + } + if got := stageNames(BuildFullPlan()); !reflect.DeepEqual(got, before) { + t.Fatalf("canonical registry changed = %#v, want %#v", got, before) + } +} + +func stageNames(stages []stage.Stage) []string { + names := make([]string, 0, len(stages)) + for _, candidate := range stages { + names = append(names, candidate.Name()) + } + return names +}