v0.40.0 076e2bb

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	"time"
13
14	"github.com/bnema/zut/packages/provider"
15)
16
17func mustJSON(t *testing.T, v any) json.RawMessage {
18	t.Helper()
19	b, err := json.Marshal(v)
20	if err != nil {
21		t.Fatal(err)
22	}
23	return b
24}
25
26func TestReadText(t *testing.T) {
27	dir := t.TempDir()
28	p := filepath.Join(dir, "a.txt")
29	if err := os.WriteFile(p, []byte("hello\nworld\n"), 0o644); err != nil {
30		t.Fatal(err)
31	}
32	tool := &ReadTool{CWD: dir}
33	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt"}), nil)
34	if err != nil {
35		t.Fatal(err)
36	}
37	got := res.Content[0].(provider.TextBlock).Text
38	if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
39		t.Fatalf("got %q", got)
40	}
41}
42
43func TestReadImageMimeFromContentNotExtension(t *testing.T) {
44	// A file named .png whose bytes are actually JPEG. The MIME must be
45	// sniffed from the content (image/jpeg), not the extension, or
46	// providers that validate the declared media type reject the request.
47	dir := t.TempDir()
48	p := filepath.Join(dir, "shot.png")
49	jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
50	if err := os.WriteFile(p, jpegBytes, 0o644); err != nil {
51		t.Fatal(err)
52	}
53	tool := &ReadTool{CWD: dir}
54	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil)
55	if err != nil {
56		t.Fatal(err)
57	}
58	img, ok := res.Content[0].(provider.ImageBlock)
59	if !ok {
60		t.Fatalf("expected ImageBlock, got %T", res.Content[0])
61	}
62	if img.MimeType != "image/jpeg" {
63		t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType)
64	}
65}
66
67func TestReadOffsetLimit(t *testing.T) {
68	dir := t.TempDir()
69	p := filepath.Join(dir, "a.txt")
70	os.WriteFile(p, []byte("1\n2\n3\n4\n5\n"), 0o644)
71	tool := &ReadTool{CWD: dir}
72	res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil)
73	got := res.Content[0].(provider.TextBlock).Text
74	// Current output format is raw bytes (no embedded line numbers):
75	// the tui draws its own gutter from the `start_line` detail.
76	if got != "2\n3\n" {
77		t.Fatalf("want \"2\\n3\\n\", got %q", got)
78	}
79	if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 {
80		t.Errorf("start_line detail want 2, got %v", start)
81	}
82}
83
84func TestReadBinaryRejected(t *testing.T) {
85	dir := t.TempDir()
86	p := filepath.Join(dir, "b.bin")
87	os.WriteFile(p, []byte{0x00, 0x01, 0x02}, 0o644)
88	tool := &ReadTool{CWD: dir}
89	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "b.bin"}), nil); err == nil {
90		t.Fatal("want binary rejection")
91	}
92}
93
94func TestWriteCreatesDirs(t *testing.T) {
95	dir := t.TempDir()
96	tool := &WriteTool{CWD: dir}
97	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "sub/a.txt", "content": "hi"}), nil)
98	if err != nil {
99		t.Fatal(err)
100	}
101	b, err := os.ReadFile(filepath.Join(dir, "sub", "a.txt"))
102	if err != nil {
103		t.Fatal(err)
104	}
105	if string(b) != "hi" {
106		t.Fatalf("got %q", string(b))
107	}
108}
109
110func TestEditSingle(t *testing.T) {
111	dir := t.TempDir()
112	p := filepath.Join(dir, "a.txt")
113	os.WriteFile(p, []byte("hello world\n"), 0o644)
114	tool := &EditTool{CWD: dir}
115	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
116		"path":  "a.txt",
117		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
118	}), nil)
119	if err != nil {
120		t.Fatal(err)
121	}
122	b, _ := os.ReadFile(p)
123	if string(b) != "hello gopher\n" {
124		t.Fatalf("got %q", string(b))
125	}
126}
127
128func TestEditPreviewReturnsDiffWithoutWriting(t *testing.T) {
129	dir := t.TempDir()
130	p := filepath.Join(dir, "a.txt")
131	if err := os.WriteFile(p, []byte("hello world\n"), 0o644); err != nil {
132		t.Fatal(err)
133	}
134	tool := &EditTool{CWD: dir}
135	args := mustJSON(t, map[string]any{
136		"path":  "a.txt",
137		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
138	})
139	preview, err := tool.Preview(context.Background(), args)
140	if err != nil {
141		t.Fatal(err)
142	}
143	text := preview.Content[0].(provider.TextBlock).Text
144	for _, want := range []string{"-hello world", "+hello gopher"} {
145		if !strings.Contains(text, want) {
146			t.Fatalf("preview missing %q:\n%s", want, text)
147		}
148	}
149	b, err := os.ReadFile(p)
150	if err != nil {
151		t.Fatal(err)
152	}
153	if string(b) != "hello world\n" {
154		t.Fatalf("preview modified file: %q", b)
155	}
156
157	result, err := tool.Execute(context.Background(), args, nil)
158	if err != nil {
159		t.Fatal(err)
160	}
161	if got := result.Content[0].(provider.TextBlock).Text; got != text {
162		t.Fatalf("executed diff differs from preview:\npreview:\n%s\nresult:\n%s", text, got)
163	}
164}
165
166func TestEditMultiple(t *testing.T) {
167	dir := t.TempDir()
168	p := filepath.Join(dir, "a.txt")
169	os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)
170	tool := &EditTool{CWD: dir}
171	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
172		"path": "a.txt",
173		"edits": []map[string]any{
174			{"oldText": "a", "newText": "A"},
175			{"oldText": "c", "newText": "C"},
176		},
177	}), nil)
178	if err != nil {
179		t.Fatal(err)
180	}
181	b, _ := os.ReadFile(p)
182	if string(b) != "A\nb\nC\n" {
183		t.Fatalf("got %q", string(b))
184	}
185}
186
187func TestEditAmbiguousGuidesRecovery(t *testing.T) {
188	dir := t.TempDir()
189	p := filepath.Join(dir, "a.txt")
190	original := []byte("x\nx\nx\nx\nx\nx\nx\n")
191	if err := os.WriteFile(p, original, 0o644); err != nil {
192		t.Fatal(err)
193	}
194	tool := &EditTool{CWD: dir}
195	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
196		"path":  "a.txt",
197		"edits": []map[string]any{{"oldText": "x", "newText": "y"}},
198	}), nil)
199	if err == nil {
200		t.Fatal("want ambiguous error")
201	}
202	for _, want := range []string{
203		"oldText matches 7 times",
204		"lines 1, 2, 3, 4, and 5 (and 2 more)",
205		"read a.txt with a bounded context window around those lines",
206		"retry with a verbatim excerpt containing enough surrounding text to identify exactly one location",
207	} {
208		if !strings.Contains(err.Error(), want) {
209			t.Errorf("error missing %q: %q", want, err)
210		}
211	}
212	got, readErr := os.ReadFile(p)
213	if readErr != nil {
214		t.Fatal(readErr)
215	}
216	if string(got) != string(original) {
217		t.Fatalf("ambiguous edit modified file: got %q, want %q", got, original)
218	}
219}
220
221func TestEditRejectsOverlappingMatches(t *testing.T) {
222	dir := t.TempDir()
223	p := filepath.Join(dir, "a.txt")
224	original := []byte("aaa\n")
225	if err := os.WriteFile(p, original, 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":  "a.txt",
231		"edits": []map[string]any{{"oldText": "aa", "newText": "b"}},
232	}), nil)
233	if err == nil || !strings.Contains(err.Error(), "oldText matches 2 times") {
234		t.Fatalf("want overlapping-match error, got %v", err)
235	}
236	got, readErr := os.ReadFile(p)
237	if readErr != nil {
238		t.Fatal(readErr)
239	}
240	if string(got) != string(original) {
241		t.Fatalf("ambiguous edit modified file: got %q, want %q", got, original)
242	}
243}
244
245func TestEditGuidance(t *testing.T) {
246	desc := (&EditTool{}).Description()
247	for _, want := range []string{"Inspect that file", "directly from its current contents", "verbatim excerpt", "enough surrounding text", "write"} {
248		if !strings.Contains(desc, want) {
249			t.Errorf("description missing %q: %q", want, desc)
250		}
251	}
252
253	var schema map[string]any
254	if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
255		t.Fatal(err)
256	}
257	properties := schema["properties"].(map[string]any)
258	edits := properties["edits"].(map[string]any)
259	items := edits["items"].(map[string]any)
260	editProperties := items["properties"].(map[string]any)
261	oldText := editProperties["oldText"].(map[string]any)
262	oldTextDescription, _ := oldText["description"].(string)
263	for _, want := range []string{"file being modified", "verbatim excerpt", "enough surrounding text", "exactly one location"} {
264		if !strings.Contains(oldTextDescription, want) {
265			t.Errorf("oldText schema description missing %q: %q", want, oldTextDescription)
266		}
267	}
268}
269
270func TestEditNotFoundGuidesRecovery(t *testing.T) {
271	dir := t.TempDir()
272	p := filepath.Join(dir, "destination.txt")
273	if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
274		t.Fatal(err)
275	}
276	tool := &EditTool{CWD: dir}
277	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
278		"path":  "destination.txt",
279		"edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
280	}), nil)
281	if err == nil {
282		t.Fatal("want oldText not found error")
283	}
284	for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
285		if !strings.Contains(err.Error(), want) {
286			t.Errorf("error missing %q: %q", want, err)
287		}
288	}
289}
290
291func TestEditPreservesCRLF(t *testing.T) {
292	dir := t.TempDir()
293	p := filepath.Join(dir, "a.txt")
294	if err := os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644); err != nil {
295		t.Fatal(err)
296	}
297	tool := &EditTool{CWD: dir}
298	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
299		"path": "a.txt",
300		"edits": []map[string]any{{
301			"oldText": "hello\r\nworld",
302			"newText": "hello\r\ngopher",
303		}},
304	}), nil)
305	if err != nil {
306		t.Fatal(err)
307	}
308	b, err := os.ReadFile(p)
309	if err != nil {
310		t.Fatal(err)
311	}
312	if string(b) != "hello\r\ngopher\r\n" {
313		t.Fatalf("got %q", string(b))
314	}
315}
316
317func TestResolveShell(t *testing.T) {
318	notFound := errors.New("not found")
319	tests := []struct {
320		name     string
321		goos     string
322		binBash  bool
323		pathBash string
324		pathErr  error
325		want     shellCommand
326	}{
327		{
328			name: "prefers /bin/bash",
329			goos: "linux", binBash: true, pathBash: "/usr/local/bin/bash",
330			want: shellCommand{path: "/bin/bash", flag: "-c", isBash: true},
331		},
332		{
333			name: "uses bash from PATH",
334			goos: "linux", pathBash: "/usr/local/bin/bash",
335			want: shellCommand{path: "/usr/local/bin/bash", flag: "-c", isBash: true},
336		},
337		{
338			name: "falls back to POSIX sh",
339			goos: "linux", pathErr: notFound,
340			want: shellCommand{path: "/bin/sh", flag: "-c"},
341		},
342		{
343			name: "uses Command Prompt on Windows",
344			goos: "windows", binBash: true, pathBash: "/usr/local/bin/bash",
345			want: shellCommand{path: "cmd", flag: "/C"},
346		},
347	}
348	for _, tt := range tests {
349		t.Run(tt.name, func(t *testing.T) {
350			got := resolveShell(tt.goos, func(path string) bool {
351				return path == "/bin/bash" && tt.binBash
352			}, func(name string) (string, error) {
353				if name != "bash" {
354					t.Fatalf("unexpected PATH lookup for %q", name)
355				}
356				return tt.pathBash, tt.pathErr
357			})
358			if got != tt.want {
359				t.Fatalf("resolveShell() = %+v, want %+v", got, tt.want)
360			}
361		})
362	}
363}
364
365func TestShellDescription(t *testing.T) {
366	tests := []struct {
367		name  string
368		shell shellCommand
369		want  string
370	}{
371		{
372			name:  "bash",
373			shell: shellCommand{path: "/opt/bin/bash", flag: "-c", isBash: true},
374			want:  "Run a Bash command via /opt/bin/bash -c. stdout+stderr merged.",
375		},
376		{
377			name:  "POSIX fallback",
378			shell: shellCommand{path: "/bin/sh", flag: "-c"},
379			want:  "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged.",
380		},
381		{
382			name:  "Windows",
383			shell: shellCommand{path: "cmd", flag: "/C"},
384			want:  "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.",
385		},
386	}
387	for _, tt := range tests {
388		t.Run(tt.name, func(t *testing.T) {
389			if got := shellDescription(tt.shell); got != tt.want {
390				t.Fatalf("shellDescription() = %q, want %q", got, tt.want)
391			}
392		})
393	}
394}
395
396func TestBashSchemaRequiresTimeoutInSeconds(t *testing.T) {
397	var schema struct {
398		Properties map[string]struct {
399			Type        string `json:"type"`
400			Minimum     *int64 `json:"minimum"`
401			Maximum     *int64 `json:"maximum"`
402			Description string `json:"description"`
403		} `json:"properties"`
404		Required []string `json:"required"`
405	}
406	if err := json.Unmarshal((&BashTool{}).Schema(), &schema); err != nil {
407		t.Fatal(err)
408	}
409
410	timeout, ok := schema.Properties["timeout"]
411	if !ok {
412		t.Fatal("timeout property missing from schema")
413	}
414	if timeout.Type != "integer" {
415		t.Fatalf("timeout type = %q, want integer", timeout.Type)
416	}
417	if timeout.Minimum == nil || *timeout.Minimum != 1 {
418		if timeout.Minimum == nil {
419			t.Fatal("timeout minimum missing from schema")
420		}
421		t.Fatalf("timeout minimum = %d, want 1", *timeout.Minimum)
422	}
423	if timeout.Maximum == nil {
424		t.Fatal("timeout maximum missing from schema")
425	}
426	if *timeout.Maximum != maxBashTimeoutSeconds {
427		t.Fatalf("timeout maximum = %d, want %d", *timeout.Maximum, maxBashTimeoutSeconds)
428	}
429	if !strings.Contains(strings.ToLower(timeout.Description), "second") {
430		t.Fatalf("timeout description = %q, want seconds wording", timeout.Description)
431	}
432
433	required := make(map[string]bool, len(schema.Required))
434	for _, name := range schema.Required {
435		required[name] = true
436	}
437	for _, name := range []string{"command", "timeout"} {
438		if !required[name] {
439			t.Errorf("required fields missing %q: %v", name, schema.Required)
440		}
441	}
442}
443
444func TestBashTimeoutDurationBounds(t *testing.T) {
445	tests := []struct {
446		name    string
447		seconds int64
448		want    time.Duration
449		wantErr bool
450	}{
451		{name: "minimum", seconds: 1, want: time.Second},
452		{name: "maximum", seconds: maxBashTimeoutSeconds, want: time.Duration(maxBashTimeoutSeconds) * time.Second},
453		{name: "one over maximum", seconds: maxBashTimeoutSeconds + 1, wantErr: true},
454	}
455	for _, tt := range tests {
456		t.Run(tt.name, func(t *testing.T) {
457			got, err := bashTimeoutDuration(tt.seconds)
458			if tt.wantErr {
459				if err == nil {
460					t.Fatalf("bashTimeoutDuration(%d) returned nil error", tt.seconds)
461				}
462				return
463			}
464			if err != nil {
465				t.Fatalf("bashTimeoutDuration(%d): %v", tt.seconds, err)
466			}
467			if got != tt.want {
468				t.Fatalf("bashTimeoutDuration(%d) = %s, want %s", tt.seconds, got, tt.want)
469			}
470		})
471	}
472}
473
474func TestBashSuccess(t *testing.T) {
475	if runtime.GOOS == "windows" {
476		t.Skip("posix shell only")
477	}
478	tool := &BashTool{CWD: t.TempDir()}
479	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi", "timeout": 1}), nil)
480	if err != nil {
481		t.Fatal(err)
482	}
483	got := res.Content[0].(provider.TextBlock).Text
484	if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
485		t.Fatalf("got %q", got)
486	}
487	if strings.Contains(got, "Took ") {
488		t.Fatalf("bash output still contains duplicate timing: %q", got)
489	}
490	details, ok := res.Details.(map[string]any)
491	if !ok || details == nil {
492		t.Fatalf("bash details = %#v (%T), want non-nil map[string]any", res.Details, res.Details)
493	}
494	if _, exists := details["duration_ms"]; exists {
495		t.Fatalf("bash details still contain duplicate duration: %#v", details)
496	}
497	if res.IsError {
498		t.Fatal("unexpected error flag")
499	}
500}
501
502func TestBashSyntax(t *testing.T) {
503	if runtime.GOOS == "windows" {
504		t.Skip("bash syntax is not used on Windows")
505	}
506	if !currentShell().isBash {
507		t.Skip("bash is unavailable")
508	}
509	tool := &BashTool{CWD: t.TempDir()}
510	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
511		"command": `items=(one two); [[ ${#items[@]} -eq 2 ]] && printf 'bash syntax works\n'`,
512		"timeout": 1,
513	}), nil)
514	if err != nil {
515		t.Fatal(err)
516	}
517	got := res.Content[0].(provider.TextBlock).Text
518	if res.IsError || !strings.Contains(got, "bash syntax works") {
519		t.Fatalf("got %q", got)
520	}
521}
522
523func TestBashFailure(t *testing.T) {
524	if runtime.GOOS == "windows" {
525		t.Skip("posix shell only")
526	}
527	tool := &BashTool{CWD: t.TempDir()}
528	res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false", "timeout": 1}), nil)
529	if !res.IsError {
530		t.Fatal("want error")
531	}
532	got := res.Content[0].(provider.TextBlock).Text
533	if !strings.Contains(got, "[exit 1]") {
534		t.Fatalf("got %q", got)
535	}
536}
537
538func TestBashRejectsInvalidTimeout(t *testing.T) {
539	tool := &BashTool{CWD: t.TempDir()}
540	tests := []struct {
541		name string
542		args map[string]any
543		want string
544	}{
545		{name: "missing", args: map[string]any{"command": "echo hi"}, want: "timeout is required"},
546		{name: "null", args: map[string]any{"command": "echo hi", "timeout": nil}, want: "timeout is required"},
547		{name: "zero", args: map[string]any{"command": "echo hi", "timeout": 0}, want: "timeout must be between 1 and"},
548		{name: "negative", args: map[string]any{"command": "echo hi", "timeout": -1}, want: "timeout must be between 1 and"},
549		{name: "one over maximum", args: map[string]any{"command": "echo hi", "timeout": maxBashTimeoutSeconds + 1}, want: "timeout must be between 1 and"},
550	}
551	for _, tt := range tests {
552		t.Run(tt.name, func(t *testing.T) {
553			_, err := tool.Execute(context.Background(), mustJSON(t, tt.args), nil)
554			if err == nil || !strings.Contains(err.Error(), tt.want) {
555				t.Fatalf("error = %v, want substring %q", err, tt.want)
556			}
557		})
558	}
559
560	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
561		"command": "echo hi",
562		"timeout": "1",
563	}), nil)
564	if err == nil || !strings.Contains(err.Error(), "invalid args") {
565		t.Fatalf("wrong-type error = %v, want invalid args", err)
566	}
567}
568
569func TestBashTimeoutCancelsCommand(t *testing.T) {
570	if runtime.GOOS == "windows" {
571		t.Skip("posix shell only")
572	}
573	tool := &BashTool{CWD: t.TempDir()}
574	start := time.Now()
575	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
576		"command": "sleep 5",
577		"timeout": 1,
578	}), nil)
579	elapsed := time.Since(start)
580	if err != nil {
581		t.Fatal(err)
582	}
583	if !res.IsError {
584		t.Fatal("timed-out command should be an error")
585	}
586	if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "[timed out after 1 second]") {
587		t.Fatalf("timeout result = %q, want timeout diagnostic", got)
588	}
589	if elapsed >= 3*time.Second {
590		t.Fatalf("timeout took %s; command was not cancelled promptly", elapsed)
591	}
592}

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 477

↑↓ moveenter openesc close