Merge pull request 'refactor(config): load the config with kvconf' (#4) from refactor/kvconf into main
Reviewed-on: https://git.bnema.dev/bnema/nefercap/pulls/4
4 files · +107 -98
M go.mod +1 -0
| @@ -4,6 +4,7 @@ go 1.27 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | + | github.com/bnema/kvconf v0.1.2 | |
| 7 | 8 | github.com/bnema/neferclient v0.3.0 | |
| 8 | 9 | github.com/bnema/nefergui v0.7.2 | |
| 9 | 10 | github.com/bnema/purego-libwayland v0.8.0 | |
| @@ -4,6 +4,7 @@ go 1.27 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/go-wayland-bindings v0.1.0 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 7 | + | github.com/bnema/kvconf v0.1.2 | |||
| 7 | github.com/bnema/neferclient v0.3.0 | 8 | github.com/bnema/neferclient v0.3.0 | ||
| 8 | github.com/bnema/nefergui v0.7.2 | 9 | github.com/bnema/nefergui v0.7.2 | ||
| 9 | github.com/bnema/purego-libwayland v0.8.0 | 10 | github.com/bnema/purego-libwayland v0.8.0 | ||
M go.sum +2 -0
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk= | |
| 2 | 2 | github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA= | |
| 3 | + | github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70= | |
| 4 | + | github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU= | |
| 3 | 5 | github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90= | |
| 4 | 6 | github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw= | |
| 5 | 7 | github.com/bnema/nefergui v0.7.2 h1:9gnYhVlMxpBp+cVE+9Uursc/HwPXe/Li16+m4HL/gkU= | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk= | 1 | github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk= | ||
| 2 | github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA= | 2 | github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA= | ||
| 3 | + | github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70= | |||
| 4 | + | github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU= | |||
| 3 | github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90= | 5 | github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90= | ||
| 4 | github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw= | 6 | github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw= | ||
| 5 | github.com/bnema/nefergui v0.7.2 h1:9gnYhVlMxpBp+cVE+9Uursc/HwPXe/Li16+m4HL/gkU= | 7 | github.com/bnema/nefergui v0.7.2 h1:9gnYhVlMxpBp+cVE+9Uursc/HwPXe/Li16+m4HL/gkU= | ||
M internal/adapters/config/config.go +93 -98
| @@ -4,12 +4,11 @@ package config | |||
| 4 | 4 | import ( | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | - | "io" | |
| 8 | 7 | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 8 | "slices" | |
| 11 | 9 | "strings" | |
| 12 | - | "syscall" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/kvconf" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | const ( | |
| @@ -38,116 +37,112 @@ type Settings struct { | |||
| 38 | 37 | Output ScreenshotOutput | |
| 39 | 38 | } | |
| 40 | 39 | ||
| 41 | - | const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir" | |
| 40 | + | var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"} | |
| 41 | + | ||
| 42 | + | var knownKeys = strings.Join(keys[:], ", ") | |
| 43 | + | ||
| 44 | + | // A "#" only starts a comment at the start of a line, and values are never | |
| 45 | + | // unquoted. Every problem is collected, so Load can fail on the first one in | |
| 46 | + | // line order: a line without "=", an unknown key, a duplicate or a bad value. | |
| 47 | + | var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes} | |
| 48 | + | ||
| 49 | + | // defaults are the settings of an absent file or key. | |
| 50 | + | var defaults = Settings{Grid: true, Freeze: true, Output: OutputFile} | |
| 51 | + | ||
| 52 | + | // file is the config file as kvconf decodes it, before it becomes Settings. | |
| 53 | + | type file struct { | |
| 54 | + | Grid onOff `kv:"selector.grid"` | |
| 55 | + | Freeze onOff `kv:"selector.freeze"` | |
| 56 | + | ShotDir dirPath `kv:"screenshot.dir"` | |
| 57 | + | VideoDir dirPath `kv:"video.dir"` | |
| 58 | + | Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"` | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1). | |
| 62 | + | type onOff bool | |
| 63 | + | ||
| 64 | + | func (o *onOff) UnmarshalText(text []byte) error { | |
| 65 | + | switch string(text) { | |
| 66 | + | case "on": | |
| 67 | + | *o = true | |
| 68 | + | case "off": | |
| 69 | + | *o = false | |
| 70 | + | default: | |
| 71 | + | return kvconf.ValueError("must be on or off") | |
| 72 | + | } | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath. | |
| 77 | + | type dirPath string | |
| 78 | + | ||
| 79 | + | func (d *dirPath) UnmarshalText(text []byte) error { | |
| 80 | + | if len(text) > maxPath { | |
| 81 | + | return kvconf.ValueError(fmt.Sprintf("must be at most %d bytes", maxPath)) | |
| 82 | + | } | |
| 83 | + | if string(text) == "~" { | |
| 84 | + | return kvconf.ValueError("must be an absolute path or start with ~/") | |
| 85 | + | } | |
| 86 | + | var p kvconf.Path | |
| 87 | + | if err := p.UnmarshalText(text); err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | *d = dirPath(p) | |
| 91 | + | return nil | |
| 92 | + | } | |
| 42 | 93 | ||
| 43 | 94 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | |
| 44 | 95 | // An absent file or setting enables the full monitor grid and the frozen | |
| 45 | 96 | // screenshot selector. Invalid settings fail explicitly rather than silently | |
| 46 | 97 | // changing the requested appearance. | |
| 47 | 98 | func Load() (Settings, error) { | |
| 48 | - | settings := Settings{Grid: true, Freeze: true, Output: OutputFile} | |
| 49 | - | root := os.Getenv("XDG_CONFIG_HOME") | |
| 50 | - | if !filepath.IsAbs(root) { | |
| 51 | - | home, err := os.UserHomeDir() | |
| 52 | - | if err != nil { | |
| 53 | - | return settings, nil | |
| 54 | - | } | |
| 55 | - | root = filepath.Join(home, ".config") | |
| 56 | - | } | |
| 57 | - | path := filepath.Join(root, "nefercap", "config") | |
| 58 | - | // Nonblocking open also protects against a FIFO substituted before Stat. | |
| 59 | - | file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 60 | - | if errors.Is(err, os.ErrNotExist) { | |
| 61 | - | return settings, nil | |
| 62 | - | } | |
| 99 | + | path, err := kvconf.DefaultPath("nefercap", "config") | |
| 63 | 100 | if err != nil { | |
| 64 | - | return Settings{}, fmt.Errorf("open config: %w", err) | |
| 101 | + | return defaults, nil | |
| 65 | 102 | } | |
| 66 | - | defer file.Close() | |
| 67 | - | info, err := file.Stat() | |
| 68 | - | if err != nil { | |
| 69 | - | return Settings{}, fmt.Errorf("stat config: %w", err) | |
| 70 | - | } | |
| 71 | - | if !info.Mode().IsRegular() { | |
| 103 | + | data, err := kvconf.ReadFile(path, options) | |
| 104 | + | switch { | |
| 105 | + | case errors.Is(err, os.ErrNotExist): | |
| 106 | + | return defaults, nil | |
| 107 | + | case errors.Is(err, kvconf.ErrTooLarge): | |
| 108 | + | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | |
| 109 | + | case errors.Is(err, kvconf.ErrNotRegular): | |
| 72 | 110 | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | |
| 111 | + | case err != nil: | |
| 112 | + | return Settings{}, fmt.Errorf("read config: %w", err) | |
| 73 | 113 | } | |
| 74 | - | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | |
| 114 | + | entries, syntax, err := kvconf.ParseBytes(data, options) | |
| 75 | 115 | if err != nil { | |
| 76 | - | return Settings{}, fmt.Errorf("read config %s: %w", path, err) | |
| 77 | - | } | |
| 78 | - | if len(data) > maxBytes { | |
| 79 | - | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | |
| 116 | + | return Settings{}, fmt.Errorf("config %s: %w", path, err) | |
| 80 | 117 | } | |
| 81 | - | var seen [len(keys)]bool | |
| 82 | - | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | |
| 83 | - | line := strings.TrimSpace(raw) | |
| 84 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 85 | - | continue | |
| 86 | - | } | |
| 87 | - | key, value, ok := strings.Cut(line, "=") | |
| 88 | - | key, value = strings.TrimSpace(key), strings.TrimSpace(value) | |
| 89 | - | slot := -1 | |
| 90 | - | if ok { | |
| 91 | - | slot = slices.Index(keys[:], key) | |
| 92 | - | } | |
| 93 | - | if slot < 0 || seen[slot] { | |
| 94 | - | return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys) | |
| 95 | - | } | |
| 96 | - | seen[slot] = true | |
| 97 | - | if err := apply(&settings, key, value); err != nil { | |
| 98 | - | return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err) | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | return settings, nil | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"} | |
| 105 | - | ||
| 106 | - | func apply(s *Settings, key, value string) (err error) { | |
| 107 | - | switch key { | |
| 108 | - | case "selector.grid": | |
| 109 | - | s.Grid, err = onOff(key, value) | |
| 110 | - | case "selector.freeze": | |
| 111 | - | s.Freeze, err = onOff(key, value) | |
| 112 | - | case "screenshot.output": | |
| 113 | - | switch out := ScreenshotOutput(value); out { | |
| 114 | - | case OutputFile, OutputFileClipboard, OutputClipboard: | |
| 115 | - | s.Output = out | |
| 116 | - | default: | |
| 117 | - | return errors.New("screenshot.output must be file, file+clipboard or clipboard") | |
| 118 | - | } | |
| 119 | - | case "screenshot.dir": | |
| 120 | - | s.ScreenshotDir, err = dir(key, value) | |
| 121 | - | case "video.dir": | |
| 122 | - | s.VideoDir, err = dir(key, value) | |
| 118 | + | f := file{Grid: onOff(defaults.Grid), Freeze: onOff(defaults.Freeze), Output: defaults.Output} | |
| 119 | + | values, err := kvconf.Decode(entries, &f, options) | |
| 120 | + | if err != nil { | |
| 121 | + | return Settings{}, fmt.Errorf("config %s: %w", path, err) | |
| 123 | 122 | } | |
| 124 | - | return err | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | func onOff(key, value string) (bool, error) { | |
| 128 | - | switch value { | |
| 129 | - | case "on": | |
| 130 | - | return true, nil | |
| 131 | - | case "off": | |
| 132 | - | return false, nil | |
| 123 | + | // Fail on the first problem in line order. A line without "=" or a | |
| 124 | + | // duplicate (from ParseBytes) wins a tie, as a repeated key is wrong | |
| 125 | + | // whatever its value. | |
| 126 | + | var first *kvconf.Warning | |
| 127 | + | generic := false | |
| 128 | + | if len(syntax) > 0 { | |
| 129 | + | first, generic = &syntax[0], true | |
| 133 | 130 | } | |
| 134 | - | return false, fmt.Errorf("%s must be on or off", key) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // dir accepts an absolute path or one starting with ~/ (the home directory). | |
| 138 | - | func dir(key, value string) (string, error) { | |
| 139 | - | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | |
| 140 | - | return "", fmt.Errorf("%s must be a non-empty path", key) | |
| 131 | + | if len(values) > 0 && (first == nil || values[0].Line < first.Line) { | |
| 132 | + | first, generic = &values[0], !slices.Contains(keys[:], values[0].Key) | |
| 141 | 133 | } | |
| 142 | - | if strings.HasPrefix(value, "~/") { | |
| 143 | - | home, err := os.UserHomeDir() | |
| 144 | - | if err != nil || !filepath.IsAbs(home) { | |
| 145 | - | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | |
| 134 | + | if first != nil { | |
| 135 | + | msg := first.Key + " " + first.Msg | |
| 136 | + | if generic { | |
| 137 | + | msg = "expected each of " + knownKeys + " at most once as key = value" | |
| 146 | 138 | } | |
| 147 | - | value = filepath.Join(home, value[1:]) | |
| 148 | - | } | |
| 149 | - | if !filepath.IsAbs(value) { | |
| 150 | - | return "", fmt.Errorf("%s must be an absolute path or start with ~/", key) | |
| 139 | + | return Settings{}, fmt.Errorf("config %s line %d: %s", path, first.Line, msg) | |
| 151 | 140 | } | |
| 152 | - | return filepath.Clean(value), nil | |
| 141 | + | return Settings{ | |
| 142 | + | Grid: bool(f.Grid), | |
| 143 | + | Freeze: bool(f.Freeze), | |
| 144 | + | ScreenshotDir: string(f.ShotDir), | |
| 145 | + | VideoDir: string(f.VideoDir), | |
| 146 | + | Output: f.Output, | |
| 147 | + | }, nil | |
| 153 | 148 | } | |
| @@ -4,12 +4,11 @@ package config | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | "io" | |||
| 8 | "os" | 7 | "os" | ||
| 9 | - | "path/filepath" | |||
| 10 | "slices" | 8 | "slices" | ||
| 11 | "strings" | 9 | "strings" | ||
| 12 | - | "syscall" | 10 | + | |
| 11 | + | "github.com/bnema/kvconf" | |||
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | const ( | 14 | const ( | ||
| @@ -38,116 +37,112 @@ type Settings struct { | |||||
| 38 | Output ScreenshotOutput | 37 | Output ScreenshotOutput | ||
| 39 | } | 38 | } | ||
| 40 | 39 | ||||
| 41 | - | const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir" | 40 | + | var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"} |
| 41 | + | ||||
| 42 | + | var knownKeys = strings.Join(keys[:], ", ") | |||
| 43 | + | ||||
| 44 | + | // A "#" only starts a comment at the start of a line, and values are never | |||
| 45 | + | // unquoted. Every problem is collected, so Load can fail on the first one in | |||
| 46 | + | // line order: a line without "=", an unknown key, a duplicate or a bad value. | |||
| 47 | + | var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes} | |||
| 48 | + | ||||
| 49 | + | // defaults are the settings of an absent file or key. | |||
| 50 | + | var defaults = Settings{Grid: true, Freeze: true, Output: OutputFile} | |||
| 51 | + | ||||
| 52 | + | // file is the config file as kvconf decodes it, before it becomes Settings. | |||
| 53 | + | type file struct { | |||
| 54 | + | Grid onOff `kv:"selector.grid"` | |||
| 55 | + | Freeze onOff `kv:"selector.freeze"` | |||
| 56 | + | ShotDir dirPath `kv:"screenshot.dir"` | |||
| 57 | + | VideoDir dirPath `kv:"video.dir"` | |||
| 58 | + | Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"` | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1). | |||
| 62 | + | type onOff bool | |||
| 63 | + | ||||
| 64 | + | func (o *onOff) UnmarshalText(text []byte) error { | |||
| 65 | + | switch string(text) { | |||
| 66 | + | case "on": | |||
| 67 | + | *o = true | |||
| 68 | + | case "off": | |||
| 69 | + | *o = false | |||
| 70 | + | default: | |||
| 71 | + | return kvconf.ValueError("must be on or off") | |||
| 72 | + | } | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath. | |||
| 77 | + | type dirPath string | |||
| 78 | + | ||||
| 79 | + | func (d *dirPath) UnmarshalText(text []byte) error { | |||
| 80 | + | if len(text) > maxPath { | |||
| 81 | + | return kvconf.ValueError(fmt.Sprintf("must be at most %d bytes", maxPath)) | |||
| 82 | + | } | |||
| 83 | + | if string(text) == "~" { | |||
| 84 | + | return kvconf.ValueError("must be an absolute path or start with ~/") | |||
| 85 | + | } | |||
| 86 | + | var p kvconf.Path | |||
| 87 | + | if err := p.UnmarshalText(text); err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | *d = dirPath(p) | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 42 | 93 | ||||
| 43 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | 94 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | ||
| 44 | // An absent file or setting enables the full monitor grid and the frozen | 95 | // An absent file or setting enables the full monitor grid and the frozen | ||
| 45 | // screenshot selector. Invalid settings fail explicitly rather than silently | 96 | // screenshot selector. Invalid settings fail explicitly rather than silently | ||
| 46 | // changing the requested appearance. | 97 | // changing the requested appearance. | ||
| 47 | func Load() (Settings, error) { | 98 | func Load() (Settings, error) { | ||
| 48 | - | settings := Settings{Grid: true, Freeze: true, Output: OutputFile} | 99 | + | path, err := kvconf.DefaultPath("nefercap", "config") |
| 49 | - | root := os.Getenv("XDG_CONFIG_HOME") | |||
| 50 | - | if !filepath.IsAbs(root) { | |||
| 51 | - | home, err := os.UserHomeDir() | |||
| 52 | - | if err != nil { | |||
| 53 | - | return settings, nil | |||
| 54 | - | } | |||
| 55 | - | root = filepath.Join(home, ".config") | |||
| 56 | - | } | |||
| 57 | - | path := filepath.Join(root, "nefercap", "config") | |||
| 58 | - | // Nonblocking open also protects against a FIFO substituted before Stat. | |||
| 59 | - | file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |||
| 60 | - | if errors.Is(err, os.ErrNotExist) { | |||
| 61 | - | return settings, nil | |||
| 62 | - | } | |||
| 63 | if err != nil { | 100 | if err != nil { | ||
| 64 | - | return Settings{}, fmt.Errorf("open config: %w", err) | 101 | + | return defaults, nil |
| 65 | } | 102 | } | ||
| 66 | - | defer file.Close() | 103 | + | data, err := kvconf.ReadFile(path, options) |
| 67 | - | info, err := file.Stat() | 104 | + | switch { |
| 68 | - | if err != nil { | 105 | + | case errors.Is(err, os.ErrNotExist): |
| 69 | - | return Settings{}, fmt.Errorf("stat config: %w", err) | 106 | + | return defaults, nil |
| 70 | - | } | 107 | + | case errors.Is(err, kvconf.ErrTooLarge): |
| 71 | - | if !info.Mode().IsRegular() { | 108 | + | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) |
| 109 | + | case errors.Is(err, kvconf.ErrNotRegular): | |||
| 72 | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | 110 | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | ||
| 111 | + | case err != nil: | |||
| 112 | + | return Settings{}, fmt.Errorf("read config: %w", err) | |||
| 73 | } | 113 | } | ||
| 74 | - | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | 114 | + | entries, syntax, err := kvconf.ParseBytes(data, options) |
| 75 | if err != nil { | 115 | if err != nil { | ||
| 76 | - | return Settings{}, fmt.Errorf("read config %s: %w", path, err) | 116 | + | return Settings{}, fmt.Errorf("config %s: %w", path, err) |
| 77 | - | } | |||
| 78 | - | if len(data) > maxBytes { | |||
| 79 | - | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | |||
| 80 | } | 117 | } | ||
| 81 | - | var seen [len(keys)]bool | 118 | + | f := file{Grid: onOff(defaults.Grid), Freeze: onOff(defaults.Freeze), Output: defaults.Output} |
| 82 | - | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | 119 | + | values, err := kvconf.Decode(entries, &f, options) |
| 83 | - | line := strings.TrimSpace(raw) | 120 | + | if err != nil { |
| 84 | - | if line == "" || strings.HasPrefix(line, "#") { | 121 | + | return Settings{}, fmt.Errorf("config %s: %w", path, err) |
| 85 | - | continue | |||
| 86 | - | } | |||
| 87 | - | key, value, ok := strings.Cut(line, "=") | |||
| 88 | - | key, value = strings.TrimSpace(key), strings.TrimSpace(value) | |||
| 89 | - | slot := -1 | |||
| 90 | - | if ok { | |||
| 91 | - | slot = slices.Index(keys[:], key) | |||
| 92 | - | } | |||
| 93 | - | if slot < 0 || seen[slot] { | |||
| 94 | - | return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys) | |||
| 95 | - | } | |||
| 96 | - | seen[slot] = true | |||
| 97 | - | if err := apply(&settings, key, value); err != nil { | |||
| 98 | - | return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err) | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | - | return settings, nil | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"} | |||
| 105 | - | ||||
| 106 | - | func apply(s *Settings, key, value string) (err error) { | |||
| 107 | - | switch key { | |||
| 108 | - | case "selector.grid": | |||
| 109 | - | s.Grid, err = onOff(key, value) | |||
| 110 | - | case "selector.freeze": | |||
| 111 | - | s.Freeze, err = onOff(key, value) | |||
| 112 | - | case "screenshot.output": | |||
| 113 | - | switch out := ScreenshotOutput(value); out { | |||
| 114 | - | case OutputFile, OutputFileClipboard, OutputClipboard: | |||
| 115 | - | s.Output = out | |||
| 116 | - | default: | |||
| 117 | - | return errors.New("screenshot.output must be file, file+clipboard or clipboard") | |||
| 118 | - | } | |||
| 119 | - | case "screenshot.dir": | |||
| 120 | - | s.ScreenshotDir, err = dir(key, value) | |||
| 121 | - | case "video.dir": | |||
| 122 | - | s.VideoDir, err = dir(key, value) | |||
| 123 | } | 122 | } | ||
| 124 | - | return err | 123 | + | // Fail on the first problem in line order. A line without "=" or a |
| 125 | - | } | 124 | + | // duplicate (from ParseBytes) wins a tie, as a repeated key is wrong |
| 126 | - | 125 | + | // whatever its value. | |
| 127 | - | func onOff(key, value string) (bool, error) { | 126 | + | var first *kvconf.Warning |
| 128 | - | switch value { | 127 | + | generic := false |
| 129 | - | case "on": | 128 | + | if len(syntax) > 0 { |
| 130 | - | return true, nil | 129 | + | first, generic = &syntax[0], true |
| 131 | - | case "off": | |||
| 132 | - | return false, nil | |||
| 133 | } | 130 | } | ||
| 134 | - | return false, fmt.Errorf("%s must be on or off", key) | 131 | + | if len(values) > 0 && (first == nil || values[0].Line < first.Line) { |
| 135 | - | } | 132 | + | first, generic = &values[0], !slices.Contains(keys[:], values[0].Key) |
| 136 | - | ||||
| 137 | - | // dir accepts an absolute path or one starting with ~/ (the home directory). | |||
| 138 | - | func dir(key, value string) (string, error) { | |||
| 139 | - | if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 { | |||
| 140 | - | return "", fmt.Errorf("%s must be a non-empty path", key) | |||
| 141 | } | 133 | } | ||
| 142 | - | if strings.HasPrefix(value, "~/") { | 134 | + | if first != nil { |
| 143 | - | home, err := os.UserHomeDir() | 135 | + | msg := first.Key + " " + first.Msg |
| 144 | - | if err != nil || !filepath.IsAbs(home) { | 136 | + | if generic { |
| 145 | - | return "", fmt.Errorf("%s: no absolute home directory for ~", key) | 137 | + | msg = "expected each of " + knownKeys + " at most once as key = value" |
| 146 | } | 138 | } | ||
| 147 | - | value = filepath.Join(home, value[1:]) | 139 | + | return Settings{}, fmt.Errorf("config %s line %d: %s", path, first.Line, msg) |
| 148 | - | } | |||
| 149 | - | if !filepath.IsAbs(value) { | |||
| 150 | - | return "", fmt.Errorf("%s must be an absolute path or start with ~/", key) | |||
| 151 | } | 140 | } | ||
| 152 | - | return filepath.Clean(value), nil | 141 | + | return Settings{ |
| 142 | + | Grid: bool(f.Grid), | |||
| 143 | + | Freeze: bool(f.Freeze), | |||
| 144 | + | ScreenshotDir: string(f.ShotDir), | |||
| 145 | + | VideoDir: string(f.VideoDir), | |||
| 146 | + | Output: f.Output, | |||
| 147 | + | }, nil | |||
| 153 | } | 148 | } | ||
M internal/adapters/config/config_test.go +11 -0
| @@ -182,6 +182,17 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) { | |||
| 182 | 182 | {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"}, | |
| 183 | 183 | {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"}, | |
| 184 | 184 | {"video.output = file", "line 1: expected each of"}, | |
| 185 | + | // The first problem in line order is reported. | |
| 186 | + | {"selector.grid = maybe\nselector.grid = on", "line 1: selector.grid must be on or off"}, | |
| 187 | + | {"selector.grid = maybe\nnoequals", "line 1: selector.grid must be on or off"}, | |
| 188 | + | {"selector.grid = maybe\n= x", "line 1: selector.grid must be on or off"}, | |
| 189 | + | {"selector.grid = maybe\nscreenshot.dir = /a\nscreenshot.dir = /b", "line 1: selector.grid must be on or off"}, | |
| 190 | + | {"foo = 1\nbar", "line 1: expected each of"}, | |
| 191 | + | {"selector.grid = on\nselector.grid = maybe", "line 2: expected each of"}, | |
| 192 | + | // '#' only starts a comment at the start of a line; values stay raw. | |
| 193 | + | {"selector.grid = on # c", "line 1: selector.grid must be on or off"}, | |
| 194 | + | {"screenshot.output = \"file\"", "line 1: screenshot.output must be"}, | |
| 195 | + | {"# caf\xe9\nselector.grid = on", "not valid UTF-8"}, | |
| 185 | 196 | } { | |
| 186 | 197 | t.Run(tc.text, func(t *testing.T) { | |
| 187 | 198 | t.Setenv("HOME", t.TempDir()) | |
| @@ -182,6 +182,17 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) { | |||||
| 182 | {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"}, | 182 | {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"}, | ||
| 183 | {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"}, | 183 | {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"}, | ||
| 184 | {"video.output = file", "line 1: expected each of"}, | 184 | {"video.output = file", "line 1: expected each of"}, | ||
| 185 | + | // The first problem in line order is reported. | |||
| 186 | + | {"selector.grid = maybe\nselector.grid = on", "line 1: selector.grid must be on or off"}, | |||
| 187 | + | {"selector.grid = maybe\nnoequals", "line 1: selector.grid must be on or off"}, | |||
| 188 | + | {"selector.grid = maybe\n= x", "line 1: selector.grid must be on or off"}, | |||
| 189 | + | {"selector.grid = maybe\nscreenshot.dir = /a\nscreenshot.dir = /b", "line 1: selector.grid must be on or off"}, | |||
| 190 | + | {"foo = 1\nbar", "line 1: expected each of"}, | |||
| 191 | + | {"selector.grid = on\nselector.grid = maybe", "line 2: expected each of"}, | |||
| 192 | + | // '#' only starts a comment at the start of a line; values stay raw. | |||
| 193 | + | {"selector.grid = on # c", "line 1: selector.grid must be on or off"}, | |||
| 194 | + | {"screenshot.output = \"file\"", "line 1: screenshot.output must be"}, | |||
| 195 | + | {"# caf\xe9\nselector.grid = on", "not valid UTF-8"}, | |||
| 185 | } { | 196 | } { | ||
| 186 | t.Run(tc.text, func(t *testing.T) { | 197 | t.Run(tc.text, func(t *testing.T) { | ||
| 187 | t.Setenv("HOME", t.TempDir()) | 198 | t.Setenv("HOME", t.TempDir()) | ||