detached 02bf2da

Merge pull request #45 from bnema/feature/config-migrate

feat(config): add config migration to detect and add missing settings
commit
02bf2daa6e3a10bd871c108ba6cdd5dd7e394081
author
bnema · · 2025-12-27 19:15 UTC
committer
GitHub · · 2025-12-27 19:15 UTC
merge of
495ecb8 + 905294c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +1773 -5
M .mockery.yaml +1 -0
@@ -8,6 +8,7 @@ packages:
88 filename: mocks.go
99 pkgname: mocks
1010 interfaces:
11+ ConfigMigrator: {}
1112 SessionLogger: {}
1213 SessionSpawner: {}
1314 TabListProvider: {}
@@ -8,6 +8,7 @@ packages:
8 filename: mocks.go8 filename: mocks.go
9 pkgname: mocks9 pkgname: mocks
10 interfaces:10 interfaces:
11+ ConfigMigrator: {}
11 SessionLogger: {}12 SessionLogger: {}
12 SessionSpawner: {}13 SessionSpawner: {}
13 TabListProvider: {}14 TabListProvider: {}
M CHANGELOG.md +4 -0
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
9+ - `dumber config status`: Shows config file path and number of new settings available.
10+ - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.
11+ - GUI toast notification on startup when new settings are available (configurable via `update.notify_on_new_settings`).
812 - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.
913 - Interactive spinner animation during check and download phases.
1014 - `--force` / `-f` flag to skip version check and reinstall the current version.
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
9+ - `dumber config status`: Shows config file path and number of new settings available.
10+ - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.
11+ - GUI toast notification on startup when new settings are available (configurable via `update.notify_on_new_settings`).
8 - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.12 - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.
9 - Interactive spinner animation during check and download phases.13 - Interactive spinner animation during check and download phases.
10 - `--force` / `-f` flag to skip version check and reinstall the current version.14 - `--force` / `-f` flag to skip version check and reinstall the current version.
M README.md +4 -0
@@ -261,6 +261,10 @@ sudo apt install va-driver-all
261261 - Clean up data:
262262 - `dumber purge` # interactive selection (TUI)
263263 - `dumber purge --force` # remove everything (no prompts)
264+- Manage configuration:
265+ - `dumber config status` # show config path and migration availability
266+ - `dumber config migrate` # add missing default settings to config
267+ - `dumber config migrate --yes` # skip confirmation prompt
264268 - Manage sessions:
265269 - `dumber sessions` # interactive session browser (TUI)
266270 - `dumber sessions list` # list saved sessions
@@ -261,6 +261,10 @@ sudo apt install va-driver-all
261 - Clean up data:261 - Clean up data:
262 - `dumber purge` # interactive selection (TUI)262 - `dumber purge` # interactive selection (TUI)
263 - `dumber purge --force` # remove everything (no prompts)263 - `dumber purge --force` # remove everything (no prompts)
264+- Manage configuration:
265+ - `dumber config status` # show config path and migration availability
266+ - `dumber config migrate` # add missing default settings to config
267+ - `dumber config migrate --yes` # skip confirmation prompt
264 - Manage sessions:268 - Manage sessions:
265 - `dumber sessions` # interactive session browser (TUI)269 - `dumber sessions` # interactive session browser (TUI)
266 - `dumber sessions list` # list saved sessions270 - `dumber sessions list` # list saved sessions
M docs/CONFIG.md +28 -0
@@ -451,6 +451,34 @@ Notes:
451451 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
452452 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
453453
454+## Update
455+
456+| Key | Type | Default | Description |
457+|-----|------|---------|-------------|
458+| `update.enable_on_startup` | bool | `true` | Check for updates when browser starts |
459+| `update.auto_download` | bool | `false` | Automatically download updates in background |
460+| `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
461+
462+**Example:**
463+```toml
464+[update]
465+enable_on_startup = true # Check for updates on startup
466+auto_download = false # Don't auto-download (prompt instead)
467+notify_on_new_settings = true # Show toast when config migration available
468+```
469+
470+**CLI Commands:**
471+```bash
472+# Check config status and available migrations
473+dumber config status
474+
475+# Add missing settings with default values
476+dumber config migrate
477+
478+# Skip confirmation prompt
479+dumber config migrate --yes
480+```
481+
454482 ## Environment Variables
455483
456484 All config values can be overridden via environment variables with the prefix `DUMBER_`:
@@ -451,6 +451,34 @@ Notes:
451 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.451 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
452 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.452 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
453 453
454+## Update
455+
456+| Key | Type | Default | Description |
457+|-----|------|---------|-------------|
458+| `update.enable_on_startup` | bool | `true` | Check for updates when browser starts |
459+| `update.auto_download` | bool | `false` | Automatically download updates in background |
460+| `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
461+
462+**Example:**
463+```toml
464+[update]
465+enable_on_startup = true # Check for updates on startup
466+auto_download = false # Don't auto-download (prompt instead)
467+notify_on_new_settings = true # Show toast when config migration available
468+```
469+
470+**CLI Commands:**
471+```bash
472+# Check config status and available migrations
473+dumber config status
474+
475+# Add missing settings with default values
476+dumber config migrate
477+
478+# Skip confirmation prompt
479+dumber config migrate --yes
480+```
481+
454 ## Environment Variables482 ## Environment Variables
455 483
456 All config values can be overridden via environment variables with the prefix `DUMBER_`:484 All config values can be overridden via environment variables with the prefix `DUMBER_`:
M go.mod +3 -3
@@ -8,14 +8,17 @@ require (
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
1010 github.com/fsnotify/fsnotify v1.9.0
11+ github.com/godbus/dbus/v5 v5.2.1
1112 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3
1213 github.com/ncruces/go-sqlite3 v0.30.3
14+ github.com/pelletier/go-toml/v2 v2.2.4
1315 github.com/pressly/goose/v3 v3.26.0
1416 github.com/rs/zerolog v1.33.0
1517 github.com/spf13/cobra v1.10.2
1618 github.com/spf13/viper v1.21.0
1719 github.com/stretchr/testify v1.11.1
1820 golang.org/x/image v0.34.0
21+ golang.org/x/sys v0.38.0
1922 )
2023
2124 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -30,7 +33,6 @@ require (
3033 github.com/davecgh/go-spew v1.1.1 // indirect
3134 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3235 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33- github.com/godbus/dbus/v5 v5.2.1 // indirect
3436 github.com/inconshreveable/mousetrap v1.1.0 // indirect
3537 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
3638 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -43,7 +45,6 @@ require (
4345 github.com/muesli/cancelreader v0.2.2 // indirect
4446 github.com/muesli/termenv v0.16.0 // indirect
4547 github.com/ncruces/julianday v1.0.0 // indirect
46- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
4748 github.com/pmezard/go-difflib v1.0.0 // indirect
4849 github.com/rivo/uniseg v0.4.7 // indirect
4950 github.com/sagikazarmark/locafero v0.11.0 // indirect
@@ -60,7 +61,6 @@ require (
6061 go.uber.org/multierr v1.11.0 // indirect
6162 go.yaml.in/yaml/v3 v3.0.4 // indirect
6263 golang.org/x/sync v0.19.0 // indirect
63- golang.org/x/sys v0.38.0 // indirect
6464 golang.org/x/text v0.32.0 // indirect
6565 gopkg.in/yaml.v3 v3.0.1 // indirect
6666 )
@@ -8,14 +8,17 @@ require (
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10 github.com/fsnotify/fsnotify v1.9.010 github.com/fsnotify/fsnotify v1.9.0
11+ github.com/godbus/dbus/v5 v5.2.1
11 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa312 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3
12 github.com/ncruces/go-sqlite3 v0.30.313 github.com/ncruces/go-sqlite3 v0.30.3
14+ github.com/pelletier/go-toml/v2 v2.2.4
13 github.com/pressly/goose/v3 v3.26.015 github.com/pressly/goose/v3 v3.26.0
14 github.com/rs/zerolog v1.33.016 github.com/rs/zerolog v1.33.0
15 github.com/spf13/cobra v1.10.217 github.com/spf13/cobra v1.10.2
16 github.com/spf13/viper v1.21.018 github.com/spf13/viper v1.21.0
17 github.com/stretchr/testify v1.11.119 github.com/stretchr/testify v1.11.1
18 golang.org/x/image v0.34.020 golang.org/x/image v0.34.0
21+ golang.org/x/sys v0.38.0
19 )22 )
20 23
21 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa324 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -30,7 +33,6 @@ require (
30 github.com/davecgh/go-spew v1.1.1 // indirect33 github.com/davecgh/go-spew v1.1.1 // indirect
31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect34 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect35 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33- github.com/godbus/dbus/v5 v5.2.1 // indirect
34 github.com/inconshreveable/mousetrap v1.1.0 // indirect36 github.com/inconshreveable/mousetrap v1.1.0 // indirect
35 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect37 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
36 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect38 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -43,7 +45,6 @@ require (
43 github.com/muesli/cancelreader v0.2.2 // indirect45 github.com/muesli/cancelreader v0.2.2 // indirect
44 github.com/muesli/termenv v0.16.0 // indirect46 github.com/muesli/termenv v0.16.0 // indirect
45 github.com/ncruces/julianday v1.0.0 // indirect47 github.com/ncruces/julianday v1.0.0 // indirect
46- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
47 github.com/pmezard/go-difflib v1.0.0 // indirect48 github.com/pmezard/go-difflib v1.0.0 // indirect
48 github.com/rivo/uniseg v0.4.7 // indirect49 github.com/rivo/uniseg v0.4.7 // indirect
49 github.com/sagikazarmark/locafero v0.11.0 // indirect50 github.com/sagikazarmark/locafero v0.11.0 // indirect
@@ -60,7 +61,6 @@ require (
60 go.uber.org/multierr v1.11.0 // indirect61 go.uber.org/multierr v1.11.0 // indirect
61 go.yaml.in/yaml/v3 v3.0.4 // indirect62 go.yaml.in/yaml/v3 v3.0.4 // indirect
62 golang.org/x/sync v0.19.0 // indirect63 golang.org/x/sync v0.19.0 // indirect
63- golang.org/x/sys v0.38.0 // indirect
64 golang.org/x/text v0.32.0 // indirect64 golang.org/x/text v0.32.0 // indirect
65 gopkg.in/yaml.v3 v3.0.1 // indirect65 gopkg.in/yaml.v3 v3.0.1 // indirect
66 )66 )
A internal/application/port/config_migrator.go +33 -0
@@ -0,0 +1,33 @@
1+package port
2+
3+// MigrationResult contains the result of a config migration check.
4+type MigrationResult struct {
5+ // MissingKeys contains the keys that exist in defaults but not in user config.
6+ MissingKeys []string
7+ // ConfigFile is the path to the user's config file.
8+ ConfigFile string
9+}
10+
11+// KeyInfo contains metadata about a config key for display purposes.
12+type KeyInfo struct {
13+ // Key is the dot-notation key path (e.g., "update.enable_on_startup").
14+ Key string
15+ // Type is the Go type of the value (e.g., "bool", "int", "string").
16+ Type string
17+ // DefaultValue is a string representation of the default value.
18+ DefaultValue string
19+}
20+
21+// ConfigMigrator checks for and applies config migrations.
22+type ConfigMigrator interface {
23+ // CheckMigration checks if user config is missing any default keys.
24+ // Returns nil if no migration is needed (config file doesn't exist or is complete).
25+ CheckMigration() (*MigrationResult, error)
26+
27+ // Migrate adds missing default keys to the user's config file.
28+ // Returns the list of keys that were added.
29+ Migrate() ([]string, error)
30+
31+ // GetKeyInfo returns detailed information about a config key.
32+ GetKeyInfo(key string) KeyInfo
33+}
@@ -0,0 +1,33 @@
1+package port
2+
3+// MigrationResult contains the result of a config migration check.
4+type MigrationResult struct {
5+ // MissingKeys contains the keys that exist in defaults but not in user config.
6+ MissingKeys []string
7+ // ConfigFile is the path to the user's config file.
8+ ConfigFile string
9+}
10+
11+// KeyInfo contains metadata about a config key for display purposes.
12+type KeyInfo struct {
13+ // Key is the dot-notation key path (e.g., "update.enable_on_startup").
14+ Key string
15+ // Type is the Go type of the value (e.g., "bool", "int", "string").
16+ Type string
17+ // DefaultValue is a string representation of the default value.
18+ DefaultValue string
19+}
20+
21+// ConfigMigrator checks for and applies config migrations.
22+type ConfigMigrator interface {
23+ // CheckMigration checks if user config is missing any default keys.
24+ // Returns nil if no migration is needed (config file doesn't exist or is complete).
25+ CheckMigration() (*MigrationResult, error)
26+
27+ // Migrate adds missing default keys to the user's config file.
28+ // Returns the list of keys that were added.
29+ Migrate() ([]string, error)
30+
31+ // GetKeyInfo returns detailed information about a config key.
32+ GetKeyInfo(key string) KeyInfo
33+}
M internal/application/port/mocks/mocks.go +188 -0
@@ -13,6 +13,194 @@ import (
1313 mock "github.com/stretchr/testify/mock"
1414 )
1515
16+// NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewMockConfigMigrator(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *MockConfigMigrator {
22+ mock := &MockConfigMigrator{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() { mock.AssertExpectations(t) })
26+
27+ return mock
28+}
29+
30+// MockConfigMigrator is an autogenerated mock type for the ConfigMigrator type
31+type MockConfigMigrator struct {
32+ mock.Mock
33+}
34+
35+type MockConfigMigrator_Expecter struct {
36+ mock *mock.Mock
37+}
38+
39+func (_m *MockConfigMigrator) EXPECT() *MockConfigMigrator_Expecter {
40+ return &MockConfigMigrator_Expecter{mock: &_m.Mock}
41+}
42+
43+// CheckMigration provides a mock function for the type MockConfigMigrator
44+func (_mock *MockConfigMigrator) CheckMigration() (*port.MigrationResult, error) {
45+ ret := _mock.Called()
46+
47+ if len(ret) == 0 {
48+ panic("no return value specified for CheckMigration")
49+ }
50+
51+ var r0 *port.MigrationResult
52+ var r1 error
53+ if returnFunc, ok := ret.Get(0).(func() (*port.MigrationResult, error)); ok {
54+ return returnFunc()
55+ }
56+ if returnFunc, ok := ret.Get(0).(func() *port.MigrationResult); ok {
57+ r0 = returnFunc()
58+ } else {
59+ if ret.Get(0) != nil {
60+ r0 = ret.Get(0).(*port.MigrationResult)
61+ }
62+ }
63+ if returnFunc, ok := ret.Get(1).(func() error); ok {
64+ r1 = returnFunc()
65+ } else {
66+ r1 = ret.Error(1)
67+ }
68+ return r0, r1
69+}
70+
71+// MockConfigMigrator_CheckMigration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckMigration'
72+type MockConfigMigrator_CheckMigration_Call struct {
73+ *mock.Call
74+}
75+
76+// CheckMigration is a helper method to define mock.On call
77+func (_e *MockConfigMigrator_Expecter) CheckMigration() *MockConfigMigrator_CheckMigration_Call {
78+ return &MockConfigMigrator_CheckMigration_Call{Call: _e.mock.On("CheckMigration")}
79+}
80+
81+func (_c *MockConfigMigrator_CheckMigration_Call) Run(run func()) *MockConfigMigrator_CheckMigration_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ run()
84+ })
85+ return _c
86+}
87+
88+func (_c *MockConfigMigrator_CheckMigration_Call) Return(migrationResult *port.MigrationResult, err error) *MockConfigMigrator_CheckMigration_Call {
89+ _c.Call.Return(migrationResult, err)
90+ return _c
91+}
92+
93+func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port.MigrationResult, error)) *MockConfigMigrator_CheckMigration_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// GetKeyInfo provides a mock function for the type MockConfigMigrator
99+func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
100+ ret := _mock.Called(key)
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for GetKeyInfo")
104+ }
105+
106+ var r0 port.KeyInfo
107+ if returnFunc, ok := ret.Get(0).(func(string) port.KeyInfo); ok {
108+ r0 = returnFunc(key)
109+ } else {
110+ r0 = ret.Get(0).(port.KeyInfo)
111+ }
112+ return r0
113+}
114+
115+// MockConfigMigrator_GetKeyInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeyInfo'
116+type MockConfigMigrator_GetKeyInfo_Call struct {
117+ *mock.Call
118+}
119+
120+// GetKeyInfo is a helper method to define mock.On call
121+// - key string
122+func (_e *MockConfigMigrator_Expecter) GetKeyInfo(key interface{}) *MockConfigMigrator_GetKeyInfo_Call {
123+ return &MockConfigMigrator_GetKeyInfo_Call{Call: _e.mock.On("GetKeyInfo", key)}
124+}
125+
126+func (_c *MockConfigMigrator_GetKeyInfo_Call) Run(run func(key string)) *MockConfigMigrator_GetKeyInfo_Call {
127+ _c.Call.Run(func(args mock.Arguments) {
128+ var arg0 string
129+ if args[0] != nil {
130+ arg0 = args[0].(string)
131+ }
132+ run(
133+ arg0,
134+ )
135+ })
136+ return _c
137+}
138+
139+func (_c *MockConfigMigrator_GetKeyInfo_Call) Return(keyInfo port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
140+ _c.Call.Return(keyInfo)
141+ return _c
142+}
143+
144+func (_c *MockConfigMigrator_GetKeyInfo_Call) RunAndReturn(run func(key string) port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
145+ _c.Call.Return(run)
146+ return _c
147+}
148+
149+// Migrate provides a mock function for the type MockConfigMigrator
150+func (_mock *MockConfigMigrator) Migrate() ([]string, error) {
151+ ret := _mock.Called()
152+
153+ if len(ret) == 0 {
154+ panic("no return value specified for Migrate")
155+ }
156+
157+ var r0 []string
158+ var r1 error
159+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
160+ return returnFunc()
161+ }
162+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
163+ r0 = returnFunc()
164+ } else {
165+ if ret.Get(0) != nil {
166+ r0 = ret.Get(0).([]string)
167+ }
168+ }
169+ if returnFunc, ok := ret.Get(1).(func() error); ok {
170+ r1 = returnFunc()
171+ } else {
172+ r1 = ret.Error(1)
173+ }
174+ return r0, r1
175+}
176+
177+// MockConfigMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate'
178+type MockConfigMigrator_Migrate_Call struct {
179+ *mock.Call
180+}
181+
182+// Migrate is a helper method to define mock.On call
183+func (_e *MockConfigMigrator_Expecter) Migrate() *MockConfigMigrator_Migrate_Call {
184+ return &MockConfigMigrator_Migrate_Call{Call: _e.mock.On("Migrate")}
185+}
186+
187+func (_c *MockConfigMigrator_Migrate_Call) Run(run func()) *MockConfigMigrator_Migrate_Call {
188+ _c.Call.Run(func(args mock.Arguments) {
189+ run()
190+ })
191+ return _c
192+}
193+
194+func (_c *MockConfigMigrator_Migrate_Call) Return(strings []string, err error) *MockConfigMigrator_Migrate_Call {
195+ _c.Call.Return(strings, err)
196+ return _c
197+}
198+
199+func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, error)) *MockConfigMigrator_Migrate_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
16204 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17205 // The first argument is typically a *testing.T value.
18206 func NewMockSessionLogger(t interface {
@@ -13,6 +13,194 @@ import (
13 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
14 )14 )
15 15
16+// NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewMockConfigMigrator(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *MockConfigMigrator {
22+ mock := &MockConfigMigrator{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() { mock.AssertExpectations(t) })
26+
27+ return mock
28+}
29+
30+// MockConfigMigrator is an autogenerated mock type for the ConfigMigrator type
31+type MockConfigMigrator struct {
32+ mock.Mock
33+}
34+
35+type MockConfigMigrator_Expecter struct {
36+ mock *mock.Mock
37+}
38+
39+func (_m *MockConfigMigrator) EXPECT() *MockConfigMigrator_Expecter {
40+ return &MockConfigMigrator_Expecter{mock: &_m.Mock}
41+}
42+
43+// CheckMigration provides a mock function for the type MockConfigMigrator
44+func (_mock *MockConfigMigrator) CheckMigration() (*port.MigrationResult, error) {
45+ ret := _mock.Called()
46+
47+ if len(ret) == 0 {
48+ panic("no return value specified for CheckMigration")
49+ }
50+
51+ var r0 *port.MigrationResult
52+ var r1 error
53+ if returnFunc, ok := ret.Get(0).(func() (*port.MigrationResult, error)); ok {
54+ return returnFunc()
55+ }
56+ if returnFunc, ok := ret.Get(0).(func() *port.MigrationResult); ok {
57+ r0 = returnFunc()
58+ } else {
59+ if ret.Get(0) != nil {
60+ r0 = ret.Get(0).(*port.MigrationResult)
61+ }
62+ }
63+ if returnFunc, ok := ret.Get(1).(func() error); ok {
64+ r1 = returnFunc()
65+ } else {
66+ r1 = ret.Error(1)
67+ }
68+ return r0, r1
69+}
70+
71+// MockConfigMigrator_CheckMigration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckMigration'
72+type MockConfigMigrator_CheckMigration_Call struct {
73+ *mock.Call
74+}
75+
76+// CheckMigration is a helper method to define mock.On call
77+func (_e *MockConfigMigrator_Expecter) CheckMigration() *MockConfigMigrator_CheckMigration_Call {
78+ return &MockConfigMigrator_CheckMigration_Call{Call: _e.mock.On("CheckMigration")}
79+}
80+
81+func (_c *MockConfigMigrator_CheckMigration_Call) Run(run func()) *MockConfigMigrator_CheckMigration_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ run()
84+ })
85+ return _c
86+}
87+
88+func (_c *MockConfigMigrator_CheckMigration_Call) Return(migrationResult *port.MigrationResult, err error) *MockConfigMigrator_CheckMigration_Call {
89+ _c.Call.Return(migrationResult, err)
90+ return _c
91+}
92+
93+func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port.MigrationResult, error)) *MockConfigMigrator_CheckMigration_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// GetKeyInfo provides a mock function for the type MockConfigMigrator
99+func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
100+ ret := _mock.Called(key)
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for GetKeyInfo")
104+ }
105+
106+ var r0 port.KeyInfo
107+ if returnFunc, ok := ret.Get(0).(func(string) port.KeyInfo); ok {
108+ r0 = returnFunc(key)
109+ } else {
110+ r0 = ret.Get(0).(port.KeyInfo)
111+ }
112+ return r0
113+}
114+
115+// MockConfigMigrator_GetKeyInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeyInfo'
116+type MockConfigMigrator_GetKeyInfo_Call struct {
117+ *mock.Call
118+}
119+
120+// GetKeyInfo is a helper method to define mock.On call
121+// - key string
122+func (_e *MockConfigMigrator_Expecter) GetKeyInfo(key interface{}) *MockConfigMigrator_GetKeyInfo_Call {
123+ return &MockConfigMigrator_GetKeyInfo_Call{Call: _e.mock.On("GetKeyInfo", key)}
124+}
125+
126+func (_c *MockConfigMigrator_GetKeyInfo_Call) Run(run func(key string)) *MockConfigMigrator_GetKeyInfo_Call {
127+ _c.Call.Run(func(args mock.Arguments) {
128+ var arg0 string
129+ if args[0] != nil {
130+ arg0 = args[0].(string)
131+ }
132+ run(
133+ arg0,
134+ )
135+ })
136+ return _c
137+}
138+
139+func (_c *MockConfigMigrator_GetKeyInfo_Call) Return(keyInfo port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
140+ _c.Call.Return(keyInfo)
141+ return _c
142+}
143+
144+func (_c *MockConfigMigrator_GetKeyInfo_Call) RunAndReturn(run func(key string) port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
145+ _c.Call.Return(run)
146+ return _c
147+}
148+
149+// Migrate provides a mock function for the type MockConfigMigrator
150+func (_mock *MockConfigMigrator) Migrate() ([]string, error) {
151+ ret := _mock.Called()
152+
153+ if len(ret) == 0 {
154+ panic("no return value specified for Migrate")
155+ }
156+
157+ var r0 []string
158+ var r1 error
159+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
160+ return returnFunc()
161+ }
162+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
163+ r0 = returnFunc()
164+ } else {
165+ if ret.Get(0) != nil {
166+ r0 = ret.Get(0).([]string)
167+ }
168+ }
169+ if returnFunc, ok := ret.Get(1).(func() error); ok {
170+ r1 = returnFunc()
171+ } else {
172+ r1 = ret.Error(1)
173+ }
174+ return r0, r1
175+}
176+
177+// MockConfigMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate'
178+type MockConfigMigrator_Migrate_Call struct {
179+ *mock.Call
180+}
181+
182+// Migrate is a helper method to define mock.On call
183+func (_e *MockConfigMigrator_Expecter) Migrate() *MockConfigMigrator_Migrate_Call {
184+ return &MockConfigMigrator_Migrate_Call{Call: _e.mock.On("Migrate")}
185+}
186+
187+func (_c *MockConfigMigrator_Migrate_Call) Run(run func()) *MockConfigMigrator_Migrate_Call {
188+ _c.Call.Run(func(args mock.Arguments) {
189+ run()
190+ })
191+ return _c
192+}
193+
194+func (_c *MockConfigMigrator_Migrate_Call) Return(strings []string, err error) *MockConfigMigrator_Migrate_Call {
195+ _c.Call.Return(strings, err)
196+ return _c
197+}
198+
199+func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, error)) *MockConfigMigrator_Migrate_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
16 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.204 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17 // The first argument is typically a *testing.T value.205 // The first argument is typically a *testing.T value.
18 func NewMockSessionLogger(t interface {206 func NewMockSessionLogger(t interface {
A internal/application/usecase/migrate_config.go +118 -0
@@ -0,0 +1,118 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// CheckConfigMigrationInput holds the input for checking config migration.
11+type CheckConfigMigrationInput struct{}
12+
13+// CheckConfigMigrationOutput holds the result of the migration check.
14+type CheckConfigMigrationOutput struct {
15+ // NeedsMigration is true if there are missing keys.
16+ NeedsMigration bool
17+ // MissingKeys contains info about each missing key.
18+ MissingKeys []port.KeyInfo
19+ // ConfigFile is the path to the config file.
20+ ConfigFile string
21+}
22+
23+// MigrateConfigInput holds the input for migrating config.
24+type MigrateConfigInput struct{}
25+
26+// MigrateConfigOutput holds the result of the migration.
27+type MigrateConfigOutput struct {
28+ // AddedKeys contains the keys that were added.
29+ AddedKeys []string
30+ // ConfigFile is the path to the config file.
31+ ConfigFile string
32+}
33+
34+// MigrateConfigUseCase handles config migration operations.
35+type MigrateConfigUseCase struct {
36+ migrator port.ConfigMigrator
37+}
38+
39+// NewMigrateConfigUseCase creates a new migrate config use case.
40+func NewMigrateConfigUseCase(migrator port.ConfigMigrator) *MigrateConfigUseCase {
41+ return &MigrateConfigUseCase{
42+ migrator: migrator,
43+ }
44+}
45+
46+// Check checks if the user config is missing any default keys.
47+func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigrationInput) (*CheckConfigMigrationOutput, error) {
48+ log := logging.FromContext(ctx)
49+
50+ result, err := uc.migrator.CheckMigration()
51+ if err != nil {
52+ log.Warn().Err(err).Msg("config migration check failed")
53+ return nil, err
54+ }
55+
56+ // No migration needed
57+ if result == nil || len(result.MissingKeys) == 0 {
58+ log.Debug().Msg("config is up to date, no migration needed")
59+ return &CheckConfigMigrationOutput{
60+ NeedsMigration: false,
61+ MissingKeys: nil,
62+ ConfigFile: "",
63+ }, nil
64+ }
65+
66+ // Build key info for each missing key
67+ keyInfos := make([]port.KeyInfo, 0, len(result.MissingKeys))
68+ for _, key := range result.MissingKeys {
69+ keyInfos = append(keyInfos, uc.migrator.GetKeyInfo(key))
70+ }
71+
72+ log.Debug().
73+ Int("missing_keys", len(result.MissingKeys)).
74+ Str("config_file", result.ConfigFile).
75+ Msg("config migration check completed")
76+
77+ return &CheckConfigMigrationOutput{
78+ NeedsMigration: true,
79+ MissingKeys: keyInfos,
80+ ConfigFile: result.ConfigFile,
81+ }, nil
82+}
83+
84+// Execute adds missing default keys to the user's config file.
85+func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {
86+ log := logging.FromContext(ctx)
87+
88+ // First check if migration is needed
89+ checkResult, err := uc.migrator.CheckMigration()
90+ if err != nil {
91+ return nil, err
92+ }
93+
94+ if checkResult == nil || len(checkResult.MissingKeys) == 0 {
95+ log.Debug().Msg("no migration needed")
96+ return &MigrateConfigOutput{
97+ AddedKeys: nil,
98+ ConfigFile: "",
99+ }, nil
100+ }
101+
102+ // Perform migration
103+ addedKeys, err := uc.migrator.Migrate()
104+ if err != nil {
105+ log.Error().Err(err).Msg("config migration failed")
106+ return nil, err
107+ }
108+
109+ log.Info().
110+ Int("added_keys", len(addedKeys)).
111+ Str("config_file", checkResult.ConfigFile).
112+ Msg("config migration completed")
113+
114+ return &MigrateConfigOutput{
115+ AddedKeys: addedKeys,
116+ ConfigFile: checkResult.ConfigFile,
117+ }, nil
118+}
@@ -0,0 +1,118 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// CheckConfigMigrationInput holds the input for checking config migration.
11+type CheckConfigMigrationInput struct{}
12+
13+// CheckConfigMigrationOutput holds the result of the migration check.
14+type CheckConfigMigrationOutput struct {
15+ // NeedsMigration is true if there are missing keys.
16+ NeedsMigration bool
17+ // MissingKeys contains info about each missing key.
18+ MissingKeys []port.KeyInfo
19+ // ConfigFile is the path to the config file.
20+ ConfigFile string
21+}
22+
23+// MigrateConfigInput holds the input for migrating config.
24+type MigrateConfigInput struct{}
25+
26+// MigrateConfigOutput holds the result of the migration.
27+type MigrateConfigOutput struct {
28+ // AddedKeys contains the keys that were added.
29+ AddedKeys []string
30+ // ConfigFile is the path to the config file.
31+ ConfigFile string
32+}
33+
34+// MigrateConfigUseCase handles config migration operations.
35+type MigrateConfigUseCase struct {
36+ migrator port.ConfigMigrator
37+}
38+
39+// NewMigrateConfigUseCase creates a new migrate config use case.
40+func NewMigrateConfigUseCase(migrator port.ConfigMigrator) *MigrateConfigUseCase {
41+ return &MigrateConfigUseCase{
42+ migrator: migrator,
43+ }
44+}
45+
46+// Check checks if the user config is missing any default keys.
47+func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigrationInput) (*CheckConfigMigrationOutput, error) {
48+ log := logging.FromContext(ctx)
49+
50+ result, err := uc.migrator.CheckMigration()
51+ if err != nil {
52+ log.Warn().Err(err).Msg("config migration check failed")
53+ return nil, err
54+ }
55+
56+ // No migration needed
57+ if result == nil || len(result.MissingKeys) == 0 {
58+ log.Debug().Msg("config is up to date, no migration needed")
59+ return &CheckConfigMigrationOutput{
60+ NeedsMigration: false,
61+ MissingKeys: nil,
62+ ConfigFile: "",
63+ }, nil
64+ }
65+
66+ // Build key info for each missing key
67+ keyInfos := make([]port.KeyInfo, 0, len(result.MissingKeys))
68+ for _, key := range result.MissingKeys {
69+ keyInfos = append(keyInfos, uc.migrator.GetKeyInfo(key))
70+ }
71+
72+ log.Debug().
73+ Int("missing_keys", len(result.MissingKeys)).
74+ Str("config_file", result.ConfigFile).
75+ Msg("config migration check completed")
76+
77+ return &CheckConfigMigrationOutput{
78+ NeedsMigration: true,
79+ MissingKeys: keyInfos,
80+ ConfigFile: result.ConfigFile,
81+ }, nil
82+}
83+
84+// Execute adds missing default keys to the user's config file.
85+func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {
86+ log := logging.FromContext(ctx)
87+
88+ // First check if migration is needed
89+ checkResult, err := uc.migrator.CheckMigration()
90+ if err != nil {
91+ return nil, err
92+ }
93+
94+ if checkResult == nil || len(checkResult.MissingKeys) == 0 {
95+ log.Debug().Msg("no migration needed")
96+ return &MigrateConfigOutput{
97+ AddedKeys: nil,
98+ ConfigFile: "",
99+ }, nil
100+ }
101+
102+ // Perform migration
103+ addedKeys, err := uc.migrator.Migrate()
104+ if err != nil {
105+ log.Error().Err(err).Msg("config migration failed")
106+ return nil, err
107+ }
108+
109+ log.Info().
110+ Int("added_keys", len(addedKeys)).
111+ Str("config_file", checkResult.ConfigFile).
112+ Msg("config migration completed")
113+
114+ return &MigrateConfigOutput{
115+ AddedKeys: addedKeys,
116+ ConfigFile: checkResult.ConfigFile,
117+ }, nil
118+}
A internal/application/usecase/migrate_config_test.go +206 -0
@@ -0,0 +1,206 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+)
15+
16+func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
17+ mockMigrator := mocks.NewMockConfigMigrator(t)
18+ mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
19+
20+ uc := NewMigrateConfigUseCase(mockMigrator)
21+ ctx := context.Background()
22+
23+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
24+
25+ require.NoError(t, err)
26+ assert.False(t, result.NeedsMigration)
27+ assert.Empty(t, result.MissingKeys)
28+}
29+
30+func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
31+ mockMigrator := mocks.NewMockConfigMigrator(t)
32+
33+ migrationResult := &port.MigrationResult{
34+ MissingKeys: []string{"key1", "key2"},
35+ ConfigFile: "/path/to/config.toml",
36+ }
37+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
38+
39+ // Mock GetKeyInfo for each missing key
40+ mockMigrator.EXPECT().GetKeyInfo("key1").Return(port.KeyInfo{
41+ Key: "key1",
42+ Type: "bool",
43+ DefaultValue: "true",
44+ })
45+ mockMigrator.EXPECT().GetKeyInfo("key2").Return(port.KeyInfo{
46+ Key: "key2",
47+ Type: "string",
48+ DefaultValue: `"default"`,
49+ })
50+
51+ uc := NewMigrateConfigUseCase(mockMigrator)
52+ ctx := context.Background()
53+
54+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
55+
56+ require.NoError(t, err)
57+ assert.True(t, result.NeedsMigration)
58+ assert.Len(t, result.MissingKeys, 2)
59+ assert.Equal(t, "/path/to/config.toml", result.ConfigFile)
60+
61+ // Verify key info
62+ assert.Equal(t, "key1", result.MissingKeys[0].Key)
63+ assert.Equal(t, "bool", result.MissingKeys[0].Type)
64+ assert.Equal(t, "key2", result.MissingKeys[1].Key)
65+ assert.Equal(t, "string", result.MissingKeys[1].Type)
66+}
67+
68+func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
69+ mockMigrator := mocks.NewMockConfigMigrator(t)
70+ expectedErr := errors.New("check failed")
71+ mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
72+
73+ uc := NewMigrateConfigUseCase(mockMigrator)
74+ ctx := context.Background()
75+
76+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
77+
78+ require.Error(t, err)
79+ assert.Nil(t, result)
80+ assert.Equal(t, expectedErr, err)
81+}
82+
83+func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
84+ mockMigrator := mocks.NewMockConfigMigrator(t)
85+ mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
86+
87+ uc := NewMigrateConfigUseCase(mockMigrator)
88+ ctx := context.Background()
89+
90+ result, err := uc.Execute(ctx, MigrateConfigInput{})
91+
92+ require.NoError(t, err)
93+ assert.Empty(t, result.AddedKeys)
94+}
95+
96+func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
97+ mockMigrator := mocks.NewMockConfigMigrator(t)
98+
99+ migrationResult := &port.MigrationResult{
100+ MissingKeys: []string{"key1", "key2"},
101+ ConfigFile: "/path/to/config.toml",
102+ }
103+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
104+ mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)
105+
106+ uc := NewMigrateConfigUseCase(mockMigrator)
107+ ctx := context.Background()
108+
109+ result, err := uc.Execute(ctx, MigrateConfigInput{})
110+
111+ require.NoError(t, err)
112+ assert.Len(t, result.AddedKeys, 2)
113+ assert.Equal(t, "/path/to/config.toml", result.ConfigFile)
114+}
115+
116+func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
117+ mockMigrator := mocks.NewMockConfigMigrator(t)
118+
119+ migrationResult := &port.MigrationResult{
120+ MissingKeys: []string{"key1"},
121+ ConfigFile: "/path/to/config.toml",
122+ }
123+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
124+
125+ expectedErr := errors.New("migrate failed")
126+ mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)
127+
128+ uc := NewMigrateConfigUseCase(mockMigrator)
129+ ctx := context.Background()
130+
131+ result, err := uc.Execute(ctx, MigrateConfigInput{})
132+
133+ require.Error(t, err)
134+ assert.Nil(t, result)
135+ assert.Equal(t, expectedErr, err)
136+}
137+
138+func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
139+ mockMigrator := mocks.NewMockConfigMigrator(t)
140+
141+ expectedErr := errors.New("check failed")
142+ mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
143+
144+ uc := NewMigrateConfigUseCase(mockMigrator)
145+ ctx := context.Background()
146+
147+ result, err := uc.Execute(ctx, MigrateConfigInput{})
148+
149+ require.Error(t, err)
150+ assert.Nil(t, result)
151+ assert.Equal(t, expectedErr, err)
152+}
153+
154+func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
155+ mockMigrator := mocks.NewMockConfigMigrator(t)
156+
157+ // Return a result with empty missing keys
158+ migrationResult := &port.MigrationResult{
159+ MissingKeys: []string{},
160+ ConfigFile: "/path/to/config.toml",
161+ }
162+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
163+
164+ uc := NewMigrateConfigUseCase(mockMigrator)
165+ ctx := context.Background()
166+
167+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
168+
169+ require.NoError(t, err)
170+ assert.False(t, result.NeedsMigration)
171+ assert.Empty(t, result.MissingKeys)
172+}
173+
174+func TestNewMigrateConfigUseCase(t *testing.T) {
175+ mockMigrator := mocks.NewMockConfigMigrator(t)
176+ uc := NewMigrateConfigUseCase(mockMigrator)
177+
178+ assert.NotNil(t, uc)
179+ assert.Equal(t, mockMigrator, uc.migrator)
180+}
181+
182+// Ensure mock expectations are set up correctly
183+func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
184+ mockMigrator := mocks.NewMockConfigMigrator(t)
185+
186+ // Set up expectations with mock.Anything for flexibility
187+ mockMigrator.On("CheckMigration").Return(&port.MigrationResult{
188+ MissingKeys: []string{"test.key"},
189+ ConfigFile: "/test/config.toml",
190+ }, nil).Once()
191+
192+ mockMigrator.On("GetKeyInfo", mock.AnythingOfType("string")).Return(port.KeyInfo{
193+ Key: "test.key",
194+ Type: "string",
195+ DefaultValue: `"test"`,
196+ }).Once()
197+
198+ uc := NewMigrateConfigUseCase(mockMigrator)
199+ ctx := context.Background()
200+
201+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
202+
203+ require.NoError(t, err)
204+ assert.True(t, result.NeedsMigration)
205+ mockMigrator.AssertExpectations(t)
206+}
@@ -0,0 +1,206 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+)
15+
16+func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
17+ mockMigrator := mocks.NewMockConfigMigrator(t)
18+ mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
19+
20+ uc := NewMigrateConfigUseCase(mockMigrator)
21+ ctx := context.Background()
22+
23+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
24+
25+ require.NoError(t, err)
26+ assert.False(t, result.NeedsMigration)
27+ assert.Empty(t, result.MissingKeys)
28+}
29+
30+func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
31+ mockMigrator := mocks.NewMockConfigMigrator(t)
32+
33+ migrationResult := &port.MigrationResult{
34+ MissingKeys: []string{"key1", "key2"},
35+ ConfigFile: "/path/to/config.toml",
36+ }
37+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
38+
39+ // Mock GetKeyInfo for each missing key
40+ mockMigrator.EXPECT().GetKeyInfo("key1").Return(port.KeyInfo{
41+ Key: "key1",
42+ Type: "bool",
43+ DefaultValue: "true",
44+ })
45+ mockMigrator.EXPECT().GetKeyInfo("key2").Return(port.KeyInfo{
46+ Key: "key2",
47+ Type: "string",
48+ DefaultValue: `"default"`,
49+ })
50+
51+ uc := NewMigrateConfigUseCase(mockMigrator)
52+ ctx := context.Background()
53+
54+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
55+
56+ require.NoError(t, err)
57+ assert.True(t, result.NeedsMigration)
58+ assert.Len(t, result.MissingKeys, 2)
59+ assert.Equal(t, "/path/to/config.toml", result.ConfigFile)
60+
61+ // Verify key info
62+ assert.Equal(t, "key1", result.MissingKeys[0].Key)
63+ assert.Equal(t, "bool", result.MissingKeys[0].Type)
64+ assert.Equal(t, "key2", result.MissingKeys[1].Key)
65+ assert.Equal(t, "string", result.MissingKeys[1].Type)
66+}
67+
68+func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
69+ mockMigrator := mocks.NewMockConfigMigrator(t)
70+ expectedErr := errors.New("check failed")
71+ mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
72+
73+ uc := NewMigrateConfigUseCase(mockMigrator)
74+ ctx := context.Background()
75+
76+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
77+
78+ require.Error(t, err)
79+ assert.Nil(t, result)
80+ assert.Equal(t, expectedErr, err)
81+}
82+
83+func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
84+ mockMigrator := mocks.NewMockConfigMigrator(t)
85+ mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
86+
87+ uc := NewMigrateConfigUseCase(mockMigrator)
88+ ctx := context.Background()
89+
90+ result, err := uc.Execute(ctx, MigrateConfigInput{})
91+
92+ require.NoError(t, err)
93+ assert.Empty(t, result.AddedKeys)
94+}
95+
96+func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
97+ mockMigrator := mocks.NewMockConfigMigrator(t)
98+
99+ migrationResult := &port.MigrationResult{
100+ MissingKeys: []string{"key1", "key2"},
101+ ConfigFile: "/path/to/config.toml",
102+ }
103+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
104+ mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)
105+
106+ uc := NewMigrateConfigUseCase(mockMigrator)
107+ ctx := context.Background()
108+
109+ result, err := uc.Execute(ctx, MigrateConfigInput{})
110+
111+ require.NoError(t, err)
112+ assert.Len(t, result.AddedKeys, 2)
113+ assert.Equal(t, "/path/to/config.toml", result.ConfigFile)
114+}
115+
116+func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
117+ mockMigrator := mocks.NewMockConfigMigrator(t)
118+
119+ migrationResult := &port.MigrationResult{
120+ MissingKeys: []string{"key1"},
121+ ConfigFile: "/path/to/config.toml",
122+ }
123+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
124+
125+ expectedErr := errors.New("migrate failed")
126+ mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)
127+
128+ uc := NewMigrateConfigUseCase(mockMigrator)
129+ ctx := context.Background()
130+
131+ result, err := uc.Execute(ctx, MigrateConfigInput{})
132+
133+ require.Error(t, err)
134+ assert.Nil(t, result)
135+ assert.Equal(t, expectedErr, err)
136+}
137+
138+func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
139+ mockMigrator := mocks.NewMockConfigMigrator(t)
140+
141+ expectedErr := errors.New("check failed")
142+ mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
143+
144+ uc := NewMigrateConfigUseCase(mockMigrator)
145+ ctx := context.Background()
146+
147+ result, err := uc.Execute(ctx, MigrateConfigInput{})
148+
149+ require.Error(t, err)
150+ assert.Nil(t, result)
151+ assert.Equal(t, expectedErr, err)
152+}
153+
154+func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
155+ mockMigrator := mocks.NewMockConfigMigrator(t)
156+
157+ // Return a result with empty missing keys
158+ migrationResult := &port.MigrationResult{
159+ MissingKeys: []string{},
160+ ConfigFile: "/path/to/config.toml",
161+ }
162+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
163+
164+ uc := NewMigrateConfigUseCase(mockMigrator)
165+ ctx := context.Background()
166+
167+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
168+
169+ require.NoError(t, err)
170+ assert.False(t, result.NeedsMigration)
171+ assert.Empty(t, result.MissingKeys)
172+}
173+
174+func TestNewMigrateConfigUseCase(t *testing.T) {
175+ mockMigrator := mocks.NewMockConfigMigrator(t)
176+ uc := NewMigrateConfigUseCase(mockMigrator)
177+
178+ assert.NotNil(t, uc)
179+ assert.Equal(t, mockMigrator, uc.migrator)
180+}
181+
182+// Ensure mock expectations are set up correctly
183+func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
184+ mockMigrator := mocks.NewMockConfigMigrator(t)
185+
186+ // Set up expectations with mock.Anything for flexibility
187+ mockMigrator.On("CheckMigration").Return(&port.MigrationResult{
188+ MissingKeys: []string{"test.key"},
189+ ConfigFile: "/test/config.toml",
190+ }, nil).Once()
191+
192+ mockMigrator.On("GetKeyInfo", mock.AnythingOfType("string")).Return(port.KeyInfo{
193+ Key: "test.key",
194+ Type: "string",
195+ DefaultValue: `"test"`,
196+ }).Once()
197+
198+ uc := NewMigrateConfigUseCase(mockMigrator)
199+ ctx := context.Background()
200+
201+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
202+
203+ require.NoError(t, err)
204+ assert.True(t, result.NeedsMigration)
205+ mockMigrator.AssertExpectations(t)
206+}
A internal/cli/cmd/config.go +306 -0
@@ -0,0 +1,306 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "github.com/charmbracelet/bubbles/spinner"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/cli/styles"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+)
18+
19+var configYes bool
20+
21+var configCmd = &cobra.Command{
22+ Use: "config",
23+ Short: "Manage configuration",
24+ Long: `View configuration status and migrate to add new default settings.`,
25+}
26+
27+var configStatusCmd = &cobra.Command{
28+ Use: "status",
29+ Short: "Show config file status and migration availability",
30+ Long: `Display the config file path and check if any new settings are available.`,
31+ RunE: runConfigStatus,
32+}
33+
34+var configMigrateCmd = &cobra.Command{
35+ Use: "migrate",
36+ Short: "Add missing default settings to config file",
37+ Long: `Compares your config file with available defaults and adds any missing settings.
38+
39+Existing settings are never modified - only missing keys are added with default values.`,
40+ RunE: runConfigMigrate,
41+}
42+
43+func init() {
44+ rootCmd.AddCommand(configCmd)
45+ configCmd.AddCommand(configStatusCmd)
46+ configCmd.AddCommand(configMigrateCmd)
47+ configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
48+}
49+
50+// runConfigStatus shows config file path and migration status.
51+func runConfigStatus(_ *cobra.Command, _ []string) error {
52+ app := GetApp()
53+ if app == nil {
54+ return fmt.Errorf("app not initialized")
55+ }
56+
57+ renderer := styles.NewConfigRenderer(app.Theme)
58+ migrator := config.NewMigrator()
59+ uc := usecase.NewMigrateConfigUseCase(migrator)
60+
61+ // Get config file path
62+ configFile, err := config.GetConfigFile()
63+ if err != nil {
64+ fmt.Println(renderer.RenderError(err))
65+ return nil
66+ }
67+
68+ // Check if config file exists
69+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
70+ fmt.Println(renderer.RenderNoConfigFile(configFile))
71+ return nil
72+ }
73+
74+ // Check for migration
75+ ctx := context.Background()
76+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
77+ if err != nil {
78+ fmt.Println(renderer.RenderError(err))
79+ return nil
80+ }
81+
82+ if !result.NeedsMigration {
83+ fmt.Println(renderer.RenderUpToDate(configFile))
84+ return nil
85+ }
86+
87+ // Show status with missing count
88+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
89+ fmt.Println(renderer.RenderMigrateHint())
90+
91+ return nil
92+}
93+
94+// runConfigMigrate runs the migration with optional confirmation.
95+func runConfigMigrate(_ *cobra.Command, _ []string) error {
96+ app := GetApp()
97+ if app == nil {
98+ return fmt.Errorf("app not initialized")
99+ }
100+
101+ renderer := styles.NewConfigRenderer(app.Theme)
102+ migrator := config.NewMigrator()
103+ uc := usecase.NewMigrateConfigUseCase(migrator)
104+
105+ // Get config file path
106+ configFile, err := config.GetConfigFile()
107+ if err != nil {
108+ fmt.Println(renderer.RenderError(err))
109+ return nil
110+ }
111+
112+ // Check if config file exists
113+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
114+ fmt.Println(renderer.RenderNoConfigFile(configFile))
115+ return nil
116+ }
117+
118+ // Check for migration
119+ ctx := context.Background()
120+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
121+ if err != nil {
122+ fmt.Println(renderer.RenderError(err))
123+ return nil
124+ }
125+
126+ if !result.NeedsMigration {
127+ fmt.Println(renderer.RenderUpToDate(configFile))
128+ return nil
129+ }
130+
131+ // Show config info and missing keys
132+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
133+ fmt.Println(renderer.RenderMissingKeys(result.MissingKeys))
134+
135+ // If --yes flag, proceed without confirmation
136+ if configYes {
137+ return executeMigration(ctx, uc, renderer)
138+ }
139+
140+ // Show confirmation dialog
141+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys)
142+}
143+
144+// executeMigration performs the actual migration.
145+func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
146+ result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
147+ if err != nil {
148+ fmt.Println(renderer.RenderError(err))
149+ return nil
150+ }
151+
152+ if len(result.AddedKeys) > 0 {
153+ fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
154+ }
155+
156+ return nil
157+}
158+
159+// migrateState represents the current state of the migrate confirmation.
160+type migrateState int
161+
162+const (
163+ migrateStateConfirm migrateState = iota
164+ migrateStateDone
165+)
166+
167+// migrateModel is the bubbletea model for the migrate confirmation.
168+type migrateModel struct {
169+ spinner spinner.Model
170+ renderer *styles.ConfigRenderer
171+ confirm styles.ConfirmModel
172+ state migrateState
173+ uc *usecase.MigrateConfigUseCase
174+ missingKeys []port.KeyInfo
175+
176+ result string
177+ err error
178+ quitting bool
179+}
180+
181+// migrateResultMsg is sent when the migration completes.
182+type migrateResultMsg struct {
183+ output *usecase.MigrateConfigOutput
184+ err error
185+}
186+
187+func newMigrateModel(
188+ renderer *styles.ConfigRenderer,
189+ theme *styles.Theme,
190+ uc *usecase.MigrateConfigUseCase,
191+ missingKeys []port.KeyInfo,
192+) migrateModel {
193+ s := spinner.New()
194+ s.Spinner = spinner.Dot
195+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
196+
197+ confirm := styles.NewConfirm(theme, "Add these settings with default values?")
198+
199+ return migrateModel{
200+ spinner: s,
201+ renderer: renderer,
202+ confirm: confirm,
203+ state: migrateStateConfirm,
204+ uc: uc,
205+ missingKeys: missingKeys,
206+ }
207+}
208+
209+func (m migrateModel) Init() tea.Cmd {
210+ return m.spinner.Tick
211+}
212+
213+func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
214+ switch msg := msg.(type) {
215+ case tea.KeyMsg:
216+ switch msg.String() {
217+ case "ctrl+c":
218+ m.quitting = true
219+ return m, tea.Quit
220+ }
221+
222+ case spinner.TickMsg:
223+ var cmd tea.Cmd
224+ m.spinner, cmd = m.spinner.Update(msg)
225+ return m, cmd
226+
227+ case migrateResultMsg:
228+ m.state = migrateStateDone
229+ if msg.err != nil {
230+ m.err = msg.err
231+ return m, tea.Quit
232+ }
233+
234+ if len(msg.output.AddedKeys) > 0 {
235+ m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
236+ }
237+ return m, tea.Quit
238+ }
239+
240+ // Handle confirm dialog
241+ if m.state == migrateStateConfirm {
242+ var cmd tea.Cmd
243+ m.confirm, cmd = m.confirm.Update(msg)
244+
245+ if m.confirm.Done() {
246+ if m.confirm.Result() {
247+ // User confirmed, run migration
248+ return m, m.runMigration()
249+ }
250+ // User canceled
251+ m.quitting = true
252+ return m, tea.Quit
253+ }
254+
255+ return m, cmd
256+ }
257+
258+ return m, nil
259+}
260+
261+func (m migrateModel) View() string {
262+ if m.quitting {
263+ return ""
264+ }
265+
266+ if m.err != nil {
267+ return m.renderer.RenderError(m.err)
268+ }
269+
270+ if m.state == migrateStateDone {
271+ return m.result
272+ }
273+
274+ // Show confirmation dialog
275+ return m.confirm.View()
276+}
277+
278+func (m migrateModel) runMigration() tea.Cmd {
279+ return func() tea.Msg {
280+ ctx := context.Background()
281+ result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
282+ return migrateResultMsg{output: result, err: err}
283+ }
284+}
285+
286+// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
287+func runMigrateWithConfirmation(
288+ ctx context.Context,
289+ uc *usecase.MigrateConfigUseCase,
290+ renderer *styles.ConfigRenderer,
291+ theme *styles.Theme,
292+ missingKeys []port.KeyInfo,
293+) error {
294+ // Suppress unused ctx warning - kept for consistency with other run functions
295+ _ = ctx
296+
297+ m := newMigrateModel(renderer, theme, uc, missingKeys)
298+ p := tea.NewProgram(m)
299+
300+ _, err := p.Run()
301+ if err != nil {
302+ return fmt.Errorf("migration failed: %w", err)
303+ }
304+
305+ return nil
306+}
@@ -0,0 +1,306 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "github.com/charmbracelet/bubbles/spinner"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/cli/styles"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+)
18+
19+var configYes bool
20+
21+var configCmd = &cobra.Command{
22+ Use: "config",
23+ Short: "Manage configuration",
24+ Long: `View configuration status and migrate to add new default settings.`,
25+}
26+
27+var configStatusCmd = &cobra.Command{
28+ Use: "status",
29+ Short: "Show config file status and migration availability",
30+ Long: `Display the config file path and check if any new settings are available.`,
31+ RunE: runConfigStatus,
32+}
33+
34+var configMigrateCmd = &cobra.Command{
35+ Use: "migrate",
36+ Short: "Add missing default settings to config file",
37+ Long: `Compares your config file with available defaults and adds any missing settings.
38+
39+Existing settings are never modified - only missing keys are added with default values.`,
40+ RunE: runConfigMigrate,
41+}
42+
43+func init() {
44+ rootCmd.AddCommand(configCmd)
45+ configCmd.AddCommand(configStatusCmd)
46+ configCmd.AddCommand(configMigrateCmd)
47+ configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
48+}
49+
50+// runConfigStatus shows config file path and migration status.
51+func runConfigStatus(_ *cobra.Command, _ []string) error {
52+ app := GetApp()
53+ if app == nil {
54+ return fmt.Errorf("app not initialized")
55+ }
56+
57+ renderer := styles.NewConfigRenderer(app.Theme)
58+ migrator := config.NewMigrator()
59+ uc := usecase.NewMigrateConfigUseCase(migrator)
60+
61+ // Get config file path
62+ configFile, err := config.GetConfigFile()
63+ if err != nil {
64+ fmt.Println(renderer.RenderError(err))
65+ return nil
66+ }
67+
68+ // Check if config file exists
69+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
70+ fmt.Println(renderer.RenderNoConfigFile(configFile))
71+ return nil
72+ }
73+
74+ // Check for migration
75+ ctx := context.Background()
76+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
77+ if err != nil {
78+ fmt.Println(renderer.RenderError(err))
79+ return nil
80+ }
81+
82+ if !result.NeedsMigration {
83+ fmt.Println(renderer.RenderUpToDate(configFile))
84+ return nil
85+ }
86+
87+ // Show status with missing count
88+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
89+ fmt.Println(renderer.RenderMigrateHint())
90+
91+ return nil
92+}
93+
94+// runConfigMigrate runs the migration with optional confirmation.
95+func runConfigMigrate(_ *cobra.Command, _ []string) error {
96+ app := GetApp()
97+ if app == nil {
98+ return fmt.Errorf("app not initialized")
99+ }
100+
101+ renderer := styles.NewConfigRenderer(app.Theme)
102+ migrator := config.NewMigrator()
103+ uc := usecase.NewMigrateConfigUseCase(migrator)
104+
105+ // Get config file path
106+ configFile, err := config.GetConfigFile()
107+ if err != nil {
108+ fmt.Println(renderer.RenderError(err))
109+ return nil
110+ }
111+
112+ // Check if config file exists
113+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
114+ fmt.Println(renderer.RenderNoConfigFile(configFile))
115+ return nil
116+ }
117+
118+ // Check for migration
119+ ctx := context.Background()
120+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
121+ if err != nil {
122+ fmt.Println(renderer.RenderError(err))
123+ return nil
124+ }
125+
126+ if !result.NeedsMigration {
127+ fmt.Println(renderer.RenderUpToDate(configFile))
128+ return nil
129+ }
130+
131+ // Show config info and missing keys
132+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
133+ fmt.Println(renderer.RenderMissingKeys(result.MissingKeys))
134+
135+ // If --yes flag, proceed without confirmation
136+ if configYes {
137+ return executeMigration(ctx, uc, renderer)
138+ }
139+
140+ // Show confirmation dialog
141+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys)
142+}
143+
144+// executeMigration performs the actual migration.
145+func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
146+ result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
147+ if err != nil {
148+ fmt.Println(renderer.RenderError(err))
149+ return nil
150+ }
151+
152+ if len(result.AddedKeys) > 0 {
153+ fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
154+ }
155+
156+ return nil
157+}
158+
159+// migrateState represents the current state of the migrate confirmation.
160+type migrateState int
161+
162+const (
163+ migrateStateConfirm migrateState = iota
164+ migrateStateDone
165+)
166+
167+// migrateModel is the bubbletea model for the migrate confirmation.
168+type migrateModel struct {
169+ spinner spinner.Model
170+ renderer *styles.ConfigRenderer
171+ confirm styles.ConfirmModel
172+ state migrateState
173+ uc *usecase.MigrateConfigUseCase
174+ missingKeys []port.KeyInfo
175+
176+ result string
177+ err error
178+ quitting bool
179+}
180+
181+// migrateResultMsg is sent when the migration completes.
182+type migrateResultMsg struct {
183+ output *usecase.MigrateConfigOutput
184+ err error
185+}
186+
187+func newMigrateModel(
188+ renderer *styles.ConfigRenderer,
189+ theme *styles.Theme,
190+ uc *usecase.MigrateConfigUseCase,
191+ missingKeys []port.KeyInfo,
192+) migrateModel {
193+ s := spinner.New()
194+ s.Spinner = spinner.Dot
195+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
196+
197+ confirm := styles.NewConfirm(theme, "Add these settings with default values?")
198+
199+ return migrateModel{
200+ spinner: s,
201+ renderer: renderer,
202+ confirm: confirm,
203+ state: migrateStateConfirm,
204+ uc: uc,
205+ missingKeys: missingKeys,
206+ }
207+}
208+
209+func (m migrateModel) Init() tea.Cmd {
210+ return m.spinner.Tick
211+}
212+
213+func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
214+ switch msg := msg.(type) {
215+ case tea.KeyMsg:
216+ switch msg.String() {
217+ case "ctrl+c":
218+ m.quitting = true
219+ return m, tea.Quit
220+ }
221+
222+ case spinner.TickMsg:
223+ var cmd tea.Cmd
224+ m.spinner, cmd = m.spinner.Update(msg)
225+ return m, cmd
226+
227+ case migrateResultMsg:
228+ m.state = migrateStateDone
229+ if msg.err != nil {
230+ m.err = msg.err
231+ return m, tea.Quit
232+ }
233+
234+ if len(msg.output.AddedKeys) > 0 {
235+ m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
236+ }
237+ return m, tea.Quit
238+ }
239+
240+ // Handle confirm dialog
241+ if m.state == migrateStateConfirm {
242+ var cmd tea.Cmd
243+ m.confirm, cmd = m.confirm.Update(msg)
244+
245+ if m.confirm.Done() {
246+ if m.confirm.Result() {
247+ // User confirmed, run migration
248+ return m, m.runMigration()
249+ }
250+ // User canceled
251+ m.quitting = true
252+ return m, tea.Quit
253+ }
254+
255+ return m, cmd
256+ }
257+
258+ return m, nil
259+}
260+
261+func (m migrateModel) View() string {
262+ if m.quitting {
263+ return ""
264+ }
265+
266+ if m.err != nil {
267+ return m.renderer.RenderError(m.err)
268+ }
269+
270+ if m.state == migrateStateDone {
271+ return m.result
272+ }
273+
274+ // Show confirmation dialog
275+ return m.confirm.View()
276+}
277+
278+func (m migrateModel) runMigration() tea.Cmd {
279+ return func() tea.Msg {
280+ ctx := context.Background()
281+ result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
282+ return migrateResultMsg{output: result, err: err}
283+ }
284+}
285+
286+// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
287+func runMigrateWithConfirmation(
288+ ctx context.Context,
289+ uc *usecase.MigrateConfigUseCase,
290+ renderer *styles.ConfigRenderer,
291+ theme *styles.Theme,
292+ missingKeys []port.KeyInfo,
293+) error {
294+ // Suppress unused ctx warning - kept for consistency with other run functions
295+ _ = ctx
296+
297+ m := newMigrateModel(renderer, theme, uc, missingKeys)
298+ p := tea.NewProgram(m)
299+
300+ _, err := p.Run()
301+ if err != nil {
302+ return fmt.Errorf("migration failed: %w", err)
303+ }
304+
305+ return nil
306+}
A internal/cli/styles/config.go +140 -0
@@ -0,0 +1,140 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+// ConfigRenderer renders config status messages with styled output.
14+type ConfigRenderer struct {
15+ theme *Theme
16+}
17+
18+// NewConfigRenderer creates a new config renderer with the given theme.
19+func NewConfigRenderer(theme *Theme) *ConfigRenderer {
20+ return &ConfigRenderer{theme: theme}
21+}
22+
23+// RenderConfigInfo renders the config file info with status.
24+func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string {
25+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
26+ pathStyle := r.theme.Subtle
27+
28+ var status string
29+ if missingCount > 0 {
30+ countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
31+ status = fmt.Sprintf("\n %s %s new settings available",
32+ iconStyle.Render(IconInfo),
33+ countStyle.Render(fmt.Sprintf("%d", missingCount)),
34+ )
35+ }
36+
37+ return fmt.Sprintf(
38+ "\n %s Config %s%s\n",
39+ iconStyle.Render(IconConfig),
40+ pathStyle.Render(path),
41+ status,
42+ )
43+}
44+
45+// RenderMissingKeys renders the list of missing keys with their types and default values.
46+func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
47+ if len(keys) == 0 {
48+ return ""
49+ }
50+
51+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
52+ keyStyle := r.theme.Highlight
53+ typeStyle := r.theme.Subtle
54+ valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
55+
56+ var sb strings.Builder
57+ sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys)))
58+
59+ for _, key := range keys {
60+ sb.WriteString(fmt.Sprintf(
61+ " %s %s\n Type: %s | Default: %s\n",
62+ iconStyle.Render(IconCursor),
63+ keyStyle.Render(key.Key),
64+ typeStyle.Render(key.Type),
65+ valueStyle.Render(key.DefaultValue),
66+ ))
67+ }
68+
69+ return sb.String()
70+}
71+
72+// RenderMigrationSuccess renders the success message after migration.
73+func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
74+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
75+ countStyle := r.theme.Highlight
76+ pathStyle := r.theme.Subtle
77+
78+ // Get just the filename from the path
79+ filename := filepath.Base(path)
80+
81+ return fmt.Sprintf(
82+ "\n %s Added %s new settings to %s\n",
83+ iconStyle.Render(IconCheck),
84+ countStyle.Render(fmt.Sprintf("%d", count)),
85+ pathStyle.Render(filename),
86+ )
87+}
88+
89+// RenderUpToDate renders the "config is up to date" message.
90+func (r *ConfigRenderer) RenderUpToDate(path string) string {
91+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
92+ pathStyle := r.theme.Subtle
93+
94+ return fmt.Sprintf(
95+ "\n %s Config %s\n %s Config is up to date\n",
96+ iconStyle.Render(IconConfig),
97+ pathStyle.Render(path),
98+ iconStyle.Render(IconCheck),
99+ )
100+}
101+
102+// RenderError renders an error message.
103+func (r *ConfigRenderer) RenderError(err error) string {
104+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
105+
106+ return fmt.Sprintf(
107+ "\n %s Config error: %v\n",
108+ iconStyle.Render(IconX),
109+ err,
110+ )
111+}
112+
113+// RenderChecking renders the "checking config..." message with spinner.
114+func (*ConfigRenderer) RenderChecking(spinner string) string {
115+ return fmt.Sprintf("\n %s Checking config...\n", spinner)
116+}
117+
118+// RenderMigrateHint renders a hint to run the migrate command.
119+func (r *ConfigRenderer) RenderMigrateHint() string {
120+ hintStyle := r.theme.Subtle
121+
122+ return fmt.Sprintf(
123+ "\n %s\n",
124+ hintStyle.Render("Run 'dumber config migrate' to add missing defaults."),
125+ )
126+}
127+
128+// RenderNoConfigFile renders message when config file doesn't exist yet.
129+func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
130+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
131+ pathStyle := r.theme.Subtle
132+ hintStyle := r.theme.Subtle
133+
134+ return fmt.Sprintf(
135+ "\n %s Config %s\n %s\n",
136+ iconStyle.Render(IconConfig),
137+ pathStyle.Render(path),
138+ hintStyle.Render("Config file will be created on first run with all defaults."),
139+ )
140+}
@@ -0,0 +1,140 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+// ConfigRenderer renders config status messages with styled output.
14+type ConfigRenderer struct {
15+ theme *Theme
16+}
17+
18+// NewConfigRenderer creates a new config renderer with the given theme.
19+func NewConfigRenderer(theme *Theme) *ConfigRenderer {
20+ return &ConfigRenderer{theme: theme}
21+}
22+
23+// RenderConfigInfo renders the config file info with status.
24+func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string {
25+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
26+ pathStyle := r.theme.Subtle
27+
28+ var status string
29+ if missingCount > 0 {
30+ countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
31+ status = fmt.Sprintf("\n %s %s new settings available",
32+ iconStyle.Render(IconInfo),
33+ countStyle.Render(fmt.Sprintf("%d", missingCount)),
34+ )
35+ }
36+
37+ return fmt.Sprintf(
38+ "\n %s Config %s%s\n",
39+ iconStyle.Render(IconConfig),
40+ pathStyle.Render(path),
41+ status,
42+ )
43+}
44+
45+// RenderMissingKeys renders the list of missing keys with their types and default values.
46+func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
47+ if len(keys) == 0 {
48+ return ""
49+ }
50+
51+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
52+ keyStyle := r.theme.Highlight
53+ typeStyle := r.theme.Subtle
54+ valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
55+
56+ var sb strings.Builder
57+ sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys)))
58+
59+ for _, key := range keys {
60+ sb.WriteString(fmt.Sprintf(
61+ " %s %s\n Type: %s | Default: %s\n",
62+ iconStyle.Render(IconCursor),
63+ keyStyle.Render(key.Key),
64+ typeStyle.Render(key.Type),
65+ valueStyle.Render(key.DefaultValue),
66+ ))
67+ }
68+
69+ return sb.String()
70+}
71+
72+// RenderMigrationSuccess renders the success message after migration.
73+func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
74+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
75+ countStyle := r.theme.Highlight
76+ pathStyle := r.theme.Subtle
77+
78+ // Get just the filename from the path
79+ filename := filepath.Base(path)
80+
81+ return fmt.Sprintf(
82+ "\n %s Added %s new settings to %s\n",
83+ iconStyle.Render(IconCheck),
84+ countStyle.Render(fmt.Sprintf("%d", count)),
85+ pathStyle.Render(filename),
86+ )
87+}
88+
89+// RenderUpToDate renders the "config is up to date" message.
90+func (r *ConfigRenderer) RenderUpToDate(path string) string {
91+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
92+ pathStyle := r.theme.Subtle
93+
94+ return fmt.Sprintf(
95+ "\n %s Config %s\n %s Config is up to date\n",
96+ iconStyle.Render(IconConfig),
97+ pathStyle.Render(path),
98+ iconStyle.Render(IconCheck),
99+ )
100+}
101+
102+// RenderError renders an error message.
103+func (r *ConfigRenderer) RenderError(err error) string {
104+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
105+
106+ return fmt.Sprintf(
107+ "\n %s Config error: %v\n",
108+ iconStyle.Render(IconX),
109+ err,
110+ )
111+}
112+
113+// RenderChecking renders the "checking config..." message with spinner.
114+func (*ConfigRenderer) RenderChecking(spinner string) string {
115+ return fmt.Sprintf("\n %s Checking config...\n", spinner)
116+}
117+
118+// RenderMigrateHint renders a hint to run the migrate command.
119+func (r *ConfigRenderer) RenderMigrateHint() string {
120+ hintStyle := r.theme.Subtle
121+
122+ return fmt.Sprintf(
123+ "\n %s\n",
124+ hintStyle.Render("Run 'dumber config migrate' to add missing defaults."),
125+ )
126+}
127+
128+// RenderNoConfigFile renders message when config file doesn't exist yet.
129+func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
130+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
131+ pathStyle := r.theme.Subtle
132+ hintStyle := r.theme.Subtle
133+
134+ return fmt.Sprintf(
135+ "\n %s Config %s\n %s\n",
136+ iconStyle.Render(IconConfig),
137+ pathStyle.Render(path),
138+ hintStyle.Render("Config file will be created on first run with all defaults."),
139+ )
140+}
M internal/infrastructure/config/defaults.go +3 -2
@@ -292,8 +292,9 @@ func DefaultConfig() *Config {
292292 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
293293 },
294294 Update: UpdateConfig{
295- EnableOnStartup: true, // Check for updates on startup by default
296- AutoDownload: false, // Conservative: don't auto-download by default
295+ EnableOnStartup: true, // Check for updates on startup by default
296+ AutoDownload: false, // Conservative: don't auto-download by default
297+ NotifyOnNewSettings: true, // Show toast when new config settings available
297298 },
298299 }
299300 }
@@ -292,8 +292,9 @@ func DefaultConfig() *Config {
292 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,292 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
293 },293 },
294 Update: UpdateConfig{294 Update: UpdateConfig{
295- EnableOnStartup: true, // Check for updates on startup by default295+ EnableOnStartup: true, // Check for updates on startup by default
296- AutoDownload: false, // Conservative: don't auto-download by default296+ AutoDownload: false, // Conservative: don't auto-download by default
297+ NotifyOnNewSettings: true, // Show toast when new config settings available
297 },298 },
298 }299 }
299 }300 }
M internal/infrastructure/config/loader.go +1 -0
@@ -460,6 +460,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
460460 func (m *Manager) setUpdateDefaults(defaults *Config) {
461461 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
462462 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
463+ m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
463464 }
464465
465466 // New returns a new default configuration instance.
@@ -460,6 +460,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
460 func (m *Manager) setUpdateDefaults(defaults *Config) {460 func (m *Manager) setUpdateDefaults(defaults *Config) {
461 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)461 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
462 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)462 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
463+ m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
463 }464 }
464 465
465 // New returns a new default configuration instance.466 // New returns a new default configuration instance.
A internal/infrastructure/config/migrate.go +341 -0
@@ -0,0 +1,341 @@
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 strings.HasSuffix(keyPath, 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 related key exists in user config
216+ if m.keyOrRelatedExists(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+// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
228+// This handles cases where:
229+// - User has defined a parent section but not all children
230+// - Default is a struct key but user has individual sub-keys (e.g., palettes)
231+func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
232+ if keys[key] {
233+ return true
234+ }
235+
236+ // Check if this is a child of a leaf map (like search_shortcuts.ddg)
237+ // In that case, we should check if the parent exists
238+ parts := strings.Split(key, ".")
239+ for i := len(parts) - 1; i > 0; i-- {
240+ parentKey := strings.Join(parts[:i], ".")
241+ if keys[parentKey] {
242+ // Parent exists as a leaf map, so child is implicitly defined
243+ return true
244+ }
245+ }
246+
247+ // Check if any child keys exist (e.g., default is "appearance.dark_palette"
248+ // but user has "appearance.dark_palette.background")
249+ keyPrefix := key + "."
250+ for userKey := range keys {
251+ if strings.HasPrefix(userKey, keyPrefix) {
252+ return true
253+ }
254+ }
255+
256+ return false
257+}
258+
259+// getTypeName returns a human-readable type name for a value.
260+func (*Migrator) getTypeName(value any) string {
261+ if value == nil {
262+ return "unknown"
263+ }
264+
265+ t := reflect.TypeOf(value)
266+ switch t.Kind() {
267+ case reflect.Bool:
268+ return "bool"
269+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
270+ return "int"
271+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
272+ return "int"
273+ case reflect.Float32, reflect.Float64:
274+ return "float"
275+ case reflect.String:
276+ return "string"
277+ case reflect.Slice:
278+ return "list"
279+ case reflect.Map:
280+ return "map"
281+ default:
282+ return t.String()
283+ }
284+}
285+
286+// formatValue returns a human-readable string representation of a value.
287+func (*Migrator) formatValue(value any) string {
288+ if value == nil {
289+ return "null"
290+ }
291+
292+ switch v := value.(type) {
293+ case bool:
294+ if v {
295+ return "true"
296+ }
297+ return "false"
298+ case int, int8, int16, int32, int64:
299+ return fmt.Sprintf("%d", v)
300+ case uint, uint8, uint16, uint32, uint64:
301+ return fmt.Sprintf("%d", v)
302+ case float32, float64:
303+ return fmt.Sprintf("%v", v)
304+ case string:
305+ if v == "" {
306+ return `""`
307+ }
308+ // Truncate long strings
309+ const maxStringLen = 50
310+ if len(v) > maxStringLen {
311+ return fmt.Sprintf("%q...", v[:maxStringLen-3])
312+ }
313+ return fmt.Sprintf("%q", v)
314+ case []any:
315+ if len(v) == 0 {
316+ return "[]"
317+ }
318+ return fmt.Sprintf("[%d items]", len(v))
319+ case map[string]any:
320+ if len(v) == 0 {
321+ return "{}"
322+ }
323+ return fmt.Sprintf("{%d entries}", len(v))
324+ default:
325+ // Handle slices of strings (common for action bindings)
326+ rv := reflect.ValueOf(value)
327+ if rv.Kind() == reflect.Slice {
328+ if rv.Len() == 0 {
329+ return "[]"
330+ }
331+ return fmt.Sprintf("[%d items]", rv.Len())
332+ }
333+ if rv.Kind() == reflect.Map {
334+ if rv.Len() == 0 {
335+ return "{}"
336+ }
337+ return fmt.Sprintf("{%d entries}", rv.Len())
338+ }
339+ return fmt.Sprintf("%v", v)
340+ }
341+}
@@ -0,0 +1,341 @@
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 strings.HasSuffix(keyPath, 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 related key exists in user config
216+ if m.keyOrRelatedExists(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+// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
228+// This handles cases where:
229+// - User has defined a parent section but not all children
230+// - Default is a struct key but user has individual sub-keys (e.g., palettes)
231+func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
232+ if keys[key] {
233+ return true
234+ }
235+
236+ // Check if this is a child of a leaf map (like search_shortcuts.ddg)
237+ // In that case, we should check if the parent exists
238+ parts := strings.Split(key, ".")
239+ for i := len(parts) - 1; i > 0; i-- {
240+ parentKey := strings.Join(parts[:i], ".")
241+ if keys[parentKey] {
242+ // Parent exists as a leaf map, so child is implicitly defined
243+ return true
244+ }
245+ }
246+
247+ // Check if any child keys exist (e.g., default is "appearance.dark_palette"
248+ // but user has "appearance.dark_palette.background")
249+ keyPrefix := key + "."
250+ for userKey := range keys {
251+ if strings.HasPrefix(userKey, keyPrefix) {
252+ return true
253+ }
254+ }
255+
256+ return false
257+}
258+
259+// getTypeName returns a human-readable type name for a value.
260+func (*Migrator) getTypeName(value any) string {
261+ if value == nil {
262+ return "unknown"
263+ }
264+
265+ t := reflect.TypeOf(value)
266+ switch t.Kind() {
267+ case reflect.Bool:
268+ return "bool"
269+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
270+ return "int"
271+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
272+ return "int"
273+ case reflect.Float32, reflect.Float64:
274+ return "float"
275+ case reflect.String:
276+ return "string"
277+ case reflect.Slice:
278+ return "list"
279+ case reflect.Map:
280+ return "map"
281+ default:
282+ return t.String()
283+ }
284+}
285+
286+// formatValue returns a human-readable string representation of a value.
287+func (*Migrator) formatValue(value any) string {
288+ if value == nil {
289+ return "null"
290+ }
291+
292+ switch v := value.(type) {
293+ case bool:
294+ if v {
295+ return "true"
296+ }
297+ return "false"
298+ case int, int8, int16, int32, int64:
299+ return fmt.Sprintf("%d", v)
300+ case uint, uint8, uint16, uint32, uint64:
301+ return fmt.Sprintf("%d", v)
302+ case float32, float64:
303+ return fmt.Sprintf("%v", v)
304+ case string:
305+ if v == "" {
306+ return `""`
307+ }
308+ // Truncate long strings
309+ const maxStringLen = 50
310+ if len(v) > maxStringLen {
311+ return fmt.Sprintf("%q...", v[:maxStringLen-3])
312+ }
313+ return fmt.Sprintf("%q", v)
314+ case []any:
315+ if len(v) == 0 {
316+ return "[]"
317+ }
318+ return fmt.Sprintf("[%d items]", len(v))
319+ case map[string]any:
320+ if len(v) == 0 {
321+ return "{}"
322+ }
323+ return fmt.Sprintf("{%d entries}", len(v))
324+ default:
325+ // Handle slices of strings (common for action bindings)
326+ rv := reflect.ValueOf(value)
327+ if rv.Kind() == reflect.Slice {
328+ if rv.Len() == 0 {
329+ return "[]"
330+ }
331+ return fmt.Sprintf("[%d items]", rv.Len())
332+ }
333+ if rv.Kind() == reflect.Map {
334+ if rv.Len() == 0 {
335+ return "{}"
336+ }
337+ return fmt.Sprintf("{%d entries}", rv.Len())
338+ }
339+ return fmt.Sprintf("%v", v)
340+ }
341+}
A internal/infrastructure/config/migrate_test.go +353 -0
@@ -0,0 +1,353 @@
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+ 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+
248+func 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+
288+func 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+
314+func 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+
343+func TestMigrator_CheckMigration_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+}
348+
349+func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
350+ // This test would need to temporarily modify the config path
351+ // which is difficult without refactoring. Skip for now.
352+ t.Skip("requires config path mocking")
353+}
@@ -0,0 +1,353 @@
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+ 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+
248+func 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+
288+func 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+
314+func 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+
343+func TestMigrator_CheckMigration_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+}
348+
349+func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
350+ // This test would need to temporarily modify the config path
351+ // which is difficult without refactoring. Skip for now.
352+ t.Skip("requires config path mocking")
353+}
M internal/infrastructure/config/schema.go +3 -0
@@ -294,6 +294,9 @@ type UpdateConfig struct {
294294 // When enabled, updates are applied on browser exit.
295295 // Default: false
296296 AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
297+ // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
298+ // Default: true
299+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
297300 }
298301
299302 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
@@ -294,6 +294,9 @@ type UpdateConfig struct {
294 // When enabled, updates are applied on browser exit.294 // When enabled, updates are applied on browser exit.
295 // Default: false295 // Default: false
296 AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`296 AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
297+ // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
298+ // Default: true
299+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
297 }300 }
298 301
299 // WorkspaceConfig captures layout, pane, and tab behavior preferences.302 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
M internal/ui/app.go +3 -0
@@ -929,6 +929,9 @@ func (a *App) finalizeActivation(ctx context.Context) {
929929
930930 // Start async filter loading after window is visible.
931931 a.initFilteringAsync(ctx)
932+
933+ // Check for config migration after window is visible.
934+ a.checkConfigMigration(ctx)
932935 }
933936
934937 // onShutdown is called when the GTK application is shutting down.
@@ -929,6 +929,9 @@ func (a *App) finalizeActivation(ctx context.Context) {
929 929
930 // Start async filter loading after window is visible.930 // Start async filter loading after window is visible.
931 a.initFilteringAsync(ctx)931 a.initFilteringAsync(ctx)
932+
933+ // Check for config migration after window is visible.
934+ a.checkConfigMigration(ctx)
932 }935 }
933 936
934 // onShutdown is called when the GTK application is shutting down.937 // onShutdown is called when the GTK application is shutting down.
A internal/ui/config_migration.go +38 -0
@@ -0,0 +1,38 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+// checkConfigMigration checks if user config needs migration and shows a toast.
13+func (a *App) checkConfigMigration(ctx context.Context) {
14+ log := logging.FromContext(ctx)
15+ cfg := config.Get()
16+
17+ migrator := config.NewMigrator()
18+ result, err := migrator.CheckMigration()
19+ if err != nil {
20+ log.Debug().Err(err).Msg("config migration check failed")
21+ return
22+ }
23+
24+ if result == nil || len(result.MissingKeys) == 0 {
25+ return // No migration needed
26+ }
27+
28+ // Log is always shown for debugging purposes
29+ log.Info().
30+ Int("missing_keys", len(result.MissingKeys)).
31+ Msg("config migration available")
32+
33+ // Show toast notification if enabled
34+ if cfg.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
36+ a.appToaster.Show(ctx, msg, component.ToastInfo)
37+ }
38+}
@@ -0,0 +1,38 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+// checkConfigMigration checks if user config needs migration and shows a toast.
13+func (a *App) checkConfigMigration(ctx context.Context) {
14+ log := logging.FromContext(ctx)
15+ cfg := config.Get()
16+
17+ migrator := config.NewMigrator()
18+ result, err := migrator.CheckMigration()
19+ if err != nil {
20+ log.Debug().Err(err).Msg("config migration check failed")
21+ return
22+ }
23+
24+ if result == nil || len(result.MissingKeys) == 0 {
25+ return // No migration needed
26+ }
27+
28+ // Log is always shown for debugging purposes
29+ log.Info().
30+ Int("missing_keys", len(result.MissingKeys)).
31+ Msg("config migration available")
32+
33+ // Show toast notification if enabled
34+ if cfg.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
36+ a.appToaster.Show(ctx, msg, component.ToastInfo)
37+ }
38+}

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