1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11
12 "github.com/patriceckhart/zot/packages/provider"
13)
14
15func mustJSON(t *testing.T, v any) json.RawMessage {
16 t.Helper()
17 b, err := json.Marshal(v)
18 if err != nil {
19 t.Fatal(err)
20 }
21 return b
22}
23
24func TestReadText(t *testing.T) {
25 dir := t.TempDir()
26 p := filepath.Join(dir, "a.txt")
27 if err := os.WriteFile(p, []byte("hello\nworld\n"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 tool := &ReadTool{CWD: dir}
31 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt"}), nil)
32 if err != nil {
33 t.Fatal(err)
34 }
35 got := res.Content[0].(provider.TextBlock).Text
36 if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
37 t.Fatalf("got %q", got)
38 }
39}
40
41func TestReadImageMimeFromContentNotExtension(t *testing.T) {
42 // A file named .png whose bytes are actually JPEG. The MIME must be
43 // sniffed from the content (image/jpeg), not the extension, or
44 // providers that validate the declared media type reject the request.
45 dir := t.TempDir()
46 p := filepath.Join(dir, "shot.png")
47 jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
48 if err := os.WriteFile(p, jpegBytes, 0o644); err != nil {
49 t.Fatal(err)
50 }
51 tool := &ReadTool{CWD: dir}
52 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil)
53 if err != nil {
54 t.Fatal(err)
55 }
56 img, ok := res.Content[0].(provider.ImageBlock)
57 if !ok {
58 t.Fatalf("expected ImageBlock, got %T", res.Content[0])
59 }
60 if img.MimeType != "image/jpeg" {
61 t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType)
62 }
63}
64
65func TestReadOffsetLimit(t *testing.T) {
66 dir := t.TempDir()
67 p := filepath.Join(dir, "a.txt")
68 os.WriteFile(p, []byte("1\n2\n3\n4\n5\n"), 0o644)
69 tool := &ReadTool{CWD: dir}
70 res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil)
71 got := res.Content[0].(provider.TextBlock).Text
72 // Current output format is raw bytes (no embedded line numbers):
73 // the tui draws its own gutter from the `start_line` detail.
74 if got != "2\n3\n" {
75 t.Fatalf("want \"2\\n3\\n\", got %q", got)
76 }
77 if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 {
78 t.Errorf("start_line detail want 2, got %v", start)
79 }
80}
81
82func TestReadBinaryRejected(t *testing.T) {
83 dir := t.TempDir()
84 p := filepath.Join(dir, "b.bin")
85 os.WriteFile(p, []byte{0x00, 0x01, 0x02}, 0o644)
86 tool := &ReadTool{CWD: dir}
87 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "b.bin"}), nil); err == nil {
88 t.Fatal("want binary rejection")
89 }
90}
91
92func TestWriteCreatesDirs(t *testing.T) {
93 dir := t.TempDir()
94 tool := &WriteTool{CWD: dir}
95 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "sub/a.txt", "content": "hi"}), nil)
96 if err != nil {
97 t.Fatal(err)
98 }
99 b, err := os.ReadFile(filepath.Join(dir, "sub", "a.txt"))
100 if err != nil {
101 t.Fatal(err)
102 }
103 if string(b) != "hi" {
104 t.Fatalf("got %q", string(b))
105 }
106}
107
108func TestEditSingle(t *testing.T) {
109 dir := t.TempDir()
110 p := filepath.Join(dir, "a.txt")
111 os.WriteFile(p, []byte("hello world\n"), 0o644)
112 tool := &EditTool{CWD: dir}
113 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
114 "path": "a.txt",
115 "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
116 }), nil)
117 if err != nil {
118 t.Fatal(err)
119 }
120 b, _ := os.ReadFile(p)
121 if string(b) != "hello gopher\n" {
122 t.Fatalf("got %q", string(b))
123 }
124}
125
126func TestEditPreviewReturnsDiffWithoutWriting(t *testing.T) {
127 dir := t.TempDir()
128 p := filepath.Join(dir, "a.txt")
129 if err := os.WriteFile(p, []byte("hello world\n"), 0o644); err != nil {
130 t.Fatal(err)
131 }
132 tool := &EditTool{CWD: dir}
133 args := mustJSON(t, map[string]any{
134 "path": "a.txt",
135 "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
136 })
137 preview, err := tool.Preview(context.Background(), args)
138 if err != nil {
139 t.Fatal(err)
140 }
141 text := preview.Content[0].(provider.TextBlock).Text
142 for _, want := range []string{"-hello world", "+hello gopher"} {
143 if !strings.Contains(text, want) {
144 t.Fatalf("preview missing %q:\n%s", want, text)
145 }
146 }
147 b, err := os.ReadFile(p)
148 if err != nil {
149 t.Fatal(err)
150 }
151 if string(b) != "hello world\n" {
152 t.Fatalf("preview modified file: %q", b)
153 }
154
155 result, err := tool.Execute(context.Background(), args, nil)
156 if err != nil {
157 t.Fatal(err)
158 }
159 if got := result.Content[0].(provider.TextBlock).Text; got != text {
160 t.Fatalf("executed diff differs from preview:\npreview:\n%s\nresult:\n%s", text, got)
161 }
162}
163
164func TestEditMultiple(t *testing.T) {
165 dir := t.TempDir()
166 p := filepath.Join(dir, "a.txt")
167 os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)
168 tool := &EditTool{CWD: dir}
169 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
170 "path": "a.txt",
171 "edits": []map[string]any{
172 {"oldText": "a", "newText": "A"},
173 {"oldText": "c", "newText": "C"},
174 },
175 }), nil)
176 if err != nil {
177 t.Fatal(err)
178 }
179 b, _ := os.ReadFile(p)
180 if string(b) != "A\nb\nC\n" {
181 t.Fatalf("got %q", string(b))
182 }
183}
184
185func TestEditAmbiguous(t *testing.T) {
186 dir := t.TempDir()
187 p := filepath.Join(dir, "a.txt")
188 os.WriteFile(p, []byte("x\nx\n"), 0o644)
189 tool := &EditTool{CWD: dir}
190 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
191 "path": "a.txt",
192 "edits": []map[string]any{{"oldText": "x", "newText": "y"}},
193 }), nil)
194 if err == nil {
195 t.Fatal("want ambiguous error")
196 }
197}
198
199func TestEditGuidance(t *testing.T) {
200 desc := (&EditTool{}).Description()
201 for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
202 if !strings.Contains(desc, want) {
203 t.Errorf("description missing %q: %q", want, desc)
204 }
205 }
206
207 var schema map[string]any
208 if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
209 t.Fatal(err)
210 }
211 properties := schema["properties"].(map[string]any)
212 edits := properties["edits"].(map[string]any)
213 items := edits["items"].(map[string]any)
214 editProperties := items["properties"].(map[string]any)
215 oldText := editProperties["oldText"].(map[string]any)
216 if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
217 t.Fatalf("oldText schema description missing target-file guidance: %q", got)
218 }
219}
220
221func TestEditNotFoundGuidesRecovery(t *testing.T) {
222 dir := t.TempDir()
223 p := filepath.Join(dir, "destination.txt")
224 if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
225 t.Fatal(err)
226 }
227 tool := &EditTool{CWD: dir}
228 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
229 "path": "destination.txt",
230 "edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
231 }), nil)
232 if err == nil {
233 t.Fatal("want oldText not found error")
234 }
235 for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
236 if !strings.Contains(err.Error(), want) {
237 t.Errorf("error missing %q: %q", want, err)
238 }
239 }
240}
241
242func TestEditPreservesCRLF(t *testing.T) {
243 dir := t.TempDir()
244 p := filepath.Join(dir, "a.txt")
245 os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644)
246 tool := &EditTool{CWD: dir}
247 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
248 "path": "a.txt",
249 "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
250 }), nil)
251 if err != nil {
252 t.Fatal(err)
253 }
254 b, _ := os.ReadFile(p)
255 if string(b) != "hello\r\ngopher\r\n" {
256 t.Fatalf("got %q", string(b))
257 }
258}
259
260func TestBashSuccess(t *testing.T) {
261 if runtime.GOOS == "windows" {
262 t.Skip("posix shell only")
263 }
264 tool := &BashTool{CWD: t.TempDir()}
265 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi"}), nil)
266 if err != nil {
267 t.Fatal(err)
268 }
269 got := res.Content[0].(provider.TextBlock).Text
270 if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
271 t.Fatalf("got %q", got)
272 }
273 if res.IsError {
274 t.Fatal("unexpected error flag")
275 }
276}
277
278func TestBashFailure(t *testing.T) {
279 if runtime.GOOS == "windows" {
280 t.Skip("posix shell only")
281 }
282 tool := &BashTool{CWD: t.TempDir()}
283 res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false"}), nil)
284 if !res.IsError {
285 t.Fatal("want error")
286 }
287 got := res.Content[0].(provider.TextBlock).Text
288 if !strings.Contains(got, "[exit 1]") {
289 t.Fatalf("got %q", got)
290 }
291}