1package kvconf
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strconv"
10 "strings"
11 "testing"
12 "time"
13)
14
15type mode string
16
17type output struct {
18 Scale float64 `kv:"scale" min:"0.25" max:"4"`
19 HDR bool `kv:"hdr"`
20 Primary bool `kv:"primary"`
21 Bright int `kv:"sdr-brightness" min:"80" max:"1000"`
22}
23
24func (o *output) SetDefaults() { o.Scale, o.Bright = 1, 203 }
25
26type testConfig struct {
27 Name string `kv:"name,nonempty"`
28 Enabled bool `kv:"enabled"`
29 Count int `kv:"count" min:"1" max:"10"`
30 Size uint16 `kv:"size"`
31 Ratio float32 `kv:"ratio"`
32 After time.Duration `kv:"lock.after,off" max:"1h"`
33 Mode mode `kv:"mode" oneof:"auto|dark|light"`
34 Tags []string `kv:"tags"`
35 Ports []int `kv:"ports" sep:"|" min:"1" max:"65535"`
36 Dir Path `kv:"dir"`
37 Fade struct {
38 After time.Duration `kv:"after"`
39 Duration time.Duration `kv:"duration"`
40 } `kv:"fade"`
41 Inline struct {
42 Flag bool `kv:"flag"`
43 } `kv:",inline"`
44 Binds map[string]string `kv:"bind.*"`
45 Outputs map[string]output `kv:"output.*"`
46 Codes map[string]int `kv:"code.*" min:"0"`
47 Ignored string `kv:"-"`
48 Rest []Entry `kv:"*"`
49 Plain string // untagged: ignored
50}
51
52func defaults() testConfig {
53 c := testConfig{Name: "x", Count: 5, Mode: "auto", After: 5 * time.Minute}
54 return c
55}
56
57func TestDecode(t *testing.T) {
58 home, _ := os.UserHomeDir()
59 tests := []struct {
60 name string
61 in string
62 opts Options
63 check func(t *testing.T, c *testConfig)
64 warnings []string
65 err string
66 }{
67 {
68 name: "scalars",
69 in: `name = vev
70enabled = on
71count = 7
72size = 512
73ratio = 0.5
74lock.after = 10m
75mode = dark
76flag = yes
77`,
78 check: func(t *testing.T, c *testConfig) {
79 want := defaults()
80 want.Name, want.Enabled, want.Count, want.Size, want.Ratio = "vev", true, 7, 512, 0.5
81 want.After, want.Mode, want.Inline.Flag = 10*time.Minute, "dark", true
82 want.Rest = nil
83 if !reflect.DeepEqual(*c, want) {
84 t.Errorf("got %+v\nwant %+v", *c, want)
85 }
86 },
87 },
88 {
89 name: "absent keys keep defaults",
90 in: "",
91 check: func(t *testing.T, c *testConfig) { eq(t, c.Count, 5); eq(t, c.After, 5*time.Minute) },
92 },
93 {
94 name: "off is zero",
95 in: "lock.after = off\n",
96 check: func(t *testing.T, c *testConfig) { eq(t, c.After, time.Duration(0)) },
97 },
98 {
99 name: "nested struct",
100 in: "fade.after = 1s\nfade.duration = 250ms\n",
101 check: func(t *testing.T, c *testConfig) {
102 eq(t, c.Fade.After, time.Second)
103 eq(t, c.Fade.Duration, 250*time.Millisecond)
104 },
105 },
106 {
107 name: "lists",
108 in: "tags = a, b ,,c\nports = 80|443\n",
109 check: func(t *testing.T, c *testConfig) {
110 eq(t, c.Tags, []string{"a", "b", "c"})
111 eq(t, c.Ports, []int{80, 443})
112 },
113 },
114 {
115 name: "empty list",
116 in: "tags =\n",
117 check: func(t *testing.T, c *testConfig) { eq(t, c.Tags, []string{}) },
118 },
119 {
120 name: "path",
121 in: "dir = ~/shots/../pics\n",
122 check: func(t *testing.T, c *testConfig) { eq(t, c.Dir, Path(filepath.Join(home, "pics"))) },
123 },
124 {
125 name: "map of strings",
126 in: "bind.cmd+q = close\nbind.cmd+return = spawn foot\n",
127 check: func(t *testing.T, c *testConfig) {
128 eq(t, c.Binds, map[string]string{"cmd+q": "close", "cmd+return": "spawn foot"})
129 },
130 },
131 {
132 name: "map of structs with dotted names and defaults",
133 in: "output.DP-1.scale = 1.5\noutput.eDP.1.hdr = on\noutput.DP-1.primary = on\n",
134 check: func(t *testing.T, c *testConfig) {
135 eq(t, c.Outputs, map[string]output{
136 "DP-1": {Scale: 1.5, Primary: true, Bright: 203},
137 "eDP.1": {Scale: 1, HDR: true, Bright: 203},
138 })
139 },
140 },
141 {
142 name: "map of structs rejects bad value without creating the entry",
143 in: "output.DP-1.scale = 9\n",
144 check: func(t *testing.T, c *testConfig) { eq(t, len(c.Outputs), 0) },
145 warnings: []string{"line 1: output.DP-1.scale: must be at most 4"},
146 },
147 {
148 name: "map of ints",
149 in: "code.foo = 3\n",
150 check: func(t *testing.T, c *testConfig) { eq(t, c.Codes, map[string]int{"foo": 3}) },
151 },
152 {
153 name: "catch-all keeps unknown keys in order",
154 in: "zz = 1\naa = 2\noutput.DP-1.nope = 3\n",
155 check: func(t *testing.T, c *testConfig) {
156 eq(t, c.Rest, []Entry{{"zz", "1", 1}, {"aa", "2", 2}, {"output.DP-1.nope", "3", 3}})
157 },
158 },
159 {
160 name: "invalid values keep the previous value",
161 in: `count = 0
162count = 11
163count = x
164enabled = maybe
165mode = blue
166lock.after = 2h
167lock.after = soon
168name =
169size = -1
170ratio = big
171ports = 80|0
172dir = rel/path
173`,
174 check: func(t *testing.T, c *testConfig) {
175 want := defaults()
176 if !reflect.DeepEqual(*c, want) {
177 t.Errorf("got %+v\nwant %+v", *c, want)
178 }
179 },
180 warnings: []string{
181 "line 2: count: overrides line 1",
182 "line 3: count: overrides line 2",
183 "line 7: lock.after: overrides line 6",
184 "line 1: count: must be at least 1",
185 "line 2: count: must be at most 10",
186 "line 3: count: must be an integer",
187 "line 4: enabled: must be on or off",
188 "line 5: mode: must be auto, dark or light",
189 "line 6: lock.after: must be at most 1h",
190 "line 7: lock.after: must be a duration such as 500ms, 5m or 1h30m",
191 "line 8: name: must not be empty",
192 "line 9: size: must be a non-negative integer",
193 "line 10: ratio: must be a number",
194 "line 11: ports: item 2: must be at least 1",
195 "line 12: dir: must be an absolute path or start with ~/",
196 },
197 },
198 {
199 name: "last valid value wins",
200 in: "count = 2\ncount = 3\n",
201 opts: Options{AllowDuplicates: true},
202 check: func(t *testing.T, c *testConfig) { eq(t, c.Count, 3) },
203 },
204 {
205 name: "strict fails on first bad value",
206 in: "count = 2\ncount = 99\nmode = x\n",
207 opts: Options{Strict: true, AllowDuplicates: true},
208 warnings: []string{"line 2: count: must be at most 10"},
209 err: "line 2: count: must be at most 10",
210 },
211 }
212 for _, tt := range tests {
213 t.Run(tt.name, func(t *testing.T) {
214 c := defaults()
215 warnings, err := Unmarshal([]byte(tt.in), &c, tt.opts)
216 if tt.err != "" {
217 if err == nil || err.Error() != tt.err {
218 t.Fatalf("err = %v, want %q", err, tt.err)
219 }
220 } else if err != nil {
221 t.Fatalf("err = %v", err)
222 }
223 if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
224 t.Errorf("warnings =\n%q\nwant\n%q", got, tt.warnings)
225 }
226 if tt.check != nil {
227 tt.check(t, &c)
228 }
229 })
230 }
231}
232
233type nestedMaps struct {
234 All map[string]string `kv:"m.*"`
235 Outputs map[string]output `kv:"m.out.*"`
236}
237
238func TestDecodeMaps(t *testing.T) {
239 // One key per name: more names than the element hint guesses, past the
240 // linear search.
241 var many strings.Builder
242 want := map[string]output{}
243 for i := range 3 * maxElemHint {
244 name := "o" + strconv.Itoa(i)
245 fmt.Fprintf(&many, "output.%s.hdr = on\n", name)
246 want[name] = output{Scale: 1, HDR: true, Bright: 203}
247 }
248 tests := []struct {
249 name string
250 in string
251 opts Options
252 start func(c *testConfig)
253 check func(t *testing.T, c *testConfig)
254 err bool
255 }{
256 {
257 name: "many names",
258 in: many.String(),
259 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, want) },
260 },
261 {
262 name: "one name, many keys",
263 in: strings.Repeat("output.x.scale = 2\n", 200),
264 opts: Options{AllowDuplicates: true},
265 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{"x": {Scale: 2, Bright: 203}}) },
266 },
267 {
268 name: "map reached before a strict error exists",
269 in: "output.x.scale = 2\ncount = bad\n",
270 opts: Options{Strict: true},
271 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{}) },
272 err: true,
273 },
274 {
275 name: "existing elements are updated, not reset",
276 in: "output.A.hdr = on\noutput.B.scale = 3\nbind.x = y\n",
277 start: func(c *testConfig) {
278 c.Outputs = map[string]output{"A": {Scale: 2, Bright: 100}, "C": {Scale: 1}}
279 c.Binds = map[string]string{"keep": "me"}
280 },
281 check: func(t *testing.T, c *testConfig) {
282 eq(t, c.Outputs, map[string]output{
283 "A": {Scale: 2, HDR: true, Bright: 100},
284 "B": {Scale: 3, Bright: 203},
285 "C": {Scale: 1},
286 })
287 eq(t, c.Binds, map[string]string{"keep": "me", "x": "y"})
288 },
289 },
290 {
291 name: "rejected element leaves an existing map untouched",
292 in: "output.A.scale = 9\n",
293 start: func(c *testConfig) { c.Outputs = map[string]output{"A": {Scale: 2}} },
294 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{"A": {Scale: 2}}) },
295 },
296 }
297 for _, tt := range tests {
298 t.Run(tt.name, func(t *testing.T) {
299 c := defaults()
300 if tt.start != nil {
301 tt.start(&c)
302 }
303 _, err := Unmarshal([]byte(tt.in), &c, tt.opts)
304 if (err != nil) != tt.err {
305 t.Fatalf("err = %v", err)
306 }
307 tt.check(t, &c)
308 })
309 }
310
311 t.Run("key matching two prefixes", func(t *testing.T) {
312 // m.out.x.nope is no output field, so m.* takes it.
313 var n nestedMaps
314 _, err := Unmarshal([]byte("m.out.x.scale = 2\nm.out.x.nope = 1\nm.a = b\n"), &n, Options{})
315 must(t, err)
316 eq(t, n.Outputs, map[string]output{"x": {Scale: 2, Bright: 203}})
317 eq(t, n.All, map[string]string{"out.x.nope": "1", "a": "b"})
318 })
319}
320
321type plain struct {
322 A int `kv:"a"`
323}
324
325type validated struct {
326 Min int `kv:"min"`
327 Max int `kv:"max"`
328}
329
330func (v *validated) Validate() error {
331 if v.Min > v.Max {
332 return errors.New("min must not exceed max")
333 }
334 return nil
335}
336
337func TestDecodeOptionsAndValidate(t *testing.T) {
338 tests := []struct {
339 name string
340 in string
341 target any
342 opts Options
343 warnings []string
344 err string
345 }{
346 {name: "unknown key warns", in: "b = 1\n", target: &plain{}, warnings: []string{"line 1: b: unknown key"}},
347 {name: "unknown key allowed", in: "b = 1\n", target: &plain{}, opts: Options{AllowUnknown: true}},
348 {name: "unknown key strict", in: "b = 1\n", target: &plain{}, opts: Options{Strict: true},
349 warnings: []string{"line 1: b: unknown key"}, err: "line 1: b: unknown key"},
350 {name: "validate passes", in: "min = 1\nmax = 2\n", target: &validated{}},
351 {name: "validate fails", in: "min = 3\nmax = 2\n", target: &validated{}, err: "min must not exceed max"},
352 {name: "not a pointer", target: plain{}, err: errTarget.Error()},
353 {name: "nil pointer", target: (*plain)(nil), err: errTarget.Error()},
354 }
355 for _, tt := range tests {
356 t.Run(tt.name, func(t *testing.T) {
357 warnings, err := Unmarshal([]byte(tt.in), tt.target, tt.opts)
358 if gotErr := errString(err); gotErr != tt.err {
359 t.Fatalf("err = %q, want %q", gotErr, tt.err)
360 }
361 if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
362 t.Errorf("warnings = %q, want %q", got, tt.warnings)
363 }
364 })
365 }
366}
367
368func errString(err error) string {
369 if err == nil {
370 return ""
371 }
372 return err.Error()
373}
374
375func TestSchemaErrors(t *testing.T) {
376 tests := []struct {
377 name string
378 target any
379 want string
380 }{
381 {"unknown option", &struct {
382 A int `kv:"a,bogus"`
383 }{}, `unknown tag option "bogus"`},
384 {"unsupported type", &struct {
385 A chan int `kv:"a"`
386 }{}, "unsupported type chan int"},
387 {"duplicate key", &struct {
388 A int `kv:"a"`
389 B int `kv:"a"`
390 }{}, `key "a" declared twice`},
391 {"map without string keys", &struct {
392 A map[int]int `kv:"a.*"`
393 }{}, "needs a map with string keys"},
394 {"bad catch-all", &struct {
395 A int `kv:"*"`
396 }{}, `needs []kvconf.Entry or map[string]string`},
397 {"unnamed struct", &struct {
398 A struct{} `kv:""`
399 }{}, "needs a name or inline"},
400 {"bad bound", &struct {
401 A int `kv:"a" min:"x"`
402 }{}, `bad min "x"`},
403 {"nested list", &struct {
404 A [][]int `kv:"a"`
405 }{}, "nested lists are not supported"},
406 }
407 for _, tt := range tests {
408 t.Run(tt.name, func(t *testing.T) {
409 _, err := Unmarshal(nil, tt.target, Options{})
410 if err == nil || !strings.Contains(err.Error(), tt.want) {
411 t.Fatalf("err = %v, want it to contain %q", err, tt.want)
412 }
413 })
414 }
415}
416
417// leaky mimics a third-party type whose errors quote the input.
418type leaky string
419
420func (l *leaky) UnmarshalText(b []byte) error { return fmt.Errorf("bad input %q", b) }
421
422// explained returns a ValueError, which is shown.
423type explained string
424
425func (e *explained) UnmarshalText([]byte) error { return ValueError("must be a colour") }
426
427func TestDecodeNeverLeaksValues(t *testing.T) {
428 type cfg struct {
429 L leaky `kv:"l"`
430 E explained `kv:"e"`
431 Ratio float64 `kv:"ratio" min:"0" max:"1"`
432 Any float64 `kv:"any"`
433 }
434 tests := []struct{ in, want string }{
435 {"l = hunter2", "line 1: l: invalid kvconf.leaky"},
436 {"e = hunter2", "line 1: e: must be a colour"},
437 {"ratio = NaN", "line 1: ratio: must be a finite number"},
438 {"ratio = +Inf", "line 1: ratio: must be a finite number"},
439 {"any = NaN", "line 1: any: must be a finite number"},
440 }
441 for _, tt := range tests {
442 t.Run(tt.in, func(t *testing.T) {
443 var c cfg
444 w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{})
445 must(t, err)
446 eq(t, warningStrings(w), []string{tt.want})
447 })
448 }
449}
450
451func TestParseBool(t *testing.T) {
452 tests := []struct {
453 in string
454 want bool
455 err bool
456 }{
457 {"on", true, false}, {"ON", true, false}, {"true", true, false}, {"Yes", true, false}, {"1", true, false},
458 {"off", false, false}, {"False", false, false}, {"no", false, false}, {"0", false, false},
459 {"", false, true}, {"2", false, true}, {"enable", false, true},
460 }
461 for _, tt := range tests {
462 t.Run(tt.in, func(t *testing.T) {
463 got, err := ParseBool(tt.in)
464 if (err != nil) != tt.err || got != tt.want {
465 t.Fatalf("ParseBool(%q) = %v, %v", tt.in, got, err)
466 }
467 })
468 }
469}
470
471func eq[T any](t *testing.T, got, want T) {
472 t.Helper()
473 if !reflect.DeepEqual(got, want) {
474 t.Errorf("got %#v, want %#v", got, want)
475 }
476}