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