Vectrum
Sign In
251 lines · 6.9 KB · Go
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 codey\n")
	writeFile(t, dir, "cmd/codey/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", "# codey\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/codey/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/codey/main.go"},
		},
		{
			name:     "path narrows the search",
			args:     GlobArgs{Pattern: "*.go", Path: "cmd"},
			want:     []string{"cmd/codey/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/codey/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) }