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}