1package config
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/bnema/kvconf"
15 "github.com/bnema/vev/internal/domain"
16 "github.com/stretchr/testify/require"
17)
18
19func TestDefaultsDisableBarCommands(t *testing.T) {
20 bar := domain.Defaults().Bar
21 require.Empty(t, bar.TopRight)
22 require.Empty(t, bar.BottomRight)
23}
24
25func TestParse(t *testing.T) {
26 t.Parallel()
27
28 tests := []struct {
29 name string
30 input string
31 want domain.Config
32 wantWarnings []domain.Warning
33 }{
34 {
35 name: "comments and inline comments",
36 input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
37 want: domain.Config{
38 Theme: domain.ThemeAuto,
39 BindingEntries: []domain.ConfigEntry{
40 {Key: "new-tab", Value: "alt+t"},
41 {Key: "detach", Value: "alt+#"},
42 },
43 Codes: map[string]string{},
44 },
45 },
46 {
47 name: "crlf and code prefix",
48 input: "theme = light\r\ncode.detach = dt\r\n",
49 want: domain.Config{
50 Theme: domain.ThemeLight,
51 BindingEntries: []domain.ConfigEntry{},
52 Codes: map[string]string{"detach": "dt"},
53 },
54 },
55 {
56 name: "invalid theme warns and keeps default",
57 input: "theme = blue\n",
58 want: domain.Config{
59 Theme: domain.ThemeAuto,
60 BindingEntries: []domain.ConfigEntry{},
61 Codes: map[string]string{},
62 },
63 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
64 },
65 {
66 name: "unknown binding key stored",
67 input: "unknown.action = alt+x\n",
68 want: domain.Config{
69 Theme: domain.ThemeAuto,
70 BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
71 Codes: map[string]string{},
72 },
73 },
74 {
75 name: "duplicates warn and last wins",
76 input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
77 want: domain.Config{
78 Theme: domain.ThemeAuto,
79 BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
80 Codes: map[string]string{"detach": "DX"},
81 },
82 wantWarnings: []domain.Warning{
83 {Line: 2, Msg: "duplicate key \"new-tab\""},
84 {Line: 4, Msg: "duplicate key \"code.detach\""},
85 },
86 },
87 {
88 name: "invalid lines report line numbers",
89 input: "\nno equals\n = value\ntheme = dark\n",
90 want: domain.Config{
91 Theme: domain.ThemeDark,
92 BindingEntries: []domain.ConfigEntry{},
93 Codes: map[string]string{},
94 },
95 wantWarnings: []domain.Warning{
96 {Line: 2, Msg: "missing '='"},
97 {Line: 3, Msg: "missing key"},
98 },
99 },
100 {
101 name: "floating settings custom values",
102 input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
103 want: domain.Config{
104 Theme: domain.ThemeAuto,
105 Floating: domain.FloatingConfig{
106 Command: "btop --utf-force",
107 Width: 75,
108 Height: 60,
109 },
110 BindingEntries: []domain.ConfigEntry{},
111 Codes: map[string]string{},
112 },
113 },
114 {
115 name: "floating command can be empty or quoted",
116 input: "floating.command = echo ready\nfloating.command = \"\"\n",
117 want: domain.Config{
118 Theme: domain.ThemeAuto,
119 Floating: domain.FloatingConfig{
120 Width: 80,
121 Height: 80,
122 },
123 BindingEntries: []domain.ConfigEntry{},
124 Codes: map[string]string{},
125 },
126 wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
127 },
128 {
129 name: "a value that is only a comment is empty",
130 input: "floating.command = echo ready\nfloating.command = # default shell\n",
131 want: domain.Config{
132 Theme: domain.ThemeAuto,
133 Floating: domain.FloatingConfig{
134 Width: 80,
135 Height: 80,
136 },
137 BindingEntries: []domain.ConfigEntry{},
138 Codes: map[string]string{},
139 },
140 wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
141 },
142 {
143 name: "floating percentage endpoints",
144 input: "floating.width = 1%\nfloating.height = 100%\n",
145 want: domain.Config{
146 Theme: domain.ThemeAuto,
147 Floating: domain.FloatingConfig{
148 Width: 1,
149 Height: 100,
150 },
151 BindingEntries: []domain.ConfigEntry{},
152 Codes: map[string]string{},
153 },
154 },
155 {
156 name: "floating invalid percentages warn and preserve defaults",
157 input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
158 want: domain.Config{
159 Theme: domain.ThemeAuto,
160 Floating: domain.FloatingConfig{
161 Width: 80,
162 Height: 80,
163 },
164 BindingEntries: []domain.ConfigEntry{},
165 Codes: map[string]string{},
166 },
167 wantWarnings: []domain.Warning{
168 {Line: 1, Msg: "invalid floating.width \"80\""},
169 {Line: 2, Msg: "invalid floating.height \"0%\""},
170 {Line: 3, Msg: "duplicate key \"floating.width\""},
171 {Line: 3, Msg: "invalid floating.width \"101%\""},
172 {Line: 4, Msg: "duplicate key \"floating.height\""},
173 {Line: 4, Msg: "invalid floating.height \"eighty%\""},
174 },
175 },
176 {
177 name: "floating duplicate keys warn and last valid value wins",
178 input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
179 want: domain.Config{
180 Theme: domain.ThemeAuto,
181 Floating: domain.FloatingConfig{
182 Command: "lazygit",
183 Width: 90,
184 Height: 85,
185 },
186 BindingEntries: []domain.ConfigEntry{},
187 Codes: map[string]string{},
188 },
189 wantWarnings: []domain.Warning{
190 {Line: 2, Msg: "duplicate key \"floating.command\""},
191 {Line: 4, Msg: "duplicate key \"floating.width\""},
192 {Line: 6, Msg: "duplicate key \"floating.height\""},
193 },
194 },
195 {
196 name: "floating malformed quoted command warns and keeps default",
197 input: "floating.command = \"bad\\q\"\n",
198 want: domain.Config{
199 Theme: domain.ThemeAuto,
200 BindingEntries: []domain.ConfigEntry{},
201 Codes: map[string]string{},
202 },
203 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
204 },
205 {
206 name: "bar settings custom values",
207 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
208 want: domain.Config{
209 Theme: domain.ThemeAuto,
210 Bar: domain.BarConfig{
211 TopRight: "custom-top",
212 BottomRight: "custom-bottom",
213 Interval: 2 * time.Second,
214 },
215 BindingEntries: []domain.ConfigEntry{},
216 Codes: map[string]string{},
217 },
218 },
219 {
220 name: "bar settings quoted commands unquote",
221 input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
222 want: domain.Config{
223 Theme: domain.ThemeAuto,
224 Bar: domain.BarConfig{
225 TopRight: "date +%H:%M",
226 BottomRight: "",
227 Interval: 5 * time.Second,
228 },
229 BindingEntries: []domain.ConfigEntry{},
230 Codes: map[string]string{},
231 },
232 },
233 {
234 name: "bar settings quoted commands preserve hash",
235 input: "bar.top-right = \"echo foo #1\" # inline comment\n",
236 want: domain.Config{
237 Theme: domain.ThemeAuto,
238 Bar: domain.BarConfig{
239 TopRight: "echo foo #1",
240 BottomRight: "",
241 Interval: 5 * time.Second,
242 },
243 BindingEntries: []domain.ConfigEntry{},
244 Codes: map[string]string{},
245 },
246 },
247 {
248 name: "bar settings malformed quoted command warns and keeps default",
249 input: "bar.top-right = \"bad\\q\"\n",
250 want: domain.Config{
251 Theme: domain.ThemeAuto,
252 Bar: domain.BarConfig{
253 TopRight: "",
254 BottomRight: "",
255 Interval: 5 * time.Second,
256 },
257 BindingEntries: []domain.ConfigEntry{},
258 Codes: map[string]string{},
259 },
260 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
261 },
262 {
263 name: "bar settings empty commands disable anchors",
264 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
265 want: domain.Config{
266 Theme: domain.ThemeAuto,
267 Bar: domain.BarConfig{
268 TopRight: "",
269 BottomRight: "",
270 Interval: 5 * time.Second,
271 },
272 BindingEntries: []domain.ConfigEntry{},
273 Codes: map[string]string{},
274 },
275 },
276 {
277 name: "bar interval invalid warns and keeps default",
278 input: "bar.interval = nope\n",
279 want: domain.Config{
280 Theme: domain.ThemeAuto,
281 Bar: domain.BarConfig{
282 TopRight: "",
283 BottomRight: "",
284 Interval: 5 * time.Second,
285 },
286 BindingEntries: []domain.ConfigEntry{},
287 Codes: map[string]string{},
288 },
289 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
290 },
291 {
292 name: "bar interval below minimum clamps and warns",
293 input: "bar.interval = 100ms\n",
294 want: domain.Config{
295 Theme: domain.ThemeAuto,
296 Bar: domain.BarConfig{
297 TopRight: "",
298 BottomRight: "",
299 Interval: time.Second,
300 },
301 BindingEntries: []domain.ConfigEntry{},
302 Codes: map[string]string{},
303 },
304 wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
305 },
306 {
307 name: "snapshot restore processes",
308 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
309 want: domain.Config{
310 Theme: domain.ThemeAuto,
311 BindingEntries: []domain.ConfigEntry{},
312 Codes: map[string]string{},
313 Snapshot: domain.SnapshotConfig{
314 RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
315 RestoreProcessesSet: true,
316 },
317 },
318 },
319 {
320 name: "snapshot restore processes empty disables",
321 input: "snapshot.restore_processes =\n",
322 want: domain.Config{
323 Theme: domain.ThemeAuto,
324 BindingEntries: []domain.ConfigEntry{},
325 Codes: map[string]string{},
326 Snapshot: domain.SnapshotConfig{
327 RestoreProcessesSet: true,
328 },
329 },
330 },
331 {
332 name: "snapshot restore processes warns on malformed entries",
333 input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
334 want: domain.Config{
335 Theme: domain.ThemeAuto,
336 BindingEntries: []domain.ConfigEntry{},
337 Codes: map[string]string{},
338 Snapshot: domain.SnapshotConfig{
339 RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
340 RestoreProcessesSet: true,
341 },
342 },
343 wantWarnings: []domain.Warning{
344 {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
345 {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
346 {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
347 },
348 },
349 }
350
351 for _, tt := range tests {
352 t.Run(tt.name, func(t *testing.T) {
353 t.Parallel()
354
355 if !tt.want.ThemePalette {
356 tt.want.ThemePalette = domain.Defaults().ThemePalette
357 }
358 if tt.want.Bar.Interval == 0 {
359 tt.want.Bar = domain.Defaults().Bar
360 }
361 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
362 tt.want.Floating = domain.Defaults().Floating
363 }
364 if !tt.want.Tabs.TerminalTitle {
365 tt.want.Tabs = domain.Defaults().Tabs
366 }
367 if !tt.want.Ephemeral.CloseOnExit {
368 tt.want.Ephemeral = domain.Defaults().Ephemeral
369 }
370 if !tt.want.Keyboard.KittyProtocol {
371 tt.want.Keyboard = domain.Defaults().Keyboard
372 }
373 if tt.want.Echo.Predict == domain.EchoPredictNever {
374 tt.want.Echo = domain.Defaults().Echo
375 }
376 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
377 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
378 }
379 if tt.want.Copy.WordSeparators == "" {
380 tt.want.Copy = domain.Defaults().Copy
381 }
382 if !tt.want.Palette.AnchorSet {
383 tt.want.Palette = domain.Defaults().Palette
384 }
385 tt.want.Scrollback = domain.DefaultScrollbackConfig()
386 got, warnings, err := Parse(strings.NewReader(tt.input))
387 if err != nil {
388 t.Fatalf("Parse() error = %v", err)
389 }
390 if !reflect.DeepEqual(got, tt.want) {
391 t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
392 }
393 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
394 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
395 }
396 })
397 }
398}
399
400func TestParseThemePalette(t *testing.T) {
401 t.Parallel()
402
403 tests := []struct {
404 name string
405 input string
406 want bool
407 wantWarnings []domain.Warning
408 }{
409 {
410 name: "on enables terminal palette inheritance",
411 input: "theme.palette = on\n",
412 want: true,
413 },
414 {
415 name: "off disables terminal palette inheritance",
416 input: "theme.palette = off\n",
417 want: false,
418 },
419 {
420 name: "invalid value warns once and keeps default",
421 input: "theme.palette = maybe\n",
422 want: true,
423 wantWarnings: []domain.Warning{
424 {Line: 1, Msg: "invalid theme.palette \"maybe\""},
425 },
426 },
427 {
428 name: "absent key keeps default",
429 input: "theme = dark\n",
430 want: true,
431 },
432 }
433
434 for _, tt := range tests {
435 t.Run(tt.name, func(t *testing.T) {
436 t.Parallel()
437
438 got, warnings, err := Parse(strings.NewReader(tt.input))
439 require.NoError(t, err)
440 require.Equal(t, tt.want, got.ThemePalette)
441 require.Equal(t, tt.wantWarnings, warnings)
442 })
443 }
444}
445
446func TestParseBarBackground(t *testing.T) {
447 t.Parallel()
448
449 tests := []struct {
450 name string
451 input string
452 wantTransparent bool
453 wantWarnings []domain.Warning
454 }{
455 {name: "on keeps the theme surface", input: "bar.background = on\n"},
456 {name: "off makes bars transparent", input: "bar.background = off\n", wantTransparent: true},
457 {
458 name: "invalid value warns and keeps default",
459 input: "bar.background = none\n",
460 wantWarnings: []domain.Warning{
461 {Line: 1, Msg: "invalid bar.background \"none\""},
462 },
463 },
464 {name: "absent key keeps default", input: "bar.interval = 2s\n"},
465 }
466
467 for _, tt := range tests {
468 t.Run(tt.name, func(t *testing.T) {
469 t.Parallel()
470
471 got, warnings, err := Parse(strings.NewReader(tt.input))
472 require.NoError(t, err)
473 require.Equal(t, tt.wantTransparent, got.Bar.Transparent)
474 require.Equal(t, tt.wantWarnings, warnings)
475 })
476 }
477}
478
479func TestDefaultsThemePalette(t *testing.T) {
480 t.Parallel()
481
482 require.True(t, domain.Defaults().ThemePalette)
483}
484
485func TestParseTabsTerminalTitle(t *testing.T) {
486 t.Parallel()
487
488 tests := []struct {
489 name string
490 input string
491 want bool
492 wantWarnings []domain.Warning
493 }{
494 {
495 name: "on enables terminal title",
496 input: "tabs.terminal-title = on\n",
497 want: true,
498 },
499 {
500 name: "off disables terminal title",
501 input: "tabs.terminal-title = off\n",
502 want: false,
503 },
504 {
505 name: "invalid value warns and keeps default",
506 input: "tabs.terminal-title = maybe\n",
507 want: true,
508 wantWarnings: []domain.Warning{
509 {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
510 },
511 },
512 {
513 name: "absent key keeps default",
514 input: "theme = dark\n",
515 want: true,
516 },
517 }
518
519 for _, tt := range tests {
520 t.Run(tt.name, func(t *testing.T) {
521 t.Parallel()
522
523 got, warnings, err := Parse(strings.NewReader(tt.input))
524 if err != nil {
525 t.Fatalf("Parse() error = %v", err)
526 }
527 if got.Tabs.TerminalTitle != tt.want {
528 t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
529 }
530 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
531 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
532 }
533 })
534 }
535}
536
537func TestParseEphemeralCloseOnExit(t *testing.T) {
538 t.Parallel()
539
540 tests := []struct {
541 name string
542 input string
543 want bool
544 wantWarnings []domain.Warning
545 }{
546 {name: "absent key defaults on", input: "theme = dark\n", want: true},
547 {name: "on", input: "ephemeral.close-on-exit = on\n", want: true},
548 {name: "off", input: "ephemeral.close-on-exit = off\n", want: false},
549 {
550 name: "invalid value warns and keeps default",
551 input: "ephemeral.close-on-exit = kill\n",
552 want: true,
553 wantWarnings: []domain.Warning{
554 {Line: 1, Msg: "invalid ephemeral.close-on-exit \"kill\""},
555 },
556 },
557 }
558
559 for _, tt := range tests {
560 t.Run(tt.name, func(t *testing.T) {
561 t.Parallel()
562
563 got, warnings, err := Parse(strings.NewReader(tt.input))
564 if err != nil {
565 t.Fatalf("Parse() error = %v", err)
566 }
567 if got.Ephemeral.CloseOnExit != tt.want {
568 t.Fatalf("Parse() Ephemeral.CloseOnExit = %v, want %v", got.Ephemeral.CloseOnExit, tt.want)
569 }
570 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
571 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
572 }
573 })
574 }
575}
576
577func TestParseKeyboardKittyProtocol(t *testing.T) {
578 t.Parallel()
579
580 tests := []struct {
581 name string
582 input string
583 want bool
584 wantWarnings []domain.Warning
585 }{
586 {name: "absent key defaults on", input: "theme = dark\n", want: true},
587 {name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
588 {name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
589 {
590 name: "invalid value warns and keeps default",
591 input: "keyboard.kitty-protocol = maybe\n",
592 want: true,
593 wantWarnings: []domain.Warning{
594 {Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
595 },
596 },
597 }
598
599 for _, tt := range tests {
600 t.Run(tt.name, func(t *testing.T) {
601 t.Parallel()
602
603 got, warnings, err := Parse(strings.NewReader(tt.input))
604 if err != nil {
605 t.Fatalf("Parse() error = %v", err)
606 }
607 if got.Keyboard.KittyProtocol != tt.want {
608 t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
609 }
610 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
611 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
612 }
613 })
614 }
615}
616
617func TestParseEchoPredict(t *testing.T) {
618 t.Parallel()
619
620 tests := []struct {
621 name string
622 input string
623 want domain.EchoPredictMode
624 wantWarnings []domain.Warning
625 }{
626 {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
627 {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
628 {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
629 {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
630 {
631 name: "invalid value warns and keeps default",
632 input: "echo.predict = sometimes\n",
633 want: domain.EchoPredictAdaptive,
634 wantWarnings: []domain.Warning{
635 {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
636 },
637 },
638 {
639 name: "duplicate key warns and keeps last",
640 input: "echo.predict = always\necho.predict = never\n",
641 want: domain.EchoPredictNever,
642 wantWarnings: []domain.Warning{
643 {Line: 2, Msg: "duplicate key \"echo.predict\""},
644 },
645 },
646 }
647
648 for _, tt := range tests {
649 t.Run(tt.name, func(t *testing.T) {
650 t.Parallel()
651
652 got, warnings, err := Parse(strings.NewReader(tt.input))
653 if err != nil {
654 t.Fatalf("Parse() error = %v", err)
655 }
656 if got.Echo.Predict != tt.want {
657 t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
658 }
659 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
660 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
661 }
662 })
663 }
664}
665
666func TestParseNavigationOverflow(t *testing.T) {
667 t.Parallel()
668
669 tests := []struct {
670 name string
671 input string
672 want domain.NavConfig
673 wantWarnings []domain.Warning
674 }{
675 {
676 name: "absent defaults both settings off",
677 },
678 {
679 name: "tabs on enables only tab overflow",
680 input: "nav.overflow-tabs = on\n",
681 want: domain.NavConfig{OverflowTabs: true},
682 },
683 {
684 name: "sessions on enables only session overflow",
685 input: "nav.overflow-sessions = on\n",
686 want: domain.NavConfig{OverflowSessions: true},
687 },
688 {
689 name: "off disables both settings",
690 input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
691 },
692 {
693 name: "settings are independent",
694 input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
695 want: domain.NavConfig{OverflowTabs: true},
696 },
697 {
698 name: "invalid values warn and preserve defaults",
699 input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
700 wantWarnings: []domain.Warning{
701 {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
702 {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
703 },
704 },
705 {
706 name: "duplicate keys warn and last valid value wins",
707 input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
708 want: domain.NavConfig{OverflowTabs: true},
709 wantWarnings: []domain.Warning{
710 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
711 {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
712 },
713 },
714 {
715 name: "invalid duplicate still warns and keeps previous value",
716 input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
717 want: domain.NavConfig{OverflowTabs: true},
718 wantWarnings: []domain.Warning{
719 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
720 {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
721 },
722 },
723 }
724
725 for _, tt := range tests {
726 t.Run(tt.name, func(t *testing.T) {
727 t.Parallel()
728
729 cfg, warnings, err := Parse(strings.NewReader(tt.input))
730 require.NoError(t, err)
731 require.Equal(t, tt.want, cfg.Nav)
732 require.Equal(t, tt.wantWarnings, warnings)
733 })
734 }
735}
736
737func TestDefaultsDisableNavigationOverflow(t *testing.T) {
738 t.Parallel()
739
740 require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
741}
742
743func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
744 t.Parallel()
745
746 first := domain.Defaults()
747 first.Snapshot.RestoreProcesses[0] = "mutated"
748 second := domain.Defaults()
749 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
750}
751
752func TestParseCopyWordSeparators(t *testing.T) {
753 t.Parallel()
754
755 tests := []struct {
756 name string
757 input string
758 want string
759 wantWarnings []domain.Warning
760 }{
761 {
762 name: "default",
763 input: "",
764 want: domain.DefaultWordSeparators,
765 },
766 {
767 name: "quoted preserves leading separator space",
768 input: `copy.word-separators = " -_@/"`,
769 want: " -_@/",
770 },
771 {
772 name: "quoted empty is valid",
773 input: `copy.word-separators = ""`,
774 want: "",
775 },
776 {
777 name: "unquoted empty is valid",
778 input: "copy.word-separators =",
779 want: "",
780 },
781 {
782 name: "malformed quote keeps default",
783 input: `copy.word-separators = "unterminated`,
784 want: domain.DefaultWordSeparators,
785 wantWarnings: []domain.Warning{
786 {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
787 },
788 },
789 {
790 name: "duplicate last value wins",
791 input: "copy.word-separators = /\ncopy.word-separators = _",
792 want: "_",
793 wantWarnings: []domain.Warning{
794 {Line: 2, Msg: `duplicate key "copy.word-separators"`},
795 },
796 },
797 }
798
799 for _, tt := range tests {
800 t.Run(tt.name, func(t *testing.T) {
801 t.Parallel()
802
803 cfg, warnings, err := Parse(strings.NewReader(tt.input))
804 require.NoError(t, err)
805 require.Equal(t, tt.want, cfg.Copy.WordSeparators)
806 require.Equal(t, tt.wantWarnings, warnings)
807 })
808 }
809}
810
811func TestDefaultsCopyWordSeparators(t *testing.T) {
812 t.Parallel()
813
814 require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
815}
816
817func TestParsePreservesBindingFileOrder(t *testing.T) {
818 t.Parallel()
819
820 cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
821 if err != nil {
822 t.Fatalf("Parse() error = %v", err)
823 }
824 if len(warnings) != 0 {
825 t.Fatalf("Parse() warnings = %#v, want none", warnings)
826 }
827 want := []domain.ConfigEntry{
828 {Key: "open-palette", Value: "alt+p"},
829 {Key: "focus-pane-left", Value: "alt+p"},
830 }
831 if !reflect.DeepEqual(cfg.BindingEntries, want) {
832 t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
833 }
834}
835
836func TestLoadMissingFileReturnsDefaults(t *testing.T) {
837 t.Parallel()
838
839 cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
840 if err != nil {
841 t.Fatalf("Load() error = %v", err)
842 }
843 if len(warnings) != 0 {
844 t.Fatalf("Load() warnings = %#v, want none", warnings)
845 }
846 if !reflect.DeepEqual(cfg, domain.Defaults()) {
847 t.Fatalf("Load() config = %#v, want defaults", cfg)
848 }
849}
850
851type watchChange struct {
852 cfg domain.Config
853 warnings []domain.Warning
854}
855
856// startWatch runs watch on path and returns the onChange stream once the
857// watches are installed. The returned stop cancels it and checks its result.
858func startWatch(t *testing.T, path string) (<-chan watchChange, func()) {
859 t.Helper()
860 ctx, cancel := context.WithCancel(context.Background())
861 t.Cleanup(cancel)
862
863 changes := make(chan watchChange, 8)
864 ready := make(chan struct{})
865 done := make(chan error, 1)
866 go func() {
867 done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready},
868 func(cfg domain.Config, warnings []domain.Warning) {
869 changes <- watchChange{cfg: cfg, warnings: warnings}
870 })
871 }()
872 select {
873 case <-ready:
874 case err := <-done:
875 t.Fatalf("watch() returned before ready: %v", err)
876 case <-time.After(5 * time.Second):
877 t.Fatal("timed out waiting for the watcher to be ready")
878 }
879 return changes, func() {
880 t.Helper()
881 cancel()
882 select {
883 case err := <-done:
884 require.ErrorIs(t, err, context.Canceled)
885 case <-time.After(5 * time.Second):
886 t.Fatal("timed out waiting for watch() to return")
887 }
888 }
889}
890
891// writeAtomic replaces path in one step, like most editors: the watcher
892// never sees a truncated file, whatever the scheduling.
893func writeAtomic(t *testing.T, path string, data []byte) {
894 t.Helper()
895 tmp := path + ".tmp"
896 require.NoError(t, os.WriteFile(tmp, data, 0o600))
897 require.NoError(t, os.Rename(tmp, path))
898}
899
900func nextChange(t *testing.T, changes <-chan watchChange) watchChange {
901 t.Helper()
902 select {
903 case got := <-changes:
904 return got
905 case <-time.After(5 * time.Second):
906 t.Fatal("timed out waiting for config reload")
907 return watchChange{}
908 }
909}
910
911func TestWatchReloadsOnChange(t *testing.T) {
912 t.Parallel()
913
914 light := domain.Defaults()
915 light.Theme = domain.ThemeLight
916
917 tests := []struct {
918 name string
919 initial *string // nil: the file does not exist yet
920 write func(t *testing.T, path string)
921 wantConfig domain.Config
922 wantWarnings []domain.Warning
923 wantAnyWarn bool
924 }{
925 {
926 name: "changed file reloads with parse warnings",
927 initial: ptr("theme = dark\n"),
928 write: func(t *testing.T, path string) {
929 writeAtomic(t, path, []byte("theme = light\nbad line\n"))
930 },
931 wantConfig: light,
932 wantWarnings: []domain.Warning{{Line: 2, Msg: "missing '='"}},
933 },
934 {
935 name: "created file loads",
936 initial: nil,
937 write: func(t *testing.T, path string) {
938 writeAtomic(t, path, []byte("theme = light\n"))
939 },
940 wantConfig: light,
941 },
942 {
943 name: "deleted file reloads to defaults",
944 initial: ptr("theme = light\n"),
945 write: func(t *testing.T, path string) {
946 require.NoError(t, os.Remove(path))
947 },
948 wantConfig: domain.Defaults(),
949 },
950 {
951 name: "load failure reports defaults and a warning",
952 initial: ptr("theme = light\n"),
953 write: func(t *testing.T, path string) {
954 // Larger than kvconf.DefaultMaxBytes.
955 writeAtomic(t, path, []byte(strings.Repeat("# x\n", kvconf.DefaultMaxBytes/4+1)))
956 },
957 wantConfig: domain.Defaults(),
958 wantAnyWarn: true,
959 },
960 {
961 name: "invalid UTF-8 reports defaults and a warning",
962 initial: ptr("theme = light\n"),
963 write: func(t *testing.T, path string) {
964 writeAtomic(t, path, []byte("theme = \xff\n"))
965 },
966 wantConfig: domain.Defaults(),
967 wantAnyWarn: true,
968 },
969 }
970 for _, tt := range tests {
971 t.Run(tt.name, func(t *testing.T) {
972 t.Parallel()
973
974 path := filepath.Join(t.TempDir(), "config")
975 if tt.initial != nil {
976 writeAtomic(t, path, []byte(*tt.initial))
977 }
978 changes, stop := startWatch(t, path)
979 tt.write(t, path)
980
981 got := nextChange(t, changes)
982 require.Equal(t, tt.wantConfig, got.cfg)
983 if tt.wantAnyWarn {
984 require.NotEmpty(t, got.warnings)
985 } else {
986 require.Equal(t, tt.wantWarnings, got.warnings)
987 }
988 stop()
989 })
990 }
991}
992
993// A load error is reported once until a good load, then again.
994func TestWatchReportsLoadErrorOnce(t *testing.T) {
995 t.Parallel()
996
997 path := filepath.Join(t.TempDir(), "config")
998 writeAtomic(t, path, []byte("theme = light\n"))
999 changes, stop := startWatch(t, path)
1000 invalid := []byte("theme = \xff\n")
1001
1002 writeAtomic(t, path, invalid)
1003 got := nextChange(t, changes)
1004 require.Equal(t, domain.Defaults(), got.cfg)
1005 require.Len(t, got.warnings, 1)
1006
1007 // The same failure again is not reported; the next good load is.
1008 writeAtomic(t, path, invalid)
1009 writeAtomic(t, path, []byte("theme = dark\n"))
1010 got = nextChange(t, changes)
1011 require.Equal(t, domain.ThemeDark, got.cfg.Theme)
1012 require.Empty(t, got.warnings)
1013
1014 // After a good load, the failure is news again.
1015 writeAtomic(t, path, invalid)
1016 got = nextChange(t, changes)
1017 require.Len(t, got.warnings, 1)
1018 stop()
1019}
1020
1021// A stat error at startup other than a missing file is reported.
1022func TestWatchReportsStartupStatError(t *testing.T) {
1023 t.Parallel()
1024
1025 // A name longer than NAME_MAX makes every stat fail.
1026 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
1027 ctx, cancel := context.WithCancel(context.Background())
1028 defer cancel()
1029 changes := make(chan watchChange, 4)
1030 done := make(chan error, 1)
1031 go func() {
1032 done <- watch(ctx, path, kvconf.WatchOptions{Poll: time.Hour}, func(cfg domain.Config, warnings []domain.Warning) {
1033 changes <- watchChange{cfg: cfg, warnings: warnings}
1034 })
1035 }()
1036 got := nextChange(t, changes)
1037 require.Equal(t, domain.Defaults(), got.cfg)
1038 require.Len(t, got.warnings, 1)
1039 cancel()
1040 select {
1041 case <-done:
1042 case <-time.After(5 * time.Second):
1043 t.Fatal("timed out waiting for watch() to return")
1044 }
1045}
1046
1047func ptr[T any](v T) *T { return &v }
1048
1049// When native watching fails (inotify limits), watch keeps the current
1050// config, warns once and polls instead of giving up.
1051// Not parallel: it replaces kvWatch.
1052func TestWatchFallsBackToPolling(t *testing.T) {
1053 path := filepath.Join(t.TempDir(), "config")
1054 writeAtomic(t, path, []byte("theme = light\n"))
1055 var polled atomic.Bool
1056 orig := kvWatch
1057 t.Cleanup(func() { kvWatch = orig })
1058 kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
1059 if opts.Poll == 0 {
1060 return errors.New("too many open files")
1061 }
1062 polled.Store(true)
1063 <-ctx.Done()
1064 return ctx.Err()
1065 }
1066 ctx, cancel := context.WithCancel(context.Background())
1067 changes := make(chan watchChange, 4)
1068 done := make(chan error, 1)
1069 go func() {
1070 done <- watch(ctx, path, kvconf.WatchOptions{}, func(cfg domain.Config, warnings []domain.Warning) {
1071 changes <- watchChange{cfg: cfg, warnings: warnings}
1072 })
1073 }()
1074 got := nextChange(t, changes)
1075 require.Equal(t, domain.ThemeLight, got.cfg.Theme, "the current config is kept")
1076 require.Len(t, got.warnings, 1)
1077 require.Contains(t, got.warnings[0].Msg, "polling every")
1078 cancel()
1079 require.ErrorIs(t, <-done, context.Canceled)
1080 require.True(t, polled.Load())
1081}