1package kvconf
2
3import (
4 "context"
5 "errors"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13)
14
15type liveConfig struct {
16 Theme string `kv:"theme" oneof:"dark|light"`
17 After time.Duration `kv:"after"`
18}
19
20func (c *liveConfig) SetDefaults() { c.Theme, c.After = "dark", time.Minute }
21
22type strictPair struct {
23 A int `kv:"a"`
24 B int `kv:"b"`
25}
26
27func (p *strictPair) Validate() error {
28 if p.A > p.B {
29 return errors.New("a must not exceed b")
30 }
31 return nil
32}
33
34func TestLoad(t *testing.T) {
35 tests := []struct {
36 name string
37 content *string // nil: no file
38 opts Options
39 want liveConfig
40 warnings int
41 err string
42 notExist bool
43 }{
44 {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
45 {name: "missing file required", opts: Options{RequireFile: true}, notExist: true},
46 {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},
47 {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1},
48 {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
49 }
50 for _, tt := range tests {
51 t.Run(tt.name, func(t *testing.T) {
52 path := filepath.Join(t.TempDir(), "config")
53 if tt.content != nil {
54 must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
55 }
56 cfg, warnings, err := Load[liveConfig](path, tt.opts)
57 if tt.notExist {
58 if !errors.Is(err, fs.ErrNotExist) {
59 t.Fatalf("err = %v, want fs.ErrNotExist", err)
60 }
61 return
62 }
63 if tt.err != "" {
64 if err == nil || !strings.Contains(err.Error(), tt.err) {
65 t.Fatalf("err = %v, want %q", err, tt.err)
66 }
67 return
68 }
69 must(t, err)
70 eq(t, *cfg, tt.want)
71 eq(t, len(warnings), tt.warnings)
72 })
73 }
74}
75
76// badDefaults has defaults that fail its own Validate.
77type badDefaults struct{ strictPair }
78
79func (p *badDefaults) SetDefaults() { p.A, p.B = 3, 2 }
80func (p *badDefaults) Validate() error { return p.strictPair.Validate() }
81
82func TestLoadValidates(t *testing.T) {
83 dir := t.TempDir()
84 bad := writeTemp(t, dir, "bad", "a = 3\nb = 2\n")
85 tests := []struct {
86 name string
87 load func(path string) error
88 path string
89 err string
90 }{
91 {name: "valid zero defaults", load: loadErr[strictPair], path: filepath.Join(dir, "none")},
92 {name: "invalid file", load: loadErr[strictPair], path: bad, err: bad + ": a must not exceed b"},
93 {name: "invalid defaults", load: loadErr[badDefaults], path: filepath.Join(dir, "none"), err: "a must not exceed b"},
94 }
95 for _, tt := range tests {
96 t.Run(tt.name, func(t *testing.T) {
97 err := tt.load(tt.path)
98 if tt.err == "" {
99 must(t, err)
100 } else if err == nil || err.Error() != tt.err {
101 t.Fatalf("err = %v, want %q", err, tt.err)
102 }
103 })
104 }
105}
106
107func loadErr[T any](path string) error {
108 _, _, err := Load[T](path, Options{})
109 return err
110}
111
112// liveRecorder collects what a Live reports.
113type liveRecorder struct {
114 mu sync.Mutex
115 changes []Change[liveConfig]
116 errs []error
117 signal chan struct{}
118}
119
120func newRecorder() *liveRecorder { return &liveRecorder{signal: make(chan struct{}, 16)} }
121
122func (r *liveRecorder) opts() LiveOptions[liveConfig] {
123 return LiveOptions[liveConfig]{
124 Watch: WatchOptions{Debounce: 20 * time.Millisecond},
125 OnChange: func(c Change[liveConfig]) {
126 r.mu.Lock()
127 r.changes = append(r.changes, c)
128 r.mu.Unlock()
129 r.signal <- struct{}{}
130 },
131 OnError: func(err error) {
132 r.mu.Lock()
133 r.errs = append(r.errs, err)
134 r.mu.Unlock()
135 r.signal <- struct{}{}
136 },
137 }
138}
139
140func (r *liveRecorder) wait(t *testing.T, why string) {
141 t.Helper()
142 select {
143 case <-r.signal:
144 case <-time.After(5 * time.Second):
145 t.Fatalf("nothing reported after %s", why)
146 }
147}
148
149func (r *liveRecorder) quiet(t *testing.T, why string) {
150 t.Helper()
151 select {
152 case <-r.signal:
153 t.Fatalf("unexpected report after %s", why)
154 case <-time.After(quiet):
155 }
156}
157
158func (r *liveRecorder) last() (Change[liveConfig], error) {
159 r.mu.Lock()
160 defer r.mu.Unlock()
161 var c Change[liveConfig]
162 var err error
163 if len(r.changes) > 0 {
164 c = r.changes[len(r.changes)-1]
165 }
166 if len(r.errs) > 0 {
167 err = r.errs[len(r.errs)-1]
168 }
169 return c, err
170}
171
172func TestLive(t *testing.T) {
173 tests := []struct {
174 name string
175 initial *string
176 steps func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder)
177 }{
178 {
179 name: "reload on change",
180 initial: ptr("theme = dark\n"),
181 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
182 old := l.Get()
183 must(t, WriteFile(path, []byte("theme = light\n"), 0o600))
184 r.wait(t, "a change")
185 c, _ := r.last()
186 eq(t, c.Keys, []string{"theme"})
187 eq(t, c.Old, old)
188 eq(t, l.Get().Theme, "light")
189 eq(t, old.Theme, "dark") // a published config is never modified
190 },
191 },
192 {
193 name: "comment and spacing only",
194 initial: ptr("theme = light\n"),
195 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
196 must(t, os.WriteFile(path, []byte("# note\ntheme = light # same\n"), 0o600))
197 r.quiet(t, "a save that changes no value")
198 },
199 },
200 {
201 name: "reordered keys",
202 initial: ptr("theme = light\nafter = 5s\n"),
203 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
204 old := l.Get()
205 must(t, os.WriteFile(path, []byte("after = 5s\ntheme = light\n"), 0o600))
206 r.quiet(t, "a save that only reorders keys")
207 if l.Get() != old {
208 t.Fatal("config swapped")
209 }
210 },
211 },
212 {
213 name: "empty file created then removed",
214 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
215 must(t, os.WriteFile(path, nil, 0o600))
216 r.quiet(t, "an empty file")
217 must(t, os.Remove(path))
218 r.wait(t, "a removal")
219 if _, err := r.last(); !errors.Is(err, fs.ErrNotExist) {
220 t.Fatalf("err = %v", err)
221 }
222 },
223 },
224 {
225 name: "broken file keeps the config",
226 initial: ptr("theme = light\n"),
227 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
228 must(t, os.WriteFile(path, []byte("theme = light\n\xff\n"), 0o600))
229 r.wait(t, "an invalid file")
230 _, err := r.last()
231 if !errors.Is(err, ErrNotUTF8) {
232 t.Fatalf("err = %v", err)
233 }
234 eq(t, l.Get().Theme, "light")
235 },
236 },
237 {
238 name: "removed file keeps the config",
239 initial: ptr("theme = light\n"),
240 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
241 must(t, os.Remove(path))
242 r.wait(t, "a removal")
243 _, err := r.last()
244 if !errors.Is(err, fs.ErrNotExist) {
245 t.Fatalf("err = %v", err)
246 }
247 eq(t, l.Get().Theme, "light")
248 must(t, os.WriteFile(path, []byte("theme = dark\n"), 0o600))
249 r.wait(t, "the file came back")
250 eq(t, l.Get().Theme, "dark")
251 },
252 },
253 {
254 name: "file created after start",
255 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
256 eq(t, l.Get().Theme, "dark")
257 must(t, os.WriteFile(path, []byte("after = 5s\n"), 0o600))
258 r.wait(t, "creation")
259 eq(t, *l.Get(), liveConfig{"dark", 5 * time.Second})
260 },
261 },
262 {
263 name: "repeated key with an invalid last value",
264 initial: ptr("after = 5s\nafter = bad\n"),
265 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
266 eq(t, l.Get().After, 5*time.Second)
267 must(t, WriteFile(path, []byte("after = 6s\nafter = bad\n"), 0o600))
268 r.wait(t, "a change of the valid value")
269 eq(t, l.Get().After, 6*time.Second)
270 c, _ := r.last()
271 eq(t, c.Keys, []string{"after"})
272 },
273 },
274 {
275 name: "warnings are reported with the change",
276 initial: ptr("after = 1s\n"),
277 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
278 must(t, WriteFile(path, []byte("after = 2s\ntheme = blue\n"), 0o600))
279 r.wait(t, "a change with a bad value")
280 c, _ := r.last()
281 eq(t, warningStrings(c.Warnings), []string{"line 2: theme: must be dark or light"})
282 eq(t, l.Get().After, 2*time.Second)
283 },
284 },
285 }
286 for _, tt := range tests {
287 t.Run(tt.name, func(t *testing.T) {
288 t.Parallel()
289 path := filepath.Join(t.TempDir(), "config")
290 if tt.initial != nil {
291 must(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
292 }
293 r := newRecorder()
294 opts := r.opts()
295 ready := make(chan struct{})
296 opts.Watch.Ready = ready
297 l, _, err := Open(path, opts)
298 must(t, err)
299 ctx, cancel := context.WithCancel(context.Background())
300 done := make(chan error, 1)
301 go func() { done <- l.Run(ctx) }()
302 <-ready
303 tt.steps(t, path, l, r)
304 cancel()
305 must(t, <-done)
306 })
307 }
308}
309
310func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
311 path := writeTemp(t, t.TempDir(), "config", "theme = dark\n")
312 r := newRecorder()
313 l, _, err := Open(path, r.opts())
314 must(t, err)
315 must(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
316 ctx, cancel := context.WithCancel(context.Background())
317 defer cancel()
318 go func() { _ = l.Run(ctx) }()
319 r.wait(t, "a save before Run")
320 eq(t, l.Get().Theme, "light")
321}
322
323func TestLiveRunTwice(t *testing.T) {
324 ready := make(chan struct{})
325 l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{Watch: WatchOptions{Ready: ready}})
326 must(t, err)
327 ctx, cancel := context.WithCancel(context.Background())
328 defer cancel()
329 go func() { _ = l.Run(ctx) }()
330 <-ready
331 if err := l.Run(ctx); err == nil {
332 t.Fatal("a second Run was accepted")
333 }
334}
335
336func TestLiveConcurrentGet(t *testing.T) {
337 path := writeTemp(t, t.TempDir(), "config", "after = 1s\n")
338 r := newRecorder()
339 l, _, err := Open(path, r.opts())
340 must(t, err)
341 ctx, cancel := context.WithCancel(context.Background())
342 defer cancel()
343 go func() { _ = l.Run(ctx) }()
344 var wg sync.WaitGroup
345 stop := make(chan struct{})
346 for range 4 {
347 wg.Go(func() {
348 for {
349 select {
350 case <-stop:
351 return
352 default:
353 if l.Get().Theme == "" {
354 t.Error("saw a half-built config")
355 }
356 }
357 }
358 })
359 }
360 for i := range 5 {
361 must(t, WriteFile(path, []byte("after = "+time.Duration(i+2).String()+"\n"), 0o600))
362 r.wait(t, "a change")
363 }
364 close(stop)
365 wg.Wait()
366}