glob_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	"github.com/stretchr/testify/assert"
 14	"github.com/stretchr/testify/require"
 15)
 16
 17// Test data structure for benchmark scenarios
 18type benchmarkScenario struct {
 19	name        string
 20	pattern     string
 21	description string
 22}
 23
 24// Common benchmark scenarios
 25var benchmarkScenarios = []benchmarkScenario{
 26	{"SimpleExtension", "*.go", "Find all Go files in current directory"},
 27	{"RecursiveExtension", "**/*.go", "Find all Go files recursively"},
 28	{"MultipleExtensions", "*.{go,js,ts}", "Find multiple file types"},
 29	{"RecursiveMultiple", "**/*.{go,js,ts,py}", "Find multiple file types recursively"},
 30	{"VerySpecific", "internal/llm/tools/*.go", "Very specific path pattern"},
 31}
 32
 33func TestGlobTool_Info(t *testing.T) {
 34	tool := NewGlobTool("/tmp")
 35	info := tool.Info()
 36
 37	assert.Equal(t, GlobToolName, info.Name)
 38	assert.Contains(t, info.Description, "Fast file pattern matching tool")
 39	assert.Contains(t, info.Required, "pattern")
 40	assert.Contains(t, info.Parameters, "pattern")
 41	assert.Contains(t, info.Parameters, "path")
 42}
 43
 44func TestGlobTool_BasicFunctionality(t *testing.T) {
 45	// Create a temporary directory structure for testing
 46	tempDir := t.TempDir()
 47	createTestFileStructure(t, tempDir)
 48
 49	tool := NewGlobTool(tempDir)
 50	ctx := context.Background()
 51
 52	tests := []struct {
 53		name             string
 54		pattern          string
 55		path             string
 56		expectFiles      bool
 57		expectError      bool
 58		expectedCount    int
 59		shouldContain    []string
 60		shouldNotContain []string
 61	}{
 62		{
 63			name:          "Find Go files",
 64			pattern:       "*.go",
 65			expectFiles:   true,
 66			expectedCount: 2,
 67			shouldContain: []string{"main.go", "test.go"},
 68		},
 69		{
 70			name:          "Find JS files recursively",
 71			pattern:       "**/*.js",
 72			expectFiles:   true,
 73			expectedCount: 2,
 74			shouldContain: []string{"app.js", "utils.js"},
 75		},
 76		{
 77			name:          "Multiple extensions",
 78			pattern:       "*.{go,txt}",
 79			expectFiles:   true,
 80			expectedCount: 3,
 81			shouldContain: []string{"main.go", "test.go", "readme.txt"},
 82		},
 83		{
 84			name:          "No matches",
 85			pattern:       "*.nonexistent",
 86			expectFiles:   false,
 87			expectedCount: 0,
 88		},
 89		{
 90			name:          "Specific directory",
 91			pattern:       "src/**/*.js",
 92			expectFiles:   true,
 93			expectedCount: 2,
 94			shouldContain: []string{"app.js", "utils.js"},
 95		},
 96		{
 97			name:        "Empty pattern",
 98			pattern:     "",
 99			expectError: true,
100		},
101	}
102
103	for _, tt := range tests {
104		t.Run(tt.name, func(t *testing.T) {
105			input := fmt.Sprintf(`{"pattern": "%s"`, tt.pattern)
106			if tt.path != "" {
107				input += fmt.Sprintf(`, "path": "%s"`, tt.path)
108			}
109			input += "}"
110
111			call := ToolCall{
112				ID:    "test",
113				Name:  GlobToolName,
114				Input: input,
115			}
116
117			response, err := tool.Run(ctx, call)
118
119			if tt.expectError {
120				assert.True(t, response.IsError)
121				return
122			}
123
124			require.NoError(t, err)
125			assert.False(t, response.IsError)
126
127			if !tt.expectFiles {
128				assert.Contains(t, response.Content, "No files found")
129				return
130			}
131
132			// Parse metadata
133			var metadata GlobResponseMetadata
134			if response.Metadata != "" {
135				err := json.Unmarshal([]byte(response.Metadata), &metadata)
136				require.NoError(t, err)
137				assert.Equal(t, tt.expectedCount, metadata.NumberOfFiles)
138			}
139
140			// Check file contents
141			files := strings.Split(response.Content, "\n")
142			actualCount := 0
143			for _, file := range files {
144				if strings.TrimSpace(file) != "" && !strings.Contains(file, "Results are truncated") {
145					actualCount++
146				}
147			}
148
149			assert.Equal(t, tt.expectedCount, actualCount)
150
151			// Check specific files are included
152			for _, expected := range tt.shouldContain {
153				assert.Contains(t, response.Content, expected, "Should contain %s", expected)
154			}
155
156			// Check specific files are not included
157			for _, notExpected := range tt.shouldNotContain {
158				assert.NotContains(t, response.Content, notExpected, "Should not contain %s", notExpected)
159			}
160		})
161	}
162}
163
164func TestGlobTool_Truncation(t *testing.T) {
165	// Create a directory with many files to test truncation
166	tempDir := t.TempDir()
167
168	// Create 150 files to exceed the 100 file limit
169	for i := 0; i < 150; i++ {
170		filename := filepath.Join(tempDir, fmt.Sprintf("file%03d.txt", i))
171		err := os.WriteFile(filename, []byte("test"), 0o644)
172		require.NoError(t, err)
173	}
174
175	tool := NewGlobTool(tempDir)
176	ctx := context.Background()
177
178	call := ToolCall{
179		ID:    "test",
180		Name:  GlobToolName,
181		Input: `{"pattern": "*.txt"}`,
182	}
183
184	response, err := tool.Run(ctx, call)
185	require.NoError(t, err)
186	assert.False(t, response.IsError)
187
188	// Should be truncated
189	assert.Contains(t, response.Content, "Results are truncated")
190
191	// Parse metadata
192	var metadata GlobResponseMetadata
193	err = json.Unmarshal([]byte(response.Metadata), &metadata)
194	require.NoError(t, err)
195	assert.Equal(t, 100, metadata.NumberOfFiles)
196	assert.True(t, metadata.Truncated)
197}
198
199func TestGlobTool_SortingByModTime(t *testing.T) {
200	tempDir := t.TempDir()
201
202	// Create files with different modification times
203	files := []string{"old.txt", "newer.txt", "newest.txt"}
204	basetime := time.Now().Add(-time.Hour)
205
206	for i, filename := range files {
207		path := filepath.Join(tempDir, filename)
208		err := os.WriteFile(path, []byte("test"), 0o644)
209		require.NoError(t, err)
210
211		// Set different modification times
212		modTime := basetime.Add(time.Duration(i) * time.Minute)
213		err = os.Chtimes(path, modTime, modTime)
214		require.NoError(t, err)
215	}
216
217	tool := NewGlobTool(tempDir)
218	ctx := context.Background()
219
220	call := ToolCall{
221		ID:    "test",
222		Name:  GlobToolName,
223		Input: `{"pattern": "*.txt"}`,
224	}
225
226	response, err := tool.Run(ctx, call)
227	require.NoError(t, err)
228	assert.False(t, response.IsError)
229
230	lines := strings.Split(strings.TrimSpace(response.Content), "\n")
231	require.Len(t, lines, 3)
232
233	// Should be sorted by modification time (newest first)
234	assert.Contains(t, lines[0], "newest.txt")
235	assert.Contains(t, lines[1], "newer.txt")
236	assert.Contains(t, lines[2], "old.txt")
237}
238
239func TestGlobTool_ErrorHandling(t *testing.T) {
240	tool := NewGlobTool("/tmp")
241	ctx := context.Background()
242
243	tests := []struct {
244		name  string
245		input string
246	}{
247		{"Invalid JSON", `{"pattern": "*.go"`},
248		{"Missing pattern", `{"path": "/tmp"}`},
249		{"Empty pattern", `{"pattern": ""}`},
250	}
251
252	for _, tt := range tests {
253		t.Run(tt.name, func(t *testing.T) {
254			call := ToolCall{
255				ID:    "test",
256				Name:  GlobToolName,
257				Input: tt.input,
258			}
259
260			response, err := tool.Run(ctx, call)
261			require.NoError(t, err)
262			assert.True(t, response.IsError)
263		})
264	}
265}
266
267// Benchmark tests for performance comparison
268func BenchmarkGlobTool(b *testing.B) {
269	// Use the current project directory for realistic benchmarks
270	workingDir, err := os.Getwd()
271	require.NoError(b, err)
272
273	// Go up to the project root
274	for !strings.HasSuffix(workingDir, "crush") {
275		parent := filepath.Dir(workingDir)
276		if parent == workingDir {
277			b.Fatal("Could not find project root")
278		}
279		workingDir = parent
280	}
281
282	tool := NewGlobTool(workingDir)
283	ctx := context.Background()
284
285	for _, scenario := range benchmarkScenarios {
286		b.Run(scenario.name, func(b *testing.B) {
287			input := fmt.Sprintf(`{"pattern": "%s"}`, scenario.pattern)
288			call := ToolCall{
289				ID:    "bench",
290				Name:  GlobToolName,
291				Input: input,
292			}
293
294			b.ResetTimer()
295			for i := 0; i < b.N; i++ {
296				response, err := tool.Run(ctx, call)
297				if err != nil {
298					b.Fatal(err)
299				}
300				if response.IsError {
301					b.Fatal("Unexpected error response:", response.Content)
302				}
303			}
304		})
305	}
306}
307
308// Memory benchmark
309func BenchmarkGlobTool_Memory(b *testing.B) {
310	b.Skip("Skipping memory benchmark due to potential runtime issues")
311
312	workingDir, err := os.Getwd()
313	require.NoError(b, err)
314
315	// Go up to the project root
316	for !strings.HasSuffix(workingDir, "crush") {
317		parent := filepath.Dir(workingDir)
318		if parent == workingDir {
319			b.Fatal("Could not find project root")
320		}
321		workingDir = parent
322	}
323
324	tool := NewGlobTool(workingDir)
325	ctx := context.Background()
326
327	// Test memory usage with moderate search to avoid runtime issues
328	input := `{"pattern": "**/*.go"}`
329	call := ToolCall{
330		ID:    "bench",
331		Name:  GlobToolName,
332		Input: input,
333	}
334
335	b.ResetTimer()
336	b.ReportAllocs()
337
338	for i := 0; i < b.N; i++ {
339		response, err := tool.Run(ctx, call)
340		if err != nil {
341			b.Fatal(err)
342		}
343		if response.IsError {
344			b.Fatal("Unexpected error response:", response.Content)
345		}
346	}
347}
348
349// Benchmark different pattern complexities
350func BenchmarkGlobPatterns(b *testing.B) {
351	workingDir, err := os.Getwd()
352	require.NoError(b, err)
353
354	// Go up to the project root
355	for !strings.HasSuffix(workingDir, "crush") {
356		parent := filepath.Dir(workingDir)
357		if parent == workingDir {
358			b.Fatal("Could not find project root")
359		}
360		workingDir = parent
361	}
362
363	patterns := map[string]string{
364		"Simple":          "*.go",
365		"SingleRecursive": "**/*.go",
366		"MultiExtension":  "*.{go,js,ts,py}",
367		"MultiRecursive":  "**/*.{go,js,ts,py}",
368	}
369
370	tool := NewGlobTool(workingDir)
371	ctx := context.Background()
372
373	for name, pattern := range patterns {
374		b.Run(name, func(b *testing.B) {
375			input := fmt.Sprintf(`{"pattern": "%s"}`, pattern)
376			call := ToolCall{
377				ID:    "bench",
378				Name:  GlobToolName,
379				Input: input,
380			}
381
382			b.ResetTimer()
383			for i := 0; i < b.N; i++ {
384				_, err := tool.Run(ctx, call)
385				if err != nil {
386					b.Fatal(err)
387				}
388			}
389		})
390	}
391}
392
393// Benchmark with different directory depths
394func BenchmarkGlobDepth(b *testing.B) {
395	// Create a temporary deep directory structure
396	tempDir := b.TempDir()
397	createDeepTestStructure(b, tempDir, 3, 5) // Reduced depth to avoid issues
398
399	tool := NewGlobTool(tempDir)
400	ctx := context.Background()
401
402	depths := map[string]string{
403		"Depth1":   "*.txt",
404		"Depth2":   "*/*.txt",
405		"DepthAll": "**/*.txt",
406	}
407
408	for name, pattern := range depths {
409		b.Run(name, func(b *testing.B) {
410			input := fmt.Sprintf(`{"pattern": "%s"}`, pattern)
411			call := ToolCall{
412				ID:    "bench",
413				Name:  GlobToolName,
414				Input: input,
415			}
416
417			b.ResetTimer()
418			for i := 0; i < b.N; i++ {
419				_, err := tool.Run(ctx, call)
420				if err != nil {
421					b.Fatal(err)
422				}
423			}
424		})
425	}
426}
427
428// Helper function to create test file structure
429func createTestFileStructure(t *testing.T, baseDir string) {
430	files := map[string]string{
431		"main.go":             "package main",
432		"test.go":             "package main",
433		"readme.txt":          "README",
434		"src/app.js":          "console.log('app')",
435		"src/utils.js":        "console.log('utils')",
436		"docs/guide.md":       "# Guide",
437		"config/app.json":     "{}",
438		".hidden/secret":      "secret",
439		"node_modules/lib.js": "// lib",
440	}
441
442	for path, content := range files {
443		fullPath := filepath.Join(baseDir, path)
444		dir := filepath.Dir(fullPath)
445
446		err := os.MkdirAll(dir, 0o755)
447		require.NoError(t, err)
448
449		err = os.WriteFile(fullPath, []byte(content), 0o644)
450		require.NoError(t, err)
451	}
452}
453
454// Helper function to create deep directory structure for benchmarking
455func createDeepTestStructure(b *testing.B, baseDir string, depth, filesPerLevel int) {
456	var createLevel func(string, int)
457	createLevel = func(currentDir string, currentDepth int) {
458		if currentDepth >= depth {
459			return
460		}
461
462		// Create files at this level
463		for i := 0; i < filesPerLevel; i++ {
464			filename := filepath.Join(currentDir, fmt.Sprintf("file%d_%d.txt", currentDepth, i))
465			err := os.WriteFile(filename, []byte("test content"), 0o644)
466			require.NoError(b, err)
467		}
468
469		// Create subdirectories
470		for i := 0; i < 2; i++ { // Reduced from 3 to 2 to avoid issues
471			subDir := filepath.Join(currentDir, fmt.Sprintf("subdir%d_%d", currentDepth, i))
472			err := os.MkdirAll(subDir, 0o755)
473			require.NoError(b, err)
474			createLevel(subDir, currentDepth+1)
475		}
476	}
477
478	createLevel(baseDir, 0)
479}
480
481// Test to verify the tool works with the actual project structure
482func TestGlobTool_RealProject(t *testing.T) {
483	workingDir, err := os.Getwd()
484	require.NoError(t, err)
485
486	// Go up to the project root
487	for !strings.HasSuffix(workingDir, "crush") {
488		parent := filepath.Dir(workingDir)
489		if parent == workingDir {
490			t.Skip("Could not find project root")
491		}
492		workingDir = parent
493	}
494
495	tool := NewGlobTool(workingDir)
496	ctx := context.Background()
497
498	tests := []struct {
499		name     string
500		pattern  string
501		minFiles int // Minimum expected files (project might grow)
502	}{
503		{"Go files", "**/*.go", 10},
504		{"Test files", "**/*_test.go", 5},
505		{"Tool files", "internal/llm/tools/*.go", 5},
506		{"Config files", "*.{json,yaml,yml}", 2},
507	}
508
509	for _, tt := range tests {
510		t.Run(tt.name, func(t *testing.T) {
511			input := fmt.Sprintf(`{"pattern": "%s"}`, tt.pattern)
512			call := ToolCall{
513				ID:    "test",
514				Name:  GlobToolName,
515				Input: input,
516			}
517
518			response, err := tool.Run(ctx, call)
519			require.NoError(t, err)
520			assert.False(t, response.IsError)
521
522			var metadata GlobResponseMetadata
523			err = json.Unmarshal([]byte(response.Metadata), &metadata)
524			require.NoError(t, err)
525
526			assert.GreaterOrEqual(t, metadata.NumberOfFiles, tt.minFiles,
527				"Expected at least %d files for pattern %s, got %d",
528				tt.minFiles, tt.pattern, metadata.NumberOfFiles)
529		})
530	}
531}