refactor(config): add typed runtime config provider
- commit
- 608a18d46442171391326e6dc518aab956418b0a
- author
- brice · · 2026-06-23 14:03 UTC
- committer
- brice · · 2026-06-23 20:01 UTC
- parent
- aebb5c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +129 -0
M internal/bootstrap/runtime_config.go +64 -0
| @@ -1,10 +1,58 @@ | |||
| 1 | 1 | package bootstrap | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 5 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 10 | + | type configChangeManager interface { | |
| 11 | + | Watch() error | |
| 12 | + | OnConfigChange(func(*config.Config)) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | type runtimeConfigProvider struct { | |
| 16 | + | mu sync.RWMutex | |
| 17 | + | current port.RuntimeConfigSnapshot | |
| 18 | + | manager configChangeManager | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider { | |
| 22 | + | return &runtimeConfigProvider{ | |
| 23 | + | current: RuntimeConfigSnapshotFromConfig(cfg), | |
| 24 | + | manager: manager, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot { | |
| 29 | + | p.mu.RLock() | |
| 30 | + | defer p.mu.RUnlock() | |
| 31 | + | return cloneRuntimeConfigSnapshot(p.current) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (p *runtimeConfigProvider) Watch() error { | |
| 35 | + | if p == nil || p.manager == nil { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | return p.manager.Watch() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (p *runtimeConfigProvider) OnChange(callback func(port.RuntimeConfigSnapshot)) { | |
| 42 | + | if p == nil || p.manager == nil { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | p.manager.OnConfigChange(func(cfg *config.Config) { | |
| 46 | + | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | |
| 47 | + | p.mu.Lock() | |
| 48 | + | p.current = snapshot | |
| 49 | + | p.mu.Unlock() | |
| 50 | + | if callback != nil { | |
| 51 | + | callback(cloneRuntimeConfigSnapshot(snapshot)) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | ||
| 8 | 56 | func EngineSettingsPayloadFromConfig(cfg *config.Config) port.EngineSettingsPayload { | |
| 9 | 57 | if cfg == nil { | |
| 10 | 58 | return port.EngineSettingsPayload{} | |
| @@ -65,6 +113,22 @@ func runtimeSearchShortcutsFromConfig(in map[string]config.SearchShortcut) map[s | |||
| 65 | 113 | return out | |
| 66 | 114 | } | |
| 67 | 115 | ||
| 116 | + | func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot { | |
| 117 | + | snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts) | |
| 118 | + | return snapshot | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func cloneRuntimeSearchShortcuts(in map[string]port.RuntimeSearchShortcut) map[string]port.RuntimeSearchShortcut { | |
| 122 | + | if in == nil { | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | out := make(map[string]port.RuntimeSearchShortcut, len(in)) | |
| 126 | + | for key, shortcut := range in { | |
| 127 | + | out[key] = shortcut | |
| 128 | + | } | |
| 129 | + | return out | |
| 130 | + | } | |
| 131 | + | ||
| 68 | 132 | func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode { | |
| 69 | 133 | switch mode { | |
| 70 | 134 | case config.HardwareDecodingForce: | |
| @@ -1,10 +1,58 @@ | |||||
| 1 | package bootstrap | 1 | package bootstrap | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/dumber/internal/application/port" | 6 | "github.com/bnema/dumber/internal/application/port" | ||
| 5 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 10 | + | type configChangeManager interface { | |||
| 11 | + | Watch() error | |||
| 12 | + | OnConfigChange(func(*config.Config)) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | type runtimeConfigProvider struct { | |||
| 16 | + | mu sync.RWMutex | |||
| 17 | + | current port.RuntimeConfigSnapshot | |||
| 18 | + | manager configChangeManager | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider { | |||
| 22 | + | return &runtimeConfigProvider{ | |||
| 23 | + | current: RuntimeConfigSnapshotFromConfig(cfg), | |||
| 24 | + | manager: manager, | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot { | |||
| 29 | + | p.mu.RLock() | |||
| 30 | + | defer p.mu.RUnlock() | |||
| 31 | + | return cloneRuntimeConfigSnapshot(p.current) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (p *runtimeConfigProvider) Watch() error { | |||
| 35 | + | if p == nil || p.manager == nil { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | return p.manager.Watch() | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (p *runtimeConfigProvider) OnChange(callback func(port.RuntimeConfigSnapshot)) { | |||
| 42 | + | if p == nil || p.manager == nil { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | p.manager.OnConfigChange(func(cfg *config.Config) { | |||
| 46 | + | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | |||
| 47 | + | p.mu.Lock() | |||
| 48 | + | p.current = snapshot | |||
| 49 | + | p.mu.Unlock() | |||
| 50 | + | if callback != nil { | |||
| 51 | + | callback(cloneRuntimeConfigSnapshot(snapshot)) | |||
| 52 | + | } | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 8 | func EngineSettingsPayloadFromConfig(cfg *config.Config) port.EngineSettingsPayload { | 56 | func EngineSettingsPayloadFromConfig(cfg *config.Config) port.EngineSettingsPayload { | ||
| 9 | if cfg == nil { | 57 | if cfg == nil { | ||
| 10 | return port.EngineSettingsPayload{} | 58 | return port.EngineSettingsPayload{} | ||
| @@ -65,6 +113,22 @@ func runtimeSearchShortcutsFromConfig(in map[string]config.SearchShortcut) map[s | |||||
| 65 | return out | 113 | return out | ||
| 66 | } | 114 | } | ||
| 67 | 115 | ||||
| 116 | + | func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot { | |||
| 117 | + | snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts) | |||
| 118 | + | return snapshot | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func cloneRuntimeSearchShortcuts(in map[string]port.RuntimeSearchShortcut) map[string]port.RuntimeSearchShortcut { | |||
| 122 | + | if in == nil { | |||
| 123 | + | return nil | |||
| 124 | + | } | |||
| 125 | + | out := make(map[string]port.RuntimeSearchShortcut, len(in)) | |||
| 126 | + | for key, shortcut := range in { | |||
| 127 | + | out[key] = shortcut | |||
| 128 | + | } | |||
| 129 | + | return out | |||
| 130 | + | } | |||
| 131 | + | ||||
| 68 | func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode { | 132 | func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode { | ||
| 69 | switch mode { | 133 | switch mode { | ||
| 70 | case config.HardwareDecodingForce: | 134 | case config.HardwareDecodingForce: | ||
M internal/bootstrap/runtime_config_test.go +65 -0
| @@ -8,6 +8,20 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | type fakeConfigManager struct { | |
| 12 | + | watchCalls int | |
| 13 | + | callbacks []func(*config.Config) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (f *fakeConfigManager) Watch() error { | |
| 17 | + | f.watchCalls++ | |
| 18 | + | return nil | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (f *fakeConfigManager) OnConfigChange(callback func(*config.Config)) { | |
| 22 | + | f.callbacks = append(f.callbacks, callback) | |
| 23 | + | } | |
| 24 | + | ||
| 11 | 25 | func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | |
| 12 | 26 | cfg := config.DefaultConfig() | |
| 13 | 27 | cfg.DefaultUIScale = 1.25 | |
| @@ -41,6 +55,57 @@ func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | |||
| 41 | 55 | } | |
| 42 | 56 | } | |
| 43 | 57 | ||
| 58 | + | func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) { | |
| 59 | + | initial := config.DefaultConfig() | |
| 60 | + | initial.DefaultUIScale = 1 | |
| 61 | + | manager := &fakeConfigManager{} | |
| 62 | + | provider := NewRuntimeConfigProvider(initial, manager) | |
| 63 | + | ||
| 64 | + | var seen port.RuntimeConfigSnapshot | |
| 65 | + | provider.OnChange(func(snapshot port.RuntimeConfigSnapshot) { | |
| 66 | + | seen = snapshot | |
| 67 | + | }) | |
| 68 | + | ||
| 69 | + | next := config.DefaultConfig() | |
| 70 | + | next.DefaultUIScale = 1.8 | |
| 71 | + | manager.callbacks[0](next) | |
| 72 | + | ||
| 73 | + | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | |
| 74 | + | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | |
| 75 | + | } | |
| 76 | + | if seen.UI.DefaultUIScale != 1.8 { | |
| 77 | + | t.Fatalf("callback DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) { | |
| 82 | + | manager := &fakeConfigManager{} | |
| 83 | + | provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager) | |
| 84 | + | ||
| 85 | + | if err := provider.Watch(); err != nil { | |
| 86 | + | t.Fatalf("Watch returned error: %v", err) | |
| 87 | + | } | |
| 88 | + | if manager.watchCalls != 1 { | |
| 89 | + | t.Fatalf("watchCalls=%d, want 1", manager.watchCalls) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) { | |
| 94 | + | cfg := config.DefaultConfig() | |
| 95 | + | cfg.SearchShortcuts = map[string]config.SearchShortcut{ | |
| 96 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |
| 97 | + | } | |
| 98 | + | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{}) | |
| 99 | + | ||
| 100 | + | first := provider.Current() | |
| 101 | + | first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"} | |
| 102 | + | second := provider.Current() | |
| 103 | + | ||
| 104 | + | if second.UI.SearchShortcuts["gh"].URL == "mutated" { | |
| 105 | + | t.Fatal("Current must return a snapshot clone so callers cannot mutate provider state") | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 44 | 109 | func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) { | |
| 45 | 110 | got := EngineSettingsPayloadFromConfig(nil) | |
| 46 | 111 | if got != (port.EngineSettingsPayload{}) { | |
| @@ -8,6 +8,20 @@ import ( | |||||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | type fakeConfigManager struct { | |||
| 12 | + | watchCalls int | |||
| 13 | + | callbacks []func(*config.Config) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (f *fakeConfigManager) Watch() error { | |||
| 17 | + | f.watchCalls++ | |||
| 18 | + | return nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (f *fakeConfigManager) OnConfigChange(callback func(*config.Config)) { | |||
| 22 | + | f.callbacks = append(f.callbacks, callback) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 11 | func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | 25 | func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | ||
| 12 | cfg := config.DefaultConfig() | 26 | cfg := config.DefaultConfig() | ||
| 13 | cfg.DefaultUIScale = 1.25 | 27 | cfg.DefaultUIScale = 1.25 | ||
| @@ -41,6 +55,57 @@ func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | |||||
| 41 | } | 55 | } | ||
| 42 | } | 56 | } | ||
| 43 | 57 | ||||
| 58 | + | func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) { | |||
| 59 | + | initial := config.DefaultConfig() | |||
| 60 | + | initial.DefaultUIScale = 1 | |||
| 61 | + | manager := &fakeConfigManager{} | |||
| 62 | + | provider := NewRuntimeConfigProvider(initial, manager) | |||
| 63 | + | ||||
| 64 | + | var seen port.RuntimeConfigSnapshot | |||
| 65 | + | provider.OnChange(func(snapshot port.RuntimeConfigSnapshot) { | |||
| 66 | + | seen = snapshot | |||
| 67 | + | }) | |||
| 68 | + | ||||
| 69 | + | next := config.DefaultConfig() | |||
| 70 | + | next.DefaultUIScale = 1.8 | |||
| 71 | + | manager.callbacks[0](next) | |||
| 72 | + | ||||
| 73 | + | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | |||
| 74 | + | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | |||
| 75 | + | } | |||
| 76 | + | if seen.UI.DefaultUIScale != 1.8 { | |||
| 77 | + | t.Fatalf("callback DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) { | |||
| 82 | + | manager := &fakeConfigManager{} | |||
| 83 | + | provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager) | |||
| 84 | + | ||||
| 85 | + | if err := provider.Watch(); err != nil { | |||
| 86 | + | t.Fatalf("Watch returned error: %v", err) | |||
| 87 | + | } | |||
| 88 | + | if manager.watchCalls != 1 { | |||
| 89 | + | t.Fatalf("watchCalls=%d, want 1", manager.watchCalls) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) { | |||
| 94 | + | cfg := config.DefaultConfig() | |||
| 95 | + | cfg.SearchShortcuts = map[string]config.SearchShortcut{ | |||
| 96 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |||
| 97 | + | } | |||
| 98 | + | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{}) | |||
| 99 | + | ||||
| 100 | + | first := provider.Current() | |||
| 101 | + | first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"} | |||
| 102 | + | second := provider.Current() | |||
| 103 | + | ||||
| 104 | + | if second.UI.SearchShortcuts["gh"].URL == "mutated" { | |||
| 105 | + | t.Fatal("Current must return a snapshot clone so callers cannot mutate provider state") | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 44 | func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) { | 109 | func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) { | ||
| 45 | got := EngineSettingsPayloadFromConfig(nil) | 110 | got := EngineSettingsPayloadFromConfig(nil) | ||
| 46 | if got != (port.EngineSettingsPayload{}) { | 111 | if got != (port.EngineSettingsPayload{}) { | ||