Files
james.maes 0031b00eb3 feat(#29): single-form init wizard with adaptive live preview
Replaces the 7-step 3-pane wizard with a single repo-seeded form and a
persistent adaptive preview. wizard.go is now a thin Bubble Tea adapter;
all logic lives in internal/initwizard at 100% coverage.

Closes #29
2026-06-08 12:33:49 -05:00

444 lines
12 KiB
Go

package initwizard
import (
"strings"
"github.com/KofTwentyTwo/license-tool/internal/config"
"github.com/KofTwentyTwo/license-tool/internal/model"
"github.com/KofTwentyTwo/license-tool/internal/spdx"
)
// Field identifies one editable row of the single-screen init form, in display
// order. FieldWrite is the trailing "write" affordance, not a value.
type Field int
const (
// FieldLicense is the target SPDX license row.
FieldLicense Field = iota
// FieldHolder is the copyright holder row.
FieldHolder
// FieldYear is the year-policy row.
FieldYear
// FieldStyle is the header-style row.
FieldStyle
// FieldManageFiles is the manage-license-files toggle row.
FieldManageFiles
// FieldInclude is the include-globs list row.
FieldInclude
// FieldExclude is the exclude-globs list row.
FieldExclude
// FieldWrite is the trailing write-config action row.
FieldWrite
)
// fieldCount is the number of focusable rows (values + the write action).
const fieldCount = int(FieldWrite) + 1
// Label is the human label for a field row.
func (f Field) Label() string {
switch f {
case FieldLicense:
return "License"
case FieldHolder:
return "Holder"
case FieldYear:
return "Year"
case FieldStyle:
return "Header style"
case FieldManageFiles:
return "Manage license files"
case FieldInclude:
return "Include globs"
case FieldExclude:
return "Exclude globs"
default:
return "Write .license-tool.yaml"
}
}
// Action is the outcome the adapter must act on after a transition: write the config
// or quit. Most transitions return ActionNone.
type Action int
const (
// ActionNone means the transition only changed in-form state.
ActionNone Action = iota
// ActionWrite means the user confirmed a valid write.
ActionWrite
// ActionQuit means the user asked to leave without writing.
ActionQuit
)
// FieldError is a single failed validation, addressed to the field that owns it.
type FieldError struct {
Field Field
Msg string
}
// styleOptions are the selectable header styles, in display order.
var styleOptions = []string{"reuse", "notice", "reuse+notice"}
// FormState is the pure, PTY-free state of the init form: the answers being edited,
// which fields were repo-detected, the focused row, and any open editor's transient
// cursor/buffer. The Bubble Tea adapter maps keystrokes to the transition methods
// and renders via Render; all behavior is unit-tested here so the adapter stays a
// trivial, coverage-excluded shell.
type FormState struct {
answers Answers
detected Detected
focus Field
editing bool
licenseOptions []string
licenseFilter string
licenseCursor int
styleCursor int
textBuffer string
globBuffer string
globCursor int
}
// NewForm builds a form from seeded answers, applying the documented defaults for any
// field the caller left unset (license = first option, year = git, style = reuse+notice).
func NewForm(a Answers, detected Detected) FormState {
s := FormState{
answers: a,
detected: detected,
focus: FieldLicense,
licenseOptions: licenseSelectOptions(),
}
if s.answers.License.SPDXID == "" && len(s.licenseOptions) > 0 {
s.answers.License.SPDXID = s.licenseOptions[0]
}
if s.answers.Identity.Year == "" {
s.answers.Identity.Year = "git"
}
if s.answers.HeaderStyle.Style == "" {
s.answers.HeaderStyle.Style = "reuse+notice"
}
return s
}
// licenseSelectOptions lists renderable SPDX ids with the common ones first, then the
// remaining renderable ids in sorted order, without duplicates.
func licenseSelectOptions() []string {
return selectOptions(spdx.CommonIDs(), spdx.RenderableIDs())
}
// selectOptions merges the editorial common list (kept first, in order) with the full
// renderable list (appended, deduped). Split out from licenseSelectOptions so the
// dedup/append behavior is unit-testable independent of the vendored snapshot, where
// the common set currently happens to cover every renderable id.
func selectOptions(common, renderable []string) []string {
seen := make(map[string]bool, len(common))
opts := make([]string, 0, len(common)+len(renderable))
for _, id := range common {
seen[id] = true
opts = append(opts, id)
}
for _, id := range renderable {
if !seen[id] {
opts = append(opts, id)
}
}
return opts
}
// Editing reports whether the focused field's inline editor is open.
func (s FormState) Editing() bool { return s.editing }
// Focused returns the focused field.
func (s FormState) Focused() Field { return s.focus }
// Answers returns the current answers (a snapshot for the adapter to translate).
func (s FormState) Answers() Answers { return s.answers }
// WriteConfig translates the current answers strictly, for the write path.
func (s FormState) WriteConfig() (model.Config, error) {
return Translate(s.answers, TranslateOptions{})
}
// MoveFocus moves the focused row by delta, wrapping. No-op while an editor is open.
func (s *FormState) MoveFocus(delta int) {
if s.editing {
return
}
s.focus = Field(((int(s.focus)+delta)%fieldCount + fieldCount) % fieldCount)
}
// Activate handles Enter (or space) on the focused row: toggles the manage flag,
// requests a write from the write row (only when valid), or opens an inline editor.
func (s *FormState) Activate() Action {
switch s.focus {
case FieldManageFiles:
s.answers.LicenseFiles.Manage = !s.answers.LicenseFiles.Manage
return ActionNone
case FieldWrite:
if s.CanWrite() {
return ActionWrite
}
return ActionNone
default:
s.openEditor()
return ActionNone
}
}
func (s *FormState) openEditor() {
s.editing = true
switch s.focus {
case FieldLicense:
s.licenseFilter = ""
s.licenseCursor = s.licenseIndexOf(s.answers.License.SPDXID)
case FieldHolder:
s.textBuffer = s.answers.Identity.Holder
case FieldYear:
s.textBuffer = s.answers.Identity.Year
case FieldStyle:
s.styleCursor = optionIndex(styleOptions, s.answers.HeaderStyle.Style)
case FieldInclude, FieldExclude:
s.globBuffer = ""
s.globCursor = 0
}
}
// Cancel handles Esc: close an open editor (discarding its buffer), or quit when no
// editor is open.
func (s *FormState) Cancel() Action {
if s.editing {
s.editing = false
return ActionNone
}
return ActionQuit
}
// Input feeds a typed rune into the active editor's buffer (license filter, text
// field, or glob entry). No-op outside an editor or for non-text editors.
func (s *FormState) Input(r rune) {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
s.licenseFilter += string(r)
s.clampLicenseCursor()
case FieldHolder, FieldYear:
s.textBuffer += string(r)
case FieldInclude, FieldExclude:
s.globBuffer += string(r)
}
}
// Backspace deletes the last rune of the active editor's buffer.
func (s *FormState) Backspace() {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
s.licenseFilter = dropLastRune(s.licenseFilter)
s.clampLicenseCursor()
case FieldHolder, FieldYear:
s.textBuffer = dropLastRune(s.textBuffer)
case FieldInclude, FieldExclude:
s.globBuffer = dropLastRune(s.globBuffer)
}
}
// MoveCursor moves the selection within an open list/choice editor (license choices,
// style options, or existing glob entries).
func (s *FormState) MoveCursor(delta int) {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
if n := len(s.filteredLicenses()); n > 0 {
s.licenseCursor = clamp(s.licenseCursor+delta, 0, n-1)
}
case FieldStyle:
s.styleCursor = clamp(s.styleCursor+delta, 0, len(styleOptions)-1)
case FieldInclude, FieldExclude:
if n := len(s.globEntries()); n > 0 {
s.globCursor = clamp(s.globCursor+delta, 0, n-1)
}
}
}
// Commit handles Enter inside an editor: choose the selected license/style, store the
// text field, or append the buffered glob (the glob editor stays open for more adds).
func (s *FormState) Commit() {
if !s.editing {
return
}
switch s.focus {
case FieldLicense:
if choices := s.filteredLicenses(); len(choices) > 0 {
s.answers.License.SPDXID = choices[clamp(s.licenseCursor, 0, len(choices)-1)]
}
s.editing = false
case FieldStyle:
s.answers.HeaderStyle.Style = styleOptions[clamp(s.styleCursor, 0, len(styleOptions)-1)]
s.editing = false
case FieldHolder:
s.answers.Identity.Holder = strings.TrimSpace(s.textBuffer)
s.editing = false
case FieldYear:
s.answers.Identity.Year = strings.TrimSpace(s.textBuffer)
s.editing = false
case FieldInclude:
if g := strings.TrimSpace(s.globBuffer); g != "" {
s.answers.Coverage.Include = append(s.answers.Coverage.Include, g)
s.globBuffer = ""
}
case FieldExclude:
if g := strings.TrimSpace(s.globBuffer); g != "" {
s.answers.Coverage.Exclude = append(s.answers.Coverage.Exclude, g)
s.globBuffer = ""
}
}
}
// Remove deletes the selected entry from the focused glob list editor.
func (s *FormState) Remove() {
if !s.editing {
return
}
switch s.focus {
case FieldInclude:
s.answers.Coverage.Include = removeAt(s.answers.Coverage.Include, s.globCursor)
s.clampGlobCursor()
case FieldExclude:
s.answers.Coverage.Exclude = removeAt(s.answers.Coverage.Exclude, s.globCursor)
s.clampGlobCursor()
}
}
// Validate returns every failing field-level rule. An empty result means the answers
// can be written.
func (s FormState) Validate() []FieldError {
var errs []FieldError
if _, ok := spdx.Lookup(strings.TrimSpace(s.answers.License.SPDXID)); !ok {
errs = append(errs, FieldError{FieldLicense, "choose a supported license"})
}
if strings.TrimSpace(s.answers.Identity.Holder) == "" {
errs = append(errs, FieldError{FieldHolder, "holder is required"})
}
if y := strings.TrimSpace(s.answers.Identity.Year); y != "" {
if _, err := config.ParseYearSpec(y); err != nil {
errs = append(errs, FieldError{FieldYear, "use current, git, YYYY, or YYYY-YYYY"})
}
}
for _, g := range s.answers.Coverage.Include {
if !validGlob(g) {
errs = append(errs, FieldError{FieldInclude, "glob entries cannot be blank"})
break
}
}
for _, g := range s.answers.Coverage.Exclude {
if !validGlob(g) {
errs = append(errs, FieldError{FieldExclude, "glob entries cannot be blank"})
break
}
}
return errs
}
// CanWrite reports whether the answers pass every validation rule.
func (s FormState) CanWrite() bool {
return len(s.Validate()) == 0
}
// BlockingReason returns the first validation message, or "" when writable. The
// footer shows this to explain why the write action is disabled.
func (s FormState) BlockingReason() string {
if errs := s.Validate(); len(errs) > 0 {
return errs[0].Msg
}
return ""
}
func (s FormState) filteredLicenses() []string {
f := strings.ToLower(strings.TrimSpace(s.licenseFilter))
if f == "" {
return s.licenseOptions
}
out := make([]string, 0, len(s.licenseOptions))
for _, id := range s.licenseOptions {
if strings.Contains(strings.ToLower(id), f) {
out = append(out, id)
}
}
return out
}
func (s FormState) licenseIndexOf(id string) int {
return optionIndex(s.filteredLicenses(), id)
}
func (s *FormState) clampLicenseCursor() {
n := len(s.filteredLicenses())
if n == 0 {
s.licenseCursor = 0
return
}
s.licenseCursor = clamp(s.licenseCursor, 0, n-1)
}
func (s FormState) globEntries() []string {
if s.focus == FieldExclude {
return s.answers.Coverage.Exclude
}
return s.answers.Coverage.Include
}
func (s *FormState) clampGlobCursor() {
n := len(s.globEntries())
if n == 0 {
s.globCursor = 0
return
}
s.globCursor = clamp(s.globCursor, 0, n-1)
}
func validGlob(g string) bool {
return strings.TrimSpace(g) != ""
}
func optionIndex(values []string, value string) int {
for i, v := range values {
if v == value {
return i
}
}
return 0
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func dropLastRune(s string) string {
if s == "" {
return s
}
r := []rune(s)
return string(r[:len(r)-1])
}
func removeAt(values []string, i int) []string {
if i < 0 || i >= len(values) {
return values
}
return append(values[:i], values[i+1:]...)
}