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/lune/internal/config"
19 "git.secluded.site/lune/internal/ui"
20 "git.secluded.site/lune/internal/validate"
21)
22
23const (
24 choiceBack = "back"
25 choiceAdd = "add"
26 choiceDone = "done"
27 actionEdit = "edit"
28 actionDelete = "delete"
29 actionGoals = "goals"
30)
31
32var (
33 errKeyRequired = errors.New("key is required")
34 errKeyFormat = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
35 errKeyDuplicate = errors.New("this key is already in use")
36 errIDRequired = errors.New("ID is required")
37 errIDFormat = errors.New("invalid UUID format")
38 errUserAborted = errors.New("user aborted")
39 errIndexOutRange = errors.New("index out of range")
40)
41
42func configureUIPrefs(cfg *config.Config) error {
43 color := cfg.UI.Color
44 if color == "" {
45 color = "auto"
46 }
47
48 err := huh.NewSelect[string]().
49 Title("Color output").
50 Description("When should lune use colored output?").
51 Options(
52 huh.NewOption("Auto (detect terminal capability)", "auto"),
53 huh.NewOption("Always", "always"),
54 huh.NewOption("Never", "never"),
55 ).
56 Value(&color).
57 Run()
58 if err != nil {
59 if errors.Is(err, huh.ErrUserAborted) {
60 return nil
61 }
62
63 return err
64 }
65
66 cfg.UI.Color = color
67
68 return nil
69}
70
71func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
72 path, err := config.Path()
73 if err != nil {
74 return err
75 }
76
77 var confirm bool
78
79 err = huh.NewConfirm().
80 Title("Reset all configuration?").
81 Description(fmt.Sprintf("This will delete %s\nYou'll need to run 'lune init' again.", path)).
82 Affirmative("Reset").
83 Negative("Cancel").
84 Value(&confirm).
85 Run()
86 if err != nil {
87 if errors.Is(err, huh.ErrUserAborted) {
88 return nil
89 }
90
91 return err
92 }
93
94 if confirm {
95 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
96 return fmt.Errorf("removing config: %w", err)
97 }
98
99 fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
100
101 *cfg = config.Config{}
102 }
103
104 return nil
105}
106
107func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
108 var choice string
109
110 selector := huh.NewSelect[string]().
111 Title(title).
112 Options(options...).
113 Value(&choice)
114
115 if description != "" {
116 selector = selector.Description(description)
117 }
118
119 if err := selector.Run(); err != nil {
120 if errors.Is(err, huh.ErrUserAborted) {
121 return choiceBack, nil
122 }
123
124 return "", err
125 }
126
127 return choice, nil
128}
129
130func parseEditIndex(choice string) (int, bool) {
131 if !strings.HasPrefix(choice, "edit:") {
132 return 0, false
133 }
134
135 idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
136 if err != nil {
137 return 0, false
138 }
139
140 return idx, true
141}
142
143var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
144
145func validateKeyFormat(input string) error {
146 if input == "" {
147 return errKeyRequired
148 }
149
150 if !keyPattern.MatchString(input) {
151 return errKeyFormat
152 }
153
154 return nil
155}
156
157func validateID(input string) error {
158 if input == "" {
159 return errIDRequired
160 }
161
162 if err := validate.ID(input); err != nil {
163 return errIDFormat
164 }
165
166 return nil
167}
168
169type itemFormConfig struct {
170 itemType string
171 namePlaceholder string
172 keyPlaceholder string
173 keyValidator func(string) error
174}
175
176func titleCase(s string) string {
177 if s == "" {
178 return s
179 }
180
181 return strings.ToUpper(s[:1]) + s[1:]
182}
183
184// itemAction represents the result of an action selection dialog.
185type itemAction int
186
187const (
188 itemActionNone itemAction = iota
189 itemActionEdit
190 itemActionDelete
191 itemActionGoals
192)
193
194// runActionSelect shows an action selection dialog for an item and returns the chosen action.
195func runActionSelect(title string, includeGoals bool) (itemAction, error) {
196 var action string
197
198 options := []huh.Option[string]{
199 huh.NewOption("Edit", actionEdit),
200 }
201
202 if includeGoals {
203 options = append(options, huh.NewOption("Manage goals", actionGoals))
204 }
205
206 options = append(options,
207 huh.NewOption("Delete", actionDelete),
208 huh.NewOption("Back", choiceBack),
209 )
210
211 err := huh.NewSelect[string]().
212 Title(title).
213 Options(options...).
214 Value(&action).
215 Run()
216 if err != nil {
217 if errors.Is(err, huh.ErrUserAborted) {
218 return itemActionNone, nil
219 }
220
221 return itemActionNone, err
222 }
223
224 switch action {
225 case actionEdit:
226 return itemActionEdit, nil
227 case actionDelete:
228 return itemActionDelete, nil
229 case actionGoals:
230 return itemActionGoals, nil
231 default:
232 return itemActionNone, nil
233 }
234}
235
236func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
237 form := huh.NewForm(
238 huh.NewGroup(
239 huh.NewInput().
240 Title("Name").
241 Description("Display name for this "+cfg.itemType+".").
242 Placeholder(cfg.namePlaceholder).
243 Value(name).
244 Validate(huh.ValidateNotEmpty()),
245 huh.NewInput().
246 Title("Key").
247 Description("Short alias for CLI use (lowercase, no spaces).").
248 Placeholder(cfg.keyPlaceholder).
249 Value(key).
250 Validate(cfg.keyValidator),
251 huh.NewInput().
252 Title("ID").
253 Description("Settings → 'Copy "+titleCase(cfg.itemType)+" ID' (bottom left).").
254 Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
255 Value(itemID).
256 Validate(validateID),
257 ),
258 )
259
260 if err := form.Run(); err != nil {
261 if errors.Is(err, huh.ErrUserAborted) {
262 return errUserAborted
263 }
264
265 return err
266 }
267
268 return nil
269}