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