package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// searchRepo builds a small tree with the shapes that matter: nesting, a
// pruned directory, and a binary file.
func searchRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeFile(t, dir, "go.mod", "module harness\n")
writeFile(t, dir, "cmd/harness/main.go", "package main\n\nfunc main() { run() }\n")
writeFile(t, dir, "internal/agent/loop.go", "package agent\n\nfunc Run() {}\n")
writeFile(t, dir, "internal/agent/loop_test.go", "package agent\n\nfunc TestRun(t *testing.T) {}\n")
writeFile(t, dir, "README.md", "# harness\n\nRun it.\n")
writeFile(t, dir, "node_modules/junk/index.js", "function run() {}\n")
writeFile(t, dir, ".git/config", "[core]\n\trun = true\n")
if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0x00, 0x01, 'r', 'u', 'n'}, 0o644); err != nil {
t.Fatal(err)
}
return dir
}
func call(t *testing.T, tool Tool, args any) string {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
out, err := tool.Execute(context.Background(), raw)
if err != nil {
t.Fatalf("%s: %v", tool.Name(), err)
}
return out
}
func TestGlob(t *testing.T) {
dir := searchRepo(t)
tool := NewGlob(dir)
tests := []struct {
name string
args GlobArgs
want []string
unwanted []string
}{
{
name: "bare pattern matches at any depth",
args: GlobArgs{Pattern: "*.go"},
want: []string{"cmd/harness/main.go", "internal/agent/loop.go", "internal/agent/loop_test.go"},
unwanted: []string{"go.mod", "README.md"},
},
{
name: "pattern with a slash is anchored to the subtree",
args: GlobArgs{Pattern: "internal/**/*_test.go"},
want: []string{"internal/agent/loop_test.go"},
unwanted: []string{"cmd/harness/main.go"},
},
{
name: "path narrows the search",
args: GlobArgs{Pattern: "*.go", Path: "cmd"},
want: []string{"cmd/harness/main.go"},
unwanted: []string{"internal/agent/loop.go"},
},
{
name: "noise directories are pruned",
args: GlobArgs{Pattern: "*.js"},
unwanted: []string{"node_modules"},
},
{
name: "exact name",
args: GlobArgs{Pattern: "go.mod"},
want: []string{"go.mod"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out := call(t, tool, tc.args)
for _, w := range tc.want {
if !strings.Contains(out, w) {
t.Errorf("missing %q in:\n%s", w, out)
}
}
for _, u := range tc.unwanted {
if strings.Contains(out, u) {
t.Errorf("unexpected %q in:\n%s", u, out)
}
}
})
}
}
func TestGlobNoMatchesSaysSoUsefully(t *testing.T) {
out := call(t, NewGlob(searchRepo(t)), GlobArgs{Pattern: "*.rs"})
if !strings.Contains(out, "No files match") || !strings.Contains(out, "files scanned") {
t.Errorf("a miss should report the scan, got: %s", out)
}
}
func TestGlobSortsNewestFirst(t *testing.T) {
dir := searchRepo(t)
newest := filepath.Join(dir, "internal/agent/loop.go")
if err := os.Chtimes(newest, timeNow(), timeNow()); err != nil {
t.Fatal(err)
}
out := call(t, NewGlob(dir), GlobArgs{Pattern: "*.go"})
if first := strings.SplitN(out, "\n", 2)[0]; first != "internal/agent/loop.go" {
t.Errorf("first result = %q, want the most recently modified file", first)
}
}
func TestGlobErrors(t *testing.T) {
tool := NewGlob(searchRepo(t))
for _, tc := range []struct {
name, want string
args GlobArgs
}{
{name: "no pattern", want: "pattern is required", args: GlobArgs{}},
{name: "bad path", want: "no such directory", args: GlobArgs{Pattern: "*.go", Path: "nowhere"}},
} {
t.Run(tc.name, func(t *testing.T) {
raw, _ := json.Marshal(tc.args)
if _, err := tool.Execute(context.Background(), raw); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %v, want %q", err, tc.want)
}
})
}
}
func TestGrep(t *testing.T) {
dir := searchRepo(t)
tests := []struct {
name string
args GrepArgs
want []string
unwanted []string
}{
{
name: "finds a symbol with path and line number",
args: GrepArgs{Pattern: `func Run\(\)`},
want: []string{"internal/agent/loop.go:3: func Run() {}"},
},
{
name: "glob narrows to a file type",
args: GrepArgs{Pattern: "Run", Glob: "*.md"},
want: []string{"README.md"},
unwanted: []string{".go:"},
},
{
name: "case sensitive by default",
args: GrepArgs{Pattern: "RUN"},
unwanted: []string{".go:", ".md:"},
},
{
name: "ignore_case",
args: GrepArgs{Pattern: "RUN", IgnoreCase: true},
want: []string{"README.md"},
},
{
name: "noise directories and binaries are skipped",
args: GrepArgs{Pattern: "run"},
unwanted: []string{"node_modules", ".git/config", "bin.dat"},
},
{
name: "path narrows the search",
args: GrepArgs{Pattern: "package", Path: "cmd"},
want: []string{"cmd/harness/main.go"},
},
}
// Both engines must produce identical output, or eval numbers would depend
// on whether the machine has ripgrep installed.
for _, engine := range []string{"ripgrep", "walk"} {
t.Run(engine, func(t *testing.T) {
if engine == "walk" {
saved := ripgrep
ripgrep = ""
t.Cleanup(func() { ripgrep = saved })
} else if ripgrep == "" {
t.Skip("ripgrep not installed")
}
tool := NewGrep(dir)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out := call(t, tool, tc.args)
for _, w := range tc.want {
if !strings.Contains(out, w) {
t.Errorf("missing %q in:\n%s", w, out)
}
}
for _, u := range tc.unwanted {
if strings.Contains(out, u) {
t.Errorf("unexpected %q in:\n%s", u, out)
}
}
})
}
})
}
}
func TestGrepEnginesAgree(t *testing.T) {
if ripgrep == "" {
t.Skip("ripgrep not installed")
}
dir := searchRepo(t)
withRG := call(t, NewGrep(dir), GrepArgs{Pattern: "func"})
saved := ripgrep
ripgrep = ""
defer func() { ripgrep = saved }()
withWalk := call(t, NewGrep(dir), GrepArgs{Pattern: "func"})
if withRG != withWalk {
t.Errorf("engines disagree:\nripgrep:\n%s\nwalk:\n%s", withRG, withWalk)
}
}
func TestGrepNoMatchIsNotAnError(t *testing.T) {
for _, engine := range []string{"ripgrep", "walk"} {
t.Run(engine, func(t *testing.T) {
if engine == "walk" {
saved := ripgrep
ripgrep = ""
t.Cleanup(func() { ripgrep = saved })
} else if ripgrep == "" {
t.Skip("ripgrep not installed")
}
out := call(t, NewGrep(searchRepo(t)), GrepArgs{Pattern: "zzzznotpresent"})
if !strings.Contains(out, "No matches") {
t.Errorf("want an explicit no-match message, got %q", out)
}
})
}
}
func TestGrepInvalidRegexIsReported(t *testing.T) {
raw, _ := json.Marshal(GrepArgs{Pattern: "func ("})
_, err := NewGrep(searchRepo(t)).Execute(context.Background(), raw)
if err == nil || !strings.Contains(err.Error(), "invalid regular expression") {
t.Errorf("error = %v, want a regex error the model can fix", err)
}
}
func timeNow() time.Time { return time.Now().Add(time.Hour) }