v0.11.2 619aabb

tools_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"os"
8	"path/filepath"
9	"runtime"
10	"strings"
11	"testing"
12
13	"github.com/bnema/zut/packages/provider"
14)
15
16func mustJSON(t *testing.T, v any) json.RawMessage {
17	t.Helper()
18	b, err := json.Marshal(v)
19	if err != nil {
20		t.Fatal(err)
21	}
22	return b
23}
24
25func TestReadText(t *testing.T) {
26	dir := t.TempDir()
27	p := filepath.Join(dir, "a.txt")
28	if err := os.WriteFile(p, []byte("hello\nworld\n"), 0o644); err != nil {
29		t.Fatal(err)
30	}
31	tool := &ReadTool{CWD: dir}
32	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt"}), nil)
33	if err != nil {
34		t.Fatal(err)
35	}
36	got := res.Content[0].(provider.TextBlock).Text
37	if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
38		t.Fatalf("got %q", got)
39	}
40}
41
42func TestReadImageMimeFromContentNotExtension(t *testing.T) {
43	// A file named .png whose bytes are actually JPEG. The MIME must be
44	// sniffed from the content (image/jpeg), not the extension, or
45	// providers that validate the declared media type reject the request.
46	dir := t.TempDir()
47	p := filepath.Join(dir, "shot.png")
48	jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
49	if err := os.WriteFile(p, jpegBytes, 0o644); err != nil {
50		t.Fatal(err)
51	}
52	tool := &ReadTool{CWD: dir}
53	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil)
54	if err != nil {
55		t.Fatal(err)
56	}
57	img, ok := res.Content[0].(provider.ImageBlock)
58	if !ok {
59		t.Fatalf("expected ImageBlock, got %T", res.Content[0])
60	}
61	if img.MimeType != "image/jpeg" {
62		t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType)
63	}
64}
65
66func TestReadOffsetLimit(t *testing.T) {
67	dir := t.TempDir()
68	p := filepath.Join(dir, "a.txt")
69	os.WriteFile(p, []byte("1\n2\n3\n4\n5\n"), 0o644)
70	tool := &ReadTool{CWD: dir}
71	res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil)
72	got := res.Content[0].(provider.TextBlock).Text
73	// Current output format is raw bytes (no embedded line numbers):
74	// the tui draws its own gutter from the `start_line` detail.
75	if got != "2\n3\n" {
76		t.Fatalf("want \"2\\n3\\n\", got %q", got)
77	}
78	if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 {
79		t.Errorf("start_line detail want 2, got %v", start)
80	}
81}
82
83func TestReadBinaryRejected(t *testing.T) {
84	dir := t.TempDir()
85	p := filepath.Join(dir, "b.bin")
86	os.WriteFile(p, []byte{0x00, 0x01, 0x02}, 0o644)
87	tool := &ReadTool{CWD: dir}
88	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "b.bin"}), nil); err == nil {
89		t.Fatal("want binary rejection")
90	}
91}
92
93func TestWriteCreatesDirs(t *testing.T) {
94	dir := t.TempDir()
95	tool := &WriteTool{CWD: dir}
96	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "sub/a.txt", "content": "hi"}), nil)
97	if err != nil {
98		t.Fatal(err)
99	}
100	b, err := os.ReadFile(filepath.Join(dir, "sub", "a.txt"))
101	if err != nil {
102		t.Fatal(err)
103	}
104	if string(b) != "hi" {
105		t.Fatalf("got %q", string(b))
106	}
107}
108
109func TestEditSingle(t *testing.T) {
110	dir := t.TempDir()
111	p := filepath.Join(dir, "a.txt")
112	os.WriteFile(p, []byte("hello world\n"), 0o644)
113	tool := &EditTool{CWD: dir}
114	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
115		"path":  "a.txt",
116		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
117	}), nil)
118	if err != nil {
119		t.Fatal(err)
120	}
121	b, _ := os.ReadFile(p)
122	if string(b) != "hello gopher\n" {
123		t.Fatalf("got %q", string(b))
124	}
125}
126
127func TestEditPreviewReturnsDiffWithoutWriting(t *testing.T) {
128	dir := t.TempDir()
129	p := filepath.Join(dir, "a.txt")
130	if err := os.WriteFile(p, []byte("hello world\n"), 0o644); err != nil {
131		t.Fatal(err)
132	}
133	tool := &EditTool{CWD: dir}
134	args := mustJSON(t, map[string]any{
135		"path":  "a.txt",
136		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
137	})
138	preview, err := tool.Preview(context.Background(), args)
139	if err != nil {
140		t.Fatal(err)
141	}
142	text := preview.Content[0].(provider.TextBlock).Text
143	for _, want := range []string{"-hello world", "+hello gopher"} {
144		if !strings.Contains(text, want) {
145			t.Fatalf("preview missing %q:\n%s", want, text)
146		}
147	}
148	b, err := os.ReadFile(p)
149	if err != nil {
150		t.Fatal(err)
151	}
152	if string(b) != "hello world\n" {
153		t.Fatalf("preview modified file: %q", b)
154	}
155
156	result, err := tool.Execute(context.Background(), args, nil)
157	if err != nil {
158		t.Fatal(err)
159	}
160	if got := result.Content[0].(provider.TextBlock).Text; got != text {
161		t.Fatalf("executed diff differs from preview:\npreview:\n%s\nresult:\n%s", text, got)
162	}
163}
164
165func TestEditMultiple(t *testing.T) {
166	dir := t.TempDir()
167	p := filepath.Join(dir, "a.txt")
168	os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)
169	tool := &EditTool{CWD: dir}
170	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
171		"path": "a.txt",
172		"edits": []map[string]any{
173			{"oldText": "a", "newText": "A"},
174			{"oldText": "c", "newText": "C"},
175		},
176	}), nil)
177	if err != nil {
178		t.Fatal(err)
179	}
180	b, _ := os.ReadFile(p)
181	if string(b) != "A\nb\nC\n" {
182		t.Fatalf("got %q", string(b))
183	}
184}
185
186func TestEditAmbiguous(t *testing.T) {
187	dir := t.TempDir()
188	p := filepath.Join(dir, "a.txt")
189	os.WriteFile(p, []byte("x\nx\n"), 0o644)
190	tool := &EditTool{CWD: dir}
191	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
192		"path":  "a.txt",
193		"edits": []map[string]any{{"oldText": "x", "newText": "y"}},
194	}), nil)
195	if err == nil {
196		t.Fatal("want ambiguous error")
197	}
198}
199
200func TestEditGuidance(t *testing.T) {
201	desc := (&EditTool{}).Description()
202	for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
203		if !strings.Contains(desc, want) {
204			t.Errorf("description missing %q: %q", want, desc)
205		}
206	}
207
208	var schema map[string]any
209	if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
210		t.Fatal(err)
211	}
212	properties := schema["properties"].(map[string]any)
213	edits := properties["edits"].(map[string]any)
214	items := edits["items"].(map[string]any)
215	editProperties := items["properties"].(map[string]any)
216	oldText := editProperties["oldText"].(map[string]any)
217	if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
218		t.Fatalf("oldText schema description missing target-file guidance: %q", got)
219	}
220}
221
222func TestEditNotFoundGuidesRecovery(t *testing.T) {
223	dir := t.TempDir()
224	p := filepath.Join(dir, "destination.txt")
225	if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
226		t.Fatal(err)
227	}
228	tool := &EditTool{CWD: dir}
229	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
230		"path":  "destination.txt",
231		"edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
232	}), nil)
233	if err == nil {
234		t.Fatal("want oldText not found error")
235	}
236	for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
237		if !strings.Contains(err.Error(), want) {
238			t.Errorf("error missing %q: %q", want, err)
239		}
240	}
241}
242
243func TestEditPreservesCRLF(t *testing.T) {
244	dir := t.TempDir()
245	p := filepath.Join(dir, "a.txt")
246	os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644)
247	tool := &EditTool{CWD: dir}
248	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
249		"path":  "a.txt",
250		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
251	}), nil)
252	if err != nil {
253		t.Fatal(err)
254	}
255	b, _ := os.ReadFile(p)
256	if string(b) != "hello\r\ngopher\r\n" {
257		t.Fatalf("got %q", string(b))
258	}
259}
260
261func TestResolveShell(t *testing.T) {
262	notFound := errors.New("not found")
263	tests := []struct {
264		name     string
265		goos     string
266		binBash  bool
267		pathBash string
268		pathErr  error
269		want     shellCommand
270	}{
271		{
272			name: "prefers /bin/bash",
273			goos: "linux", binBash: true, pathBash: "/usr/local/bin/bash",
274			want: shellCommand{path: "/bin/bash", flag: "-c", isBash: true},
275		},
276		{
277			name: "uses bash from PATH",
278			goos: "linux", pathBash: "/usr/local/bin/bash",
279			want: shellCommand{path: "/usr/local/bin/bash", flag: "-c", isBash: true},
280		},
281		{
282			name: "falls back to POSIX sh",
283			goos: "linux", pathErr: notFound,
284			want: shellCommand{path: "/bin/sh", flag: "-c"},
285		},
286		{
287			name: "uses Command Prompt on Windows",
288			goos: "windows", binBash: true, pathBash: "/usr/local/bin/bash",
289			want: shellCommand{path: "cmd", flag: "/C"},
290		},
291	}
292	for _, tt := range tests {
293		t.Run(tt.name, func(t *testing.T) {
294			got := resolveShell(tt.goos, func(path string) bool {
295				return path == "/bin/bash" && tt.binBash
296			}, func(name string) (string, error) {
297				if name != "bash" {
298					t.Fatalf("unexpected PATH lookup for %q", name)
299				}
300				return tt.pathBash, tt.pathErr
301			})
302			if got != tt.want {
303				t.Fatalf("resolveShell() = %+v, want %+v", got, tt.want)
304			}
305		})
306	}
307}
308
309func TestShellDescription(t *testing.T) {
310	tests := []struct {
311		name  string
312		shell shellCommand
313		want  string
314	}{
315		{
316			name:  "bash",
317			shell: shellCommand{path: "/opt/bin/bash", flag: "-c", isBash: true},
318			want:  "Run a Bash command via /opt/bin/bash -c. stdout+stderr merged.",
319		},
320		{
321			name:  "POSIX fallback",
322			shell: shellCommand{path: "/bin/sh", flag: "-c"},
323			want:  "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged.",
324		},
325		{
326			name:  "Windows",
327			shell: shellCommand{path: "cmd", flag: "/C"},
328			want:  "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.",
329		},
330	}
331	for _, tt := range tests {
332		t.Run(tt.name, func(t *testing.T) {
333			if got := shellDescription(tt.shell); got != tt.want {
334				t.Fatalf("shellDescription() = %q, want %q", got, tt.want)
335			}
336		})
337	}
338}
339
340func TestBashSuccess(t *testing.T) {
341	if runtime.GOOS == "windows" {
342		t.Skip("posix shell only")
343	}
344	tool := &BashTool{CWD: t.TempDir()}
345	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi"}), nil)
346	if err != nil {
347		t.Fatal(err)
348	}
349	got := res.Content[0].(provider.TextBlock).Text
350	if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
351		t.Fatalf("got %q", got)
352	}
353	if res.IsError {
354		t.Fatal("unexpected error flag")
355	}
356}
357
358func TestBashSyntax(t *testing.T) {
359	if runtime.GOOS == "windows" {
360		t.Skip("bash syntax is not used on Windows")
361	}
362	if !currentShell().isBash {
363		t.Skip("bash is unavailable")
364	}
365	tool := &BashTool{CWD: t.TempDir()}
366	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
367		"command": `items=(one two); [[ ${#items[@]} -eq 2 ]] && printf 'bash syntax works\n'`,
368	}), nil)
369	if err != nil {
370		t.Fatal(err)
371	}
372	got := res.Content[0].(provider.TextBlock).Text
373	if res.IsError || !strings.Contains(got, "bash syntax works") {
374		t.Fatalf("got %q", got)
375	}
376}
377
378func TestBashFailure(t *testing.T) {
379	if runtime.GOOS == "windows" {
380		t.Skip("posix shell only")
381	}
382	tool := &BashTool{CWD: t.TempDir()}
383	res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false"}), nil)
384	if !res.IsError {
385		t.Fatal("want error")
386	}
387	got := res.Content[0].(provider.TextBlock).Text
388	if !strings.Contains(got, "[exit 1]") {
389		t.Fatalf("got %q", got)
390	}
391}

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 476

↑↓ moveenter openesc close