v0.28.0 3cb3770

migrate_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close