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 // Build sets of keys to remove and renames to apply
375 keysToRemove := make(map[string]bool)
376 renames := make(map[string]string) // oldKey -> newKey
377
378 var appliedKeys []string
379 for _, change := range changes {
380 switch change.Type {
381 case port.KeyChangeRenamed:
382 renames[change.OldKey] = change.NewKey
383 keysToRemove[change.OldKey] = true
384 appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
385 case port.KeyChangeAdded:
386 appliedKeys = append(appliedKeys, change.NewKey)
387 case port.KeyChangeRemoved:
388 keysToRemove[change.OldKey] = true
389 appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey))
390 }
391 }
392
393 // Apply renames: copy value from old key to new key before removing old key
394 for oldKey, newKey := range renames {
395 if value := m.getNestedValue(rawConfig, oldKey); value != nil {
396 m.setNestedValue(rawConfig, newKey, value)
397 }
398 }
399
400 // Remove deprecated and renamed keys from the raw config
401 for key := range keysToRemove {
402 m.deleteNestedKey(rawConfig, key)
403 }
404
405 // Create a new Viper instance with defaults for added keys
406 userViper := viper.New()
407 userViper.SetConfigFile(configFile)
408 userViper.SetConfigType(m.getConfigType(configFile))
409
410 // Set all defaults first
411 mgr := &Manager{viper: userViper}
412 mgr.setDefaults()
413
414 // Merge the cleaned raw config on top of defaults
415 if err := userViper.MergeConfigMap(rawConfig); err != nil {
416 return nil, fmt.Errorf("failed to merge config: %w", err)
417 }
418
419 // Write the merged config back
420 if err := userViper.WriteConfig(); err != nil {
421 return nil, fmt.Errorf("failed to write config file: %w", err)
422 }
423
424 return appliedKeys, nil
425}
426
427// getConfigType returns the config type based on file extension.
428func (*Migrator) getConfigType(configFile string) string {
429 ext := strings.TrimPrefix(filepath.Ext(configFile), ".")
430 switch ext {
431 case configFormatYAML, "yml":
432 return configFormatYAML
433 case configFormatJSON:
434 return configFormatJSON
435 case configFormatTOML:
436 return configFormatTOML
437 default:
438 return configFormatTOML // default to TOML
439 }
440}
441
442// readRawConfig reads the config file into a nested map without applying defaults.
443func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) {
444 data, err := os.ReadFile(configFile)
445 if err != nil {
446 return nil, err
447 }
448
449 var rawConfig map[string]any
450 configType := m.getConfigType(configFile)
451
452 switch configType {
453 case configFormatTOML:
454 if err := toml.Unmarshal(data, &rawConfig); err != nil {
455 return nil, fmt.Errorf("failed to parse TOML: %w", err)
456 }
457 case configFormatYAML:
458 if err := yaml.Unmarshal(data, &rawConfig); err != nil {
459 return nil, fmt.Errorf("failed to parse YAML: %w", err)
460 }
461 case configFormatJSON:
462 if err := json.Unmarshal(data, &rawConfig); err != nil {
463 return nil, fmt.Errorf("failed to parse JSON: %w", err)
464 }
465 default:
466 return nil, fmt.Errorf("unsupported config type: %s", configType)
467 }
468
469 return rawConfig, nil
470}
471
472// getNestedValue retrieves a value from a nested map using dot-notation key.
473func (*Migrator) getNestedValue(m map[string]any, key string) any {
474 parts := strings.Split(key, ".")
475 current := m
476
477 for i, part := range parts {
478 val, ok := current[part]
479 if !ok {
480 return nil
481 }
482
483 if i == len(parts)-1 {
484 return val
485 }
486
487 nested, ok := val.(map[string]any)
488 if !ok {
489 return nil
490 }
491 current = nested
492 }
493 return nil
494}
495
496// setNestedValue sets a value in a nested map using dot-notation key.
497func (*Migrator) setNestedValue(m map[string]any, key string, value any) {
498 parts := strings.Split(key, ".")
499 current := m
500
501 for i, part := range parts {
502 if i == len(parts)-1 {
503 current[part] = value
504 return
505 }
506
507 val, ok := current[part]
508 if !ok {
509 // Create intermediate map
510 newMap := make(map[string]any)
511 current[part] = newMap
512 current = newMap
513 continue
514 }
515
516 nested, ok := val.(map[string]any)
517 if !ok {
518 // Overwrite non-map value with a map
519 newMap := make(map[string]any)
520 current[part] = newMap
521 current = newMap
522 continue
523 }
524 current = nested
525 }
526}
527
528// deleteNestedKey removes a key from a nested map using dot-notation key.
529// It also cleans up empty parent maps.
530func (m *Migrator) deleteNestedKey(config map[string]any, key string) {
531 parts := strings.Split(key, ".")
532 m.deleteNestedKeyRecursive(config, parts)
533}
534
535// deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents.
536func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool {
537 if len(parts) == 0 {
538 return false
539 }
540
541 part := parts[0]
542 val, ok := current[part]
543 if !ok {
544 return false
545 }
546
547 if len(parts) == 1 {
548 // This is the leaf key, delete it
549 delete(current, part)
550 return true
551 }
552
553 // Navigate deeper
554 nested, ok := val.(map[string]any)
555 if !ok {
556 return false
557 }
558
559 // Recursively delete in the nested map
560 deleted := m.deleteNestedKeyRecursive(nested, parts[1:])
561
562 // If the nested map is now empty, remove it too
563 if deleted && len(nested) == 0 {
564 delete(current, part)
565 }
566
567 return deleted
568}
569
570// GetKeyInfo returns detailed information about a config key.
571func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
572 value := m.defaultViper.Get(key)
573 if value == nil {
574 return port.KeyInfo{
575 Key: key,
576 Type: "unknown",
577 DefaultValue: "unknown",
578 }
579 }
580
581 return port.KeyInfo{
582 Key: key,
583 Type: m.getTypeName(value),
584 DefaultValue: m.formatValue(value),
585 }
586}
587
588// GetConfigFile returns the path to the user's config file.
589func (*Migrator) GetConfigFile() (string, error) {
590 return GetConfigFile()
591}
592
593// getAllDefaultKeys returns all keys from the default configuration.
594func (m *Migrator) getAllDefaultKeys() []string {
595 keys := m.defaultViper.AllKeys()
596
597 // Filter out keys that should not be migrated
598 filtered := make([]string, 0, len(keys))
599 for _, key := range keys {
600 // Skip database.path as it's set dynamically
601 if key == "database.path" {
602 continue
603 }
604 filtered = append(filtered, key)
605 }
606
607 sort.Strings(filtered)
608 return filtered
609}
610
611// getUserConfigKeys parses the user's TOML file and returns all defined keys.
612func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
613 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
614 if err != nil {
615 return nil, err
616 }
617
618 keys := make(map[string]bool)
619 for k := range keysWithValues {
620 keys[k] = true
621 }
622 return keys, nil
623}
624
625// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
626func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
627 data, err := os.ReadFile(configFile)
628 if err != nil {
629 return nil, fmt.Errorf("failed to read config file: %w", err)
630 }
631
632 // Parse TOML into a generic map
633 var rawConfig map[string]any
634 if err := toml.Unmarshal(data, &rawConfig); err != nil {
635 return nil, fmt.Errorf("failed to parse TOML: %w", err)
636 }
637
638 // Flatten the map to dot-notation keys with values
639 result := make(map[string]any)
640 m.flattenMapWithValues(rawConfig, "", result)
641
642 return result, nil
643}
644
645// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
646func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
647 for k, v := range data {
648 key := k
649 if prefix != "" {
650 key = prefix + "." + k
651 }
652
653 switch val := v.(type) {
654 case map[string]any:
655 // Check if this key represents user data (like search_shortcuts, actions)
656 // that should be treated as a single key rather than recursed into.
657 if m.isUserDataSection(key) {
658 result[key] = v
659 } else {
660 // Recurse into nested config sections
661 m.flattenMapWithValues(val, key, result)
662 }
663 default:
664 result[key] = v
665 }
666 }
667}
668
669// flattenMap recursively flattens a nested map to dot-notation keys.
670func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
671 for k, v := range data {
672 key := k
673 if prefix != "" {
674 key = prefix + "." + k
675 }
676
677 switch val := v.(type) {
678 case map[string]any:
679 // Check if this key represents user data (like search_shortcuts, actions)
680 // that should be treated as a single key rather than recursed into.
681 if m.isUserDataSection(key) {
682 keys[key] = true
683 } else {
684 // Recurse into nested config sections
685 m.flattenMap(val, key, keys)
686 }
687 default:
688 keys[key] = true
689 }
690 }
691}
692
693// isUserDataSection checks if a key path represents user-defined data
694// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
695func (*Migrator) isUserDataSection(keyPath string) bool {
696 // User data sections - treat entire section as one key
697 switch keyPath {
698 case "search_shortcuts":
699 return true
700 }
701
702 // Action maps are user-customizable
703 userDataSuffixes := []string{
704 ".actions", // pane_mode.actions, tab_mode.actions, etc.
705 }
706
707 for _, suffix := range userDataSuffixes {
708 if strings.HasSuffix(keyPath, suffix) {
709 return true
710 }
711 }
712
713 return false
714}
715
716// findMissingKeys returns keys that are in defaults but not in user config.
717func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
718 missing := make([]string, 0)
719
720 for _, key := range defaultKeys {
721 // Check if key or any related key exists in user config
722 if m.keyOrRelatedExists(key, userKeys) {
723 continue
724 }
725 missing = append(missing, key)
726 }
727
728 // Sort for consistent output
729 sort.Strings(missing)
730 return missing
731}
732
733// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
734// This handles cases where:
735// - User has defined a parent section but not all children
736// - Default is a struct key but user has individual sub-keys (e.g., palettes)
737func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
738 if keys[key] {
739 return true
740 }
741
742 // Check if this is a child of a leaf map (like search_shortcuts.ddg)
743 // In that case, we should check if the parent exists
744 parts := strings.Split(key, ".")
745 for i := len(parts) - 1; i > 0; i-- {
746 parentKey := strings.Join(parts[:i], ".")
747 if keys[parentKey] {
748 // Parent exists as a leaf map, so child is implicitly defined
749 return true
750 }
751 }
752
753 // Check if any child keys exist (e.g., default is "appearance.dark_palette"
754 // but user has "appearance.dark_palette.background")
755 keyPrefix := key + "."
756 for userKey := range keys {
757 if strings.HasPrefix(userKey, keyPrefix) {
758 return true
759 }
760 }
761
762 return false
763}
764
765// getTypeName returns a human-readable type name for a value.
766func (*Migrator) getTypeName(value any) string {
767 if value == nil {
768 return "unknown"
769 }
770
771 t := reflect.TypeOf(value)
772 switch t.Kind() {
773 case reflect.Bool:
774 return "bool"
775 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
776 return "int"
777 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
778 return "int"
779 case reflect.Float32, reflect.Float64:
780 return "float"
781 case reflect.String:
782 return "string"
783 case reflect.Slice:
784 return "list"
785 case reflect.Map:
786 return "map"
787 default:
788 return t.String()
789 }
790}
791
792// formatValue returns a human-readable string representation of a value.
793func (*Migrator) formatValue(value any) string {
794 if value == nil {
795 return "null"
796 }
797
798 switch v := value.(type) {
799 case bool:
800 if v {
801 return "true"
802 }
803 return "false"
804 case int, int8, int16, int32, int64:
805 return fmt.Sprintf("%d", v)
806 case uint, uint8, uint16, uint32, uint64:
807 return fmt.Sprintf("%d", v)
808 case float32, float64:
809 return fmt.Sprintf("%v", v)
810 case string:
811 if v == "" {
812 return `""`
813 }
814 // Truncate long strings
815 const maxStringLen = 50
816 if len(v) > maxStringLen {
817 return fmt.Sprintf("%q...", v[:maxStringLen-3])
818 }
819 return fmt.Sprintf("%q", v)
820 case []any:
821 if len(v) == 0 {
822 return "[]"
823 }
824 return fmt.Sprintf("[%d items]", len(v))
825 case map[string]any:
826 if len(v) == 0 {
827 return "{}"
828 }
829 return fmt.Sprintf("{%d entries}", len(v))
830 default:
831 // Handle slices of strings (common for action bindings)
832 rv := reflect.ValueOf(value)
833 if rv.Kind() == reflect.Slice {
834 if rv.Len() == 0 {
835 return "[]"
836 }
837 return fmt.Sprintf("[%d items]", rv.Len())
838 }
839 if rv.Kind() == reflect.Map {
840 if rv.Len() == 0 {
841 return "{}"
842 }
843 return fmt.Sprintf("{%d entries}", rv.Len())
844 }
845 return fmt.Sprintf("%v", v)
846 }
847}