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.Echo.Predict == domain.EchoPredictNever {
359 tt.want.Echo = domain.Defaults().Echo
360 }
361 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
362 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
363 }
364 if tt.want.Copy.WordSeparators == "" {
365 tt.want.Copy = domain.Defaults().Copy
366 }
367 if !tt.want.Palette.AnchorSet {
368 tt.want.Palette = domain.Defaults().Palette
369 }
370 tt.want.Scrollback = domain.DefaultScrollbackConfig()
371 got, warnings, err := Parse(strings.NewReader(tt.input))
372 if err != nil {
373 t.Fatalf("Parse() error = %v", err)
374 }
375 if !reflect.DeepEqual(got, tt.want) {
376 t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
377 }
378 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
379 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
380 }
381 })
382 }
383}
384
385func TestParseThemePalette(t *testing.T) {
386 t.Parallel()
387
388 tests := []struct {
389 name string
390 input string
391 want bool
392 wantWarnings []domain.Warning
393 }{
394 {
395 name: "on enables terminal palette inheritance",
396 input: "theme.palette = on\n",
397 want: true,
398 },
399 {
400 name: "off disables terminal palette inheritance",
401 input: "theme.palette = off\n",
402 want: false,
403 },
404 {
405 name: "invalid value warns once and keeps default",
406 input: "theme.palette = maybe\n",
407 want: true,
408 wantWarnings: []domain.Warning{
409 {Line: 1, Msg: "invalid theme.palette \"maybe\""},
410 },
411 },
412 {
413 name: "absent key keeps default",
414 input: "theme = dark\n",
415 want: true,
416 },
417 }
418
419 for _, tt := range tests {
420 t.Run(tt.name, func(t *testing.T) {
421 t.Parallel()
422
423 got, warnings, err := Parse(strings.NewReader(tt.input))
424 require.NoError(t, err)
425 require.Equal(t, tt.want, got.ThemePalette)
426 require.Equal(t, tt.wantWarnings, warnings)
427 })
428 }
429}
430
431func TestDefaultsThemePalette(t *testing.T) {
432 t.Parallel()
433
434 require.True(t, domain.Defaults().ThemePalette)
435}
436
437func TestParseTabsTerminalTitle(t *testing.T) {
438 t.Parallel()
439
440 tests := []struct {
441 name string
442 input string
443 want bool
444 wantWarnings []domain.Warning
445 }{
446 {
447 name: "on enables terminal title",
448 input: "tabs.terminal-title = on\n",
449 want: true,
450 },
451 {
452 name: "off disables terminal title",
453 input: "tabs.terminal-title = off\n",
454 want: false,
455 },
456 {
457 name: "invalid value warns and keeps default",
458 input: "tabs.terminal-title = maybe\n",
459 want: true,
460 wantWarnings: []domain.Warning{
461 {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
462 },
463 },
464 {
465 name: "absent key keeps default",
466 input: "theme = dark\n",
467 want: true,
468 },
469 }
470
471 for _, tt := range tests {
472 t.Run(tt.name, func(t *testing.T) {
473 t.Parallel()
474
475 got, warnings, err := Parse(strings.NewReader(tt.input))
476 if err != nil {
477 t.Fatalf("Parse() error = %v", err)
478 }
479 if got.Tabs.TerminalTitle != tt.want {
480 t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
481 }
482 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
483 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
484 }
485 })
486 }
487}
488
489func TestParseEphemeralCloseOnExit(t *testing.T) {
490 t.Parallel()
491
492 tests := []struct {
493 name string
494 input string
495 want bool
496 wantWarnings []domain.Warning
497 }{
498 {name: "absent key defaults on", input: "theme = dark\n", want: true},
499 {name: "on", input: "ephemeral.close-on-exit = on\n", want: true},
500 {name: "off", input: "ephemeral.close-on-exit = off\n", want: false},
501 {
502 name: "invalid value warns and keeps default",
503 input: "ephemeral.close-on-exit = kill\n",
504 want: true,
505 wantWarnings: []domain.Warning{
506 {Line: 1, Msg: "invalid ephemeral.close-on-exit \"kill\""},
507 },
508 },
509 }
510
511 for _, tt := range tests {
512 t.Run(tt.name, func(t *testing.T) {
513 t.Parallel()
514
515 got, warnings, err := Parse(strings.NewReader(tt.input))
516 if err != nil {
517 t.Fatalf("Parse() error = %v", err)
518 }
519 if got.Ephemeral.CloseOnExit != tt.want {
520 t.Fatalf("Parse() Ephemeral.CloseOnExit = %v, want %v", got.Ephemeral.CloseOnExit, tt.want)
521 }
522 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
523 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
524 }
525 })
526 }
527}
528
529func TestParseKeyboardKittyProtocol(t *testing.T) {
530 t.Parallel()
531
532 tests := []struct {
533 name string
534 input string
535 want bool
536 wantWarnings []domain.Warning
537 }{
538 {name: "absent key defaults on", input: "theme = dark\n", want: true},
539 {name: "on", input: "keyboard.kitty-protocol = on\n", want: true},
540 {name: "off", input: "keyboard.kitty-protocol = off\n", want: false},
541 {
542 name: "invalid value warns and keeps default",
543 input: "keyboard.kitty-protocol = maybe\n",
544 want: true,
545 wantWarnings: []domain.Warning{
546 {Line: 1, Msg: "invalid keyboard.kitty-protocol \"maybe\""},
547 },
548 },
549 }
550
551 for _, tt := range tests {
552 t.Run(tt.name, func(t *testing.T) {
553 t.Parallel()
554
555 got, warnings, err := Parse(strings.NewReader(tt.input))
556 if err != nil {
557 t.Fatalf("Parse() error = %v", err)
558 }
559 if got.Keyboard.KittyProtocol != tt.want {
560 t.Fatalf("Parse() Keyboard.KittyProtocol = %v, want %v", got.Keyboard.KittyProtocol, tt.want)
561 }
562 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
563 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
564 }
565 })
566 }
567}
568
569func TestParseEchoPredict(t *testing.T) {
570 t.Parallel()
571
572 tests := []struct {
573 name string
574 input string
575 want domain.EchoPredictMode
576 wantWarnings []domain.Warning
577 }{
578 {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive},
579 {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive},
580 {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways},
581 {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever},
582 {
583 name: "invalid value warns and keeps default",
584 input: "echo.predict = sometimes\n",
585 want: domain.EchoPredictAdaptive,
586 wantWarnings: []domain.Warning{
587 {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"},
588 },
589 },
590 {
591 name: "duplicate key warns and keeps last",
592 input: "echo.predict = always\necho.predict = never\n",
593 want: domain.EchoPredictNever,
594 wantWarnings: []domain.Warning{
595 {Line: 2, Msg: "duplicate key \"echo.predict\""},
596 },
597 },
598 }
599
600 for _, tt := range tests {
601 t.Run(tt.name, func(t *testing.T) {
602 t.Parallel()
603
604 got, warnings, err := Parse(strings.NewReader(tt.input))
605 if err != nil {
606 t.Fatalf("Parse() error = %v", err)
607 }
608 if got.Echo.Predict != tt.want {
609 t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want)
610 }
611 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
612 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
613 }
614 })
615 }
616}
617
618func TestParseNavigationOverflow(t *testing.T) {
619 t.Parallel()
620
621 tests := []struct {
622 name string
623 input string
624 want domain.NavConfig
625 wantWarnings []domain.Warning
626 }{
627 {
628 name: "absent defaults both settings off",
629 },
630 {
631 name: "tabs on enables only tab overflow",
632 input: "nav.overflow-tabs = on\n",
633 want: domain.NavConfig{OverflowTabs: true},
634 },
635 {
636 name: "sessions on enables only session overflow",
637 input: "nav.overflow-sessions = on\n",
638 want: domain.NavConfig{OverflowSessions: true},
639 },
640 {
641 name: "off disables both settings",
642 input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
643 },
644 {
645 name: "settings are independent",
646 input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
647 want: domain.NavConfig{OverflowTabs: true},
648 },
649 {
650 name: "invalid values warn and preserve defaults",
651 input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
652 wantWarnings: []domain.Warning{
653 {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
654 {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
655 },
656 },
657 {
658 name: "duplicate keys warn and last valid value wins",
659 input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
660 want: domain.NavConfig{OverflowTabs: true},
661 wantWarnings: []domain.Warning{
662 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
663 {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
664 },
665 },
666 {
667 name: "invalid duplicate still warns and keeps previous value",
668 input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
669 want: domain.NavConfig{OverflowTabs: true},
670 wantWarnings: []domain.Warning{
671 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
672 {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
673 },
674 },
675 }
676
677 for _, tt := range tests {
678 t.Run(tt.name, func(t *testing.T) {
679 t.Parallel()
680
681 cfg, warnings, err := Parse(strings.NewReader(tt.input))
682 require.NoError(t, err)
683 require.Equal(t, tt.want, cfg.Nav)
684 require.Equal(t, tt.wantWarnings, warnings)
685 })
686 }
687}
688
689func TestDefaultsDisableNavigationOverflow(t *testing.T) {
690 t.Parallel()
691
692 require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
693}
694
695func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
696 t.Parallel()
697
698 first := domain.Defaults()
699 first.Snapshot.RestoreProcesses[0] = "mutated"
700 second := domain.Defaults()
701 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
702}
703
704func TestParseCopyWordSeparators(t *testing.T) {
705 t.Parallel()
706
707 tests := []struct {
708 name string
709 input string
710 want string
711 wantWarnings []domain.Warning
712 }{
713 {
714 name: "default",
715 input: "",
716 want: domain.DefaultWordSeparators,
717 },
718 {
719 name: "quoted preserves leading separator space",
720 input: `copy.word-separators = " -_@/"`,
721 want: " -_@/",
722 },
723 {
724 name: "quoted empty is valid",
725 input: `copy.word-separators = ""`,
726 want: "",
727 },
728 {
729 name: "unquoted empty is valid",
730 input: "copy.word-separators =",
731 want: "",
732 },
733 {
734 name: "malformed quote keeps default",
735 input: `copy.word-separators = "unterminated`,
736 want: domain.DefaultWordSeparators,
737 wantWarnings: []domain.Warning{
738 {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
739 },
740 },
741 {
742 name: "duplicate last value wins",
743 input: "copy.word-separators = /\ncopy.word-separators = _",
744 want: "_",
745 wantWarnings: []domain.Warning{
746 {Line: 2, Msg: `duplicate key "copy.word-separators"`},
747 },
748 },
749 }
750
751 for _, tt := range tests {
752 t.Run(tt.name, func(t *testing.T) {
753 t.Parallel()
754
755 cfg, warnings, err := Parse(strings.NewReader(tt.input))
756 require.NoError(t, err)
757 require.Equal(t, tt.want, cfg.Copy.WordSeparators)
758 require.Equal(t, tt.wantWarnings, warnings)
759 })
760 }
761}
762
763func TestDefaultsCopyWordSeparators(t *testing.T) {
764 t.Parallel()
765
766 require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
767}
768
769func TestParsePreservesBindingFileOrder(t *testing.T) {
770 t.Parallel()
771
772 cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
773 if err != nil {
774 t.Fatalf("Parse() error = %v", err)
775 }
776 if len(warnings) != 0 {
777 t.Fatalf("Parse() warnings = %#v, want none", warnings)
778 }
779 want := []domain.ConfigEntry{
780 {Key: "open-palette", Value: "alt+p"},
781 {Key: "focus-pane-left", Value: "alt+p"},
782 }
783 if !reflect.DeepEqual(cfg.BindingEntries, want) {
784 t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
785 }
786}
787
788func TestLoadMissingFileReturnsDefaults(t *testing.T) {
789 t.Parallel()
790
791 cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
792 if err != nil {
793 t.Fatalf("Load() error = %v", err)
794 }
795 if len(warnings) != 0 {
796 t.Fatalf("Load() warnings = %#v, want none", warnings)
797 }
798 if !reflect.DeepEqual(cfg, domain.Defaults()) {
799 t.Fatalf("Load() config = %#v, want defaults", cfg)
800 }
801}
802
803func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
804 t.Parallel()
805
806 path := filepath.Join(t.TempDir(), "config")
807 if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
808 t.Fatal(err)
809 }
810
811 clk := newWatchClockMock(t)
812 ctx, cancel := context.WithCancel(context.Background())
813 defer cancel()
814
815 type change struct {
816 cfg domain.Config
817 warnings []domain.Warning
818 }
819 changes := make(chan change, 2)
820 done := make(chan error, 1)
821 go func() {
822 done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
823 changes <- change{cfg: cfg, warnings: warnings}
824 })
825 }()
826
827 clk.waitForTimer()
828 if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
829 t.Fatal(err)
830 }
831 clk.fire()
832
833 var got change
834 select {
835 case got = <-changes:
836 case <-time.After(2 * time.Second):
837 t.Fatal("timed out waiting for config reload")
838 }
839 if got.cfg.Theme != domain.ThemeLight {
840 t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
841 }
842 if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
843 t.Fatalf("Watch() warnings = %#v", got.warnings)
844 }
845
846 cancel()
847 if err := <-done; !errors.Is(err, context.Canceled) {
848 t.Fatalf("Watch() error = %v, want context.Canceled", err)
849 }
850}
851
852func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
853 t.Parallel()
854
855 path := filepath.Join(t.TempDir(), "config")
856 if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
857 t.Fatal(err)
858 }
859
860 clk := newWatchClockMock(t)
861 ctx, cancel := context.WithCancel(context.Background())
862 defer cancel()
863
864 type change struct {
865 cfg domain.Config
866 warnings []domain.Warning
867 }
868 changes := make(chan change, 2)
869 done := make(chan error, 1)
870 go func() {
871 done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
872 changes <- change{cfg: cfg, warnings: warnings}
873 })
874 }()
875
876 clk.waitForTimer()
877 tooLong := strings.Repeat("x", 70*1024)
878 if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
879 t.Fatal(err)
880 }
881 clk.fire()
882
883 got := <-changes
884 require.Equal(t, domain.Defaults(), got.cfg)
885 require.NotEmpty(t, got.warnings)
886
887 cancel()
888 if err := <-done; !errors.Is(err, context.Canceled) {
889 t.Fatalf("Watch() error = %v, want context.Canceled", err)
890 }
891}
892
893func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
894 t.Parallel()
895
896 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
897 clk := newWatchClockMock(t)
898 ctx, cancel := context.WithCancel(context.Background())
899 defer cancel()
900
901 changes := make(chan []domain.Warning, 4)
902 done := make(chan error, 1)
903 go func() {
904 done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
905 changes <- warnings
906 })
907 }()
908
909 first := <-changes
910 require.Len(t, first, 1)
911 clk.waitForTimer()
912 clk.fire()
913 clk.fire()
914
915 select {
916 case extra := <-changes:
917 t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
918 case <-time.After(100 * time.Millisecond):
919 }
920
921 cancel()
922 if err := <-done; !errors.Is(err, context.Canceled) {
923 t.Fatalf("Watch() error = %v, want context.Canceled", err)
924 }
925}
926
927type watchClockMock struct {
928 t *testing.T
929 clock *portsmocks.MockClock
930 ticks chan time.Time
931 ready chan struct{}
932 resets chan struct{}
933}
934
935func newWatchClockMock(t *testing.T) *watchClockMock {
936 t.Helper()
937 timer := portsmocks.NewMockTimer(t)
938 clock := portsmocks.NewMockClock(t)
939 m := &watchClockMock{
940 t: t,
941 clock: clock,
942 ticks: make(chan time.Time),
943 ready: make(chan struct{}),
944 resets: make(chan struct{}),
945 }
946 timer.EXPECT().C().Return(m.ticks)
947 timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
948 m.resets <- struct{}{}
949 }).Return(false)
950 timer.EXPECT().Stop().Return(true)
951 clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
952 close(m.ready)
953 }).Return(timer).Once()
954 return m
955}
956
957func (m *watchClockMock) waitForTimer() {
958 m.t.Helper()
959 select {
960 case <-m.ready:
961 case <-time.After(time.Second):
962 m.t.Fatal("timed out waiting for watcher timer creation")
963 }
964}
965
966func (m *watchClockMock) fire() {
967 m.t.Helper()
968 select {
969 case m.ticks <- time.Now():
970 case <-time.After(time.Second):
971 m.t.Fatal("timed out firing watcher timer")
972 }
973 select {
974 case <-m.resets:
975 case <-time.After(time.Second):
976 m.t.Fatal("timed out waiting for watcher timer reset")
977 }
978}