1package config
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10)
11
12func TestMigrator_NewMigrator(t *testing.T) {
13 m := NewMigrator()
14 require.NotNil(t, m)
15 require.NotNil(t, m.defaultViper)
16 require.NotNil(t, m.defaultConfig)
17}
18
19func TestMigrator_GetAllDefaultKeys(t *testing.T) {
20 m := NewMigrator()
21 keys := m.getAllDefaultKeys()
22
23 // Should have many default keys
24 assert.Greater(t, len(keys), 50, "should have many default keys")
25
26 // Should not include database.path (it's dynamically set)
27 for _, key := range keys {
28 assert.NotEqual(t, "database.path", key, "should not include database.path")
29 }
30
31 // Should include some known keys
32 keySet := make(map[string]bool)
33 for _, k := range keys {
34 keySet[k] = true
35 }
36
37 assert.True(t, keySet["history.max_entries"], "should include history.max_entries")
38 assert.True(t, keySet["logging.level"], "should include logging.level")
39 assert.True(t, keySet["appearance.sans_font"], "should include appearance.sans_font")
40}
41
42func TestMigrator_GetUserConfigKeys(t *testing.T) {
43 m := NewMigrator()
44
45 // Create a temp config file
46 tmpDir := t.TempDir()
47 configFile := filepath.Join(tmpDir, "config.toml")
48
49 content := `
50[history]
51max_entries = 5000
52retention_period_days = 180
53
54[logging]
55level = "debug"
56
57[appearance]
58sans_font = "Arial"
59`
60 err := os.WriteFile(configFile, []byte(content), 0o644)
61 require.NoError(t, err)
62
63 keys, err := m.getUserConfigKeys(configFile)
64 require.NoError(t, err)
65
66 // Should have the keys we defined
67 assert.True(t, keys["history.max_entries"])
68 assert.True(t, keys["history.retention_period_days"])
69 assert.True(t, keys["logging.level"])
70 assert.True(t, keys["appearance.sans_font"])
71
72 // Should not have keys we didn't define
73 assert.False(t, keys["history.cleanup_interval_days"])
74 assert.False(t, keys["logging.format"])
75}
76
77func TestMigrator_FlattenMap(t *testing.T) {
78 m := NewMigrator()
79
80 tests := []struct {
81 name string
82 input map[string]any
83 prefix string
84 expected []string
85 }{
86 {
87 name: "simple flat map",
88 input: map[string]any{
89 "key1": "value1",
90 "key2": 123,
91 },
92 prefix: "",
93 expected: []string{"key1", "key2"},
94 },
95 {
96 name: "nested map",
97 input: map[string]any{
98 "parent": map[string]any{
99 "child1": "value1",
100 "child2": "value2",
101 },
102 },
103 prefix: "",
104 expected: []string{"parent.child1", "parent.child2"},
105 },
106 {
107 name: "with prefix",
108 input: map[string]any{
109 "key": "value",
110 },
111 prefix: "section",
112 expected: []string{"section.key"},
113 },
114 {
115 name: "search_shortcuts treated as single key",
116 input: map[string]any{
117 "search_shortcuts": map[string]any{
118 "g": "https://google.com/?q=%s",
119 "ddg": "https://duckduckgo.com/?q=%s",
120 },
121 },
122 prefix: "",
123 expected: []string{"search_shortcuts"}, // Should be treated as user data
124 },
125 {
126 name: "actions map treated as single key",
127 input: map[string]any{
128 "pane_mode": map[string]any{
129 "actions": map[string]any{
130 "split-right": []string{"r"},
131 },
132 },
133 },
134 prefix: "",
135 expected: []string{"pane_mode.actions"}, // Actions treated as user data
136 },
137 }
138
139 for _, tt := range tests {
140 t.Run(tt.name, func(t *testing.T) {
141 keys := make(map[string]bool)
142 m.flattenMap(tt.input, tt.prefix, keys)
143
144 for _, expectedKey := range tt.expected {
145 assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
146 }
147 })
148 }
149}
150
151func TestMigrator_IsUserDataSection(t *testing.T) {
152 m := NewMigrator()
153
154 tests := []struct {
155 name string
156 keyPath string
157 expected bool
158 }{
159 {
160 name: "search_shortcuts is user data",
161 keyPath: "search_shortcuts",
162 expected: true,
163 },
164 {
165 name: "pane_mode.actions is user data",
166 keyPath: "pane_mode.actions",
167 expected: true,
168 },
169 {
170 name: "tab_mode.actions is user data",
171 keyPath: "tab_mode.actions",
172 expected: true,
173 },
174 {
175 name: "history is not user data",
176 keyPath: "history",
177 expected: false,
178 },
179 {
180 name: "history.max_entries is not user data",
181 keyPath: "history.max_entries",
182 expected: false,
183 },
184 {
185 name: "appearance.sans_font is not user data",
186 keyPath: "appearance.sans_font",
187 expected: false,
188 },
189 }
190
191 for _, tt := range tests {
192 t.Run(tt.name, func(t *testing.T) {
193 result := m.isUserDataSection(tt.keyPath)
194 assert.Equal(t, tt.expected, result)
195 })
196 }
197}
198
199func TestMigrator_FindMissingKeys(t *testing.T) {
200 m := NewMigrator()
201
202 tests := []struct {
203 name string
204 defaultKeys []string
205 userKeys map[string]bool
206 expected []string
207 }{
208 {
209 name: "all keys present",
210 defaultKeys: []string{"key1", "key2"},
211 userKeys: map[string]bool{"key1": true, "key2": true},
212 expected: []string{},
213 },
214 {
215 name: "some keys missing",
216 defaultKeys: []string{"key1", "key2", "key3"},
217 userKeys: map[string]bool{"key1": true},
218 expected: []string{"key2", "key3"},
219 },
220 {
221 name: "all keys missing",
222 defaultKeys: []string{"key1", "key2"},
223 userKeys: map[string]bool{},
224 expected: []string{"key1", "key2"},
225 },
226 {
227 name: "parent key covers children",
228 defaultKeys: []string{"parent.child1", "parent.child2"},
229 userKeys: map[string]bool{"parent": true},
230 expected: []string{}, // Parent covers children
231 },
232 {
233 name: "child keys cover parent (struct defaults like palettes)",
234 defaultKeys: []string{"appearance.dark_palette"},
235 userKeys: map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
236 expected: []string{}, // Child keys mean parent is defined
237 },
238 }
239
240 for _, tt := range tests {
241 t.Run(tt.name, func(t *testing.T) {
242 result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
243 assert.ElementsMatch(t, tt.expected, result)
244 })
245 }
246}
247
248func TestMigrator_GetKeyInfo(t *testing.T) {
249 m := NewMigrator()
250
251 tests := []struct {
252 name string
253 key string
254 expectedType string
255 }{
256 {
257 name: "bool key",
258 key: "debug.enable_devtools",
259 expectedType: "bool",
260 },
261 {
262 name: "int key",
263 key: "history.max_entries",
264 expectedType: "int",
265 },
266 {
267 name: "string key",
268 key: "logging.level",
269 expectedType: "string",
270 },
271 {
272 name: "float key",
273 key: "default_webpage_zoom",
274 expectedType: "float",
275 },
276 }
277
278 for _, tt := range tests {
279 t.Run(tt.name, func(t *testing.T) {
280 info := m.GetKeyInfo(tt.key)
281 assert.Equal(t, tt.key, info.Key)
282 assert.Equal(t, tt.expectedType, info.Type)
283 assert.NotEmpty(t, info.DefaultValue)
284 })
285 }
286}
287
288func TestMigrator_GetTypeName(t *testing.T) {
289 m := NewMigrator()
290
291 tests := []struct {
292 name string
293 value any
294 expected string
295 }{
296 {name: "bool", value: true, expected: "bool"},
297 {name: "int", value: 42, expected: "int"},
298 {name: "int64", value: int64(42), expected: "int"},
299 {name: "float64", value: 3.14, expected: "float"},
300 {name: "string", value: "hello", expected: "string"},
301 {name: "slice", value: []any{"a", "b"}, expected: "list"},
302 {name: "map", value: map[string]any{"a": 1}, expected: "map"},
303 {name: "nil", value: nil, expected: "unknown"},
304 }
305
306 for _, tt := range tests {
307 t.Run(tt.name, func(t *testing.T) {
308 result := m.getTypeName(tt.value)
309 assert.Equal(t, tt.expected, result)
310 })
311 }
312}
313
314func TestMigrator_FormatValue(t *testing.T) {
315 m := NewMigrator()
316
317 tests := []struct {
318 name string
319 value any
320 expected string
321 }{
322 {name: "true", value: true, expected: "true"},
323 {name: "false", value: false, expected: "false"},
324 {name: "int", value: 42, expected: "42"},
325 {name: "float", value: 3.14, expected: "3.14"},
326 {name: "string", value: "hello", expected: `"hello"`},
327 {name: "empty string", value: "", expected: `""`},
328 {name: "empty slice", value: []any{}, expected: "[]"},
329 {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
330 {name: "empty map", value: map[string]any{}, expected: "{}"},
331 {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
332 {name: "nil", value: nil, expected: "null"},
333 }
334
335 for _, tt := range tests {
336 t.Run(tt.name, func(t *testing.T) {
337 result := m.formatValue(tt.value)
338 assert.Equal(t, tt.expected, result)
339 })
340 }
341}
342
343func TestKeysAreSimilar(t *testing.T) {
344 tests := []struct {
345 name string
346 oldKey string
347 newKey string
348 expected bool
349 }{
350 {
351 name: "identical keys",
352 oldKey: "section.pane_mode_color",
353 newKey: "section.pane_mode_color",
354 expected: true,
355 },
356 {
357 name: "substring match - old contains new",
358 oldKey: "section.pane_mode_border_color",
359 newKey: "section.pane_mode_color",
360 expected: true,
361 },
362 {
363 name: "substring match - new contains old",
364 oldKey: "section.mode_color",
365 newKey: "section.pane_mode_color",
366 expected: true,
367 },
368 {
369 name: "token matching - border removed",
370 oldKey: "section.tab_mode_border_color",
371 newKey: "section.tab_mode_color",
372 expected: true,
373 },
374 {
375 name: "different parent paths",
376 oldKey: "section_a.pane_mode_color",
377 newKey: "section_b.pane_mode_color",
378 expected: false,
379 },
380 {
381 name: "different depth",
382 oldKey: "pane_mode_color",
383 newKey: "section.pane_mode_color",
384 expected: false,
385 },
386 {
387 name: "completely different keys",
388 oldKey: "section.foo_bar",
389 newKey: "section.baz_qux",
390 expected: false,
391 },
392 {
393 name: "top level keys rejected",
394 oldKey: "foo",
395 newKey: "bar",
396 expected: false,
397 },
398 {
399 name: "no common tokens",
400 oldKey: "section.aaa_bbb",
401 newKey: "section.ccc_ddd",
402 expected: false,
403 },
404 {
405 name: "single token difference allowed",
406 oldKey: "section.pane_mode_border_width",
407 newKey: "section.mode_border_width",
408 expected: true,
409 },
410 {
411 name: "duplicate tokens in old key - no double counting",
412 oldKey: "section.mode_mode_mode",
413 newKey: "section.mode_other_another",
414 expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false
415 },
416 {
417 name: "enough common tokens with duplicates",
418 oldKey: "section.pane_mode_border",
419 newKey: "section.pane_mode_color",
420 expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
421 },
422 }
423
424 for _, tt := range tests {
425 t.Run(tt.name, func(t *testing.T) {
426 result := keysAreSimilar(tt.oldKey, tt.newKey)
427 assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
428 })
429 }
430}
431
432func TestMigrator_TypesAreCompatible(t *testing.T) {
433 m := NewMigrator()
434
435 tests := []struct {
436 name string
437 oldKey string
438 newKey string
439 expected bool
440 }{
441 {
442 name: "color to color - compatible (using real key)",
443 oldKey: "workspace.styling.pane_mode_border_color",
444 newKey: "workspace.styling.pane_mode_color",
445 expected: true,
446 },
447 {
448 name: "width to width - compatible (using real key)",
449 oldKey: "workspace.styling.pane_mode_border_width",
450 newKey: "workspace.styling.mode_border_width",
451 expected: true,
452 },
453 {
454 name: "width suffix to color type - incompatible",
455 oldKey: "workspace.styling.some_width",
456 newKey: "workspace.styling.pane_mode_color", // real key with string type
457 expected: false,
458 },
459 {
460 name: "color suffix to int type - incompatible",
461 oldKey: "workspace.styling.some_color",
462 newKey: "workspace.styling.mode_border_width", // real key with int type
463 expected: false,
464 },
465 {
466 name: "generic key to string type - compatible",
467 oldKey: "workspace.styling.some_setting",
468 newKey: "workspace.styling.pane_mode_color", // real key with string type
469 expected: true,
470 },
471 {
472 name: "non-existent new key - incompatible",
473 oldKey: "section.old_key",
474 newKey: "section.nonexistent_key_xyz",
475 expected: false,
476 },
477 }
478
479 for _, tt := range tests {
480 t.Run(tt.name, func(t *testing.T) {
481 result := m.typesAreCompatible(tt.oldKey, tt.newKey)
482 assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
483 })
484 }
485}
486
487func TestMigrator_FindDeprecatedKeys(t *testing.T) {
488 m := NewMigrator()
489
490 tests := []struct {
491 name string
492 userKeys map[string]any
493 defaultKeys map[string]bool
494 expected []string
495 }{
496 {
497 name: "no deprecated keys",
498 userKeys: map[string]any{
499 "history.max_entries": 1000,
500 "logging.level": "info",
501 },
502 defaultKeys: map[string]bool{
503 "history.max_entries": true,
504 "logging.level": true,
505 },
506 expected: []string{},
507 },
508 {
509 name: "one deprecated key",
510 userKeys: map[string]any{
511 "history.max_entries": 1000,
512 "old.deprecated_key": "value",
513 },
514 defaultKeys: map[string]bool{
515 "history.max_entries": true,
516 },
517 expected: []string{"old.deprecated_key"},
518 },
519 {
520 name: "multiple deprecated keys",
521 userKeys: map[string]any{
522 "old.key1": "a",
523 "old.key2": "b",
524 "old.key3": "c",
525 },
526 defaultKeys: map[string]bool{
527 "new.key1": true,
528 },
529 expected: []string{"old.key1", "old.key2", "old.key3"},
530 },
531 {
532 name: "parent key exists in defaults",
533 userKeys: map[string]any{
534 "parent.child": "value",
535 },
536 defaultKeys: map[string]bool{
537 "parent.child": true,
538 "parent.other_child": true,
539 },
540 expected: []string{},
541 },
542 {
543 name: "database.path ignored as deprecated",
544 userKeys: map[string]any{
545 "database.path": "",
546 },
547 defaultKeys: map[string]bool{
548 "history.max_entries": true,
549 },
550 expected: []string{},
551 },
552 }
553
554 for _, tt := range tests {
555 t.Run(tt.name, func(t *testing.T) {
556 result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
557 assert.ElementsMatch(t, tt.expected, result)
558 })
559 }
560}
561
562func TestMigrator_MatchRenamedKeys(t *testing.T) {
563 m := NewMigrator()
564
565 tests := []struct {
566 name string
567 deprecated []string
568 missing []string
569 expectedRenames map[string]string
570 expectedUnmatchedOld []string
571 expectedUnmatchedNew []string
572 }{
573 {
574 name: "no keys to match",
575 deprecated: []string{},
576 missing: []string{},
577 expectedRenames: map[string]string{},
578 expectedUnmatchedOld: nil,
579 expectedUnmatchedNew: nil,
580 },
581 {
582 name: "simple rename match (using real keys)",
583 deprecated: []string{"workspace.styling.pane_mode_border_color"},
584 missing: []string{"workspace.styling.pane_mode_color"},
585 expectedRenames: map[string]string{
586 "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
587 },
588 expectedUnmatchedOld: nil,
589 expectedUnmatchedNew: nil,
590 },
591 {
592 name: "no match possible - different types (width vs color)",
593 deprecated: []string{"workspace.styling.pane_mode_border_width"},
594 missing: []string{"workspace.styling.pane_mode_color"},
595 expectedRenames: map[string]string{},
596 expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
597 expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
598 },
599 {
600 name: "no match possible - different parents",
601 deprecated: []string{"old_section.some_key"},
602 missing: []string{"new_section.some_key"},
603 expectedRenames: map[string]string{},
604 expectedUnmatchedOld: []string{"old_section.some_key"},
605 expectedUnmatchedNew: []string{"new_section.some_key"},
606 },
607 {
608 name: "multiple renames (using real keys)",
609 deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
610 missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
611 expectedRenames: map[string]string{
612 "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
613 "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color",
614 },
615 expectedUnmatchedOld: nil,
616 expectedUnmatchedNew: nil,
617 },
618 }
619
620 for _, tt := range tests {
621 t.Run(tt.name, func(t *testing.T) {
622 renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
623 assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
624 assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
625 assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
626 })
627 }
628}
629
630func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
631 // This test would need to temporarily modify the config path
632 // which is difficult without refactoring. Skip for now.
633 t.Skip("requires config path mocking")
634}
635
636func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
637 // This test would need to temporarily modify the config path
638 // which is difficult without refactoring. Skip for now.
639 t.Skip("requires config path mocking")
640}
641
642func TestMigrator_GetConfigType(t *testing.T) {
643 m := NewMigrator()
644
645 tests := []struct {
646 name string
647 configFile string
648 expected string
649 }{
650 {
651 name: "toml file",
652 configFile: "/path/to/config.toml",
653 expected: "toml",
654 },
655 {
656 name: "yaml file",
657 configFile: "/path/to/config.yaml",
658 expected: "yaml",
659 },
660 {
661 name: "yml file",
662 configFile: "/path/to/config.yml",
663 expected: "yaml",
664 },
665 {
666 name: "json file",
667 configFile: "/path/to/config.json",
668 expected: "json",
669 },
670 {
671 name: "unknown extension defaults to toml",
672 configFile: "/path/to/config.unknown",
673 expected: "toml",
674 },
675 {
676 name: "no extension defaults to toml",
677 configFile: "/path/to/config",
678 expected: "toml",
679 },
680 }
681
682 for _, tt := range tests {
683 t.Run(tt.name, func(t *testing.T) {
684 result := m.getConfigType(tt.configFile)
685 assert.Equal(t, tt.expected, result)
686 })
687 }
688}
689
690func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
691 m := NewMigrator()
692
693 defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
694 userActions := make(map[string]any, len(defaultActions))
695 for actionName, value := range defaultActions {
696 userActions[actionName] = value
697 }
698 delete(userActions, "toggle_floating_pane")
699
700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701 "workspace.shortcuts.actions": userActions,
702 })
703
704 assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)
705}
706
707func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
708 m := NewMigrator()
709
710 rawConfig := map[string]any{
711 "workspace": map[string]any{
712 "shortcuts": map[string]any{
713 "actions": map[string]any{
714 "close_pane": map[string]any{
715 "keys": []string{"ctrl+w", "ctrl+i"},
716 "desc": "Custom close pane",
717 },
718 "next_tab": map[string]any{
719 "keys": []string{"ctrl+tab"},
720 "desc": "Switch to next tab",
721 },
722 "previous_tab": map[string]any{
723 "keys": []string{"ctrl+shift+tab"},
724 "desc": "Switch to previous tab",
725 },
726 "consume_or_expel_left": map[string]any{
727 "keys": []string{"alt+["},
728 "desc": "Consume/expel pane left",
729 },
730 "consume_or_expel_right": map[string]any{
731 "keys": []string{"alt+]"},
732 "desc": "Consume/expel pane right",
733 },
734 "consume_or_expel_up": map[string]any{
735 "keys": []string{"alt+{"},
736 "desc": "Consume/expel pane up",
737 },
738 "consume_or_expel_down": map[string]any{
739 "keys": []string{"alt+}"},
740 "desc": "Consume/expel pane down",
741 },
742 },
743 },
744 },
745 }
746
747 m.mergeMissingWorkspaceShortcutActions(rawConfig)
748
749 actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
750 actions, ok := actionsAny.(map[string]any)
751 require.True(t, ok)
752
753 toggleAny, hasToggle := actions["toggle_floating_pane"]
754 assert.True(t, hasToggle)
755 assert.NotNil(t, toggleAny)
756
757 closePaneAny, hasClosePane := actions["close_pane"]
758 require.True(t, hasClosePane)
759 closePane, ok := closePaneAny.(map[string]any)
760 require.True(t, ok)
761 assert.Equal(t, "Custom close pane", closePane["desc"])
762}
763
764func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765 m := NewMigrator()
766
767 value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")
768
769 action, ok := value.(ActionBinding)
770 require.True(t, ok)
771 assert.Equal(t, []string{"alt+f"}, action.Keys)
772}
773
774func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
775 m := NewMigrator()
776
777 value := m.defaultValueForKey("history.max_entries")
778 assert.Equal(t, 10000, value)
779}
780
781func TestMigrator_GetNestedValue(t *testing.T) {
782 m := NewMigrator()
783
784 config := map[string]any{
785 "level1": map[string]any{
786 "level2": map[string]any{
787 "level3": "deep_value",
788 },
789 "simple": "simple_value",
790 },
791 "top": "top_value",
792 }
793
794 tests := []struct {
795 name string
796 key string
797 expected any
798 }{
799 {
800 name: "top level key",
801 key: "top",
802 expected: "top_value",
803 },
804 {
805 name: "nested key",
806 key: "level1.simple",
807 expected: "simple_value",
808 },
809 {
810 name: "deeply nested key",
811 key: "level1.level2.level3",
812 expected: "deep_value",
813 },
814 {
815 name: "non-existent key",
816 key: "nonexistent",
817 expected: nil,
818 },
819 {
820 name: "non-existent nested key",
821 key: "level1.nonexistent",
822 expected: nil,
823 },
824 }
825
826 for _, tt := range tests {
827 t.Run(tt.name, func(t *testing.T) {
828 result := m.getNestedValue(config, tt.key)
829 assert.Equal(t, tt.expected, result)
830 })
831 }
832}
833
834func TestMigrator_SetNestedValue(t *testing.T) {
835 m := NewMigrator()
836
837 t.Run("set top level value", func(t *testing.T) {
838 config := make(map[string]any)
839 m.setNestedValue(config, "top", "value")
840 assert.Equal(t, "value", config["top"])
841 })
842
843 t.Run("set nested value with existing parent", func(t *testing.T) {
844 config := map[string]any{
845 "parent": map[string]any{},
846 }
847 m.setNestedValue(config, "parent.child", "value")
848 parent := config["parent"].(map[string]any)
849 assert.Equal(t, "value", parent["child"])
850 })
851
852 t.Run("set nested value creates intermediate maps", func(t *testing.T) {
853 config := make(map[string]any)
854 m.setNestedValue(config, "a.b.c", "deep_value")
855
856 a := config["a"].(map[string]any)
857 b := a["b"].(map[string]any)
858 assert.Equal(t, "deep_value", b["c"])
859 })
860}
861
862func TestMigrator_DeleteNestedKey(t *testing.T) {
863 m := NewMigrator()
864
865 t.Run("delete top level key", func(t *testing.T) {
866 config := map[string]any{
867 "keep": "value",
868 "delete": "value",
869 }
870 m.deleteNestedKey(config, "delete")
871 assert.Contains(t, config, "keep")
872 assert.NotContains(t, config, "delete")
873 })
874
875 t.Run("delete nested key", func(t *testing.T) {
876 config := map[string]any{
877 "parent": map[string]any{
878 "keep": "value",
879 "delete": "value",
880 },
881 }
882 m.deleteNestedKey(config, "parent.delete")
883 parent := config["parent"].(map[string]any)
884 assert.Contains(t, parent, "keep")
885 assert.NotContains(t, parent, "delete")
886 })
887
888 t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
889 config := map[string]any{
890 "keep": "value",
891 "parent": map[string]any{
892 "child": map[string]any{
893 "leaf": "value",
894 },
895 },
896 }
897 m.deleteNestedKey(config, "parent.child.leaf")
898 // Both parent and child should be removed since they're empty
899 assert.NotContains(t, config, "parent")
900 assert.Contains(t, config, "keep")
901 })
902
903 t.Run("delete non-existent key is no-op", func(t *testing.T) {
904 config := map[string]any{
905 "keep": "value",
906 }
907 m.deleteNestedKey(config, "nonexistent")
908 assert.Contains(t, config, "keep")
909 })
910}
911
912func TestMigrator_ReadRawConfig(t *testing.T) {
913 m := NewMigrator()
914
915 t.Run("read TOML config", func(t *testing.T) {
916 tmpDir := t.TempDir()
917 configFile := filepath.Join(tmpDir, "config.toml")
918
919 content := `
920[section]
921key = "value"
922number = 42
923`
924 err := os.WriteFile(configFile, []byte(content), 0o644)
925 require.NoError(t, err)
926
927 config, err := m.readRawConfig(configFile)
928 require.NoError(t, err)
929
930 section := config["section"].(map[string]any)
931 assert.Equal(t, "value", section["key"])
932 assert.Equal(t, int64(42), section["number"])
933 })
934
935 t.Run("read YAML config", func(t *testing.T) {
936 tmpDir := t.TempDir()
937 configFile := filepath.Join(tmpDir, "config.yaml")
938
939 content := `
940section:
941 key: value
942 number: 42
943`
944 err := os.WriteFile(configFile, []byte(content), 0o644)
945 require.NoError(t, err)
946
947 config, err := m.readRawConfig(configFile)
948 require.NoError(t, err)
949
950 section := config["section"].(map[string]any)
951 assert.Equal(t, "value", section["key"])
952 assert.Equal(t, 42, section["number"])
953 })
954
955 t.Run("read JSON config", func(t *testing.T) {
956 tmpDir := t.TempDir()
957 configFile := filepath.Join(tmpDir, "config.json")
958
959 content := `{
960 "section": {
961 "key": "value",
962 "number": 42
963 }
964}`
965 err := os.WriteFile(configFile, []byte(content), 0o644)
966 require.NoError(t, err)
967
968 config, err := m.readRawConfig(configFile)
969 require.NoError(t, err)
970
971 section := config["section"].(map[string]any)
972 assert.Equal(t, "value", section["key"])
973 // JSON numbers are float64
974 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
975 })
976}