ui.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package config
  6
  7import (
  8	"errors"
  9	"fmt"
 10	"os"
 11	"regexp"
 12	"strconv"
 13	"strings"
 14
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17
 18	"git.secluded.site/go-lunatask"
 19	"git.secluded.site/lunatask-mcp-server/internal/config"
 20	"git.secluded.site/lunatask-mcp-server/internal/ui"
 21)
 22
 23const (
 24	choiceBack   = "back"
 25	choiceNext   = "next"
 26	choiceAdd    = "add"
 27	choiceDone   = "done"
 28	actionEdit   = "edit"
 29	actionDelete = "delete"
 30	actionGoals  = "goals"
 31)
 32
 33var (
 34	errNameRequired  = errors.New("name is required")
 35	errKeyRequired   = errors.New("key is required")
 36	errTokenRequired = errors.New("access token is required")
 37	errKeyFormat     = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
 38	errKeyDuplicate  = errors.New("this key is already in use")
 39	errRefRequired   = errors.New("reference is required")
 40	errRefFormat     = errors.New("expected UUID or lunatask:// deep link")
 41	errIndexOutRange = errors.New("index out of range")
 42	errBack          = errors.New("user went back")
 43)
 44
 45func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
 46	path, err := config.Path()
 47	if err != nil {
 48		return err
 49	}
 50
 51	var confirm bool
 52
 53	err = huh.NewConfirm().
 54		Title("Reset all configuration?").
 55		Description(fmt.Sprintf("This will delete %s and restart the setup wizard.", path)).
 56		Affirmative("Reset").
 57		Negative("Cancel").
 58		Value(&confirm).
 59		Run()
 60	if err != nil {
 61		if errors.Is(err, huh.ErrUserAborted) {
 62			return errQuit
 63		}
 64
 65		return err
 66	}
 67
 68	if confirm {
 69		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
 70			return fmt.Errorf("removing config: %w", err)
 71		}
 72
 73		_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
 74		_, _ = fmt.Fprintln(cmd.OutOrStdout())
 75
 76		*cfg = config.Config{} //nolint:exhaustruct // reset to fresh config
 77
 78		return errReset
 79	}
 80
 81	return nil
 82}
 83
 84func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
 85	var choice string
 86
 87	selector := huh.NewSelect[string]().
 88		Title(title).
 89		Options(options...).
 90		Value(&choice)
 91
 92	if description != "" {
 93		selector = selector.Description(description)
 94	}
 95
 96	if err := selector.Run(); err != nil {
 97		if errors.Is(err, huh.ErrUserAborted) {
 98			return "", errQuit
 99		}
100
101		return "", err
102	}
103
104	return choice, nil
105}
106
107func parseEditIndex(choice string) (int, bool) {
108	if !strings.HasPrefix(choice, "edit:") {
109		return 0, false
110	}
111
112	idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
113	if err != nil {
114		return 0, false
115	}
116
117	return idx, true
118}
119
120var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
121
122func validateKeyFormat(input string) error {
123	if input == "" {
124		return errKeyRequired
125	}
126
127	if !keyPattern.MatchString(input) {
128		return errKeyFormat
129	}
130
131	return nil
132}
133
134func validateReference(input string) (string, error) {
135	if input == "" {
136		return "", errRefRequired
137	}
138
139	_, id, err := lunatask.ParseDeepLink(input)
140	if err != nil {
141		return "", errRefFormat
142	}
143
144	return id, nil
145}
146
147type itemFormConfig struct {
148	itemType        string
149	namePlaceholder string
150	keyPlaceholder  string
151	keyValidator    func(string) error
152}
153
154func titleCase(s string) string {
155	if s == "" {
156		return s
157	}
158
159	return strings.ToUpper(s[:1]) + s[1:]
160}
161
162// itemAction represents the result of an action selection dialog.
163type itemAction int
164
165const (
166	itemActionNone itemAction = iota
167	itemActionEdit
168	itemActionDelete
169	itemActionGoals
170)
171
172// runActionSelect shows an action selection dialog for an item and returns the chosen action.
173func runActionSelect(title string, includeGoals bool) (itemAction, error) {
174	var action string
175
176	options := []huh.Option[string]{
177		huh.NewOption("Edit", actionEdit),
178	}
179
180	if includeGoals {
181		options = append(options, huh.NewOption("Manage goals", actionGoals))
182	}
183
184	options = append(options,
185		huh.NewOption("Delete", actionDelete),
186		huh.NewOption("Back", choiceBack),
187	)
188
189	err := huh.NewSelect[string]().
190		Title(title).
191		Options(options...).
192		Value(&action).
193		Run()
194	if err != nil {
195		if errors.Is(err, huh.ErrUserAborted) {
196			return itemActionNone, errQuit
197		}
198
199		return itemActionNone, err
200	}
201
202	switch action {
203	case actionEdit:
204		return itemActionEdit, nil
205	case actionDelete:
206		return itemActionDelete, nil
207	case actionGoals:
208		return itemActionGoals, nil
209	default:
210		return itemActionNone, nil
211	}
212}
213
214func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
215	var save bool
216
217	requireNonEmpty := false
218
219	for {
220		form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save)
221
222		if err := form.Run(); err != nil {
223			if errors.Is(err, huh.ErrUserAborted) {
224				return errBack
225			}
226
227			return err
228		}
229
230		if !save {
231			return errBack
232		}
233
234		// If any required field is empty, re-run with strict validation
235		if *name == "" || *key == "" || *itemID == "" {
236			requireNonEmpty = true
237
238			continue
239		}
240
241		// Normalise reference to UUID
242		parsedID, _ := validateReference(*itemID)
243		*itemID = parsedID
244
245		return nil
246	}
247}
248
249func buildItemFormGroup(
250	name, key, itemID *string,
251	cfg itemFormConfig,
252	requireNonEmpty *bool,
253	save *bool,
254) *huh.Form {
255	refDescription := "Settings → 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)."
256
257	return huh.NewForm(
258		huh.NewGroup(
259			huh.NewInput().
260				Title("Name").
261				Description("Display name for this "+cfg.itemType+".").
262				Placeholder(cfg.namePlaceholder).
263				Value(name).
264				Validate(makeNameValidator(requireNonEmpty)),
265			huh.NewInput().
266				Title("Key").
267				Description("Short alias for reference (lowercase, no spaces).").
268				Placeholder(cfg.keyPlaceholder).
269				Value(key).
270				Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
271			huh.NewInput().
272				Title("Reference").
273				Description(refDescription).
274				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
275				Value(itemID).
276				Validate(makeRefValidator(requireNonEmpty)),
277			huh.NewConfirm().
278				Title("Save this "+cfg.itemType+"?").
279				Affirmative("Save").
280				Negative("Cancel").
281				Value(save),
282		),
283	)
284}
285
286func makeNameValidator(requireNonEmpty *bool) func(string) error {
287	return func(input string) error {
288		if *requireNonEmpty && input == "" {
289			return errNameRequired
290		}
291
292		return nil
293	}
294}
295
296func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error {
297	return func(input string) error {
298		if input == "" {
299			if *requireNonEmpty {
300				return errKeyRequired
301			}
302
303			return nil
304		}
305
306		return formatValidator(input)
307	}
308}
309
310func makeRefValidator(requireNonEmpty *bool) func(string) error {
311	return func(input string) error {
312		if input == "" {
313			if *requireNonEmpty {
314				return errRefRequired
315			}
316
317			return nil
318		}
319
320		_, err := validateReference(input)
321
322		return err
323	}
324}