summaryrefslogtreecommitdiff
path: root/internal/runtimeconfig
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-24 23:21:43 +0300
committerPaul Buetow <paul@buetow.org>2025-09-24 23:21:43 +0300
commitc3c71345db9086392cd9b7529c7f5287009c226e (patch)
treed227894ab900d6050cbe1418984526088a692db5 /internal/runtimeconfig
parent127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff)
Add runtime config store and reload command
Diffstat (limited to 'internal/runtimeconfig')
-rw-r--r--internal/runtimeconfig/store.go178
-rw-r--r--internal/runtimeconfig/store_test.go59
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)
+ }
+}