1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "charm.land/fantasy"
14 "github.com/charmbracelet/crush/internal/filetracker"
15 "github.com/charmbracelet/crush/internal/permission"
16 "github.com/charmbracelet/crush/internal/pubsub"
17 "github.com/stretchr/testify/require"
18)
19
20func TestReadTextFileBoundaryCases(t *testing.T) {
21 t.Parallel()
22
23 tmpDir := t.TempDir()
24 filePath := filepath.Join(tmpDir, "sample.txt")
25
26 var allLines []string
27 for i := range 5 {
28 allLines = append(allLines, fmt.Sprintf("line %d", i+1))
29 }
30 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(allLines, "\n")), 0o644))
31
32 tests := []struct {
33 name string
34 offset int
35 limit int
36 wantContent string
37 wantHasMore bool
38 }{
39 {
40 name: "exactly limit lines remaining",
41 offset: 0,
42 limit: 5,
43 wantContent: "line 1\nline 2\nline 3\nline 4\nline 5",
44 wantHasMore: false,
45 },
46 {
47 name: "limit plus one line remaining",
48 offset: 0,
49 limit: 4,
50 wantContent: "line 1\nline 2\nline 3\nline 4",
51 wantHasMore: true,
52 },
53 {
54 name: "offset at last line",
55 offset: 4,
56 limit: 3,
57 wantContent: "line 5",
58 wantHasMore: false,
59 },
60 {
61 name: "offset beyond eof",
62 offset: 10,
63 limit: 3,
64 wantContent: "",
65 wantHasMore: false,
66 },
67 }
68
69 for _, tt := range tests {
70 t.Run(tt.name, func(t *testing.T) {
71 t.Parallel()
72
73 gotContent, gotHasMore, err := readTextFile(filePath, tt.offset, tt.limit, 0)
74 require.NoError(t, err)
75 require.Equal(t, tt.wantContent, gotContent)
76 require.Equal(t, tt.wantHasMore, gotHasMore)
77 })
78 }
79}
80
81func TestReadTextFileTruncatesLongLines(t *testing.T) {
82 t.Parallel()
83
84 tmpDir := t.TempDir()
85 filePath := filepath.Join(tmpDir, "longline.txt")
86
87 longLine := strings.Repeat("a", MaxLineLength+10)
88 require.NoError(t, os.WriteFile(filePath, []byte(longLine), 0o644))
89
90 content, hasMore, err := readTextFile(filePath, 0, 1, 0)
91 require.NoError(t, err)
92 require.False(t, hasMore)
93 require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content)
94}
95
96func TestReadTextFileLineExceeding1MB(t *testing.T) {
97 t.Parallel()
98
99 tmpDir := t.TempDir()
100 filePath := filepath.Join(tmpDir, "huge_line.txt")
101
102 hugeLine := strings.Repeat("A", 2*1024*1024) // 2MB — exceeds bufio.Scanner max
103 require.NoError(t, os.WriteFile(filePath, []byte(hugeLine), 0o644))
104
105 content, hasMore, err := readTextFile(filePath, 0, 1, 0)
106 require.NoError(t, err)
107 require.False(t, hasMore)
108 require.Equal(t, strings.Repeat("A", MaxLineLength)+"...", content)
109}
110
111func TestViewToolAllowsSmallSectionsOfLargeFiles(t *testing.T) {
112 t.Parallel()
113
114 workingDir := t.TempDir()
115 filePath := filepath.Join(workingDir, "large.txt")
116 lines := []string{strings.Repeat("a", MaxViewSize+1), "target line", "after target"}
117 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644))
118
119 tool := newViewToolForTest(workingDir)
120 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
121 resp := runViewTool(t, tool, ctx, ViewParams{
122 FilePath: filePath,
123 Offset: 1,
124 Limit: 1,
125 })
126
127 require.False(t, resp.IsError)
128 require.Contains(t, resp.Content, " 2|target line")
129 require.NotContains(t, resp.Content, "File is too large")
130
131 var meta ViewResponseMetadata
132 require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
133 require.Equal(t, "target line", meta.Content)
134}
135
136func TestViewToolBlocksOversizedReturnedSections(t *testing.T) {
137 t.Parallel()
138
139 workingDir := t.TempDir()
140 filePath := filepath.Join(workingDir, "large-section.txt")
141 lines := make([]string, DefaultReadLimit)
142 for i := range lines {
143 lines[i] = strings.Repeat("a", MaxLineLength)
144 }
145 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644))
146
147 tool := newViewToolForTest(workingDir)
148 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
149 resp := runViewTool(t, tool, ctx, ViewParams{
150 FilePath: filePath,
151 })
152
153 require.True(t, resp.IsError)
154 require.Contains(t, resp.Content, "Content section is too large")
155}
156
157func TestViewToolBlocksOversizedImages(t *testing.T) {
158 t.Parallel()
159
160 workingDir := t.TempDir()
161 filePath := filepath.Join(workingDir, "large.png")
162 require.NoError(t, os.WriteFile(filePath, []byte(strings.Repeat("a", MaxViewSize+1)), 0o644))
163
164 tool := newViewToolForTest(workingDir)
165 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
166 ctx = context.WithValue(ctx, SupportsImagesContextKey, true)
167 resp := runViewTool(t, tool, ctx, ViewParams{
168 FilePath: filePath,
169 })
170
171 require.True(t, resp.IsError)
172 require.Contains(t, resp.Content, "Image file is too large")
173}
174
175func TestReadTextFileEnforcesMaxContentSize(t *testing.T) {
176 t.Parallel()
177
178 workingDir := t.TempDir()
179 filePath := filepath.Join(workingDir, "oversized.txt")
180 lines := []string{
181 strings.Repeat("a", MaxLineLength),
182 strings.Repeat("b", MaxLineLength),
183 "target line",
184 }
185 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644))
186
187 content, hasMore, err := readTextFile(filePath, 0, len(lines), MaxLineLength)
188 require.ErrorAs(t, err, &contentTooLargeError{})
189 require.Empty(t, content)
190 require.False(t, hasMore)
191
192 content, hasMore, err = readTextFile(filePath, 2, 1, MaxLineLength)
193 require.NoError(t, err)
194 require.Equal(t, "target line", content)
195 require.False(t, hasMore)
196}
197
198func TestReadTextFileAllowsExactMaxContentSize(t *testing.T) {
199 t.Parallel()
200
201 workingDir := t.TempDir()
202 filePath := filepath.Join(workingDir, "exact-size.txt")
203 require.NoError(t, os.WriteFile(filePath, []byte("abcd\nefgh"), 0o644))
204
205 content, hasMore, err := readTextFile(filePath, 0, 2, len("abcd\nefgh"))
206 require.NoError(t, err)
207 require.Equal(t, "abcd\nefgh", content)
208 require.False(t, hasMore)
209}
210
211type mockViewPermissionService struct {
212 *pubsub.Broker[permission.PermissionRequest]
213}
214
215func (m *mockViewPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
216 return true, nil
217}
218
219func (m *mockViewPermissionService) Grant(req permission.PermissionRequest) bool { return true }
220
221func (m *mockViewPermissionService) Deny(req permission.PermissionRequest) bool { return true }
222
223func (m *mockViewPermissionService) GrantPersistent(req permission.PermissionRequest) bool {
224 return true
225}
226
227func (m *mockViewPermissionService) AutoApproveSession(sessionID string) {}
228
229func (m *mockViewPermissionService) SetSkipRequests(skip bool) {}
230
231func (m *mockViewPermissionService) SkipRequests() bool {
232 return false
233}
234
235func (m *mockViewPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
236 return make(<-chan pubsub.Event[permission.PermissionNotification])
237}
238
239type mockFileTracker struct{}
240
241func (m mockFileTracker) RecordRead(ctx context.Context, sessionID, path string) {}
242
243func (m mockFileTracker) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
244 return time.Time{}
245}
246
247func (m mockFileTracker) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
248 return nil, nil
249}
250
251func newViewToolForTest(workingDir string) fantasy.AgentTool {
252 permissions := &mockViewPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
253 return NewViewTool(nil, permissions, mockFileTracker{}, nil, workingDir)
254}
255
256func runViewTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params ViewParams) fantasy.ToolResponse {
257 t.Helper()
258
259 input, err := json.Marshal(params)
260 require.NoError(t, err)
261
262 call := fantasy.ToolCall{
263 ID: "test-call",
264 Name: ViewToolName,
265 Input: string(input),
266 }
267
268 resp, err := tool.Run(ctx, call)
269 require.NoError(t, err)
270 return resp
271}
272
273var _ filetracker.Service = mockFileTracker{}
274
275func TestReadBuiltinFile(t *testing.T) {
276 t.Parallel()
277
278 t.Run("reads crush-config skill", func(t *testing.T) {
279 t.Parallel()
280
281 resp, err := readBuiltinFile(ViewParams{
282 FilePath: "crush://skills/crush-config/SKILL.md",
283 }, nil)
284 require.NoError(t, err)
285 require.NotEmpty(t, resp.Content)
286 require.Contains(t, resp.Content, "Crush Configuration")
287 })
288
289 t.Run("not found", func(t *testing.T) {
290 t.Parallel()
291
292 resp, err := readBuiltinFile(ViewParams{
293 FilePath: "crush://skills/nonexistent/SKILL.md",
294 }, nil)
295 require.NoError(t, err)
296 require.True(t, resp.IsError)
297 })
298
299 t.Run("metadata has skill info", func(t *testing.T) {
300 t.Parallel()
301
302 resp, err := readBuiltinFile(ViewParams{
303 FilePath: "crush://skills/crush-config/SKILL.md",
304 }, nil)
305 require.NoError(t, err)
306
307 var meta ViewResponseMetadata
308 require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
309 require.Equal(t, ViewResourceSkill, meta.ResourceType)
310 require.Equal(t, "crush-config", meta.ResourceName)
311 require.NotEmpty(t, meta.ResourceDescription)
312 })
313
314 t.Run("respects offset", func(t *testing.T) {
315 t.Parallel()
316
317 resp, err := readBuiltinFile(ViewParams{
318 FilePath: "crush://skills/crush-config/SKILL.md",
319 Offset: 5,
320 }, nil)
321 require.NoError(t, err)
322 require.NotContains(t, resp.Content, " 1|")
323 })
324}
325
326func TestSniffImageMimeType(t *testing.T) {
327 t.Parallel()
328
329 jpegMagic := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
330 pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
331 gifMagic := []byte("GIF89a")
332 // Minimal RIFF/WEBP header.
333 webpMagic := append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 16)...)
334 random := []byte("not an image at all, just text")
335
336 cases := []struct {
337 name string
338 data []byte
339 fallback string
340 want string
341 }{
342 {"jpeg bytes in .png file uses sniffed", jpegMagic, "image/png", "image/jpeg"},
343 {"png bytes in .jpg file uses sniffed", pngMagic, "image/jpeg", "image/png"},
344 {"gif bytes uses sniffed", gifMagic, "image/png", "image/gif"},
345 {"webp bytes uses sniffed", webpMagic, "image/png", "image/webp"},
346 {"matching extension and content keeps sniffed", pngMagic, "image/png", "image/png"},
347 {"unsniffable content falls back", random, "image/png", "image/png"},
348 {"empty content falls back", nil, "image/jpeg", "image/jpeg"},
349 }
350 for _, tc := range cases {
351 t.Run(tc.name, func(t *testing.T) {
352 t.Parallel()
353 require.Equal(t, tc.want, sniffImageMimeType(tc.data, tc.fallback))
354 })
355 }
356}