1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
|
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
}
changes := s.Set(cfg)
if logger != nil {
logger.Print(FormatSummary("Reloaded config", changes))
}
return changes, 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"
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]
}
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, ",")
}
if v.Type().Elem() == reflect.TypeOf(appconfig.SurfaceConfig{}) {
parts := make([]string, 0, v.Len())
for i := 0; i < v.Len(); i++ {
entry := v.Index(i).Interface().(appconfig.SurfaceConfig)
segment := strings.TrimSpace(entry.Provider)
if segment != "" {
segment += ":"
}
segment += strings.TrimSpace(entry.Model)
if entry.Temperature != nil {
segment += fmt.Sprintf("@%.3f", *entry.Temperature)
}
parts = append(parts, segment)
}
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())
}
}
// FormatSummary creates a human-readable summary for configuration changes.
func FormatSummary(prefix string, changes []Change) string {
if len(changes) == 0 {
return fmt.Sprintf("%s (no changes detected).", prefix)
}
lines := make([]string, 0, len(changes)+1)
lines = append(lines, fmt.Sprintf("%s (%d changes):", prefix, len(changes)))
for _, ch := range changes {
lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New))
}
return strings.Join(lines, "\n")
}
|