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