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 TestEditAmbiguous(t *testing.T) {
188 dir := t.TempDir()
189 p := filepath.Join(dir, "a.txt")
190 os.WriteFile(p, []byte("x\nx\n"), 0o644)
191 tool := &EditTool{CWD: dir}
192 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
193 "path": "a.txt",
194 "edits": []map[string]any{{"oldText": "x", "newText": "y"}},
195 }), nil)
196 if err == nil {
197 t.Fatal("want ambiguous error")
198 }
199}
200
201func TestEditGuidance(t *testing.T) {
202 desc := (&EditTool{}).Description()
203 for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
204 if !strings.Contains(desc, want) {
205 t.Errorf("description missing %q: %q", want, desc)
206 }
207 }
208
209 var schema map[string]any
210 if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
211 t.Fatal(err)
212 }
213 properties := schema["properties"].(map[string]any)
214 edits := properties["edits"].(map[string]any)
215 items := edits["items"].(map[string]any)
216 editProperties := items["properties"].(map[string]any)
217 oldText := editProperties["oldText"].(map[string]any)
218 if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
219 t.Fatalf("oldText schema description missing target-file guidance: %q", got)
220 }
221}
222
223func TestEditNotFoundGuidesRecovery(t *testing.T) {
224 dir := t.TempDir()
225 p := filepath.Join(dir, "destination.txt")
226 if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
227 t.Fatal(err)
228 }
229 tool := &EditTool{CWD: dir}
230 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
231 "path": "destination.txt",
232 "edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
233 }), nil)
234 if err == nil {
235 t.Fatal("want oldText not found error")
236 }
237 for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
238 if !strings.Contains(err.Error(), want) {
239 t.Errorf("error missing %q: %q", want, err)
240 }
241 }
242}
243
244func TestEditPreservesCRLF(t *testing.T) {
245 dir := t.TempDir()
246 p := filepath.Join(dir, "a.txt")
247 os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644)
248 tool := &EditTool{CWD: dir}
249 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
250 "path": "a.txt",
251 "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
252 }), nil)
253 if err != nil {
254 t.Fatal(err)
255 }
256 b, _ := os.ReadFile(p)
257 if string(b) != "hello\r\ngopher\r\n" {
258 t.Fatalf("got %q", string(b))
259 }
260}
261
262func TestResolveShell(t *testing.T) {
263 notFound := errors.New("not found")
264 tests := []struct {
265 name string
266 goos string
267 binBash bool
268 pathBash string
269 pathErr error
270 want shellCommand
271 }{
272 {
273 name: "prefers /bin/bash",
274 goos: "linux", binBash: true, pathBash: "/usr/local/bin/bash",
275 want: shellCommand{path: "/bin/bash", flag: "-c", isBash: true},
276 },
277 {
278 name: "uses bash from PATH",
279 goos: "linux", pathBash: "/usr/local/bin/bash",
280 want: shellCommand{path: "/usr/local/bin/bash", flag: "-c", isBash: true},
281 },
282 {
283 name: "falls back to POSIX sh",
284 goos: "linux", pathErr: notFound,
285 want: shellCommand{path: "/bin/sh", flag: "-c"},
286 },
287 {
288 name: "uses Command Prompt on Windows",
289 goos: "windows", binBash: true, pathBash: "/usr/local/bin/bash",
290 want: shellCommand{path: "cmd", flag: "/C"},
291 },
292 }
293 for _, tt := range tests {
294 t.Run(tt.name, func(t *testing.T) {
295 got := resolveShell(tt.goos, func(path string) bool {
296 return path == "/bin/bash" && tt.binBash
297 }, func(name string) (string, error) {
298 if name != "bash" {
299 t.Fatalf("unexpected PATH lookup for %q", name)
300 }
301 return tt.pathBash, tt.pathErr
302 })
303 if got != tt.want {
304 t.Fatalf("resolveShell() = %+v, want %+v", got, tt.want)
305 }
306 })
307 }
308}
309
310func TestShellDescription(t *testing.T) {
311 tests := []struct {
312 name string
313 shell shellCommand
314 want string
315 }{
316 {
317 name: "bash",
318 shell: shellCommand{path: "/opt/bin/bash", flag: "-c", isBash: true},
319 want: "Run a Bash command via /opt/bin/bash -c. stdout+stderr merged.",
320 },
321 {
322 name: "POSIX fallback",
323 shell: shellCommand{path: "/bin/sh", flag: "-c"},
324 want: "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged.",
325 },
326 {
327 name: "Windows",
328 shell: shellCommand{path: "cmd", flag: "/C"},
329 want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.",
330 },
331 }
332 for _, tt := range tests {
333 t.Run(tt.name, func(t *testing.T) {
334 if got := shellDescription(tt.shell); got != tt.want {
335 t.Fatalf("shellDescription() = %q, want %q", got, tt.want)
336 }
337 })
338 }
339}
340
341func TestBashSchemaRequiresTimeoutInSeconds(t *testing.T) {
342 var schema struct {
343 Properties map[string]struct {
344 Type string `json:"type"`
345 Minimum *int64 `json:"minimum"`
346 Maximum *int64 `json:"maximum"`
347 Description string `json:"description"`
348 } `json:"properties"`
349 Required []string `json:"required"`
350 }
351 if err := json.Unmarshal((&BashTool{}).Schema(), &schema); err != nil {
352 t.Fatal(err)
353 }
354
355 timeout, ok := schema.Properties["timeout"]
356 if !ok {
357 t.Fatal("timeout property missing from schema")
358 }
359 if timeout.Type != "integer" {
360 t.Fatalf("timeout type = %q, want integer", timeout.Type)
361 }
362 if timeout.Minimum == nil || *timeout.Minimum != 1 {
363 if timeout.Minimum == nil {
364 t.Fatal("timeout minimum missing from schema")
365 }
366 t.Fatalf("timeout minimum = %d, want 1", *timeout.Minimum)
367 }
368 if timeout.Maximum == nil {
369 t.Fatal("timeout maximum missing from schema")
370 }
371 if *timeout.Maximum != maxBashTimeoutSeconds {
372 t.Fatalf("timeout maximum = %d, want %d", *timeout.Maximum, maxBashTimeoutSeconds)
373 }
374 if !strings.Contains(strings.ToLower(timeout.Description), "second") {
375 t.Fatalf("timeout description = %q, want seconds wording", timeout.Description)
376 }
377
378 required := make(map[string]bool, len(schema.Required))
379 for _, name := range schema.Required {
380 required[name] = true
381 }
382 for _, name := range []string{"command", "timeout"} {
383 if !required[name] {
384 t.Errorf("required fields missing %q: %v", name, schema.Required)
385 }
386 }
387}
388
389func TestBashTimeoutDurationBounds(t *testing.T) {
390 tests := []struct {
391 name string
392 seconds int64
393 want time.Duration
394 wantErr bool
395 }{
396 {name: "minimum", seconds: 1, want: time.Second},
397 {name: "maximum", seconds: maxBashTimeoutSeconds, want: time.Duration(maxBashTimeoutSeconds) * time.Second},
398 {name: "one over maximum", seconds: maxBashTimeoutSeconds + 1, wantErr: true},
399 }
400 for _, tt := range tests {
401 t.Run(tt.name, func(t *testing.T) {
402 got, err := bashTimeoutDuration(tt.seconds)
403 if tt.wantErr {
404 if err == nil {
405 t.Fatalf("bashTimeoutDuration(%d) returned nil error", tt.seconds)
406 }
407 return
408 }
409 if err != nil {
410 t.Fatalf("bashTimeoutDuration(%d): %v", tt.seconds, err)
411 }
412 if got != tt.want {
413 t.Fatalf("bashTimeoutDuration(%d) = %s, want %s", tt.seconds, got, tt.want)
414 }
415 })
416 }
417}
418
419func TestBashSuccess(t *testing.T) {
420 if runtime.GOOS == "windows" {
421 t.Skip("posix shell only")
422 }
423 tool := &BashTool{CWD: t.TempDir()}
424 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi", "timeout": 1}), nil)
425 if err != nil {
426 t.Fatal(err)
427 }
428 got := res.Content[0].(provider.TextBlock).Text
429 if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
430 t.Fatalf("got %q", got)
431 }
432 if strings.Contains(got, "Took ") {
433 t.Fatalf("bash output still contains duplicate timing: %q", got)
434 }
435 details, ok := res.Details.(map[string]any)
436 if !ok || details == nil {
437 t.Fatalf("bash details = %#v (%T), want non-nil map[string]any", res.Details, res.Details)
438 }
439 if _, exists := details["duration_ms"]; exists {
440 t.Fatalf("bash details still contain duplicate duration: %#v", details)
441 }
442 if res.IsError {
443 t.Fatal("unexpected error flag")
444 }
445}
446
447func TestBashSyntax(t *testing.T) {
448 if runtime.GOOS == "windows" {
449 t.Skip("bash syntax is not used on Windows")
450 }
451 if !currentShell().isBash {
452 t.Skip("bash is unavailable")
453 }
454 tool := &BashTool{CWD: t.TempDir()}
455 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
456 "command": `items=(one two); [[ ${#items[@]} -eq 2 ]] && printf 'bash syntax works\n'`,
457 "timeout": 1,
458 }), nil)
459 if err != nil {
460 t.Fatal(err)
461 }
462 got := res.Content[0].(provider.TextBlock).Text
463 if res.IsError || !strings.Contains(got, "bash syntax works") {
464 t.Fatalf("got %q", got)
465 }
466}
467
468func TestBashFailure(t *testing.T) {
469 if runtime.GOOS == "windows" {
470 t.Skip("posix shell only")
471 }
472 tool := &BashTool{CWD: t.TempDir()}
473 res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false", "timeout": 1}), nil)
474 if !res.IsError {
475 t.Fatal("want error")
476 }
477 got := res.Content[0].(provider.TextBlock).Text
478 if !strings.Contains(got, "[exit 1]") {
479 t.Fatalf("got %q", got)
480 }
481}
482
483func TestBashRejectsInvalidTimeout(t *testing.T) {
484 tool := &BashTool{CWD: t.TempDir()}
485 tests := []struct {
486 name string
487 args map[string]any
488 want string
489 }{
490 {name: "missing", args: map[string]any{"command": "echo hi"}, want: "timeout is required"},
491 {name: "null", args: map[string]any{"command": "echo hi", "timeout": nil}, want: "timeout is required"},
492 {name: "zero", args: map[string]any{"command": "echo hi", "timeout": 0}, want: "timeout must be between 1 and"},
493 {name: "negative", args: map[string]any{"command": "echo hi", "timeout": -1}, want: "timeout must be between 1 and"},
494 {name: "one over maximum", args: map[string]any{"command": "echo hi", "timeout": maxBashTimeoutSeconds + 1}, want: "timeout must be between 1 and"},
495 }
496 for _, tt := range tests {
497 t.Run(tt.name, func(t *testing.T) {
498 _, err := tool.Execute(context.Background(), mustJSON(t, tt.args), nil)
499 if err == nil || !strings.Contains(err.Error(), tt.want) {
500 t.Fatalf("error = %v, want substring %q", err, tt.want)
501 }
502 })
503 }
504
505 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
506 "command": "echo hi",
507 "timeout": "1",
508 }), nil)
509 if err == nil || !strings.Contains(err.Error(), "invalid args") {
510 t.Fatalf("wrong-type error = %v, want invalid args", err)
511 }
512}
513
514func TestBashTimeoutCancelsCommand(t *testing.T) {
515 if runtime.GOOS == "windows" {
516 t.Skip("posix shell only")
517 }
518 tool := &BashTool{CWD: t.TempDir()}
519 start := time.Now()
520 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
521 "command": "sleep 5",
522 "timeout": 1,
523 }), nil)
524 elapsed := time.Since(start)
525 if err != nil {
526 t.Fatal(err)
527 }
528 if !res.IsError {
529 t.Fatal("timed-out command should be an error")
530 }
531 if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "[timed out after 1 second]") {
532 t.Fatalf("timeout result = %q, want timeout diagnostic", got)
533 }
534 if elapsed >= 3*time.Second {
535 t.Fatalf("timeout took %s; command was not cancelled promptly", elapsed)
536 }
537}