1package cmd
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "strings"
9
10 "github.com/charmbracelet/bubbles/spinner"
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/spf13/cobra"
14
15 "github.com/bnema/dumber/internal/application/port"
16 "github.com/bnema/dumber/internal/application/usecase"
17 "github.com/bnema/dumber/internal/cli/styles"
18 "github.com/bnema/dumber/internal/infrastructure/config"
19)
20
21var configYes bool
22
23var configCmd = &cobra.Command{
24 Use: "config",
25 Short: "Manage configuration",
26 Long: `View configuration status and migrate to add new default settings.`,
27}
28
29var configOpenCmd = &cobra.Command{
30 Use: "open",
31 Short: "Open config file in your editor",
32 Long: `Opens the config file using your preferred editor.
33
34Editor selection order:
35 1. $EDITOR environment variable
36 2. $VISUAL environment variable
37 3. Fallback to: nano, vim, vi (first found)`,
38 RunE: runConfigOpen,
39}
40
41var configStatusCmd = &cobra.Command{
42 Use: "status",
43 Short: "Show config file status and migration availability",
44 Long: `Display the config file path and check if any new settings are available.`,
45 RunE: runConfigStatus,
46}
47
48var configMigrateCmd = &cobra.Command{
49 Use: "migrate",
50 Short: "Add missing default settings to config file",
51 Long: `Compares your config file with available defaults and adds any missing settings.
52
53Existing settings are never modified - only missing keys are added with default values.`,
54 RunE: runConfigMigrate,
55}
56
57var configSchemaJSON bool
58
59var configSchemaCmd = &cobra.Command{
60 Use: "schema",
61 Short: "Show all config keys with types and valid values",
62 Long: `Display a reference of all configuration keys with their types, defaults,
63descriptions, and valid values.
64
65Use --json for machine-readable output.`,
66 RunE: runConfigSchema,
67}
68
69func init() {
70 rootCmd.AddCommand(configCmd)
71 configCmd.AddCommand(configOpenCmd)
72 configCmd.AddCommand(configStatusCmd)
73 configCmd.AddCommand(configMigrateCmd)
74 configCmd.AddCommand(configSchemaCmd)
75 configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
76 configSchemaCmd.Flags().BoolVar(&configSchemaJSON, "json", false, "output as JSON")
77}
78
79// runConfigSchema displays all configuration keys with their metadata.
80func runConfigSchema(_ *cobra.Command, _ []string) error {
81 app := GetApp()
82 if app == nil {
83 return fmt.Errorf("app not initialized")
84 }
85
86 provider := config.NewSchemaProvider()
87 uc := usecase.NewGetConfigSchemaUseCase(provider)
88
89 ctx := context.Background()
90 result, err := uc.Execute(ctx, usecase.GetConfigSchemaInput{})
91 if err != nil {
92 return fmt.Errorf("get config schema: %w", err)
93 }
94
95 renderer := styles.NewConfigSchemaRenderer(app.Theme)
96
97 if configSchemaJSON {
98 output, err := renderer.RenderJSON(result.Keys)
99 if err != nil {
100 return err
101 }
102 fmt.Println(output)
103 return nil
104 }
105
106 fmt.Println(renderer.Render(result.Keys))
107 return nil
108}
109
110// runConfigOpen opens the config file in the user's preferred editor.
111func runConfigOpen(_ *cobra.Command, _ []string) error {
112 app := GetApp()
113 if app == nil {
114 return fmt.Errorf("app not initialized")
115 }
116
117 renderer := styles.NewConfigRenderer(app.Theme)
118
119 configFile, err := config.GetConfigFile()
120 if err != nil {
121 wrappedErr := fmt.Errorf("get config file: %w", err)
122 fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(wrappedErr), "\n"))
123 return wrapPrintedError(wrappedErr)
124 }
125
126 // Check if config file exists
127 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
128 err := fmt.Errorf("config file does not exist: %s", configFile)
129 fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(err), "\n"))
130 return wrapPrintedError(err)
131 }
132
133 editor := getEditor()
134 if editor == "" {
135 const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
136 err := fmt.Errorf("%s", msg)
137 fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(err), "\n"))
138 return wrapPrintedError(err)
139 }
140
141 // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
142 fmt.Println(renderer.RenderOpening(configFile, editor))
143
144 cmd := exec.Command(editor, configFile)
145 cmd.Stdin = os.Stdin
146 cmd.Stdout = os.Stdout
147 cmd.Stderr = os.Stderr
148
149 if err := cmd.Run(); err != nil {
150 wrappedErr := fmt.Errorf("open config: %w", err)
151 fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(wrappedErr), "\n"))
152 return wrapPrintedError(wrappedErr)
153 }
154 return nil
155}
156
157// getEditor returns the user's preferred editor.
158func getEditor() string {
159 // Check $EDITOR first
160 if editor := os.Getenv("EDITOR"); editor != "" {
161 return editor
162 }
163
164 // Then $VISUAL
165 if editor := os.Getenv("VISUAL"); editor != "" {
166 return editor
167 }
168
169 // Fallback to common editors
170 fallbacks := []string{"nano", "vim", "vi"}
171 for _, editor := range fallbacks {
172 if path, err := exec.LookPath(editor); err == nil {
173 return path
174 }
175 }
176
177 return ""
178}
179
180// runConfigStatus shows config file path and migration status.
181func runConfigStatus(_ *cobra.Command, _ []string) error {
182 app := GetApp()
183 if app == nil {
184 return fmt.Errorf("app not initialized")
185 }
186
187 renderer := styles.NewConfigRenderer(app.Theme)
188 migrator := config.NewMigrator()
189 diffFormatter := config.NewDiffFormatter()
190 uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
191
192 // Get config file path
193 configFile, err := config.GetConfigFile()
194 if err != nil {
195 fmt.Println(renderer.RenderError(err))
196 return nil
197 }
198
199 // Check if config file exists
200 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
201 fmt.Println(renderer.RenderNoConfigFile(configFile))
202 return nil
203 }
204
205 // Check for migration
206 ctx := context.Background()
207 result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
208 if err != nil {
209 fmt.Println(renderer.RenderError(err))
210 return nil
211 }
212
213 if !result.NeedsMigration {
214 fmt.Println(renderer.RenderUpToDate(configFile))
215 return nil
216 }
217
218 // Show status with missing count
219 fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
220 fmt.Println(renderer.RenderMigrateHint())
221
222 return nil
223}
224
225// runConfigMigrate runs the migration with optional confirmation.
226func runConfigMigrate(_ *cobra.Command, _ []string) error {
227 app := GetApp()
228 if app == nil {
229 return fmt.Errorf("app not initialized")
230 }
231
232 renderer := styles.NewConfigRenderer(app.Theme)
233 migrator := config.NewMigrator()
234 diffFormatter := config.NewDiffFormatter()
235 uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
236
237 // Get config file path
238 configFile, err := config.GetConfigFile()
239 if err != nil {
240 fmt.Println(renderer.RenderError(err))
241 return nil
242 }
243
244 // Check if config file exists
245 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
246 fmt.Println(renderer.RenderNoConfigFile(configFile))
247 return nil
248 }
249
250 // Detect changes (includes renames, additions, and removals)
251 ctx := context.Background()
252 detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
253 if err != nil {
254 fmt.Println(renderer.RenderError(err))
255 return nil
256 }
257
258 if !detectResult.HasChanges {
259 fmt.Println(renderer.RenderUpToDate(configFile))
260 return nil
261 }
262
263 // Show config info with change summary
264 fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
265 fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
266 fmt.Println(renderer.RenderChanges(detectResult.Changes))
267
268 // If --yes flag, proceed without confirmation
269 if configYes {
270 return executeMigration(ctx, uc, renderer)
271 }
272
273 // Show confirmation dialog
274 return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
275}
276
277// executeMigration performs the actual migration.
278func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
279 result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
280 if err != nil {
281 fmt.Println(renderer.RenderError(err))
282 return nil
283 }
284
285 if len(result.AddedKeys) > 0 {
286 fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
287 }
288
289 return nil
290}
291
292// migrateState represents the current state of the migrate confirmation.
293type migrateState int
294
295const (
296 migrateStateConfirm migrateState = iota
297 migrateStateDone
298)
299
300// migrateModel is the bubbletea model for the migrate confirmation.
301type migrateModel struct {
302 spinner spinner.Model
303 renderer *styles.ConfigRenderer
304 confirm styles.ConfirmModel
305 state migrateState
306 uc *usecase.MigrateConfigUseCase
307 changes []port.KeyChange
308
309 result string
310 err error
311 quitting bool
312}
313
314// migrateResultMsg is sent when the migration completes.
315type migrateResultMsg struct {
316 output *usecase.MigrateConfigOutput
317 err error
318}
319
320func newMigrateModel(
321 renderer *styles.ConfigRenderer,
322 theme *styles.Theme,
323 uc *usecase.MigrateConfigUseCase,
324 changes []port.KeyChange,
325) migrateModel {
326 s := spinner.New()
327 s.Spinner = spinner.Dot
328 s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
329
330 confirm := styles.NewConfirm(theme, "Apply these changes?")
331
332 return migrateModel{
333 spinner: s,
334 renderer: renderer,
335 confirm: confirm,
336 state: migrateStateConfirm,
337 uc: uc,
338 changes: changes,
339 }
340}
341
342func (m migrateModel) Init() tea.Cmd {
343 return m.spinner.Tick
344}
345
346func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
347 switch msg := msg.(type) {
348 case tea.KeyMsg:
349 switch msg.String() {
350 case "ctrl+c":
351 m.quitting = true
352 return m, tea.Quit
353 }
354
355 case spinner.TickMsg:
356 var cmd tea.Cmd
357 m.spinner, cmd = m.spinner.Update(msg)
358 return m, cmd
359
360 case migrateResultMsg:
361 m.state = migrateStateDone
362 if msg.err != nil {
363 m.err = msg.err
364 return m, tea.Quit
365 }
366
367 if len(msg.output.AddedKeys) > 0 {
368 m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
369 }
370 return m, tea.Quit
371 }
372
373 // Handle confirm dialog
374 if m.state == migrateStateConfirm {
375 var cmd tea.Cmd
376 m.confirm, cmd = m.confirm.Update(msg)
377
378 if m.confirm.Done() {
379 if m.confirm.Result() {
380 // User confirmed, run migration
381 return m, m.runMigration()
382 }
383 // User canceled
384 m.quitting = true
385 return m, tea.Quit
386 }
387
388 return m, cmd
389 }
390
391 return m, nil
392}
393
394func (m migrateModel) View() string {
395 if m.quitting {
396 return ""
397 }
398
399 if m.err != nil {
400 return m.renderer.RenderError(m.err)
401 }
402
403 if m.state == migrateStateDone {
404 return m.result
405 }
406
407 // Show confirmation dialog
408 return m.confirm.View()
409}
410
411func (m migrateModel) runMigration() tea.Cmd {
412 return func() tea.Msg {
413 ctx := context.Background()
414 result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
415 return migrateResultMsg{output: result, err: err}
416 }
417}
418
419// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
420func runMigrateWithConfirmation(
421 ctx context.Context,
422 uc *usecase.MigrateConfigUseCase,
423 renderer *styles.ConfigRenderer,
424 theme *styles.Theme,
425 changes []port.KeyChange,
426) error {
427 // Suppress unused ctx warning - kept for consistency with other run functions
428 _ = ctx
429
430 m := newMigrateModel(renderer, theme, uc, changes)
431 p := tea.NewProgram(m)
432
433 _, err := p.Run()
434 if err != nil {
435 return fmt.Errorf("migration failed: %w", err)
436 }
437
438 return nil
439}