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