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/patriceckhart/zot/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}