detached ffd31dd

feat(config): add Migrator to detect and add missing config keys

commit
ffd31ddaea54ea683416486035ec8266594036ea
author
bnema · · 2025-12-27 18:48 UTC
parent
dfe0556
signature
GPG Unverified2B82207D370127A0 The key is unknown
2 files · +677 -0
A internal/infrastructure/config/migrate.go +330 -0
@@ -0,0 +1,330 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "reflect"
7+ "sort"
8+ "strings"
9+
10+ "github.com/pelletier/go-toml/v2"
11+ "github.com/spf13/viper"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+// Migrator implements port.ConfigMigrator for comparing and merging config files.
17+type Migrator struct {
18+ // defaultViper holds a Viper instance with all defaults set.
19+ defaultViper *viper.Viper
20+ // defaultConfig holds the default configuration.
21+ defaultConfig *Config
22+}
23+
24+// NewMigrator creates a new Migrator instance.
25+func NewMigrator() *Migrator {
26+ v := viper.New()
27+ v.SetConfigType("toml")
28+
29+ // Create a temporary manager to set defaults
30+ m := &Manager{viper: v}
31+ m.setDefaults()
32+
33+ return &Migrator{
34+ defaultViper: v,
35+ defaultConfig: DefaultConfig(),
36+ }
37+}
38+
39+// CheckMigration checks if user config is missing any default keys.
40+// Returns nil if no migration is needed.
41+func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
42+ configFile, err := GetConfigFile()
43+ if err != nil {
44+ return nil, fmt.Errorf("failed to get config file path: %w", err)
45+ }
46+
47+ // If config file doesn't exist, no migration needed (will be created with all defaults)
48+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
49+ return nil, nil
50+ }
51+
52+ // Get user-defined keys from the TOML file
53+ userKeys, err := m.getUserConfigKeys(configFile)
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to parse user config: %w", err)
56+ }
57+
58+ // Get all default keys
59+ defaultKeys := m.getAllDefaultKeys()
60+
61+ // Find missing keys (in defaults but not in user config)
62+ missingKeys := m.findMissingKeys(defaultKeys, userKeys)
63+
64+ if len(missingKeys) == 0 {
65+ return nil, nil
66+ }
67+
68+ return &port.MigrationResult{
69+ MissingKeys: missingKeys,
70+ ConfigFile: configFile,
71+ }, nil
72+}
73+
74+// Migrate adds missing default keys to the user's config file.
75+func (m *Migrator) Migrate() ([]string, error) {
76+ result, err := m.CheckMigration()
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ if result == nil || len(result.MissingKeys) == 0 {
82+ return nil, nil
83+ }
84+
85+ // Create a new Viper instance to read user config
86+ userViper := viper.New()
87+ userViper.SetConfigFile(result.ConfigFile)
88+ userViper.SetConfigType("toml")
89+
90+ // Set all defaults first
91+ mgr := &Manager{viper: userViper}
92+ mgr.setDefaults()
93+
94+ // Read existing config (this merges with defaults)
95+ if err := userViper.ReadInConfig(); err != nil {
96+ return nil, fmt.Errorf("failed to read config file: %w", err)
97+ }
98+
99+ // Write the merged config back
100+ if err := userViper.WriteConfig(); err != nil {
101+ return nil, fmt.Errorf("failed to write config file: %w", err)
102+ }
103+
104+ return result.MissingKeys, nil
105+}
106+
107+// GetKeyInfo returns detailed information about a config key.
108+func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
109+ value := m.defaultViper.Get(key)
110+ if value == nil {
111+ return port.KeyInfo{
112+ Key: key,
113+ Type: "unknown",
114+ DefaultValue: "unknown",
115+ }
116+ }
117+
118+ return port.KeyInfo{
119+ Key: key,
120+ Type: m.getTypeName(value),
121+ DefaultValue: m.formatValue(value),
122+ }
123+}
124+
125+// getAllDefaultKeys returns all keys from the default configuration.
126+func (m *Migrator) getAllDefaultKeys() []string {
127+ keys := m.defaultViper.AllKeys()
128+
129+ // Filter out keys that should not be migrated
130+ filtered := make([]string, 0, len(keys))
131+ for _, key := range keys {
132+ // Skip database.path as it's set dynamically
133+ if key == "database.path" {
134+ continue
135+ }
136+ filtered = append(filtered, key)
137+ }
138+
139+ sort.Strings(filtered)
140+ return filtered
141+}
142+
143+// getUserConfigKeys parses the user's TOML file and returns all defined keys.
144+func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
145+ data, err := os.ReadFile(configFile)
146+ if err != nil {
147+ return nil, fmt.Errorf("failed to read config file: %w", err)
148+ }
149+
150+ // Parse TOML into a generic map
151+ var rawConfig map[string]any
152+ if err := toml.Unmarshal(data, &rawConfig); err != nil {
153+ return nil, fmt.Errorf("failed to parse TOML: %w", err)
154+ }
155+
156+ // Flatten the map to dot-notation keys
157+ keys := make(map[string]bool)
158+ m.flattenMap(rawConfig, "", keys)
159+
160+ return keys, nil
161+}
162+
163+// flattenMap recursively flattens a nested map to dot-notation keys.
164+func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
165+ for k, v := range data {
166+ key := k
167+ if prefix != "" {
168+ key = prefix + "." + k
169+ }
170+
171+ switch val := v.(type) {
172+ case map[string]any:
173+ // Check if this key represents user data (like search_shortcuts, actions)
174+ // that should be treated as a single key rather than recursed into.
175+ if m.isUserDataSection(key) {
176+ keys[key] = true
177+ } else {
178+ // Recurse into nested config sections
179+ m.flattenMap(val, key, keys)
180+ }
181+ default:
182+ keys[key] = true
183+ }
184+ }
185+}
186+
187+// isUserDataSection checks if a key path represents user-defined data
188+// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
189+func (*Migrator) isUserDataSection(keyPath string) bool {
190+ // User data sections - treat entire section as one key
191+ switch keyPath {
192+ case "search_shortcuts":
193+ return true
194+ }
195+
196+ // Action maps are user-customizable
197+ userDataSuffixes := []string{
198+ ".actions", // pane_mode.actions, tab_mode.actions, etc.
199+ }
200+
201+ for _, suffix := range userDataSuffixes {
202+ if len(keyPath) > len(suffix) && keyPath[len(keyPath)-len(suffix):] == suffix {
203+ return true
204+ }
205+ }
206+
207+ return false
208+}
209+
210+// findMissingKeys returns keys that are in defaults but not in user config.
211+func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
212+ missing := make([]string, 0)
213+
214+ for _, key := range defaultKeys {
215+ // Check if key or any parent key exists in user config
216+ if m.keyOrParentExists(key, userKeys) {
217+ continue
218+ }
219+ missing = append(missing, key)
220+ }
221+
222+ // Sort for consistent output
223+ sort.Strings(missing)
224+ return missing
225+}
226+
227+// keyOrParentExists checks if a key or any of its parent keys exist in the map.
228+// This handles cases where a user has defined a parent section but not all children.
229+func (*Migrator) keyOrParentExists(key string, keys map[string]bool) bool {
230+ if keys[key] {
231+ return true
232+ }
233+
234+ // Check if this is a child of a leaf map (like search_shortcuts.ddg)
235+ // In that case, we should check if the parent exists
236+ parts := strings.Split(key, ".")
237+ for i := len(parts) - 1; i > 0; i-- {
238+ parentKey := strings.Join(parts[:i], ".")
239+ if keys[parentKey] {
240+ // Parent exists as a leaf map, so child is implicitly defined
241+ return true
242+ }
243+ }
244+
245+ return false
246+}
247+
248+// getTypeName returns a human-readable type name for a value.
249+func (*Migrator) getTypeName(value any) string {
250+ if value == nil {
251+ return "unknown"
252+ }
253+
254+ t := reflect.TypeOf(value)
255+ switch t.Kind() {
256+ case reflect.Bool:
257+ return "bool"
258+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
259+ return "int"
260+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
261+ return "int"
262+ case reflect.Float32, reflect.Float64:
263+ return "float"
264+ case reflect.String:
265+ return "string"
266+ case reflect.Slice:
267+ return "list"
268+ case reflect.Map:
269+ return "map"
270+ default:
271+ return t.String()
272+ }
273+}
274+
275+// formatValue returns a human-readable string representation of a value.
276+func (*Migrator) formatValue(value any) string {
277+ if value == nil {
278+ return "null"
279+ }
280+
281+ switch v := value.(type) {
282+ case bool:
283+ if v {
284+ return "true"
285+ }
286+ return "false"
287+ case int, int8, int16, int32, int64:
288+ return fmt.Sprintf("%d", v)
289+ case uint, uint8, uint16, uint32, uint64:
290+ return fmt.Sprintf("%d", v)
291+ case float32, float64:
292+ return fmt.Sprintf("%v", v)
293+ case string:
294+ if v == "" {
295+ return `""`
296+ }
297+ // Truncate long strings
298+ const maxStringLen = 50
299+ if len(v) > maxStringLen {
300+ return fmt.Sprintf("%q...", v[:maxStringLen-3])
301+ }
302+ return fmt.Sprintf("%q", v)
303+ case []any:
304+ if len(v) == 0 {
305+ return "[]"
306+ }
307+ return fmt.Sprintf("[%d items]", len(v))
308+ case map[string]any:
309+ if len(v) == 0 {
310+ return "{}"
311+ }
312+ return fmt.Sprintf("{%d entries}", len(v))
313+ default:
314+ // Handle slices of strings (common for action bindings)
315+ rv := reflect.ValueOf(value)
316+ if rv.Kind() == reflect.Slice {
317+ if rv.Len() == 0 {
318+ return "[]"
319+ }
320+ return fmt.Sprintf("[%d items]", rv.Len())
321+ }
322+ if rv.Kind() == reflect.Map {
323+ if rv.Len() == 0 {
324+ return "{}"
325+ }
326+ return fmt.Sprintf("{%d entries}", rv.Len())
327+ }
328+ return fmt.Sprintf("%v", v)
329+ }
330+}
@@ -0,0 +1,330 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "reflect"
7+ "sort"
8+ "strings"
9+
10+ "github.com/pelletier/go-toml/v2"
11+ "github.com/spf13/viper"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+// Migrator implements port.ConfigMigrator for comparing and merging config files.
17+type Migrator struct {
18+ // defaultViper holds a Viper instance with all defaults set.
19+ defaultViper *viper.Viper
20+ // defaultConfig holds the default configuration.
21+ defaultConfig *Config
22+}
23+
24+// NewMigrator creates a new Migrator instance.
25+func NewMigrator() *Migrator {
26+ v := viper.New()
27+ v.SetConfigType("toml")
28+
29+ // Create a temporary manager to set defaults
30+ m := &Manager{viper: v}
31+ m.setDefaults()
32+
33+ return &Migrator{
34+ defaultViper: v,
35+ defaultConfig: DefaultConfig(),
36+ }
37+}
38+
39+// CheckMigration checks if user config is missing any default keys.
40+// Returns nil if no migration is needed.
41+func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
42+ configFile, err := GetConfigFile()
43+ if err != nil {
44+ return nil, fmt.Errorf("failed to get config file path: %w", err)
45+ }
46+
47+ // If config file doesn't exist, no migration needed (will be created with all defaults)
48+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
49+ return nil, nil
50+ }
51+
52+ // Get user-defined keys from the TOML file
53+ userKeys, err := m.getUserConfigKeys(configFile)
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to parse user config: %w", err)
56+ }
57+
58+ // Get all default keys
59+ defaultKeys := m.getAllDefaultKeys()
60+
61+ // Find missing keys (in defaults but not in user config)
62+ missingKeys := m.findMissingKeys(defaultKeys, userKeys)
63+
64+ if len(missingKeys) == 0 {
65+ return nil, nil
66+ }
67+
68+ return &port.MigrationResult{
69+ MissingKeys: missingKeys,
70+ ConfigFile: configFile,
71+ }, nil
72+}
73+
74+// Migrate adds missing default keys to the user's config file.
75+func (m *Migrator) Migrate() ([]string, error) {
76+ result, err := m.CheckMigration()
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ if result == nil || len(result.MissingKeys) == 0 {
82+ return nil, nil
83+ }
84+
85+ // Create a new Viper instance to read user config
86+ userViper := viper.New()
87+ userViper.SetConfigFile(result.ConfigFile)
88+ userViper.SetConfigType("toml")
89+
90+ // Set all defaults first
91+ mgr := &Manager{viper: userViper}
92+ mgr.setDefaults()
93+
94+ // Read existing config (this merges with defaults)
95+ if err := userViper.ReadInConfig(); err != nil {
96+ return nil, fmt.Errorf("failed to read config file: %w", err)
97+ }
98+
99+ // Write the merged config back
100+ if err := userViper.WriteConfig(); err != nil {
101+ return nil, fmt.Errorf("failed to write config file: %w", err)
102+ }
103+
104+ return result.MissingKeys, nil
105+}
106+
107+// GetKeyInfo returns detailed information about a config key.
108+func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
109+ value := m.defaultViper.Get(key)
110+ if value == nil {
111+ return port.KeyInfo{
112+ Key: key,
113+ Type: "unknown",
114+ DefaultValue: "unknown",
115+ }
116+ }
117+
118+ return port.KeyInfo{
119+ Key: key,
120+ Type: m.getTypeName(value),
121+ DefaultValue: m.formatValue(value),
122+ }
123+}
124+
125+// getAllDefaultKeys returns all keys from the default configuration.
126+func (m *Migrator) getAllDefaultKeys() []string {
127+ keys := m.defaultViper.AllKeys()
128+
129+ // Filter out keys that should not be migrated
130+ filtered := make([]string, 0, len(keys))
131+ for _, key := range keys {
132+ // Skip database.path as it's set dynamically
133+ if key == "database.path" {
134+ continue
135+ }
136+ filtered = append(filtered, key)
137+ }
138+
139+ sort.Strings(filtered)
140+ return filtered
141+}
142+
143+// getUserConfigKeys parses the user's TOML file and returns all defined keys.
144+func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
145+ data, err := os.ReadFile(configFile)
146+ if err != nil {
147+ return nil, fmt.Errorf("failed to read config file: %w", err)
148+ }
149+
150+ // Parse TOML into a generic map
151+ var rawConfig map[string]any
152+ if err := toml.Unmarshal(data, &rawConfig); err != nil {
153+ return nil, fmt.Errorf("failed to parse TOML: %w", err)
154+ }
155+
156+ // Flatten the map to dot-notation keys
157+ keys := make(map[string]bool)
158+ m.flattenMap(rawConfig, "", keys)
159+
160+ return keys, nil
161+}
162+
163+// flattenMap recursively flattens a nested map to dot-notation keys.
164+func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
165+ for k, v := range data {
166+ key := k
167+ if prefix != "" {
168+ key = prefix + "." + k
169+ }
170+
171+ switch val := v.(type) {
172+ case map[string]any:
173+ // Check if this key represents user data (like search_shortcuts, actions)
174+ // that should be treated as a single key rather than recursed into.
175+ if m.isUserDataSection(key) {
176+ keys[key] = true
177+ } else {
178+ // Recurse into nested config sections
179+ m.flattenMap(val, key, keys)
180+ }
181+ default:
182+ keys[key] = true
183+ }
184+ }
185+}
186+
187+// isUserDataSection checks if a key path represents user-defined data
188+// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
189+func (*Migrator) isUserDataSection(keyPath string) bool {
190+ // User data sections - treat entire section as one key
191+ switch keyPath {
192+ case "search_shortcuts":
193+ return true
194+ }
195+
196+ // Action maps are user-customizable
197+ userDataSuffixes := []string{
198+ ".actions", // pane_mode.actions, tab_mode.actions, etc.
199+ }
200+
201+ for _, suffix := range userDataSuffixes {
202+ if len(keyPath) > len(suffix) && keyPath[len(keyPath)-len(suffix):] == suffix {
203+ return true
204+ }
205+ }
206+
207+ return false
208+}
209+
210+// findMissingKeys returns keys that are in defaults but not in user config.
211+func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
212+ missing := make([]string, 0)
213+
214+ for _, key := range defaultKeys {
215+ // Check if key or any parent key exists in user config
216+ if m.keyOrParentExists(key, userKeys) {
217+ continue
218+ }
219+ missing = append(missing, key)
220+ }
221+
222+ // Sort for consistent output
223+ sort.Strings(missing)
224+ return missing
225+}
226+
227+// keyOrParentExists checks if a key or any of its parent keys exist in the map.
228+// This handles cases where a user has defined a parent section but not all children.
229+func (*Migrator) keyOrParentExists(key string, keys map[string]bool) bool {
230+ if keys[key] {
231+ return true
232+ }
233+
234+ // Check if this is a child of a leaf map (like search_shortcuts.ddg)
235+ // In that case, we should check if the parent exists
236+ parts := strings.Split(key, ".")
237+ for i := len(parts) - 1; i > 0; i-- {
238+ parentKey := strings.Join(parts[:i], ".")
239+ if keys[parentKey] {
240+ // Parent exists as a leaf map, so child is implicitly defined
241+ return true
242+ }
243+ }
244+
245+ return false
246+}
247+
248+// getTypeName returns a human-readable type name for a value.
249+func (*Migrator) getTypeName(value any) string {
250+ if value == nil {
251+ return "unknown"
252+ }
253+
254+ t := reflect.TypeOf(value)
255+ switch t.Kind() {
256+ case reflect.Bool:
257+ return "bool"
258+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
259+ return "int"
260+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
261+ return "int"
262+ case reflect.Float32, reflect.Float64:
263+ return "float"
264+ case reflect.String:
265+ return "string"
266+ case reflect.Slice:
267+ return "list"
268+ case reflect.Map:
269+ return "map"
270+ default:
271+ return t.String()
272+ }
273+}
274+
275+// formatValue returns a human-readable string representation of a value.
276+func (*Migrator) formatValue(value any) string {
277+ if value == nil {
278+ return "null"
279+ }
280+
281+ switch v := value.(type) {
282+ case bool:
283+ if v {
284+ return "true"
285+ }
286+ return "false"
287+ case int, int8, int16, int32, int64:
288+ return fmt.Sprintf("%d", v)
289+ case uint, uint8, uint16, uint32, uint64:
290+ return fmt.Sprintf("%d", v)
291+ case float32, float64:
292+ return fmt.Sprintf("%v", v)
293+ case string:
294+ if v == "" {
295+ return `""`
296+ }
297+ // Truncate long strings
298+ const maxStringLen = 50
299+ if len(v) > maxStringLen {
300+ return fmt.Sprintf("%q...", v[:maxStringLen-3])
301+ }
302+ return fmt.Sprintf("%q", v)
303+ case []any:
304+ if len(v) == 0 {
305+ return "[]"
306+ }
307+ return fmt.Sprintf("[%d items]", len(v))
308+ case map[string]any:
309+ if len(v) == 0 {
310+ return "{}"
311+ }
312+ return fmt.Sprintf("{%d entries}", len(v))
313+ default:
314+ // Handle slices of strings (common for action bindings)
315+ rv := reflect.ValueOf(value)
316+ if rv.Kind() == reflect.Slice {
317+ if rv.Len() == 0 {
318+ return "[]"
319+ }
320+ return fmt.Sprintf("[%d items]", rv.Len())
321+ }
322+ if rv.Kind() == reflect.Map {
323+ if rv.Len() == 0 {
324+ return "{}"
325+ }
326+ return fmt.Sprintf("{%d entries}", rv.Len())
327+ }
328+ return fmt.Sprintf("%v", v)
329+ }
330+}
A internal/infrastructure/config/migrate_test.go +347 -0
@@ -0,0 +1,347 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func 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+
19+func 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+
42+func 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]
51+max_entries = 5000
52+retention_period_days = 180
53+
54+[logging]
55+level = "debug"
56+
57+[appearance]
58+sans_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+
77+func 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+
151+func 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+
199+func 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+
234+ for _, tt := range tests {
235+ t.Run(tt.name, func(t *testing.T) {
236+ result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
237+ assert.ElementsMatch(t, tt.expected, result)
238+ })
239+ }
240+}
241+
242+func TestMigrator_GetKeyInfo(t *testing.T) {
243+ m := NewMigrator()
244+
245+ tests := []struct {
246+ name string
247+ key string
248+ expectedType string
249+ }{
250+ {
251+ name: "bool key",
252+ key: "debug.enable_devtools",
253+ expectedType: "bool",
254+ },
255+ {
256+ name: "int key",
257+ key: "history.max_entries",
258+ expectedType: "int",
259+ },
260+ {
261+ name: "string key",
262+ key: "logging.level",
263+ expectedType: "string",
264+ },
265+ {
266+ name: "float key",
267+ key: "default_webpage_zoom",
268+ expectedType: "float",
269+ },
270+ }
271+
272+ for _, tt := range tests {
273+ t.Run(tt.name, func(t *testing.T) {
274+ info := m.GetKeyInfo(tt.key)
275+ assert.Equal(t, tt.key, info.Key)
276+ assert.Equal(t, tt.expectedType, info.Type)
277+ assert.NotEmpty(t, info.DefaultValue)
278+ })
279+ }
280+}
281+
282+func TestMigrator_GetTypeName(t *testing.T) {
283+ m := NewMigrator()
284+
285+ tests := []struct {
286+ name string
287+ value any
288+ expected string
289+ }{
290+ {name: "bool", value: true, expected: "bool"},
291+ {name: "int", value: 42, expected: "int"},
292+ {name: "int64", value: int64(42), expected: "int"},
293+ {name: "float64", value: 3.14, expected: "float"},
294+ {name: "string", value: "hello", expected: "string"},
295+ {name: "slice", value: []any{"a", "b"}, expected: "list"},
296+ {name: "map", value: map[string]any{"a": 1}, expected: "map"},
297+ {name: "nil", value: nil, expected: "unknown"},
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ result := m.getTypeName(tt.value)
303+ assert.Equal(t, tt.expected, result)
304+ })
305+ }
306+}
307+
308+func TestMigrator_FormatValue(t *testing.T) {
309+ m := NewMigrator()
310+
311+ tests := []struct {
312+ name string
313+ value any
314+ expected string
315+ }{
316+ {name: "true", value: true, expected: "true"},
317+ {name: "false", value: false, expected: "false"},
318+ {name: "int", value: 42, expected: "42"},
319+ {name: "float", value: 3.14, expected: "3.14"},
320+ {name: "string", value: "hello", expected: `"hello"`},
321+ {name: "empty string", value: "", expected: `""`},
322+ {name: "empty slice", value: []any{}, expected: "[]"},
323+ {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
324+ {name: "empty map", value: map[string]any{}, expected: "{}"},
325+ {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
326+ {name: "nil", value: nil, expected: "null"},
327+ }
328+
329+ for _, tt := range tests {
330+ t.Run(tt.name, func(t *testing.T) {
331+ result := m.formatValue(tt.value)
332+ assert.Equal(t, tt.expected, result)
333+ })
334+ }
335+}
336+
337+func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
338+ // This test would need to temporarily modify the config path
339+ // which is difficult without refactoring. Skip for now.
340+ t.Skip("requires config path mocking")
341+}
342+
343+func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
344+ // This test would need to temporarily modify the config path
345+ // which is difficult without refactoring. Skip for now.
346+ t.Skip("requires config path mocking")
347+}
@@ -0,0 +1,347 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func 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+
19+func 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+
42+func 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]
51+max_entries = 5000
52+retention_period_days = 180
53+
54+[logging]
55+level = "debug"
56+
57+[appearance]
58+sans_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+
77+func 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+
151+func 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+
199+func 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+
234+ for _, tt := range tests {
235+ t.Run(tt.name, func(t *testing.T) {
236+ result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
237+ assert.ElementsMatch(t, tt.expected, result)
238+ })
239+ }
240+}
241+
242+func TestMigrator_GetKeyInfo(t *testing.T) {
243+ m := NewMigrator()
244+
245+ tests := []struct {
246+ name string
247+ key string
248+ expectedType string
249+ }{
250+ {
251+ name: "bool key",
252+ key: "debug.enable_devtools",
253+ expectedType: "bool",
254+ },
255+ {
256+ name: "int key",
257+ key: "history.max_entries",
258+ expectedType: "int",
259+ },
260+ {
261+ name: "string key",
262+ key: "logging.level",
263+ expectedType: "string",
264+ },
265+ {
266+ name: "float key",
267+ key: "default_webpage_zoom",
268+ expectedType: "float",
269+ },
270+ }
271+
272+ for _, tt := range tests {
273+ t.Run(tt.name, func(t *testing.T) {
274+ info := m.GetKeyInfo(tt.key)
275+ assert.Equal(t, tt.key, info.Key)
276+ assert.Equal(t, tt.expectedType, info.Type)
277+ assert.NotEmpty(t, info.DefaultValue)
278+ })
279+ }
280+}
281+
282+func TestMigrator_GetTypeName(t *testing.T) {
283+ m := NewMigrator()
284+
285+ tests := []struct {
286+ name string
287+ value any
288+ expected string
289+ }{
290+ {name: "bool", value: true, expected: "bool"},
291+ {name: "int", value: 42, expected: "int"},
292+ {name: "int64", value: int64(42), expected: "int"},
293+ {name: "float64", value: 3.14, expected: "float"},
294+ {name: "string", value: "hello", expected: "string"},
295+ {name: "slice", value: []any{"a", "b"}, expected: "list"},
296+ {name: "map", value: map[string]any{"a": 1}, expected: "map"},
297+ {name: "nil", value: nil, expected: "unknown"},
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ result := m.getTypeName(tt.value)
303+ assert.Equal(t, tt.expected, result)
304+ })
305+ }
306+}
307+
308+func TestMigrator_FormatValue(t *testing.T) {
309+ m := NewMigrator()
310+
311+ tests := []struct {
312+ name string
313+ value any
314+ expected string
315+ }{
316+ {name: "true", value: true, expected: "true"},
317+ {name: "false", value: false, expected: "false"},
318+ {name: "int", value: 42, expected: "42"},
319+ {name: "float", value: 3.14, expected: "3.14"},
320+ {name: "string", value: "hello", expected: `"hello"`},
321+ {name: "empty string", value: "", expected: `""`},
322+ {name: "empty slice", value: []any{}, expected: "[]"},
323+ {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
324+ {name: "empty map", value: map[string]any{}, expected: "{}"},
325+ {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
326+ {name: "nil", value: nil, expected: "null"},
327+ }
328+
329+ for _, tt := range tests {
330+ t.Run(tt.name, func(t *testing.T) {
331+ result := m.formatValue(tt.value)
332+ assert.Equal(t, tt.expected, result)
333+ })
334+ }
335+}
336+
337+func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
338+ // This test would need to temporarily modify the config path
339+ // which is difficult without refactoring. Skip for now.
340+ t.Skip("requires config path mocking")
341+}
342+
343+func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
344+ // This test would need to temporarily modify the config path
345+ // which is difficult without refactoring. Skip for now.
346+ t.Skip("requires config path mocking")
347+}

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