detached cce3930

refactor(config): parse and watch the config with kvconf

commit
cce3930ff6aa04ed7ca97223b712a35be0fd0b68
author
brice · · 2026-10-10 03:00 UTC
parent
cad1d13
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +228 -295
M go.mod +1 -0
@@ -3,6 +3,7 @@ module github.com/bnema/vev
33 go 1.27
44
55 require (
6+ github.com/bnema/kvconf v0.1.0
67 github.com/bnema/vev-vt v0.7.0
78 github.com/coder/websocket v1.8.15
89 github.com/quic-go/quic-go v0.62.0
@@ -3,6 +3,7 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6+ github.com/bnema/kvconf v0.1.0
6 github.com/bnema/vev-vt v0.7.07 github.com/bnema/vev-vt v0.7.0
7 github.com/coder/websocket v1.8.158 github.com/coder/websocket v1.8.15
8 github.com/quic-go/quic-go v0.62.09 github.com/quic-go/quic-go v0.62.0
M internal/adapters/config/config.go +74 -106
@@ -1,7 +1,7 @@
11 package config
22
33 import (
4- "bufio"
4+ "bytes"
55 "context"
66 "errors"
77 "fmt"
@@ -12,13 +12,11 @@ import (
1212 "strings"
1313 "time"
1414
15+ "github.com/bnema/kvconf"
1516 "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/domain/terminalcap"
17- "github.com/bnema/vev/internal/ports"
1818 )
1919
20-const pollInterval = 2 * time.Second
21-
2220 var (
2321 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
2422 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
@@ -43,30 +41,19 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
4341 seenScrollbackKeys := make(map[string]bool)
4442 seenWebKeys := make(map[string]bool)
4543
46- scanner := bufio.NewScanner(r)
47- lineNo := 0
48- for scanner.Scan() {
49- lineNo++
50- line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
51- if line == "" || strings.HasPrefix(line, "#") {
52- continue
53- }
54- line = stripInlineComment(line)
55- if line == "" {
56- continue
57- }
44+ entries, syntax, err := kvconf.Parse(r, parseOptions)
45+ if err != nil {
46+ return domain.Defaults(), nil, err
47+ }
48+ pending := syntaxWarnings(syntax)
5849
59- key, value, ok := strings.Cut(line, "=")
60- if !ok {
61- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
62- continue
63- }
64- key = strings.TrimSpace(key)
65- value = strings.TrimSpace(value)
66- if key == "" {
67- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
68- continue
50+ for _, entry := range entries {
51+ // Interleave line-syntax warnings with domain warnings in file order.
52+ for len(pending) > 0 && pending[0].Line < entry.Line {
53+ warnings = append(warnings, pending[0])
54+ pending = pending[1:]
6955 }
56+ lineNo, key, value := entry.Line, entry.Key, entry.Value
7057
7158 switch {
7259 case key == "web.listen" || key == "web.origin":
@@ -278,84 +265,92 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
278265 }
279266 }
280267 }
281- if err := scanner.Err(); err != nil {
282- return cfg, warnings, err
283- }
268+ warnings = append(warnings, pending...)
284269 return cfg, warnings, nil
285270 }
286271
272+// parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes
273+// per key and reports its own duplicate warnings.
274+var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true}
275+
276+// syntaxWarnings maps kvconf line-syntax warnings to domain warnings.
277+func syntaxWarnings(in []kvconf.Warning) []domain.Warning {
278+ out := make([]domain.Warning, 0, len(in))
279+ for _, w := range in {
280+ msg := w.Msg
281+ if msg == "expected key = value" {
282+ msg = "missing '='"
283+ }
284+ out = append(out, domain.Warning{Line: w.Line, Msg: msg})
285+ }
286+ return out
287+}
288+
287289 // Load reads path and parses it. Missing config files are treated as defaults.
288290 func Load(path string) (domain.Config, []domain.Warning, error) {
289- f, err := os.Open(path)
291+ data, err := kvconf.ReadFile(path, parseOptions)
290292 if errors.Is(err, os.ErrNotExist) {
291293 return domain.Defaults(), nil, nil
292294 }
293295 if err != nil {
294296 return domain.Defaults(), nil, err
295297 }
296- cfg, warnings, parseErr := Parse(f)
297- if closeErr := f.Close(); parseErr == nil && closeErr != nil {
298- return cfg, warnings, closeErr
299- }
300- return cfg, warnings, parseErr
298+ return Parse(bytes.NewReader(data))
301299 }
302300
303-// Watch polls path for mtime/size changes every two seconds and calls onChange
304-// with each successfully loaded config. Missing files are allowed and reload to
305-// defaults when the watched state changes.
306-func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
307- last, err := fileStamp(path)
301+// Watch follows path with native file notifications (kvconf.Watch) and calls
302+// onChange with the config reloaded after each change. Missing files are
303+// allowed and reload to defaults. A load failure reports defaults plus a
304+// warning; the same failure is not reported twice in a row. Watch returns
305+// ctx.Err() when ctx ends.
306+func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error {
307+ return watch(ctx, path, kvconf.WatchOptions{}, onChange)
308+}
309+
310+func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error {
308311 lastErrMsg := ""
309- if err != nil {
310- lastErrMsg = err.Error()
311- onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
312+ reportErr := func(err error) {
313+ if msg := err.Error(); msg != lastErrMsg {
314+ lastErrMsg = msg
315+ onChange(domain.Defaults(), []domain.Warning{{Msg: msg}})
316+ }
317+ }
318+ if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) {
319+ reportErr(err)
312320 }
313- timer := clock.NewTimer(pollInterval)
314- defer timer.Stop()
321+
322+ watchCtx, cancel := context.WithCancel(ctx)
323+ defer cancel()
324+ changed := make(chan struct{}, 1)
325+ watchErr := make(chan error, 1)
326+ go func() { watchErr <- kvconf.Watch(watchCtx, path, opts, changed) }()
315327
316328 for {
317329 select {
318330 case <-ctx.Done():
331+ cancel()
332+ <-watchErr
319333 return ctx.Err()
320- case <-timer.C():
321- current, statErr := fileStamp(path)
322- if statErr != nil {
323- if statErr.Error() != lastErrMsg {
324- lastErrMsg = statErr.Error()
325- onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
326- }
327- } else if current != last || lastErrMsg != "" {
328- lastErrMsg = ""
329- last = current
330- cfg, warnings, err := Load(path)
331- if err != nil {
332- cfg = domain.Defaults()
333- warnings = append(warnings, domain.Warning{Msg: err.Error()})
334- }
335- onChange(cfg, warnings)
334+ case err := <-watchErr:
335+ if ctx.Err() != nil {
336+ return ctx.Err()
336337 }
337- timer.Reset(pollInterval)
338+ if err == nil {
339+ err = errors.New("config watcher stopped")
340+ }
341+ return err
342+ case <-changed:
343+ cfg, warnings, err := Load(path)
344+ if err != nil {
345+ reportErr(err)
346+ continue
347+ }
348+ lastErrMsg = ""
349+ onChange(cfg, warnings)
338350 }
339351 }
340352 }
341353
342-type stamp struct {
343- modTime time.Time
344- size int64
345- exists bool
346-}
347-
348-func fileStamp(path string) (stamp, error) {
349- st, err := os.Stat(path)
350- if errors.Is(err, os.ErrNotExist) {
351- return stamp{}, nil
352- }
353- if err != nil {
354- return stamp{}, err
355- }
356- return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
357-}
358-
359354 func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {
360355 switch strings.ToLower(value) {
361356 case "adaptive":
@@ -474,30 +469,3 @@ func parseTheme(value string) (domain.ThemeMode, bool) {
474469 return domain.ThemeAuto, false
475470 }
476471 }
477-
478-func stripInlineComment(line string) string {
479- inQuote := false
480- escaped := false
481- for i, r := range line {
482- if escaped {
483- escaped = false
484- continue
485- }
486- if inQuote && r == '\\' {
487- escaped = true
488- continue
489- }
490- if r == '"' {
491- inQuote = !inQuote
492- continue
493- }
494- if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
495- return strings.TrimSpace(line[:i])
496- }
497- }
498- return line
499-}
500-
501-func isSpace(r rune) bool {
502- return r == ' ' || r == '\t' || r == '\r' || r == '\n'
503-}
@@ -1,7 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4- "bufio"4+ "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
@@ -12,13 +12,11 @@ import (
12 "strings"12 "strings"
13 "time"13 "time"
14 14
15+ "github.com/bnema/kvconf"
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/domain/terminalcap"17 "github.com/bnema/vev/internal/domain/terminalcap"
17- "github.com/bnema/vev/internal/ports"
18 )18 )
19 19
20-const pollInterval = 2 * time.Second
21-
22 var (20 var (
23 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)21 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
24 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)22 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
@@ -43,30 +41,19 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
43 seenScrollbackKeys := make(map[string]bool)41 seenScrollbackKeys := make(map[string]bool)
44 seenWebKeys := make(map[string]bool)42 seenWebKeys := make(map[string]bool)
45 43
46- scanner := bufio.NewScanner(r)44+ entries, syntax, err := kvconf.Parse(r, parseOptions)
47- lineNo := 045+ if err != nil {
48- for scanner.Scan() {46+ return domain.Defaults(), nil, err
49- lineNo++47+ }
50- line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))48+ pending := syntaxWarnings(syntax)
51- if line == "" || strings.HasPrefix(line, "#") {
52- continue
53- }
54- line = stripInlineComment(line)
55- if line == "" {
56- continue
57- }
58 49
59- key, value, ok := strings.Cut(line, "=")50+ for _, entry := range entries {
60- if !ok {51+ // Interleave line-syntax warnings with domain warnings in file order.
61- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})52+ for len(pending) > 0 && pending[0].Line < entry.Line {
62- continue53+ warnings = append(warnings, pending[0])
63- }54+ pending = pending[1:]
64- key = strings.TrimSpace(key)
65- value = strings.TrimSpace(value)
66- if key == "" {
67- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
68- continue
69 }55 }
56+ lineNo, key, value := entry.Line, entry.Key, entry.Value
70 57
71 switch {58 switch {
72 case key == "web.listen" || key == "web.origin":59 case key == "web.listen" || key == "web.origin":
@@ -278,84 +265,92 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
278 }265 }
279 }266 }
280 }267 }
281- if err := scanner.Err(); err != nil {268+ warnings = append(warnings, pending...)
282- return cfg, warnings, err
283- }
284 return cfg, warnings, nil269 return cfg, warnings, nil
285 }270 }
286 271
272+// parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes
273+// per key and reports its own duplicate warnings.
274+var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true}
275+
276+// syntaxWarnings maps kvconf line-syntax warnings to domain warnings.
277+func syntaxWarnings(in []kvconf.Warning) []domain.Warning {
278+ out := make([]domain.Warning, 0, len(in))
279+ for _, w := range in {
280+ msg := w.Msg
281+ if msg == "expected key = value" {
282+ msg = "missing '='"
283+ }
284+ out = append(out, domain.Warning{Line: w.Line, Msg: msg})
285+ }
286+ return out
287+}
288+
287 // Load reads path and parses it. Missing config files are treated as defaults.289 // Load reads path and parses it. Missing config files are treated as defaults.
288 func Load(path string) (domain.Config, []domain.Warning, error) {290 func Load(path string) (domain.Config, []domain.Warning, error) {
289- f, err := os.Open(path)291+ data, err := kvconf.ReadFile(path, parseOptions)
290 if errors.Is(err, os.ErrNotExist) {292 if errors.Is(err, os.ErrNotExist) {
291 return domain.Defaults(), nil, nil293 return domain.Defaults(), nil, nil
292 }294 }
293 if err != nil {295 if err != nil {
294 return domain.Defaults(), nil, err296 return domain.Defaults(), nil, err
295 }297 }
296- cfg, warnings, parseErr := Parse(f)298+ return Parse(bytes.NewReader(data))
297- if closeErr := f.Close(); parseErr == nil && closeErr != nil {
298- return cfg, warnings, closeErr
299- }
300- return cfg, warnings, parseErr
301 }299 }
302 300
303-// Watch polls path for mtime/size changes every two seconds and calls onChange301+// Watch follows path with native file notifications (kvconf.Watch) and calls
304-// with each successfully loaded config. Missing files are allowed and reload to302+// onChange with the config reloaded after each change. Missing files are
305-// defaults when the watched state changes.303+// allowed and reload to defaults. A load failure reports defaults plus a
306-func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {304+// warning; the same failure is not reported twice in a row. Watch returns
307- last, err := fileStamp(path)305+// ctx.Err() when ctx ends.
306+func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error {
307+ return watch(ctx, path, kvconf.WatchOptions{}, onChange)
308+}
309+
310+func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error {
308 lastErrMsg := ""311 lastErrMsg := ""
309- if err != nil {312+ reportErr := func(err error) {
310- lastErrMsg = err.Error()313+ if msg := err.Error(); msg != lastErrMsg {
311- onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})314+ lastErrMsg = msg
315+ onChange(domain.Defaults(), []domain.Warning{{Msg: msg}})
316+ }
317+ }
318+ if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) {
319+ reportErr(err)
312 }320 }
313- timer := clock.NewTimer(pollInterval)321+
314- defer timer.Stop()322+ watchCtx, cancel := context.WithCancel(ctx)
323+ defer cancel()
324+ changed := make(chan struct{}, 1)
325+ watchErr := make(chan error, 1)
326+ go func() { watchErr <- kvconf.Watch(watchCtx, path, opts, changed) }()
315 327
316 for {328 for {
317 select {329 select {
318 case <-ctx.Done():330 case <-ctx.Done():
331+ cancel()
332+ <-watchErr
319 return ctx.Err()333 return ctx.Err()
320- case <-timer.C():334+ case err := <-watchErr:
321- current, statErr := fileStamp(path)335+ if ctx.Err() != nil {
322- if statErr != nil {336+ return ctx.Err()
323- if statErr.Error() != lastErrMsg {
324- lastErrMsg = statErr.Error()
325- onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
326- }
327- } else if current != last || lastErrMsg != "" {
328- lastErrMsg = ""
329- last = current
330- cfg, warnings, err := Load(path)
331- if err != nil {
332- cfg = domain.Defaults()
333- warnings = append(warnings, domain.Warning{Msg: err.Error()})
334- }
335- onChange(cfg, warnings)
336 }337 }
337- timer.Reset(pollInterval)338+ if err == nil {
339+ err = errors.New("config watcher stopped")
340+ }
341+ return err
342+ case <-changed:
343+ cfg, warnings, err := Load(path)
344+ if err != nil {
345+ reportErr(err)
346+ continue
347+ }
348+ lastErrMsg = ""
349+ onChange(cfg, warnings)
338 }350 }
339 }351 }
340 }352 }
341 353
342-type stamp struct {
343- modTime time.Time
344- size int64
345- exists bool
346-}
347-
348-func fileStamp(path string) (stamp, error) {
349- st, err := os.Stat(path)
350- if errors.Is(err, os.ErrNotExist) {
351- return stamp{}, nil
352- }
353- if err != nil {
354- return stamp{}, err
355- }
356- return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
357-}
358-
359 func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {354 func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {
360 switch strings.ToLower(value) {355 switch strings.ToLower(value) {
361 case "adaptive":356 case "adaptive":
@@ -474,30 +469,3 @@ func parseTheme(value string) (domain.ThemeMode, bool) {
474 return domain.ThemeAuto, false469 return domain.ThemeAuto, false
475 }470 }
476 }471 }
477-
478-func stripInlineComment(line string) string {
479- inQuote := false
480- escaped := false
481- for i, r := range line {
482- if escaped {
483- escaped = false
484- continue
485- }
486- if inQuote && r == '\\' {
487- escaped = true
488- continue
489- }
490- if r == '"' {
491- inQuote = !inQuote
492- continue
493- }
494- if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
495- return strings.TrimSpace(line[:i])
496- }
497- }
498- return line
499-}
500-
501-func isSpace(r rune) bool {
502- return r == ' ' || r == '\t' || r == '\r' || r == '\n'
503-}
M internal/adapters/config/config_test.go +140 -136
@@ -2,7 +2,6 @@ package config
22
33 import (
44 "context"
5- "errors"
65 "os"
76 "path/filepath"
87 "reflect"
@@ -10,8 +9,8 @@ import (
109 "testing"
1110 "time"
1211
12+ "github.com/bnema/kvconf"
1313 "github.com/bnema/vev/internal/domain"
14- portsmocks "github.com/bnema/vev/internal/ports/mocks"
1514 "github.com/stretchr/testify/require"
1615 )
1716
@@ -833,179 +832,184 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) {
833832 }
834833 }
835834
836-func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
837- t.Parallel()
838-
839- path := filepath.Join(t.TempDir(), "config")
840- if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
841- t.Fatal(err)
842- }
835+type watchChange struct {
836+ cfg domain.Config
837+ warnings []domain.Warning
838+}
843839
844- clk := newWatchClockMock(t)
840+// startWatch runs watch on path and returns the onChange stream once the
841+// watches are installed. The returned stop cancels it and checks its result.
842+func startWatch(t *testing.T, path string) (<-chan watchChange, func()) {
843+ t.Helper()
845844 ctx, cancel := context.WithCancel(context.Background())
846- defer cancel()
845+ t.Cleanup(cancel)
847846
848- type change struct {
849- cfg domain.Config
850- warnings []domain.Warning
851- }
852- changes := make(chan change, 2)
847+ changes := make(chan watchChange, 8)
848+ ready := make(chan struct{})
853849 done := make(chan error, 1)
854850 go func() {
855- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
856- changes <- change{cfg: cfg, warnings: warnings}
857- })
851+ done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready},
852+ func(cfg domain.Config, warnings []domain.Warning) {
853+ changes <- watchChange{cfg: cfg, warnings: warnings}
854+ })
858855 }()
859-
860- clk.waitForTimer()
861- if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
862- t.Fatal(err)
856+ select {
857+ case <-ready:
858+ case err := <-done:
859+ t.Fatalf("watch() returned before ready: %v", err)
860+ case <-time.After(5 * time.Second):
861+ t.Fatal("timed out waiting for the watcher to be ready")
862+ }
863+ return changes, func() {
864+ t.Helper()
865+ cancel()
866+ select {
867+ case err := <-done:
868+ require.ErrorIs(t, err, context.Canceled)
869+ case <-time.After(5 * time.Second):
870+ t.Fatal("timed out waiting for watch() to return")
871+ }
863872 }
864- clk.fire()
873+}
865874
866- var got change
875+func nextChange(t *testing.T, changes <-chan watchChange) watchChange {
876+ t.Helper()
867877 select {
868- case got = <-changes:
869- case <-time.After(2 * time.Second):
878+ case got := <-changes:
879+ return got
880+ case <-time.After(5 * time.Second):
870881 t.Fatal("timed out waiting for config reload")
871- }
872- if got.cfg.Theme != domain.ThemeLight {
873- t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
874- }
875- if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
876- t.Fatalf("Watch() warnings = %#v", got.warnings)
877- }
878-
879- cancel()
880- if err := <-done; !errors.Is(err, context.Canceled) {
881- t.Fatalf("Watch() error = %v, want context.Canceled", err)
882+ return watchChange{}
882883 }
883884 }
884885
885-func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
886+func TestWatchReloadsOnChange(t *testing.T) {
886887 t.Parallel()
887888
888- path := filepath.Join(t.TempDir(), "config")
889- if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
890- t.Fatal(err)
891- }
892-
893- clk := newWatchClockMock(t)
894- ctx, cancel := context.WithCancel(context.Background())
895- defer cancel()
896-
897- type change struct {
898- cfg domain.Config
899- warnings []domain.Warning
900- }
901- changes := make(chan change, 2)
902- done := make(chan error, 1)
903- go func() {
904- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
905- changes <- change{cfg: cfg, warnings: warnings}
906- })
907- }()
889+ light := domain.Defaults()
890+ light.Theme = domain.ThemeLight
908891
909- clk.waitForTimer()
910- tooLong := strings.Repeat("x", 70*1024)
911- if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
912- t.Fatal(err)
892+ tests := []struct {
893+ name string
894+ initial *string // nil: the file does not exist yet
895+ write func(t *testing.T, path string)
896+ wantConfig domain.Config
897+ wantWarnings []domain.Warning
898+ wantAnyWarn bool
899+ }{
900+ {
901+ name: "changed file reloads with parse warnings",
902+ initial: ptr("theme = dark\n"),
903+ write: func(t *testing.T, path string) {
904+ require.NoError(t, os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600))
905+ },
906+ wantConfig: light,
907+ wantWarnings: []domain.Warning{{Line: 2, Msg: "missing '='"}},
908+ },
909+ {
910+ name: "created file loads",
911+ initial: nil,
912+ write: func(t *testing.T, path string) {
913+ require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
914+ },
915+ wantConfig: light,
916+ },
917+ {
918+ name: "deleted file reloads to defaults",
919+ initial: ptr("theme = light\n"),
920+ write: func(t *testing.T, path string) {
921+ require.NoError(t, os.Remove(path))
922+ },
923+ wantConfig: domain.Defaults(),
924+ },
925+ {
926+ name: "load failure reports defaults and a warning",
927+ initial: ptr("theme = light\n"),
928+ write: func(t *testing.T, path string) {
929+ // Larger than kvconf.DefaultMaxBytes.
930+ require.NoError(t, os.WriteFile(path, []byte(strings.Repeat("# x\n", kvconf.DefaultMaxBytes/4+1)), 0o600))
931+ },
932+ wantConfig: domain.Defaults(),
933+ wantAnyWarn: true,
934+ },
935+ {
936+ name: "invalid UTF-8 reports defaults and a warning",
937+ initial: ptr("theme = light\n"),
938+ write: func(t *testing.T, path string) {
939+ require.NoError(t, os.WriteFile(path, []byte("theme = \xff\n"), 0o600))
940+ },
941+ wantConfig: domain.Defaults(),
942+ wantAnyWarn: true,
943+ },
913944 }
914- clk.fire()
915-
916- got := <-changes
917- require.Equal(t, domain.Defaults(), got.cfg)
918- require.NotEmpty(t, got.warnings)
945+ for _, tt := range tests {
946+ t.Run(tt.name, func(t *testing.T) {
947+ t.Parallel()
919948
920- cancel()
921- if err := <-done; !errors.Is(err, context.Canceled) {
922- t.Fatalf("Watch() error = %v, want context.Canceled", err)
949+ path := filepath.Join(t.TempDir(), "config")
950+ if tt.initial != nil {
951+ require.NoError(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
952+ }
953+ changes, stop := startWatch(t, path)
954+ tt.write(t, path)
955+
956+ got := nextChange(t, changes)
957+ require.Equal(t, tt.wantConfig, got.cfg)
958+ if tt.wantAnyWarn {
959+ require.NotEmpty(t, got.warnings)
960+ } else {
961+ require.Equal(t, tt.wantWarnings, got.warnings)
962+ }
963+ stop()
964+ })
923965 }
924966 }
925967
926-func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
968+func TestWatchReportsStatErrorOnce(t *testing.T) {
927969 t.Parallel()
928970
971+ // A name longer than NAME_MAX makes every stat fail with the same error.
929972 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
930- clk := newWatchClockMock(t)
931973 ctx, cancel := context.WithCancel(context.Background())
932974 defer cancel()
933975
934976 changes := make(chan []domain.Warning, 4)
977+ ready := make(chan struct{})
935978 done := make(chan error, 1)
936979 go func() {
937- done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
938- changes <- warnings
939- })
980+ done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready},
981+ func(cfg domain.Config, warnings []domain.Warning) {
982+ require.Equal(t, domain.Defaults(), cfg)
983+ changes <- warnings
984+ })
940985 }()
941986
942- first := <-changes
943- require.Len(t, first, 1)
944- clk.waitForTimer()
945- clk.fire()
946- clk.fire()
947-
948987 select {
949- case extra := <-changes:
950- t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
951- case <-time.After(100 * time.Millisecond):
988+ case first := <-changes:
989+ require.Len(t, first, 1)
990+ case <-time.After(5 * time.Second):
991+ t.Fatal("timed out waiting for the initial stat error")
952992 }
953-
954- cancel()
955- if err := <-done; !errors.Is(err, context.Canceled) {
956- t.Fatalf("Watch() error = %v, want context.Canceled", err)
957- }
958-}
959-
960-type watchClockMock struct {
961- t *testing.T
962- clock *portsmocks.MockClock
963- ticks chan time.Time
964- ready chan struct{}
965- resets chan struct{}
966-}
967-
968-func newWatchClockMock(t *testing.T) *watchClockMock {
969- t.Helper()
970- timer := portsmocks.NewMockTimer(t)
971- clock := portsmocks.NewMockClock(t)
972- m := &watchClockMock{
973- t: t,
974- clock: clock,
975- ticks: make(chan time.Time),
976- ready: make(chan struct{}),
977- resets: make(chan struct{}),
978- }
979- timer.EXPECT().C().Return(m.ticks)
980- timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
981- m.resets <- struct{}{}
982- }).Return(false)
983- timer.EXPECT().Stop().Return(true)
984- clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
985- close(m.ready)
986- }).Return(timer).Once()
987- return m
988-}
989-
990-func (m *watchClockMock) waitForTimer() {
991- m.t.Helper()
992993 select {
993- case <-m.ready:
994- case <-time.After(time.Second):
995- m.t.Fatal("timed out waiting for watcher timer creation")
994+ case extra := <-changes:
995+ t.Fatalf("watch() repeated persistent stat error: %#v", extra)
996+ case <-done:
997+ // The watcher may refuse such a path; the error is still reported once.
998+ case <-time.After(300 * time.Millisecond):
996999 }
997-}
9981000
999-func (m *watchClockMock) fire() {
1000- m.t.Helper()
1001+ cancel()
10011002 select {
1002- case m.ticks <- time.Now():
1003- case <-time.After(time.Second):
1004- m.t.Fatal("timed out firing watcher timer")
1003+ case err := <-done:
1004+ _ = err
1005+ case <-time.After(5 * time.Second):
1006+ t.Fatal("timed out waiting for watch() to return")
10051007 }
10061008 select {
1007- case <-m.resets:
1008- case <-time.After(time.Second):
1009- m.t.Fatal("timed out waiting for watcher timer reset")
1009+ case extra := <-changes:
1010+ t.Fatalf("watch() repeated persistent stat error: %#v", extra)
1011+ default:
10101012 }
10111013 }
1014+
1015+func ptr[T any](v T) *T { return &v }
@@ -2,7 +2,6 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "os"5 "os"
7 "path/filepath"6 "path/filepath"
8 "reflect"7 "reflect"
@@ -10,8 +9,8 @@ import (
10 "testing"9 "testing"
11 "time"10 "time"
12 11
12+ "github.com/bnema/kvconf"
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14- portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
16 )15 )
17 16
@@ -833,179 +832,184 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) {
833 }832 }
834 }833 }
835 834
836-func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {835+type watchChange struct {
837- t.Parallel()836+ cfg domain.Config
838-837+ warnings []domain.Warning
839- path := filepath.Join(t.TempDir(), "config")838+}
840- if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
841- t.Fatal(err)
842- }
843 839
844- clk := newWatchClockMock(t)840+// startWatch runs watch on path and returns the onChange stream once the
841+// watches are installed. The returned stop cancels it and checks its result.
842+func startWatch(t *testing.T, path string) (<-chan watchChange, func()) {
843+ t.Helper()
845 ctx, cancel := context.WithCancel(context.Background())844 ctx, cancel := context.WithCancel(context.Background())
846- defer cancel()845+ t.Cleanup(cancel)
847 846
848- type change struct {847+ changes := make(chan watchChange, 8)
849- cfg domain.Config848+ ready := make(chan struct{})
850- warnings []domain.Warning
851- }
852- changes := make(chan change, 2)
853 done := make(chan error, 1)849 done := make(chan error, 1)
854 go func() {850 go func() {
855- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {851+ done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready},
856- changes <- change{cfg: cfg, warnings: warnings}852+ func(cfg domain.Config, warnings []domain.Warning) {
857- })853+ changes <- watchChange{cfg: cfg, warnings: warnings}
854+ })
858 }()855 }()
859-856+ select {
860- clk.waitForTimer()857+ case <-ready:
861- if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {858+ case err := <-done:
862- t.Fatal(err)859+ t.Fatalf("watch() returned before ready: %v", err)
860+ case <-time.After(5 * time.Second):
861+ t.Fatal("timed out waiting for the watcher to be ready")
862+ }
863+ return changes, func() {
864+ t.Helper()
865+ cancel()
866+ select {
867+ case err := <-done:
868+ require.ErrorIs(t, err, context.Canceled)
869+ case <-time.After(5 * time.Second):
870+ t.Fatal("timed out waiting for watch() to return")
871+ }
863 }872 }
864- clk.fire()873+}
865 874
866- var got change875+func nextChange(t *testing.T, changes <-chan watchChange) watchChange {
876+ t.Helper()
867 select {877 select {
868- case got = <-changes:878+ case got := <-changes:
869- case <-time.After(2 * time.Second):879+ return got
880+ case <-time.After(5 * time.Second):
870 t.Fatal("timed out waiting for config reload")881 t.Fatal("timed out waiting for config reload")
871- }882+ return watchChange{}
872- if got.cfg.Theme != domain.ThemeLight {
873- t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
874- }
875- if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
876- t.Fatalf("Watch() warnings = %#v", got.warnings)
877- }
878-
879- cancel()
880- if err := <-done; !errors.Is(err, context.Canceled) {
881- t.Fatalf("Watch() error = %v, want context.Canceled", err)
882 }883 }
883 }884 }
884 885
885-func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {886+func TestWatchReloadsOnChange(t *testing.T) {
886 t.Parallel()887 t.Parallel()
887 888
888- path := filepath.Join(t.TempDir(), "config")889+ light := domain.Defaults()
889- if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {890+ light.Theme = domain.ThemeLight
890- t.Fatal(err)
891- }
892-
893- clk := newWatchClockMock(t)
894- ctx, cancel := context.WithCancel(context.Background())
895- defer cancel()
896-
897- type change struct {
898- cfg domain.Config
899- warnings []domain.Warning
900- }
901- changes := make(chan change, 2)
902- done := make(chan error, 1)
903- go func() {
904- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
905- changes <- change{cfg: cfg, warnings: warnings}
906- })
907- }()
908 891
909- clk.waitForTimer()892+ tests := []struct {
910- tooLong := strings.Repeat("x", 70*1024)893+ name string
911- if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {894+ initial *string // nil: the file does not exist yet
912- t.Fatal(err)895+ write func(t *testing.T, path string)
896+ wantConfig domain.Config
897+ wantWarnings []domain.Warning
898+ wantAnyWarn bool
899+ }{
900+ {
901+ name: "changed file reloads with parse warnings",
902+ initial: ptr("theme = dark\n"),
903+ write: func(t *testing.T, path string) {
904+ require.NoError(t, os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600))
905+ },
906+ wantConfig: light,
907+ wantWarnings: []domain.Warning{{Line: 2, Msg: "missing '='"}},
908+ },
909+ {
910+ name: "created file loads",
911+ initial: nil,
912+ write: func(t *testing.T, path string) {
913+ require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
914+ },
915+ wantConfig: light,
916+ },
917+ {
918+ name: "deleted file reloads to defaults",
919+ initial: ptr("theme = light\n"),
920+ write: func(t *testing.T, path string) {
921+ require.NoError(t, os.Remove(path))
922+ },
923+ wantConfig: domain.Defaults(),
924+ },
925+ {
926+ name: "load failure reports defaults and a warning",
927+ initial: ptr("theme = light\n"),
928+ write: func(t *testing.T, path string) {
929+ // Larger than kvconf.DefaultMaxBytes.
930+ require.NoError(t, os.WriteFile(path, []byte(strings.Repeat("# x\n", kvconf.DefaultMaxBytes/4+1)), 0o600))
931+ },
932+ wantConfig: domain.Defaults(),
933+ wantAnyWarn: true,
934+ },
935+ {
936+ name: "invalid UTF-8 reports defaults and a warning",
937+ initial: ptr("theme = light\n"),
938+ write: func(t *testing.T, path string) {
939+ require.NoError(t, os.WriteFile(path, []byte("theme = \xff\n"), 0o600))
940+ },
941+ wantConfig: domain.Defaults(),
942+ wantAnyWarn: true,
943+ },
913 }944 }
914- clk.fire()945+ for _, tt := range tests {
915-946+ t.Run(tt.name, func(t *testing.T) {
916- got := <-changes947+ t.Parallel()
917- require.Equal(t, domain.Defaults(), got.cfg)
918- require.NotEmpty(t, got.warnings)
919 948
920- cancel()949+ path := filepath.Join(t.TempDir(), "config")
921- if err := <-done; !errors.Is(err, context.Canceled) {950+ if tt.initial != nil {
922- t.Fatalf("Watch() error = %v, want context.Canceled", err)951+ require.NoError(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
952+ }
953+ changes, stop := startWatch(t, path)
954+ tt.write(t, path)
955+
956+ got := nextChange(t, changes)
957+ require.Equal(t, tt.wantConfig, got.cfg)
958+ if tt.wantAnyWarn {
959+ require.NotEmpty(t, got.warnings)
960+ } else {
961+ require.Equal(t, tt.wantWarnings, got.warnings)
962+ }
963+ stop()
964+ })
923 }965 }
924 }966 }
925 967
926-func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {968+func TestWatchReportsStatErrorOnce(t *testing.T) {
927 t.Parallel()969 t.Parallel()
928 970
971+ // A name longer than NAME_MAX makes every stat fail with the same error.
929 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))972 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
930- clk := newWatchClockMock(t)
931 ctx, cancel := context.WithCancel(context.Background())973 ctx, cancel := context.WithCancel(context.Background())
932 defer cancel()974 defer cancel()
933 975
934 changes := make(chan []domain.Warning, 4)976 changes := make(chan []domain.Warning, 4)
977+ ready := make(chan struct{})
935 done := make(chan error, 1)978 done := make(chan error, 1)
936 go func() {979 go func() {
937- done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {980+ done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready},
938- changes <- warnings981+ func(cfg domain.Config, warnings []domain.Warning) {
939- })982+ require.Equal(t, domain.Defaults(), cfg)
983+ changes <- warnings
984+ })
940 }()985 }()
941 986
942- first := <-changes
943- require.Len(t, first, 1)
944- clk.waitForTimer()
945- clk.fire()
946- clk.fire()
947-
948 select {987 select {
949- case extra := <-changes:988+ case first := <-changes:
950- t.Fatalf("Watch() repeated persistent stat error: %#v", extra)989+ require.Len(t, first, 1)
951- case <-time.After(100 * time.Millisecond):990+ case <-time.After(5 * time.Second):
991+ t.Fatal("timed out waiting for the initial stat error")
952 }992 }
953-
954- cancel()
955- if err := <-done; !errors.Is(err, context.Canceled) {
956- t.Fatalf("Watch() error = %v, want context.Canceled", err)
957- }
958-}
959-
960-type watchClockMock struct {
961- t *testing.T
962- clock *portsmocks.MockClock
963- ticks chan time.Time
964- ready chan struct{}
965- resets chan struct{}
966-}
967-
968-func newWatchClockMock(t *testing.T) *watchClockMock {
969- t.Helper()
970- timer := portsmocks.NewMockTimer(t)
971- clock := portsmocks.NewMockClock(t)
972- m := &watchClockMock{
973- t: t,
974- clock: clock,
975- ticks: make(chan time.Time),
976- ready: make(chan struct{}),
977- resets: make(chan struct{}),
978- }
979- timer.EXPECT().C().Return(m.ticks)
980- timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
981- m.resets <- struct{}{}
982- }).Return(false)
983- timer.EXPECT().Stop().Return(true)
984- clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
985- close(m.ready)
986- }).Return(timer).Once()
987- return m
988-}
989-
990-func (m *watchClockMock) waitForTimer() {
991- m.t.Helper()
992 select {993 select {
993- case <-m.ready:994+ case extra := <-changes:
994- case <-time.After(time.Second):995+ t.Fatalf("watch() repeated persistent stat error: %#v", extra)
995- m.t.Fatal("timed out waiting for watcher timer creation")996+ case <-done:
997+ // The watcher may refuse such a path; the error is still reported once.
998+ case <-time.After(300 * time.Millisecond):
996 }999 }
997-}
998 1000
999-func (m *watchClockMock) fire() {1001+ cancel()
1000- m.t.Helper()
1001 select {1002 select {
1002- case m.ticks <- time.Now():1003+ case err := <-done:
1003- case <-time.After(time.Second):1004+ _ = err
1004- m.t.Fatal("timed out firing watcher timer")1005+ case <-time.After(5 * time.Second):
1006+ t.Fatal("timed out waiting for watch() to return")
1005 }1007 }
1006 select {1008 select {
1007- case <-m.resets:1009+ case extra := <-changes:
1008- case <-time.After(time.Second):1010+ t.Fatalf("watch() repeated persistent stat error: %#v", extra)
1009- m.t.Fatal("timed out waiting for watcher timer reset")1011+ default:
1010 }1012 }
1011 }1013 }
1014+
1015+func ptr[T any](v T) *T { return &v }
M internal/adapters/config/palette_anchor_test.go +7 -17
@@ -1,7 +1,6 @@
11 package config
22
33 import (
4- "context"
54 "os"
65 "path/filepath"
76 "strings"
@@ -47,26 +46,17 @@ func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) {
4746 }
4847
4948 func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) {
49+ t.Parallel()
50+
5051 path := filepath.Join(t.TempDir(), "config")
5152 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
52- clk := newWatchClockMock(t)
53- ctx, cancel := context.WithCancel(context.Background())
54- defer cancel()
55- changes := make(chan domain.Config, 2)
56- done := make(chan error, 1)
57- go func() {
58- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg })
59- }()
60- clk.waitForTimer()
53+ changes, stop := startWatch(t, path)
6154
6255 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
63- clk.fire()
64- require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
56+ require.Equal(t, domain.AnchorBottomLeft, nextChange(t, changes).cfg.Palette.Anchor)
6557
6658 require.NoError(t, os.Remove(path))
67- clk.fire()
68- got := <-changes
69- require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette)
70- cancel()
71- require.ErrorIs(t, <-done, context.Canceled)
59+ got := nextChange(t, changes)
60+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.cfg.Palette)
61+ stop()
7262 }
@@ -1,7 +1,6 @@
1 package config1 package config
2 2
3 import (3 import (
4- "context"
5 "os"4 "os"
6 "path/filepath"5 "path/filepath"
7 "strings"6 "strings"
@@ -47,26 +46,17 @@ func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) {
47 }46 }
48 47
49 func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) {48 func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) {
49+ t.Parallel()
50+
50 path := filepath.Join(t.TempDir(), "config")51 path := filepath.Join(t.TempDir(), "config")
51 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))52 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
52- clk := newWatchClockMock(t)53+ changes, stop := startWatch(t, path)
53- ctx, cancel := context.WithCancel(context.Background())
54- defer cancel()
55- changes := make(chan domain.Config, 2)
56- done := make(chan error, 1)
57- go func() {
58- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg })
59- }()
60- clk.waitForTimer()
61 54
62 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))55 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
63- clk.fire()56+ require.Equal(t, domain.AnchorBottomLeft, nextChange(t, changes).cfg.Palette.Anchor)
64- require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
65 57
66 require.NoError(t, os.Remove(path))58 require.NoError(t, os.Remove(path))
67- clk.fire()59+ got := nextChange(t, changes)
68- got := <-changes60+ require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.cfg.Palette)
69- require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette)61+ stop()
70- cancel()
71- require.ErrorIs(t, <-done, context.Canceled)
72 }62 }
M internal/adapters/config/theme_accent_test.go +5 -35
@@ -1,13 +1,10 @@
11 package config
22
33 import (
4- "context"
5- "errors"
64 "os"
75 "path/filepath"
86 "strings"
97 "testing"
10- "time"
118
129 "github.com/bnema/vev/internal/domain"
1310 "github.com/stretchr/testify/require"
@@ -118,38 +115,11 @@ func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) {
118115 require.Empty(t, warnings)
119116 require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)
120117
121- clk := newWatchClockMock(t)
122- ctx, cancel := context.WithCancel(context.Background())
123- defer cancel()
124- type change struct {
125- cfg domain.Config
126- warnings []domain.Warning
127- }
128- changes := make(chan change, 1)
129- done := make(chan error, 1)
130- go func() {
131- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
132- changes <- change{cfg: cfg, warnings: warnings}
133- })
134- }()
135-
136- clk.waitForTimer()
118+ changes, stop := startWatch(t, path)
137119 require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))
138- clk.fire()
139120
140- select {
141- case got := <-changes:
142- require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
143- require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
144- case <-time.After(2 * time.Second):
145- t.Fatal("timed out waiting for config reload")
146- }
147-
148- cancel()
149- select {
150- case err := <-done:
151- require.True(t, errors.Is(err, context.Canceled))
152- case <-time.After(2 * time.Second):
153- t.Fatal("timed out waiting for config watcher shutdown")
154- }
121+ got := nextChange(t, changes)
122+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
123+ require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
124+ stop()
155125 }
@@ -1,13 +1,10 @@
1 package config1 package config
2 2
3 import (3 import (
4- "context"
5- "errors"
6 "os"4 "os"
7 "path/filepath"5 "path/filepath"
8 "strings"6 "strings"
9 "testing"7 "testing"
10- "time"
11 8
12 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
13 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -118,38 +115,11 @@ func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) {
118 require.Empty(t, warnings)115 require.Empty(t, warnings)
119 require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)116 require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)
120 117
121- clk := newWatchClockMock(t)118+ changes, stop := startWatch(t, path)
122- ctx, cancel := context.WithCancel(context.Background())
123- defer cancel()
124- type change struct {
125- cfg domain.Config
126- warnings []domain.Warning
127- }
128- changes := make(chan change, 1)
129- done := make(chan error, 1)
130- go func() {
131- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
132- changes <- change{cfg: cfg, warnings: warnings}
133- })
134- }()
135-
136- clk.waitForTimer()
137 require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))119 require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))
138- clk.fire()
139 120
140- select {121+ got := nextChange(t, changes)
141- case got := <-changes:122+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
142- require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)123+ require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
143- require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)124+ stop()
144- case <-time.After(2 * time.Second):
145- t.Fatal("timed out waiting for config reload")
146- }
147-
148- cancel()
149- select {
150- case err := <-done:
151- require.True(t, errors.Is(err, context.Canceled))
152- case <-time.After(2 * time.Second):
153- t.Fatal("timed out waiting for config watcher shutdown")
154- }
155 }125 }
M internal/app/daemon_run.go +1 -1
@@ -300,7 +300,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
300300 watchCtx, stopWatch := context.WithCancel(ctx)
301301 defer stopWatch()
302302 go func() {
303- if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
303+ if err := config.Watch(watchCtx, configPath, func(cfg domain.Config, warnings []domain.Warning) {
304304 logConfigWarnings(log, warnings)
305305 d.ApplyConfig(cfg)
306306 }); err != nil && !errors.Is(err, context.Canceled) {
@@ -300,7 +300,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
300 watchCtx, stopWatch := context.WithCancel(ctx)300 watchCtx, stopWatch := context.WithCancel(ctx)
301 defer stopWatch()301 defer stopWatch()
302 go func() {302 go func() {
303- if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {303+ if err := config.Watch(watchCtx, configPath, func(cfg domain.Config, warnings []domain.Warning) {
304 logConfigWarnings(log, warnings)304 logConfigWarnings(log, warnings)
305 d.ApplyConfig(cfg)305 d.ApplyConfig(cfg)
306 }); err != nil && !errors.Is(err, context.Canceled) {306 }); err != nil && !errors.Is(err, context.Canceled) {

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 12

Tags 11

↑↓ moveenter openesc close