fix(live): skip reloads that change no key and track file presence
Reordered keys no longer swap the config or call OnChange with no keys, and removing a file that was created empty is reported. Load validates missing-file defaults through Decode. Slice items read their own oneof, min and max, and map[string]string decoding no longer uses unsafe. Tests: Run waits on Ready instead of sleeping, missing files are checked with errors.Is, DefaultPath expects literal paths, file tests move to file_test.go, and vacuous checks are removed.
- commit
- 946d18cfab518cb389e813a271b460794b1a8fc9
- author
- brice · · 2026-10-10 06:21 UTC
- parent
- 3b1b6ef
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +203 -141
M decode.go +6 -9
| @@ -231,14 +231,16 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string | |||
| 231 | 231 | if item.item != nil { | |
| 232 | 232 | return nil, fmt.Errorf("kvconf: %s: nested lists are not supported", where) | |
| 233 | 233 | } | |
| 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 | |
| 236 | 238 | default: | |
| 237 | 239 | return nil, fmt.Errorf("kvconf: %s: unsupported type %s", where, t) | |
| 238 | 240 | } | |
| 239 | 241 | } | |
| 240 | 242 | bound := func(s string) (float64, error) { | |
| 241 | - | if f.duration || f.item != nil && f.item.duration { | |
| 243 | + | if f.duration { | |
| 242 | 244 | d, err := time.ParseDuration(s) | |
| 243 | 245 | return float64(d), err | |
| 244 | 246 | } | |
| @@ -257,11 +259,6 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string | |||
| 257 | 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 | 262 | return f, nil | |
| 266 | 263 | } | |
| 267 | 264 | ||
| @@ -420,7 +417,7 @@ func (s *schema) apply(st *decodeState, e *Entry) (msg string, known bool) { | |||
| 420 | 417 | return msg, true | |
| 421 | 418 | } | |
| 422 | 419 | 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 | |
| 424 | 421 | return "", true | |
| 425 | 422 | } | |
| 426 | 423 | 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, nil | 234 | + | // The item reads oneof, min and max from the same tag and |
| 235 | - | f.item = item | 235 | + | // 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), err | 245 | 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, nil | 262 | 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, true | 417 | 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.Value | 420 | + | m.Interface().(map[string]string)[name] = e.Value |
| 424 | return "", true | 421 | 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 | |||
| 207 | 207 | warnings: []string{"line 2: count: must be at most 10"}, | |
| 208 | 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 | 211 | for _, tt := range tests { | |
| 218 | 212 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -361,9 +355,6 @@ func TestDecodeNeverLeaksValues(t *testing.T) { | |||
| 361 | 355 | w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{}) | |
| 362 | 356 | must(t, err) | |
| 363 | 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 | } | |
| @@ -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) { | |||
| 17 | 17 | return cfg, warnings, err | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | // load returns nil entries, not an empty slice, when the file is missing. | |
| 20 | 21 | func load[T any](path string, opts Options) (*T, []Entry, []Warning, error) { | |
| 21 | 22 | data, err := ReadFile(path, opts) | |
| 22 | 23 | if err != nil { | |
| 23 | 24 | if isNotExist(err) && !opts.RequireFile { | |
| 25 | + | // No entries: the defaults, then Validate. | |
| 24 | 26 | 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 | |
| 29 | 29 | } | |
| 30 | 30 | return cfg, nil, nil, nil | |
| 31 | 31 | } | |
| 32 | 32 | 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 | 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 | 88 | opts LiveOptions[T] | |
| 85 | 89 | cur atomic.Pointer[T] | |
| 86 | 90 | entries []Entry // owned by Run | |
| 91 | + | exists bool // whether the file was there at the last load; owned by Run | |
| 87 | 92 | 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 | 99 | if err != nil { | |
| 95 | 100 | 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 | 103 | l.cur.Store(cfg) | |
| 99 | 104 | return l, warnings, nil | |
| 100 | 105 | } | |
| @@ -113,8 +118,6 @@ func (l *Live[T]) Run(ctx context.Context) error { | |||
| 113 | 118 | if !l.running.CompareAndSwap(false, true) { | |
| 114 | 119 | return errors.New("kvconf: Live.Run called twice") | |
| 115 | 120 | } | |
| 116 | - | ctx, cancel := context.WithCancel(ctx) | |
| 117 | - | defer cancel() | |
| 118 | 121 | changed := make(chan struct{}, 1) | |
| 119 | 122 | ready := make(chan struct{}) | |
| 120 | 123 | wopts := l.opts.Watch | |
| @@ -147,7 +150,8 @@ func (l *Live[T]) reload() { | |||
| 147 | 150 | if isNotExist(err) { | |
| 148 | 151 | // Keep the current config: a removed file is often about to be | |
| 149 | 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 | 155 | l.report(fmt.Errorf("%s: %w", l.path, fs.ErrNotExist)) | |
| 152 | 156 | } | |
| 153 | 157 | return | |
| @@ -161,13 +165,14 @@ func (l *Live[T]) reload() { | |||
| 161 | 165 | l.report(err) | |
| 162 | 166 | return | |
| 163 | 167 | } | |
| 164 | - | if sameEntries(entries, l.entries) { | |
| 165 | - | return | |
| 166 | - | } | |
| 168 | + | l.exists = true | |
| 167 | 169 | // Compare every assignment, not the last one: a repeated key whose | |
| 168 | 170 | // last value is invalid decodes to an earlier one. | |
| 169 | 171 | keys := changedKeys(l.entries, entries) | |
| 170 | 172 | l.entries = entries | |
| 173 | + | if len(keys) == 0 { | |
| 174 | + | return | |
| 175 | + | } | |
| 171 | 176 | old := l.cur.Swap(cfg) | |
| 172 | 177 | if l.opts.OnChange != nil { | |
| 173 | 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 | 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 | 209 | l.opts.OnError(err) | |
| 205 | 210 | } | |
| 206 | 211 | } | |
| @@ -17,21 +17,25 @@ func Load[T any](path string, opts Options) (*T, []Warning, error) { | |||||
| 17 | return cfg, warnings, err | 17 | 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, nil | 30 | return cfg, nil, nil, nil | ||
| 31 | } | 31 | } | ||
| 32 | return nil, nil, nil, err | 32 | 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 Run | 90 | entries []Entry // owned by Run | ||
| 91 | + | exists bool // whether the file was there at the last load; owned by Run | |||
| 87 | running atomic.Bool | 92 | 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, err | 100 | 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, nil | 104 | 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.Watch | 123 | 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 be | 151 | // 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 | return | 157 | return | ||
| @@ -161,13 +165,14 @@ func (l *Live[T]) reload() { | |||||
| 161 | l.report(err) | 165 | l.report(err) | ||
| 162 | return | 166 | 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 whose | 169 | // 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 = entries | 172 | 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) { | |||
| 39 | 39 | want liveConfig | |
| 40 | 40 | warnings int | |
| 41 | 41 | err string | |
| 42 | + | notExist bool | |
| 42 | 43 | }{ | |
| 43 | 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 | 46 | {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}}, | |
| 46 | 47 | {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1}, | |
| 47 | 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 | 54 | must(t, os.WriteFile(path, []byte(*tt.content), 0o600)) | |
| 54 | 55 | } | |
| 55 | 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 | 63 | if tt.err != "" { | |
| 57 | 64 | if err == nil || !strings.Contains(err.Error(), tt.err) { | |
| 58 | 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) { | |
| 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"}, | |
| 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 | 112 | // liveRecorder collects what a Live reports. | |
| @@ -163,6 +197,30 @@ func TestLive(t *testing.T) { | |||
| 163 | 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 | 225 | name: "broken file keeps the config", | |
| 168 | 226 | initial: ptr("theme = light\n"), | |
| @@ -263,12 +321,13 @@ func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) { | |||
| 263 | 321 | } | |
| 264 | 322 | ||
| 265 | 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 | 326 | must(t, err) | |
| 268 | 327 | ctx, cancel := context.WithCancel(context.Background()) | |
| 269 | 328 | defer cancel() | |
| 270 | 329 | go func() { _ = l.Run(ctx) }() | |
| 271 | - | time.Sleep(50 * time.Millisecond) | |
| 330 | + | <-ready | |
| 272 | 331 | if err := l.Run(ctx); err == nil { | |
| 273 | 332 | t.Fatal("a second Run was accepted") | |
| 274 | 333 | } | |
| @@ -39,9 +39,10 @@ func TestLoad(t *testing.T) { | |||||
| 39 | want liveConfig | 39 | want liveConfig | ||
| 40 | warnings int | 40 | warnings int | ||
| 41 | err string | 41 | err string | ||
| 42 | + | notExist bool | |||
| 42 | }{ | 43 | }{ | ||
| 43 | {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}}, | 44 | {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}}, | ||
| 44 | - | {name: "missing file required", opts: Options{RequireFile: true}, err: "config"}, | 45 | + | {name: "missing file required", opts: Options{RequireFile: true}, notExist: true}, |
| 45 | {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}}, | 46 | {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}}, | ||
| 46 | {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1}, | 47 | {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1}, | ||
| 47 | {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"}, | 48 | {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"}, | ||
| @@ -53,6 +54,12 @@ func TestLoad(t *testing.T) { | |||||
| 53 | must(t, os.WriteFile(path, []byte(*tt.content), 0o600)) | 54 | must(t, os.WriteFile(path, []byte(*tt.content), 0o600)) | ||
| 54 | } | 55 | } | ||
| 55 | cfg, warnings, err := Load[liveConfig](path, tt.opts) | 56 | cfg, warnings, err := Load[liveConfig](path, tt.opts) | ||
| 57 | + | if tt.notExist { | |||
| 58 | + | if !errors.Is(err, fs.ErrNotExist) { | |||
| 59 | + | t.Fatalf("err = %v, want fs.ErrNotExist", err) | |||
| 60 | + | } | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 56 | if tt.err != "" { | 63 | if tt.err != "" { | ||
| 57 | if err == nil || !strings.Contains(err.Error(), tt.err) { | 64 | if err == nil || !strings.Contains(err.Error(), tt.err) { | ||
| 58 | t.Fatalf("err = %v, want %q", err, tt.err) | 65 | t.Fatalf("err = %v, want %q", err, tt.err) | ||
| @@ -66,13 +73,40 @@ func TestLoad(t *testing.T) { | |||||
| 66 | } | 73 | } | ||
| 67 | } | 74 | } | ||
| 68 | 75 | ||||
| 69 | - | func TestLoadValidatesDefaults(t *testing.T) { | 76 | + | // badDefaults has defaults that fail its own Validate. |
| 70 | - | _, _, err := Load[strictPair](filepath.Join(t.TempDir(), "none"), Options{}) | 77 | + | type badDefaults struct{ strictPair } |
| 71 | - | must(t, err) | 78 | + | |
| 72 | - | path := writeTemp(t, t.TempDir(), "config", "a = 3\nb = 2\n") | 79 | + | func (p *badDefaults) SetDefaults() { p.A, p.B = 3, 2 } |
| 73 | - | if _, _, err := Load[strictPair](path, Options{}); err == nil || err.Error() != path+": a must not exceed b" { | 80 | + | func (p *badDefaults) Validate() error { return p.strictPair.Validate() } |
| 74 | - | t.Fatalf("err = %v", err) | 81 | + | |
| 82 | + | func TestLoadValidates(t *testing.T) { | |||
| 83 | + | dir := t.TempDir() | |||
| 84 | + | bad := writeTemp(t, dir, "bad", "a = 3\nb = 2\n") | |||
| 85 | + | tests := []struct { | |||
| 86 | + | name string | |||
| 87 | + | load func(path string) error | |||
| 88 | + | path string | |||
| 89 | + | err string | |||
| 90 | + | }{ | |||
| 91 | + | {name: "valid zero defaults", load: loadErr[strictPair], path: filepath.Join(dir, "none")}, | |||
| 92 | + | {name: "invalid file", load: loadErr[strictPair], path: bad, err: bad + ": a must not exceed b"}, | |||
| 93 | + | {name: "invalid defaults", load: loadErr[badDefaults], path: filepath.Join(dir, "none"), err: "a must not exceed b"}, | |||
| 75 | } | 94 | } | ||
| 95 | + | for _, tt := range tests { | |||
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 97 | + | err := tt.load(tt.path) | |||
| 98 | + | if tt.err == "" { | |||
| 99 | + | must(t, err) | |||
| 100 | + | } else if err == nil || err.Error() != tt.err { | |||
| 101 | + | t.Fatalf("err = %v, want %q", err, tt.err) | |||
| 102 | + | } | |||
| 103 | + | }) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func loadErr[T any](path string) error { | |||
| 108 | + | _, _, err := Load[T](path, Options{}) | |||
| 109 | + | return err | |||
| 76 | } | 110 | } | ||
| 77 | 111 | ||||
| 78 | // liveRecorder collects what a Live reports. | 112 | // liveRecorder collects what a Live reports. | ||
| @@ -163,6 +197,30 @@ func TestLive(t *testing.T) { | |||||
| 163 | r.quiet(t, "a save that changes no value") | 197 | r.quiet(t, "a save that changes no value") | ||
| 164 | }, | 198 | }, | ||
| 165 | }, | 199 | }, | ||
| 200 | + | { | |||
| 201 | + | name: "reordered keys", | |||
| 202 | + | initial: ptr("theme = light\nafter = 5s\n"), | |||
| 203 | + | steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) { | |||
| 204 | + | old := l.Get() | |||
| 205 | + | must(t, os.WriteFile(path, []byte("after = 5s\ntheme = light\n"), 0o600)) | |||
| 206 | + | r.quiet(t, "a save that only reorders keys") | |||
| 207 | + | if l.Get() != old { | |||
| 208 | + | t.Fatal("config swapped") | |||
| 209 | + | } | |||
| 210 | + | }, | |||
| 211 | + | }, | |||
| 212 | + | { | |||
| 213 | + | name: "empty file created then removed", | |||
| 214 | + | steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) { | |||
| 215 | + | must(t, os.WriteFile(path, nil, 0o600)) | |||
| 216 | + | r.quiet(t, "an empty file") | |||
| 217 | + | must(t, os.Remove(path)) | |||
| 218 | + | r.wait(t, "a removal") | |||
| 219 | + | if _, err := r.last(); !errors.Is(err, fs.ErrNotExist) { | |||
| 220 | + | t.Fatalf("err = %v", err) | |||
| 221 | + | } | |||
| 222 | + | }, | |||
| 223 | + | }, | |||
| 166 | { | 224 | { | ||
| 167 | name: "broken file keeps the config", | 225 | name: "broken file keeps the config", | ||
| 168 | initial: ptr("theme = light\n"), | 226 | initial: ptr("theme = light\n"), | ||
| @@ -263,12 +321,13 @@ func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) { | |||||
| 263 | } | 321 | } | ||
| 264 | 322 | ||||
| 265 | func TestLiveRunTwice(t *testing.T) { | 323 | func TestLiveRunTwice(t *testing.T) { | ||
| 266 | - | l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{}) | 324 | + | ready := make(chan struct{}) |
| 325 | + | l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{Watch: WatchOptions{Ready: ready}}) | |||
| 267 | must(t, err) | 326 | must(t, err) | ||
| 268 | ctx, cancel := context.WithCancel(context.Background()) | 327 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 269 | defer cancel() | 328 | defer cancel() | ||
| 270 | go func() { _ = l.Run(ctx) }() | 329 | go func() { _ = l.Run(ctx) }() | ||
| 271 | - | time.Sleep(50 * time.Millisecond) | 330 | + | <-ready |
| 272 | if err := l.Run(ctx); err == nil { | 331 | if err := l.Run(ctx); err == nil { | ||
| 273 | t.Fatal("a second Run was accepted") | 332 | t.Fatal("a second Run was accepted") | ||
| 274 | } | 333 | } | ||
M parse.go +2 -7
| @@ -248,7 +248,8 @@ func stripComment(v string, style CommentStyle) string { | |||
| 248 | 248 | quote = 0 | |
| 249 | 249 | } | |
| 250 | 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 | 253 | quote = c | |
| 253 | 254 | case c == '#' && (style == CommentAny || i > 0 && (v[i-1] == ' ' || v[i-1] == '\t')): | |
| 254 | 255 | return strings.TrimSpace(v[:i]) | |
| @@ -307,9 +308,3 @@ func Changed(old, cur map[string]string) []string { | |||
| 307 | 308 | slices.Sort(keys) | |
| 308 | 309 | 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 | - | } | |
| @@ -248,7 +248,8 @@ func stripComment(v string, style CommentStyle) string { | |||||
| 248 | quote = 0 | 248 | 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 = c | 253 | 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 keys | 309 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "reflect" | |
| 6 | + | "slices" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ) | |
| @@ -225,7 +226,7 @@ func FuzzParse(f *testing.F) { | |||
| 225 | 226 | if err != nil { | |
| 226 | 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 | 230 | t.Fatalf("round trip:\n%#v\n%#v", entries, again) | |
| 230 | 231 | } | |
| 231 | 232 | }) | |
| @@ -3,6 +3,7 @@ package kvconf | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "reflect" | 5 | "reflect" | ||
| 6 | + | "slices" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | ) | 9 | ) | ||
| @@ -225,7 +226,7 @@ func FuzzParse(f *testing.F) { | |||||
| 225 | if err != nil { | 226 | if err != nil { | ||
| 226 | t.Fatalf("re-parse: %v\n%s", err, data) | 227 | t.Fatalf("re-parse: %v\n%s", err, data) | ||
| 227 | } | 228 | } | ||
| 228 | - | if !sameEntries(entries, again) { | 229 | + | if !slices.EqualFunc(entries, again, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value }) { |
| 229 | t.Fatalf("round trip:\n%#v\n%#v", entries, again) | 230 | t.Fatalf("round trip:\n%#v\n%#v", entries, again) | ||
| 230 | } | 231 | } | ||
| 231 | }) | 232 | }) | ||
M write_test.go +0 -93
| @@ -259,99 +259,6 @@ func TestSetFile(t *testing.T) { | |||
| 259 | 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 | 262 | func readString(t *testing.T, path string) string { | |
| 356 | 263 | t.Helper() | |
| 357 | 264 | 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) | ||