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}