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