Merge pull request #45 from bnema/feature/config-migrate
feat(config): add config migration to detect and add missing settings
18 files · +1773 -5
M .mockery.yaml +1 -0
| @@ -8,6 +8,7 @@ packages: | |||
| 8 | 8 | filename: mocks.go | |
| 9 | 9 | pkgname: mocks | |
| 10 | 10 | interfaces: | |
| 11 | + | ConfigMigrator: {} | |
| 11 | 12 | SessionLogger: {} | |
| 12 | 13 | SessionSpawner: {} | |
| 13 | 14 | TabListProvider: {} | |
| @@ -8,6 +8,7 @@ packages: | |||||
| 8 | filename: mocks.go | 8 | filename: mocks.go | ||
| 9 | pkgname: mocks | 9 | 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. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### 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 | 12 | - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line. | |
| 9 | 13 | - Interactive spinner animation during check and download phases. | |
| 10 | 14 | - `--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 | ### Added | 7 | ### 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 | |||
| 261 | 261 | - Clean up data: | |
| 262 | 262 | - `dumber purge` # interactive selection (TUI) | |
| 263 | 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 | 268 | - Manage sessions: | |
| 265 | 269 | - `dumber sessions` # interactive session browser (TUI) | |
| 266 | 270 | - `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 sessions | 270 | - `dumber sessions list` # list saved sessions | ||
M docs/CONFIG.md +28 -0
| @@ -451,6 +451,34 @@ Notes: | |||
| 451 | 451 | - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases. | |
| 452 | 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 | 482 | ## Environment Variables | |
| 455 | 483 | ||
| 456 | 484 | 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 Variables | 482 | ## 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 ( | |||
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 10 | github.com/fsnotify/fsnotify v1.9.0 | |
| 11 | + | github.com/godbus/dbus/v5 v5.2.1 | |
| 11 | 12 | github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3 | |
| 12 | 13 | github.com/ncruces/go-sqlite3 v0.30.3 | |
| 14 | + | github.com/pelletier/go-toml/v2 v2.2.4 | |
| 13 | 15 | github.com/pressly/goose/v3 v3.26.0 | |
| 14 | 16 | github.com/rs/zerolog v1.33.0 | |
| 15 | 17 | github.com/spf13/cobra v1.10.2 | |
| 16 | 18 | github.com/spf13/viper v1.21.0 | |
| 17 | 19 | github.com/stretchr/testify v1.11.1 | |
| 18 | 20 | golang.org/x/image v0.34.0 | |
| 21 | + | golang.org/x/sys v0.38.0 | |
| 19 | 22 | ) | |
| 20 | 23 | ||
| 21 | 24 | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | |
| @@ -30,7 +33,6 @@ require ( | |||
| 30 | 33 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 31 | 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 32 | 35 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 33 | - | github.com/godbus/dbus/v5 v5.2.1 // indirect | |
| 34 | 36 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 35 | 37 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | |
| 36 | 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| @@ -43,7 +45,6 @@ require ( | |||
| 43 | 45 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 44 | 46 | github.com/muesli/termenv v0.16.0 // indirect | |
| 45 | 47 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 46 | - | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 47 | 48 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 48 | 49 | github.com/rivo/uniseg v0.4.7 // indirect | |
| 49 | 50 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| @@ -60,7 +61,6 @@ require ( | |||
| 60 | 61 | go.uber.org/multierr v1.11.0 // indirect | |
| 61 | 62 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 62 | 63 | golang.org/x/sync v0.19.0 // indirect | |
| 63 | - | golang.org/x/sys v0.38.0 // indirect | |
| 64 | 64 | golang.org/x/text v0.32.0 // indirect | |
| 65 | 65 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 66 | 66 | ) | |
| @@ -8,14 +8,17 @@ require ( | |||||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 10 | github.com/fsnotify/fsnotify v1.9.0 | 10 | 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-73930527faa3 | 12 | github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3 | ||
| 12 | github.com/ncruces/go-sqlite3 v0.30.3 | 13 | 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.0 | 15 | github.com/pressly/goose/v3 v3.26.0 | ||
| 14 | github.com/rs/zerolog v1.33.0 | 16 | github.com/rs/zerolog v1.33.0 | ||
| 15 | github.com/spf13/cobra v1.10.2 | 17 | github.com/spf13/cobra v1.10.2 | ||
| 16 | github.com/spf13/viper v1.21.0 | 18 | github.com/spf13/viper v1.21.0 | ||
| 17 | github.com/stretchr/testify v1.11.1 | 19 | github.com/stretchr/testify v1.11.1 | ||
| 18 | golang.org/x/image v0.34.0 | 20 | 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-73930527faa3 | 24 | 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 // indirect | 33 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 35 | 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 // indirect | 36 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 35 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | 37 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | ||
| 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | ||
| @@ -43,7 +45,6 @@ require ( | |||||
| 43 | github.com/muesli/cancelreader v0.2.2 // indirect | 45 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 44 | github.com/muesli/termenv v0.16.0 // indirect | 46 | github.com/muesli/termenv v0.16.0 // indirect | ||
| 45 | github.com/ncruces/julianday v1.0.0 // indirect | 47 | 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 // indirect | 48 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| 48 | github.com/rivo/uniseg v0.4.7 // indirect | 49 | github.com/rivo/uniseg v0.4.7 // indirect | ||
| 49 | github.com/sagikazarmark/locafero v0.11.0 // indirect | 50 | github.com/sagikazarmark/locafero v0.11.0 // indirect | ||
| @@ -60,7 +61,6 @@ require ( | |||||
| 60 | go.uber.org/multierr v1.11.0 // indirect | 61 | go.uber.org/multierr v1.11.0 // indirect | ||
| 61 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 62 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 62 | golang.org/x/sync v0.19.0 // indirect | 63 | 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 // indirect | 64 | golang.org/x/text v0.32.0 // indirect | ||
| 65 | gopkg.in/yaml.v3 v3.0.1 // indirect | 65 | 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 ( | |||
| 13 | 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 | 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 | 205 | // The first argument is typically a *testing.T value. | |
| 18 | 206 | 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 { | |||
| 292 | 292 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 293 | 293 | }, | |
| 294 | 294 | 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 | |
| 297 | 298 | }, | |
| 298 | 299 | } | |
| 299 | 300 | } | |
| @@ -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 default | 295 | + | EnableOnStartup: true, // Check for updates on startup by default |
| 296 | - | AutoDownload: false, // Conservative: don't auto-download by default | 296 | + | 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) { | |||
| 460 | 460 | func (m *Manager) setUpdateDefaults(defaults *Config) { | |
| 461 | 461 | m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup) | |
| 462 | 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 | 466 | // 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 { | |||
| 294 | 294 | // When enabled, updates are applied on browser exit. | |
| 295 | 295 | // Default: false | |
| 296 | 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 | 302 | // 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: false | 295 | // 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) { | |||
| 929 | 929 | ||
| 930 | 930 | // Start async filter loading after window is visible. | |
| 931 | 931 | a.initFilteringAsync(ctx) | |
| 932 | + | ||
| 933 | + | // Check for config migration after window is visible. | |
| 934 | + | a.checkConfigMigration(ctx) | |
| 932 | 935 | } | |
| 933 | 936 | ||
| 934 | 937 | // 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 | + | } | |||