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