main b4521f0

write_test.go RawHistoryFolder

1package kvconf
2
3import (
4	"os"
5	"path/filepath"
6	"runtime"
7	"strings"
8	"testing"
9	"time"
10)
11
12func ptr(s string) *string { return &s }
13
14func TestSet(t *testing.T) {
15	tests := []struct {
16		name   string
17		in     string
18		values map[string]*string
19		want   string
20	}{
21		{name: "empty file", in: "", values: map[string]*string{"a": ptr("1")}, want: "a = 1\n"},
22		{name: "no final newline", in: "x = 1", values: map[string]*string{"a": ptr("1")}, want: "x = 1\na = 1\n"},
23		{
24			name:   "replace keeps spacing and comment",
25			in:     "# top\n  a  =   1   # one\nb = 2\n",
26			values: map[string]*string{"a": ptr("10")},
27			want:   "# top\n  a  =   10   # one\nb = 2\n",
28		},
29		{
30			name:   "replace the last assignment",
31			in:     "a = 1\na = 2\n",
32			values: map[string]*string{"a": ptr("3")},
33			want:   "a = 1\na = 3\n",
34		},
35		{
36			name:   "replace empty value",
37			in:     "a =\n",
38			values: map[string]*string{"a": ptr("1")},
39			want:   "a = 1\n",
40		},
41		{
42			name:   "enable commented assignment",
43			in:     "# a = 1\nb = 2\n",
44			values: map[string]*string{"a": ptr("5")},
45			want:   "a = 5\nb = 2\n",
46		},
47		{
48			name:   "insert beside the longest dotted parent",
49			in:     "output.DP-1.scale = 1\noutput.DP-2.hdr = on\nother = 1\n",
50			values: map[string]*string{"output.DP-2.scale": ptr("2")},
51			want:   "output.DP-1.scale = 1\noutput.DP-2.hdr = on\noutput.DP-2.scale = 2\nother = 1\n",
52		},
53		{
54			name:   "insert keeps CRLF",
55			in:     "a.x = 1\r\nb = 2\r\n",
56			values: map[string]*string{"a.y": ptr("3")},
57			want:   "a.x = 1\r\na.y = 3\r\nb = 2\r\n",
58		},
59		{
60			name:   "quote when needed",
61			in:     "",
62			values: map[string]*string{"a": ptr("x # y"), "b": ptr(" pad"), "c": ptr("")},
63			want:   "a = \"x # y\"\nb = \" pad\"\nc = \"\"\n",
64		},
65		{
66			name:   "remove every assignment",
67			in:     "a = 1\nb = 2\na = 3\n",
68			values: map[string]*string{"a": nil},
69			want:   "b = 2\n",
70		},
71	}
72	for _, tt := range tests {
73		t.Run(tt.name, func(t *testing.T) {
74			got, err := Set([]byte(tt.in), tt.values)
75			must(t, err)
76			if string(got) != tt.want {
77				t.Errorf("got\n%q\nwant\n%q", got, tt.want)
78			}
79		})
80	}
81}
82
83func TestSetRejectsInjection(t *testing.T) {
84	for _, key := range []string{"", "a = b", "a\nadmin", "bind.q = y\nadmin", "# a", " a", "\ufeffa"} {
85		t.Run(key, func(t *testing.T) {
86			if _, err := Set(nil, map[string]*string{key: ptr("v")}); err == nil {
87				t.Fatalf("Set accepted key %q", key)
88			}
89		})
90	}
91}
92
93func TestMarshalRejectsInjection(t *testing.T) {
94	tests := []struct {
95		name string
96		v    any
97	}{
98		{"map name with newline", &marshalConfig{Binds: map[string]string{"x = 1\nadmin": "y"}}},
99		{"map name with equals", &marshalConfig{Binds: map[string]string{"a=b": "y"}}},
100		{"catch-all key", &marshalConfig{Rest: map[string]string{"a\nb": "y"}}},
101	}
102	for _, tt := range tests {
103		t.Run(tt.name, func(t *testing.T) {
104			if _, err := Marshal(tt.v); err == nil {
105				t.Fatal("Marshal accepted an injected key")
106			}
107		})
108	}
109	t.Run("first invalid name in order", func(t *testing.T) {
110		v := &marshalConfig{Binds: map[string]string{" d": "", " b": "", " a": "", " c": ""}}
111		for range 20 { // map order is random
112			_, err := Marshal(v)
113			if err == nil || err.Error() != `kvconf: bind.: invalid map name " a"` {
114				t.Fatalf("err = %v", err)
115			}
116		}
117	})
118}
119
120// upper marshals with a pointer receiver, like many TextMarshalers.
121type upper string
122
123func (u *upper) UnmarshalText(b []byte) error { *u = upper(strings.ToLower(string(b))); return nil }
124func (u *upper) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(string(*u))), nil }
125
126func TestMarshalPointerReceiver(t *testing.T) {
127	type cfg struct {
128		U upper            `kv:"u"`
129		M map[string]upper `kv:"m.*"`
130	}
131	data, err := Marshal(&cfg{U: "hello", M: map[string]upper{"a": "x"}})
132	must(t, err)
133	eq(t, string(data), "u = HELLO\nm.a = X\n")
134}
135
136func TestQuoteRoundTrip(t *testing.T) {
137	for _, v := range []string{"", "plain", "#ff0000", "a # b", " lead", "trail ", `"q"`, "it's", "a\nb", `back\slash`, "x\t#y", `it's "x # y"`, "don't # x"} {
138		t.Run(v, func(t *testing.T) {
139			entries, _, err := ParseBytes([]byte("k = "+Quote(v)+"\n"), Options{})
140			if err != nil || len(entries) != 1 || entries[0].Value != v {
141				t.Fatalf("Quote(%q) = %q reads back as %#v, %v", v, Quote(v), entries, err)
142			}
143		})
144	}
145}
146
147type marshalConfig struct {
148	Name  string        `kv:"name"`
149	On    bool          `kv:"on"`
150	After time.Duration `kv:"after,off"`
151	List  []int         `kv:"list"`
152	Dir   Path          `kv:"dir"`
153	Sub   struct {
154		N uint8 `kv:"n"`
155	} `kv:"sub"`
156	Binds map[string]string `kv:"bind.*"`
157	Outs  map[string]output `kv:"output.*"`
158	Rest  map[string]string `kv:"*"`
159}
160
161func TestMarshalRoundTrip(t *testing.T) {
162	dir := filepath.Join(t.TempDir(), "x")
163	in := marshalConfig{
164		Name: "a # b", On: true, List: []int{1, 2}, Dir: Path(dir),
165		Binds: map[string]string{"z": "2", "a": "1"},
166		Outs:  map[string]output{"DP-1": {Scale: 1.5, HDR: true, Bright: 100}},
167		Rest:  map[string]string{"extra": "x"},
168	}
169	in.Sub.N = 7
170	data, err := Marshal(&in)
171	if err != nil {
172		t.Fatal(err)
173	}
174	want := `name = "a # b"
175on = on
176after = off
177list = 1,2
178dir = ` + Quote(dir) + `
179sub.n = 7
180bind.a = 1
181bind.z = 2
182output.DP-1.scale = 1.5
183output.DP-1.hdr = on
184output.DP-1.primary = off
185output.DP-1.sdr-brightness = 100
186extra = x
187`
188	if string(data) != want {
189		t.Fatalf("Marshal =\n%s\nwant\n%s", data, want)
190	}
191	var out marshalConfig
192	if w, err := Unmarshal(data, &out, Options{Strict: true}); err != nil || len(w) > 0 {
193		t.Fatalf("Unmarshal: %v %v", w, err)
194	}
195	eq(t, out, in)
196}
197
198func TestWriteFile(t *testing.T) {
199	tests := []struct {
200		name  string
201		setup func(t *testing.T, dir string) string // returns the path to write
202		check func(t *testing.T, dir, path string)
203	}{
204		{
205			name:  "new file and directory",
206			setup: func(t *testing.T, dir string) string { return filepath.Join(dir, "sub", "config") },
207			check: func(t *testing.T, dir, path string) {
208				eq(t, readString(t, path), "a = 1\n")
209				if runtime.GOOS != "windows" {
210					info, _ := os.Stat(path)
211					eq(t, info.Mode().Perm(), os.FileMode(0o600))
212				}
213			},
214		},
215		{
216			name: "existing file keeps its mode",
217			setup: func(t *testing.T, dir string) string {
218				p := filepath.Join(dir, "config")
219				must(t, os.WriteFile(p, []byte("old"), 0o640))
220				return p
221			},
222			check: func(t *testing.T, dir, path string) {
223				eq(t, readString(t, path), "a = 1\n")
224				if runtime.GOOS != "windows" {
225					info, _ := os.Stat(path)
226					eq(t, info.Mode().Perm(), os.FileMode(0o640))
227				}
228				entries, _ := os.ReadDir(dir)
229				eq(t, len(entries), 1) // no temporary file left
230			},
231		},
232		{
233			name: "symlink is written through",
234			setup: func(t *testing.T, dir string) string {
235				target := filepath.Join(dir, "dotfiles", "config")
236				must(t, os.MkdirAll(filepath.Dir(target), 0o700))
237				must(t, os.WriteFile(target, []byte("old"), 0o600))
238				link := filepath.Join(dir, "config")
239				if err := os.Symlink(target, link); err != nil {
240					t.Skip("symlinks unavailable:", err)
241				}
242				return link
243			},
244			check: func(t *testing.T, dir, path string) {
245				info, err := os.Lstat(path)
246				must(t, err)
247				if info.Mode()&os.ModeSymlink == 0 {
248					t.Fatal("the link was replaced")
249				}
250				eq(t, readString(t, filepath.Join(dir, "dotfiles", "config")), "a = 1\n")
251			},
252		},
253	}
254	for _, tt := range tests {
255		t.Run(tt.name, func(t *testing.T) {
256			dir := t.TempDir()
257			path := tt.setup(t, dir)
258			must(t, WriteFile(path, []byte("a = 1\n"), 0o600))
259			tt.check(t, dir, path)
260		})
261	}
262}
263
264func TestSetFile(t *testing.T) {
265	path := filepath.Join(t.TempDir(), "config")
266	must(t, os.WriteFile(path, []byte("# keep\na = 1\n"), 0o600))
267	must(t, SetFile(path, map[string]*string{"a": ptr("2"), "b": ptr("3")}, 0o600))
268	eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
269}
270
271func readString(t *testing.T, path string) string {
272	t.Helper()
273	data, err := os.ReadFile(path)
274	must(t, err)
275	return string(data)
276}
277
278func writeTemp(t *testing.T, dir, name, content string) string {
279	t.Helper()
280	p := filepath.Join(dir, name)
281	must(t, os.WriteFile(p, []byte(content), 0o600))
282	return p
283}
284
285func must(t *testing.T, err error) {
286	t.Helper()
287	if err != nil {
288		t.Fatal(err)
289	}
290}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close