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 for _, tt := range tests {
212 t.Run(tt.name, func(t *testing.T) {
213 c := defaults()
214 warnings, err := Unmarshal([]byte(tt.in), &c, tt.opts)
215 if tt.err != "" {
216 if err == nil || err.Error() != tt.err {
217 t.Fatalf("err = %v, want %q", err, tt.err)
218 }
219 } else if err != nil {
220 t.Fatalf("err = %v", err)
221 }
222 if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
223 t.Errorf("warnings =\n%q\nwant\n%q", got, tt.warnings)
224 }
225 if tt.check != nil {
226 tt.check(t, &c)
227 }
228 })
229 }
230}
231
232type plain struct {
233 A int `kv:"a"`
234}
235
236type validated struct {
237 Min int `kv:"min"`
238 Max int `kv:"max"`
239}
240
241func (v *validated) Validate() error {
242 if v.Min > v.Max {
243 return errors.New("min must not exceed max")
244 }
245 return nil
246}
247
248func TestDecodeOptionsAndValidate(t *testing.T) {
249 tests := []struct {
250 name string
251 in string
252 target any
253 opts Options
254 warnings []string
255 err string
256 }{
257 {name: "unknown key warns", in: "b = 1\n", target: &plain{}, warnings: []string{"line 1: b: unknown key"}},
258 {name: "unknown key allowed", in: "b = 1\n", target: &plain{}, opts: Options{AllowUnknown: true}},
259 {name: "unknown key strict", in: "b = 1\n", target: &plain{}, opts: Options{Strict: true},
260 warnings: []string{"line 1: b: unknown key"}, err: "line 1: b: unknown key"},
261 {name: "validate passes", in: "min = 1\nmax = 2\n", target: &validated{}},
262 {name: "validate fails", in: "min = 3\nmax = 2\n", target: &validated{}, err: "min must not exceed max"},
263 {name: "not a pointer", target: plain{}, err: errTarget.Error()},
264 {name: "nil pointer", target: (*plain)(nil), err: errTarget.Error()},
265 }
266 for _, tt := range tests {
267 t.Run(tt.name, func(t *testing.T) {
268 warnings, err := Unmarshal([]byte(tt.in), tt.target, tt.opts)
269 if gotErr := errString(err); gotErr != tt.err {
270 t.Fatalf("err = %q, want %q", gotErr, tt.err)
271 }
272 if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
273 t.Errorf("warnings = %q, want %q", got, tt.warnings)
274 }
275 })
276 }
277}
278
279func errString(err error) string {
280 if err == nil {
281 return ""
282 }
283 return err.Error()
284}
285
286func TestSchemaErrors(t *testing.T) {
287 tests := []struct {
288 name string
289 target any
290 want string
291 }{
292 {"unknown option", &struct {
293 A int `kv:"a,bogus"`
294 }{}, `unknown tag option "bogus"`},
295 {"unsupported type", &struct {
296 A chan int `kv:"a"`
297 }{}, "unsupported type chan int"},
298 {"duplicate key", &struct {
299 A int `kv:"a"`
300 B int `kv:"a"`
301 }{}, `key "a" declared twice`},
302 {"map without string keys", &struct {
303 A map[int]int `kv:"a.*"`
304 }{}, "needs a map with string keys"},
305 {"bad catch-all", &struct {
306 A int `kv:"*"`
307 }{}, `needs []kvconf.Entry or map[string]string`},
308 {"unnamed struct", &struct {
309 A struct{} `kv:""`
310 }{}, "needs a name or inline"},
311 {"bad bound", &struct {
312 A int `kv:"a" min:"x"`
313 }{}, `bad min "x"`},
314 {"nested list", &struct {
315 A [][]int `kv:"a"`
316 }{}, "nested lists are not supported"},
317 }
318 for _, tt := range tests {
319 t.Run(tt.name, func(t *testing.T) {
320 _, err := Unmarshal(nil, tt.target, Options{})
321 if err == nil || !strings.Contains(err.Error(), tt.want) {
322 t.Fatalf("err = %v, want it to contain %q", err, tt.want)
323 }
324 })
325 }
326}
327
328// leaky mimics a third-party type whose errors quote the input.
329type leaky string
330
331func (l *leaky) UnmarshalText(b []byte) error { return fmt.Errorf("bad input %q", b) }
332
333// explained returns a ValueError, which is shown.
334type explained string
335
336func (e *explained) UnmarshalText([]byte) error { return ValueError("must be a colour") }
337
338func TestDecodeNeverLeaksValues(t *testing.T) {
339 type cfg struct {
340 L leaky `kv:"l"`
341 E explained `kv:"e"`
342 Ratio float64 `kv:"ratio" min:"0" max:"1"`
343 Any float64 `kv:"any"`
344 }
345 tests := []struct{ in, want string }{
346 {"l = hunter2", "line 1: l: invalid kvconf.leaky"},
347 {"e = hunter2", "line 1: e: must be a colour"},
348 {"ratio = NaN", "line 1: ratio: must be a finite number"},
349 {"ratio = +Inf", "line 1: ratio: must be a finite number"},
350 {"any = NaN", "line 1: any: must be a finite number"},
351 }
352 for _, tt := range tests {
353 t.Run(tt.in, func(t *testing.T) {
354 var c cfg
355 w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{})
356 must(t, err)
357 eq(t, warningStrings(w), []string{tt.want})
358 })
359 }
360}
361
362func TestParseBool(t *testing.T) {
363 tests := []struct {
364 in string
365 want bool
366 err bool
367 }{
368 {"on", true, false}, {"ON", true, false}, {"true", true, false}, {"Yes", true, false}, {"1", true, false},
369 {"off", false, false}, {"False", false, false}, {"no", false, false}, {"0", false, false},
370 {"", false, true}, {"2", false, true}, {"enable", false, true},
371 }
372 for _, tt := range tests {
373 t.Run(tt.in, func(t *testing.T) {
374 got, err := ParseBool(tt.in)
375 if (err != nil) != tt.err || got != tt.want {
376 t.Fatalf("ParseBool(%q) = %v, %v", tt.in, got, err)
377 }
378 })
379 }
380}
381
382func eq[T any](t *testing.T, got, want T) {
383 t.Helper()
384 if !reflect.DeepEqual(got, want) {
385 t.Errorf("got %#v, want %#v", got, want)
386 }
387}