1package config
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "reflect"
9 "sort"
10 "strings"
11
12 "github.com/pelletier/go-toml/v2"
13 "github.com/spf13/viper"
14 "gopkg.in/yaml.v3"
15
16 "github.com/bnema/dumber/internal/application/port"
17 "github.com/bnema/dumber/internal/domain/entity"
18)
19
20// Migrator implements port.ConfigMigrator for comparing and merging config files.
21type Migrator struct {
22 // defaultViper holds a Viper instance with all defaults set.
23 defaultViper *viper.Viper
24 // defaultConfig holds the default configuration.
25 defaultConfig *Config
26}
27
28// NewMigrator creates a new Migrator instance.
29func NewMigrator() *Migrator {
30 v := viper.New()
31 v.SetConfigType("toml")
32
33 // Create a temporary manager to set defaults
34 m := &Manager{viper: v}
35 m.setDefaults()
36
37 return &Migrator{
38 defaultViper: v,
39 defaultConfig: DefaultConfig(),
40 }
41}
42
43// CheckMigration checks if user config is missing any default keys.
44// Returns nil if no migration is needed.
45func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
46 configFile, err := GetConfigFile()
47 if err != nil {
48 return nil, fmt.Errorf("failed to get config file path: %w", err)
49 }
50
51 // If config file doesn't exist, no migration needed (will be created with all defaults)
52 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
53 return nil, nil
54 }
55
56 userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
57 if err != nil {
58 return nil, fmt.Errorf("failed to parse user config: %w", err)
59 }
60
61 userKeys := make(map[string]bool, len(userKeysWithValues))
62 for key := range userKeysWithValues {
63 userKeys[key] = true
64 }
65
66 // Get all default keys
67 defaultKeys := m.getAllDefaultKeys()
68
69 // Find missing keys (in defaults but not in user config)
70 missingKeys := m.findMissingKeys(defaultKeys, userKeys)
71 missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
72
73 if len(missingKeys) == 0 {
74 return nil, nil
75 }
76
77 return &port.MigrationResult{
78 MissingKeys: missingKeys,
79 ConfigFile: configFile,
80 }, nil
81}
82
83// DetectChanges analyzes user config and returns all detected changes.
84// This provides a detailed diff-like view of what migration would do.
85func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
86 configFile, err := GetConfigFile()
87 if err != nil {
88 return nil, fmt.Errorf("failed to get config file path: %w", err)
89 }
90
91 // If config file doesn't exist, no changes to detect
92 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
93 return nil, nil
94 }
95
96 // Get user-defined keys with values after compatibility transforms.
97 userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
98 if err != nil {
99 return nil, fmt.Errorf("failed to parse user config: %w", err)
100 }
101
102 // Get raw user-defined keys before popup compatibility transforms so legacy
103 // workspace.popups -> workspace.browsing_contexts renames are still reported.
104 rawUserKeysWithValues, err := m.getRawUserConfigKeysWithValues(configFile)
105 if err != nil {
106 return nil, fmt.Errorf("failed to parse raw user config: %w", err)
107 }
108
109 // Get all default keys
110 defaultKeys := m.getAllDefaultKeys()
111 defaultKeySet := make(map[string]bool)
112 for _, k := range defaultKeys {
113 defaultKeySet[k] = true
114 }
115
116 // Build user key set for lookup
117 userKeySet := make(map[string]bool)
118 for k := range userKeysWithValues {
119 userKeySet[k] = true
120 }
121
122 legacyRenames := append(
123 m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues),
124 m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125 )
126 legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...)
127 legacyRenames = append(legacyRenames, m.detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeysWithValues)...)
128 var changes []port.KeyChange
129 changes = append(changes, legacyRenames...)
130
131 handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames))
132 handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames))
133 for _, change := range legacyRenames {
134 handledLegacyDeprecatedKeys[change.OldKey] = true
135 handledLegacyMissingKeys[change.NewKey] = true
136 }
137
138 // Find deprecated keys (in user config but not in defaults)
139 deprecatedKeys := excludeKeys(m.findDeprecatedKeys(userKeysWithValues, defaultKeySet), handledLegacyDeprecatedKeys)
140
141 // Find missing keys (in defaults but not in user config)
142 missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys)
143 missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
144
145 // Try to match deprecated keys with missing keys (detect renames)
146 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
147
148 // Add rename changes
149 for oldKey, newKey := range renames {
150 oldValue := m.formatValue(userKeysWithValues[oldKey])
151 newValue := m.formatValue(m.defaultValueForKey(newKey))
152 changes = append(changes, port.KeyChange{
153 Type: port.KeyChangeRenamed,
154 OldKey: oldKey,
155 NewKey: newKey,
156 OldValue: oldValue,
157 NewValue: newValue,
158 })
159 }
160
161 // Add removed changes (deprecated keys that couldn't be matched)
162 for _, oldKey := range unmatchedDeprecated {
163 changes = append(changes, port.KeyChange{
164 Type: port.KeyChangeRemoved,
165 OldKey: oldKey,
166 OldValue: m.formatValue(userKeysWithValues[oldKey]),
167 })
168 }
169
170 // Add new key changes (missing keys that couldn't be matched)
171 for _, newKey := range unmatchedMissing {
172 changes = append(changes, port.KeyChange{
173 Type: port.KeyChangeAdded,
174 NewKey: newKey,
175 NewValue: m.formatValue(m.defaultValueForKey(newKey)),
176 })
177 }
178
179 // Sort changes for consistent output
180 sort.Slice(changes, func(i, j int) bool {
181 // Sort by type first, then by key
182 if changes[i].Type != changes[j].Type {
183 return changes[i].Type < changes[j].Type
184 }
185 keyI := changes[i].NewKey
186 if keyI == "" {
187 keyI = changes[i].OldKey
188 }
189 keyJ := changes[j].NewKey
190 if keyJ == "" {
191 keyJ = changes[j].OldKey
192 }
193 return keyI < keyJ
194 })
195
196 return changes, nil
197}
198
199// findDeprecatedKeys returns user keys that don't exist in defaults.
200func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
201 var deprecated []string
202 for userKey := range userKeys {
203 if m.isMigrationIgnoredDeprecatedKey(userKey) {
204 continue
205 }
206 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
207 deprecated = append(deprecated, userKey)
208 }
209 }
210 sort.Strings(deprecated)
211 return deprecated
212}
213
214// keyOrRelatedExistsInDefaults checks if a user key exists in defaults.
215func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool {
216 if defaultKeys[userKey] {
217 return true
218 }
219
220 // Check if this is a parent of any default key
221 userKeyPrefix := userKey + "."
222 for defaultKey := range defaultKeys {
223 if strings.HasPrefix(defaultKey, userKeyPrefix) {
224 return true
225 }
226 }
227
228 // Check if any parent of this user key exists
229 parts := strings.Split(userKey, ".")
230 for i := len(parts) - 1; i > 0; i-- {
231 parentKey := strings.Join(parts[:i], ".")
232 if defaultKeys[parentKey] {
233 return true
234 }
235 }
236
237 return false
238}
239
240// matchRenamedKeys attempts to match deprecated keys with missing keys.
241// Returns: renames map, unmatched deprecated, unmatched missing.
242func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) {
243 renames := make(map[string]string)
244 usedDeprecated := make(map[string]bool)
245 usedMissing := make(map[string]bool)
246
247 // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color")
248 for _, oldKey := range deprecated {
249 for _, newKey := range missing {
250 if usedMissing[newKey] {
251 continue
252 }
253
254 // Check if keys are similar (same parent path + related leaf names)
255 // AND have compatible types (don't match int keys with string keys)
256 if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) {
257 renames[oldKey] = newKey
258 usedDeprecated[oldKey] = true
259 usedMissing[newKey] = true
260 break
261 }
262 }
263 }
264
265 // Collect unmatched
266 var unmatchedDeprecated []string
267 for _, k := range deprecated {
268 if !usedDeprecated[k] {
269 unmatchedDeprecated = append(unmatchedDeprecated, k)
270 }
271 }
272
273 var unmatchedMissing []string
274 for _, k := range missing {
275 if !usedMissing[k] {
276 unmatchedMissing = append(unmatchedMissing, k)
277 }
278 }
279
280 return renames, unmatchedDeprecated, unmatchedMissing
281}
282
283// Type name constants for migration compatibility checks.
284const (
285 typeNameInt = "int"
286 typeNameString = "string"
287)
288
289// Config format constants.
290const (
291 configFormatTOML = "toml"
292 configFormatYAML = "yaml"
293 configFormatJSON = "json"
294 databasePathKey = "database.path"
295)
296
297type defaultActionMap struct {
298 key string
299 actions map[string]entity.ActionBinding
300}
301
302func (m *Migrator) defaultActionMaps() []defaultActionMap {
303 return []defaultActionMap{
304 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
305 {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},
306 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
307 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
308 {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
309 {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
310 {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
311 }
312}
313
314// typesAreCompatible checks if the default types for two keys are compatible.
315// This prevents matching int keys with string keys during rename detection.
316func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
317 // Get the type of the new key from defaults
318 newValue := m.defaultViper.Get(newKey)
319 if newValue == nil {
320 return false
321 }
322
323 newType := m.getTypeName(newValue)
324
325 // Infer expected old type from key name patterns
326 // Keys ending in "_width" are typically int
327 // Keys ending in "_color" are typically string
328 if strings.HasSuffix(oldKey, "_width") && newType == typeNameString {
329 return false
330 }
331 if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt {
332 return false
333 }
334
335 // Additional checks based on new key patterns
336 if strings.HasSuffix(newKey, "_width") && newType != typeNameInt {
337 return false
338 }
339 if strings.HasSuffix(newKey, "_color") && newType != typeNameString {
340 return false
341 }
342
343 return true
344}
345
346// keysAreSimilar checks if two keys are likely renames of each other.
347func keysAreSimilar(oldKey, newKey string) bool {
348 oldParts := strings.Split(oldKey, ".")
349 newParts := strings.Split(newKey, ".")
350
351 // Must have same parent path (all but last part)
352 if len(oldParts) != len(newParts) {
353 return false
354 }
355 if len(oldParts) < 2 {
356 return false
357 }
358
359 // Check parent path matches
360 for i := 0; i < len(oldParts)-1; i++ {
361 if oldParts[i] != newParts[i] {
362 return false
363 }
364 }
365
366 // Check if leaf names are similar (one is substring of the other, or share a common base)
367 oldLeaf := oldParts[len(oldParts)-1]
368 newLeaf := newParts[len(newParts)-1]
369
370 // Direct substring check
371 if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) {
372 return true
373 }
374
375 // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color")
376 // Extract common parts by splitting on underscore
377 oldTokens := strings.Split(oldLeaf, "_")
378 newTokens := strings.Split(newLeaf, "_")
379
380 // Count matching tokens, ensuring each new token is matched at most once
381 matches := 0
382 matchedNew := make([]bool, len(newTokens))
383 for _, ot := range oldTokens {
384 for j, nt := range newTokens {
385 if ot == nt && !matchedNew[j] {
386 matches++
387 matchedNew[j] = true
388 break
389 }
390 }
391 }
392
393 // If most tokens match, consider similar
394 minTokens := min(len(newTokens), len(oldTokens))
395 return matches >= minTokens-1 && matches > 0
396}
397
398// Migrate adds missing default keys to the user's config file and removes deprecated keys.
399func (m *Migrator) Migrate() ([]string, error) {
400 changes, err := m.DetectChanges()
401 if err != nil {
402 return nil, err
403 }
404
405 if len(changes) == 0 {
406 return nil, nil
407 }
408
409 configFile, err := GetConfigFile()
410 if err != nil {
411 return nil, fmt.Errorf("failed to get config file path: %w", err)
412 }
413
414 // Read the raw config file into a nested map
415 rawConfig, err := m.readRawConfig(configFile)
416 if err != nil {
417 return nil, fmt.Errorf("failed to read config file: %w", err)
418 }
419
420 // Transform legacy action bindings (slice -> ActionBinding struct)
421 // and migrate popups -> browsing_contexts
422 transformer := NewLegacyConfigTransformer()
423 transformer.TransformLegacyActions(rawConfig)
424 transformer.TransformLegacyEngineConfig(rawConfig)
425 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
426 m.migrateLegacyVimMode(rawConfig)
427
428 // Build sets of keys to remove and renames to apply
429 keysToRemove := make(map[string]bool)
430 renames := make(map[string]string) // oldKey -> newKey
431
432 var appliedKeys []string
433 for _, change := range changes {
434 switch change.Type {
435 case port.KeyChangeRenamed:
436 renames[change.OldKey] = change.NewKey
437 keysToRemove[change.OldKey] = true
438 appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
439 case port.KeyChangeAdded:
440 appliedKeys = append(appliedKeys, change.NewKey)
441 case port.KeyChangeRemoved:
442 keysToRemove[change.OldKey] = true
443 appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey))
444 }
445 }
446
447 // Apply renames: copy value from old key to new key before removing old key
448 for oldKey, newKey := range renames {
449 if value := m.getNestedValue(rawConfig, oldKey); value != nil {
450 m.setNestedValue(rawConfig, newKey, value)
451 }
452 }
453
454 // Remove deprecated and renamed keys from the raw config.
455 for key := range keysToRemove {
456 m.deleteNestedKey(rawConfig, key)
457 }
458 m.upgradeLegacyFavoritesSidebarDefault(rawConfig)
459 m.mergeMissingDefaultActions(rawConfig)
460
461 // Create a new Viper instance with defaults for added keys
462 userViper := viper.New()
463 userViper.SetConfigFile(configFile)
464 userViper.SetConfigType(m.getConfigType(configFile))
465
466 // Set all defaults first
467 mgr := &Manager{viper: userViper}
468 mgr.setDefaults()
469
470 // Merge the cleaned raw config on top of defaults
471 if err := userViper.MergeConfigMap(rawConfig); err != nil {
472 return nil, fmt.Errorf("failed to merge config: %w", err)
473 }
474
475 // Unmarshal to Config struct for ordered writing
476 var cfg Config
477 if err := userViper.Unmarshal(&cfg); err != nil {
478 return nil, fmt.Errorf("failed to unmarshal merged config: %w", err)
479 }
480
481 // Write with ordered sections
482 if err := WriteConfigOrdered(&cfg, configFile); err != nil {
483 return nil, fmt.Errorf("failed to write config file: %w", err)
484 }
485
486 return appliedKeys, nil
487}
488
489// getConfigType returns the config type based on file extension.
490func (*Migrator) getConfigType(configFile string) string {
491 ext := strings.TrimPrefix(filepath.Ext(configFile), ".")
492 switch ext {
493 case configFormatYAML, "yml":
494 return configFormatYAML
495 case configFormatJSON:
496 return configFormatJSON
497 case configFormatTOML:
498 return configFormatTOML
499 default:
500 return configFormatTOML // default to TOML
501 }
502}
503
504// readRawConfig reads the config file into a nested map without applying defaults.
505func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) {
506 data, err := os.ReadFile(configFile)
507 if err != nil {
508 return nil, err
509 }
510
511 var rawConfig map[string]any
512 configType := m.getConfigType(configFile)
513
514 switch configType {
515 case configFormatTOML:
516 if err := toml.Unmarshal(data, &rawConfig); err != nil {
517 return nil, fmt.Errorf("failed to parse TOML: %w", err)
518 }
519 case configFormatYAML:
520 if err := yaml.Unmarshal(data, &rawConfig); err != nil {
521 return nil, fmt.Errorf("failed to parse YAML: %w", err)
522 }
523 case configFormatJSON:
524 if err := json.Unmarshal(data, &rawConfig); err != nil {
525 return nil, fmt.Errorf("failed to parse JSON: %w", err)
526 }
527 default:
528 return nil, fmt.Errorf("unsupported config type: %s", configType)
529 }
530
531 return rawConfig, nil
532}
533
534// getNestedValue retrieves a value from a nested map using dot-notation key.
535func (*Migrator) getNestedValue(m map[string]any, key string) any {
536 parts := strings.Split(key, ".")
537 current := m
538
539 for i, part := range parts {
540 val, ok := current[part]
541 if !ok {
542 return nil
543 }
544
545 if i == len(parts)-1 {
546 return val
547 }
548
549 nested, ok := val.(map[string]any)
550 if !ok {
551 return nil
552 }
553 current = nested
554 }
555 return nil
556}
557
558// setNestedValue sets a value in a nested map using dot-notation key.
559func (*Migrator) setNestedValue(m map[string]any, key string, value any) {
560 parts := strings.Split(key, ".")
561 current := m
562
563 for i, part := range parts {
564 if i == len(parts)-1 {
565 current[part] = value
566 return
567 }
568
569 val, ok := current[part]
570 if !ok {
571 // Create intermediate map
572 newMap := make(map[string]any)
573 current[part] = newMap
574 current = newMap
575 continue
576 }
577
578 nested, ok := val.(map[string]any)
579 if !ok {
580 // Overwrite non-map value with a map
581 newMap := make(map[string]any)
582 current[part] = newMap
583 current = newMap
584 continue
585 }
586 current = nested
587 }
588}
589
590// deleteNestedKey removes a key from a nested map using dot-notation key.
591// It also cleans up empty parent maps.
592func (m *Migrator) deleteNestedKey(config map[string]any, key string) {
593 parts := strings.Split(key, ".")
594 m.deleteNestedKeyRecursive(config, parts)
595}
596
597// deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents.
598func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool {
599 if len(parts) == 0 {
600 return false
601 }
602
603 part := parts[0]
604 val, ok := current[part]
605 if !ok {
606 return false
607 }
608
609 if len(parts) == 1 {
610 // This is the leaf key, delete it
611 delete(current, part)
612 return true
613 }
614
615 // Navigate deeper
616 nested, ok := val.(map[string]any)
617 if !ok {
618 return false
619 }
620
621 // Recursively delete in the nested map
622 deleted := m.deleteNestedKeyRecursive(nested, parts[1:])
623
624 // If the nested map is now empty, remove it too
625 if deleted && len(nested) == 0 {
626 delete(current, part)
627 }
628
629 return deleted
630}
631
632// GetKeyInfo returns detailed information about a config key.
633func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
634 value := m.defaultValueForKey(key)
635 if value == nil {
636 return port.KeyInfo{
637 Key: key,
638 Type: "unknown",
639 DefaultValue: "unknown",
640 }
641 }
642
643 return port.KeyInfo{
644 Key: key,
645 Type: m.getTypeName(value),
646 DefaultValue: m.formatValue(value),
647 }
648}
649
650// GetConfigFile returns the path to the user's config file.
651func (*Migrator) GetConfigFile() (string, error) {
652 return GetConfigFile()
653}
654
655func (m *Migrator) defaultValueForKey(key string) any {
656 if value := m.defaultViper.Get(key); value != nil {
657 return value
658 }
659
660 for _, actionMap := range m.defaultActionMaps() {
661 if after, ok := strings.CutPrefix(key, actionMap.key+"."); ok {
662 actionName := after
663 if action, ok := actionMap.actions[actionName]; ok {
664 return action
665 }
666 }
667 }
668
669 return nil
670}
671
672// getAllDefaultKeys returns all keys from the default configuration.
673func (m *Migrator) getAllDefaultKeys() []string {
674 keys := m.defaultViper.AllKeys()
675
676 // Filter out keys that should not be migrated
677 filtered := make([]string, 0, len(keys))
678 for _, key := range keys {
679 if m.isMigrationIgnoredDeprecatedKey(key) {
680 continue
681 }
682 filtered = append(filtered, key)
683 }
684
685 sort.Strings(filtered)
686 return filtered
687}
688
689// getUserConfigKeys parses the user's TOML file and returns all defined keys.
690func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
691 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
692 if err != nil {
693 return nil, err
694 }
695
696 keys := make(map[string]bool)
697 for k := range keysWithValues {
698 keys[k] = true
699 }
700 return keys, nil
701}
702
703// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
704func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
705 rawConfig, err := m.readRawConfig(configFile)
706 if err != nil {
707 return nil, err
708 }
709
710 // Transform legacy action bindings and popups before flattening.
711 transformer := NewLegacyConfigTransformer()
712 transformer.TransformLegacyActions(rawConfig)
713 transformer.TransformLegacyEngineConfig(rawConfig)
714 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
715
716 // Flatten the map to dot-notation keys with values.
717 result := make(map[string]any)
718 m.flattenMapWithValues(rawConfig, "", result)
719
720 return result, nil
721}
722
723func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string]any, error) {
724 rawConfig, err := m.readRawConfig(configFile)
725 if err != nil {
726 return nil, err
727 }
728
729 result := make(map[string]any)
730 m.flattenMapWithValues(rawConfig, "", result)
731 return result, nil
732}
733
734func (m *Migrator) detectLegacyFavoritesSidebarRename(userKeys map[string]any) []port.KeyChange {
735 const (
736 actionsKey = "workspace.shortcuts.actions"
737 legacyName = "toggle-favorites-systemview"
738 newName = "toggle-favorites-sidebar"
739 )
740
741 actionsValue, ok := userKeys[actionsKey]
742 if !ok {
743 return nil
744 }
745 actions, ok := m.toStringAnyMap(actionsValue)
746 if !ok || actions[legacyName] == nil || actions[newName] != nil {
747 return nil
748 }
749
750 return []port.KeyChange{{
751 Type: port.KeyChangeRenamed,
752 OldKey: actionsKey + "." + legacyName,
753 NewKey: actionsKey + "." + newName,
754 OldValue: m.formatValue(actions[legacyName]),
755 NewValue: m.formatValue(m.defaultConfig.Workspace.Shortcuts.Actions[newName]),
756 }}
757}
758
759func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
760 type renamePair struct {
761 oldKey string
762 newKey string
763 }
764
765 renamePairs := []renamePair{
766 {"workspace.popups.behavior", "workspace.browsing_contexts.behavior"},
767 {"workspace.popups.placement", "workspace.browsing_contexts.placement"},
768 {"workspace.popups.open_in_new_pane", "workspace.browsing_contexts.open_in_new_pane"},
769 {"workspace.popups.follow_pane_context", "workspace.browsing_contexts.follow_pane_context"},
770 {"workspace.popups.blank_target_behavior", "workspace.browsing_contexts.blank_target_behavior"},
771 {"workspace.popups.enable_smart_detection", "workspace.browsing_contexts.enable_smart_detection"},
772 {"workspace.popups.oauth_auto_close", "workspace.browsing_contexts.oauth_auto_close"},
773 }
774
775 changes := make([]port.KeyChange, 0, len(renamePairs))
776 for _, pair := range renamePairs {
777 oldValue, hasOld := rawUserKeys[pair.oldKey]
778 if !hasOld {
779 continue
780 }
781 if _, hasNew := rawUserKeys[pair.newKey]; hasNew {
782 continue
783 }
784 changes = append(changes, port.KeyChange{
785 Type: port.KeyChangeRenamed,
786 OldKey: pair.oldKey,
787 NewKey: pair.newKey,
788 OldValue: m.formatValue(oldValue),
789 NewValue: m.formatValue(m.defaultValueForKey(pair.newKey)),
790 })
791 }
792 return changes
793}
794
795func (m *Migrator) detectLegacyOmniboxMaxHistoryDaysRename(rawUserKeys map[string]any) []port.KeyChange {
796 const (
797 oldKey = "omnibox.most_visited_days"
798 newKey = "omnibox.max_history_days"
799 )
800
801 oldValue, hasOld := rawUserKeys[oldKey]
802 if !hasOld {
803 return nil
804 }
805 if _, hasNew := rawUserKeys[newKey]; hasNew {
806 return nil
807 }
808
809 return []port.KeyChange{{
810 Type: port.KeyChangeRenamed,
811 OldKey: oldKey,
812 NewKey: newKey,
813 OldValue: m.formatValue(oldValue),
814 NewValue: m.formatValue(m.defaultValueForKey(newKey)),
815 }}
816}
817
818func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange {
819 changes := make([]port.KeyChange, 0, 3)
820 for oldKey, oldValue := range rawUserKeys {
821 newKey, ok := legacyVimModeKey(oldKey)
822 if !ok {
823 continue
824 }
825 if _, hasNew := rawUserKeys[newKey]; hasNew {
826 continue
827 }
828 changes = append(changes, port.KeyChange{
829 Type: port.KeyChangeRenamed,
830 OldKey: oldKey,
831 NewKey: newKey,
832 OldValue: m.formatValue(oldValue),
833 NewValue: m.formatValue(m.defaultValueForKey(newKey)),
834 })
835 }
836 return changes
837}
838
839func (m *Migrator) migrateLegacyVimMode(rawConfig map[string]any) {
840 workspace, ok := m.toStringAnyMap(rawConfig["workspace"])
841 if !ok {
842 return
843 }
844 rawConfig["workspace"] = workspace
845 legacyMode, ok := m.toStringAnyMap(workspace["page_mode"])
846 if !ok {
847 return
848 }
849 workspace["page_mode"] = legacyMode
850 vimMode, ok := m.toStringAnyMap(workspace["vim_mode"])
851 if !ok {
852 vimMode = make(map[string]any)
853 workspace["vim_mode"] = vimMode
854 } else {
855 workspace["vim_mode"] = vimMode
856 }
857 if legacyActions, ok := m.toStringAnyMap(legacyMode["actions"]); ok {
858 legacyMode["actions"] = legacyActions
859 }
860 if vimActions, ok := m.toStringAnyMap(vimMode["actions"]); ok {
861 vimMode["actions"] = vimActions
862 }
863 for key, value := range legacyMode {
864 if key == "actions" {
865 continue
866 }
867 if _, exists := vimMode[key]; !exists {
868 vimMode[key] = value
869 }
870 }
871 legacyActions, _ := m.toStringAnyMap(legacyMode["actions"])
872 vimActions, _ := m.toStringAnyMap(vimMode["actions"])
873 if vimActions == nil {
874 vimActions = make(map[string]any)
875 vimMode["actions"] = vimActions
876 }
877 for oldAction, value := range legacyActions {
878 newKey, ok := legacyVimModeKey("workspace.page_mode.actions." + oldAction)
879 if !ok {
880 continue
881 }
882 newAction := strings.TrimPrefix(newKey, "workspace.vim_mode.actions.")
883 if _, exists := vimActions[newAction]; !exists {
884 vimActions[newAction] = value
885 }
886 }
887 delete(workspace, "page_mode")
888}
889
890// legacyVimModeKey converts one legacy Page Mode config key to its canonical
891// Vim Mode key, including action-name normalization.
892func legacyVimModeKey(oldKey string) (string, bool) {
893 const oldPrefix = "workspace.page_mode"
894 const newPrefix = "workspace.vim_mode"
895
896 if !strings.HasPrefix(oldKey, oldPrefix+".") {
897 return "", false
898 }
899 newKey := newPrefix + strings.TrimPrefix(oldKey, oldPrefix)
900 const actionsPrefix = newPrefix + ".actions."
901 if strings.HasPrefix(newKey, actionsPrefix) {
902 return actionsPrefix + legacyVimActionName(strings.TrimPrefix(newKey, actionsPrefix)), true
903 }
904 return newKey, true
905}
906
907func legacyVimActionName(action string) string {
908 for _, prefix := range []string{"page-scroll-", "page_scroll_"} {
909 if strings.HasPrefix(action, prefix) {
910 return "vim-scroll-" + strings.ReplaceAll(strings.TrimPrefix(action, prefix), "_", "-")
911 }
912 }
913 return action
914}
915
916// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
917func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
918 for k, v := range data {
919 key := k
920 if prefix != "" {
921 key = prefix + "." + k
922 }
923
924 switch val := v.(type) {
925 case map[string]any:
926 // Check if this key represents user data (like search_shortcuts, actions)
927 // that should be treated as a single key rather than recursed into.
928 if m.isUserDataSection(key) {
929 result[key] = v
930 } else {
931 // Recurse into nested config sections
932 m.flattenMapWithValues(val, key, result)
933 }
934 default:
935 result[key] = v
936 }
937 }
938}
939
940// flattenMap recursively flattens a nested map to dot-notation keys.
941func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
942 for k, v := range data {
943 key := k
944 if prefix != "" {
945 key = prefix + "." + k
946 }
947
948 switch val := v.(type) {
949 case map[string]any:
950 // Check if this key represents user data (like search_shortcuts, actions)
951 // that should be treated as a single key rather than recursed into.
952 if m.isUserDataSection(key) {
953 keys[key] = true
954 } else {
955 // Recurse into nested config sections
956 m.flattenMap(val, key, keys)
957 }
958 default:
959 keys[key] = true
960 }
961 }
962}
963
964// isUserDataSection checks if a key path represents user-defined data
965// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
966func (*Migrator) isUserDataSection(keyPath string) bool {
967 // User data sections - treat entire section as one key.
968 // Some of these sections legitimately default to an empty map/table; keeping
969 // them as leaf keys makes detection agree with TOML writing.
970 switch keyPath {
971 case "search_shortcuts",
972 "workspace.floating_pane.profiles":
973 return true
974 }
975
976 // Action maps are user-customizable
977 userDataSuffixes := []string{
978 ".actions", // pane_mode.actions, tab_mode.actions, etc.
979 }
980
981 for _, suffix := range userDataSuffixes {
982 if strings.HasSuffix(keyPath, suffix) {
983 return true
984 }
985 }
986
987 return false
988}
989
990// detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
991func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
992 if userKeysWithValues == nil {
993 return nil
994 }
995
996 missing := make([]string, 0)
997 for _, actionMap := range m.defaultActionMaps() {
998 userActionsValue, ok := userKeysWithValues[actionMap.key]
999 if !ok {
1000 continue
1001 }
1002
1003 userActions, ok := m.toStringAnyMap(userActionsValue)
1004 if !ok || len(actionMap.actions) == 0 {
1005 continue
1006 }
1007
1008 for actionName := range actionMap.actions {
1009 if _, exists := userActions[actionName]; exists {
1010 continue
1011 }
1012 legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1013 if _, exists := userActions[legacyActionName]; exists {
1014 continue
1015 }
1016 missing = append(missing, actionMap.key+"."+actionName)
1017 }
1018 }
1019
1020 sort.Strings(missing)
1021 return missing
1022}
1023
1024// mergeMissingDefaultActions injects new default shortcut/modal actions while preserving user overrides.
1025func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
1026 for _, actionMap := range m.defaultActionMaps() {
1027 actionsValue := m.getNestedValue(rawConfig, actionMap.key)
1028 if actionsValue == nil {
1029 continue
1030 }
1031
1032 userActions, ok := m.toStringAnyMap(actionsValue)
1033 if !ok {
1034 continue
1035 }
1036
1037 for actionName, defaultAction := range actionMap.actions {
1038 if _, exists := userActions[actionName]; exists {
1039 continue
1040 }
1041 legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1042 if _, exists := userActions[legacyActionName]; exists {
1043 continue
1044 }
1045 userActions[actionName] = defaultAction
1046 }
1047
1048 m.setNestedValue(rawConfig, actionMap.key, userActions)
1049 }
1050}
1051
1052func (m *Migrator) upgradeLegacyFavoritesSidebarDefault(rawConfig map[string]any) {
1053 const (
1054 actionsKey = "workspace.shortcuts.actions"
1055 actionName = "toggle-favorites-sidebar"
1056 )
1057
1058 actionsValue := m.getNestedValue(rawConfig, actionsKey)
1059 actions, ok := m.toStringAnyMap(actionsValue)
1060 if !ok {
1061 return
1062 }
1063 binding, ok := m.actionBindingFromAny(actions[actionName])
1064 if !ok || !reflect.DeepEqual(binding, entity.ActionBinding{Keys: []string{}, Desc: "Toggle Favorites in right split"}) {
1065 return
1066 }
1067 actions[actionName] = m.defaultConfig.Workspace.Shortcuts.Actions[actionName]
1068 m.setNestedValue(rawConfig, actionsKey, actions)
1069}
1070
1071func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) {
1072 switch v := value.(type) {
1073 case entity.ActionBinding:
1074 return v, true
1075 case map[string]any:
1076 return actionBindingFromMap(v)
1077 default:
1078 mapping, ok := m.toStringAnyMap(value)
1079 if !ok {
1080 return entity.ActionBinding{}, false
1081 }
1082 return actionBindingFromMap(mapping)
1083 }
1084}
1085
1086func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) {
1087 var binding entity.ActionBinding
1088 if keysValue, exists := m["keys"]; exists {
1089 keys, ok := stringSliceFromAny(keysValue)
1090 if !ok {
1091 return entity.ActionBinding{}, false
1092 }
1093 binding.Keys = keys
1094 }
1095 if descValue, exists := m["desc"]; exists {
1096 desc, ok := descValue.(string)
1097 if !ok {
1098 return entity.ActionBinding{}, false
1099 }
1100 binding.Desc = desc
1101 }
1102 return binding, true
1103}
1104
1105func stringSliceFromAny(value any) ([]string, bool) {
1106 switch v := value.(type) {
1107 case []string:
1108 return append([]string(nil), v...), true
1109 case []any:
1110 out := make([]string, 0, len(v))
1111 for _, item := range v {
1112 s, ok := item.(string)
1113 if !ok {
1114 return nil, false
1115 }
1116 out = append(out, s)
1117 }
1118 return out, true
1119 default:
1120 rv := reflect.ValueOf(value)
1121 if rv.Kind() != reflect.Slice {
1122 return nil, false
1123 }
1124 out := make([]string, 0, rv.Len())
1125 for i := 0; i < rv.Len(); i++ {
1126 item := rv.Index(i).Interface()
1127 s, ok := item.(string)
1128 if !ok {
1129 return nil, false
1130 }
1131 out = append(out, s)
1132 }
1133 return out, true
1134 }
1135}
1136
1137func appendUniqueKeys(base, extra []string) []string {
1138 if len(extra) == 0 {
1139 return base
1140 }
1141
1142 seen := make(map[string]bool, len(base)+len(extra))
1143 for _, key := range base {
1144 seen[key] = true
1145 }
1146
1147 for _, key := range extra {
1148 if seen[key] {
1149 continue
1150 }
1151 base = append(base, key)
1152 seen[key] = true
1153 }
1154
1155 sort.Strings(base)
1156 return base
1157}
1158
1159func (*Migrator) isMigrationIgnoredDeprecatedKey(key string) bool {
1160 switch key {
1161 case databasePathKey:
1162 return true
1163 case "appearance.external_theme.path":
1164 // Runtime may derive this path when it is omitted, so it is not a Viper
1165 // default. It remains a valid explicit config key and should not be
1166 // removed as deprecated.
1167 return true
1168 default:
1169 return false
1170 }
1171}
1172
1173func excludeKeys(keys []string, excluded map[string]bool) []string {
1174 if len(excluded) == 0 {
1175 return keys
1176 }
1177
1178 filtered := make([]string, 0, len(keys))
1179 for _, key := range keys {
1180 if excluded[key] {
1181 continue
1182 }
1183 filtered = append(filtered, key)
1184 }
1185 return filtered
1186}
1187
1188func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
1189 if value == nil {
1190 return nil, false
1191 }
1192
1193 if m, ok := value.(map[string]any); ok {
1194 return m, true
1195 }
1196
1197 // Some TOML/YAML parser paths yield non-map[string]any map implementations
1198 // for nested tables, so keep a reflect.Map coercion fallback here.
1199 rv := reflect.ValueOf(value)
1200 if rv.Kind() != reflect.Map {
1201 return nil, false
1202 }
1203
1204 out := make(map[string]any, rv.Len())
1205 iter := rv.MapRange()
1206 for iter.Next() {
1207 k := iter.Key()
1208 if k.Kind() != reflect.String {
1209 return nil, false
1210 }
1211 out[k.String()] = iter.Value().Interface()
1212 }
1213
1214 return out, true
1215}
1216
1217// findMissingKeys returns keys that are in defaults but not in user config.
1218func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
1219 missing := make([]string, 0)
1220
1221 for _, key := range defaultKeys {
1222 // Check if key or any related key exists in user config
1223 if m.keyOrRelatedExists(key, userKeys) {
1224 continue
1225 }
1226 missing = append(missing, key)
1227 }
1228
1229 // Sort for consistent output
1230 sort.Strings(missing)
1231 return missing
1232}
1233
1234// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
1235// This handles cases where:
1236// - User has defined a parent section but not all children
1237// - Default is a struct key but user has individual sub-keys (e.g., palettes)
1238func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
1239 if keys[key] {
1240 return true
1241 }
1242
1243 // Check if this is a child of a leaf map (like search_shortcuts.ddg)
1244 // In that case, we should check if the parent exists
1245 parts := strings.Split(key, ".")
1246 for i := len(parts) - 1; i > 0; i-- {
1247 parentKey := strings.Join(parts[:i], ".")
1248 if keys[parentKey] {
1249 // Parent exists as a leaf map, so child is implicitly defined
1250 return true
1251 }
1252 }
1253
1254 // Check if any child keys exist (e.g., default is "appearance.dark_palette"
1255 // but user has "appearance.dark_palette.background")
1256 keyPrefix := key + "."
1257 for userKey := range keys {
1258 if strings.HasPrefix(userKey, keyPrefix) {
1259 return true
1260 }
1261 }
1262
1263 return false
1264}
1265
1266// getTypeName returns a human-readable type name for a value.
1267func (*Migrator) getTypeName(value any) string {
1268 if value == nil {
1269 return "unknown"
1270 }
1271
1272 t := reflect.TypeOf(value)
1273 switch t.Kind() {
1274 case reflect.Bool:
1275 return "bool"
1276 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
1277 return "int"
1278 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
1279 return "int"
1280 case reflect.Float32, reflect.Float64:
1281 return "float"
1282 case reflect.String:
1283 return "string"
1284 case reflect.Slice:
1285 return "list"
1286 case reflect.Map:
1287 return "map"
1288 default:
1289 return t.String()
1290 }
1291}
1292
1293// formatValue returns a human-readable string representation of a value.
1294func (*Migrator) formatValue(value any) string {
1295 if value == nil {
1296 return "null"
1297 }
1298
1299 switch v := value.(type) {
1300 case bool:
1301 if v {
1302 return "true"
1303 }
1304 return "false"
1305 case int, int8, int16, int32, int64:
1306 return fmt.Sprintf("%d", v)
1307 case uint, uint8, uint16, uint32, uint64:
1308 return fmt.Sprintf("%d", v)
1309 case float32, float64:
1310 return fmt.Sprintf("%v", v)
1311 case string:
1312 if v == "" {
1313 return `""`
1314 }
1315 // Truncate long strings
1316 const maxStringLen = 50
1317 if len(v) > maxStringLen {
1318 return fmt.Sprintf("%q...", v[:maxStringLen-3])
1319 }
1320 return fmt.Sprintf("%q", v)
1321 case []any:
1322 if len(v) == 0 {
1323 return "[]"
1324 }
1325 return fmt.Sprintf("[%d items]", len(v))
1326 case map[string]any:
1327 if len(v) == 0 {
1328 return "{}"
1329 }
1330 return fmt.Sprintf("{%d entries}", len(v))
1331 default:
1332 // Handle slices of strings (common for action bindings)
1333 rv := reflect.ValueOf(value)
1334 if rv.Kind() == reflect.Slice {
1335 if rv.Len() == 0 {
1336 return "[]"
1337 }
1338 return fmt.Sprintf("[%d items]", rv.Len())
1339 }
1340 if rv.Kind() == reflect.Map {
1341 if rv.Len() == 0 {
1342 return "{}"
1343 }
1344 return fmt.Sprintf("{%d entries}", rv.Len())
1345 }
1346 return fmt.Sprintf("%v", v)
1347 }
1348}