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 TestEditMultiple(t *testing.T) {
127 dir := t.TempDir()
128 p := filepath.Join(dir, "a.txt")
129 os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)
130 tool := &EditTool{CWD: dir}
131 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
132 "path": "a.txt",
133 "edits": []map[string]any{
134 {"oldText": "a", "newText": "A"},
135 {"oldText": "c", "newText": "C"},
136 },
137 }), nil)
138 if err != nil {
139 t.Fatal(err)
140 }
141 b, _ := os.ReadFile(p)
142 if string(b) != "A\nb\nC\n" {
143 t.Fatalf("got %q", string(b))
144 }
145}
146
147func TestEditAmbiguous(t *testing.T) {
148 dir := t.TempDir()
149 p := filepath.Join(dir, "a.txt")
150 os.WriteFile(p, []byte("x\nx\n"), 0o644)
151 tool := &EditTool{CWD: dir}
152 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
153 "path": "a.txt",
154 "edits": []map[string]any{{"oldText": "x", "newText": "y"}},
155 }), nil)
156 if err == nil {
157 t.Fatal("want ambiguous error")
158 }
159}
160
161func TestEditGuidance(t *testing.T) {
162 desc := (&EditTool{}).Description()
163 for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
164 if !strings.Contains(desc, want) {
165 t.Errorf("description missing %q: %q", want, desc)
166 }
167 }
168
169 var schema map[string]any
170 if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
171 t.Fatal(err)
172 }
173 properties := schema["properties"].(map[string]any)
174 edits := properties["edits"].(map[string]any)
175 items := edits["items"].(map[string]any)
176 editProperties := items["properties"].(map[string]any)
177 oldText := editProperties["oldText"].(map[string]any)
178 if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
179 t.Fatalf("oldText schema description missing target-file guidance: %q", got)
180 }
181}
182
183func TestEditNotFoundGuidesRecovery(t *testing.T) {
184 dir := t.TempDir()
185 p := filepath.Join(dir, "destination.txt")
186 if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
187 t.Fatal(err)
188 }
189 tool := &EditTool{CWD: dir}
190 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
191 "path": "destination.txt",
192 "edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
193 }), nil)
194 if err == nil {
195 t.Fatal("want oldText not found error")
196 }
197 for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
198 if !strings.Contains(err.Error(), want) {
199 t.Errorf("error missing %q: %q", want, err)
200 }
201 }
202}
203
204func TestEditPreservesCRLF(t *testing.T) {
205 dir := t.TempDir()
206 p := filepath.Join(dir, "a.txt")
207 os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644)
208 tool := &EditTool{CWD: dir}
209 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
210 "path": "a.txt",
211 "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
212 }), nil)
213 if err != nil {
214 t.Fatal(err)
215 }
216 b, _ := os.ReadFile(p)
217 if string(b) != "hello\r\ngopher\r\n" {
218 t.Fatalf("got %q", string(b))
219 }
220}
221
222func TestBashSuccess(t *testing.T) {
223 if runtime.GOOS == "windows" {
224 t.Skip("posix shell only")
225 }
226 tool := &BashTool{CWD: t.TempDir()}
227 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi"}), nil)
228 if err != nil {
229 t.Fatal(err)
230 }
231 got := res.Content[0].(provider.TextBlock).Text
232 if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
233 t.Fatalf("got %q", got)
234 }
235 if res.IsError {
236 t.Fatal("unexpected error flag")
237 }
238}
239
240func TestBashFailure(t *testing.T) {
241 if runtime.GOOS == "windows" {
242 t.Skip("posix shell only")
243 }
244 tool := &BashTool{CWD: t.TempDir()}
245 res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false"}), nil)
246 if !res.IsError {
247 t.Fatal("want error")
248 }
249 got := res.Content[0].(provider.TextBlock).Text
250 if !strings.Contains(got, "[exit 1]") {
251 t.Fatalf("got %q", got)
252 }
253}