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