1package config
2
3import (
4 "context"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10)
11
12func startWatch(t *testing.T, path string, last Config) <-chan Config {
13 t.Helper()
14 out := make(chan Config, 4)
15 ctx, cancel := context.WithCancel(context.Background())
16 done := make(chan struct{})
17 go func() {
18 defer close(done)
19 if err := Watch(ctx, path, last, out, slog.New(slog.DiscardHandler)); err != nil {
20 t.Error(err)
21 }
22 }()
23 t.Cleanup(func() { cancel(); <-done })
24 time.Sleep(150 * time.Millisecond) // let the watch attach
25 return out
26}
27
28func recv(t *testing.T, ch <-chan Config) Config {
29 t.Helper()
30 select {
31 case c := <-ch:
32 return c
33 case <-time.After(3 * time.Second):
34 t.Fatal("no config reload")
35 return Config{}
36 }
37}
38
39func none(t *testing.T, ch <-chan Config) {
40 t.Helper()
41 select {
42 case c := <-ch:
43 t.Fatalf("unexpected reload: %+v", c.Bar)
44 case <-time.After(600 * time.Millisecond):
45 }
46}
47
48func TestWatchInPlaceAndAtomicSaves(t *testing.T) {
49 path := write(t, "[bar]\nsize = 14\n")
50 first, err := Load(path, true)
51 if err != nil {
52 t.Fatal(err)
53 }
54 ch := startWatch(t, path, first)
55
56 if err := os.WriteFile(path, []byte("[bar]\nsize = 18\n"), 0o600); err != nil {
57 t.Fatal(err)
58 }
59 if got := recv(t, ch).Bar.Size; got != 18 {
60 t.Fatalf("in-place save: size = %v", got)
61 }
62
63 tmp := path + ".tmp" // what most editors do: write a sibling, rename over
64 if err := os.WriteFile(tmp, []byte("[bar]\nsize = 20\n"), 0o600); err != nil {
65 t.Fatal(err)
66 }
67 if err := os.Rename(tmp, path); err != nil {
68 t.Fatal(err)
69 }
70 if got := recv(t, ch).Bar.Size; got != 20 {
71 t.Fatalf("atomic save: size = %v", got)
72 }
73}
74
75func TestWatchKeepsOldConfigOnErrors(t *testing.T) {
76 path := write(t, "[bar]\nsize = 14\n")
77 first, _ := Load(path, true)
78 ch := startWatch(t, path, first)
79
80 os.WriteFile(path, []byte("[bar\n"), 0o600) // syntax error
81 none(t, ch)
82 os.WriteFile(path, []byte("[bar]\nsize = 1\n"), 0o600) // out of range
83 none(t, ch)
84 os.Remove(path)
85 none(t, ch)
86
87 os.WriteFile(path, []byte("[bar]\nsize = 16\n"), 0o600) // recovers
88 if got := recv(t, ch).Bar.Size; got != 16 {
89 t.Fatalf("after recovery: size = %v", got)
90 }
91}
92
93func TestWatchIgnoresUnchangedSave(t *testing.T) {
94 path := write(t, "[bar]\nsize = 14\n")
95 first, _ := Load(path, true)
96 ch := startWatch(t, path, first)
97 os.WriteFile(path, []byte("# a comment\n[bar]\nsize = 14\n"), 0o600)
98 none(t, ch)
99}
100
101func TestWatchFollowsSymlink(t *testing.T) {
102 dir := t.TempDir()
103 real := filepath.Join(dir, "dotfiles", "config.toml")
104 os.MkdirAll(filepath.Dir(real), 0o700)
105 os.WriteFile(real, []byte("[bar]\nsize = 14\n"), 0o600)
106 link := filepath.Join(dir, "config.toml")
107 if err := os.Symlink(real, link); err != nil {
108 t.Fatal(err)
109 }
110 first, _ := Load(link, true)
111 ch := startWatch(t, link, first)
112 os.WriteFile(real, []byte("[bar]\nsize = 22\n"), 0o600)
113 if got := recv(t, ch).Bar.Size; got != 22 {
114 t.Fatalf("symlink target edit: size = %v", got)
115 }
116}
117
118func TestDuplicateModuleNames(t *testing.T) {
119 _, err := Load(write(t, "[[module]]\nname=\"a\"\nzone=\"left\"\nexec=\"x\"\n[[module]]\nname=\"a\"\nzone=\"right\"\nexec=\"y\"\n"), true)
120 if err == nil {
121 t.Fatal("duplicate module names must fail")
122 }
123}