feat(tui): wizard uses PlanForce preview when Force is set

This commit is contained in:
MDietermann
2026-04-14 22:24:49 +02:00
parent 48d50efe19
commit ee3bb1d67c
3 changed files with 117 additions and 39 deletions

View File

@@ -31,6 +31,10 @@ type WizardState struct {
Parallelism int // advanced; reserved for a future concurrent engine Parallelism int // advanced; reserved for a future concurrent engine
HaltOnError bool // advanced HaltOnError bool // advanced
Confirmed bool // true once the user hits Confirm on the last screen 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 // NewWizardState builds an initial state from the running config and the

View File

@@ -77,7 +77,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
Title("Review plan"). Title("Review plan").
DescriptionFunc(func() string { DescriptionFunc(func() string {
s.Modules = syncSelected(s.Modules, selectedModules) s.Modules = syncSelected(s.Modules, selectedModules)
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache) return renderPlanCached(ctx, reg, s.SelectedIDs(), cache, s.Force)
}, &selectedModules)), }, &selectedModules)),
huh.NewGroup(huh.NewConfirm(). huh.NewGroup(huh.NewConfirm().
@@ -118,24 +118,35 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice
return out return out
} }
// planCache memoises rendered Detect+Plan output per module ID so // planCache memoises rendered Detect+Plan (or PlanForce) output per
// DescriptionFunc (which fires on every redraw) does not re-run // (module ID, force) tuple so DescriptionFunc (which fires on every
// potentially expensive Detect/Plan calls. Entries are evicted when a // redraw) does not re-run potentially expensive module calls. Including
// module leaves the current selection. // 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 planCacheEntry struct{ out string }
type planCache struct{ entries map[string]planCacheEntry } type planCache struct{ entries map[string]planCacheEntry }
func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} } func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} }
func (c *planCache) get(id string) (string, bool) { func cacheKey(id string, force bool) string {
e, ok := c.entries[id] 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 return e.out, ok
} }
func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} } func (c *planCache) put(id string, force bool, out string) {
func (c *planCache) prune(keepIDs []string) { c.entries[cacheKey(id, force)] = planCacheEntry{out: out}
}
func (c *planCache) prune(keepIDs []string, force bool) {
keep := map[string]bool{} keep := map[string]bool{}
for _, id := range keepIDs { for _, id := range keepIDs {
keep[id] = true keep[cacheKey(id, force)] = true
} }
for k := range c.entries { for k := range c.entries {
if !keep[k] { if !keep[k] {
@@ -147,39 +158,52 @@ func (c *planCache) prune(keepIDs []string) {
// renderPlanCached is the cached equivalent of running Detect+Plan for // renderPlanCached is the cached equivalent of running Detect+Plan for
// every selected module and joining the rendered lines. IDs no longer in // every selected module and joining the rendered lines. IDs no longer in
// the selection are evicted so a deselect+reselect re-runs Detect. // the selection are evicted so a deselect+reselect re-runs Detect.
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string { func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache, force bool) string {
cache.prune(selectedIDs) cache.prune(selectedIDs, force)
if len(selectedIDs) == 0 { if len(selectedIDs) == 0 {
return "No modules selected." return "No modules selected."
} }
var b strings.Builder var b strings.Builder
if force {
b.WriteString("⚠ Force mode: all selected modules will be reapplied\n\n")
}
for _, id := range selectedIDs { for _, id := range selectedIDs {
if cached, ok := cache.get(id); ok { if cached, ok := cache.get(id, force); ok {
b.WriteString(cached) b.WriteString(cached)
continue continue
} }
line := renderOne(ctx, reg, id) line := renderOne(ctx, reg, id, force)
cache.put(id, line) cache.put(id, force, line)
b.WriteString(line) b.WriteString(line)
} }
return b.String() return b.String()
} }
// renderOne runs Detect+Plan for a single module and returns a rendered // renderOne runs Detect+Plan (or PlanForce when force=true) for a single
// line. A failing Detect/Plan is shown inline but does not abort. // module and returns a rendered line. A failing Detect/Plan/PlanForce is
func renderOne(ctx context.Context, reg *module.Registry, id string) string { // shown inline but does not abort.
func renderOne(ctx context.Context, reg *module.Registry, id string, force bool) string {
m, ok := reg.Get(id) m, ok := reg.Get(id)
if !ok { if !ok {
return fmt.Sprintf("• %s — (unknown)\n", id) return fmt.Sprintf("• %s — (unknown)\n", id)
} }
state, err := m.Detect(ctx) var plan module.Plan
if err != nil { var err error
return fmt.Sprintf("• %s — detect failed: %v\n", id, err) if force {
} plan, err = m.PlanForce(ctx)
plan, err := m.Plan(ctx, state)
if err != nil { if err != nil {
return fmt.Sprintf("• %s — plan failed: %v\n", id, err) 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() { if plan.IsEmpty() {
return fmt.Sprintf("• %s — already up to date\n", id) return fmt.Sprintf("• %s — already up to date\n", id)
} }

View File

@@ -31,14 +31,14 @@ func TestSyncSelected(t *testing.T) {
func TestRenderPlanWithHello(t *testing.T) { func TestRenderPlanWithHello(t *testing.T) {
reg := module.NewRegistry() reg := module.NewRegistry()
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) _ = 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") { if !strings.Contains(out, "hello") {
t.Fatalf("output missing hello: %q", out) t.Fatalf("output missing hello: %q", out)
} }
} }
func TestRenderPlanEmpty(t *testing.T) { 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) t.Fatalf("got %q", got)
} }
} }
@@ -75,9 +75,9 @@ func TestRenderPlanCachesDetectAndPlan(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
cache := newPlanCache() cache := newPlanCache()
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
if detects != 1 || plans != 1 { if detects != 1 || plans != 1 {
t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans) t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans)
} }
@@ -92,9 +92,9 @@ func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) {
reg := module.NewRegistry() reg := module.NewRegistry()
_ = reg.Register(m) _ = reg.Register(m)
cache := newPlanCache() cache := newPlanCache()
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{}, cache) _ = renderPlanCached(context.Background(), reg, []string{}, cache, false)
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
if detects != 2 { if detects != 2 {
t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects) t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects)
} }
@@ -104,6 +104,8 @@ type countingModule struct {
meta module.Meta meta module.Meta
onDetect func() onDetect func()
onPlan func() onPlan func()
onPlanForce func()
planForceOut module.Plan
} }
func (c countingModule) Meta() module.Meta { return c.meta } 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 return module.Plan{}, nil
} }
func (c countingModule) PlanForce(context.Context) (module.Plan, error) { func (c countingModule) PlanForce(context.Context) (module.Plan, error) {
if c.onPlan != nil { if c.onPlanForce != nil {
c.onPlan() 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) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
func (c countingModule) Verify(context.Context) 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) { func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()