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