detached ebc8127

Merge pull request 'fix(live): review cleanup of Live, decoding and watchers' (#2) from fix/review-cleanup into main

Reviewed-on: https://git.bnema.dev/bnema/kvconf/pulls/2
commit
ebc81278cf73a0d7299b033991dbca9c91a40d43
author
bnema · · 2026-10-10 06:21 UTC
merge of
fff2e74 + c3a07c7 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.1.1
16 files · +270 -199
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 open_other.go +0 -3
@@ -5,8 +5,5 @@ package kvconf
55 import "os"
66
77 func openNonblocking(path string) (*os.File, error) {
8- if info, err := os.Stat(path); err == nil && !info.Mode().IsRegular() {
9- return nil, &os.PathError{Op: "open", Path: path, Err: ErrNotRegular}
10- }
118 return os.Open(path)
129 }
@@ -5,8 +5,5 @@ package kvconf
5 import "os"5 import "os"
6 6
7 func openNonblocking(path string) (*os.File, error) {7 func openNonblocking(path string) (*os.File, error) {
8- if info, err := os.Stat(path); err == nil && !info.Mode().IsRegular() {
9- return nil, &os.PathError{Op: "open", Path: path, Err: ErrNotRegular}
10- }
11 return os.Open(path)8 return os.Open(path)
12 }9 }
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 watch.go +3 -11
@@ -68,15 +68,13 @@ func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struc
6868 b = newPoller(opts.Poll)
6969 } else if b, err = newNative(); err != nil {
7070 return err
71- } else if b == nil {
72- b = newPoller(time.Second)
7371 }
7472 defer b.close() //nolint:errcheck // nothing to report once watching ends
7573 stop := context.AfterFunc(ctx, b.wake)
7674 defer stop()
7775
7876 w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}}
79- missing, err := w.rewatch(b)
77+ missing, _, err := w.rewatchChanged(b)
8078 if err != nil {
8179 return err
8280 }
@@ -189,10 +187,9 @@ func (t *target) has(name string) bool {
189187
190188 // event is a change in a watched directory. An empty name means "something
191189 // changed in dir" (kqueue, polling, overflow); an empty dir means anything
192-// may have changed. lost means the watch itself vanished.
190+// may have changed, or a watch was lost.
193191 type event struct {
194192 dir, name string
195- lost bool
196193 // moved: the name was created, removed or renamed, so a link may now
197194 // point elsewhere. A plain write leaves links alone.
198195 moved bool
@@ -329,11 +326,6 @@ func (w *watcher) plan() (targets []*target, files []string) {
329326 return targets, files
330327 }
331328
332-func (w *watcher) rewatch(b backend) (missing bool, err error) {
333- missing, _, err = w.rewatchChanged(b)
334- return missing, err
335-}
336-
337329 // rewatchChanged installs the watches again and reports whether the watched
338330 // files changed meanwhile: a link was retargeted, a missing directory
339331 // appeared, or a file changed while it was not watched.
@@ -367,7 +359,7 @@ func sameTargets(a, b []*target) bool {
367359 func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) {
368360 relinked := false
369361 for _, ev := range evs {
370- if ev.lost || ev.dir == "" {
362+ if ev.dir == "" {
371363 relevant, rewatch, recheck = true, true, true
372364 continue
373365 }
@@ -68,15 +68,13 @@ func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struc
68 b = newPoller(opts.Poll)68 b = newPoller(opts.Poll)
69 } else if b, err = newNative(); err != nil {69 } else if b, err = newNative(); err != nil {
70 return err70 return err
71- } else if b == nil {
72- b = newPoller(time.Second)
73 }71 }
74 defer b.close() //nolint:errcheck // nothing to report once watching ends72 defer b.close() //nolint:errcheck // nothing to report once watching ends
75 stop := context.AfterFunc(ctx, b.wake)73 stop := context.AfterFunc(ctx, b.wake)
76 defer stop()74 defer stop()
77 75
78 w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}}76 w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}}
79- missing, err := w.rewatch(b)77+ missing, _, err := w.rewatchChanged(b)
80 if err != nil {78 if err != nil {
81 return err79 return err
82 }80 }
@@ -189,10 +187,9 @@ func (t *target) has(name string) bool {
189 187
190 // event is a change in a watched directory. An empty name means "something188 // event is a change in a watched directory. An empty name means "something
191 // changed in dir" (kqueue, polling, overflow); an empty dir means anything189 // changed in dir" (kqueue, polling, overflow); an empty dir means anything
192-// may have changed. lost means the watch itself vanished.190+// may have changed, or a watch was lost.
193 type event struct {191 type event struct {
194 dir, name string192 dir, name string
195- lost bool
196 // moved: the name was created, removed or renamed, so a link may now193 // moved: the name was created, removed or renamed, so a link may now
197 // point elsewhere. A plain write leaves links alone.194 // point elsewhere. A plain write leaves links alone.
198 moved bool195 moved bool
@@ -329,11 +326,6 @@ func (w *watcher) plan() (targets []*target, files []string) {
329 return targets, files326 return targets, files
330 }327 }
331 328
332-func (w *watcher) rewatch(b backend) (missing bool, err error) {
333- missing, _, err = w.rewatchChanged(b)
334- return missing, err
335-}
336-
337 // rewatchChanged installs the watches again and reports whether the watched329 // rewatchChanged installs the watches again and reports whether the watched
338 // files changed meanwhile: a link was retargeted, a missing directory330 // files changed meanwhile: a link was retargeted, a missing directory
339 // appeared, or a file changed while it was not watched.331 // appeared, or a file changed while it was not watched.
@@ -367,7 +359,7 @@ func sameTargets(a, b []*target) bool {
367 func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) {359 func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) {
368 relinked := false360 relinked := false
369 for _, ev := range evs {361 for _, ev := range evs {
370- if ev.lost || ev.dir == "" {362+ if ev.dir == "" {
371 relevant, rewatch, recheck = true, true, true363 relevant, rewatch, recheck = true, true, true
372 continue364 continue
373 }365 }
M watch_kqueue.go +6 -3
@@ -1,4 +1,4 @@
1-//go:build darwin || ios || freebsd || netbsd || openbsd || dragonfly
1+//go:build darwin || freebsd || netbsd || openbsd || dragonfly
22
33 package kvconf
44
@@ -124,8 +124,11 @@ func (k *kqueue) wait(timeout time.Duration, evs []event) ([]event, error) {
124124 if !ok {
125125 continue
126126 }
127- lost := ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0
128- evs = append(evs, event{dir: dir, lost: lost})
127+ if ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0 {
128+ evs = append(evs, event{}) // a watch was lost
129+ continue
130+ }
131+ evs = append(evs, event{dir: dir})
129132 }
130133 return evs, nil
131134 }
@@ -1,4 +1,4 @@
1-//go:build darwin || ios || freebsd || netbsd || openbsd || dragonfly1+//go:build darwin || freebsd || netbsd || openbsd || dragonfly
2 2
3 package kvconf3 package kvconf
4 4
@@ -124,8 +124,11 @@ func (k *kqueue) wait(timeout time.Duration, evs []event) ([]event, error) {
124 if !ok {124 if !ok {
125 continue125 continue
126 }126 }
127- lost := ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0127+ if ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0 {
128- evs = append(evs, event{dir: dir, lost: lost})128+ evs = append(evs, event{}) // a watch was lost
129+ continue
130+ }
131+ evs = append(evs, event{dir: dir})
129 }132 }
130 return evs, nil133 return evs, nil
131 }134 }
M watch_kqueue_darwin.go +1 -1
@@ -1,4 +1,4 @@
1-//go:build darwin || ios
1+//go:build darwin
22
33 package kvconf
44
@@ -1,4 +1,4 @@
1-//go:build darwin || ios1+//go:build darwin
2 2
3 package kvconf3 package kvconf
4 4
M watch_linux.go +1 -1
@@ -123,7 +123,7 @@ func (in *inotify) wait(timeout time.Duration, evs []event) ([]event, error) {
123123 if in.wds[dir] == ev.Wd {
124124 delete(in.wds, dir)
125125 }
126- evs = append(evs, event{dir: dir, lost: true})
126+ evs = append(evs, event{})
127127 continue
128128 }
129129 for len(name) > 0 && name[len(name)-1] == 0 {
@@ -123,7 +123,7 @@ func (in *inotify) wait(timeout time.Duration, evs []event) ([]event, error) {
123 if in.wds[dir] == ev.Wd {123 if in.wds[dir] == ev.Wd {
124 delete(in.wds, dir)124 delete(in.wds, dir)
125 }125 }
126- evs = append(evs, event{dir: dir, lost: true})126+ evs = append(evs, event{})
127 continue127 continue
128 }128 }
129 for len(name) > 0 && name[len(name)-1] == 0 {129 for len(name) > 0 && name[len(name)-1] == 0 {
M watch_other.go +5 -3
@@ -1,6 +1,8 @@
1-//go:build !linux && !windows && !darwin && !ios && !freebsd && !netbsd && !openbsd && !dragonfly
1+//go:build !linux && !windows && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
22
33 package kvconf
44
5-// newNative returns nil: this OS has no native backend, Watch polls.
6-func newNative() (backend, error) { return nil, nil }
5+import "time"
6+
7+// newNative polls every second: this OS has no native backend.
8+func newNative() (backend, error) { return newPoller(time.Second), nil }
@@ -1,6 +1,8 @@
1-//go:build !linux && !windows && !darwin && !ios && !freebsd && !netbsd && !openbsd && !dragonfly1+//go:build !linux && !windows && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
2 2
3 package kvconf3 package kvconf
4 4
5-// newNative returns nil: this OS has no native backend, Watch polls.5+import "time"
6-func newNative() (backend, error) { return nil, nil }6+
7+// newNative polls every second: this OS has no native backend.
8+func newNative() (backend, error) { return newPoller(time.Second), nil }
M watch_test.go +47 -29
@@ -44,12 +44,23 @@ func (e *watchEnv) symlink(target, rel string) {
4444 }
4545 }
4646
47+// fastWait is how long a change that needs no retry may take. It stays below
48+// retryMissing, so a broken fast path is not hidden by the periodic look.
49+const fastWait = retryMissing - 500*time.Millisecond
50+
4751 func (e *watchEnv) expectChange(why string) {
52+ e.t.Helper()
53+ e.expectChangeWithin(why, fastWait)
54+}
55+
56+// expectChangeWithin is expectChange for a step that legitimately relies on
57+// the retry or recheck, which fires after retryMissing.
58+func (e *watchEnv) expectChangeWithin(why string, d time.Duration) {
4859 e.t.Helper()
4960 select {
5061 case <-e.out:
51- case <-time.After(5 * time.Second):
52- e.t.Fatalf("no change reported after %s", why)
62+ case <-time.After(d):
63+ e.t.Fatalf("no change reported within %v after %s", d, why)
5364 }
5465 }
5566
@@ -76,6 +87,14 @@ type backendCase struct {
7687 poll time.Duration
7788 }
7889
90+// slowWait is the deadline for a step that relies on the retry on this OS.
91+func slowWait() time.Duration {
92+ if runtime.GOOS == "windows" {
93+ return retryMissing + 3*time.Second
94+ }
95+ return fastWait
96+}
97+
7998 func backends() []backendCase {
8099 return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}}
81100 }
@@ -135,7 +154,8 @@ func TestWatch(t *testing.T) {
135154 rel: "a/b/config",
136155 steps: func(e *watchEnv) {
137156 e.write("a/b/config", "a = 1\n")
138- e.expectChange("the directories and file appeared")
157+ // Windows may only find the new directories on the retry.
158+ e.expectChangeWithin("the directories and file appeared", slowWait())
139159 e.settle()
140160 e.write("a/b/config", "a = 2\n")
141161 e.expectChange("a write once the directory exists")
@@ -147,10 +167,12 @@ func TestWatch(t *testing.T) {
147167 setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") },
148168 steps: func(e *watchEnv) {
149169 must(e.t, os.RemoveAll(filepath.Join(e.dir, "d")))
150- e.expectChange("the directory removal")
170+ // Windows reports a removed directory only after the watch on
171+ // it is closed: the recheck finds it.
172+ e.expectChangeWithin("the directory removal", slowWait())
151173 e.settle()
152174 e.write("d/config", "a = 2\n")
153- e.expectChange("the recreated file")
175+ e.expectChangeWithin("the recreated file", slowWait())
154176 },
155177 },
156178 {
@@ -286,9 +308,17 @@ func TestWatch(t *testing.T) {
286308 }
287309 }
288310
311+// Every write restarts the debounce: a burst longer than the debounce is
312+// reported once, after its last write.
289313 func TestWatchDebounce(t *testing.T) {
314+ const (
315+ debounce = 200 * time.Millisecond
316+ writes = 10
317+ gap = 50 * time.Millisecond
318+ )
290319 for _, bc := range backends() {
291320 t.Run(bc.name, func(t *testing.T) {
321+ t.Parallel()
292322 dir := t.TempDir()
293323 path := filepath.Join(dir, "config")
294324 must(t, os.WriteFile(path, nil, 0o600))
@@ -297,15 +327,20 @@ func TestWatchDebounce(t *testing.T) {
297327 defer cancel()
298328 ready := make(chan struct{})
299329 go func() {
300- _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 300 * time.Millisecond, Ready: ready}, out)
330+ _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: debounce, Ready: ready}, out)
301331 }()
302332 <-ready
303- for i := range 5 {
333+ var lastWrite time.Time
334+ for i := range writes {
335+ lastWrite = time.Now()
304336 must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600))
305- time.Sleep(50 * time.Millisecond)
337+ time.Sleep(gap)
306338 }
307339 select {
308340 case <-out:
341+ if d := time.Since(lastWrite); d < debounce {
342+ t.Fatalf("reported %v after the last write, before the %v debounce", d, debounce)
343+ }
309344 case <-time.After(5 * time.Second):
310345 t.Fatal("no change reported")
311346 }
@@ -319,10 +354,12 @@ func TestWatchDebounce(t *testing.T) {
319354 }
320355
321356 // An atomic save is one change: nothing is reported again when the watcher
322-// looks at the files later.
357+// looks at the files later. This guards Windows, where the recheck after a
358+// rename would otherwise report the same save a second time.
323359 func TestWatchReportsASaveOnce(t *testing.T) {
324360 for _, bc := range backends() {
325361 t.Run(bc.name, func(t *testing.T) {
362+ t.Parallel()
326363 dir := t.TempDir()
327364 path := filepath.Join(dir, "config")
328365 must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600))
@@ -337,7 +374,7 @@ func TestWatchReportsASaveOnce(t *testing.T) {
337374 must(t, WriteFile(path, []byte("a = 2\n"), 0o600))
338375 select {
339376 case <-out:
340- case <-time.After(5 * time.Second):
377+ case <-time.After(slowWait()):
341378 t.Fatal("no change reported")
342379 }
343380 select {
@@ -348,22 +385,3 @@ func TestWatchReportsASaveOnce(t *testing.T) {
348385 })
349386 }
350387 }
351-
352-func TestWatchCancelBeforeEvents(t *testing.T) {
353- for _, bc := range backends() {
354- t.Run(bc.name, func(t *testing.T) {
355- ctx, cancel := context.WithCancel(context.Background())
356- cancel()
357- done := make(chan error, 1)
358- go func() {
359- done <- Watch(ctx, filepath.Join(t.TempDir(), "config"), WatchOptions{Poll: bc.poll}, make(chan struct{}, 1))
360- }()
361- select {
362- case err := <-done:
363- must(t, err)
364- case <-time.After(5 * time.Second):
365- t.Fatal("Watch ignored a cancelled context")
366- }
367- })
368- }
369-}
@@ -44,12 +44,23 @@ func (e *watchEnv) symlink(target, rel string) {
44 }44 }
45 }45 }
46 46
47+// fastWait is how long a change that needs no retry may take. It stays below
48+// retryMissing, so a broken fast path is not hidden by the periodic look.
49+const fastWait = retryMissing - 500*time.Millisecond
50+
47 func (e *watchEnv) expectChange(why string) {51 func (e *watchEnv) expectChange(why string) {
52+ e.t.Helper()
53+ e.expectChangeWithin(why, fastWait)
54+}
55+
56+// expectChangeWithin is expectChange for a step that legitimately relies on
57+// the retry or recheck, which fires after retryMissing.
58+func (e *watchEnv) expectChangeWithin(why string, d time.Duration) {
48 e.t.Helper()59 e.t.Helper()
49 select {60 select {
50 case <-e.out:61 case <-e.out:
51- case <-time.After(5 * time.Second):62+ case <-time.After(d):
52- e.t.Fatalf("no change reported after %s", why)63+ e.t.Fatalf("no change reported within %v after %s", d, why)
53 }64 }
54 }65 }
55 66
@@ -76,6 +87,14 @@ type backendCase struct {
76 poll time.Duration87 poll time.Duration
77 }88 }
78 89
90+// slowWait is the deadline for a step that relies on the retry on this OS.
91+func slowWait() time.Duration {
92+ if runtime.GOOS == "windows" {
93+ return retryMissing + 3*time.Second
94+ }
95+ return fastWait
96+}
97+
79 func backends() []backendCase {98 func backends() []backendCase {
80 return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}}99 return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}}
81 }100 }
@@ -135,7 +154,8 @@ func TestWatch(t *testing.T) {
135 rel: "a/b/config",154 rel: "a/b/config",
136 steps: func(e *watchEnv) {155 steps: func(e *watchEnv) {
137 e.write("a/b/config", "a = 1\n")156 e.write("a/b/config", "a = 1\n")
138- e.expectChange("the directories and file appeared")157+ // Windows may only find the new directories on the retry.
158+ e.expectChangeWithin("the directories and file appeared", slowWait())
139 e.settle()159 e.settle()
140 e.write("a/b/config", "a = 2\n")160 e.write("a/b/config", "a = 2\n")
141 e.expectChange("a write once the directory exists")161 e.expectChange("a write once the directory exists")
@@ -147,10 +167,12 @@ func TestWatch(t *testing.T) {
147 setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") },167 setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") },
148 steps: func(e *watchEnv) {168 steps: func(e *watchEnv) {
149 must(e.t, os.RemoveAll(filepath.Join(e.dir, "d")))169 must(e.t, os.RemoveAll(filepath.Join(e.dir, "d")))
150- e.expectChange("the directory removal")170+ // Windows reports a removed directory only after the watch on
171+ // it is closed: the recheck finds it.
172+ e.expectChangeWithin("the directory removal", slowWait())
151 e.settle()173 e.settle()
152 e.write("d/config", "a = 2\n")174 e.write("d/config", "a = 2\n")
153- e.expectChange("the recreated file")175+ e.expectChangeWithin("the recreated file", slowWait())
154 },176 },
155 },177 },
156 {178 {
@@ -286,9 +308,17 @@ func TestWatch(t *testing.T) {
286 }308 }
287 }309 }
288 310
311+// Every write restarts the debounce: a burst longer than the debounce is
312+// reported once, after its last write.
289 func TestWatchDebounce(t *testing.T) {313 func TestWatchDebounce(t *testing.T) {
314+ const (
315+ debounce = 200 * time.Millisecond
316+ writes = 10
317+ gap = 50 * time.Millisecond
318+ )
290 for _, bc := range backends() {319 for _, bc := range backends() {
291 t.Run(bc.name, func(t *testing.T) {320 t.Run(bc.name, func(t *testing.T) {
321+ t.Parallel()
292 dir := t.TempDir()322 dir := t.TempDir()
293 path := filepath.Join(dir, "config")323 path := filepath.Join(dir, "config")
294 must(t, os.WriteFile(path, nil, 0o600))324 must(t, os.WriteFile(path, nil, 0o600))
@@ -297,15 +327,20 @@ func TestWatchDebounce(t *testing.T) {
297 defer cancel()327 defer cancel()
298 ready := make(chan struct{})328 ready := make(chan struct{})
299 go func() {329 go func() {
300- _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 300 * time.Millisecond, Ready: ready}, out)330+ _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: debounce, Ready: ready}, out)
301 }()331 }()
302 <-ready332 <-ready
303- for i := range 5 {333+ var lastWrite time.Time
334+ for i := range writes {
335+ lastWrite = time.Now()
304 must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600))336 must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600))
305- time.Sleep(50 * time.Millisecond)337+ time.Sleep(gap)
306 }338 }
307 select {339 select {
308 case <-out:340 case <-out:
341+ if d := time.Since(lastWrite); d < debounce {
342+ t.Fatalf("reported %v after the last write, before the %v debounce", d, debounce)
343+ }
309 case <-time.After(5 * time.Second):344 case <-time.After(5 * time.Second):
310 t.Fatal("no change reported")345 t.Fatal("no change reported")
311 }346 }
@@ -319,10 +354,12 @@ func TestWatchDebounce(t *testing.T) {
319 }354 }
320 355
321 // An atomic save is one change: nothing is reported again when the watcher356 // An atomic save is one change: nothing is reported again when the watcher
322-// looks at the files later.357+// looks at the files later. This guards Windows, where the recheck after a
358+// rename would otherwise report the same save a second time.
323 func TestWatchReportsASaveOnce(t *testing.T) {359 func TestWatchReportsASaveOnce(t *testing.T) {
324 for _, bc := range backends() {360 for _, bc := range backends() {
325 t.Run(bc.name, func(t *testing.T) {361 t.Run(bc.name, func(t *testing.T) {
362+ t.Parallel()
326 dir := t.TempDir()363 dir := t.TempDir()
327 path := filepath.Join(dir, "config")364 path := filepath.Join(dir, "config")
328 must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600))365 must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600))
@@ -337,7 +374,7 @@ func TestWatchReportsASaveOnce(t *testing.T) {
337 must(t, WriteFile(path, []byte("a = 2\n"), 0o600))374 must(t, WriteFile(path, []byte("a = 2\n"), 0o600))
338 select {375 select {
339 case <-out:376 case <-out:
340- case <-time.After(5 * time.Second):377+ case <-time.After(slowWait()):
341 t.Fatal("no change reported")378 t.Fatal("no change reported")
342 }379 }
343 select {380 select {
@@ -348,22 +385,3 @@ func TestWatchReportsASaveOnce(t *testing.T) {
348 })385 })
349 }386 }
350 }387 }
351-
352-func TestWatchCancelBeforeEvents(t *testing.T) {
353- for _, bc := range backends() {
354- t.Run(bc.name, func(t *testing.T) {
355- ctx, cancel := context.WithCancel(context.Background())
356- cancel()
357- done := make(chan error, 1)
358- go func() {
359- done <- Watch(ctx, filepath.Join(t.TempDir(), "config"), WatchOptions{Poll: bc.poll}, make(chan struct{}, 1))
360- }()
361- select {
362- case err := <-done:
363- must(t, err)
364- case <-time.After(5 * time.Second):
365- t.Fatal("Watch ignored a cancelled context")
366- }
367- })
368- }
369-}
M watch_windows.go +4 -7
@@ -8,8 +8,8 @@ import (
88 "unsafe"
99 )
1010
11-// The standard syscall package lacks these, or declares them with a wrong
12-// signature on 64-bit Windows; they are loaded from kernel32 directly.
11+// These functions are missing from package syscall: they are loaded from
12+// kernel32 directly.
1313 var (
1414 kernel32 = syscall.NewLazyDLL("kernel32.dll")
1515 procCreateEventW = kernel32.NewProc("CreateEventW")
@@ -58,9 +58,6 @@ func createEvent() (syscall.Handle, error) {
5858 }
5959
6060 func newNative() (backend, error) {
61- if err := procWaitForMultipleObjects.Find(); err != nil {
62- return nil, nil // poll instead
63- }
6461 ev, err := createEvent()
6562 if err != nil {
6663 return nil, err
@@ -186,7 +183,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) {
186183 return evs, nil
187184 }
188185 // The directory was removed or became unreadable.
189- return append(evs, event{dir: d.dir, lost: true}), nil
186+ return append(evs, event{}), nil
190187 }
191188 if n == 0 {
192189 evs = append(evs, event{dir: d.dir}) // the buffer overflowed
@@ -203,7 +200,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) {
203200 off += info.NextEntryOffset
204201 }
205202 if err := d.read(); err != nil {
206- evs = append(evs, event{dir: d.dir, lost: true})
203+ evs = append(evs, event{})
207204 }
208205 return evs, nil
209206 }
@@ -8,8 +8,8 @@ import (
8 "unsafe"8 "unsafe"
9 )9 )
10 10
11-// The standard syscall package lacks these, or declares them with a wrong11+// These functions are missing from package syscall: they are loaded from
12-// signature on 64-bit Windows; they are loaded from kernel32 directly.12+// kernel32 directly.
13 var (13 var (
14 kernel32 = syscall.NewLazyDLL("kernel32.dll")14 kernel32 = syscall.NewLazyDLL("kernel32.dll")
15 procCreateEventW = kernel32.NewProc("CreateEventW")15 procCreateEventW = kernel32.NewProc("CreateEventW")
@@ -58,9 +58,6 @@ func createEvent() (syscall.Handle, error) {
58 }58 }
59 59
60 func newNative() (backend, error) {60 func newNative() (backend, error) {
61- if err := procWaitForMultipleObjects.Find(); err != nil {
62- return nil, nil // poll instead
63- }
64 ev, err := createEvent()61 ev, err := createEvent()
65 if err != nil {62 if err != nil {
66 return nil, err63 return nil, err
@@ -186,7 +183,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) {
186 return evs, nil183 return evs, nil
187 }184 }
188 // The directory was removed or became unreadable.185 // The directory was removed or became unreadable.
189- return append(evs, event{dir: d.dir, lost: true}), nil186+ return append(evs, event{}), nil
190 }187 }
191 if n == 0 {188 if n == 0 {
192 evs = append(evs, event{dir: d.dir}) // the buffer overflowed189 evs = append(evs, event{dir: d.dir}) // the buffer overflowed
@@ -203,7 +200,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) {
203 off += info.NextEntryOffset200 off += info.NextEntryOffset
204 }201 }
205 if err := d.read(); err != nil {202 if err := d.read(); err != nil {
206- evs = append(evs, event{dir: d.dir, lost: true})203+ evs = append(evs, event{})
207 }204 }
208 return evs, nil205 return evs, nil
209 }206 }
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