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