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 // Structural migration: move old [rendering]/[performance]/[privacy]/[runtime]
251 // sections to [engine]/[engine.webkit] if needed.
252 engineMigrated, engineErr := config.MigrateToEngineConfig(configFile)
253 if engineErr != nil {
254 fmt.Println(renderer.RenderError(fmt.Errorf("engine config migration failed: %w", engineErr)))
255 return nil
256 }
257 if engineMigrated {
258 fmt.Print(renderer.RenderEngineMigrationSuccess())
259 }
260
261 // Detect changes (includes renames, additions, and removals)
262 ctx := context.Background()
263 detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
264 if err != nil {
265 fmt.Println(renderer.RenderError(err))
266 return nil
267 }
268
269 if !detectResult.HasChanges {
270 fmt.Println(renderer.RenderUpToDate(configFile))
271 return nil
272 }
273
274 // Show config info with change summary
275 fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
276 fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
277 fmt.Println(renderer.RenderChanges(detectResult.Changes))
278
279 // If --yes flag, proceed without confirmation
280 if configYes {
281 return executeMigration(ctx, uc, renderer)
282 }
283
284 // Show confirmation dialog
285 return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
286}
287
288// executeMigration performs the actual migration.
289func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
290 result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
291 if err != nil {
292 fmt.Println(renderer.RenderError(err))
293 return nil
294 }
295
296 if len(result.AddedKeys) > 0 {
297 fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
298 }
299
300 return nil
301}
302
303// migrateState represents the current state of the migrate confirmation.
304type migrateState int
305
306const (
307 migrateStateConfirm migrateState = iota
308 migrateStateDone
309)
310
311// migrateModel is the bubbletea model for the migrate confirmation.
312type migrateModel struct {
313 spinner spinner.Model
314 renderer *styles.ConfigRenderer
315 confirm styles.ConfirmModel
316 state migrateState
317 uc *usecase.MigrateConfigUseCase
318 changes []port.KeyChange
319
320 result string
321 err error
322 quitting bool
323}
324
325// migrateResultMsg is sent when the migration completes.
326type migrateResultMsg struct {
327 output *usecase.MigrateConfigOutput
328 err error
329}
330
331func newMigrateModel(
332 renderer *styles.ConfigRenderer,
333 theme *styles.Theme,
334 uc *usecase.MigrateConfigUseCase,
335 changes []port.KeyChange,
336) migrateModel {
337 s := spinner.New()
338 s.Spinner = spinner.Dot
339 s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
340
341 confirm := styles.NewConfirm(theme, "Apply these changes?")
342
343 return migrateModel{
344 spinner: s,
345 renderer: renderer,
346 confirm: confirm,
347 state: migrateStateConfirm,
348 uc: uc,
349 changes: changes,
350 }
351}
352
353func (m migrateModel) Init() tea.Cmd {
354 return m.spinner.Tick
355}
356
357func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
358 switch msg := msg.(type) {
359 case tea.KeyMsg:
360 switch msg.String() {
361 case "ctrl+c":
362 m.quitting = true
363 return m, tea.Quit
364 }
365
366 case spinner.TickMsg:
367 var cmd tea.Cmd
368 m.spinner, cmd = m.spinner.Update(msg)
369 return m, cmd
370
371 case migrateResultMsg:
372 m.state = migrateStateDone
373 if msg.err != nil {
374 m.err = msg.err
375 return m, tea.Quit
376 }
377
378 if len(msg.output.AddedKeys) > 0 {
379 m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
380 }
381 return m, tea.Quit
382 }
383
384 // Handle confirm dialog
385 if m.state == migrateStateConfirm {
386 var cmd tea.Cmd
387 m.confirm, cmd = m.confirm.Update(msg)
388
389 if m.confirm.Done() {
390 if m.confirm.Result() {
391 // User confirmed, run migration
392 return m, m.runMigration()
393 }
394 // User canceled
395 m.quitting = true
396 return m, tea.Quit
397 }
398
399 return m, cmd
400 }
401
402 return m, nil
403}
404
405func (m migrateModel) View() string {
406 if m.quitting {
407 return ""
408 }
409
410 if m.err != nil {
411 return m.renderer.RenderError(m.err)
412 }
413
414 if m.state == migrateStateDone {
415 return m.result
416 }
417
418 // Show confirmation dialog
419 return m.confirm.View()
420}
421
422func (m migrateModel) runMigration() tea.Cmd {
423 return func() tea.Msg {
424 ctx := context.Background()
425 result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
426 return migrateResultMsg{output: result, err: err}
427 }
428}
429
430// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
431func runMigrateWithConfirmation(
432 ctx context.Context,
433 uc *usecase.MigrateConfigUseCase,
434 renderer *styles.ConfigRenderer,
435 theme *styles.Theme,
436 changes []port.KeyChange,
437) error {
438 // Suppress unused ctx warning - kept for consistency with other run functions
439 _ = ctx
440
441 m := newMigrateModel(renderer, theme, uc, changes)
442 p := tea.NewProgram(m)
443
444 _, err := p.Run()
445 if err != nil {
446 return fmt.Errorf("migration failed: %w", err)
447 }
448
449 return nil
450}