package config import ( "encoding/json" "fmt" "os" "path/filepath" "reflect" "sort" "strings" "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" "gopkg.in/yaml.v3" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/domain/entity" ) // Migrator implements port.ConfigMigrator for comparing and merging config files. type Migrator struct { // defaultViper holds a Viper instance with all defaults set. defaultViper *viper.Viper // defaultConfig holds the default configuration. defaultConfig *Config } // NewMigrator creates a new Migrator instance. func NewMigrator() *Migrator { v := viper.New() v.SetConfigType("toml") // Create a temporary manager to set defaults m := &Manager{viper: v} m.setDefaults() return &Migrator{ defaultViper: v, defaultConfig: DefaultConfig(), } } // CheckMigration checks if user config is missing any default keys. // Returns nil if no migration is needed. func (m *Migrator) CheckMigration() (*port.MigrationResult, error) { configFile, err := GetConfigFile() if err != nil { return nil, fmt.Errorf("failed to get config file path: %w", err) } // If config file doesn't exist, no migration needed (will be created with all defaults) if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { return nil, nil } userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) if err != nil { return nil, fmt.Errorf("failed to parse user config: %w", err) } userKeys := make(map[string]bool, len(userKeysWithValues)) for key := range userKeysWithValues { userKeys[key] = true } // Get all default keys defaultKeys := m.getAllDefaultKeys() // Find missing keys (in defaults but not in user config) missingKeys := m.findMissingKeys(defaultKeys, userKeys) missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues)) if len(missingKeys) == 0 { return nil, nil } return &port.MigrationResult{ MissingKeys: missingKeys, ConfigFile: configFile, }, nil } // DetectChanges analyzes user config and returns all detected changes. // This provides a detailed diff-like view of what migration would do. func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { configFile, err := GetConfigFile() if err != nil { return nil, fmt.Errorf("failed to get config file path: %w", err) } // If config file doesn't exist, no changes to detect if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { return nil, nil } // Get user-defined keys with values after compatibility transforms. userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) if err != nil { return nil, fmt.Errorf("failed to parse user config: %w", err) } // Get raw user-defined keys before popup compatibility transforms so legacy // workspace.popups -> workspace.browsing_contexts renames are still reported. rawUserKeysWithValues, err := m.getRawUserConfigKeysWithValues(configFile) if err != nil { return nil, fmt.Errorf("failed to parse raw user config: %w", err) } // Get all default keys defaultKeys := m.getAllDefaultKeys() defaultKeySet := make(map[string]bool) for _, k := range defaultKeys { defaultKeySet[k] = true } // Build user key set for lookup userKeySet := make(map[string]bool) for k := range userKeysWithValues { userKeySet[k] = true } legacyRenames := append( m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues), m.detectLegacyFavoritesSidebarRename(userKeysWithValues)..., ) legacyRenames = append(legacyRenames, m.detectLegacyVimModeRenames(rawUserKeysWithValues)...) var changes []port.KeyChange changes = append(changes, legacyRenames...) handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames)) handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames)) for _, change := range legacyRenames { handledLegacyDeprecatedKeys[change.OldKey] = true handledLegacyMissingKeys[change.NewKey] = true } // Find deprecated keys (in user config but not in defaults) deprecatedKeys := excludeKeys(m.findDeprecatedKeys(userKeysWithValues, defaultKeySet), handledLegacyDeprecatedKeys) // Find missing keys (in defaults but not in user config) missingKeys := excludeKeys(m.findMissingKeys(defaultKeys, userKeySet), handledLegacyMissingKeys) missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues)) // Try to match deprecated keys with missing keys (detect renames) renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys) // Add rename changes for oldKey, newKey := range renames { oldValue := m.formatValue(userKeysWithValues[oldKey]) newValue := m.formatValue(m.defaultValueForKey(newKey)) changes = append(changes, port.KeyChange{ Type: port.KeyChangeRenamed, OldKey: oldKey, NewKey: newKey, OldValue: oldValue, NewValue: newValue, }) } // Add removed changes (deprecated keys that couldn't be matched) for _, oldKey := range unmatchedDeprecated { changes = append(changes, port.KeyChange{ Type: port.KeyChangeRemoved, OldKey: oldKey, OldValue: m.formatValue(userKeysWithValues[oldKey]), }) } // Add new key changes (missing keys that couldn't be matched) for _, newKey := range unmatchedMissing { changes = append(changes, port.KeyChange{ Type: port.KeyChangeAdded, NewKey: newKey, NewValue: m.formatValue(m.defaultValueForKey(newKey)), }) } // Sort changes for consistent output sort.Slice(changes, func(i, j int) bool { // Sort by type first, then by key if changes[i].Type != changes[j].Type { return changes[i].Type < changes[j].Type } keyI := changes[i].NewKey if keyI == "" { keyI = changes[i].OldKey } keyJ := changes[j].NewKey if keyJ == "" { keyJ = changes[j].OldKey } return keyI < keyJ }) return changes, nil } // findDeprecatedKeys returns user keys that don't exist in defaults. func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { var deprecated []string for userKey := range userKeys { if m.isMigrationIgnoredDeprecatedKey(userKey) { continue } if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { deprecated = append(deprecated, userKey) } } sort.Strings(deprecated) return deprecated } // keyOrRelatedExistsInDefaults checks if a user key exists in defaults. func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool { if defaultKeys[userKey] { return true } // Check if this is a parent of any default key userKeyPrefix := userKey + "." for defaultKey := range defaultKeys { if strings.HasPrefix(defaultKey, userKeyPrefix) { return true } } // Check if any parent of this user key exists parts := strings.Split(userKey, ".") for i := len(parts) - 1; i > 0; i-- { parentKey := strings.Join(parts[:i], ".") if defaultKeys[parentKey] { return true } } return false } // matchRenamedKeys attempts to match deprecated keys with missing keys. // Returns: renames map, unmatched deprecated, unmatched missing. func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) { renames := make(map[string]string) usedDeprecated := make(map[string]bool) usedMissing := make(map[string]bool) // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color") for _, oldKey := range deprecated { for _, newKey := range missing { if usedMissing[newKey] { continue } // Check if keys are similar (same parent path + related leaf names) // AND have compatible types (don't match int keys with string keys) if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) { renames[oldKey] = newKey usedDeprecated[oldKey] = true usedMissing[newKey] = true break } } } // Collect unmatched var unmatchedDeprecated []string for _, k := range deprecated { if !usedDeprecated[k] { unmatchedDeprecated = append(unmatchedDeprecated, k) } } var unmatchedMissing []string for _, k := range missing { if !usedMissing[k] { unmatchedMissing = append(unmatchedMissing, k) } } return renames, unmatchedDeprecated, unmatchedMissing } // Type name constants for migration compatibility checks. const ( typeNameInt = "int" typeNameString = "string" ) // Config format constants. const ( configFormatTOML = "toml" configFormatYAML = "yaml" configFormatJSON = "json" databasePathKey = "database.path" ) type defaultActionMap struct { key string actions map[string]entity.ActionBinding } func (m *Migrator) defaultActionMaps() []defaultActionMap { return []defaultActionMap{ {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions}, {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions}, {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions}, {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions}, } } // typesAreCompatible checks if the default types for two keys are compatible. // This prevents matching int keys with string keys during rename detection. func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { // Get the type of the new key from defaults newValue := m.defaultViper.Get(newKey) if newValue == nil { return false } newType := m.getTypeName(newValue) // Infer expected old type from key name patterns // Keys ending in "_width" are typically int // Keys ending in "_color" are typically string if strings.HasSuffix(oldKey, "_width") && newType == typeNameString { return false } if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt { return false } // Additional checks based on new key patterns if strings.HasSuffix(newKey, "_width") && newType != typeNameInt { return false } if strings.HasSuffix(newKey, "_color") && newType != typeNameString { return false } return true } // keysAreSimilar checks if two keys are likely renames of each other. func keysAreSimilar(oldKey, newKey string) bool { oldParts := strings.Split(oldKey, ".") newParts := strings.Split(newKey, ".") // Must have same parent path (all but last part) if len(oldParts) != len(newParts) { return false } if len(oldParts) < 2 { return false } // Check parent path matches for i := 0; i < len(oldParts)-1; i++ { if oldParts[i] != newParts[i] { return false } } // Check if leaf names are similar (one is substring of the other, or share a common base) oldLeaf := oldParts[len(oldParts)-1] newLeaf := newParts[len(newParts)-1] // Direct substring check if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) { return true } // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color") // Extract common parts by splitting on underscore oldTokens := strings.Split(oldLeaf, "_") newTokens := strings.Split(newLeaf, "_") // Count matching tokens, ensuring each new token is matched at most once matches := 0 matchedNew := make([]bool, len(newTokens)) for _, ot := range oldTokens { for j, nt := range newTokens { if ot == nt && !matchedNew[j] { matches++ matchedNew[j] = true break } } } // If most tokens match, consider similar minTokens := min(len(newTokens), len(oldTokens)) return matches >= minTokens-1 && matches > 0 } // Migrate adds missing default keys to the user's config file and removes deprecated keys. func (m *Migrator) Migrate() ([]string, error) { changes, err := m.DetectChanges() if err != nil { return nil, err } if len(changes) == 0 { return nil, nil } configFile, err := GetConfigFile() if err != nil { return nil, fmt.Errorf("failed to get config file path: %w", err) } // Read the raw config file into a nested map rawConfig, err := m.readRawConfig(configFile) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } // Transform legacy action bindings (slice -> ActionBinding struct) // and migrate popups -> browsing_contexts transformer := NewLegacyConfigTransformer() transformer.TransformLegacyActions(rawConfig) transformer.TransformLegacyEngineConfig(rawConfig) transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig) m.migrateLegacyVimMode(rawConfig) // Build sets of keys to remove and renames to apply keysToRemove := make(map[string]bool) renames := make(map[string]string) // oldKey -> newKey var appliedKeys []string for _, change := range changes { switch change.Type { case port.KeyChangeRenamed: renames[change.OldKey] = change.NewKey keysToRemove[change.OldKey] = true appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) case port.KeyChangeAdded: appliedKeys = append(appliedKeys, change.NewKey) case port.KeyChangeRemoved: keysToRemove[change.OldKey] = true appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey)) } } // Apply renames: copy value from old key to new key before removing old key for oldKey, newKey := range renames { if value := m.getNestedValue(rawConfig, oldKey); value != nil { m.setNestedValue(rawConfig, newKey, value) } } // Remove deprecated and renamed keys from the raw config. for key := range keysToRemove { m.deleteNestedKey(rawConfig, key) } m.upgradeLegacyFavoritesSidebarDefault(rawConfig) m.mergeMissingDefaultActions(rawConfig) // Create a new Viper instance with defaults for added keys userViper := viper.New() userViper.SetConfigFile(configFile) userViper.SetConfigType(m.getConfigType(configFile)) // Set all defaults first mgr := &Manager{viper: userViper} mgr.setDefaults() // Merge the cleaned raw config on top of defaults if err := userViper.MergeConfigMap(rawConfig); err != nil { return nil, fmt.Errorf("failed to merge config: %w", err) } // Unmarshal to Config struct for ordered writing var cfg Config if err := userViper.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal merged config: %w", err) } // Write with ordered sections if err := WriteConfigOrdered(&cfg, configFile); err != nil { return nil, fmt.Errorf("failed to write config file: %w", err) } return appliedKeys, nil } // getConfigType returns the config type based on file extension. func (*Migrator) getConfigType(configFile string) string { ext := strings.TrimPrefix(filepath.Ext(configFile), ".") switch ext { case configFormatYAML, "yml": return configFormatYAML case configFormatJSON: return configFormatJSON case configFormatTOML: return configFormatTOML default: return configFormatTOML // default to TOML } } // readRawConfig reads the config file into a nested map without applying defaults. func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) { data, err := os.ReadFile(configFile) if err != nil { return nil, err } var rawConfig map[string]any configType := m.getConfigType(configFile) switch configType { case configFormatTOML: if err := toml.Unmarshal(data, &rawConfig); err != nil { return nil, fmt.Errorf("failed to parse TOML: %w", err) } case configFormatYAML: if err := yaml.Unmarshal(data, &rawConfig); err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } case configFormatJSON: if err := json.Unmarshal(data, &rawConfig); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } default: return nil, fmt.Errorf("unsupported config type: %s", configType) } return rawConfig, nil } // getNestedValue retrieves a value from a nested map using dot-notation key. func (*Migrator) getNestedValue(m map[string]any, key string) any { parts := strings.Split(key, ".") current := m for i, part := range parts { val, ok := current[part] if !ok { return nil } if i == len(parts)-1 { return val } nested, ok := val.(map[string]any) if !ok { return nil } current = nested } return nil } // setNestedValue sets a value in a nested map using dot-notation key. func (*Migrator) setNestedValue(m map[string]any, key string, value any) { parts := strings.Split(key, ".") current := m for i, part := range parts { if i == len(parts)-1 { current[part] = value return } val, ok := current[part] if !ok { // Create intermediate map newMap := make(map[string]any) current[part] = newMap current = newMap continue } nested, ok := val.(map[string]any) if !ok { // Overwrite non-map value with a map newMap := make(map[string]any) current[part] = newMap current = newMap continue } current = nested } } // deleteNestedKey removes a key from a nested map using dot-notation key. // It also cleans up empty parent maps. func (m *Migrator) deleteNestedKey(config map[string]any, key string) { parts := strings.Split(key, ".") m.deleteNestedKeyRecursive(config, parts) } // deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents. func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool { if len(parts) == 0 { return false } part := parts[0] val, ok := current[part] if !ok { return false } if len(parts) == 1 { // This is the leaf key, delete it delete(current, part) return true } // Navigate deeper nested, ok := val.(map[string]any) if !ok { return false } // Recursively delete in the nested map deleted := m.deleteNestedKeyRecursive(nested, parts[1:]) // If the nested map is now empty, remove it too if deleted && len(nested) == 0 { delete(current, part) } return deleted } // GetKeyInfo returns detailed information about a config key. func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { value := m.defaultValueForKey(key) if value == nil { return port.KeyInfo{ Key: key, Type: "unknown", DefaultValue: "unknown", } } return port.KeyInfo{ Key: key, Type: m.getTypeName(value), DefaultValue: m.formatValue(value), } } // GetConfigFile returns the path to the user's config file. func (*Migrator) GetConfigFile() (string, error) { return GetConfigFile() } func (m *Migrator) defaultValueForKey(key string) any { if value := m.defaultViper.Get(key); value != nil { return value } for _, actionMap := range m.defaultActionMaps() { if after, ok := strings.CutPrefix(key, actionMap.key+"."); ok { actionName := after if action, ok := actionMap.actions[actionName]; ok { return action } } } return nil } // getAllDefaultKeys returns all keys from the default configuration. func (m *Migrator) getAllDefaultKeys() []string { keys := m.defaultViper.AllKeys() // Filter out keys that should not be migrated filtered := make([]string, 0, len(keys)) for _, key := range keys { if m.isMigrationIgnoredDeprecatedKey(key) { continue } filtered = append(filtered, key) } sort.Strings(filtered) return filtered } // getUserConfigKeys parses the user's TOML file and returns all defined keys. func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { keysWithValues, err := m.getUserConfigKeysWithValues(configFile) if err != nil { return nil, err } keys := make(map[string]bool) for k := range keysWithValues { keys[k] = true } return keys, nil } // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values. func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) { rawConfig, err := m.readRawConfig(configFile) if err != nil { return nil, err } // Transform legacy action bindings and popups before flattening. transformer := NewLegacyConfigTransformer() transformer.TransformLegacyActions(rawConfig) transformer.TransformLegacyEngineConfig(rawConfig) transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig) // Flatten the map to dot-notation keys with values. result := make(map[string]any) m.flattenMapWithValues(rawConfig, "", result) return result, nil } func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string]any, error) { rawConfig, err := m.readRawConfig(configFile) if err != nil { return nil, err } result := make(map[string]any) m.flattenMapWithValues(rawConfig, "", result) return result, nil } func (m *Migrator) detectLegacyFavoritesSidebarRename(userKeys map[string]any) []port.KeyChange { const ( actionsKey = "workspace.shortcuts.actions" legacyName = "toggle-favorites-systemview" newName = "toggle-favorites-sidebar" ) actionsValue, ok := userKeys[actionsKey] if !ok { return nil } actions, ok := m.toStringAnyMap(actionsValue) if !ok || actions[legacyName] == nil || actions[newName] != nil { return nil } return []port.KeyChange{{ Type: port.KeyChangeRenamed, OldKey: actionsKey + "." + legacyName, NewKey: actionsKey + "." + newName, OldValue: m.formatValue(actions[legacyName]), NewValue: m.formatValue(m.defaultConfig.Workspace.Shortcuts.Actions[newName]), }} } func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange { type renamePair struct { oldKey string newKey string } renamePairs := []renamePair{ {"workspace.popups.behavior", "workspace.browsing_contexts.behavior"}, {"workspace.popups.placement", "workspace.browsing_contexts.placement"}, {"workspace.popups.open_in_new_pane", "workspace.browsing_contexts.open_in_new_pane"}, {"workspace.popups.follow_pane_context", "workspace.browsing_contexts.follow_pane_context"}, {"workspace.popups.blank_target_behavior", "workspace.browsing_contexts.blank_target_behavior"}, {"workspace.popups.enable_smart_detection", "workspace.browsing_contexts.enable_smart_detection"}, {"workspace.popups.oauth_auto_close", "workspace.browsing_contexts.oauth_auto_close"}, } changes := make([]port.KeyChange, 0, len(renamePairs)) for _, pair := range renamePairs { oldValue, hasOld := rawUserKeys[pair.oldKey] if !hasOld { continue } if _, hasNew := rawUserKeys[pair.newKey]; hasNew { continue } changes = append(changes, port.KeyChange{ Type: port.KeyChangeRenamed, OldKey: pair.oldKey, NewKey: pair.newKey, OldValue: m.formatValue(oldValue), NewValue: m.formatValue(m.defaultValueForKey(pair.newKey)), }) } return changes } func (m *Migrator) detectLegacyVimModeRenames(rawUserKeys map[string]any) []port.KeyChange { changes := make([]port.KeyChange, 0, 3) for oldKey, oldValue := range rawUserKeys { newKey, ok := legacyVimModeKey(oldKey) if !ok { continue } if _, hasNew := rawUserKeys[newKey]; hasNew { continue } changes = append(changes, port.KeyChange{ Type: port.KeyChangeRenamed, OldKey: oldKey, NewKey: newKey, OldValue: m.formatValue(oldValue), NewValue: m.formatValue(m.defaultValueForKey(newKey)), }) } return changes } func (m *Migrator) migrateLegacyVimMode(rawConfig map[string]any) { workspace, ok := m.toStringAnyMap(rawConfig["workspace"]) if !ok { return } rawConfig["workspace"] = workspace legacyMode, ok := m.toStringAnyMap(workspace["page_mode"]) if !ok { return } workspace["page_mode"] = legacyMode vimMode, ok := m.toStringAnyMap(workspace["vim_mode"]) if !ok { vimMode = make(map[string]any) workspace["vim_mode"] = vimMode } else { workspace["vim_mode"] = vimMode } if legacyActions, ok := m.toStringAnyMap(legacyMode["actions"]); ok { legacyMode["actions"] = legacyActions } if vimActions, ok := m.toStringAnyMap(vimMode["actions"]); ok { vimMode["actions"] = vimActions } for key, value := range legacyMode { if key == "actions" { continue } if _, exists := vimMode[key]; !exists { vimMode[key] = value } } legacyActions, _ := m.toStringAnyMap(legacyMode["actions"]) vimActions, _ := m.toStringAnyMap(vimMode["actions"]) if vimActions == nil { vimActions = make(map[string]any) vimMode["actions"] = vimActions } for oldAction, value := range legacyActions { newKey, ok := legacyVimModeKey("workspace.page_mode.actions." + oldAction) if !ok { continue } newAction := strings.TrimPrefix(newKey, "workspace.vim_mode.actions.") if _, exists := vimActions[newAction]; !exists { vimActions[newAction] = value } } delete(workspace, "page_mode") } // legacyVimModeKey converts one legacy Page Mode config key to its canonical // Vim Mode key, including action-name normalization. func legacyVimModeKey(oldKey string) (string, bool) { const oldPrefix = "workspace.page_mode" const newPrefix = "workspace.vim_mode" if !strings.HasPrefix(oldKey, oldPrefix+".") { return "", false } newKey := newPrefix + strings.TrimPrefix(oldKey, oldPrefix) const actionsPrefix = newPrefix + ".actions." if strings.HasPrefix(newKey, actionsPrefix) { return actionsPrefix + legacyVimActionName(strings.TrimPrefix(newKey, actionsPrefix)), true } return newKey, true } func legacyVimActionName(action string) string { for _, prefix := range []string{"page-scroll-", "page_scroll_"} { if strings.HasPrefix(action, prefix) { return "vim-scroll-" + strings.ReplaceAll(strings.TrimPrefix(action, prefix), "_", "-") } } return action } // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { for k, v := range data { key := k if prefix != "" { key = prefix + "." + k } switch val := v.(type) { case map[string]any: // Check if this key represents user data (like search_shortcuts, actions) // that should be treated as a single key rather than recursed into. if m.isUserDataSection(key) { result[key] = v } else { // Recurse into nested config sections m.flattenMapWithValues(val, key, result) } default: result[key] = v } } } // flattenMap recursively flattens a nested map to dot-notation keys. func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) { for k, v := range data { key := k if prefix != "" { key = prefix + "." + k } switch val := v.(type) { case map[string]any: // Check if this key represents user data (like search_shortcuts, actions) // that should be treated as a single key rather than recursed into. if m.isUserDataSection(key) { keys[key] = true } else { // Recurse into nested config sections m.flattenMap(val, key, keys) } default: keys[key] = true } } } // isUserDataSection checks if a key path represents user-defined data // that should not be compared key-by-key (e.g., search shortcuts, action bindings). func (*Migrator) isUserDataSection(keyPath string) bool { // User data sections - treat entire section as one key. // Some of these sections legitimately default to an empty map/table; keeping // them as leaf keys makes detection agree with TOML writing. switch keyPath { case "search_shortcuts", "workspace.floating_pane.profiles": return true } // Action maps are user-customizable userDataSuffixes := []string{ ".actions", // pane_mode.actions, tab_mode.actions, etc. } for _, suffix := range userDataSuffixes { if strings.HasSuffix(keyPath, suffix) { return true } } return false } // detectMissingDefaultActions finds default shortcut/modal actions not present in user config. func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string { if userKeysWithValues == nil { return nil } missing := make([]string, 0) for _, actionMap := range m.defaultActionMaps() { userActionsValue, ok := userKeysWithValues[actionMap.key] if !ok { continue } userActions, ok := m.toStringAnyMap(userActionsValue) if !ok || len(actionMap.actions) == 0 { continue } for actionName := range actionMap.actions { if _, exists := userActions[actionName]; exists { continue } legacyActionName := strings.ReplaceAll(actionName, "-", "_") if _, exists := userActions[legacyActionName]; exists { continue } missing = append(missing, actionMap.key+"."+actionName) } } sort.Strings(missing) return missing } // mergeMissingDefaultActions injects new default shortcut/modal actions while preserving user overrides. func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) { for _, actionMap := range m.defaultActionMaps() { actionsValue := m.getNestedValue(rawConfig, actionMap.key) if actionsValue == nil { continue } userActions, ok := m.toStringAnyMap(actionsValue) if !ok { continue } for actionName, defaultAction := range actionMap.actions { if _, exists := userActions[actionName]; exists { continue } legacyActionName := strings.ReplaceAll(actionName, "-", "_") if _, exists := userActions[legacyActionName]; exists { continue } userActions[actionName] = defaultAction } m.setNestedValue(rawConfig, actionMap.key, userActions) } } func (m *Migrator) upgradeLegacyFavoritesSidebarDefault(rawConfig map[string]any) { const ( actionsKey = "workspace.shortcuts.actions" actionName = "toggle-favorites-sidebar" ) actionsValue := m.getNestedValue(rawConfig, actionsKey) actions, ok := m.toStringAnyMap(actionsValue) if !ok { return } binding, ok := m.actionBindingFromAny(actions[actionName]) if !ok || !reflect.DeepEqual(binding, entity.ActionBinding{Keys: []string{}, Desc: "Toggle Favorites in right split"}) { return } actions[actionName] = m.defaultConfig.Workspace.Shortcuts.Actions[actionName] m.setNestedValue(rawConfig, actionsKey, actions) } func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) { switch v := value.(type) { case entity.ActionBinding: return v, true case map[string]any: return actionBindingFromMap(v) default: mapping, ok := m.toStringAnyMap(value) if !ok { return entity.ActionBinding{}, false } return actionBindingFromMap(mapping) } } func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) { var binding entity.ActionBinding if keysValue, exists := m["keys"]; exists { keys, ok := stringSliceFromAny(keysValue) if !ok { return entity.ActionBinding{}, false } binding.Keys = keys } if descValue, exists := m["desc"]; exists { desc, ok := descValue.(string) if !ok { return entity.ActionBinding{}, false } binding.Desc = desc } return binding, true } func stringSliceFromAny(value any) ([]string, bool) { switch v := value.(type) { case []string: return append([]string(nil), v...), true case []any: out := make([]string, 0, len(v)) for _, item := range v { s, ok := item.(string) if !ok { return nil, false } out = append(out, s) } return out, true default: rv := reflect.ValueOf(value) if rv.Kind() != reflect.Slice { return nil, false } out := make([]string, 0, rv.Len()) for i := 0; i < rv.Len(); i++ { item := rv.Index(i).Interface() s, ok := item.(string) if !ok { return nil, false } out = append(out, s) } return out, true } } func appendUniqueKeys(base, extra []string) []string { if len(extra) == 0 { return base } seen := make(map[string]bool, len(base)+len(extra)) for _, key := range base { seen[key] = true } for _, key := range extra { if seen[key] { continue } base = append(base, key) seen[key] = true } sort.Strings(base) return base } func (*Migrator) isMigrationIgnoredDeprecatedKey(key string) bool { switch key { case databasePathKey: return true case "appearance.external_theme.path": // Runtime may derive this path when it is omitted, so it is not a Viper // default. It remains a valid explicit config key and should not be // removed as deprecated. return true default: return false } } func excludeKeys(keys []string, excluded map[string]bool) []string { if len(excluded) == 0 { return keys } filtered := make([]string, 0, len(keys)) for _, key := range keys { if excluded[key] { continue } filtered = append(filtered, key) } return filtered } func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) { if value == nil { return nil, false } if m, ok := value.(map[string]any); ok { return m, true } // Some TOML/YAML parser paths yield non-map[string]any map implementations // for nested tables, so keep a reflect.Map coercion fallback here. rv := reflect.ValueOf(value) if rv.Kind() != reflect.Map { return nil, false } out := make(map[string]any, rv.Len()) iter := rv.MapRange() for iter.Next() { k := iter.Key() if k.Kind() != reflect.String { return nil, false } out[k.String()] = iter.Value().Interface() } return out, true } // findMissingKeys returns keys that are in defaults but not in user config. func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string { missing := make([]string, 0) for _, key := range defaultKeys { // Check if key or any related key exists in user config if m.keyOrRelatedExists(key, userKeys) { continue } missing = append(missing, key) } // Sort for consistent output sort.Strings(missing) return missing } // keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map. // This handles cases where: // - User has defined a parent section but not all children // - Default is a struct key but user has individual sub-keys (e.g., palettes) func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool { if keys[key] { return true } // Check if this is a child of a leaf map (like search_shortcuts.ddg) // In that case, we should check if the parent exists parts := strings.Split(key, ".") for i := len(parts) - 1; i > 0; i-- { parentKey := strings.Join(parts[:i], ".") if keys[parentKey] { // Parent exists as a leaf map, so child is implicitly defined return true } } // Check if any child keys exist (e.g., default is "appearance.dark_palette" // but user has "appearance.dark_palette.background") keyPrefix := key + "." for userKey := range keys { if strings.HasPrefix(userKey, keyPrefix) { return true } } return false } // getTypeName returns a human-readable type name for a value. func (*Migrator) getTypeName(value any) string { if value == nil { return "unknown" } t := reflect.TypeOf(value) switch t.Kind() { case reflect.Bool: return "bool" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return "int" case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return "int" case reflect.Float32, reflect.Float64: return "float" case reflect.String: return "string" case reflect.Slice: return "list" case reflect.Map: return "map" default: return t.String() } } // formatValue returns a human-readable string representation of a value. func (*Migrator) formatValue(value any) string { if value == nil { return "null" } switch v := value.(type) { case bool: if v { return "true" } return "false" case int, int8, int16, int32, int64: return fmt.Sprintf("%d", v) case uint, uint8, uint16, uint32, uint64: return fmt.Sprintf("%d", v) case float32, float64: return fmt.Sprintf("%v", v) case string: if v == "" { return `""` } // Truncate long strings const maxStringLen = 50 if len(v) > maxStringLen { return fmt.Sprintf("%q...", v[:maxStringLen-3]) } return fmt.Sprintf("%q", v) case []any: if len(v) == 0 { return "[]" } return fmt.Sprintf("[%d items]", len(v)) case map[string]any: if len(v) == 0 { return "{}" } return fmt.Sprintf("{%d entries}", len(v)) default: // Handle slices of strings (common for action bindings) rv := reflect.ValueOf(value) if rv.Kind() == reflect.Slice { if rv.Len() == 0 { return "[]" } return fmt.Sprintf("[%d items]", rv.Len()) } if rv.Kind() == reflect.Map { if rv.Len() == 0 { return "{}" } return fmt.Sprintf("{%d entries}", rv.Len()) } return fmt.Sprintf("%v", v) } }