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