feat(tui): wizard uses PlanForce preview when Force is set
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,38 +158,51 @@ 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)
|
||||||
|
|||||||
@@ -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,18 +92,20 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type countingModule struct {
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user