1package config
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/vev/internal/domain"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/stretchr/testify/require"
16)
17
18func TestDefaultsDisableBarCommands(t *testing.T) {
19 bar := domain.Defaults().Bar
20 require.Empty(t, bar.TopRight)
21 require.Empty(t, bar.BottomRight)
22}
23
24func TestParse(t *testing.T) {
25 t.Parallel()
26
27 tests := []struct {
28 name string
29 input string
30 want domain.Config
31 wantWarnings []domain.Warning
32 }{
33 {
34 name: "comments and inline comments",
35 input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
36 want: domain.Config{
37 Theme: domain.ThemeAuto,
38 BindingEntries: []domain.ConfigEntry{
39 {Key: "new-tab", Value: "alt+t"},
40 {Key: "detach", Value: "alt+#"},
41 },
42 Codes: map[string]string{},
43 },
44 },
45 {
46 name: "crlf and code prefix",
47 input: "theme = light\r\ncode.detach = dt\r\n",
48 want: domain.Config{
49 Theme: domain.ThemeLight,
50 BindingEntries: []domain.ConfigEntry{},
51 Codes: map[string]string{"detach": "dt"},
52 },
53 },
54 {
55 name: "invalid theme warns and keeps default",
56 input: "theme = blue\n",
57 want: domain.Config{
58 Theme: domain.ThemeAuto,
59 BindingEntries: []domain.ConfigEntry{},
60 Codes: map[string]string{},
61 },
62 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
63 },
64 {
65 name: "unknown binding key stored",
66 input: "unknown.action = alt+x\n",
67 want: domain.Config{
68 Theme: domain.ThemeAuto,
69 BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
70 Codes: map[string]string{},
71 },
72 },
73 {
74 name: "duplicates warn and last wins",
75 input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
76 want: domain.Config{
77 Theme: domain.ThemeAuto,
78 BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
79 Codes: map[string]string{"detach": "DX"},
80 },
81 wantWarnings: []domain.Warning{
82 {Line: 2, Msg: "duplicate key \"new-tab\""},
83 {Line: 4, Msg: "duplicate key \"code.detach\""},
84 },
85 },
86 {
87 name: "invalid lines report line numbers",
88 input: "\nno equals\n = value\ntheme = dark\n",
89 want: domain.Config{
90 Theme: domain.ThemeDark,
91 BindingEntries: []domain.ConfigEntry{},
92 Codes: map[string]string{},
93 },
94 wantWarnings: []domain.Warning{
95 {Line: 2, Msg: "missing '='"},
96 {Line: 3, Msg: "missing key"},
97 },
98 },
99 {
100 name: "floating settings custom values",
101 input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
102 want: domain.Config{
103 Theme: domain.ThemeAuto,
104 Floating: domain.FloatingConfig{
105 Command: "btop --utf-force",
106 Width: 75,
107 Height: 60,
108 },
109 BindingEntries: []domain.ConfigEntry{},
110 Codes: map[string]string{},
111 },
112 },
113 {
114 name: "floating command can be empty or quoted",
115 input: "floating.command = echo ready\nfloating.command = \"\"\n",
116 want: domain.Config{
117 Theme: domain.ThemeAuto,
118 Floating: domain.FloatingConfig{
119 Width: 80,
120 Height: 80,
121 },
122 BindingEntries: []domain.ConfigEntry{},
123 Codes: map[string]string{},
124 },
125 wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
126 },
127 {
128 name: "floating percentage endpoints",
129 input: "floating.width = 1%\nfloating.height = 100%\n",
130 want: domain.Config{
131 Theme: domain.ThemeAuto,
132 Floating: domain.FloatingConfig{
133 Width: 1,
134 Height: 100,
135 },
136 BindingEntries: []domain.ConfigEntry{},
137 Codes: map[string]string{},
138 },
139 },
140 {
141 name: "floating invalid percentages warn and preserve defaults",
142 input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
143 want: domain.Config{
144 Theme: domain.ThemeAuto,
145 Floating: domain.FloatingConfig{
146 Width: 80,
147 Height: 80,
148 },
149 BindingEntries: []domain.ConfigEntry{},
150 Codes: map[string]string{},
151 },
152 wantWarnings: []domain.Warning{
153 {Line: 1, Msg: "invalid floating.width \"80\""},
154 {Line: 2, Msg: "invalid floating.height \"0%\""},
155 {Line: 3, Msg: "duplicate key \"floating.width\""},
156 {Line: 3, Msg: "invalid floating.width \"101%\""},
157 {Line: 4, Msg: "duplicate key \"floating.height\""},
158 {Line: 4, Msg: "invalid floating.height \"eighty%\""},
159 },
160 },
161 {
162 name: "floating duplicate keys warn and last valid value wins",
163 input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
164 want: domain.Config{
165 Theme: domain.ThemeAuto,
166 Floating: domain.FloatingConfig{
167 Command: "lazygit",
168 Width: 90,
169 Height: 85,
170 },
171 BindingEntries: []domain.ConfigEntry{},
172 Codes: map[string]string{},
173 },
174 wantWarnings: []domain.Warning{
175 {Line: 2, Msg: "duplicate key \"floating.command\""},
176 {Line: 4, Msg: "duplicate key \"floating.width\""},
177 {Line: 6, Msg: "duplicate key \"floating.height\""},
178 },
179 },
180 {
181 name: "floating malformed quoted command warns and keeps default",
182 input: "floating.command = \"bad\\q\"\n",
183 want: domain.Config{
184 Theme: domain.ThemeAuto,
185 BindingEntries: []domain.ConfigEntry{},
186 Codes: map[string]string{},
187 },
188 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
189 },
190 {
191 name: "bar settings custom values",
192 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
193 want: domain.Config{
194 Theme: domain.ThemeAuto,
195 Bar: domain.BarConfig{
196 TopRight: "custom-top",
197 BottomRight: "custom-bottom",
198 Interval: 2 * time.Second,
199 },
200 BindingEntries: []domain.ConfigEntry{},
201 Codes: map[string]string{},
202 },
203 },
204 {
205 name: "bar settings quoted commands unquote",
206 input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
207 want: domain.Config{
208 Theme: domain.ThemeAuto,
209 Bar: domain.BarConfig{
210 TopRight: "date +%H:%M",
211 BottomRight: "",
212 Interval: 5 * time.Second,
213 },
214 BindingEntries: []domain.ConfigEntry{},
215 Codes: map[string]string{},
216 },
217 },
218 {
219 name: "bar settings quoted commands preserve hash",
220 input: "bar.top-right = \"echo foo #1\" # inline comment\n",
221 want: domain.Config{
222 Theme: domain.ThemeAuto,
223 Bar: domain.BarConfig{
224 TopRight: "echo foo #1",
225 BottomRight: "",
226 Interval: 5 * time.Second,
227 },
228 BindingEntries: []domain.ConfigEntry{},
229 Codes: map[string]string{},
230 },
231 },
232 {
233 name: "bar settings malformed quoted command warns and keeps default",
234 input: "bar.top-right = \"bad\\q\"\n",
235 want: domain.Config{
236 Theme: domain.ThemeAuto,
237 Bar: domain.BarConfig{
238 TopRight: "",
239 BottomRight: "",
240 Interval: 5 * time.Second,
241 },
242 BindingEntries: []domain.ConfigEntry{},
243 Codes: map[string]string{},
244 },
245 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
246 },
247 {
248 name: "bar settings empty commands disable anchors",
249 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\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 },
261 {
262 name: "bar interval invalid warns and keeps default",
263 input: "bar.interval = nope\n",
264 want: domain.Config{
265 Theme: domain.ThemeAuto,
266 Bar: domain.BarConfig{
267 TopRight: "",
268 BottomRight: "",
269 Interval: 5 * time.Second,
270 },
271 BindingEntries: []domain.ConfigEntry{},
272 Codes: map[string]string{},
273 },
274 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
275 },
276 {
277 name: "bar interval below minimum clamps and warns",
278 input: "bar.interval = 100ms\n",
279 want: domain.Config{
280 Theme: domain.ThemeAuto,
281 Bar: domain.BarConfig{
282 TopRight: "",
283 BottomRight: "",
284 Interval: time.Second,
285 },
286 BindingEntries: []domain.ConfigEntry{},
287 Codes: map[string]string{},
288 },
289 wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
290 },
291 {
292 name: "snapshot restore processes",
293 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
294 want: domain.Config{
295 Theme: domain.ThemeAuto,
296 BindingEntries: []domain.ConfigEntry{},
297 Codes: map[string]string{},
298 Snapshot: domain.SnapshotConfig{
299 RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
300 RestoreProcessesSet: true,
301 },
302 },
303 },
304 {
305 name: "snapshot restore processes empty disables",
306 input: "snapshot.restore_processes =\n",
307 want: domain.Config{
308 Theme: domain.ThemeAuto,
309 BindingEntries: []domain.ConfigEntry{},
310 Codes: map[string]string{},
311 Snapshot: domain.SnapshotConfig{
312 RestoreProcessesSet: true,
313 },
314 },
315 },
316 {
317 name: "snapshot restore processes warns on malformed entries",
318 input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
319 want: domain.Config{
320 Theme: domain.ThemeAuto,
321 BindingEntries: []domain.ConfigEntry{},
322 Codes: map[string]string{},
323 Snapshot: domain.SnapshotConfig{
324 RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
325 RestoreProcessesSet: true,
326 },
327 },
328 wantWarnings: []domain.Warning{
329 {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
330 {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
331 {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
332 },
333 },
334 }
335
336 for _, tt := range tests {
337 t.Run(tt.name, func(t *testing.T) {
338 t.Parallel()
339
340 if !tt.want.ThemePalette {
341 tt.want.ThemePalette = domain.Defaults().ThemePalette
342 }
343 if tt.want.Bar.Interval == 0 {
344 tt.want.Bar = domain.Defaults().Bar
345 }
346 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
347 tt.want.Floating = domain.Defaults().Floating
348 }
349 if !tt.want.Tabs.TerminalTitle {
350 tt.want.Tabs = domain.Defaults().Tabs
351 }
352 if !tt.want.Ephemeral.CloseOnExit {
353 tt.want.Ephemeral = domain.Defaults().Ephemeral
354 }
355 if !tt.want.Keyboard.KittyProtocol {
356 tt.want.Keyboard = domain.Defaults().Keyboard
357 }
358 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
359 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
360 }
361 if tt.want.Copy.WordSeparators == "" {
362 tt.want.Copy = domain.Defaults().Copy
363 }
364 if !tt.want.Palette.AnchorSet {
365 tt.want.Palette = domain.Defaults().Palette
366 }
367 tt.want.Scrollback = domain.DefaultScrollbackConfig()
368 got, warnings, err := Parse(strings.NewReader(tt.input))
369 if err != nil {
370 t.Fatalf("Parse() error = %v", err)
371 }
372 if !reflect.DeepEqual(got, tt.want) {
373 t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
374 }
375 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
376 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
377 }
378 })
379 }
380}
381
382func TestParseThemePalette(t *testing.T) {
383 t.Parallel()
384
385 tests := []struct {
386 name string
387 input string
388 want bool
389 wantWarnings []domain.Warning
390 }{
391 {
392 name: "on enables terminal palette inheritance",
393 input: "theme.palette = on\n",
394 want: true,
395 },
396 {
397 name: "off disables terminal palette inheritance",
398 input: "theme.palette = off\n",
399 want: false,
400 },
401 {
402 name: "invalid value warns once and keeps default",
403 input: "theme.palette = maybe\n",
404 want: true,
405 wantWarnings: []domain.Warning{
406 {Line: 1, Msg: "invalid theme.palette \"maybe\""},
407 },
408 },
409 {
410 name: "absent key keeps default",
411 input: "theme = dark\n",
412 want: true,
413 },
414 }
415
416 for _, tt := range tests {
417 t.Run(tt.name, func(t *testing.T) {
418 t.Parallel()
419
420 got, warnings, err := Parse(strings.NewReader(tt.input))
421 require.NoError(t, err)
422 require.Equal(t, tt.want, got.ThemePalette)
423 require.Equal(t, tt.wantWarnings, warnings)
424 })
425 }
426}
427
428func TestDefaultsThemePalette(t *testing.T) {
429 t.Parallel()
430
431 require.True(t, domain.Defaults().ThemePalette)
432}
433
434func TestParseTabsTerminalTitle(t *testing.T) {
435 t.Parallel()
436
437 tests := []struct {
438 name string
439 input string
440 want bool
441 wantWarnings []domain.Warning
442 }{
443 {
444 name: "on enables terminal title",
445 input: "tabs.terminal-title = on\n",
446 want: true,
447 },
448 {
449 name: "off disables terminal title",
450 input: "tabs.terminal-title = off\n",
451 want: false,
452 },
453 {
454 name: "invalid value warns and keeps default",
455 input: "tabs.terminal-title = maybe\n",
456 want: true,
457 wantWarnings: []domain.Warning{
458 {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
459 },
460 },
461 {
462 name: "absent key keeps default",
463 input: "theme = dark\n",
464 want: true,
465 },
466 }
467
468 for _, tt := range tests {
469 t.Run(tt.name, func(t *testing.T) {
470 t.Parallel()
471
472 got, warnings, err := Parse(strings.NewReader(tt.input))
473 if err != nil {
474 t.Fatalf("Parse() error = %v", err)
475 }
476 if got.Tabs.TerminalTitle != tt.want {
477 t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
478 }
479 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
480 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
481 }
482 })
483 }
484}
485
486func TestParseEphemeralCloseOnExit(t *testing.T) {
487 t.Parallel()
488
489 tests := []struct {
490 name string
491 input string
492 want bool
493 wantWarnings []domain.Warning
494 }{
495 {name: "absent key defaults on", input: "theme = dark\n", want: true},
496 {name: "on", input: "ephemeral.close-on-exit = on\n", want: true},
497 {name: "off", input: "ephemeral.close-on-exit = off\n", want: false},
498 {
499 name: "invalid value warns and keeps default",
500 input: "ephemeral.close-on-exit = kill\n",
501 want: true,
502 wantWarnings: []domain.Warning{
503 {Line: 1, Msg: "invalid ephemeral.close-on-exit \"kill\""},
504 },
505 },
506 }
507
508 for _, tt := range tests {
509 t.Run(tt.name, func(t *testing.T) {
510 t.Parallel()
511
512 got, warnings, err := Parse(strings.NewReader(tt.input))
513 if err != nil {
514 t.Fatalf("Parse() error = %v", err)
515 }
516 if got.Ephemeral.CloseOnExit != tt.want {
517 t.Fatalf("Parse() Ephemeral.CloseOnExit = %v, want %v", got.Ephemeral.CloseOnExit, tt.want)
518 }
519 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
520 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
521 }
522 })
523 }
524}
525
526func TestParseKeyboardKittyProtocol(t *testing.T) {
527 t.Parallel()
528
529 tests := []struct {
530 name string
531 input string
532 want bool
533 wantWarnings []domain.Warning
534 }{
535 {name: "absent key defaults on", input: "theme = dark\n", want: true},
536 {name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
537 {name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
538 {
539 name: "invalid value warns and keeps default",
540 input: "keyboard.kitty-protocol = maybe\n",
541 want: true,
542 wantWarnings: []domain.Warning{
543 {Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
544 },
545 },
546 }
547
548 for _, tt := range tests {
549 t.Run(tt.name, func(t *testing.T) {
550 t.Parallel()
551
552 got, warnings, err := Parse(strings.NewReader(tt.input))
553 if err != nil {
554 t.Fatalf("Parse() error = %v", err)
555 }
556 if got.Keyboard.KittyProtocol != tt.want {
557 t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
558 }
559 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
560 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
561 }
562 })
563 }
564}
565
566func TestParseNavigationOverflow(t *testing.T) {
567 t.Parallel()
568
569 tests := []struct {
570 name string
571 input string
572 want domain.NavConfig
573 wantWarnings []domain.Warning
574 }{
575 {
576 name: "absent defaults both settings off",
577 },
578 {
579 name: "tabs on enables only tab overflow",
580 input: "nav.overflow-tabs = on\n",
581 want: domain.NavConfig{OverflowTabs: true},
582 },
583 {
584 name: "sessions on enables only session overflow",
585 input: "nav.overflow-sessions = on\n",
586 want: domain.NavConfig{OverflowSessions: true},
587 },
588 {
589 name: "off disables both settings",
590 input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
591 },
592 {
593 name: "settings are independent",
594 input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
595 want: domain.NavConfig{OverflowTabs: true},
596 },
597 {
598 name: "invalid values warn and preserve defaults",
599 input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
600 wantWarnings: []domain.Warning{
601 {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
602 {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
603 },
604 },
605 {
606 name: "duplicate keys warn and last valid value wins",
607 input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
608 want: domain.NavConfig{OverflowTabs: true},
609 wantWarnings: []domain.Warning{
610 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
611 {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
612 },
613 },
614 {
615 name: "invalid duplicate still warns and keeps previous value",
616 input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
617 want: domain.NavConfig{OverflowTabs: true},
618 wantWarnings: []domain.Warning{
619 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
620 {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
621 },
622 },
623 }
624
625 for _, tt := range tests {
626 t.Run(tt.name, func(t *testing.T) {
627 t.Parallel()
628
629 cfg, warnings, err := Parse(strings.NewReader(tt.input))
630 require.NoError(t, err)
631 require.Equal(t, tt.want, cfg.Nav)
632 require.Equal(t, tt.wantWarnings, warnings)
633 })
634 }
635}
636
637func TestDefaultsDisableNavigationOverflow(t *testing.T) {
638 t.Parallel()
639
640 require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
641}
642
643func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
644 t.Parallel()
645
646 first := domain.Defaults()
647 first.Snapshot.RestoreProcesses[0] = "mutated"
648 second := domain.Defaults()
649 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
650}
651
652func TestParseCopyWordSeparators(t *testing.T) {
653 t.Parallel()
654
655 tests := []struct {
656 name string
657 input string
658 want string
659 wantWarnings []domain.Warning
660 }{
661 {
662 name: "default",
663 input: "",
664 want: domain.DefaultWordSeparators,
665 },
666 {
667 name: "quoted preserves leading separator space",
668 input: `copy.word-separators = " -_@/"`,
669 want: " -_@/",
670 },
671 {
672 name: "quoted empty is valid",
673 input: `copy.word-separators = ""`,
674 want: "",
675 },
676 {
677 name: "unquoted empty is valid",
678 input: "copy.word-separators =",
679 want: "",
680 },
681 {
682 name: "malformed quote keeps default",
683 input: `copy.word-separators = "unterminated`,
684 want: domain.DefaultWordSeparators,
685 wantWarnings: []domain.Warning{
686 {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
687 },
688 },
689 {
690 name: "duplicate last value wins",
691 input: "copy.word-separators = /\ncopy.word-separators = _",
692 want: "_",
693 wantWarnings: []domain.Warning{
694 {Line: 2, Msg: `duplicate key "copy.word-separators"`},
695 },
696 },
697 }
698
699 for _, tt := range tests {
700 t.Run(tt.name, func(t *testing.T) {
701 t.Parallel()
702
703 cfg, warnings, err := Parse(strings.NewReader(tt.input))
704 require.NoError(t, err)
705 require.Equal(t, tt.want, cfg.Copy.WordSeparators)
706 require.Equal(t, tt.wantWarnings, warnings)
707 })
708 }
709}
710
711func TestDefaultsCopyWordSeparators(t *testing.T) {
712 t.Parallel()
713
714 require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
715}
716
717func TestParsePreservesBindingFileOrder(t *testing.T) {
718 t.Parallel()
719
720 cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
721 if err != nil {
722 t.Fatalf("Parse() error = %v", err)
723 }
724 if len(warnings) != 0 {
725 t.Fatalf("Parse() warnings = %#v, want none", warnings)
726 }
727 want := []domain.ConfigEntry{
728 {Key: "open-palette", Value: "alt+p"},
729 {Key: "focus-pane-left", Value: "alt+p"},
730 }
731 if !reflect.DeepEqual(cfg.BindingEntries, want) {
732 t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
733 }
734}
735
736func TestLoadMissingFileReturnsDefaults(t *testing.T) {
737 t.Parallel()
738
739 cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
740 if err != nil {
741 t.Fatalf("Load() error = %v", err)
742 }
743 if len(warnings) != 0 {
744 t.Fatalf("Load() warnings = %#v, want none", warnings)
745 }
746 if !reflect.DeepEqual(cfg, domain.Defaults()) {
747 t.Fatalf("Load() config = %#v, want defaults", cfg)
748 }
749}
750
751func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
752 t.Parallel()
753
754 path := filepath.Join(t.TempDir(), "config")
755 if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
756 t.Fatal(err)
757 }
758
759 clk := newWatchClockMock(t)
760 ctx, cancel := context.WithCancel(context.Background())
761 defer cancel()
762
763 type change struct {
764 cfg domain.Config
765 warnings []domain.Warning
766 }
767 changes := make(chan change, 2)
768 done := make(chan error, 1)
769 go func() {
770 done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
771 changes <- change{cfg: cfg, warnings: warnings}
772 })
773 }()
774
775 clk.waitForTimer()
776 if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
777 t.Fatal(err)
778 }
779 clk.fire()
780
781 var got change
782 select {
783 case got = <-changes:
784 case <-time.After(2 * time.Second):
785 t.Fatal("timed out waiting for config reload")
786 }
787 if got.cfg.Theme != domain.ThemeLight {
788 t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
789 }
790 if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
791 t.Fatalf("Watch() warnings = %#v", got.warnings)
792 }
793
794 cancel()
795 if err := <-done; !errors.Is(err, context.Canceled) {
796 t.Fatalf("Watch() error = %v, want context.Canceled", err)
797 }
798}
799
800func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
801 t.Parallel()
802
803 path := filepath.Join(t.TempDir(), "config")
804 if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
805 t.Fatal(err)
806 }
807
808 clk := newWatchClockMock(t)
809 ctx, cancel := context.WithCancel(context.Background())
810 defer cancel()
811
812 type change struct {
813 cfg domain.Config
814 warnings []domain.Warning
815 }
816 changes := make(chan change, 2)
817 done := make(chan error, 1)
818 go func() {
819 done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
820 changes <- change{cfg: cfg, warnings: warnings}
821 })
822 }()
823
824 clk.waitForTimer()
825 tooLong := strings.Repeat("x", 70*1024)
826 if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
827 t.Fatal(err)
828 }
829 clk.fire()
830
831 got := <-changes
832 require.Equal(t, domain.Defaults(), got.cfg)
833 require.NotEmpty(t, got.warnings)
834
835 cancel()
836 if err := <-done; !errors.Is(err, context.Canceled) {
837 t.Fatalf("Watch() error = %v, want context.Canceled", err)
838 }
839}
840
841func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
842 t.Parallel()
843
844 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
845 clk := newWatchClockMock(t)
846 ctx, cancel := context.WithCancel(context.Background())
847 defer cancel()
848
849 changes := make(chan []domain.Warning, 4)
850 done := make(chan error, 1)
851 go func() {
852 done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
853 changes <- warnings
854 })
855 }()
856
857 first := <-changes
858 require.Len(t, first, 1)
859 clk.waitForTimer()
860 clk.fire()
861 clk.fire()
862
863 select {
864 case extra := <-changes:
865 t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
866 case <-time.After(100 * time.Millisecond):
867 }
868
869 cancel()
870 if err := <-done; !errors.Is(err, context.Canceled) {
871 t.Fatalf("Watch() error = %v, want context.Canceled", err)
872 }
873}
874
875type watchClockMock struct {
876 t *testing.T
877 clock *portsmocks.MockClock
878 ticks chan time.Time
879 ready chan struct{}
880 resets chan struct{}
881}
882
883func newWatchClockMock(t *testing.T) *watchClockMock {
884 t.Helper()
885 timer := portsmocks.NewMockTimer(t)
886 clock := portsmocks.NewMockClock(t)
887 m := &watchClockMock{
888 t: t,
889 clock: clock,
890 ticks: make(chan time.Time),
891 ready: make(chan struct{}),
892 resets: make(chan struct{}),
893 }
894 timer.EXPECT().C().Return(m.ticks)
895 timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
896 m.resets <- struct{}{}
897 }).Return(false)
898 timer.EXPECT().Stop().Return(true)
899 clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
900 close(m.ready)
901 }).Return(timer).Once()
902 return m
903}
904
905func (m *watchClockMock) waitForTimer() {
906 m.t.Helper()
907 select {
908 case <-m.ready:
909 case <-time.After(time.Second):
910 m.t.Fatal("timed out waiting for watcher timer creation")
911 }
912}
913
914func (m *watchClockMock) fire() {
915 m.t.Helper()
916 select {
917 case m.ticks <- time.Now():
918 case <-time.After(time.Second):
919 m.t.Fatal("timed out firing watcher timer")
920 }
921 select {
922 case <-m.resets:
923 case <-time.After(time.Second):
924 m.t.Fatal("timed out waiting for watcher timer reset")
925 }
926}