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) {}
220
221func (m *mockViewPermissionService) Deny(req permission.PermissionRequest) {}
222
223func (m *mockViewPermissionService) GrantPersistent(req permission.PermissionRequest) {}
224
225func (m *mockViewPermissionService) AutoApproveSession(sessionID string) {}
226
227func (m *mockViewPermissionService) SetSkipRequests(skip bool) {}
228
229func (m *mockViewPermissionService) SkipRequests() bool {
230	return false
231}
232
233func (m *mockViewPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
234	return make(<-chan pubsub.Event[permission.PermissionNotification])
235}
236
237type mockFileTracker struct{}
238
239func (m mockFileTracker) RecordRead(ctx context.Context, sessionID, path string) {}
240
241func (m mockFileTracker) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
242	return time.Time{}
243}
244
245func (m mockFileTracker) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
246	return nil, nil
247}
248
249func newViewToolForTest(workingDir string) fantasy.AgentTool {
250	permissions := &mockViewPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
251	return NewViewTool(nil, permissions, mockFileTracker{}, nil, workingDir)
252}
253
254func runViewTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params ViewParams) fantasy.ToolResponse {
255	t.Helper()
256
257	input, err := json.Marshal(params)
258	require.NoError(t, err)
259
260	call := fantasy.ToolCall{
261		ID:    "test-call",
262		Name:  ViewToolName,
263		Input: string(input),
264	}
265
266	resp, err := tool.Run(ctx, call)
267	require.NoError(t, err)
268	return resp
269}
270
271var _ filetracker.Service = mockFileTracker{}
272
273func TestReadBuiltinFile(t *testing.T) {
274	t.Parallel()
275
276	t.Run("reads crush-config skill", func(t *testing.T) {
277		t.Parallel()
278
279		resp, err := readBuiltinFile(ViewParams{
280			FilePath: "crush://skills/crush-config/SKILL.md",
281		}, nil)
282		require.NoError(t, err)
283		require.NotEmpty(t, resp.Content)
284		require.Contains(t, resp.Content, "Crush Configuration")
285	})
286
287	t.Run("not found", func(t *testing.T) {
288		t.Parallel()
289
290		resp, err := readBuiltinFile(ViewParams{
291			FilePath: "crush://skills/nonexistent/SKILL.md",
292		}, nil)
293		require.NoError(t, err)
294		require.True(t, resp.IsError)
295	})
296
297	t.Run("metadata has skill info", func(t *testing.T) {
298		t.Parallel()
299
300		resp, err := readBuiltinFile(ViewParams{
301			FilePath: "crush://skills/crush-config/SKILL.md",
302		}, nil)
303		require.NoError(t, err)
304
305		var meta ViewResponseMetadata
306		require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
307		require.Equal(t, ViewResourceSkill, meta.ResourceType)
308		require.Equal(t, "crush-config", meta.ResourceName)
309		require.NotEmpty(t, meta.ResourceDescription)
310	})
311
312	t.Run("respects offset", func(t *testing.T) {
313		t.Parallel()
314
315		resp, err := readBuiltinFile(ViewParams{
316			FilePath: "crush://skills/crush-config/SKILL.md",
317			Offset:   5,
318		}, nil)
319		require.NoError(t, err)
320		require.NotContains(t, resp.Content, "     1|")
321	})
322}
323
324func TestSniffImageMimeType(t *testing.T) {
325	t.Parallel()
326
327	jpegMagic := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
328	pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
329	gifMagic := []byte("GIF89a")
330	// Minimal RIFF/WEBP header.
331	webpMagic := append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 16)...)
332	random := []byte("not an image at all, just text")
333
334	cases := []struct {
335		name     string
336		data     []byte
337		fallback string
338		want     string
339	}{
340		{"jpeg bytes in .png file uses sniffed", jpegMagic, "image/png", "image/jpeg"},
341		{"png bytes in .jpg file uses sniffed", pngMagic, "image/jpeg", "image/png"},
342		{"gif bytes uses sniffed", gifMagic, "image/png", "image/gif"},
343		{"webp bytes uses sniffed", webpMagic, "image/png", "image/webp"},
344		{"matching extension and content keeps sniffed", pngMagic, "image/png", "image/png"},
345		{"unsniffable content falls back", random, "image/png", "image/png"},
346		{"empty content falls back", nil, "image/jpeg", "image/jpeg"},
347	}
348	for _, tc := range cases {
349		t.Run(tc.name, func(t *testing.T) {
350			t.Parallel()
351			require.Equal(t, tc.want, sniffImageMimeType(tc.data, tc.fallback))
352		})
353	}
354}