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 seenTerminalKeys := make(map[string]bool)
42 seenScrollbackKeys := make(map[string]bool)
43 seenWebKeys := make(map[string]bool)
44
45 scanner := bufio.NewScanner(r)
46 lineNo := 0
47 for scanner.Scan() {
48 lineNo++
49 line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
50 if line == "" || strings.HasPrefix(line, "#") {
51 continue
52 }
53 line = stripInlineComment(line)
54 if line == "" {
55 continue
56 }
57
58 key, value, ok := strings.Cut(line, "=")
59 if !ok {
60 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
61 continue
62 }
63 key = strings.TrimSpace(key)
64 value = strings.TrimSpace(value)
65 if key == "" {
66 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
67 continue
68 }
69
70 switch {
71 case key == "web.listen" || key == "web.origin":
72 warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
73 // Keep raw values for fail-closed validation at gateway startup.
74 if key == "web.listen" {
75 cfg.WebListen = value
76 } else {
77 cfg.WebOrigin = value
78 }
79 case key == "theme":
80 mode, ok := parseTheme(value)
81 if !ok {
82 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
83 continue
84 }
85 cfg.Theme = mode
86 case key == "theme.palette":
87 on, ok := parseOnOff(value)
88 if !ok {
89 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
90 continue
91 }
92 cfg.ThemePalette = on
93 case key == "theme.accent":
94 accent, warning := parseThemeAccent(value)
95 cfg.ThemeAccent = accent
96 if warning != "" {
97 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
98 }
99 case key == "bar.top-right":
100 command, ok := parseBarCommand(value)
101 if !ok {
102 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
103 continue
104 }
105 cfg.Bar.TopRight = command
106 case key == "bar.bottom-right":
107 command, ok := parseBarCommand(value)
108 if !ok {
109 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
110 continue
111 }
112 cfg.Bar.BottomRight = command
113 case key == "bar.interval":
114 interval, err := time.ParseDuration(value)
115 if err != nil {
116 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
117 continue
118 }
119 if interval < domain.MinBarInterval {
120 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
121 interval = domain.MinBarInterval
122 }
123 cfg.Bar.Interval = interval
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 == "terminal.colors":
177 warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178 mode, auto, err := terminalcap.ParseColorMode(value)
179 if err != nil {
180 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
181 continue
182 }
183 cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
184 case key == "copy.word-separators":
185 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
186 separators, ok := parseConfigString(value)
187 if !ok {
188 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
189 continue
190 }
191 cfg.Copy.WordSeparators = separators
192 case key == "copy.reduce-motion":
193 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
194 on, ok := parseOnOff(value)
195 if !ok {
196 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)})
197 continue
198 }
199 cfg.Copy.ReduceMotion = on
200 case key == "floating.command":
201 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
202 command, ok := parseBarCommand(value)
203 if !ok {
204 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
205 continue
206 }
207 cfg.Floating.Command = command
208 case key == "floating.width":
209 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
210 width, ok := parsePercentage(value)
211 if !ok {
212 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
213 continue
214 }
215 cfg.Floating.Width = width
216 case key == "floating.height":
217 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
218 height, ok := parsePercentage(value)
219 if !ok {
220 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
221 continue
222 }
223 cfg.Floating.Height = height
224 case strings.HasPrefix(key, "code."):
225 codeKey := strings.TrimPrefix(key, "code.")
226 if codeKey == "" {
227 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
228 continue
229 }
230 if _, exists := cfg.Codes[codeKey]; exists {
231 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
232 }
233 cfg.Codes[codeKey] = value
234 case key == "scrollback.megabytes":
235 warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
236 n, err := strconv.ParseUint(value, 10, 64)
237 if err != nil || n > domain.MaxScrollbackMegabytes {
238 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)})
239 continue
240 }
241 cfg.Scrollback.Megabytes = n
242 case key == "scrollback.lines":
243 warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
244 n, err := strconv.ParseUint(value, 10, 64)
245 if err != nil || n > domain.MaxScrollbackLines {
246 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)})
247 continue
248 }
249 cfg.Scrollback.Lines = int(n)
250 case key == "snapshot.restore_processes":
251 var processWarnings []domain.Warning
252 cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
253 warnings = append(warnings, processWarnings...)
254 cfg.Snapshot.RestoreProcessesSet = true
255 default:
256 if seenBindingKeys[key] {
257 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
258 updateBindingEntry(cfg.BindingEntries, key, value)
259 } else {
260 seenBindingKeys[key] = true
261 cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
262 }
263 }
264 }
265 if err := scanner.Err(); err != nil {
266 return cfg, warnings, err
267 }
268 return cfg, warnings, nil
269}
270
271// Load reads path and parses it. Missing config files are treated as defaults.
272func Load(path string) (domain.Config, []domain.Warning, error) {
273 f, err := os.Open(path)
274 if errors.Is(err, os.ErrNotExist) {
275 return domain.Defaults(), nil, nil
276 }
277 if err != nil {
278 return domain.Defaults(), nil, err
279 }
280 cfg, warnings, parseErr := Parse(f)
281 if closeErr := f.Close(); parseErr == nil && closeErr != nil {
282 return cfg, warnings, closeErr
283 }
284 return cfg, warnings, parseErr
285}
286
287// Watch polls path for mtime/size changes every two seconds and calls onChange
288// with each successfully loaded config. Missing files are allowed and reload to
289// defaults when the watched state changes.
290func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
291 last, err := fileStamp(path)
292 lastErrMsg := ""
293 if err != nil {
294 lastErrMsg = err.Error()
295 onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
296 }
297 timer := clock.NewTimer(pollInterval)
298 defer timer.Stop()
299
300 for {
301 select {
302 case <-ctx.Done():
303 return ctx.Err()
304 case <-timer.C():
305 current, statErr := fileStamp(path)
306 if statErr != nil {
307 if statErr.Error() != lastErrMsg {
308 lastErrMsg = statErr.Error()
309 onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
310 }
311 } else if current != last || lastErrMsg != "" {
312 lastErrMsg = ""
313 last = current
314 cfg, warnings, err := Load(path)
315 if err != nil {
316 cfg = domain.Defaults()
317 warnings = append(warnings, domain.Warning{Msg: err.Error()})
318 }
319 onChange(cfg, warnings)
320 }
321 timer.Reset(pollInterval)
322 }
323 }
324}
325
326type stamp struct {
327 modTime time.Time
328 size int64
329 exists bool
330}
331
332func fileStamp(path string) (stamp, error) {
333 st, err := os.Stat(path)
334 if errors.Is(err, os.ErrNotExist) {
335 return stamp{}, nil
336 }
337 if err != nil {
338 return stamp{}, err
339 }
340 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
341}
342
343func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
344 if seen[key] {
345 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
346 }
347 seen[key] = true
348 return warnings
349}
350
351func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
352 for i := range entries {
353 if entries[i].Key == key {
354 entries[i].Value = value
355 return
356 }
357 }
358}
359
360func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
361 if strings.TrimSpace(value) == "" {
362 return nil, nil
363 }
364 parts := strings.Split(value, ",")
365 out := make([]string, 0, len(parts))
366 warnings := make([]domain.Warning, 0)
367 seen := make(map[string]struct{}, len(parts))
368 for _, part := range parts {
369 item := strings.TrimSpace(part)
370 if item == "" {
371 continue
372 }
373 if !processNamePattern.MatchString(item) {
374 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
375 continue
376 }
377 if _, ok := seen[item]; ok {
378 continue
379 }
380 seen[item] = struct{}{}
381 out = append(out, item)
382 }
383 return out, warnings
384}
385
386func parsePercentage(value string) (int, bool) {
387 if !percentagePattern.MatchString(value) {
388 return 0, false
389 }
390 percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
391 return percentage, err == nil && percentage >= 1 && percentage <= 100
392}
393
394func parseOnOff(value string) (bool, bool) {
395 switch strings.ToLower(strings.TrimSpace(value)) {
396 case "on":
397 return true, true
398 case "off":
399 return false, true
400 default:
401 return false, false
402 }
403}
404
405func parseBarCommand(value string) (string, bool) {
406 value = strings.TrimSpace(value)
407 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
408 unquoted, err := strconv.Unquote(value)
409 if err != nil {
410 return value, false
411 }
412 return unquoted, true
413 }
414 return value, true
415}
416
417func parseConfigString(value string) (string, bool) {
418 value = strings.TrimSpace(value)
419 if value == "" {
420 return "", true
421 }
422 startsQuoted := strings.HasPrefix(value, `"`)
423 endsQuoted := strings.HasSuffix(value, `"`)
424 if startsQuoted != endsQuoted {
425 return "", false
426 }
427 if !startsQuoted {
428 return value, true
429 }
430 unquoted, err := strconv.Unquote(value)
431 if err != nil {
432 return "", false
433 }
434 return unquoted, true
435}
436
437func parseTheme(value string) (domain.ThemeMode, bool) {
438 switch strings.ToLower(strings.TrimSpace(value)) {
439 case "auto", "":
440 return domain.ThemeAuto, true
441 case "dark":
442 return domain.ThemeDark, true
443 case "light":
444 return domain.ThemeLight, true
445 default:
446 return domain.ThemeAuto, false
447 }
448}
449
450func stripInlineComment(line string) string {
451 inQuote := false
452 escaped := false
453 for i, r := range line {
454 if escaped {
455 escaped = false
456 continue
457 }
458 if inQuote && r == '\\' {
459 escaped = true
460 continue
461 }
462 if r == '"' {
463 inQuote = !inQuote
464 continue
465 }
466 if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
467 return strings.TrimSpace(line[:i])
468 }
469 }
470 return line
471}
472
473func isSpace(r rune) bool {
474 return r == ' ' || r == '\t' || r == '\r' || r == '\n'
475}