diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
| commit | c3c71345db9086392cd9b7529c7f5287009c226e (patch) | |
| tree | d227894ab900d6050cbe1418984526088a692db5 /internal/runtimeconfig | |
| parent | 127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff) | |
Add runtime config store and reload command
Diffstat (limited to 'internal/runtimeconfig')
| -rw-r--r-- | internal/runtimeconfig/store.go | 178 | ||||
| -rw-r--r-- | internal/runtimeconfig/store_test.go | 59 |
2 files changed, 237 insertions, 0 deletions
diff --git a/internal/runtimeconfig/store.go b/internal/runtimeconfig/store.go new file mode 100644 index 0000000..e0a594c --- /dev/null +++ b/internal/runtimeconfig/store.go @@ -0,0 +1,178 @@ +package runtimeconfig + +import ( + "fmt" + "log" + "reflect" + "sort" + "strconv" + "strings" + "sync" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// Change captures a single configuration delta. +type Change struct { + Key string + Old string + New string +} + +// Listener receives the previous and new application configuration when updates occur. +type Listener func(old appconfig.App, new appconfig.App) + +// Store holds the active runtime configuration and notifies listeners on updates. +type Store struct { + mu sync.RWMutex + cfg appconfig.App + listeners map[int]Listener + nextID int +} + +// New creates a Store seeded with the provided configuration snapshot. +func New(cfg appconfig.App) *Store { + return &Store{cfg: cfg, listeners: make(map[int]Listener)} +} + +// Snapshot returns the current configuration snapshot. Callers must treat it as read-only. +func (s *Store) Snapshot() appconfig.App { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg +} + +// Subscribe registers a listener that will be invoked on configuration changes. +// The returned function removes the listener. +func (s *Store) Subscribe(listener Listener) func() { + if listener == nil { + return func() {} + } + s.mu.Lock() + id := s.nextID + s.nextID++ + s.listeners[id] = listener + s.mu.Unlock() + return func() { + s.mu.Lock() + delete(s.listeners, id) + s.mu.Unlock() + } +} + +// Set replaces the current configuration with the provided snapshot and notifies listeners. +// It returns the list of detected changes between the previous and new configuration. +func (s *Store) Set(cfg appconfig.App) []Change { + s.mu.Lock() + old := s.cfg + s.cfg = cfg + listeners := make([]Listener, 0, len(s.listeners)) + for _, l := range s.listeners { + listeners = append(listeners, l) + } + s.mu.Unlock() + + changes := Diff(old, cfg) + for _, l := range listeners { + l(old, cfg) + } + return changes +} + +// Reload re-reads configuration using the supplied options and applies it when valid. +func (s *Store) Reload(logger *log.Logger, opts appconfig.LoadOptions) ([]Change, error) { + cfg := appconfig.LoadWithOptions(logger, opts) + if err := cfg.Validate(); err != nil { + return nil, err + } + return s.Set(cfg), nil +} + +// Diff computes a stable, sorted list of key/value changes between two configuration snapshots. +func Diff(oldCfg, newCfg appconfig.App) []Change { + before := flattenAppConfig(oldCfg) + after := flattenAppConfig(newCfg) + keys := make(map[string]struct{}, len(before)+len(after)) + for k := range before { + keys[k] = struct{}{} + } + for k := range after { + keys[k] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Strings(ordered) + changes := make([]Change, 0, len(ordered)) + for _, k := range ordered { + if before[k] == after[k] { + continue + } + changes = append(changes, Change{Key: k, Old: before[k], New: after[k]}) + } + return changes +} + +func flattenAppConfig(cfg appconfig.App) map[string]string { + result := make(map[string]string) + val := reflect.ValueOf(cfg) + 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" + default: + continue + } + } + if idx := strings.Index(key, ","); idx >= 0 { + key = key[:idx] + } + if key == "" || key == "-" { + continue + } + result[key] = stringifyValue(val.Field(i)) + } + return result +} + +func stringifyValue(v reflect.Value) string { + if !v.IsValid() { + return "" + } + switch v.Kind() { + case reflect.String: + return v.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(v.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return strconv.FormatUint(v.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64) + case reflect.Bool: + return strconv.FormatBool(v.Bool()) + case reflect.Slice: + if v.IsNil() { + return "" + } + if v.Type().Elem().Kind() == reflect.String { + parts := make([]string, v.Len()) + for i := range parts { + parts[i] = v.Index(i).String() + } + return strings.Join(parts, ",") + } + return fmt.Sprint(v.Interface()) + case reflect.Ptr: + if v.IsNil() { + return "(unset)" + } + return stringifyValue(v.Elem()) + default: + return fmt.Sprint(v.Interface()) + } +} diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go new file mode 100644 index 0000000..9973a1a --- /dev/null +++ b/internal/runtimeconfig/store_test.go @@ -0,0 +1,59 @@ +package runtimeconfig + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +func TestStoreReloadSkipsEnvOverrides(t *testing.T) { + logger := log.New(io.Discard, "", 0) + tmp := t.TempDir() + configDir := filepath.Join(tmp, "hexai") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "config.toml") + if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 64\n"), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEXAI_MAX_TOKENS", "321") + + initial := appconfig.Load(logger) + if initial.MaxTokens != 321 { + t.Fatalf("expected env override to win initial load, got %d", initial.MaxTokens) + } + + store := New(initial) + if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 128\n"), 0o644); err != nil { + t.Fatalf("failed to update config file: %v", err) + } + + changes, err := store.Reload(logger, appconfig.LoadOptions{IgnoreEnv: true}) + if err != nil { + t.Fatalf("reload failed: %v", err) + } + + if snap := store.Snapshot(); snap.MaxTokens != 128 { + t.Fatalf("expected reload to apply file value, got %d", snap.MaxTokens) + } + + found := false + for _, change := range changes { + if change.Key == "max_tokens" { + found = true + if change.Old != "321" || change.New != "128" { + t.Fatalf("unexpected change diff: %+v", change) + } + } + } + if !found { + t.Fatalf("expected max_tokens change in diff, got %#v", changes) + } +} |
