From ee3bb1d67c0d3abdb48d926369585870a0403555 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 22:24:49 +0200 Subject: [PATCH] feat(tui): wizard uses PlanForce preview when Force is set --- internal/tui/state.go | 4 ++ internal/tui/wizard/wizard.go | 74 ++++++++++++++++++---------- internal/tui/wizard/wizard_test.go | 78 ++++++++++++++++++++++++------ 3 files changed, 117 insertions(+), 39 deletions(-) diff --git a/internal/tui/state.go b/internal/tui/state.go index e6a1bc6..e308403 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -31,6 +31,10 @@ type WizardState struct { Parallelism int // advanced; reserved for a future concurrent engine HaltOnError bool // advanced Confirmed bool // true once the user hits Confirm on the last screen + // Force mirrors the --force CLI flag: when true, the wizard's per-module + // plan preview uses PlanForce instead of Detect + Plan, and the header + // shows a "Force mode" banner. + Force bool } // NewWizardState builds an initial state from the running config and the diff --git a/internal/tui/wizard/wizard.go b/internal/tui/wizard/wizard.go index a878f2e..1a746db 100644 --- a/internal/tui/wizard/wizard.go +++ b/internal/tui/wizard/wizard.go @@ -77,7 +77,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu Title("Review plan"). DescriptionFunc(func() string { s.Modules = syncSelected(s.Modules, selectedModules) - return renderPlanCached(ctx, reg, s.SelectedIDs(), cache) + return renderPlanCached(ctx, reg, s.SelectedIDs(), cache, s.Force) }, &selectedModules)), huh.NewGroup(huh.NewConfirm(). @@ -118,24 +118,35 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice return out } -// planCache memoises rendered Detect+Plan output per module ID so -// DescriptionFunc (which fires on every redraw) does not re-run -// potentially expensive Detect/Plan calls. Entries are evicted when a -// module leaves the current selection. +// planCache memoises rendered Detect+Plan (or PlanForce) output per +// (module ID, force) tuple so DescriptionFunc (which fires on every +// redraw) does not re-run potentially expensive module calls. Including +// force in the key ensures a force-mode preview never reuses a non-force +// cached plan. Entries are evicted when a module leaves the current +// selection. type planCacheEntry struct{ out string } type planCache struct{ entries map[string]planCacheEntry } func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} } -func (c *planCache) get(id string) (string, bool) { - e, ok := c.entries[id] +func cacheKey(id string, force bool) string { + if force { + return "force=1|" + id + } + return "force=0|" + id +} + +func (c *planCache) get(id string, force bool) (string, bool) { + e, ok := c.entries[cacheKey(id, force)] return e.out, ok } -func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} } -func (c *planCache) prune(keepIDs []string) { +func (c *planCache) put(id string, force bool, out string) { + c.entries[cacheKey(id, force)] = planCacheEntry{out: out} +} +func (c *planCache) prune(keepIDs []string, force bool) { keep := map[string]bool{} for _, id := range keepIDs { - keep[id] = true + keep[cacheKey(id, force)] = true } for k := range c.entries { if !keep[k] { @@ -147,38 +158,51 @@ func (c *planCache) prune(keepIDs []string) { // renderPlanCached is the cached equivalent of running Detect+Plan for // every selected module and joining the rendered lines. IDs no longer in // the selection are evicted so a deselect+reselect re-runs Detect. -func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string { - cache.prune(selectedIDs) +func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache, force bool) string { + cache.prune(selectedIDs, force) if len(selectedIDs) == 0 { return "No modules selected." } var b strings.Builder + if force { + b.WriteString("⚠ Force mode: all selected modules will be reapplied\n\n") + } for _, id := range selectedIDs { - if cached, ok := cache.get(id); ok { + if cached, ok := cache.get(id, force); ok { b.WriteString(cached) continue } - line := renderOne(ctx, reg, id) - cache.put(id, line) + line := renderOne(ctx, reg, id, force) + cache.put(id, force, line) b.WriteString(line) } return b.String() } -// renderOne runs Detect+Plan for a single module and returns a rendered -// line. A failing Detect/Plan is shown inline but does not abort. -func renderOne(ctx context.Context, reg *module.Registry, id string) string { +// renderOne runs Detect+Plan (or PlanForce when force=true) for a single +// module and returns a rendered line. A failing Detect/Plan/PlanForce is +// shown inline but does not abort. +func renderOne(ctx context.Context, reg *module.Registry, id string, force bool) string { m, ok := reg.Get(id) if !ok { return fmt.Sprintf("• %s — (unknown)\n", id) } - state, err := m.Detect(ctx) - if err != nil { - return fmt.Sprintf("• %s — detect failed: %v\n", id, err) - } - plan, err := m.Plan(ctx, state) - if err != nil { - return fmt.Sprintf("• %s — plan failed: %v\n", id, err) + var plan module.Plan + var err error + if force { + plan, err = m.PlanForce(ctx) + if err != nil { + return fmt.Sprintf("• %s — plan failed: %v\n", id, err) + } + } else { + state, derr := m.Detect(ctx) + if derr != nil { + return fmt.Sprintf("• %s — detect failed: %v\n", id, derr) + } + plan, err = m.Plan(ctx, state) + if err != nil { + return fmt.Sprintf("• %s — plan failed: %v\n", id, err) + } } if plan.IsEmpty() { return fmt.Sprintf("• %s — already up to date\n", id) diff --git a/internal/tui/wizard/wizard_test.go b/internal/tui/wizard/wizard_test.go index bc190a2..0d00213 100644 --- a/internal/tui/wizard/wizard_test.go +++ b/internal/tui/wizard/wizard_test.go @@ -31,14 +31,14 @@ func TestSyncSelected(t *testing.T) { func TestRenderPlanWithHello(t *testing.T) { reg := module.NewRegistry() _ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) - out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache()) + out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache(), false) if !strings.Contains(out, "hello") { t.Fatalf("output missing hello: %q", out) } } func TestRenderPlanEmpty(t *testing.T) { - if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." { + if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache(), false); got != "No modules selected." { t.Fatalf("got %q", got) } } @@ -75,9 +75,9 @@ func TestRenderPlanCachesDetectAndPlan(t *testing.T) { t.Fatal(err) } cache := newPlanCache() - _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) - _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) - _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false) if detects != 1 || plans != 1 { t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans) } @@ -92,18 +92,20 @@ func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) { reg := module.NewRegistry() _ = reg.Register(m) cache := newPlanCache() - _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) - _ = renderPlanCached(context.Background(), reg, []string{}, cache) - _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false) + _ = renderPlanCached(context.Background(), reg, []string{}, cache, false) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false) if detects != 2 { t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects) } } type countingModule struct { - meta module.Meta - onDetect func() - onPlan func() + meta module.Meta + onDetect func() + onPlan func() + onPlanForce func() + planForceOut module.Plan } func (c countingModule) Meta() module.Meta { return c.meta } @@ -120,14 +122,62 @@ func (c countingModule) Plan(context.Context, module.State) (module.Plan, error) return module.Plan{}, nil } func (c countingModule) PlanForce(context.Context) (module.Plan, error) { - if c.onPlan != nil { - c.onPlan() + if c.onPlanForce != nil { + c.onPlanForce() } - return module.Plan{}, nil + return c.planForceOut, nil } func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } func (c countingModule) Verify(context.Context) error { return nil } +func TestWizardPreviewUsesPlanForce(t *testing.T) { + var planForceCalls int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { + t.Fatal("Detect must not be called in force mode") + }, + onPlan: func() { + t.Fatal("Plan must not be called in force mode") + }, + onPlanForce: func() { planForceCalls++ }, + planForceOut: module.Plan{ + Actions: []module.Action{{Description: "forced-reapply"}}, + }, + } + reg := module.NewRegistry() + if err := reg.Register(m); err != nil { + t.Fatal(err) + } + out := renderPlanCached(context.Background(), reg, []string{"count"}, newPlanCache(), true) + if planForceCalls != 1 { + t.Fatalf("want 1 PlanForce call, got %d", planForceCalls) + } + if !strings.Contains(out, "forced-reapply") { + t.Fatalf("output missing PlanForce action: %q", out) + } +} + +func TestWizardPreviewCacheSeparatesForceAndNonForce(t *testing.T) { + var detects, planForceCalls int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + onPlanForce: func() { planForceCalls++ }, + } + reg := module.NewRegistry() + _ = reg.Register(m) + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, true) + if detects != 1 { + t.Fatalf("want 1 Detect (non-force path), got %d", detects) + } + if planForceCalls != 1 { + t.Fatalf("want 1 PlanForce (force path), got %d", planForceCalls) + } +} + func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel()