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