view_test.go

  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}