1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"os"
  7	"path/filepath"
  8	"regexp"
  9	"testing"
 10
 11	"github.com/stretchr/testify/require"
 12)
 13
 14func TestRegexCache(t *testing.T) {
 15	cache := newRegexCache()
 16
 17	// Test basic caching
 18	pattern := "test.*pattern"
 19	regex1, err := cache.get(pattern)
 20	if err != nil {
 21		t.Fatalf("Failed to compile regex: %v", err)
 22	}
 23
 24	regex2, err := cache.get(pattern)
 25	if err != nil {
 26		t.Fatalf("Failed to get cached regex: %v", err)
 27	}
 28
 29	// Should be the same instance (cached)
 30	if regex1 != regex2 {
 31		t.Error("Expected cached regex to be the same instance")
 32	}
 33
 34	// Test that it actually works
 35	if !regex1.MatchString("test123pattern") {
 36		t.Error("Regex should match test string")
 37	}
 38}
 39
 40func TestGlobToRegexCaching(t *testing.T) {
 41	// Test that globToRegex uses pre-compiled regex
 42	pattern1 := globToRegex("*.{js,ts}")
 43
 44	// Should not panic and should work correctly
 45	regex1, err := regexp.Compile(pattern1)
 46	if err != nil {
 47		t.Fatalf("Failed to compile glob regex: %v", err)
 48	}
 49
 50	if !regex1.MatchString("test.js") {
 51		t.Error("Glob regex should match .js files")
 52	}
 53	if !regex1.MatchString("test.ts") {
 54		t.Error("Glob regex should match .ts files")
 55	}
 56	if regex1.MatchString("test.go") {
 57		t.Error("Glob regex should not match .go files")
 58	}
 59}
 60
 61func TestGrepWithIgnoreFiles(t *testing.T) {
 62	tempDir := t.TempDir()
 63
 64	// Create test files
 65	testFiles := map[string]string{
 66		"file1.txt":           "hello world",
 67		"file2.txt":           "hello world",
 68		"ignored/file3.txt":   "hello world",
 69		"node_modules/lib.js": "hello world",
 70		"secret.key":          "hello world",
 71	}
 72
 73	for path, content := range testFiles {
 74		fullPath := filepath.Join(tempDir, path)
 75		require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
 76		require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
 77	}
 78
 79	// Create .gitignore file
 80	gitignoreContent := "ignored/\n*.key\n"
 81	require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
 82
 83	// Create .crushignore file
 84	crushignoreContent := "node_modules/\n"
 85	require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
 86
 87	// Create grep tool
 88	grepTool := NewGrepTool(tempDir)
 89
 90	// Create grep parameters
 91	params := GrepParams{
 92		Pattern: "hello world",
 93		Path:    tempDir,
 94	}
 95	paramsJSON, err := json.Marshal(params)
 96	require.NoError(t, err)
 97
 98	// Run grep
 99	call := ToolCall{Input: string(paramsJSON)}
100	response, err := grepTool.Run(context.Background(), call)
101	require.NoError(t, err)
102
103	// Check results - should only find file1.txt and file2.txt
104	// ignored/file3.txt should be ignored by .gitignore
105	// node_modules/lib.js should be ignored by .crushignore
106	// secret.key should be ignored by .gitignore
107	result := response.Content
108	require.Contains(t, result, "file1.txt")
109	require.Contains(t, result, "file2.txt")
110	require.NotContains(t, result, "file3.txt")
111	require.NotContains(t, result, "lib.js")
112	require.NotContains(t, result, "secret.key")
113}
114
115func TestSearchImplementations(t *testing.T) {
116	t.Parallel()
117	tempDir := t.TempDir()
118
119	for path, content := range map[string]string{
120		"file1.go":         "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
121		"file2.js":         "console.log('hello world');",
122		"file3.txt":        "hello world from text file",
123		"binary.exe":       "\x00\x01\x02\x03",
124		"empty.txt":        "",
125		"subdir/nested.go": "package nested\n// hello world comment",
126		".hidden.txt":      "hello world in hidden file",
127		"file4.txt":        "hello world from a banana",
128		"file5.txt":        "hello world from a grape",
129	} {
130		fullPath := filepath.Join(tempDir, path)
131		require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
132		require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
133	}
134
135	require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
136	require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
137
138	for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
139		"regex": searchFilesWithRegex,
140		"rg": func(pattern, path, include string) ([]grepMatch, error) {
141			return searchWithRipgrep(t.Context(), pattern, path, include)
142		},
143	} {
144		t.Run(name, func(t *testing.T) {
145			t.Parallel()
146
147			if name == "rg" && getRg() == "" {
148				t.Skip("rg is not in $PATH")
149			}
150
151			matches, err := fn("hello world", tempDir, "")
152			require.NoError(t, err)
153
154			require.Equal(t, len(matches), 4)
155			for _, match := range matches {
156				require.NotEmpty(t, match.path)
157				require.NotZero(t, match.lineNum)
158				require.NotEmpty(t, match.lineText)
159				require.NotZero(t, match.modTime)
160				require.NotContains(t, match.path, ".hidden.txt")
161				require.NotContains(t, match.path, "file4.txt")
162				require.NotContains(t, match.path, "file5.txt")
163				require.NotContains(t, match.path, "binary.exe")
164			}
165		})
166	}
167}
168
169// Benchmark to show performance improvement
170func BenchmarkRegexCacheVsCompile(b *testing.B) {
171	cache := newRegexCache()
172	pattern := "test.*pattern.*[0-9]+"
173
174	b.Run("WithCache", func(b *testing.B) {
175		for b.Loop() {
176			_, err := cache.get(pattern)
177			if err != nil {
178				b.Fatal(err)
179			}
180		}
181	})
182
183	b.Run("WithoutCache", func(b *testing.B) {
184		for b.Loop() {
185			_, err := regexp.Compile(pattern)
186			if err != nil {
187				b.Fatal(err)
188			}
189		}
190	})
191}