v0.1.1 ebc8127

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"} {
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}
110
111// upper marshals with a pointer receiver, like many TextMarshalers.
112type upper string
113
114func (u *upper) UnmarshalText(b []byte) error { *u = upper(strings.ToLower(string(b))); return nil }
115func (u *upper) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(string(*u))), nil }
116
117func TestMarshalPointerReceiver(t *testing.T) {
118	type cfg struct {
119		U upper            `kv:"u"`
120		M map[string]upper `kv:"m.*"`
121	}
122	data, err := Marshal(&cfg{U: "hello", M: map[string]upper{"a": "x"}})
123	must(t, err)
124	eq(t, string(data), "u = HELLO\nm.a = X\n")
125}
126
127func TestQuoteRoundTrip(t *testing.T) {
128	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"} {
129		t.Run(v, func(t *testing.T) {
130			entries, _, err := ParseBytes([]byte("k = "+Quote(v)+"\n"), Options{})
131			if err != nil || len(entries) != 1 || entries[0].Value != v {
132				t.Fatalf("Quote(%q) = %q reads back as %#v, %v", v, Quote(v), entries, err)
133			}
134		})
135	}
136}
137
138type marshalConfig struct {
139	Name  string        `kv:"name"`
140	On    bool          `kv:"on"`
141	After time.Duration `kv:"after,off"`
142	List  []int         `kv:"list"`
143	Dir   Path          `kv:"dir"`
144	Sub   struct {
145		N uint8 `kv:"n"`
146	} `kv:"sub"`
147	Binds map[string]string `kv:"bind.*"`
148	Outs  map[string]output `kv:"output.*"`
149	Rest  map[string]string `kv:"*"`
150}
151
152func TestMarshalRoundTrip(t *testing.T) {
153	dir := filepath.Join(t.TempDir(), "x")
154	in := marshalConfig{
155		Name: "a # b", On: true, List: []int{1, 2}, Dir: Path(dir),
156		Binds: map[string]string{"z": "2", "a": "1"},
157		Outs:  map[string]output{"DP-1": {Scale: 1.5, HDR: true, Bright: 100}},
158		Rest:  map[string]string{"extra": "x"},
159	}
160	in.Sub.N = 7
161	data, err := Marshal(&in)
162	if err != nil {
163		t.Fatal(err)
164	}
165	want := `name = "a # b"
166on = on
167after = off
168list = 1,2
169dir = ` + Quote(dir) + `
170sub.n = 7
171bind.a = 1
172bind.z = 2
173output.DP-1.scale = 1.5
174output.DP-1.hdr = on
175output.DP-1.primary = off
176output.DP-1.sdr-brightness = 100
177extra = x
178`
179	if string(data) != want {
180		t.Fatalf("Marshal =\n%s\nwant\n%s", data, want)
181	}
182	var out marshalConfig
183	if w, err := Unmarshal(data, &out, Options{Strict: true}); err != nil || len(w) > 0 {
184		t.Fatalf("Unmarshal: %v %v", w, err)
185	}
186	eq(t, out, in)
187}
188
189func TestWriteFile(t *testing.T) {
190	tests := []struct {
191		name  string
192		setup func(t *testing.T, dir string) string // returns the path to write
193		check func(t *testing.T, dir, path string)
194	}{
195		{
196			name:  "new file and directory",
197			setup: func(t *testing.T, dir string) string { return filepath.Join(dir, "sub", "config") },
198			check: func(t *testing.T, dir, path string) {
199				eq(t, readString(t, path), "a = 1\n")
200				if runtime.GOOS != "windows" {
201					info, _ := os.Stat(path)
202					eq(t, info.Mode().Perm(), os.FileMode(0o600))
203				}
204			},
205		},
206		{
207			name: "existing file keeps its mode",
208			setup: func(t *testing.T, dir string) string {
209				p := filepath.Join(dir, "config")
210				must(t, os.WriteFile(p, []byte("old"), 0o640))
211				return p
212			},
213			check: func(t *testing.T, dir, path string) {
214				eq(t, readString(t, path), "a = 1\n")
215				if runtime.GOOS != "windows" {
216					info, _ := os.Stat(path)
217					eq(t, info.Mode().Perm(), os.FileMode(0o640))
218				}
219				entries, _ := os.ReadDir(dir)
220				eq(t, len(entries), 1) // no temporary file left
221			},
222		},
223		{
224			name: "symlink is written through",
225			setup: func(t *testing.T, dir string) string {
226				target := filepath.Join(dir, "dotfiles", "config")
227				must(t, os.MkdirAll(filepath.Dir(target), 0o700))
228				must(t, os.WriteFile(target, []byte("old"), 0o600))
229				link := filepath.Join(dir, "config")
230				if err := os.Symlink(target, link); err != nil {
231					t.Skip("symlinks unavailable:", err)
232				}
233				return link
234			},
235			check: func(t *testing.T, dir, path string) {
236				info, err := os.Lstat(path)
237				must(t, err)
238				if info.Mode()&os.ModeSymlink == 0 {
239					t.Fatal("the link was replaced")
240				}
241				eq(t, readString(t, filepath.Join(dir, "dotfiles", "config")), "a = 1\n")
242			},
243		},
244	}
245	for _, tt := range tests {
246		t.Run(tt.name, func(t *testing.T) {
247			dir := t.TempDir()
248			path := tt.setup(t, dir)
249			must(t, WriteFile(path, []byte("a = 1\n"), 0o600))
250			tt.check(t, dir, path)
251		})
252	}
253}
254
255func TestSetFile(t *testing.T) {
256	path := filepath.Join(t.TempDir(), "config")
257	must(t, os.WriteFile(path, []byte("# keep\na = 1\n"), 0o600))
258	must(t, SetFile(path, map[string]*string{"a": ptr("2"), "b": ptr("3")}, 0o600))
259	eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
260}
261
262func readString(t *testing.T, path string) string {
263	t.Helper()
264	data, err := os.ReadFile(path)
265	must(t, err)
266	return string(data)
267}
268
269func writeTemp(t *testing.T, dir, name, content string) string {
270	t.Helper()
271	p := filepath.Join(dir, name)
272	must(t, os.WriteFile(p, []byte(content), 0o600))
273	return p
274}
275
276func must(t *testing.T, err error) {
277	t.Helper()
278	if err != nil {
279		t.Fatal(err)
280	}
281}

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