detached 946d18c

fix(live): skip reloads that change no key and track file presence

Reordered keys no longer swap the config or call OnChange with no keys,
and removing a file that was created empty is reported. Load validates
missing-file defaults through Decode. Slice items read their own oneof,
min and max, and map[string]string decoding no longer uses unsafe.

Tests: Run waits on Ready instead of sleeping, missing files are checked
with errors.Is, DefaultPath expects literal paths, file tests move to
file_test.go, and vacuous checks are removed.
commit
946d18cfab518cb389e813a271b460794b1a8fc9
author
brice · · 2026-10-10 06:21 UTC
parent
3b1b6ef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +203 -141
M decode.go +6 -9
@@ -231,14 +231,16 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string
231231 if item.item != nil {
232232 return nil, fmt.Errorf("kvconf: %s: nested lists are not supported", where)
233233 }
234- item.oneof, f.oneof = f.oneof, nil
235- f.item = item
234+ // The item reads oneof, min and max from the same tag and
235+ // checks each element against them.
236+ f.item, f.oneof = item, nil
237+ return f, nil
236238 default:
237239 return nil, fmt.Errorf("kvconf: %s: unsupported type %s", where, t)
238240 }
239241 }
240242 bound := func(s string) (float64, error) {
241- if f.duration || f.item != nil && f.item.duration {
243+ if f.duration {
242244 d, err := time.ParseDuration(s)
243245 return float64(d), err
244246 }
@@ -257,11 +259,6 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string
257259 return nil, fmt.Errorf("kvconf: %s: bad max %q", where, f.maxText)
258260 }
259261 }
260- if f.item != nil {
261- f.item.hasMin, f.item.min, f.item.minText = f.hasMin, f.min, f.minText
262- f.item.hasMax, f.item.max, f.item.maxText = f.hasMax, f.max, f.maxText
263- f.hasMin, f.hasMax = false, false
264- }
265262 return f, nil
266263 }
267264
@@ -420,7 +417,7 @@ func (s *schema) apply(st *decodeState, e *Entry) (msg string, known bool) {
420417 return msg, true
421418 }
422419 if f.typ == stringMapType && f.val.plain() {
423- (*(*map[string]string)(m.Addr().UnsafePointer()))[name] = e.Value
420+ m.Interface().(map[string]string)[name] = e.Value
424421 return "", true
425422 }
426423 key, val := st.scratch(f)
@@ -231,14 +231,16 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string
231 if item.item != nil {231 if item.item != nil {
232 return nil, fmt.Errorf("kvconf: %s: nested lists are not supported", where)232 return nil, fmt.Errorf("kvconf: %s: nested lists are not supported", where)
233 }233 }
234- item.oneof, f.oneof = f.oneof, nil234+ // The item reads oneof, min and max from the same tag and
235- f.item = item235+ // checks each element against them.
236+ f.item, f.oneof = item, nil
237+ return f, nil
236 default:238 default:
237 return nil, fmt.Errorf("kvconf: %s: unsupported type %s", where, t)239 return nil, fmt.Errorf("kvconf: %s: unsupported type %s", where, t)
238 }240 }
239 }241 }
240 bound := func(s string) (float64, error) {242 bound := func(s string) (float64, error) {
241- if f.duration || f.item != nil && f.item.duration {243+ if f.duration {
242 d, err := time.ParseDuration(s)244 d, err := time.ParseDuration(s)
243 return float64(d), err245 return float64(d), err
244 }246 }
@@ -257,11 +259,6 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string
257 return nil, fmt.Errorf("kvconf: %s: bad max %q", where, f.maxText)259 return nil, fmt.Errorf("kvconf: %s: bad max %q", where, f.maxText)
258 }260 }
259 }261 }
260- if f.item != nil {
261- f.item.hasMin, f.item.min, f.item.minText = f.hasMin, f.min, f.minText
262- f.item.hasMax, f.item.max, f.item.maxText = f.hasMax, f.max, f.maxText
263- f.hasMin, f.hasMax = false, false
264- }
265 return f, nil262 return f, nil
266 }263 }
267 264
@@ -420,7 +417,7 @@ func (s *schema) apply(st *decodeState, e *Entry) (msg string, known bool) {
420 return msg, true417 return msg, true
421 }418 }
422 if f.typ == stringMapType && f.val.plain() {419 if f.typ == stringMapType && f.val.plain() {
423- (*(*map[string]string)(m.Addr().UnsafePointer()))[name] = e.Value420+ m.Interface().(map[string]string)[name] = e.Value
424 return "", true421 return "", true
425 }422 }
426 key, val := st.scratch(f)423 key, val := st.scratch(f)
M decode_test.go +0 -9
@@ -207,12 +207,6 @@ dir = rel/path
207207 warnings: []string{"line 2: count: must be at most 10"},
208208 err: "line 2: count: must be at most 10",
209209 },
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- },
216210 }
217211 for _, tt := range tests {
218212 t.Run(tt.name, func(t *testing.T) {
@@ -361,9 +355,6 @@ func TestDecodeNeverLeaksValues(t *testing.T) {
361355 w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{})
362356 must(t, err)
363357 eq(t, warningStrings(w), []string{tt.want})
364- if strings.Contains(tt.want, "hunter2") {
365- t.Fatal("value leaked")
366- }
367358 })
368359 }
369360 }
@@ -207,12 +207,6 @@ dir = rel/path
207 warnings: []string{"line 2: count: must be at most 10"},207 warnings: []string{"line 2: count: must be at most 10"},
208 err: "line 2: count: must be at most 10",208 err: "line 2: count: must be at most 10",
209 },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 }210 }
217 for _, tt := range tests {211 for _, tt := range tests {
218 t.Run(tt.name, func(t *testing.T) {212 t.Run(tt.name, func(t *testing.T) {
@@ -361,9 +355,6 @@ func TestDecodeNeverLeaksValues(t *testing.T) {
361 w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{})355 w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{})
362 must(t, err)356 must(t, err)
363 eq(t, warningStrings(w), []string{tt.want})357 eq(t, warningStrings(w), []string{tt.want})
364- if strings.Contains(tt.want, "hunter2") {
365- t.Fatal("value leaked")
366- }
367 })358 })
368 }359 }
369 }360 }
A file_test.go +107 -0
@@ -0,0 +1,107 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "testing"
10+)
11+
12+func TestReadFile(t *testing.T) {
13+ dir := t.TempDir()
14+ tests := []struct {
15+ name string
16+ setup func(t *testing.T) string
17+ opts Options
18+ want string
19+ err error
20+ }{
21+ {name: "regular", setup: func(t *testing.T) string { return writeTemp(t, dir, "ok", "a = 1\n") }, want: "a = 1\n"},
22+ {name: "missing", setup: func(t *testing.T) string { return filepath.Join(dir, "none") }, err: fs.ErrNotExist},
23+ {name: "directory", setup: func(t *testing.T) string { return dir }, err: ErrNotRegular},
24+ {
25+ name: "too large",
26+ setup: func(t *testing.T) string { return writeTemp(t, dir, "big", "0123456789") },
27+ opts: Options{MaxBytes: 4},
28+ err: ErrTooLarge,
29+ },
30+ {
31+ name: "at the limit",
32+ setup: func(t *testing.T) string { return writeTemp(t, dir, "limit", "0123") },
33+ opts: Options{MaxBytes: 4},
34+ want: "0123",
35+ },
36+ }
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ data, err := ReadFile(tt.setup(t), tt.opts)
40+ if tt.err != nil {
41+ if !errors.Is(err, tt.err) {
42+ t.Fatalf("err = %v, want %v", err, tt.err)
43+ }
44+ return
45+ }
46+ must(t, err)
47+ eq(t, string(data), tt.want)
48+ })
49+ }
50+}
51+
52+func TestExpandPath(t *testing.T) {
53+ home, err := os.UserHomeDir()
54+ if err != nil {
55+ t.Skip(err)
56+ }
57+ abs := filepath.Join(home, "x")
58+ tests := []struct {
59+ in, want string
60+ err bool
61+ }{
62+ {in: "~/x", want: abs},
63+ {in: "~", want: home},
64+ {in: abs + string(filepath.Separator) + ".", want: abs},
65+ {in: "", err: true},
66+ {in: "rel", err: true},
67+ {in: "~user/x", err: true},
68+ {in: "/a\x00b", err: true},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.in, func(t *testing.T) {
72+ got, err := ExpandPath(tt.in)
73+ if (err != nil) != tt.err || got != tt.want {
74+ t.Fatalf("ExpandPath(%q) = %q, %v", tt.in, got, err)
75+ }
76+ })
77+ }
78+}
79+
80+func TestDefaultPath(t *testing.T) {
81+ home := t.TempDir()
82+ xdg := filepath.Join(home, "xdg")
83+ // The platform config dir, spelled out rather than recomputed.
84+ native := filepath.Join(home, ".config")
85+ switch runtime.GOOS {
86+ case "darwin":
87+ native = filepath.Join(home, "Library", "Application Support")
88+ case "windows":
89+ native = filepath.Join(home, "AppData")
90+ }
91+ tests := []struct{ name, xdg, want string }{
92+ {name: "absolute XDG", xdg: xdg, want: filepath.Join(xdg, "app", "config")},
93+ {name: "relative XDG is ignored", xdg: "relative", want: filepath.Join(native, "app", "config")},
94+ {name: "no XDG", want: filepath.Join(native, "app", "config")},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ t.Setenv("HOME", home)
99+ t.Setenv("USERPROFILE", home)
100+ t.Setenv("AppData", filepath.Join(home, "AppData"))
101+ t.Setenv("XDG_CONFIG_HOME", tt.xdg)
102+ got, err := DefaultPath("app", "config")
103+ must(t, err)
104+ eq(t, got, tt.want)
105+ })
106+ }
107+}
@@ -0,0 +1,107 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "testing"
10+)
11+
12+func TestReadFile(t *testing.T) {
13+ dir := t.TempDir()
14+ tests := []struct {
15+ name string
16+ setup func(t *testing.T) string
17+ opts Options
18+ want string
19+ err error
20+ }{
21+ {name: "regular", setup: func(t *testing.T) string { return writeTemp(t, dir, "ok", "a = 1\n") }, want: "a = 1\n"},
22+ {name: "missing", setup: func(t *testing.T) string { return filepath.Join(dir, "none") }, err: fs.ErrNotExist},
23+ {name: "directory", setup: func(t *testing.T) string { return dir }, err: ErrNotRegular},
24+ {
25+ name: "too large",
26+ setup: func(t *testing.T) string { return writeTemp(t, dir, "big", "0123456789") },
27+ opts: Options{MaxBytes: 4},
28+ err: ErrTooLarge,
29+ },
30+ {
31+ name: "at the limit",
32+ setup: func(t *testing.T) string { return writeTemp(t, dir, "limit", "0123") },
33+ opts: Options{MaxBytes: 4},
34+ want: "0123",
35+ },
36+ }
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ data, err := ReadFile(tt.setup(t), tt.opts)
40+ if tt.err != nil {
41+ if !errors.Is(err, tt.err) {
42+ t.Fatalf("err = %v, want %v", err, tt.err)
43+ }
44+ return
45+ }
46+ must(t, err)
47+ eq(t, string(data), tt.want)
48+ })
49+ }
50+}
51+
52+func TestExpandPath(t *testing.T) {
53+ home, err := os.UserHomeDir()
54+ if err != nil {
55+ t.Skip(err)
56+ }
57+ abs := filepath.Join(home, "x")
58+ tests := []struct {
59+ in, want string
60+ err bool
61+ }{
62+ {in: "~/x", want: abs},
63+ {in: "~", want: home},
64+ {in: abs + string(filepath.Separator) + ".", want: abs},
65+ {in: "", err: true},
66+ {in: "rel", err: true},
67+ {in: "~user/x", err: true},
68+ {in: "/a\x00b", err: true},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.in, func(t *testing.T) {
72+ got, err := ExpandPath(tt.in)
73+ if (err != nil) != tt.err || got != tt.want {
74+ t.Fatalf("ExpandPath(%q) = %q, %v", tt.in, got, err)
75+ }
76+ })
77+ }
78+}
79+
80+func TestDefaultPath(t *testing.T) {
81+ home := t.TempDir()
82+ xdg := filepath.Join(home, "xdg")
83+ // The platform config dir, spelled out rather than recomputed.
84+ native := filepath.Join(home, ".config")
85+ switch runtime.GOOS {
86+ case "darwin":
87+ native = filepath.Join(home, "Library", "Application Support")
88+ case "windows":
89+ native = filepath.Join(home, "AppData")
90+ }
91+ tests := []struct{ name, xdg, want string }{
92+ {name: "absolute XDG", xdg: xdg, want: filepath.Join(xdg, "app", "config")},
93+ {name: "relative XDG is ignored", xdg: "relative", want: filepath.Join(native, "app", "config")},
94+ {name: "no XDG", want: filepath.Join(native, "app", "config")},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ t.Setenv("HOME", home)
99+ t.Setenv("USERPROFILE", home)
100+ t.Setenv("AppData", filepath.Join(home, "AppData"))
101+ t.Setenv("XDG_CONFIG_HOME", tt.xdg)
102+ got, err := DefaultPath("app", "config")
103+ must(t, err)
104+ eq(t, got, tt.want)
105+ })
106+ }
107+}
M live.go +18 -13
@@ -17,21 +17,25 @@ func Load[T any](path string, opts Options) (*T, []Warning, error) {
1717 return cfg, warnings, err
1818 }
1919
20+// load returns nil entries, not an empty slice, when the file is missing.
2021 func load[T any](path string, opts Options) (*T, []Entry, []Warning, error) {
2122 data, err := ReadFile(path, opts)
2223 if err != nil {
2324 if isNotExist(err) && !opts.RequireFile {
25+ // No entries: the defaults, then Validate.
2426 cfg := newDefault[T]()
25- if v, ok := any(cfg).(Validator); ok {
26- if err := v.Validate(); err != nil {
27- return nil, nil, nil, err
28- }
27+ if _, err := Decode(nil, cfg, opts); err != nil {
28+ return nil, nil, nil, err
2929 }
3030 return cfg, nil, nil, nil
3131 }
3232 return nil, nil, nil, err
3333 }
34- return decodeFile[T](path, data, opts)
34+ cfg, entries, warnings, err := decodeFile[T](path, data, opts)
35+ if err == nil && entries == nil {
36+ entries = []Entry{}
37+ }
38+ return cfg, entries, warnings, err
3539 }
3640
3741 func decodeFile[T any](path string, data []byte, opts Options) (*T, []Entry, []Warning, error) {
@@ -84,6 +88,7 @@ type Live[T any] struct {
8488 opts LiveOptions[T]
8589 cur atomic.Pointer[T]
8690 entries []Entry // owned by Run
91+ exists bool // whether the file was there at the last load; owned by Run
8792 running atomic.Bool
8893 }
8994
@@ -94,7 +99,7 @@ func Open[T any](path string, opts LiveOptions[T]) (*Live[T], []Warning, error)
9499 if err != nil {
95100 return nil, warnings, err
96101 }
97- l := &Live[T]{path: path, opts: opts, entries: entries}
102+ l := &Live[T]{path: path, opts: opts, entries: entries, exists: entries != nil}
98103 l.cur.Store(cfg)
99104 return l, warnings, nil
100105 }
@@ -113,8 +118,6 @@ func (l *Live[T]) Run(ctx context.Context) error {
113118 if !l.running.CompareAndSwap(false, true) {
114119 return errors.New("kvconf: Live.Run called twice")
115120 }
116- ctx, cancel := context.WithCancel(ctx)
117- defer cancel()
118121 changed := make(chan struct{}, 1)
119122 ready := make(chan struct{})
120123 wopts := l.opts.Watch
@@ -147,7 +150,8 @@ func (l *Live[T]) reload() {
147150 if isNotExist(err) {
148151 // Keep the current config: a removed file is often about to be
149152 // replaced, and an empty config is rarely what the user meant.
150- if l.entries != nil {
153+ if l.exists {
154+ l.exists = false
151155 l.report(fmt.Errorf("%s: %w", l.path, fs.ErrNotExist))
152156 }
153157 return
@@ -161,13 +165,14 @@ func (l *Live[T]) reload() {
161165 l.report(err)
162166 return
163167 }
164- if sameEntries(entries, l.entries) {
165- return
166- }
168+ l.exists = true
167169 // Compare every assignment, not the last one: a repeated key whose
168170 // last value is invalid decodes to an earlier one.
169171 keys := changedKeys(l.entries, entries)
170172 l.entries = entries
173+ if len(keys) == 0 {
174+ return
175+ }
171176 old := l.cur.Swap(cfg)
172177 if l.opts.OnChange != nil {
173178 l.opts.OnChange(Change[T]{Old: old, New: cfg, Keys: keys, Warnings: warnings})
@@ -200,7 +205,7 @@ func changedKeys(old, cur []Entry) []string {
200205 }
201206
202207 func (l *Live[T]) report(err error) {
203- if l.opts.OnError != nil && !errors.Is(err, context.Canceled) {
208+ if l.opts.OnError != nil {
204209 l.opts.OnError(err)
205210 }
206211 }
@@ -17,21 +17,25 @@ func Load[T any](path string, opts Options) (*T, []Warning, error) {
17 return cfg, warnings, err17 return cfg, warnings, err
18 }18 }
19 19
20+// load returns nil entries, not an empty slice, when the file is missing.
20 func load[T any](path string, opts Options) (*T, []Entry, []Warning, error) {21 func load[T any](path string, opts Options) (*T, []Entry, []Warning, error) {
21 data, err := ReadFile(path, opts)22 data, err := ReadFile(path, opts)
22 if err != nil {23 if err != nil {
23 if isNotExist(err) && !opts.RequireFile {24 if isNotExist(err) && !opts.RequireFile {
25+ // No entries: the defaults, then Validate.
24 cfg := newDefault[T]()26 cfg := newDefault[T]()
25- if v, ok := any(cfg).(Validator); ok {27+ if _, err := Decode(nil, cfg, opts); err != nil {
26- if err := v.Validate(); err != nil {28+ return nil, nil, nil, err
27- return nil, nil, nil, err
28- }
29 }29 }
30 return cfg, nil, nil, nil30 return cfg, nil, nil, nil
31 }31 }
32 return nil, nil, nil, err32 return nil, nil, nil, err
33 }33 }
34- return decodeFile[T](path, data, opts)34+ cfg, entries, warnings, err := decodeFile[T](path, data, opts)
35+ if err == nil && entries == nil {
36+ entries = []Entry{}
37+ }
38+ return cfg, entries, warnings, err
35 }39 }
36 40
37 func decodeFile[T any](path string, data []byte, opts Options) (*T, []Entry, []Warning, error) {41 func decodeFile[T any](path string, data []byte, opts Options) (*T, []Entry, []Warning, error) {
@@ -84,6 +88,7 @@ type Live[T any] struct {
84 opts LiveOptions[T]88 opts LiveOptions[T]
85 cur atomic.Pointer[T]89 cur atomic.Pointer[T]
86 entries []Entry // owned by Run90 entries []Entry // owned by Run
91+ exists bool // whether the file was there at the last load; owned by Run
87 running atomic.Bool92 running atomic.Bool
88 }93 }
89 94
@@ -94,7 +99,7 @@ func Open[T any](path string, opts LiveOptions[T]) (*Live[T], []Warning, error)
94 if err != nil {99 if err != nil {
95 return nil, warnings, err100 return nil, warnings, err
96 }101 }
97- l := &Live[T]{path: path, opts: opts, entries: entries}102+ l := &Live[T]{path: path, opts: opts, entries: entries, exists: entries != nil}
98 l.cur.Store(cfg)103 l.cur.Store(cfg)
99 return l, warnings, nil104 return l, warnings, nil
100 }105 }
@@ -113,8 +118,6 @@ func (l *Live[T]) Run(ctx context.Context) error {
113 if !l.running.CompareAndSwap(false, true) {118 if !l.running.CompareAndSwap(false, true) {
114 return errors.New("kvconf: Live.Run called twice")119 return errors.New("kvconf: Live.Run called twice")
115 }120 }
116- ctx, cancel := context.WithCancel(ctx)
117- defer cancel()
118 changed := make(chan struct{}, 1)121 changed := make(chan struct{}, 1)
119 ready := make(chan struct{})122 ready := make(chan struct{})
120 wopts := l.opts.Watch123 wopts := l.opts.Watch
@@ -147,7 +150,8 @@ func (l *Live[T]) reload() {
147 if isNotExist(err) {150 if isNotExist(err) {
148 // Keep the current config: a removed file is often about to be151 // Keep the current config: a removed file is often about to be
149 // replaced, and an empty config is rarely what the user meant.152 // replaced, and an empty config is rarely what the user meant.
150- if l.entries != nil {153+ if l.exists {
154+ l.exists = false
151 l.report(fmt.Errorf("%s: %w", l.path, fs.ErrNotExist))155 l.report(fmt.Errorf("%s: %w", l.path, fs.ErrNotExist))
152 }156 }
153 return157 return
@@ -161,13 +165,14 @@ func (l *Live[T]) reload() {
161 l.report(err)165 l.report(err)
162 return166 return
163 }167 }
164- if sameEntries(entries, l.entries) {168+ l.exists = true
165- return
166- }
167 // Compare every assignment, not the last one: a repeated key whose169 // Compare every assignment, not the last one: a repeated key whose
168 // last value is invalid decodes to an earlier one.170 // last value is invalid decodes to an earlier one.
169 keys := changedKeys(l.entries, entries)171 keys := changedKeys(l.entries, entries)
170 l.entries = entries172 l.entries = entries
173+ if len(keys) == 0 {
174+ return
175+ }
171 old := l.cur.Swap(cfg)176 old := l.cur.Swap(cfg)
172 if l.opts.OnChange != nil {177 if l.opts.OnChange != nil {
173 l.opts.OnChange(Change[T]{Old: old, New: cfg, Keys: keys, Warnings: warnings})178 l.opts.OnChange(Change[T]{Old: old, New: cfg, Keys: keys, Warnings: warnings})
@@ -200,7 +205,7 @@ func changedKeys(old, cur []Entry) []string {
200 }205 }
201 206
202 func (l *Live[T]) report(err error) {207 func (l *Live[T]) report(err error) {
203- if l.opts.OnError != nil && !errors.Is(err, context.Canceled) {208+ if l.opts.OnError != nil {
204 l.opts.OnError(err)209 l.opts.OnError(err)
205 }210 }
206 }211 }
M live_test.go +68 -9
@@ -39,9 +39,10 @@ func TestLoad(t *testing.T) {
3939 want liveConfig
4040 warnings int
4141 err string
42+ notExist bool
4243 }{
4344 {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
44- {name: "missing file required", opts: Options{RequireFile: true}, err: "config"},
45+ {name: "missing file required", opts: Options{RequireFile: true}, notExist: true},
4546 {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},
4647 {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1},
4748 {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
@@ -53,6 +54,12 @@ func TestLoad(t *testing.T) {
5354 must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
5455 }
5556 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+ }
5663 if tt.err != "" {
5764 if err == nil || !strings.Contains(err.Error(), tt.err) {
5865 t.Fatalf("err = %v, want %q", err, tt.err)
@@ -66,13 +73,40 @@ func TestLoad(t *testing.T) {
6673 }
6774 }
6875
69-func 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)
76+// badDefaults has defaults that fail its own Validate.
77+type badDefaults struct{ strictPair }
78+
79+func (p *badDefaults) SetDefaults() { p.A, p.B = 3, 2 }
80+func (p *badDefaults) Validate() error { return p.strictPair.Validate() }
81+
82+func 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"},
7594 }
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+
107+func loadErr[T any](path string) error {
108+ _, _, err := Load[T](path, Options{})
109+ return err
76110 }
77111
78112 // liveRecorder collects what a Live reports.
@@ -163,6 +197,30 @@ func TestLive(t *testing.T) {
163197 r.quiet(t, "a save that changes no value")
164198 },
165199 },
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+ },
166224 {
167225 name: "broken file keeps the config",
168226 initial: ptr("theme = light\n"),
@@ -263,12 +321,13 @@ func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
263321 }
264322
265323 func TestLiveRunTwice(t *testing.T) {
266- l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{})
324+ ready := make(chan struct{})
325+ l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{Watch: WatchOptions{Ready: ready}})
267326 must(t, err)
268327 ctx, cancel := context.WithCancel(context.Background())
269328 defer cancel()
270329 go func() { _ = l.Run(ctx) }()
271- time.Sleep(50 * time.Millisecond)
330+ <-ready
272331 if err := l.Run(ctx); err == nil {
273332 t.Fatal("a second Run was accepted")
274333 }
@@ -39,9 +39,10 @@ func TestLoad(t *testing.T) {
39 want liveConfig39 want liveConfig
40 warnings int40 warnings int
41 err string41 err string
42+ notExist bool
42 }{43 }{
43 {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},44 {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
44- {name: "missing file required", opts: Options{RequireFile: true}, err: "config"},45+ {name: "missing file required", opts: Options{RequireFile: true}, notExist: true},
45 {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},46 {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: "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 {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
@@ -53,6 +54,12 @@ func TestLoad(t *testing.T) {
53 must(t, os.WriteFile(path, []byte(*tt.content), 0o600))54 must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
54 }55 }
55 cfg, warnings, err := Load[liveConfig](path, tt.opts)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+ }
56 if tt.err != "" {63 if tt.err != "" {
57 if err == nil || !strings.Contains(err.Error(), tt.err) {64 if err == nil || !strings.Contains(err.Error(), tt.err) {
58 t.Fatalf("err = %v, want %q", err, tt.err)65 t.Fatalf("err = %v, want %q", err, tt.err)
@@ -66,13 +73,40 @@ func TestLoad(t *testing.T) {
66 }73 }
67 }74 }
68 75
69-func TestLoadValidatesDefaults(t *testing.T) {76+// badDefaults has defaults that fail its own Validate.
70- _, _, err := Load[strictPair](filepath.Join(t.TempDir(), "none"), Options{})77+type badDefaults struct{ strictPair }
71- must(t, err)78+
72- path := writeTemp(t, t.TempDir(), "config", "a = 3\nb = 2\n")79+func (p *badDefaults) SetDefaults() { p.A, p.B = 3, 2 }
73- if _, _, err := Load[strictPair](path, Options{}); err == nil || err.Error() != path+": a must not exceed b" {80+func (p *badDefaults) Validate() error { return p.strictPair.Validate() }
74- t.Fatalf("err = %v", err)81+
82+func 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"},
75 }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+
107+func loadErr[T any](path string) error {
108+ _, _, err := Load[T](path, Options{})
109+ return err
76 }110 }
77 111
78 // liveRecorder collects what a Live reports.112 // liveRecorder collects what a Live reports.
@@ -163,6 +197,30 @@ func TestLive(t *testing.T) {
163 r.quiet(t, "a save that changes no value")197 r.quiet(t, "a save that changes no value")
164 },198 },
165 },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+ },
166 {224 {
167 name: "broken file keeps the config",225 name: "broken file keeps the config",
168 initial: ptr("theme = light\n"),226 initial: ptr("theme = light\n"),
@@ -263,12 +321,13 @@ func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
263 }321 }
264 322
265 func TestLiveRunTwice(t *testing.T) {323 func TestLiveRunTwice(t *testing.T) {
266- l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{})324+ ready := make(chan struct{})
325+ l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{Watch: WatchOptions{Ready: ready}})
267 must(t, err)326 must(t, err)
268 ctx, cancel := context.WithCancel(context.Background())327 ctx, cancel := context.WithCancel(context.Background())
269 defer cancel()328 defer cancel()
270 go func() { _ = l.Run(ctx) }()329 go func() { _ = l.Run(ctx) }()
271- time.Sleep(50 * time.Millisecond)330+ <-ready
272 if err := l.Run(ctx); err == nil {331 if err := l.Run(ctx); err == nil {
273 t.Fatal("a second Run was accepted")332 t.Fatal("a second Run was accepted")
274 }333 }
M parse.go +2 -7
@@ -248,7 +248,8 @@ func stripComment(v string, style CommentStyle) string {
248248 quote = 0
249249 }
250250 case (c == '"' || c == '\'') && (i == 0 || v[i-1] == ' ' || v[i-1] == '\t'):
251- // Only a quote that starts a word opens a string: it's stays text.
251+ // Only a quote that starts a word opens a string: the quote in
252+ // "it's" stays text.
252253 quote = c
253254 case c == '#' && (style == CommentAny || i > 0 && (v[i-1] == ' ' || v[i-1] == '\t')):
254255 return strings.TrimSpace(v[:i])
@@ -307,9 +308,3 @@ func Changed(old, cur map[string]string) []string {
307308 slices.Sort(keys)
308309 return keys
309310 }
310-
311-// sameEntries reports whether two files assign the same values in the same
312-// order, whatever their comments and spacing.
313-func sameEntries(a, b []Entry) bool {
314- return slices.EqualFunc(a, b, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value })
315-}
@@ -248,7 +248,8 @@ func stripComment(v string, style CommentStyle) string {
248 quote = 0248 quote = 0
249 }249 }
250 case (c == '"' || c == '\'') && (i == 0 || v[i-1] == ' ' || v[i-1] == '\t'):250 case (c == '"' || c == '\'') && (i == 0 || v[i-1] == ' ' || v[i-1] == '\t'):
251- // Only a quote that starts a word opens a string: it's stays text.251+ // Only a quote that starts a word opens a string: the quote in
252+ // "it's" stays text.
252 quote = c253 quote = c
253 case c == '#' && (style == CommentAny || i > 0 && (v[i-1] == ' ' || v[i-1] == '\t')):254 case c == '#' && (style == CommentAny || i > 0 && (v[i-1] == ' ' || v[i-1] == '\t')):
254 return strings.TrimSpace(v[:i])255 return strings.TrimSpace(v[:i])
@@ -307,9 +308,3 @@ func Changed(old, cur map[string]string) []string {
307 slices.Sort(keys)308 slices.Sort(keys)
308 return keys309 return keys
309 }310 }
310-
311-// sameEntries reports whether two files assign the same values in the same
312-// order, whatever their comments and spacing.
313-func sameEntries(a, b []Entry) bool {
314- return slices.EqualFunc(a, b, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value })
315-}
M parse_test.go +2 -1
@@ -3,6 +3,7 @@ package kvconf
33 import (
44 "errors"
55 "reflect"
6+ "slices"
67 "strings"
78 "testing"
89 )
@@ -225,7 +226,7 @@ func FuzzParse(f *testing.F) {
225226 if err != nil {
226227 t.Fatalf("re-parse: %v\n%s", err, data)
227228 }
228- if !sameEntries(entries, again) {
229+ if !slices.EqualFunc(entries, again, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value }) {
229230 t.Fatalf("round trip:\n%#v\n%#v", entries, again)
230231 }
231232 })
@@ -3,6 +3,7 @@ package kvconf
3 import (3 import (
4 "errors"4 "errors"
5 "reflect"5 "reflect"
6+ "slices"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 )9 )
@@ -225,7 +226,7 @@ func FuzzParse(f *testing.F) {
225 if err != nil {226 if err != nil {
226 t.Fatalf("re-parse: %v\n%s", err, data)227 t.Fatalf("re-parse: %v\n%s", err, data)
227 }228 }
228- if !sameEntries(entries, again) {229+ if !slices.EqualFunc(entries, again, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value }) {
229 t.Fatalf("round trip:\n%#v\n%#v", entries, again)230 t.Fatalf("round trip:\n%#v\n%#v", entries, again)
230 }231 }
231 })232 })
M write_test.go +0 -93
@@ -259,99 +259,6 @@ func TestSetFile(t *testing.T) {
259259 eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
260260 }
261261
262-func TestReadFile(t *testing.T) {
263- dir := t.TempDir()
264- tests := []struct {
265- name string
266- setup func(t *testing.T) string
267- opts Options
268- want string
269- err string
270- }{
271- {name: "regular", setup: func(t *testing.T) string { return writeTemp(t, dir, "ok", "a = 1\n") }, want: "a = 1\n"},
272- {name: "missing", setup: func(t *testing.T) string { return filepath.Join(dir, "none") }, err: "none"},
273- {name: "directory", setup: func(t *testing.T) string { return dir }, err: "not a regular file"},
274- {
275- name: "too large",
276- setup: func(t *testing.T) string { return writeTemp(t, dir, "big", "0123456789") },
277- opts: Options{MaxBytes: 4},
278- err: "file too large",
279- },
280- {
281- name: "at the limit",
282- setup: func(t *testing.T) string { return writeTemp(t, dir, "limit", "0123") },
283- opts: Options{MaxBytes: 4},
284- want: "0123",
285- },
286- }
287- for _, tt := range tests {
288- t.Run(tt.name, func(t *testing.T) {
289- data, err := ReadFile(tt.setup(t), tt.opts)
290- if tt.err != "" {
291- if err == nil || !strings.Contains(err.Error(), tt.err) {
292- t.Fatalf("err = %v, want %q", err, tt.err)
293- }
294- return
295- }
296- must(t, err)
297- eq(t, string(data), tt.want)
298- })
299- }
300-}
301-
302-func TestExpandPath(t *testing.T) {
303- home, err := os.UserHomeDir()
304- if err != nil {
305- t.Skip(err)
306- }
307- abs := filepath.Join(home, "x")
308- tests := []struct {
309- in, want string
310- err bool
311- }{
312- {in: "~/x", want: abs},
313- {in: "~", want: home},
314- {in: abs + string(filepath.Separator) + ".", want: abs},
315- {in: "", err: true},
316- {in: "rel", err: true},
317- {in: "~user/x", err: true},
318- {in: "/a\x00b", err: true},
319- }
320- for _, tt := range tests {
321- t.Run(tt.in, func(t *testing.T) {
322- got, err := ExpandPath(tt.in)
323- if (err != nil) != tt.err || got != tt.want {
324- t.Fatalf("ExpandPath(%q) = %q, %v", tt.in, got, err)
325- }
326- })
327- }
328-}
329-
330-func TestDefaultPath(t *testing.T) {
331- tests := []struct{ name, xdg string }{
332- {name: "absolute XDG", xdg: filepath.Join(t.TempDir(), "cfg")},
333- {name: "relative XDG is ignored", xdg: "relative"},
334- {name: "no XDG"},
335- }
336- for _, tt := range tests {
337- t.Run(tt.name, func(t *testing.T) {
338- t.Setenv("XDG_CONFIG_HOME", tt.xdg)
339- got, err := DefaultPath("app", "config")
340- must(t, err)
341- var base string
342- if filepath.IsAbs(tt.xdg) {
343- base = tt.xdg
344- } else if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
345- home, _ := os.UserHomeDir()
346- base = filepath.Join(home, ".config")
347- } else {
348- base, _ = os.UserConfigDir()
349- }
350- eq(t, got, filepath.Join(base, "app", "config"))
351- })
352- }
353-}
354-
355262 func readString(t *testing.T, path string) string {
356263 t.Helper()
357264 data, err := os.ReadFile(path)
@@ -259,99 +259,6 @@ func TestSetFile(t *testing.T) {
259 eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")259 eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
260 }260 }
261 261
262-func TestReadFile(t *testing.T) {
263- dir := t.TempDir()
264- tests := []struct {
265- name string
266- setup func(t *testing.T) string
267- opts Options
268- want string
269- err string
270- }{
271- {name: "regular", setup: func(t *testing.T) string { return writeTemp(t, dir, "ok", "a = 1\n") }, want: "a = 1\n"},
272- {name: "missing", setup: func(t *testing.T) string { return filepath.Join(dir, "none") }, err: "none"},
273- {name: "directory", setup: func(t *testing.T) string { return dir }, err: "not a regular file"},
274- {
275- name: "too large",
276- setup: func(t *testing.T) string { return writeTemp(t, dir, "big", "0123456789") },
277- opts: Options{MaxBytes: 4},
278- err: "file too large",
279- },
280- {
281- name: "at the limit",
282- setup: func(t *testing.T) string { return writeTemp(t, dir, "limit", "0123") },
283- opts: Options{MaxBytes: 4},
284- want: "0123",
285- },
286- }
287- for _, tt := range tests {
288- t.Run(tt.name, func(t *testing.T) {
289- data, err := ReadFile(tt.setup(t), tt.opts)
290- if tt.err != "" {
291- if err == nil || !strings.Contains(err.Error(), tt.err) {
292- t.Fatalf("err = %v, want %q", err, tt.err)
293- }
294- return
295- }
296- must(t, err)
297- eq(t, string(data), tt.want)
298- })
299- }
300-}
301-
302-func TestExpandPath(t *testing.T) {
303- home, err := os.UserHomeDir()
304- if err != nil {
305- t.Skip(err)
306- }
307- abs := filepath.Join(home, "x")
308- tests := []struct {
309- in, want string
310- err bool
311- }{
312- {in: "~/x", want: abs},
313- {in: "~", want: home},
314- {in: abs + string(filepath.Separator) + ".", want: abs},
315- {in: "", err: true},
316- {in: "rel", err: true},
317- {in: "~user/x", err: true},
318- {in: "/a\x00b", err: true},
319- }
320- for _, tt := range tests {
321- t.Run(tt.in, func(t *testing.T) {
322- got, err := ExpandPath(tt.in)
323- if (err != nil) != tt.err || got != tt.want {
324- t.Fatalf("ExpandPath(%q) = %q, %v", tt.in, got, err)
325- }
326- })
327- }
328-}
329-
330-func TestDefaultPath(t *testing.T) {
331- tests := []struct{ name, xdg string }{
332- {name: "absolute XDG", xdg: filepath.Join(t.TempDir(), "cfg")},
333- {name: "relative XDG is ignored", xdg: "relative"},
334- {name: "no XDG"},
335- }
336- for _, tt := range tests {
337- t.Run(tt.name, func(t *testing.T) {
338- t.Setenv("XDG_CONFIG_HOME", tt.xdg)
339- got, err := DefaultPath("app", "config")
340- must(t, err)
341- var base string
342- if filepath.IsAbs(tt.xdg) {
343- base = tt.xdg
344- } else if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
345- home, _ := os.UserHomeDir()
346- base = filepath.Join(home, ".config")
347- } else {
348- base, _ = os.UserConfigDir()
349- }
350- eq(t, got, filepath.Join(base, "app", "config"))
351- })
352- }
353-}
354-
355 func readString(t *testing.T, path string) string {262 func readString(t *testing.T, path string) string {
356 t.Helper()263 t.Helper()
357 data, err := os.ReadFile(path)264 data, err := os.ReadFile(path)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close