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