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 }{
43 {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
44 {name: "missing file required", opts: Options{RequireFile: true}, err: "config"},
45 {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},
46 {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1},
47 {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
48 }
49 for _, tt := range tests {
50 t.Run(tt.name, func(t *testing.T) {
51 path := filepath.Join(t.TempDir(), "config")
52 if tt.content != nil {
53 must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
54 }
55 cfg, warnings, err := Load[liveConfig](path, tt.opts)
56 if tt.err != "" {
57 if err == nil || !strings.Contains(err.Error(), tt.err) {
58 t.Fatalf("err = %v, want %q", err, tt.err)
59 }
60 return
61 }
62 must(t, err)
63 eq(t, *cfg, tt.want)
64 eq(t, len(warnings), tt.warnings)
65 })
66 }
67}
68
69func TestLoadValidatesDefaults(t *testing.T) {
70 _, _, err := Load[strictPair](filepath.Join(t.TempDir(), "none"), Options{})
71 must(t, err)
72 path := writeTemp(t, t.TempDir(), "config", "a = 3\nb = 2\n")
73 if _, _, err := Load[strictPair](path, Options{}); err == nil || err.Error() != path+": a must not exceed b" {
74 t.Fatalf("err = %v", err)
75 }
76}
77
78// liveRecorder collects what a Live reports.
79type liveRecorder struct {
80 mu sync.Mutex
81 changes []Change[liveConfig]
82 errs []error
83 signal chan struct{}
84}
85
86func newRecorder() *liveRecorder { return &liveRecorder{signal: make(chan struct{}, 16)} }
87
88func (r *liveRecorder) opts() LiveOptions[liveConfig] {
89 return LiveOptions[liveConfig]{
90 Watch: WatchOptions{Debounce: 20 * time.Millisecond},
91 OnChange: func(c Change[liveConfig]) {
92 r.mu.Lock()
93 r.changes = append(r.changes, c)
94 r.mu.Unlock()
95 r.signal <- struct{}{}
96 },
97 OnError: func(err error) {
98 r.mu.Lock()
99 r.errs = append(r.errs, err)
100 r.mu.Unlock()
101 r.signal <- struct{}{}
102 },
103 }
104}
105
106func (r *liveRecorder) wait(t *testing.T, why string) {
107 t.Helper()
108 select {
109 case <-r.signal:
110 case <-time.After(5 * time.Second):
111 t.Fatalf("nothing reported after %s", why)
112 }
113}
114
115func (r *liveRecorder) quiet(t *testing.T, why string) {
116 t.Helper()
117 select {
118 case <-r.signal:
119 t.Fatalf("unexpected report after %s", why)
120 case <-time.After(quiet):
121 }
122}
123
124func (r *liveRecorder) last() (Change[liveConfig], error) {
125 r.mu.Lock()
126 defer r.mu.Unlock()
127 var c Change[liveConfig]
128 var err error
129 if len(r.changes) > 0 {
130 c = r.changes[len(r.changes)-1]
131 }
132 if len(r.errs) > 0 {
133 err = r.errs[len(r.errs)-1]
134 }
135 return c, err
136}
137
138func TestLive(t *testing.T) {
139 tests := []struct {
140 name string
141 initial *string
142 steps func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder)
143 }{
144 {
145 name: "reload on change",
146 initial: ptr("theme = dark\n"),
147 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
148 old := l.Get()
149 must(t, WriteFile(path, []byte("theme = light\n"), 0o600))
150 r.wait(t, "a change")
151 c, _ := r.last()
152 eq(t, c.Keys, []string{"theme"})
153 eq(t, c.Old, old)
154 eq(t, l.Get().Theme, "light")
155 eq(t, old.Theme, "dark") // a published config is never modified
156 },
157 },
158 {
159 name: "comment and spacing only",
160 initial: ptr("theme = light\n"),
161 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
162 must(t, os.WriteFile(path, []byte("# note\ntheme = light # same\n"), 0o600))
163 r.quiet(t, "a save that changes no value")
164 },
165 },
166 {
167 name: "broken file keeps the config",
168 initial: ptr("theme = light\n"),
169 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
170 must(t, os.WriteFile(path, []byte("theme = light\n\xff\n"), 0o600))
171 r.wait(t, "an invalid file")
172 _, err := r.last()
173 if !errors.Is(err, ErrNotUTF8) {
174 t.Fatalf("err = %v", err)
175 }
176 eq(t, l.Get().Theme, "light")
177 },
178 },
179 {
180 name: "removed file keeps the config",
181 initial: ptr("theme = light\n"),
182 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
183 must(t, os.Remove(path))
184 r.wait(t, "a removal")
185 _, err := r.last()
186 if !errors.Is(err, fs.ErrNotExist) {
187 t.Fatalf("err = %v", err)
188 }
189 eq(t, l.Get().Theme, "light")
190 must(t, os.WriteFile(path, []byte("theme = dark\n"), 0o600))
191 r.wait(t, "the file came back")
192 eq(t, l.Get().Theme, "dark")
193 },
194 },
195 {
196 name: "file created after start",
197 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
198 eq(t, l.Get().Theme, "dark")
199 must(t, os.WriteFile(path, []byte("after = 5s\n"), 0o600))
200 r.wait(t, "creation")
201 eq(t, *l.Get(), liveConfig{"dark", 5 * time.Second})
202 },
203 },
204 {
205 name: "repeated key with an invalid last value",
206 initial: ptr("after = 5s\nafter = bad\n"),
207 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
208 eq(t, l.Get().After, 5*time.Second)
209 must(t, WriteFile(path, []byte("after = 6s\nafter = bad\n"), 0o600))
210 r.wait(t, "a change of the valid value")
211 eq(t, l.Get().After, 6*time.Second)
212 c, _ := r.last()
213 eq(t, c.Keys, []string{"after"})
214 },
215 },
216 {
217 name: "warnings are reported with the change",
218 initial: ptr("after = 1s\n"),
219 steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
220 must(t, WriteFile(path, []byte("after = 2s\ntheme = blue\n"), 0o600))
221 r.wait(t, "a change with a bad value")
222 c, _ := r.last()
223 eq(t, warningStrings(c.Warnings), []string{"line 2: theme: must be dark or light"})
224 eq(t, l.Get().After, 2*time.Second)
225 },
226 },
227 }
228 for _, tt := range tests {
229 t.Run(tt.name, func(t *testing.T) {
230 t.Parallel()
231 path := filepath.Join(t.TempDir(), "config")
232 if tt.initial != nil {
233 must(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
234 }
235 r := newRecorder()
236 opts := r.opts()
237 ready := make(chan struct{})
238 opts.Watch.Ready = ready
239 l, _, err := Open(path, opts)
240 must(t, err)
241 ctx, cancel := context.WithCancel(context.Background())
242 done := make(chan error, 1)
243 go func() { done <- l.Run(ctx) }()
244 <-ready
245 tt.steps(t, path, l, r)
246 cancel()
247 must(t, <-done)
248 })
249 }
250}
251
252func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
253 path := writeTemp(t, t.TempDir(), "config", "theme = dark\n")
254 r := newRecorder()
255 l, _, err := Open(path, r.opts())
256 must(t, err)
257 must(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
258 ctx, cancel := context.WithCancel(context.Background())
259 defer cancel()
260 go func() { _ = l.Run(ctx) }()
261 r.wait(t, "a save before Run")
262 eq(t, l.Get().Theme, "light")
263}
264
265func TestLiveRunTwice(t *testing.T) {
266 l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{})
267 must(t, err)
268 ctx, cancel := context.WithCancel(context.Background())
269 defer cancel()
270 go func() { _ = l.Run(ctx) }()
271 time.Sleep(50 * time.Millisecond)
272 if err := l.Run(ctx); err == nil {
273 t.Fatal("a second Run was accepted")
274 }
275}
276
277func TestLiveConcurrentGet(t *testing.T) {
278 path := writeTemp(t, t.TempDir(), "config", "after = 1s\n")
279 r := newRecorder()
280 l, _, err := Open(path, r.opts())
281 must(t, err)
282 ctx, cancel := context.WithCancel(context.Background())
283 defer cancel()
284 go func() { _ = l.Run(ctx) }()
285 var wg sync.WaitGroup
286 stop := make(chan struct{})
287 for range 4 {
288 wg.Go(func() {
289 for {
290 select {
291 case <-stop:
292 return
293 default:
294 if l.Get().Theme == "" {
295 t.Error("saw a half-built config")
296 }
297 }
298 }
299 })
300 }
301 for i := range 5 {
302 must(t, WriteFile(path, []byte("after = "+time.Duration(i+2).String()+"\n"), 0o600))
303 r.wait(t, "a change")
304 }
305 close(stop)
306 wg.Wait()
307}