Merge pull request 'fix(live): review cleanup of Live, decoding and watchers' (#2) from fix/review-cleanup into main
Reviewed-on: https://git.bnema.dev/bnema/kvconf/pulls/2
16 files · +270 -199
M decode.go +6 -9
| @@ -231,14 +231,16 @@ func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string | |||
| 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 open_other.go +0 -3
| @@ -5,8 +5,5 @@ package kvconf | |||
| 5 | 5 | import "os" | |
| 6 | 6 | ||
| 7 | 7 | func openNonblocking(path string) (*os.File, error) { | |
| 8 | - | if info, err := os.Stat(path); err == nil && !info.Mode().IsRegular() { | |
| 9 | - | return nil, &os.PathError{Op: "open", Path: path, Err: ErrNotRegular} | |
| 10 | - | } | |
| 11 | 8 | return os.Open(path) | |
| 12 | 9 | } | |
| @@ -5,8 +5,5 @@ package kvconf | |||||
| 5 | import "os" | 5 | import "os" | ||
| 6 | 6 | ||||
| 7 | func openNonblocking(path string) (*os.File, error) { | 7 | func openNonblocking(path string) (*os.File, error) { | ||
| 8 | - | if info, err := os.Stat(path); err == nil && !info.Mode().IsRegular() { | |||
| 9 | - | return nil, &os.PathError{Op: "open", Path: path, Err: ErrNotRegular} | |||
| 10 | - | } | |||
| 11 | return os.Open(path) | 8 | return os.Open(path) | ||
| 12 | } | 9 | } | ||
M parse.go +2 -7
| @@ -248,7 +248,8 @@ func stripComment(v string, style CommentStyle) string { | |||
| 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 watch.go +3 -11
| @@ -68,15 +68,13 @@ func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struc | |||
| 68 | 68 | b = newPoller(opts.Poll) | |
| 69 | 69 | } else if b, err = newNative(); err != nil { | |
| 70 | 70 | return err | |
| 71 | - | } else if b == nil { | |
| 72 | - | b = newPoller(time.Second) | |
| 73 | 71 | } | |
| 74 | 72 | defer b.close() //nolint:errcheck // nothing to report once watching ends | |
| 75 | 73 | stop := context.AfterFunc(ctx, b.wake) | |
| 76 | 74 | defer stop() | |
| 77 | 75 | ||
| 78 | 76 | w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}} | |
| 79 | - | missing, err := w.rewatch(b) | |
| 77 | + | missing, _, err := w.rewatchChanged(b) | |
| 80 | 78 | if err != nil { | |
| 81 | 79 | return err | |
| 82 | 80 | } | |
| @@ -189,10 +187,9 @@ func (t *target) has(name string) bool { | |||
| 189 | 187 | ||
| 190 | 188 | // event is a change in a watched directory. An empty name means "something | |
| 191 | 189 | // changed in dir" (kqueue, polling, overflow); an empty dir means anything | |
| 192 | - | // may have changed. lost means the watch itself vanished. | |
| 190 | + | // may have changed, or a watch was lost. | |
| 193 | 191 | type event struct { | |
| 194 | 192 | dir, name string | |
| 195 | - | lost bool | |
| 196 | 193 | // moved: the name was created, removed or renamed, so a link may now | |
| 197 | 194 | // point elsewhere. A plain write leaves links alone. | |
| 198 | 195 | moved bool | |
| @@ -329,11 +326,6 @@ func (w *watcher) plan() (targets []*target, files []string) { | |||
| 329 | 326 | return targets, files | |
| 330 | 327 | } | |
| 331 | 328 | ||
| 332 | - | func (w *watcher) rewatch(b backend) (missing bool, err error) { | |
| 333 | - | missing, _, err = w.rewatchChanged(b) | |
| 334 | - | return missing, err | |
| 335 | - | } | |
| 336 | - | ||
| 337 | 329 | // rewatchChanged installs the watches again and reports whether the watched | |
| 338 | 330 | // files changed meanwhile: a link was retargeted, a missing directory | |
| 339 | 331 | // appeared, or a file changed while it was not watched. | |
| @@ -367,7 +359,7 @@ func sameTargets(a, b []*target) bool { | |||
| 367 | 359 | func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) { | |
| 368 | 360 | relinked := false | |
| 369 | 361 | for _, ev := range evs { | |
| 370 | - | if ev.lost || ev.dir == "" { | |
| 362 | + | if ev.dir == "" { | |
| 371 | 363 | relevant, rewatch, recheck = true, true, true | |
| 372 | 364 | continue | |
| 373 | 365 | } | |
| @@ -68,15 +68,13 @@ func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struc | |||||
| 68 | b = newPoller(opts.Poll) | 68 | b = newPoller(opts.Poll) | ||
| 69 | } else if b, err = newNative(); err != nil { | 69 | } else if b, err = newNative(); err != nil { | ||
| 70 | return err | 70 | return err | ||
| 71 | - | } else if b == nil { | |||
| 72 | - | b = newPoller(time.Second) | |||
| 73 | } | 71 | } | ||
| 74 | defer b.close() //nolint:errcheck // nothing to report once watching ends | 72 | defer b.close() //nolint:errcheck // nothing to report once watching ends | ||
| 75 | stop := context.AfterFunc(ctx, b.wake) | 73 | stop := context.AfterFunc(ctx, b.wake) | ||
| 76 | defer stop() | 74 | defer stop() | ||
| 77 | 75 | ||||
| 78 | w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}} | 76 | w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}} | ||
| 79 | - | missing, err := w.rewatch(b) | 77 | + | missing, _, err := w.rewatchChanged(b) |
| 80 | if err != nil { | 78 | if err != nil { | ||
| 81 | return err | 79 | return err | ||
| 82 | } | 80 | } | ||
| @@ -189,10 +187,9 @@ func (t *target) has(name string) bool { | |||||
| 189 | 187 | ||||
| 190 | // event is a change in a watched directory. An empty name means "something | 188 | // event is a change in a watched directory. An empty name means "something | ||
| 191 | // changed in dir" (kqueue, polling, overflow); an empty dir means anything | 189 | // changed in dir" (kqueue, polling, overflow); an empty dir means anything | ||
| 192 | - | // may have changed. lost means the watch itself vanished. | 190 | + | // may have changed, or a watch was lost. |
| 193 | type event struct { | 191 | type event struct { | ||
| 194 | dir, name string | 192 | dir, name string | ||
| 195 | - | lost bool | |||
| 196 | // moved: the name was created, removed or renamed, so a link may now | 193 | // moved: the name was created, removed or renamed, so a link may now | ||
| 197 | // point elsewhere. A plain write leaves links alone. | 194 | // point elsewhere. A plain write leaves links alone. | ||
| 198 | moved bool | 195 | moved bool | ||
| @@ -329,11 +326,6 @@ func (w *watcher) plan() (targets []*target, files []string) { | |||||
| 329 | return targets, files | 326 | return targets, files | ||
| 330 | } | 327 | } | ||
| 331 | 328 | ||||
| 332 | - | func (w *watcher) rewatch(b backend) (missing bool, err error) { | |||
| 333 | - | missing, _, err = w.rewatchChanged(b) | |||
| 334 | - | return missing, err | |||
| 335 | - | } | |||
| 336 | - | ||||
| 337 | // rewatchChanged installs the watches again and reports whether the watched | 329 | // rewatchChanged installs the watches again and reports whether the watched | ||
| 338 | // files changed meanwhile: a link was retargeted, a missing directory | 330 | // files changed meanwhile: a link was retargeted, a missing directory | ||
| 339 | // appeared, or a file changed while it was not watched. | 331 | // appeared, or a file changed while it was not watched. | ||
| @@ -367,7 +359,7 @@ func sameTargets(a, b []*target) bool { | |||||
| 367 | func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) { | 359 | func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) { | ||
| 368 | relinked := false | 360 | relinked := false | ||
| 369 | for _, ev := range evs { | 361 | for _, ev := range evs { | ||
| 370 | - | if ev.lost || ev.dir == "" { | 362 | + | if ev.dir == "" { |
| 371 | relevant, rewatch, recheck = true, true, true | 363 | relevant, rewatch, recheck = true, true, true | ||
| 372 | continue | 364 | continue | ||
| 373 | } | 365 | } | ||
M watch_kqueue.go +6 -3
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //go:build darwin || ios || freebsd || netbsd || openbsd || dragonfly | |
| 1 | + | //go:build darwin || freebsd || netbsd || openbsd || dragonfly | |
| 2 | 2 | ||
| 3 | 3 | package kvconf | |
| 4 | 4 | ||
| @@ -124,8 +124,11 @@ func (k *kqueue) wait(timeout time.Duration, evs []event) ([]event, error) { | |||
| 124 | 124 | if !ok { | |
| 125 | 125 | continue | |
| 126 | 126 | } | |
| 127 | - | lost := ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0 | |
| 128 | - | evs = append(evs, event{dir: dir, lost: lost}) | |
| 127 | + | if ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0 { | |
| 128 | + | evs = append(evs, event{}) // a watch was lost | |
| 129 | + | continue | |
| 130 | + | } | |
| 131 | + | evs = append(evs, event{dir: dir}) | |
| 129 | 132 | } | |
| 130 | 133 | return evs, nil | |
| 131 | 134 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | //go:build darwin || ios || freebsd || netbsd || openbsd || dragonfly | 1 | + | //go:build darwin || freebsd || netbsd || openbsd || dragonfly |
| 2 | 2 | ||||
| 3 | package kvconf | 3 | package kvconf | ||
| 4 | 4 | ||||
| @@ -124,8 +124,11 @@ func (k *kqueue) wait(timeout time.Duration, evs []event) ([]event, error) { | |||||
| 124 | if !ok { | 124 | if !ok { | ||
| 125 | continue | 125 | continue | ||
| 126 | } | 126 | } | ||
| 127 | - | lost := ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0 | 127 | + | if ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0 { |
| 128 | - | evs = append(evs, event{dir: dir, lost: lost}) | 128 | + | evs = append(evs, event{}) // a watch was lost |
| 129 | + | continue | |||
| 130 | + | } | |||
| 131 | + | evs = append(evs, event{dir: dir}) | |||
| 129 | } | 132 | } | ||
| 130 | return evs, nil | 133 | return evs, nil | ||
| 131 | } | 134 | } | ||
M watch_kqueue_darwin.go +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //go:build darwin || ios | |
| 1 | + | //go:build darwin | |
| 2 | 2 | ||
| 3 | 3 | package kvconf | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | //go:build darwin || ios | 1 | + | //go:build darwin |
| 2 | 2 | ||||
| 3 | package kvconf | 3 | package kvconf | ||
| 4 | 4 | ||||
M watch_linux.go +1 -1
| @@ -123,7 +123,7 @@ func (in *inotify) wait(timeout time.Duration, evs []event) ([]event, error) { | |||
| 123 | 123 | if in.wds[dir] == ev.Wd { | |
| 124 | 124 | delete(in.wds, dir) | |
| 125 | 125 | } | |
| 126 | - | evs = append(evs, event{dir: dir, lost: true}) | |
| 126 | + | evs = append(evs, event{}) | |
| 127 | 127 | continue | |
| 128 | 128 | } | |
| 129 | 129 | for len(name) > 0 && name[len(name)-1] == 0 { | |
| @@ -123,7 +123,7 @@ func (in *inotify) wait(timeout time.Duration, evs []event) ([]event, error) { | |||||
| 123 | if in.wds[dir] == ev.Wd { | 123 | if in.wds[dir] == ev.Wd { | ||
| 124 | delete(in.wds, dir) | 124 | delete(in.wds, dir) | ||
| 125 | } | 125 | } | ||
| 126 | - | evs = append(evs, event{dir: dir, lost: true}) | 126 | + | evs = append(evs, event{}) |
| 127 | continue | 127 | continue | ||
| 128 | } | 128 | } | ||
| 129 | for len(name) > 0 && name[len(name)-1] == 0 { | 129 | for len(name) > 0 && name[len(name)-1] == 0 { | ||
M watch_other.go +5 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | - | //go:build !linux && !windows && !darwin && !ios && !freebsd && !netbsd && !openbsd && !dragonfly | |
| 1 | + | //go:build !linux && !windows && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly | |
| 2 | 2 | ||
| 3 | 3 | package kvconf | |
| 4 | 4 | ||
| 5 | - | // newNative returns nil: this OS has no native backend, Watch polls. | |
| 6 | - | func newNative() (backend, error) { return nil, nil } | |
| 5 | + | import "time" | |
| 6 | + | ||
| 7 | + | // newNative polls every second: this OS has no native backend. | |
| 8 | + | func newNative() (backend, error) { return newPoller(time.Second), nil } | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | - | //go:build !linux && !windows && !darwin && !ios && !freebsd && !netbsd && !openbsd && !dragonfly | 1 | + | //go:build !linux && !windows && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly |
| 2 | 2 | ||||
| 3 | package kvconf | 3 | package kvconf | ||
| 4 | 4 | ||||
| 5 | - | // newNative returns nil: this OS has no native backend, Watch polls. | 5 | + | import "time" |
| 6 | - | func newNative() (backend, error) { return nil, nil } | 6 | + | |
| 7 | + | // newNative polls every second: this OS has no native backend. | |||
| 8 | + | func newNative() (backend, error) { return newPoller(time.Second), nil } | |||
M watch_test.go +47 -29
| @@ -44,12 +44,23 @@ func (e *watchEnv) symlink(target, rel string) { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | // fastWait is how long a change that needs no retry may take. It stays below | |
| 48 | + | // retryMissing, so a broken fast path is not hidden by the periodic look. | |
| 49 | + | const fastWait = retryMissing - 500*time.Millisecond | |
| 50 | + | ||
| 47 | 51 | func (e *watchEnv) expectChange(why string) { | |
| 52 | + | e.t.Helper() | |
| 53 | + | e.expectChangeWithin(why, fastWait) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // expectChangeWithin is expectChange for a step that legitimately relies on | |
| 57 | + | // the retry or recheck, which fires after retryMissing. | |
| 58 | + | func (e *watchEnv) expectChangeWithin(why string, d time.Duration) { | |
| 48 | 59 | e.t.Helper() | |
| 49 | 60 | select { | |
| 50 | 61 | case <-e.out: | |
| 51 | - | case <-time.After(5 * time.Second): | |
| 52 | - | e.t.Fatalf("no change reported after %s", why) | |
| 62 | + | case <-time.After(d): | |
| 63 | + | e.t.Fatalf("no change reported within %v after %s", d, why) | |
| 53 | 64 | } | |
| 54 | 65 | } | |
| 55 | 66 | ||
| @@ -76,6 +87,14 @@ type backendCase struct { | |||
| 76 | 87 | poll time.Duration | |
| 77 | 88 | } | |
| 78 | 89 | ||
| 90 | + | // slowWait is the deadline for a step that relies on the retry on this OS. | |
| 91 | + | func slowWait() time.Duration { | |
| 92 | + | if runtime.GOOS == "windows" { | |
| 93 | + | return retryMissing + 3*time.Second | |
| 94 | + | } | |
| 95 | + | return fastWait | |
| 96 | + | } | |
| 97 | + | ||
| 79 | 98 | func backends() []backendCase { | |
| 80 | 99 | return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}} | |
| 81 | 100 | } | |
| @@ -135,7 +154,8 @@ func TestWatch(t *testing.T) { | |||
| 135 | 154 | rel: "a/b/config", | |
| 136 | 155 | steps: func(e *watchEnv) { | |
| 137 | 156 | e.write("a/b/config", "a = 1\n") | |
| 138 | - | e.expectChange("the directories and file appeared") | |
| 157 | + | // Windows may only find the new directories on the retry. | |
| 158 | + | e.expectChangeWithin("the directories and file appeared", slowWait()) | |
| 139 | 159 | e.settle() | |
| 140 | 160 | e.write("a/b/config", "a = 2\n") | |
| 141 | 161 | e.expectChange("a write once the directory exists") | |
| @@ -147,10 +167,12 @@ func TestWatch(t *testing.T) { | |||
| 147 | 167 | setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") }, | |
| 148 | 168 | steps: func(e *watchEnv) { | |
| 149 | 169 | must(e.t, os.RemoveAll(filepath.Join(e.dir, "d"))) | |
| 150 | - | e.expectChange("the directory removal") | |
| 170 | + | // Windows reports a removed directory only after the watch on | |
| 171 | + | // it is closed: the recheck finds it. | |
| 172 | + | e.expectChangeWithin("the directory removal", slowWait()) | |
| 151 | 173 | e.settle() | |
| 152 | 174 | e.write("d/config", "a = 2\n") | |
| 153 | - | e.expectChange("the recreated file") | |
| 175 | + | e.expectChangeWithin("the recreated file", slowWait()) | |
| 154 | 176 | }, | |
| 155 | 177 | }, | |
| 156 | 178 | { | |
| @@ -286,9 +308,17 @@ func TestWatch(t *testing.T) { | |||
| 286 | 308 | } | |
| 287 | 309 | } | |
| 288 | 310 | ||
| 311 | + | // Every write restarts the debounce: a burst longer than the debounce is | |
| 312 | + | // reported once, after its last write. | |
| 289 | 313 | func TestWatchDebounce(t *testing.T) { | |
| 314 | + | const ( | |
| 315 | + | debounce = 200 * time.Millisecond | |
| 316 | + | writes = 10 | |
| 317 | + | gap = 50 * time.Millisecond | |
| 318 | + | ) | |
| 290 | 319 | for _, bc := range backends() { | |
| 291 | 320 | t.Run(bc.name, func(t *testing.T) { | |
| 321 | + | t.Parallel() | |
| 292 | 322 | dir := t.TempDir() | |
| 293 | 323 | path := filepath.Join(dir, "config") | |
| 294 | 324 | must(t, os.WriteFile(path, nil, 0o600)) | |
| @@ -297,15 +327,20 @@ func TestWatchDebounce(t *testing.T) { | |||
| 297 | 327 | defer cancel() | |
| 298 | 328 | ready := make(chan struct{}) | |
| 299 | 329 | go func() { | |
| 300 | - | _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 300 * time.Millisecond, Ready: ready}, out) | |
| 330 | + | _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: debounce, Ready: ready}, out) | |
| 301 | 331 | }() | |
| 302 | 332 | <-ready | |
| 303 | - | for i := range 5 { | |
| 333 | + | var lastWrite time.Time | |
| 334 | + | for i := range writes { | |
| 335 | + | lastWrite = time.Now() | |
| 304 | 336 | must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600)) | |
| 305 | - | time.Sleep(50 * time.Millisecond) | |
| 337 | + | time.Sleep(gap) | |
| 306 | 338 | } | |
| 307 | 339 | select { | |
| 308 | 340 | case <-out: | |
| 341 | + | if d := time.Since(lastWrite); d < debounce { | |
| 342 | + | t.Fatalf("reported %v after the last write, before the %v debounce", d, debounce) | |
| 343 | + | } | |
| 309 | 344 | case <-time.After(5 * time.Second): | |
| 310 | 345 | t.Fatal("no change reported") | |
| 311 | 346 | } | |
| @@ -319,10 +354,12 @@ func TestWatchDebounce(t *testing.T) { | |||
| 319 | 354 | } | |
| 320 | 355 | ||
| 321 | 356 | // An atomic save is one change: nothing is reported again when the watcher | |
| 322 | - | // looks at the files later. | |
| 357 | + | // looks at the files later. This guards Windows, where the recheck after a | |
| 358 | + | // rename would otherwise report the same save a second time. | |
| 323 | 359 | func TestWatchReportsASaveOnce(t *testing.T) { | |
| 324 | 360 | for _, bc := range backends() { | |
| 325 | 361 | t.Run(bc.name, func(t *testing.T) { | |
| 362 | + | t.Parallel() | |
| 326 | 363 | dir := t.TempDir() | |
| 327 | 364 | path := filepath.Join(dir, "config") | |
| 328 | 365 | must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600)) | |
| @@ -337,7 +374,7 @@ func TestWatchReportsASaveOnce(t *testing.T) { | |||
| 337 | 374 | must(t, WriteFile(path, []byte("a = 2\n"), 0o600)) | |
| 338 | 375 | select { | |
| 339 | 376 | case <-out: | |
| 340 | - | case <-time.After(5 * time.Second): | |
| 377 | + | case <-time.After(slowWait()): | |
| 341 | 378 | t.Fatal("no change reported") | |
| 342 | 379 | } | |
| 343 | 380 | select { | |
| @@ -348,22 +385,3 @@ func TestWatchReportsASaveOnce(t *testing.T) { | |||
| 348 | 385 | }) | |
| 349 | 386 | } | |
| 350 | 387 | } | |
| 351 | - | ||
| 352 | - | func TestWatchCancelBeforeEvents(t *testing.T) { | |
| 353 | - | for _, bc := range backends() { | |
| 354 | - | t.Run(bc.name, func(t *testing.T) { | |
| 355 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 356 | - | cancel() | |
| 357 | - | done := make(chan error, 1) | |
| 358 | - | go func() { | |
| 359 | - | done <- Watch(ctx, filepath.Join(t.TempDir(), "config"), WatchOptions{Poll: bc.poll}, make(chan struct{}, 1)) | |
| 360 | - | }() | |
| 361 | - | select { | |
| 362 | - | case err := <-done: | |
| 363 | - | must(t, err) | |
| 364 | - | case <-time.After(5 * time.Second): | |
| 365 | - | t.Fatal("Watch ignored a cancelled context") | |
| 366 | - | } | |
| 367 | - | }) | |
| 368 | - | } | |
| 369 | - | } | |
| @@ -44,12 +44,23 @@ func (e *watchEnv) symlink(target, rel string) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | // fastWait is how long a change that needs no retry may take. It stays below | |||
| 48 | + | // retryMissing, so a broken fast path is not hidden by the periodic look. | |||
| 49 | + | const fastWait = retryMissing - 500*time.Millisecond | |||
| 50 | + | ||||
| 47 | func (e *watchEnv) expectChange(why string) { | 51 | func (e *watchEnv) expectChange(why string) { | ||
| 52 | + | e.t.Helper() | |||
| 53 | + | e.expectChangeWithin(why, fastWait) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // expectChangeWithin is expectChange for a step that legitimately relies on | |||
| 57 | + | // the retry or recheck, which fires after retryMissing. | |||
| 58 | + | func (e *watchEnv) expectChangeWithin(why string, d time.Duration) { | |||
| 48 | e.t.Helper() | 59 | e.t.Helper() | ||
| 49 | select { | 60 | select { | ||
| 50 | case <-e.out: | 61 | case <-e.out: | ||
| 51 | - | case <-time.After(5 * time.Second): | 62 | + | case <-time.After(d): |
| 52 | - | e.t.Fatalf("no change reported after %s", why) | 63 | + | e.t.Fatalf("no change reported within %v after %s", d, why) |
| 53 | } | 64 | } | ||
| 54 | } | 65 | } | ||
| 55 | 66 | ||||
| @@ -76,6 +87,14 @@ type backendCase struct { | |||||
| 76 | poll time.Duration | 87 | poll time.Duration | ||
| 77 | } | 88 | } | ||
| 78 | 89 | ||||
| 90 | + | // slowWait is the deadline for a step that relies on the retry on this OS. | |||
| 91 | + | func slowWait() time.Duration { | |||
| 92 | + | if runtime.GOOS == "windows" { | |||
| 93 | + | return retryMissing + 3*time.Second | |||
| 94 | + | } | |||
| 95 | + | return fastWait | |||
| 96 | + | } | |||
| 97 | + | ||||
| 79 | func backends() []backendCase { | 98 | func backends() []backendCase { | ||
| 80 | return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}} | 99 | return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}} | ||
| 81 | } | 100 | } | ||
| @@ -135,7 +154,8 @@ func TestWatch(t *testing.T) { | |||||
| 135 | rel: "a/b/config", | 154 | rel: "a/b/config", | ||
| 136 | steps: func(e *watchEnv) { | 155 | steps: func(e *watchEnv) { | ||
| 137 | e.write("a/b/config", "a = 1\n") | 156 | e.write("a/b/config", "a = 1\n") | ||
| 138 | - | e.expectChange("the directories and file appeared") | 157 | + | // Windows may only find the new directories on the retry. |
| 158 | + | e.expectChangeWithin("the directories and file appeared", slowWait()) | |||
| 139 | e.settle() | 159 | e.settle() | ||
| 140 | e.write("a/b/config", "a = 2\n") | 160 | e.write("a/b/config", "a = 2\n") | ||
| 141 | e.expectChange("a write once the directory exists") | 161 | e.expectChange("a write once the directory exists") | ||
| @@ -147,10 +167,12 @@ func TestWatch(t *testing.T) { | |||||
| 147 | setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") }, | 167 | setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") }, | ||
| 148 | steps: func(e *watchEnv) { | 168 | steps: func(e *watchEnv) { | ||
| 149 | must(e.t, os.RemoveAll(filepath.Join(e.dir, "d"))) | 169 | must(e.t, os.RemoveAll(filepath.Join(e.dir, "d"))) | ||
| 150 | - | e.expectChange("the directory removal") | 170 | + | // Windows reports a removed directory only after the watch on |
| 171 | + | // it is closed: the recheck finds it. | |||
| 172 | + | e.expectChangeWithin("the directory removal", slowWait()) | |||
| 151 | e.settle() | 173 | e.settle() | ||
| 152 | e.write("d/config", "a = 2\n") | 174 | e.write("d/config", "a = 2\n") | ||
| 153 | - | e.expectChange("the recreated file") | 175 | + | e.expectChangeWithin("the recreated file", slowWait()) |
| 154 | }, | 176 | }, | ||
| 155 | }, | 177 | }, | ||
| 156 | { | 178 | { | ||
| @@ -286,9 +308,17 @@ func TestWatch(t *testing.T) { | |||||
| 286 | } | 308 | } | ||
| 287 | } | 309 | } | ||
| 288 | 310 | ||||
| 311 | + | // Every write restarts the debounce: a burst longer than the debounce is | |||
| 312 | + | // reported once, after its last write. | |||
| 289 | func TestWatchDebounce(t *testing.T) { | 313 | func TestWatchDebounce(t *testing.T) { | ||
| 314 | + | const ( | |||
| 315 | + | debounce = 200 * time.Millisecond | |||
| 316 | + | writes = 10 | |||
| 317 | + | gap = 50 * time.Millisecond | |||
| 318 | + | ) | |||
| 290 | for _, bc := range backends() { | 319 | for _, bc := range backends() { | ||
| 291 | t.Run(bc.name, func(t *testing.T) { | 320 | t.Run(bc.name, func(t *testing.T) { | ||
| 321 | + | t.Parallel() | |||
| 292 | dir := t.TempDir() | 322 | dir := t.TempDir() | ||
| 293 | path := filepath.Join(dir, "config") | 323 | path := filepath.Join(dir, "config") | ||
| 294 | must(t, os.WriteFile(path, nil, 0o600)) | 324 | must(t, os.WriteFile(path, nil, 0o600)) | ||
| @@ -297,15 +327,20 @@ func TestWatchDebounce(t *testing.T) { | |||||
| 297 | defer cancel() | 327 | defer cancel() | ||
| 298 | ready := make(chan struct{}) | 328 | ready := make(chan struct{}) | ||
| 299 | go func() { | 329 | go func() { | ||
| 300 | - | _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 300 * time.Millisecond, Ready: ready}, out) | 330 | + | _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: debounce, Ready: ready}, out) |
| 301 | }() | 331 | }() | ||
| 302 | <-ready | 332 | <-ready | ||
| 303 | - | for i := range 5 { | 333 | + | var lastWrite time.Time |
| 334 | + | for i := range writes { | |||
| 335 | + | lastWrite = time.Now() | |||
| 304 | must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600)) | 336 | must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600)) | ||
| 305 | - | time.Sleep(50 * time.Millisecond) | 337 | + | time.Sleep(gap) |
| 306 | } | 338 | } | ||
| 307 | select { | 339 | select { | ||
| 308 | case <-out: | 340 | case <-out: | ||
| 341 | + | if d := time.Since(lastWrite); d < debounce { | |||
| 342 | + | t.Fatalf("reported %v after the last write, before the %v debounce", d, debounce) | |||
| 343 | + | } | |||
| 309 | case <-time.After(5 * time.Second): | 344 | case <-time.After(5 * time.Second): | ||
| 310 | t.Fatal("no change reported") | 345 | t.Fatal("no change reported") | ||
| 311 | } | 346 | } | ||
| @@ -319,10 +354,12 @@ func TestWatchDebounce(t *testing.T) { | |||||
| 319 | } | 354 | } | ||
| 320 | 355 | ||||
| 321 | // An atomic save is one change: nothing is reported again when the watcher | 356 | // An atomic save is one change: nothing is reported again when the watcher | ||
| 322 | - | // looks at the files later. | 357 | + | // looks at the files later. This guards Windows, where the recheck after a |
| 358 | + | // rename would otherwise report the same save a second time. | |||
| 323 | func TestWatchReportsASaveOnce(t *testing.T) { | 359 | func TestWatchReportsASaveOnce(t *testing.T) { | ||
| 324 | for _, bc := range backends() { | 360 | for _, bc := range backends() { | ||
| 325 | t.Run(bc.name, func(t *testing.T) { | 361 | t.Run(bc.name, func(t *testing.T) { | ||
| 362 | + | t.Parallel() | |||
| 326 | dir := t.TempDir() | 363 | dir := t.TempDir() | ||
| 327 | path := filepath.Join(dir, "config") | 364 | path := filepath.Join(dir, "config") | ||
| 328 | must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600)) | 365 | must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600)) | ||
| @@ -337,7 +374,7 @@ func TestWatchReportsASaveOnce(t *testing.T) { | |||||
| 337 | must(t, WriteFile(path, []byte("a = 2\n"), 0o600)) | 374 | must(t, WriteFile(path, []byte("a = 2\n"), 0o600)) | ||
| 338 | select { | 375 | select { | ||
| 339 | case <-out: | 376 | case <-out: | ||
| 340 | - | case <-time.After(5 * time.Second): | 377 | + | case <-time.After(slowWait()): |
| 341 | t.Fatal("no change reported") | 378 | t.Fatal("no change reported") | ||
| 342 | } | 379 | } | ||
| 343 | select { | 380 | select { | ||
| @@ -348,22 +385,3 @@ func TestWatchReportsASaveOnce(t *testing.T) { | |||||
| 348 | }) | 385 | }) | ||
| 349 | } | 386 | } | ||
| 350 | } | 387 | } | ||
| 351 | - | ||||
| 352 | - | func TestWatchCancelBeforeEvents(t *testing.T) { | |||
| 353 | - | for _, bc := range backends() { | |||
| 354 | - | t.Run(bc.name, func(t *testing.T) { | |||
| 355 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 356 | - | cancel() | |||
| 357 | - | done := make(chan error, 1) | |||
| 358 | - | go func() { | |||
| 359 | - | done <- Watch(ctx, filepath.Join(t.TempDir(), "config"), WatchOptions{Poll: bc.poll}, make(chan struct{}, 1)) | |||
| 360 | - | }() | |||
| 361 | - | select { | |||
| 362 | - | case err := <-done: | |||
| 363 | - | must(t, err) | |||
| 364 | - | case <-time.After(5 * time.Second): | |||
| 365 | - | t.Fatal("Watch ignored a cancelled context") | |||
| 366 | - | } | |||
| 367 | - | }) | |||
| 368 | - | } | |||
| 369 | - | } | |||
M watch_windows.go +4 -7
| @@ -8,8 +8,8 @@ import ( | |||
| 8 | 8 | "unsafe" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | // The standard syscall package lacks these, or declares them with a wrong | |
| 12 | - | // signature on 64-bit Windows; they are loaded from kernel32 directly. | |
| 11 | + | // These functions are missing from package syscall: they are loaded from | |
| 12 | + | // kernel32 directly. | |
| 13 | 13 | var ( | |
| 14 | 14 | kernel32 = syscall.NewLazyDLL("kernel32.dll") | |
| 15 | 15 | procCreateEventW = kernel32.NewProc("CreateEventW") | |
| @@ -58,9 +58,6 @@ func createEvent() (syscall.Handle, error) { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | func newNative() (backend, error) { | |
| 61 | - | if err := procWaitForMultipleObjects.Find(); err != nil { | |
| 62 | - | return nil, nil // poll instead | |
| 63 | - | } | |
| 64 | 61 | ev, err := createEvent() | |
| 65 | 62 | if err != nil { | |
| 66 | 63 | return nil, err | |
| @@ -186,7 +183,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) { | |||
| 186 | 183 | return evs, nil | |
| 187 | 184 | } | |
| 188 | 185 | // The directory was removed or became unreadable. | |
| 189 | - | return append(evs, event{dir: d.dir, lost: true}), nil | |
| 186 | + | return append(evs, event{}), nil | |
| 190 | 187 | } | |
| 191 | 188 | if n == 0 { | |
| 192 | 189 | evs = append(evs, event{dir: d.dir}) // the buffer overflowed | |
| @@ -203,7 +200,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) { | |||
| 203 | 200 | off += info.NextEntryOffset | |
| 204 | 201 | } | |
| 205 | 202 | if err := d.read(); err != nil { | |
| 206 | - | evs = append(evs, event{dir: d.dir, lost: true}) | |
| 203 | + | evs = append(evs, event{}) | |
| 207 | 204 | } | |
| 208 | 205 | return evs, nil | |
| 209 | 206 | } | |
| @@ -8,8 +8,8 @@ import ( | |||||
| 8 | "unsafe" | 8 | "unsafe" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | // The standard syscall package lacks these, or declares them with a wrong | 11 | + | // These functions are missing from package syscall: they are loaded from |
| 12 | - | // signature on 64-bit Windows; they are loaded from kernel32 directly. | 12 | + | // kernel32 directly. |
| 13 | var ( | 13 | var ( | ||
| 14 | kernel32 = syscall.NewLazyDLL("kernel32.dll") | 14 | kernel32 = syscall.NewLazyDLL("kernel32.dll") | ||
| 15 | procCreateEventW = kernel32.NewProc("CreateEventW") | 15 | procCreateEventW = kernel32.NewProc("CreateEventW") | ||
| @@ -58,9 +58,6 @@ func createEvent() (syscall.Handle, error) { | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | func newNative() (backend, error) { | 60 | func newNative() (backend, error) { | ||
| 61 | - | if err := procWaitForMultipleObjects.Find(); err != nil { | |||
| 62 | - | return nil, nil // poll instead | |||
| 63 | - | } | |||
| 64 | ev, err := createEvent() | 61 | ev, err := createEvent() | ||
| 65 | if err != nil { | 62 | if err != nil { | ||
| 66 | return nil, err | 63 | return nil, err | ||
| @@ -186,7 +183,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) { | |||||
| 186 | return evs, nil | 183 | return evs, nil | ||
| 187 | } | 184 | } | ||
| 188 | // The directory was removed or became unreadable. | 185 | // The directory was removed or became unreadable. | ||
| 189 | - | return append(evs, event{dir: d.dir, lost: true}), nil | 186 | + | return append(evs, event{}), nil |
| 190 | } | 187 | } | ||
| 191 | if n == 0 { | 188 | if n == 0 { | ||
| 192 | evs = append(evs, event{dir: d.dir}) // the buffer overflowed | 189 | evs = append(evs, event{dir: d.dir}) // the buffer overflowed | ||
| @@ -203,7 +200,7 @@ func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) { | |||||
| 203 | off += info.NextEntryOffset | 200 | off += info.NextEntryOffset | ||
| 204 | } | 201 | } | ||
| 205 | if err := d.read(); err != nil { | 202 | if err := d.read(); err != nil { | ||
| 206 | - | evs = append(evs, event{dir: d.dir, lost: true}) | 203 | + | evs = append(evs, event{}) |
| 207 | } | 204 | } | ||
| 208 | return evs, nil | 205 | return evs, nil | ||
| 209 | } | 206 | } | ||
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) | ||