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