package wizard import ( "context" "errors" "fmt" "strconv" "strings" "github.com/charmbracelet/huh" "github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/tui" ) // Run presents the six-screen wizard. Returns the final state and whether // the user confirmed. A context cancellation returns (state, false, nil). func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tui.WizardState, bool, error) { s := initial // Honour an already-cancelled context before Huh tries to open /dev/tty. if err := ctx.Err(); err != nil { return s, false, nil } themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes)) for _, t := range tui.BuiltinThemes { themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID)) } moduleOptions := make([]huh.Option[string], 0, len(s.Modules)) defaults := make([]string, 0, len(s.Modules)) for _, m := range s.Modules { label := m.Name if m.Description != "" { label = fmt.Sprintf("%s — %s", m.Name, m.Description) } moduleOptions = append(moduleOptions, huh.NewOption(label, m.ID)) if m.Selected { defaults = append(defaults, m.ID) } } selectedModules := defaults parallelismStr := fmt.Sprintf("%d", s.Parallelism) var proceed bool form := huh.NewForm( huh.NewGroup(huh.NewNote(). Title("Archduino Installer"). Description("A TUI Arduino development environment.\n\nPress Enter to continue.")), huh.NewGroup(huh.NewSelect[string](). Title("Pick a colorscheme"). Options(themeOptions...). Value(&s.ThemeID)), huh.NewGroup(huh.NewMultiSelect[string](). Title("Select modules to install"). Options(moduleOptions...). Value(&selectedModules)), huh.NewGroup( huh.NewInput(). Title("Parallelism (advanced)"). Description("How many modules to run concurrently. 1 is safe."). Value(¶llelismStr), huh.NewConfirm(). Title("Halt on first error?"). Value(&s.HaltOnError), ), huh.NewGroup(huh.NewNote(). Title("Review plan"). DescriptionFunc(func() string { s.Modules = syncSelected(s.Modules, selectedModules) return renderPlan(ctx, reg, s.SelectedIDs()) }, &selectedModules)), huh.NewGroup(huh.NewConfirm(). Title("Proceed with installation?"). Value(&proceed)), ) if err := form.RunWithContext(ctx); err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, huh.ErrUserAborted) { return s, false, nil } return s, false, err } // Redundant-but-safe: the DescriptionFunc closure on the plan-review // screen already kept s.Modules in sync. The MultiSelect group is // frozen by the time we reach here, so this is idempotent. Kept for // the case where the form is exited via a path that bypasses the // plan-review screen in future revisions. s.Modules = syncSelected(s.Modules, selectedModules) s.Parallelism = parseIntClamp(parallelismStr, 1) s.Confirmed = proceed return s, proceed, nil } // syncSelected returns a copy of mods with Selected set to whether the ID // appears in selected. func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice { set := map[string]bool{} for _, id := range selected { set[id] = true } out := make([]tui.ModuleChoice, len(mods)) copy(out, mods) for i := range out { out[i].Selected = set[out[i].ID] } return out } // renderPlan runs Detect + Plan for every selected module and renders a // human-readable summary. A failing Detect/Plan is shown inline but does // not abort the wizard. func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string { if len(selectedIDs) == 0 { return "No modules selected." } var b strings.Builder for _, id := range selectedIDs { m, ok := reg.Get(id) if !ok { fmt.Fprintf(&b, "• %s — (unknown)\n", id) continue } state, err := m.Detect(ctx) if err != nil { fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err) continue } plan, err := m.Plan(ctx, state) if err != nil { fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err) continue } if plan.IsEmpty() { fmt.Fprintf(&b, "• %s — already up to date\n", id) continue } fmt.Fprintf(&b, "• %s\n", id) for _, a := range plan.Actions { fmt.Fprintf(&b, " - %s\n", a.Description) } } return b.String() } // parseIntClamp parses s as a positive int. Empty, non-numeric (including // partial matches like "3abc"), negative, zero, or overflowing input all // return fallback. strconv.Atoi (not fmt.Sscanf) is used so partial matches // are rejected and overflow surfaces as an error. func parseIntClamp(s string, fallback int) int { s = strings.TrimSpace(s) if s == "" { return fallback } n, err := strconv.Atoi(s) if err != nil || n < 1 { return fallback } return n }