diff options
Diffstat (limited to 'internal/runtimeconfig')
| -rw-r--r-- | internal/runtimeconfig/store.go | 66 | ||||
| -rw-r--r-- | internal/runtimeconfig/store_test.go | 54 |
2 files changed, 74 insertions, 46 deletions
diff --git a/internal/runtimeconfig/store.go b/internal/runtimeconfig/store.go index 4ee7ada..b8d34b4 100644 --- a/internal/runtimeconfig/store.go +++ b/internal/runtimeconfig/store.go @@ -118,38 +118,60 @@ func Diff(oldCfg, newCfg appconfig.App) []Change { return changes } +// flattenAppConfig converts an App config into a flat key/value map for diffing. +// It recurses into embedded structs (CoreConfig, ProviderConfig, etc.) to reach +// all leaf fields. Keys are derived from json tags, with fallbacks for fields +// that use json:"-" (e.g. surface configs, stats). func flattenAppConfig(cfg appconfig.App) map[string]string { result := make(map[string]string) - val := reflect.ValueOf(cfg) + flattenStructFields(reflect.ValueOf(cfg), result) + return result +} + +// flattenStructFields iterates over struct fields, recursing into anonymous +// (embedded) structs and extracting key/value pairs from leaf fields. +func flattenStructFields(val reflect.Value, result map[string]string) { typ := val.Type() for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) - key := strings.TrimSpace(field.Tag.Get("toml")) - if key == "" || key == "-" { - switch field.Name { - case "StatsWindowMinutes": - key = "stats_window_minutes" - case "CompletionConfigs": - key = "completion_configs" - case "CodeActionConfigs": - key = "code_action_configs" - case "ChatConfigs": - key = "chat_configs" - case "CLIConfigs": - key = "cli_configs" - default: - continue - } - } - if idx := strings.Index(key, ","); idx >= 0 { - key = key[:idx] + // Recurse into embedded (anonymous) structs to flatten their fields. + if field.Anonymous && field.Type.Kind() == reflect.Struct { + flattenStructFields(val.Field(i), result) + continue } - if key == "" || key == "-" { + key := fieldKey(field) + if key == "" { continue } result[key] = stringifyValue(val.Field(i)) } - return result +} + +// fieldKey derives the flattened map key for a struct field from its json tag, +// with manual fallbacks for fields tagged json:"-" that still need tracking. +func fieldKey(field reflect.StructField) string { + key := strings.TrimSpace(field.Tag.Get("json")) + if key == "" || key == "-" { + // Manual fallbacks for fields hidden from JSON but needed in diffs. + switch field.Name { + case "StatsWindowMinutes": + return "stats_window_minutes" + case "CompletionConfigs": + return "completion_configs" + case "CodeActionConfigs": + return "code_action_configs" + case "ChatConfigs": + return "chat_configs" + case "CLIConfigs": + return "cli_configs" + default: + return "" + } + } + if idx := strings.Index(key, ","); idx >= 0 { + key = key[:idx] + } + return key } func stringifyValue(v reflect.Value) string { diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go index 906d7f6..ca201a2 100644 --- a/internal/runtimeconfig/store_test.go +++ b/internal/runtimeconfig/store_test.go @@ -105,7 +105,7 @@ func TestSubscribe_NilListener(t *testing.T) { } func TestSubscribe_ReceivesUpdates(t *testing.T) { - store := New(appconfig.App{MaxTokens: 100}) + store := New(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 100}}) var gotOld, gotNew appconfig.App callCount := 0 @@ -115,7 +115,7 @@ func TestSubscribe_ReceivesUpdates(t *testing.T) { callCount++ }) - store.Set(appconfig.App{MaxTokens: 200}) + store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 200}}) if callCount != 1 { t.Fatalf("expected listener called once, got %d", callCount) } @@ -125,7 +125,7 @@ func TestSubscribe_ReceivesUpdates(t *testing.T) { // After unsubscribe, listener must not be called again. unsub() - store.Set(appconfig.App{MaxTokens: 300}) + store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 300}}) if callCount != 1 { t.Fatalf("expected listener not called after unsubscribe, got %d", callCount) } @@ -137,14 +137,14 @@ func TestSubscribe_MultipleListeners(t *testing.T) { unsub0 := store.Subscribe(func(_, _ appconfig.App) { calls[0]++ }) unsub1 := store.Subscribe(func(_, _ appconfig.App) { calls[1]++ }) - store.Set(appconfig.App{MaxTokens: 1}) + store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 1}}) if calls[0] != 1 || calls[1] != 1 { t.Fatalf("expected both listeners called once: %v", calls) } // Unsubscribe first listener only. unsub0() - store.Set(appconfig.App{MaxTokens: 2}) + store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 2}}) if calls[0] != 1 || calls[1] != 2 { t.Fatalf("expected only second listener called: %v", calls) } @@ -152,8 +152,8 @@ func TestSubscribe_MultipleListeners(t *testing.T) { } func TestSet_ReturnsChanges(t *testing.T) { - store := New(appconfig.App{MaxTokens: 10, Provider: "ollama"}) - changes := store.Set(appconfig.App{MaxTokens: 20, Provider: "ollama"}) + store := New(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 10, Provider: "ollama"}}) + changes := store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 20, Provider: "ollama"}}) found := false for _, ch := range changes { if ch.Key == "max_tokens" { @@ -169,7 +169,7 @@ func TestSet_ReturnsChanges(t *testing.T) { } func TestSet_NoChanges(t *testing.T) { - cfg := appconfig.App{MaxTokens: 10} + cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 10}} store := New(cfg) changes := store.Set(cfg) if len(changes) != 0 { @@ -181,7 +181,7 @@ func TestReload_NilLogger(t *testing.T) { // Reload with nil logger should not panic; it exercises the nil-logger guard // in Reload (skipping logger.Print). LoadWithOptions returns defaults when // logger is nil, so the store gets default config applied. - store := New(appconfig.App{MaxTokens: 1}) + store := New(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 1}}) changes, err := store.Reload(nil, appconfig.LoadOptions{IgnoreEnv: true}) if err != nil { t.Fatalf("reload failed: %v", err) @@ -227,8 +227,8 @@ func TestStringifyValue_BoolAndFloat(t *testing.T) { // Exercise the bool and float branches via Diff on App fields. temp1 := 0.5 temp2 := 0.9 - oldCfg := appconfig.App{CodingTemperature: &temp1} - newCfg := appconfig.App{CodingTemperature: &temp2} + oldCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CodingTemperature: &temp1}} + newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CodingTemperature: &temp2}} changes := Diff(oldCfg, newCfg) found := false for _, ch := range changes { @@ -248,7 +248,7 @@ func TestStringifyValue_NilPointer(t *testing.T) { // nil *float64 should produce "(unset)". oldCfg := appconfig.App{} temp := 0.3 - newCfg := appconfig.App{CodingTemperature: &temp} + newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CodingTemperature: &temp}} changes := Diff(oldCfg, newCfg) found := false for _, ch := range changes { @@ -268,7 +268,7 @@ func TestStringifyValue_NilBoolPointer(t *testing.T) { // CompletionWaitAll is *bool; nil should produce "(unset)". b := true oldCfg := appconfig.App{} - newCfg := appconfig.App{CompletionWaitAll: &b} + newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CompletionWaitAll: &b}} changes := Diff(oldCfg, newCfg) found := false for _, ch := range changes { @@ -286,8 +286,8 @@ func TestStringifyValue_NilBoolPointer(t *testing.T) { func TestStringifyValue_StringSlice(t *testing.T) { // TriggerCharacters is []string; exercise the string-slice branch. - oldCfg := appconfig.App{TriggerCharacters: []string{".", ":"}} - newCfg := appconfig.App{TriggerCharacters: []string{".", ":", "("}} + oldCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{TriggerCharacters: []string{".", ":"}}} + newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{TriggerCharacters: []string{".", ":", "("}}} changes := Diff(oldCfg, newCfg) found := false for _, ch := range changes { @@ -309,7 +309,7 @@ func TestStringifyValue_StringSlice(t *testing.T) { func TestStringifyValue_NilSlice(t *testing.T) { // nil slice vs non-nil slice. oldCfg := appconfig.App{} - newCfg := appconfig.App{TriggerCharacters: []string{"x"}} + newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{TriggerCharacters: []string{"x"}}} changes := Diff(oldCfg, newCfg) found := false for _, ch := range changes { @@ -329,13 +329,17 @@ func TestStringifyValue_SurfaceConfigWithTemperature(t *testing.T) { // Exercise the SurfaceConfig temperature branch. temp := 0.750 oldCfg := appconfig.App{ - CompletionConfigs: []appconfig.SurfaceConfig{ - {Provider: "openai", Model: "gpt-4o", Temperature: &temp}, + ProviderConfig: appconfig.ProviderConfig{ + CompletionConfigs: []appconfig.SurfaceConfig{ + {Provider: "openai", Model: "gpt-4o", Temperature: &temp}, + }, }, } newCfg := appconfig.App{ - CompletionConfigs: []appconfig.SurfaceConfig{ - {Provider: "openai", Model: "gpt-4o"}, + ProviderConfig: appconfig.ProviderConfig{ + CompletionConfigs: []appconfig.SurfaceConfig{ + {Provider: "openai", Model: "gpt-4o"}, + }, }, } changes := Diff(oldCfg, newCfg) @@ -356,8 +360,10 @@ func TestStringifyValue_SurfaceConfigWithTemperature(t *testing.T) { func TestStringifyValue_SurfaceConfigEmptyProvider(t *testing.T) { // Exercise the SurfaceConfig branch where provider is empty. oldCfg := appconfig.App{ - ChatConfigs: []appconfig.SurfaceConfig{ - {Provider: "", Model: "some-model"}, + ProviderConfig: appconfig.ProviderConfig{ + ChatConfigs: []appconfig.SurfaceConfig{ + {Provider: "", Model: "some-model"}, + }, }, } newCfg := appconfig.App{} @@ -377,8 +383,8 @@ func TestStringifyValue_SurfaceConfigEmptyProvider(t *testing.T) { } func TestDiff_SurfaceModel(t *testing.T) { - oldCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "openai", Model: "gpt-4o"}}} - newCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "claude-3-5-sonnet"}}} + oldCfg := appconfig.App{ProviderConfig: appconfig.ProviderConfig{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "openai", Model: "gpt-4o"}}}} + newCfg := appconfig.App{ProviderConfig: appconfig.ProviderConfig{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "claude-3-5-sonnet"}}}} changes := Diff(oldCfg, newCfg) if len(changes) == 0 { t.Fatalf("expected diff entries, got none") |
