refactor(config): load the config with kvconf
- commit
- 1894f55279a56d468fac8ce8fe647437dc70ba0d
- author
- brice · · 2026-10-10 03:02 UTC
- parent
- bd04964
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +70 -101
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.0 | |
| 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.0 | |||
| 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 internal/adapters/config/config.go +69 -101
| @@ -4,12 +4,10 @@ package config | |||
| 4 | 4 | import ( | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | - | "io" | |
| 8 | 7 | "os" | |
| 9 | - | "path/filepath" | |
| 10 | - | "slices" | |
| 11 | 8 | "strings" | |
| 12 | - | "syscall" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/kvconf" | |
| 13 | 11 | ) | |
| 14 | 12 | ||
| 15 | 13 | const ( | |
| @@ -40,114 +38,84 @@ type Settings struct { | |||
| 40 | 38 | ||
| 41 | 39 | const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir" | |
| 42 | 40 | ||
| 41 | + | // Strict: a line without "=", an unknown key or a duplicate is fatal. A "#" | |
| 42 | + | // only starts a comment at the start of a line, and values are never unquoted. | |
| 43 | + | var options = kvconf.Options{Strict: true, Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes} | |
| 44 | + | ||
| 45 | + | // file is the config file as kvconf decodes it, before it becomes Settings. | |
| 46 | + | type file struct { | |
| 47 | + | Grid onOff `kv:"selector.grid"` | |
| 48 | + | Freeze onOff `kv:"selector.freeze"` | |
| 49 | + | ShotDir dirPath `kv:"screenshot.dir"` | |
| 50 | + | VideoDir dirPath `kv:"video.dir"` | |
| 51 | + | Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1). | |
| 55 | + | type onOff bool | |
| 56 | + | ||
| 57 | + | func (o *onOff) UnmarshalText(text []byte) error { | |
| 58 | + | switch string(text) { | |
| 59 | + | case "on": | |
| 60 | + | *o = true | |
| 61 | + | case "off": | |
| 62 | + | *o = false | |
| 63 | + | default: | |
| 64 | + | return errors.New("must be on or off") | |
| 65 | + | } | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath. | |
| 70 | + | type dirPath string | |
| 71 | + | ||
| 72 | + | func (d *dirPath) UnmarshalText(text []byte) error { | |
| 73 | + | if len(text) > maxPath { | |
| 74 | + | return errors.New("must be a non-empty path") | |
| 75 | + | } | |
| 76 | + | if string(text) == "~" { | |
| 77 | + | return errors.New("must be an absolute path or start with ~/") | |
| 78 | + | } | |
| 79 | + | var p kvconf.Path | |
| 80 | + | if err := p.UnmarshalText(text); err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | *d = dirPath(p) | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 43 | 87 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | |
| 44 | 88 | // An absent file or setting enables the full monitor grid and the frozen | |
| 45 | 89 | // screenshot selector. Invalid settings fail explicitly rather than silently | |
| 46 | 90 | // changing the requested appearance. | |
| 47 | 91 | func Load() (Settings, error) { | |
| 48 | 92 | 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 | - | } | |
| 63 | - | if err != nil { | |
| 64 | - | return Settings{}, fmt.Errorf("open config: %w", err) | |
| 65 | - | } | |
| 66 | - | defer file.Close() | |
| 67 | - | info, err := file.Stat() | |
| 93 | + | path, err := kvconf.DefaultPath("nefercap", "config") | |
| 68 | 94 | if err != nil { | |
| 69 | - | return Settings{}, fmt.Errorf("stat config: %w", err) | |
| 70 | - | } | |
| 71 | - | if !info.Mode().IsRegular() { | |
| 72 | - | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | |
| 73 | - | } | |
| 74 | - | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | |
| 75 | - | if err != nil { | |
| 76 | - | return Settings{}, fmt.Errorf("read config %s: %w", path, err) | |
| 95 | + | return settings, nil | |
| 77 | 96 | } | |
| 78 | - | if len(data) > maxBytes { | |
| 97 | + | data, err := kvconf.ReadFile(path, options) | |
| 98 | + | switch { | |
| 99 | + | case errors.Is(err, os.ErrNotExist): | |
| 100 | + | return settings, nil | |
| 101 | + | case errors.Is(err, kvconf.ErrTooLarge): | |
| 79 | 102 | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | |
| 103 | + | case err != nil && strings.HasSuffix(err.Error(), "not a regular file"): | |
| 104 | + | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | |
| 105 | + | case err != nil: | |
| 106 | + | return Settings{}, fmt.Errorf("read config: %w", err) | |
| 80 | 107 | } | |
| 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) | |
| 108 | + | f := file{Grid: true, Freeze: true, Output: OutputFile} | |
| 109 | + | if _, err := kvconf.Unmarshal(data, &f, options); err != nil { | |
| 110 | + | var w kvconf.Warning | |
| 111 | + | if !errors.As(err, &w) { | |
| 112 | + | return Settings{}, fmt.Errorf("config %s: %w", path, err) | |
| 92 | 113 | } | |
| 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") | |
| 114 | + | msg := w.Key + " " + w.Msg | |
| 115 | + | if w.Key == "" || w.Msg == "unknown key" || strings.HasPrefix(w.Msg, "overrides line") { | |
| 116 | + | msg = "expected each of " + knownKeys + " at most once as key = value" | |
| 118 | 117 | } | |
| 119 | - | case "screenshot.dir": | |
| 120 | - | s.ScreenshotDir, err = dir(key, value) | |
| 121 | - | case "video.dir": | |
| 122 | - | s.VideoDir, err = dir(key, value) | |
| 123 | - | } | |
| 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 | |
| 133 | - | } | |
| 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) | |
| 141 | - | } | |
| 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) | |
| 146 | - | } | |
| 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) | |
| 118 | + | return Settings{}, fmt.Errorf("config %s line %d: %s", path, w.Line, msg) | |
| 151 | 119 | } | |
| 152 | - | return filepath.Clean(value), nil | |
| 120 | + | return Settings{bool(f.Grid), bool(f.Freeze), string(f.ShotDir), string(f.VideoDir), f.Output}, nil | |
| 153 | 121 | } | |
| @@ -4,12 +4,10 @@ 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" | |||
| 11 | "strings" | 8 | "strings" | ||
| 12 | - | "syscall" | 9 | + | |
| 10 | + | "github.com/bnema/kvconf" | |||
| 13 | ) | 11 | ) | ||
| 14 | 12 | ||||
| 15 | const ( | 13 | const ( | ||
| @@ -40,114 +38,84 @@ type Settings struct { | |||||
| 40 | 38 | ||||
| 41 | const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir" | 39 | const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir" | ||
| 42 | 40 | ||||
| 41 | + | // Strict: a line without "=", an unknown key or a duplicate is fatal. A "#" | |||
| 42 | + | // only starts a comment at the start of a line, and values are never unquoted. | |||
| 43 | + | var options = kvconf.Options{Strict: true, Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes} | |||
| 44 | + | ||||
| 45 | + | // file is the config file as kvconf decodes it, before it becomes Settings. | |||
| 46 | + | type file struct { | |||
| 47 | + | Grid onOff `kv:"selector.grid"` | |||
| 48 | + | Freeze onOff `kv:"selector.freeze"` | |||
| 49 | + | ShotDir dirPath `kv:"screenshot.dir"` | |||
| 50 | + | VideoDir dirPath `kv:"video.dir"` | |||
| 51 | + | Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"` | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1). | |||
| 55 | + | type onOff bool | |||
| 56 | + | ||||
| 57 | + | func (o *onOff) UnmarshalText(text []byte) error { | |||
| 58 | + | switch string(text) { | |||
| 59 | + | case "on": | |||
| 60 | + | *o = true | |||
| 61 | + | case "off": | |||
| 62 | + | *o = false | |||
| 63 | + | default: | |||
| 64 | + | return errors.New("must be on or off") | |||
| 65 | + | } | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath. | |||
| 70 | + | type dirPath string | |||
| 71 | + | ||||
| 72 | + | func (d *dirPath) UnmarshalText(text []byte) error { | |||
| 73 | + | if len(text) > maxPath { | |||
| 74 | + | return errors.New("must be a non-empty path") | |||
| 75 | + | } | |||
| 76 | + | if string(text) == "~" { | |||
| 77 | + | return errors.New("must be an absolute path or start with ~/") | |||
| 78 | + | } | |||
| 79 | + | var p kvconf.Path | |||
| 80 | + | if err := p.UnmarshalText(text); err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | *d = dirPath(p) | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 43 | // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config). | 87 | // 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 | 88 | // An absent file or setting enables the full monitor grid and the frozen | ||
| 45 | // screenshot selector. Invalid settings fail explicitly rather than silently | 89 | // screenshot selector. Invalid settings fail explicitly rather than silently | ||
| 46 | // changing the requested appearance. | 90 | // changing the requested appearance. | ||
| 47 | func Load() (Settings, error) { | 91 | func Load() (Settings, error) { | ||
| 48 | settings := Settings{Grid: true, Freeze: true, Output: OutputFile} | 92 | settings := Settings{Grid: true, Freeze: true, Output: OutputFile} | ||
| 49 | - | root := os.Getenv("XDG_CONFIG_HOME") | 93 | + | path, err := kvconf.DefaultPath("nefercap", "config") |
| 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 { | |||
| 64 | - | return Settings{}, fmt.Errorf("open config: %w", err) | |||
| 65 | - | } | |||
| 66 | - | defer file.Close() | |||
| 67 | - | info, err := file.Stat() | |||
| 68 | if err != nil { | 94 | if err != nil { | ||
| 69 | - | return Settings{}, fmt.Errorf("stat config: %w", err) | 95 | + | return settings, nil |
| 70 | - | } | |||
| 71 | - | if !info.Mode().IsRegular() { | |||
| 72 | - | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | |||
| 73 | - | } | |||
| 74 | - | data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) | |||
| 75 | - | if err != nil { | |||
| 76 | - | return Settings{}, fmt.Errorf("read config %s: %w", path, err) | |||
| 77 | } | 96 | } | ||
| 78 | - | if len(data) > maxBytes { | 97 | + | data, err := kvconf.ReadFile(path, options) |
| 98 | + | switch { | |||
| 99 | + | case errors.Is(err, os.ErrNotExist): | |||
| 100 | + | return settings, nil | |||
| 101 | + | case errors.Is(err, kvconf.ErrTooLarge): | |||
| 79 | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | 102 | return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes) | ||
| 103 | + | case err != nil && strings.HasSuffix(err.Error(), "not a regular file"): | |||
| 104 | + | return Settings{}, fmt.Errorf("config %s: expected a regular file", path) | |||
| 105 | + | case err != nil: | |||
| 106 | + | return Settings{}, fmt.Errorf("read config: %w", err) | |||
| 80 | } | 107 | } | ||
| 81 | - | var seen [len(keys)]bool | 108 | + | f := file{Grid: true, Freeze: true, Output: OutputFile} |
| 82 | - | for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") { | 109 | + | if _, err := kvconf.Unmarshal(data, &f, options); err != nil { |
| 83 | - | line := strings.TrimSpace(raw) | 110 | + | var w kvconf.Warning |
| 84 | - | if line == "" || strings.HasPrefix(line, "#") { | 111 | + | if !errors.As(err, &w) { |
| 85 | - | continue | 112 | + | return Settings{}, fmt.Errorf("config %s: %w", path, err) |
| 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 | } | 113 | } | ||
| 93 | - | if slot < 0 || seen[slot] { | 114 | + | msg := w.Key + " " + w.Msg |
| 94 | - | return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys) | 115 | + | if w.Key == "" || w.Msg == "unknown key" || strings.HasPrefix(w.Msg, "overrides line") { |
| 95 | - | } | 116 | + | msg = "expected each of " + knownKeys + " at most once as key = value" |
| 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 | } | 117 | } | ||
| 119 | - | case "screenshot.dir": | 118 | + | return Settings{}, fmt.Errorf("config %s line %d: %s", path, w.Line, msg) |
| 120 | - | s.ScreenshotDir, err = dir(key, value) | |||
| 121 | - | case "video.dir": | |||
| 122 | - | s.VideoDir, err = dir(key, value) | |||
| 123 | - | } | |||
| 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 | |||
| 133 | - | } | |||
| 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) | |||
| 141 | - | } | |||
| 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) | |||
| 146 | - | } | |||
| 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) | |||
| 151 | } | 119 | } | ||
| 152 | - | return filepath.Clean(value), nil | 120 | + | return Settings{bool(f.Grid), bool(f.Freeze), string(f.ShotDir), string(f.VideoDir), f.Output}, nil |
| 153 | } | 121 | } | ||