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 assert.True(t, keySet["omnibox.style"], "should include omnibox.style for config migrate")
43}
44
45func TestMigrator_GetUserConfigKeys(t *testing.T) {
46 m := NewMigrator()
47
48 // Create a temp config file
49 tmpDir := t.TempDir()
50 configFile := filepath.Join(tmpDir, "config.toml")
51
52 content := `
53[history]
54max_entries = 5000
55retention_period_days = 180
56
57[logging]
58level = "debug"
59
60[appearance]
61sans_font = "Arial"
62`
63 err := os.WriteFile(configFile, []byte(content), 0o644)
64 require.NoError(t, err)
65
66 keys, err := m.getUserConfigKeys(configFile)
67 require.NoError(t, err)
68
69 // Should have the keys we defined
70 assert.True(t, keys["history.max_entries"])
71 assert.True(t, keys["history.retention_period_days"])
72 assert.True(t, keys["logging.level"])
73 assert.True(t, keys["appearance.sans_font"])
74
75 // Should not have keys we didn't define
76 assert.False(t, keys["history.cleanup_interval_days"])
77 assert.False(t, keys["logging.format"])
78}
79
80func TestMigrator_FlattenMap(t *testing.T) {
81 m := NewMigrator()
82
83 tests := []struct {
84 name string
85 input map[string]any
86 prefix string
87 expected []string
88 }{
89 {
90 name: "simple flat map",
91 input: map[string]any{
92 "key1": "value1",
93 "key2": 123,
94 },
95 prefix: "",
96 expected: []string{"key1", "key2"},
97 },
98 {
99 name: "nested map",
100 input: map[string]any{
101 "parent": map[string]any{
102 "child1": "value1",
103 "child2": "value2",
104 },
105 },
106 prefix: "",
107 expected: []string{"parent.child1", "parent.child2"},
108 },
109 {
110 name: "with prefix",
111 input: map[string]any{
112 "key": "value",
113 },
114 prefix: "section",
115 expected: []string{"section.key"},
116 },
117 {
118 name: "search_shortcuts treated as single key",
119 input: map[string]any{
120 "search_shortcuts": map[string]any{
121 "g": "https://google.com/?q=%s",
122 "ddg": "https://duckduckgo.com/?q=%s",
123 },
124 },
125 prefix: "",
126 expected: []string{"search_shortcuts"}, // Should be treated as user data
127 },
128 {
129 name: "actions map treated as single key",
130 input: map[string]any{
131 "pane_mode": map[string]any{
132 "actions": map[string]any{
133 "split-right": []string{"r"},
134 },
135 },
136 },
137 prefix: "",
138 expected: []string{"pane_mode.actions"}, // Actions treated as user data
139 },
140 }
141
142 for _, tt := range tests {
143 t.Run(tt.name, func(t *testing.T) {
144 keys := make(map[string]bool)
145 m.flattenMap(tt.input, tt.prefix, keys)
146
147 for _, expectedKey := range tt.expected {
148 assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
149 }
150 })
151 }
152}
153
154func TestMigrator_IsUserDataSection(t *testing.T) {
155 m := NewMigrator()
156
157 tests := []struct {
158 name string
159 keyPath string
160 expected bool
161 }{
162 {
163 name: "search_shortcuts is user data",
164 keyPath: "search_shortcuts",
165 expected: true,
166 },
167 {
168 name: "pane_mode.actions is user data",
169 keyPath: "pane_mode.actions",
170 expected: true,
171 },
172 {
173 name: "tab_mode.actions is user data",
174 keyPath: "tab_mode.actions",
175 expected: true,
176 },
177 {
178 name: "history is not user data",
179 keyPath: "history",
180 expected: false,
181 },
182 {
183 name: "history.max_entries is not user data",
184 keyPath: "history.max_entries",
185 expected: false,
186 },
187 {
188 name: "appearance.sans_font is not user data",
189 keyPath: "appearance.sans_font",
190 expected: false,
191 },
192 }
193
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 result := m.isUserDataSection(tt.keyPath)
197 assert.Equal(t, tt.expected, result)
198 })
199 }
200}
201
202func TestMigrator_FindMissingKeys(t *testing.T) {
203 m := NewMigrator()
204
205 tests := []struct {
206 name string
207 defaultKeys []string
208 userKeys map[string]bool
209 expected []string
210 }{
211 {
212 name: "all keys present",
213 defaultKeys: []string{"key1", "key2"},
214 userKeys: map[string]bool{"key1": true, "key2": true},
215 expected: []string{},
216 },
217 {
218 name: "some keys missing",
219 defaultKeys: []string{"key1", "key2", "key3"},
220 userKeys: map[string]bool{"key1": true},
221 expected: []string{"key2", "key3"},
222 },
223 {
224 name: "all keys missing",
225 defaultKeys: []string{"key1", "key2"},
226 userKeys: map[string]bool{},
227 expected: []string{"key1", "key2"},
228 },
229 {
230 name: "parent key covers children",
231 defaultKeys: []string{"parent.child1", "parent.child2"},
232 userKeys: map[string]bool{"parent": true},
233 expected: []string{}, // Parent covers children
234 },
235 {
236 name: "child keys cover parent (struct defaults like palettes)",
237 defaultKeys: []string{"appearance.dark_palette"},
238 userKeys: map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
239 expected: []string{}, // Child keys mean parent is defined
240 },
241 }
242
243 for _, tt := range tests {
244 t.Run(tt.name, func(t *testing.T) {
245 result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
246 assert.ElementsMatch(t, tt.expected, result)
247 })
248 }
249}
250
251func TestMigrator_GetKeyInfo(t *testing.T) {
252 m := NewMigrator()
253
254 tests := []struct {
255 name string
256 key string
257 expectedType string
258 }{
259 {
260 name: "bool key",
261 key: "debug.enable_devtools",
262 expectedType: "bool",
263 },
264 {
265 name: "int key",
266 key: "history.max_entries",
267 expectedType: "int",
268 },
269 {
270 name: "string key",
271 key: "logging.level",
272 expectedType: "string",
273 },
274 {
275 name: "float key",
276 key: "default_webpage_zoom",
277 expectedType: "float",
278 },
279 }
280
281 for _, tt := range tests {
282 t.Run(tt.name, func(t *testing.T) {
283 info := m.GetKeyInfo(tt.key)
284 assert.Equal(t, tt.key, info.Key)
285 assert.Equal(t, tt.expectedType, info.Type)
286 assert.NotEmpty(t, info.DefaultValue)
287 })
288 }
289}
290
291func TestMigrator_GetTypeName(t *testing.T) {
292 m := NewMigrator()
293
294 tests := []struct {
295 name string
296 value any
297 expected string
298 }{
299 {name: "bool", value: true, expected: "bool"},
300 {name: "int", value: 42, expected: "int"},
301 {name: "int64", value: int64(42), expected: "int"},
302 {name: "float64", value: 3.14, expected: "float"},
303 {name: "string", value: "hello", expected: "string"},
304 {name: "slice", value: []any{"a", "b"}, expected: "list"},
305 {name: "map", value: map[string]any{"a": 1}, expected: "map"},
306 {name: "nil", value: nil, expected: "unknown"},
307 }
308
309 for _, tt := range tests {
310 t.Run(tt.name, func(t *testing.T) {
311 result := m.getTypeName(tt.value)
312 assert.Equal(t, tt.expected, result)
313 })
314 }
315}
316
317func TestMigrator_FormatValue(t *testing.T) {
318 m := NewMigrator()
319
320 tests := []struct {
321 name string
322 value any
323 expected string
324 }{
325 {name: "true", value: true, expected: "true"},
326 {name: "false", value: false, expected: "false"},
327 {name: "int", value: 42, expected: "42"},
328 {name: "float", value: 3.14, expected: "3.14"},
329 {name: "string", value: "hello", expected: `"hello"`},
330 {name: "empty string", value: "", expected: `""`},
331 {name: "empty slice", value: []any{}, expected: "[]"},
332 {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
333 {name: "empty map", value: map[string]any{}, expected: "{}"},
334 {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
335 {name: "nil", value: nil, expected: "null"},
336 }
337
338 for _, tt := range tests {
339 t.Run(tt.name, func(t *testing.T) {
340 result := m.formatValue(tt.value)
341 assert.Equal(t, tt.expected, result)
342 })
343 }
344}
345
346func TestKeysAreSimilar(t *testing.T) {
347 tests := []struct {
348 name string
349 oldKey string
350 newKey string
351 expected bool
352 }{
353 {
354 name: "identical keys",
355 oldKey: "section.pane_mode_color",
356 newKey: "section.pane_mode_color",
357 expected: true,
358 },
359 {
360 name: "substring match - old contains new",
361 oldKey: "section.pane_mode_border_color",
362 newKey: "section.pane_mode_color",
363 expected: true,
364 },
365 {
366 name: "substring match - new contains old",
367 oldKey: "section.mode_color",
368 newKey: "section.pane_mode_color",
369 expected: true,
370 },
371 {
372 name: "token matching - border removed",
373 oldKey: "section.tab_mode_border_color",
374 newKey: "section.tab_mode_color",
375 expected: true,
376 },
377 {
378 name: "different parent paths",
379 oldKey: "section_a.pane_mode_color",
380 newKey: "section_b.pane_mode_color",
381 expected: false,
382 },
383 {
384 name: "different depth",
385 oldKey: "pane_mode_color",
386 newKey: "section.pane_mode_color",
387 expected: false,
388 },
389 {
390 name: "completely different keys",
391 oldKey: "section.foo_bar",
392 newKey: "section.baz_qux",
393 expected: false,
394 },
395 {
396 name: "top level keys rejected",
397 oldKey: "foo",
398 newKey: "bar",
399 expected: false,
400 },
401 {
402 name: "no common tokens",
403 oldKey: "section.aaa_bbb",
404 newKey: "section.ccc_ddd",
405 expected: false,
406 },
407 {
408 name: "single token difference allowed",
409 oldKey: "section.pane_mode_border_width",
410 newKey: "section.mode_border_width",
411 expected: true,
412 },
413 {
414 name: "duplicate tokens in old key - no double counting",
415 oldKey: "section.mode_mode_mode",
416 newKey: "section.mode_other_another",
417 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
418 },
419 {
420 name: "enough common tokens with duplicates",
421 oldKey: "section.pane_mode_border",
422 newKey: "section.pane_mode_color",
423 expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
424 },
425 }
426
427 for _, tt := range tests {
428 t.Run(tt.name, func(t *testing.T) {
429 result := keysAreSimilar(tt.oldKey, tt.newKey)
430 assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
431 })
432 }
433}
434
435func TestMigrator_TypesAreCompatible(t *testing.T) {
436 m := NewMigrator()
437
438 tests := []struct {
439 name string
440 oldKey string
441 newKey string
442 expected bool
443 }{
444 {
445 name: "color to color - compatible (using real key)",
446 oldKey: "workspace.styling.pane_mode_border_color",
447 newKey: "workspace.styling.pane_mode_color",
448 expected: true,
449 },
450 {
451 name: "width to width - compatible (using real key)",
452 oldKey: "workspace.styling.pane_mode_border_width",
453 newKey: "workspace.styling.mode_border_width",
454 expected: true,
455 },
456 {
457 name: "width suffix to color type - incompatible",
458 oldKey: "workspace.styling.some_width",
459 newKey: "workspace.styling.pane_mode_color", // real key with string type
460 expected: false,
461 },
462 {
463 name: "color suffix to int type - incompatible",
464 oldKey: "workspace.styling.some_color",
465 newKey: "workspace.styling.mode_border_width", // real key with int type
466 expected: false,
467 },
468 {
469 name: "generic key to string type - compatible",
470 oldKey: "workspace.styling.some_setting",
471 newKey: "workspace.styling.pane_mode_color", // real key with string type
472 expected: true,
473 },
474 {
475 name: "non-existent new key - incompatible",
476 oldKey: "section.old_key",
477 newKey: "section.nonexistent_key_xyz",
478 expected: false,
479 },
480 }
481
482 for _, tt := range tests {
483 t.Run(tt.name, func(t *testing.T) {
484 result := m.typesAreCompatible(tt.oldKey, tt.newKey)
485 assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
486 })
487 }
488}
489
490func TestMigrator_FindDeprecatedKeys(t *testing.T) {
491 m := NewMigrator()
492
493 tests := []struct {
494 name string
495 userKeys map[string]any
496 defaultKeys map[string]bool
497 expected []string
498 }{
499 {
500 name: "no deprecated keys",
501 userKeys: map[string]any{
502 "history.max_entries": 1000,
503 "logging.level": "info",
504 },
505 defaultKeys: map[string]bool{
506 "history.max_entries": true,
507 "logging.level": true,
508 },
509 expected: []string{},
510 },
511 {
512 name: "one deprecated key",
513 userKeys: map[string]any{
514 "history.max_entries": 1000,
515 "old.deprecated_key": "value",
516 },
517 defaultKeys: map[string]bool{
518 "history.max_entries": true,
519 },
520 expected: []string{"old.deprecated_key"},
521 },
522 {
523 name: "multiple deprecated keys",
524 userKeys: map[string]any{
525 "old.key1": "a",
526 "old.key2": "b",
527 "old.key3": "c",
528 },
529 defaultKeys: map[string]bool{
530 "new.key1": true,
531 },
532 expected: []string{"old.key1", "old.key2", "old.key3"},
533 },
534 {
535 name: "parent key exists in defaults",
536 userKeys: map[string]any{
537 "parent.child": "value",
538 },
539 defaultKeys: map[string]bool{
540 "parent.child": true,
541 "parent.other_child": true,
542 },
543 expected: []string{},
544 },
545 {
546 name: "database.path ignored as deprecated",
547 userKeys: map[string]any{
548 "database.path": "",
549 },
550 defaultKeys: map[string]bool{
551 "history.max_entries": true,
552 },
553 expected: []string{},
554 },
555 }
556
557 for _, tt := range tests {
558 t.Run(tt.name, func(t *testing.T) {
559 result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
560 assert.ElementsMatch(t, tt.expected, result)
561 })
562 }
563}
564
565func TestMigrator_MatchRenamedKeys(t *testing.T) {
566 m := NewMigrator()
567
568 tests := []struct {
569 name string
570 deprecated []string
571 missing []string
572 expectedRenames map[string]string
573 expectedUnmatchedOld []string
574 expectedUnmatchedNew []string
575 }{
576 {
577 name: "no keys to match",
578 deprecated: []string{},
579 missing: []string{},
580 expectedRenames: map[string]string{},
581 expectedUnmatchedOld: nil,
582 expectedUnmatchedNew: nil,
583 },
584 {
585 name: "simple rename match (using real keys)",
586 deprecated: []string{"workspace.styling.pane_mode_border_color"},
587 missing: []string{"workspace.styling.pane_mode_color"},
588 expectedRenames: map[string]string{
589 "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
590 },
591 expectedUnmatchedOld: nil,
592 expectedUnmatchedNew: nil,
593 },
594 {
595 name: "no match possible - different types (width vs color)",
596 deprecated: []string{"workspace.styling.pane_mode_border_width"},
597 missing: []string{"workspace.styling.pane_mode_color"},
598 expectedRenames: map[string]string{},
599 expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
600 expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
601 },
602 {
603 name: "no match possible - different parents",
604 deprecated: []string{"old_section.some_key"},
605 missing: []string{"new_section.some_key"},
606 expectedRenames: map[string]string{},
607 expectedUnmatchedOld: []string{"old_section.some_key"},
608 expectedUnmatchedNew: []string{"new_section.some_key"},
609 },
610 {
611 name: "multiple renames (using real keys)",
612 deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
613 missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
614 expectedRenames: map[string]string{
615 "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
616 "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color",
617 },
618 expectedUnmatchedOld: nil,
619 expectedUnmatchedNew: nil,
620 },
621 }
622
623 for _, tt := range tests {
624 t.Run(tt.name, func(t *testing.T) {
625 renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
626 assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
627 assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
628 assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
629 })
630 }
631}
632
633func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
634 // This test would need to temporarily modify the config path
635 // which is difficult without refactoring. Skip for now.
636 t.Skip("requires config path mocking")
637}
638
639func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
640 // This test would need to temporarily modify the config path
641 // which is difficult without refactoring. Skip for now.
642 t.Skip("requires config path mocking")
643}
644
645func TestMigrator_GetConfigType(t *testing.T) {
646 m := NewMigrator()
647
648 tests := []struct {
649 name string
650 configFile string
651 expected string
652 }{
653 {
654 name: "toml file",
655 configFile: "/path/to/config.toml",
656 expected: "toml",
657 },
658 {
659 name: "yaml file",
660 configFile: "/path/to/config.yaml",
661 expected: "yaml",
662 },
663 {
664 name: "yml file",
665 configFile: "/path/to/config.yml",
666 expected: "yaml",
667 },
668 {
669 name: "json file",
670 configFile: "/path/to/config.json",
671 expected: "json",
672 },
673 {
674 name: "unknown extension defaults to toml",
675 configFile: "/path/to/config.unknown",
676 expected: "toml",
677 },
678 {
679 name: "no extension defaults to toml",
680 configFile: "/path/to/config",
681 expected: "toml",
682 },
683 }
684
685 for _, tt := range tests {
686 t.Run(tt.name, func(t *testing.T) {
687 result := m.getConfigType(tt.configFile)
688 assert.Equal(t, tt.expected, result)
689 })
690 }
691}
692
693func TestMigrator_DetectMissingDefaultActions(t *testing.T) {
694 m := NewMigrator()
695
696 shortcutActions := make(map[string]any, len(m.defaultConfig.Workspace.Shortcuts.Actions))
697 for actionName, value := range m.defaultConfig.Workspace.Shortcuts.Actions {
698 shortcutActions[actionName] = value
699 }
700 delete(shortcutActions, "toggle-floating-pane")
701
702 paneActions := make(map[string]any, len(m.defaultConfig.Workspace.PaneMode.Actions))
703 for actionName, value := range m.defaultConfig.Workspace.PaneMode.Actions {
704 paneActions[actionName] = value
705 }
706 delete(paneActions, "eject-pane-to-window")
707
708 missing := m.detectMissingDefaultActions(map[string]any{
709 "workspace.shortcuts.actions": shortcutActions,
710 "workspace.pane_mode.actions": paneActions,
711 })
712
713 assert.Equal(t, []string{
714 "workspace.pane_mode.actions.eject-pane-to-window",
715 "workspace.shortcuts.actions.toggle-floating-pane",
716 }, missing)
717}
718
719func TestMigrator_MergeMissingDefaultActions(t *testing.T) {
720 m := NewMigrator()
721
722 rawConfig := map[string]any{
723 "workspace": map[string]any{
724 "shortcuts": map[string]any{
725 "actions": map[string]any{
726 "close_pane": map[string]any{
727 "keys": []string{"ctrl+w", "ctrl+i"},
728 "desc": "Custom close pane",
729 },
730 "next_tab": map[string]any{
731 "keys": []string{"ctrl+tab"},
732 "desc": "Switch to next tab",
733 },
734 "previous_tab": map[string]any{
735 "keys": []string{"ctrl+shift+tab"},
736 "desc": "Switch to previous tab",
737 },
738 "consume_or_expel_left": map[string]any{
739 "keys": []string{"alt+["},
740 "desc": "Consume/expel pane left",
741 },
742 "consume_or_expel_right": map[string]any{
743 "keys": []string{"alt+]"},
744 "desc": "Consume/expel pane right",
745 },
746 "consume_or_expel_up": map[string]any{
747 "keys": []string{"alt+{"},
748 "desc": "Consume/expel pane up",
749 },
750 "consume_or_expel_down": map[string]any{
751 "keys": []string{"alt+}"},
752 "desc": "Consume/expel pane down",
753 },
754 },
755 },
756 },
757 }
758
759 m.mergeMissingDefaultActions(rawConfig)
760
761 actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
762 actions, ok := actionsAny.(map[string]any)
763 require.True(t, ok)
764
765 toggleAny, hasToggle := actions["toggle-floating-pane"]
766 assert.True(t, hasToggle)
767 assert.NotNil(t, toggleAny)
768
769 closePaneAny, hasClosePane := actions["close_pane"]
770 require.True(t, hasClosePane)
771 closePane, ok := closePaneAny.(map[string]any)
772 require.True(t, ok)
773 assert.Equal(t, "Custom close pane", closePane["desc"])
774 assert.NotContains(t, actions, "close-pane")
775 assert.NotContains(t, actions, "next-tab")
776 assert.NotContains(t, actions, "previous-tab")
777 assert.NotContains(t, actions, "consume-or-expel-left")
778 assert.NotContains(t, actions, "consume-or-expel-right")
779 assert.NotContains(t, actions, "consume-or-expel-up")
780 assert.NotContains(t, actions, "consume-or-expel-down")
781}
782
783func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
784 m := NewMigrator()
785
786 rawConfig := map[string]any{
787 "workspace": map[string]any{
788 "pane_mode": map[string]any{
789 "actions": map[string]any{
790 "stack-pane": map[string]any{
791 "keys": []string{"s"},
792 "desc": "Stack pane with sibling",
793 },
794 "move-pane-to-tab": map[string]any{
795 "keys": []string{"m"},
796 "desc": "Custom move pane",
797 },
798 },
799 },
800 },
801 }
802
803 m.mergeMissingDefaultActions(rawConfig)
804
805 actionsAny := m.getNestedValue(rawConfig, "workspace.pane_mode.actions")
806 actions, ok := actionsAny.(map[string]any)
807 require.True(t, ok)
808
809 ejectAny, hasEject := actions["eject-pane-to-window"]
810 assert.True(t, hasEject)
811 assert.NotNil(t, ejectAny)
812
813 moveAny, hasMove := actions["move-pane-to-tab"]
814 require.True(t, hasMove)
815 move, ok := moveAny.(map[string]any)
816 require.True(t, ok)
817 assert.Equal(t, "Custom move pane", move["desc"])
818}
819
820func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) {
821 m := NewMigrator()
822
823 rawConfig := map[string]any{
824 "workspace": map[string]any{
825 "vim_mode": map[string]any{
826 "actions": map[string]any{
827 "heading-next": map[string]any{
828 "keys": []string{"]]"},
829 "desc": "Custom next heading",
830 },
831 },
832 },
833 },
834 }
835
836 m.mergeMissingDefaultActions(rawConfig)
837
838 actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions")
839 actions, ok := actionsAny.(map[string]any)
840 require.True(t, ok)
841
842 focusAny, hasFocus := actions["focus-input"]
843 require.True(t, hasFocus)
844 focus, ok := focusAny.(ActionBinding)
845 require.True(t, ok)
846 assert.Equal(t, []string{"gi"}, focus.Keys)
847
848 headingAny, hasHeading := actions["heading-next"]
849 require.True(t, hasHeading)
850 heading, ok := headingAny.(map[string]any)
851 require.True(t, ok)
852 assert.Equal(t, "Custom next heading", heading["desc"])
853}
854
855func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
856 m := NewMigrator()
857
858 value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
859
860 action, ok := value.(ActionBinding)
861 require.True(t, ok)
862 assert.Equal(t, []string{"alt+f"}, action.Keys)
863}
864
865func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
866 m := NewMigrator()
867
868 value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
869
870 action, ok := value.(ActionBinding)
871 require.True(t, ok)
872 assert.Equal(t, []string{"w"}, action.Keys)
873}
874
875func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
876 m := NewMigrator()
877
878 value := m.defaultValueForKey("history.max_entries")
879 assert.Equal(t, 10000, value)
880}
881
882func TestMigrator_GetNestedValue(t *testing.T) {
883 m := NewMigrator()
884
885 config := map[string]any{
886 "level1": map[string]any{
887 "level2": map[string]any{
888 "level3": "deep_value",
889 },
890 "simple": "simple_value",
891 },
892 "top": "top_value",
893 }
894
895 tests := []struct {
896 name string
897 key string
898 expected any
899 }{
900 {
901 name: "top level key",
902 key: "top",
903 expected: "top_value",
904 },
905 {
906 name: "nested key",
907 key: "level1.simple",
908 expected: "simple_value",
909 },
910 {
911 name: "deeply nested key",
912 key: "level1.level2.level3",
913 expected: "deep_value",
914 },
915 {
916 name: "non-existent key",
917 key: "nonexistent",
918 expected: nil,
919 },
920 {
921 name: "non-existent nested key",
922 key: "level1.nonexistent",
923 expected: nil,
924 },
925 }
926
927 for _, tt := range tests {
928 t.Run(tt.name, func(t *testing.T) {
929 result := m.getNestedValue(config, tt.key)
930 assert.Equal(t, tt.expected, result)
931 })
932 }
933}
934
935func TestMigrator_SetNestedValue(t *testing.T) {
936 m := NewMigrator()
937
938 t.Run("set top level value", func(t *testing.T) {
939 config := make(map[string]any)
940 m.setNestedValue(config, "top", "value")
941 assert.Equal(t, "value", config["top"])
942 })
943
944 t.Run("set nested value with existing parent", func(t *testing.T) {
945 config := map[string]any{
946 "parent": map[string]any{},
947 }
948 m.setNestedValue(config, "parent.child", "value")
949 parent := config["parent"].(map[string]any)
950 assert.Equal(t, "value", parent["child"])
951 })
952
953 t.Run("set nested value creates intermediate maps", func(t *testing.T) {
954 config := make(map[string]any)
955 m.setNestedValue(config, "a.b.c", "deep_value")
956
957 a := config["a"].(map[string]any)
958 b := a["b"].(map[string]any)
959 assert.Equal(t, "deep_value", b["c"])
960 })
961}
962
963func TestMigrator_DeleteNestedKey(t *testing.T) {
964 m := NewMigrator()
965
966 t.Run("delete top level key", func(t *testing.T) {
967 config := map[string]any{
968 "keep": "value",
969 "delete": "value",
970 }
971 m.deleteNestedKey(config, "delete")
972 assert.Contains(t, config, "keep")
973 assert.NotContains(t, config, "delete")
974 })
975
976 t.Run("delete nested key", func(t *testing.T) {
977 config := map[string]any{
978 "parent": map[string]any{
979 "keep": "value",
980 "delete": "value",
981 },
982 }
983 m.deleteNestedKey(config, "parent.delete")
984 parent := config["parent"].(map[string]any)
985 assert.Contains(t, parent, "keep")
986 assert.NotContains(t, parent, "delete")
987 })
988
989 t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
990 config := map[string]any{
991 "keep": "value",
992 "parent": map[string]any{
993 "child": map[string]any{
994 "leaf": "value",
995 },
996 },
997 }
998 m.deleteNestedKey(config, "parent.child.leaf")
999 // Both parent and child should be removed since they're empty
1000 assert.NotContains(t, config, "parent")
1001 assert.Contains(t, config, "keep")
1002 })
1003
1004 t.Run("delete non-existent key is no-op", func(t *testing.T) {
1005 config := map[string]any{
1006 "keep": "value",
1007 }
1008 m.deleteNestedKey(config, "nonexistent")
1009 assert.Contains(t, config, "keep")
1010 })
1011}
1012
1013func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(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 require.Contains(t, content, "path = '")
1028 require.Contains(t, content, "[workspace.floating_pane.profiles]")
1029 content = strings.Replace(content, "\n [workspace.floating_pane.profiles]\n", "", 1)
1030 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1031
1032 m := NewMigrator()
1033 firstChanges, err := m.DetectChanges()
1034 require.NoError(t, err)
1035 assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles")
1036
1037 appliedKeys, err := m.Migrate()
1038 require.NoError(t, err)
1039 require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default")
1040
1041 secondChanges, err := m.DetectChanges()
1042 require.NoError(t, err)
1043 assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration")
1044
1045 secondAppliedKeys, err := m.Migrate()
1046 require.NoError(t, err)
1047 assert.Empty(t, secondAppliedKeys, "second migration should be a no-op")
1048}
1049
1050func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) {
1051 t.Helper()
1052
1053 for _, change := range changes {
1054 if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey {
1055 return
1056 }
1057 }
1058 assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes)
1059}
1060
1061// --- Migrate-path popup → browsing_contexts transform tests ---
1062
1063func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
1064 m := NewMigrator()
1065
1066 tmpDir := t.TempDir()
1067 configFile := filepath.Join(tmpDir, "config.toml")
1068
1069 content := `
1070[workspace]
1071new_pane_url = "about:blank"
1072
1073[workspace.popups]
1074behavior = "tabbed"
1075placement = "left"
1076oauth_auto_close = false
1077`
1078 err := os.WriteFile(configFile, []byte(content), 0o644)
1079 require.NoError(t, err)
1080
1081 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1082 require.NoError(t, err)
1083
1084 // Legacy popups keys should NOT appear after transform.
1085 _, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
1086 assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
1087
1088 // Browsing_contexts flattened keys should carry the legacy values.
1089 assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
1090 assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
1091 assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
1092}
1093
1094func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
1095 m := NewMigrator()
1096
1097 tmpDir := t.TempDir()
1098 configFile := filepath.Join(tmpDir, "config.toml")
1099
1100 // Both popups and browsing_contexts present — transform should be a no-op for popups.
1101 content := `
1102[workspace]
1103new_pane_url = "about:blank"
1104
1105[workspace.popups]
1106behavior = "tabbed"
1107
1108[workspace.browsing_contexts]
1109behavior = "split"
1110placement = "right"
1111`
1112 err := os.WriteFile(configFile, []byte(content), 0o644)
1113 require.NoError(t, err)
1114
1115 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1116 require.NoError(t, err)
1117
1118 // Browsing_contexts flattened keys should retain their original values.
1119 assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
1120 assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
1121
1122 // Popups should still exist (transform skipped when browsing_contexts already present).
1123 _, hasPopups := keysWithValues["workspace.popups.behavior"]
1124 assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
1125}
1126
1127func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
1128 m := NewMigrator()
1129
1130 tmpDir := t.TempDir()
1131 configFile := filepath.Join(tmpDir, "config.toml")
1132
1133 content := `
1134[workspace]
1135new_pane_url = "about:blank"
1136
1137[workspace.browsing_contexts]
1138behavior = "stacked"
1139`
1140 err := os.WriteFile(configFile, []byte(content), 0o644)
1141 require.NoError(t, err)
1142
1143 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1144 require.NoError(t, err)
1145
1146 // Should only have browsing_contexts, no popups.
1147 assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1148
1149 _, hasPopups := keysWithValues["workspace.popups.behavior"]
1150 assert.False(t, hasPopups, "popups should not exist when absent from config")
1151}
1152
1153func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1154 home := t.TempDir()
1155 t.Setenv("HOME", home)
1156 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1157 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1158 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1159 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1160
1161 configFile, err := GetConfigFile()
1162 require.NoError(t, err)
1163 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1164
1165 content, err := encodeConfigToTOML(DefaultConfig())
1166 require.NoError(t, err)
1167 content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1168 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1169
1170 m := NewMigrator()
1171 changes, err := m.DetectChanges()
1172 require.NoError(t, err)
1173
1174 found := false
1175 for _, change := range changes {
1176 if change.Type == port.KeyChangeRenamed &&
1177 change.OldKey == "workspace.popups.behavior" &&
1178 change.NewKey == "workspace.browsing_contexts.behavior" {
1179 found = true
1180 break
1181 }
1182 }
1183 assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1184}
1185
1186func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1187 home := t.TempDir()
1188 t.Setenv("HOME", home)
1189 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1190 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1191 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1192 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1193
1194 configFile, err := GetConfigFile()
1195 require.NoError(t, err)
1196 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1197
1198 content, err := encodeConfigToTOML(DefaultConfig())
1199 require.NoError(t, err)
1200 content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1201 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1202
1203 m := NewMigrator()
1204 appliedKeys, err := m.Migrate()
1205 require.NoError(t, err)
1206 assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1207
1208 migrated, err := os.ReadFile(configFile)
1209 require.NoError(t, err)
1210 assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1211 assert.NotContains(t, string(migrated), "[workspace.popups]")
1212}
1213
1214func TestMigrator_Migrate_RenamesLegacyPageModeToVimMode(t *testing.T) {
1215 home := t.TempDir()
1216 t.Setenv("HOME", home)
1217 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1218 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1219 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1220 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1221
1222 configFile, err := GetConfigFile()
1223 require.NoError(t, err)
1224 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1225 require.NoError(t, os.WriteFile(configFile, []byte(`
1226[workspace.page_mode]
1227activation_shortcut = "ctrl+alt+y"
1228timeout_ms = 250
1229
1230[workspace.page_mode.actions.page-scroll-down]
1231keys = ["n"]
1232desc = "Scroll down"
1233
1234[workspace.page_mode.actions.page_scroll_up]
1235keys = ["u"]
1236desc = "Scroll up"
1237
1238[workspace.page_mode.actions.page_scroll_down_fast]
1239keys = ["d"]
1240desc = "Scroll down fast"
1241`), 0o644))
1242
1243 migrator := NewMigrator()
1244 applied, err := migrator.Migrate()
1245 require.NoError(t, err)
1246 assert.Contains(t, applied, "workspace.page_mode.activation_shortcut -> workspace.vim_mode.activation_shortcut")
1247 assert.Contains(t, applied, "workspace.page_mode.timeout_ms -> workspace.vim_mode.timeout_ms")
1248 assert.Contains(t, applied, "workspace.page_mode.actions -> workspace.vim_mode.actions")
1249
1250 migrated, err := os.ReadFile(configFile)
1251 require.NoError(t, err)
1252 assert.Contains(t, string(migrated), "[workspace.vim_mode]")
1253 assert.Contains(t, string(migrated), "activation_shortcut = 'ctrl+alt+y'")
1254 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]")
1255 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]")
1256 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down-fast]")
1257 assert.Contains(t, string(migrated), "keys = ['u']")
1258 assert.NotContains(t, string(migrated), "page_mode")
1259 assert.NotContains(t, string(migrated), "page-scroll-down")
1260}
1261
1262func TestMigrator_DetectChanges_CanonicalizesLegacyVimActionNames(t *testing.T) {
1263 home := t.TempDir()
1264 t.Setenv("HOME", home)
1265 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1266 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1267 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1268 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1269
1270 configFile, err := GetConfigFile()
1271 require.NoError(t, err)
1272 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1273 require.NoError(t, os.WriteFile(configFile, []byte(`
1274[workspace.page_mode.actions.page_scroll_down_fast]
1275keys = ["d"]
1276desc = "Scroll down fast"
1277`), 0o644))
1278
1279 changes, err := NewMigrator().DetectChanges()
1280 require.NoError(t, err)
1281
1282 foundRename := false
1283 for _, change := range changes {
1284 if change.Type == port.KeyChangeRenamed &&
1285 change.OldKey == "workspace.page_mode.actions" &&
1286 change.NewKey == "workspace.vim_mode.actions" {
1287 foundRename = true
1288 }
1289 }
1290 assert.True(t, foundRename, "DetectChanges must report the canonical Vim Mode actions key")
1291 assert.Equal(t, "vim-scroll-down-fast", legacyVimActionName("page_scroll_down_fast"))
1292}
1293
1294func TestMigrator_Migrate_MergesLegacyPageModeActionsIntoExistingVimMode(t *testing.T) {
1295 home := t.TempDir()
1296 t.Setenv("HOME", home)
1297 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1298 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1299 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1300 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1301
1302 configFile, err := GetConfigFile()
1303 require.NoError(t, err)
1304 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1305 require.NoError(t, os.WriteFile(configFile, []byte(`
1306[workspace.page_mode.actions.page-scroll-down]
1307keys = ["n"]
1308
1309[workspace.page_mode.actions.page-scroll-up]
1310keys = ["u"]
1311
1312[workspace.vim_mode.actions.vim-scroll-down]
1313keys = ["j"]
1314`), 0o644))
1315
1316 _, err = NewMigrator().Migrate()
1317 require.NoError(t, err)
1318
1319 migrated, err := os.ReadFile(configFile)
1320 require.NoError(t, err)
1321 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]\n keys = ['j']")
1322 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]\n keys = ['u']")
1323 assert.NotContains(t, string(migrated), "page_mode")
1324}
1325
1326func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1327 home := t.TempDir()
1328 t.Setenv("HOME", home)
1329 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1330 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1331 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1332 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1333
1334 configFile, err := GetConfigFile()
1335 require.NoError(t, err)
1336 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1337 require.NoError(t, os.WriteFile(configFile, []byte(`
1338[workspace.popups]
1339placement = "left"
1340
1341[workspace.browsing_contexts]
1342behavior = "tabbed"
1343`), 0o644))
1344
1345 m := NewMigrator()
1346 changes, err := m.DetectChanges()
1347 require.NoError(t, err)
1348
1349 renameCount := 0
1350 for _, change := range changes {
1351 if change.Type == port.KeyChangeRenamed &&
1352 change.OldKey == "workspace.popups.placement" &&
1353 change.NewKey == "workspace.browsing_contexts.placement" {
1354 renameCount++
1355 }
1356 assert.False(t,
1357 change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1358 "legacy popup placement should be reported as a rename, not also a removal",
1359 )
1360 assert.False(t,
1361 change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1362 "canonical browsing_contexts placement should be reported as a rename, not also an addition",
1363 )
1364 }
1365 assert.Equal(t, 1, renameCount)
1366}
1367
1368func TestMigrator_ReadRawConfig(t *testing.T) {
1369 m := NewMigrator()
1370
1371 t.Run("read TOML config", func(t *testing.T) {
1372 tmpDir := t.TempDir()
1373 configFile := filepath.Join(tmpDir, "config.toml")
1374
1375 content := `
1376[section]
1377key = "value"
1378number = 42
1379`
1380 err := os.WriteFile(configFile, []byte(content), 0o644)
1381 require.NoError(t, err)
1382
1383 config, err := m.readRawConfig(configFile)
1384 require.NoError(t, err)
1385
1386 section := config["section"].(map[string]any)
1387 assert.Equal(t, "value", section["key"])
1388 assert.Equal(t, int64(42), section["number"])
1389 })
1390
1391 t.Run("read YAML config", func(t *testing.T) {
1392 tmpDir := t.TempDir()
1393 configFile := filepath.Join(tmpDir, "config.yaml")
1394
1395 content := `
1396section:
1397 key: value
1398 number: 42
1399`
1400 err := os.WriteFile(configFile, []byte(content), 0o644)
1401 require.NoError(t, err)
1402
1403 config, err := m.readRawConfig(configFile)
1404 require.NoError(t, err)
1405
1406 section := config["section"].(map[string]any)
1407 assert.Equal(t, "value", section["key"])
1408 assert.Equal(t, 42, section["number"])
1409 })
1410
1411 t.Run("read JSON config", func(t *testing.T) {
1412 tmpDir := t.TempDir()
1413 configFile := filepath.Join(tmpDir, "config.json")
1414
1415 content := `{
1416 "section": {
1417 "key": "value",
1418 "number": 42
1419 }
1420}`
1421 err := os.WriteFile(configFile, []byte(content), 0o644)
1422 require.NoError(t, err)
1423
1424 config, err := m.readRawConfig(configFile)
1425 require.NoError(t, err)
1426
1427 section := config["section"].(map[string]any)
1428 assert.Equal(t, "value", section["key"])
1429 // JSON numbers are float64
1430 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1431 })
1432}
1433
1434func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1435 home := t.TempDir()
1436 t.Setenv("HOME", home)
1437 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1438 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1439 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1440 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1441
1442 configFile, err := GetConfigFile()
1443 require.NoError(t, err)
1444 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1445 require.NoError(t, os.WriteFile(configFile, []byte(`
1446[workspace.shortcuts.actions.toggle-favorites-systemview]
1447keys = []
1448desc = "Custom favorites shortcut"
1449`), 0o644))
1450
1451 migrator := NewMigrator()
1452 result, err := migrator.CheckMigration()
1453 require.NoError(t, err)
1454 require.NotNil(t, result)
1455 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1456
1457 changes, err := migrator.DetectChanges()
1458 require.NoError(t, err)
1459 assertContainsChange(t, changes, port.KeyChangeRenamed,
1460 "workspace.shortcuts.actions.toggle-favorites-systemview",
1461 "workspace.shortcuts.actions.toggle-favorites-sidebar")
1462
1463 applied, err := migrator.Migrate()
1464 require.NoError(t, err)
1465 assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1466
1467 mgr, err := NewManager()
1468 require.NoError(t, err)
1469 require.NoError(t, mgr.Load())
1470 _, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1471 assert.False(t, ok)
1472 favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1473 assert.Empty(t, favorites.Keys)
1474 assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1475}
1476
1477func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1478 home := t.TempDir()
1479 t.Setenv("HOME", home)
1480 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1481 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1482 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1483 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1484
1485 configFile, err := GetConfigFile()
1486 require.NoError(t, err)
1487 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1488 require.NoError(t, os.WriteFile(configFile, []byte(`
1489[workspace.shortcuts.actions.toggle-favorites-systemview]
1490keys = []
1491desc = "Toggle Favorites in right split"
1492`), 0o644))
1493
1494 migrator := NewMigrator()
1495 result, err := migrator.CheckMigration()
1496 require.NoError(t, err)
1497 require.NotNil(t, result)
1498 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1499 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1500
1501 changes, err := migrator.DetectChanges()
1502 require.NoError(t, err)
1503 assertContainsChange(t, changes, port.KeyChangeRenamed,
1504 "workspace.shortcuts.actions.toggle-favorites-systemview",
1505 "workspace.shortcuts.actions.toggle-favorites-sidebar")
1506 assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1507
1508 applied, err := migrator.Migrate()
1509 require.NoError(t, err)
1510 assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1511 assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1512
1513 raw, err := migrator.readRawConfig(configFile)
1514 require.NoError(t, err)
1515 workspace, ok := raw["workspace"].(map[string]any)
1516 require.True(t, ok)
1517 shortcuts, ok := workspace["shortcuts"].(map[string]any)
1518 require.True(t, ok)
1519 actions, ok := shortcuts["actions"].(map[string]any)
1520 require.True(t, ok)
1521
1522 expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1523 _, ok = actions["toggle-favorites-systemview"]
1524 assert.False(t, ok)
1525 favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1526 require.True(t, ok)
1527 assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1528 assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1529 currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1530 require.True(t, ok)
1531 assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1532 assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1533
1534 mgr, err := NewManager()
1535 require.NoError(t, err)
1536 require.NoError(t, mgr.Load())
1537 cfg := mgr.Get()
1538 require.NotNil(t, cfg)
1539 _, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1540 assert.False(t, ok)
1541 assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1542 assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1543}
1544
1545func writeMigratorUserConfig(t *testing.T, content string) string {
1546 t.Helper()
1547 home := t.TempDir()
1548 t.Setenv("HOME", home)
1549 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1550 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1551 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1552 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1553
1554 configFile, err := GetConfigFile()
1555 require.NoError(t, err)
1556 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1557 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1558 return configFile
1559}
1560
1561func TestMigrator_DetectChanges_ReportsLegacyOmniboxMaxHistoryDaysRename(t *testing.T) {
1562 writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = 0\n")
1563
1564 changes, err := NewMigrator().DetectChanges()
1565 require.NoError(t, err)
1566
1567 found := false
1568 for _, change := range changes {
1569 if change.Type == port.KeyChangeRenamed &&
1570 change.OldKey == "omnibox.most_visited_days" &&
1571 change.NewKey == "omnibox.max_history_days" {
1572 found = true
1573 }
1574 if change.OldKey == "omnibox.most_visited_days" && change.Type == port.KeyChangeRemoved {
1575 t.Fatal("legacy key must be reported as a rename, not a removal")
1576 }
1577 if change.NewKey == "omnibox.max_history_days" && change.Type == port.KeyChangeAdded {
1578 t.Fatal("migrated key must be reported as a rename, not an addition")
1579 }
1580 }
1581 assert.True(t, found, "expected legacy omnibox.most_visited_days rename to be reported")
1582}
1583
1584func TestMigrator_Migrate_RenamesLegacyOmniboxMaxHistoryDaysPreservingValue(t *testing.T) {
1585 tests := []struct {
1586 name string
1587 value string
1588 }{
1589 {name: "zero is preserved", value: "0"},
1590 {name: "custom value is preserved", value: "7"},
1591 }
1592
1593 for _, tt := range tests {
1594 t.Run(tt.name, func(t *testing.T) {
1595 configFile := writeMigratorUserConfig(t, "[omnibox]\nmost_visited_days = "+tt.value+"\n")
1596
1597 applied, err := NewMigrator().Migrate()
1598 require.NoError(t, err)
1599 assert.Contains(t, applied, "omnibox.most_visited_days -> omnibox.max_history_days")
1600
1601 migrated, err := os.ReadFile(configFile)
1602 require.NoError(t, err)
1603 assert.Contains(t, string(migrated), "max_history_days = "+tt.value)
1604 assert.NotContains(t, string(migrated), "most_visited_days")
1605 })
1606 }
1607}
1608
1609func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.T) {
1610 configFile := writeMigratorUserConfig(t, "[omnibox]\nmax_history_days = 7\nmost_visited_days = 99\n")
1611
1612 _, err := NewMigrator().Migrate()
1613 require.NoError(t, err)
1614
1615 migrated, err := os.ReadFile(configFile)
1616 require.NoError(t, err)
1617 assert.Contains(t, string(migrated), "max_history_days = 7")
1618 assert.NotContains(t, string(migrated), "most_visited_days")
1619}
1620
1621func TestMigrator_Migrate_AddsKeymapPresetAndBuiltinShortcuts(t *testing.T) {
1622 home := t.TempDir()
1623 t.Setenv("HOME", home)
1624 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1625 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1626 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1627 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1628
1629 configFile, err := GetConfigFile()
1630 require.NoError(t, err)
1631 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1632 // A config written before keymap presets: no keymap, no prefix_mode, and a
1633 // shortcuts table without the configurable browser actions.
1634 require.NoError(t, os.WriteFile(configFile, []byte(`
1635[workspace.shortcuts.actions.quit]
1636keys = ["ctrl+shift+q"]
1637desc = "Custom quit"
1638
1639[workspace.shortcuts.actions.toggle-floating-pane]
1640keys = ["alt+f"]
1641desc = "Toggle floating pane"
1642`), 0o644))
1643
1644 m := NewMigrator()
1645 result, err := m.CheckMigration()
1646 require.NoError(t, err)
1647 require.NotNil(t, result)
1648 assert.Contains(t, result.MissingKeys, "workspace.keymap")
1649 assert.Contains(t, result.MissingKeys, "workspace.prefix_mode.activation_shortcut")
1650 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.open-omnibox")
1651
1652 _, err = m.Migrate()
1653 require.NoError(t, err)
1654
1655 mgr, err := NewManager()
1656 require.NoError(t, err)
1657 require.NoError(t, mgr.Load())
1658 cfg := mgr.Get()
1659
1660 assert.Equal(t, KeymapZellij, cfg.Workspace.Keymap)
1661 assert.Equal(t, defaultPrefixActivationShortcut, cfg.Workspace.PrefixMode.ActivationShortcut)
1662 assert.Equal(t, DefaultConfig().Workspace.PrefixMode.Actions, cfg.Workspace.PrefixMode.Actions)
1663 assert.Equal(t, []string{"ctrl+l"}, cfg.Workspace.Shortcuts.Actions["open-omnibox"].Keys)
1664 assert.Equal(t, []string{"ctrl+shift+q"}, cfg.Workspace.Shortcuts.Actions["quit"].Keys, "user override kept")
1665
1666 again, err := m.CheckMigration()
1667 require.NoError(t, err)
1668 assert.Nil(t, again, "migration is idempotent")
1669}