1package config
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "regexp"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/bnema/kvconf"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/terminalcap"
18)
19
20var (
21 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
22 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
23)
24
25// Parse reads vev's flat action = value config format. Duplicate action keys
26// are accepted with a warning; the last value wins while first-seen action order
27// is preserved for binding conflict resolution.
28func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
29 cfg := domain.Defaults()
30 var warnings []domain.Warning
31 seenBindingKeys := make(map[string]bool)
32 seenFloatingKeys := make(map[string]bool)
33 seenCopyKeys := make(map[string]bool)
34 seenPaletteKeys := make(map[string]bool)
35 seenNavKeys := make(map[string]bool)
36 seenTabsKeys := make(map[string]bool)
37 seenEphemeralKeys := make(map[string]bool)
38 seenKeyboardKeys := make(map[string]bool)
39 seenEchoKeys := make(map[string]bool)
40 seenTerminalKeys := make(map[string]bool)
41 seenScrollbackKeys := make(map[string]bool)
42 seenWebKeys := make(map[string]bool)
43
44 entries, syntax, err := kvconf.Parse(r, parseOptions)
45 if err != nil {
46 return domain.Defaults(), nil, err
47 }
48 pending := syntaxWarnings(syntax)
49
50 for _, entry := range entries {
51 // Interleave line-syntax warnings with domain warnings in file order.
52 for len(pending) > 0 && pending[0].Line < entry.Line {
53 warnings = append(warnings, pending[0])
54 pending = pending[1:]
55 }
56 lineNo, key, value := entry.Line, entry.Key, entry.Value
57 // "key = # note" has an empty value: kvconf only strips a '#' that
58 // follows other text.
59 if strings.HasPrefix(value, "#") {
60 value = ""
61 }
62
63 switch {
64 case key == "web.listen" || key == "web.origin":
65 warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
66 // Keep raw values for fail-closed validation at gateway startup.
67 if key == "web.listen" {
68 cfg.WebListen = value
69 } else {
70 cfg.WebOrigin = value
71 }
72 case key == "theme":
73 mode, ok := parseTheme(value)
74 if !ok {
75 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
76 continue
77 }
78 cfg.Theme = mode
79 case key == "theme.palette":
80 on, ok := parseOnOff(value)
81 if !ok {
82 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
83 continue
84 }
85 cfg.ThemePalette = on
86 case key == "theme.accent":
87 accent, warning := parseThemeAccent(value)
88 cfg.ThemeAccent = accent
89 if warning != "" {
90 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
91 }
92 case key == "bar.top-right":
93 command, ok := parseBarCommand(value)
94 if !ok {
95 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
96 continue
97 }
98 cfg.Bar.TopRight = command
99 case key == "bar.bottom-right":
100 command, ok := parseBarCommand(value)
101 if !ok {
102 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
103 continue
104 }
105 cfg.Bar.BottomRight = command
106 case key == "bar.interval":
107 interval, err := time.ParseDuration(value)
108 if err != nil {
109 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
110 continue
111 }
112 if interval < domain.MinBarInterval {
113 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
114 interval = domain.MinBarInterval
115 }
116 cfg.Bar.Interval = interval
117 case key == "bar.background":
118 on, ok := parseOnOff(value)
119 if !ok {
120 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.background %q", value)})
121 continue
122 }
123 cfg.Bar.Transparent = !on
124 case key == "palette.anchor":
125 warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
126 if strings.EqualFold(strings.TrimSpace(value), "auto") {
127 cfg.Palette = domain.PaletteConfig{}
128 continue
129 }
130 anchor, ok := domain.ParseAnchor(value)
131 if !ok {
132 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
133 continue
134 }
135 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
136 case key == "nav.overflow-tabs":
137 warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
138 on, ok := parseOnOff(value)
139 if !ok {
140 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)})
141 continue
142 }
143 cfg.Nav.OverflowTabs = on
144 case key == "nav.overflow-sessions":
145 warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
146 on, ok := parseOnOff(value)
147 if !ok {
148 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)})
149 continue
150 }
151 cfg.Nav.OverflowSessions = on
152 case key == "tabs.terminal-title":
153 warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
154 on, ok := parseOnOff(value)
155 if !ok {
156 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
157 continue
158 }
159 cfg.Tabs.TerminalTitle = on
160 case key == "ephemeral.close-on-exit":
161 warnings = warnDuplicateKey(warnings, seenEphemeralKeys, key, lineNo)
162 on, ok := parseOnOff(value)
163 if !ok {
164 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid ephemeral.close-on-exit %q", value)})
165 continue
166 }
167 cfg.Ephemeral.CloseOnExit = on
168 case key == "keyboard.kitty-protocol":
169 warnings = warnDuplicateKey(warnings, seenKeyboardKeys, key, lineNo)
170 on, ok := parseOnOff(value)
171 if !ok {
172 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid keyboard.kitty-protocol %q", value)})
173 continue
174 }
175 cfg.Keyboard.KittyProtocol = on
176 case key == "echo.predict":
177 warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo)
178 mode, ok := parseEchoPredict(value)
179 if !ok {
180 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)})
181 continue
182 }
183 cfg.Echo.Predict = mode
184 case key == "terminal.colors":
185 warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
186 mode, auto, err := terminalcap.ParseColorMode(value)
187 if err != nil {
188 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
189 continue
190 }
191 cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
192 case key == "copy.word-separators":
193 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
194 separators, ok := parseConfigString(value)
195 if !ok {
196 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
197 continue
198 }
199 cfg.Copy.WordSeparators = separators
200 case key == "copy.reduce-motion":
201 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
202 on, ok := parseOnOff(value)
203 if !ok {
204 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)})
205 continue
206 }
207 cfg.Copy.ReduceMotion = on
208 case key == "floating.command":
209 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
210 command, ok := parseBarCommand(value)
211 if !ok {
212 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
213 continue
214 }
215 cfg.Floating.Command = command
216 case key == "floating.width":
217 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
218 width, ok := parsePercentage(value)
219 if !ok {
220 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
221 continue
222 }
223 cfg.Floating.Width = width
224 case key == "floating.height":
225 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
226 height, ok := parsePercentage(value)
227 if !ok {
228 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
229 continue
230 }
231 cfg.Floating.Height = height
232 case strings.HasPrefix(key, "code."):
233 codeKey := strings.TrimPrefix(key, "code.")
234 if codeKey == "" {
235 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
236 continue
237 }
238 if _, exists := cfg.Codes[codeKey]; exists {
239 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
240 }
241 cfg.Codes[codeKey] = value
242 case key == "scrollback.megabytes":
243 warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
244 n, err := strconv.ParseUint(value, 10, 64)
245 if err != nil || n > domain.MaxScrollbackMegabytes {
246 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)})
247 continue
248 }
249 cfg.Scrollback.Megabytes = n
250 case key == "scrollback.lines":
251 warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
252 n, err := strconv.ParseUint(value, 10, 64)
253 if err != nil || n > domain.MaxScrollbackLines {
254 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)})
255 continue
256 }
257 cfg.Scrollback.Lines = int(n)
258 case key == "snapshot.restore_processes":
259 var processWarnings []domain.Warning
260 cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
261 warnings = append(warnings, processWarnings...)
262 cfg.Snapshot.RestoreProcessesSet = true
263 default:
264 if seenBindingKeys[key] {
265 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
266 updateBindingEntry(cfg.BindingEntries, key, value)
267 } else {
268 seenBindingKeys[key] = true
269 cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
270 }
271 }
272 }
273 warnings = append(warnings, pending...)
274 return cfg, warnings, nil
275}
276
277// parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes
278// per key and reports its own duplicate warnings.
279var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true}
280
281// syntaxWarnings maps kvconf line-syntax warnings to domain warnings.
282func syntaxWarnings(in []kvconf.Warning) []domain.Warning {
283 out := make([]domain.Warning, 0, len(in))
284 for _, w := range in {
285 msg := w.Msg
286 if msg == "expected key = value" {
287 msg = "missing '='"
288 }
289 out = append(out, domain.Warning{Line: w.Line, Msg: msg})
290 }
291 return out
292}
293
294// Load reads path and parses it. Missing config files are treated as defaults.
295func Load(path string) (domain.Config, []domain.Warning, error) {
296 data, err := kvconf.ReadFile(path, parseOptions)
297 if errors.Is(err, os.ErrNotExist) {
298 return domain.Defaults(), nil, nil
299 }
300 if err != nil {
301 return domain.Defaults(), nil, err
302 }
303 return Parse(bytes.NewReader(data))
304}
305
306// Watch follows path with native file notifications (kvconf.Watch) and calls
307// onChange with the config reloaded after each change. Missing files are
308// allowed and reload to defaults. A load failure reports defaults plus a
309// warning; the same failure is not reported twice in a row. Watch returns
310// ctx.Err() when ctx ends.
311func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error {
312 return watch(ctx, path, kvconf.WatchOptions{}, onChange)
313}
314
315// fallbackPoll is the polling interval used when native watching fails.
316const fallbackPoll = 2 * time.Second
317
318// kvWatch is kvconf.Watch; tests replace it to make native watching fail.
319var kvWatch = kvconf.Watch
320
321func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error {
322 lastErrMsg := ""
323 reportErr := func(err error) {
324 if msg := err.Error(); msg != lastErrMsg {
325 lastErrMsg = msg
326 onChange(domain.Defaults(), []domain.Warning{{Msg: msg}})
327 }
328 }
329 if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) {
330 reportErr(err)
331 }
332
333 changed := make(chan struct{}, 1)
334 watchErr := make(chan error, 1)
335 go func() { watchErr <- kvWatch(ctx, path, opts, changed) }()
336
337 for {
338 select {
339 case <-ctx.Done():
340 <-watchErr
341 return ctx.Err()
342 case err := <-watchErr:
343 if ctx.Err() != nil {
344 return ctx.Err()
345 }
346 if err == nil {
347 err = errors.New("config watcher stopped")
348 }
349 if opts.Poll > 0 {
350 return err
351 }
352 // The OS ran out of watches (inotify limits): poll like before
353 // rather than lose live reload until the daemon restarts. The
354 // warning rides on the current config, which must not reset.
355 opts.Poll = fallbackPoll
356 go func() { watchErr <- kvWatch(ctx, path, opts, changed) }()
357 if cfg, warnings, err2 := Load(path); err2 == nil {
358 lastErrMsg = ""
359 onChange(cfg, append(warnings, domain.Warning{Msg: fmt.Sprintf("config watcher: %v; polling every %s", err, fallbackPoll)}))
360 }
361 case <-changed:
362 cfg, warnings, err := Load(path)
363 if err != nil {
364 reportErr(err)
365 continue
366 }
367 lastErrMsg = ""
368 onChange(cfg, warnings)
369 }
370 }
371}
372
373func parseEchoPredict(value string) (domain.EchoPredictMode, bool) {
374 switch strings.ToLower(value) {
375 case "adaptive":
376 return domain.EchoPredictAdaptive, true
377 case "always":
378 return domain.EchoPredictAlways, true
379 case "never":
380 return domain.EchoPredictNever, true
381 }
382 return 0, false
383}
384
385func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
386 if seen[key] {
387 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
388 }
389 seen[key] = true
390 return warnings
391}
392
393func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
394 for i := range entries {
395 if entries[i].Key == key {
396 entries[i].Value = value
397 return
398 }
399 }
400}
401
402func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
403 if strings.TrimSpace(value) == "" {
404 return nil, nil
405 }
406 parts := strings.Split(value, ",")
407 out := make([]string, 0, len(parts))
408 warnings := make([]domain.Warning, 0)
409 seen := make(map[string]struct{}, len(parts))
410 for _, part := range parts {
411 item := strings.TrimSpace(part)
412 if item == "" {
413 continue
414 }
415 if !processNamePattern.MatchString(item) {
416 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
417 continue
418 }
419 if _, ok := seen[item]; ok {
420 continue
421 }
422 seen[item] = struct{}{}
423 out = append(out, item)
424 }
425 return out, warnings
426}
427
428func parsePercentage(value string) (int, bool) {
429 if !percentagePattern.MatchString(value) {
430 return 0, false
431 }
432 percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
433 return percentage, err == nil && percentage >= 1 && percentage <= 100
434}
435
436func parseOnOff(value string) (bool, bool) {
437 switch strings.ToLower(strings.TrimSpace(value)) {
438 case "on":
439 return true, true
440 case "off":
441 return false, true
442 default:
443 return false, false
444 }
445}
446
447func parseBarCommand(value string) (string, bool) {
448 value = strings.TrimSpace(value)
449 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
450 unquoted, err := strconv.Unquote(value)
451 if err != nil {
452 return value, false
453 }
454 return unquoted, true
455 }
456 return value, true
457}
458
459func parseConfigString(value string) (string, bool) {
460 value = strings.TrimSpace(value)
461 if value == "" {
462 return "", true
463 }
464 startsQuoted := strings.HasPrefix(value, `"`)
465 endsQuoted := strings.HasSuffix(value, `"`)
466 if startsQuoted != endsQuoted {
467 return "", false
468 }
469 if !startsQuoted {
470 return value, true
471 }
472 unquoted, err := strconv.Unquote(value)
473 if err != nil {
474 return "", false
475 }
476 return unquoted, true
477}
478
479func parseTheme(value string) (domain.ThemeMode, bool) {
480 switch strings.ToLower(strings.TrimSpace(value)) {
481 case "auto", "":
482 return domain.ThemeAuto, true
483 case "dark":
484 return domain.ThemeDark, true
485 case "light":
486 return domain.ThemeLight, true
487 default:
488 return domain.ThemeAuto, false
489 }
490}