package agenttool import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/smallnest/pigo/internal/agentcore" ) func runSearch(t *testing.T, tool agentcore.AgentTool, args map[string]any) agentcore.AgentToolResult { t.Helper() raw, err := json.Marshal(args) if err != nil { t.Fatalf("marshal args: %v", err) } res, gerr := tool.Execute(context.Background(), "call-1", raw, nil) if gerr != nil { t.Fatalf("execute returned go error: %v", gerr) } return res } // seedTree writes a small directory tree with a .gitignore for the search tests. func seedTree(t *testing.T) string { t.Helper() dir := t.TempDir() mustWrite := func(rel, content string) { p := filepath.Join(dir, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatalf("mkdir %s: %v", rel, err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", rel, err) } } mustWrite("main.go", "package main\nfunc main() { hello() }\n") mustWrite("util.go", "package main\nfunc hello() {}\n") mustWrite("README.md", "# project\nhello world\n") mustWrite("sub/deep.go", "package sub\n// hello from sub\n") mustWrite("build/generated.go", "package build\nfunc hello() {}\n") mustWrite(".gitignore", "build/\n*.log\n") mustWrite("debug.log", "hello log line\n") return dir } func TestGrepBasic(t *testing.T) { dir := seedTree(t) tool := &GrepTool{Root: dir} res := runSearch(t, tool, map[string]any{"pattern": "hello"}) txt := resultText(res) // Matches in tracked files. if !strings.Contains(txt, "main.go") || !strings.Contains(txt, "util.go") { t.Errorf("expected go file matches, got %q", txt) } // .gitignore'd paths must be skipped. if strings.Contains(txt, "build/generated.go") { t.Errorf("ignored dir should be skipped: %q", txt) } if strings.Contains(txt, "debug.log") { t.Errorf("ignored *.log should be skipped: %q", txt) } } func TestGrepGlobFilter(t *testing.T) { dir := seedTree(t) tool := &GrepTool{Root: dir} res := runSearch(t, tool, map[string]any{"pattern": "hello", "glob": "*.md"}) txt := resultText(res) if !strings.Contains(txt, "README.md") { t.Errorf("expected README match, got %q", txt) } if strings.Contains(txt, ".go") { t.Errorf("glob *.md should exclude .go files: %q", txt) } } func TestGrepInvalidPattern(t *testing.T) { dir := seedTree(t) tool := &GrepTool{Root: dir} res := runSearch(t, tool, map[string]any{"pattern": "["}) if !strings.Contains(resultText(res), "invalid pattern") { t.Errorf("expected invalid-pattern error, got %q", resultText(res)) } } func TestFindGlob(t *testing.T) { dir := seedTree(t) tool := &FindTool{Root: dir} res := runSearch(t, tool, map[string]any{"glob": "*.go"}) txt := resultText(res) if !strings.Contains(txt, "main.go") || !strings.Contains(txt, "sub/deep.go") { t.Errorf("expected go files, got %q", txt) } if strings.Contains(txt, "build/generated.go") { t.Errorf("ignored dir should be skipped: %q", txt) } if strings.Contains(txt, "README.md") { t.Errorf("*.go should not match README.md: %q", txt) } } func TestLsDistinguishesFilesAndDirs(t *testing.T) { dir := seedTree(t) tool := &LsTool{Root: dir} res := runSearch(t, tool, map[string]any{}) txt := resultText(res) // Directories carry a trailing slash. if !strings.Contains(txt, "sub/") { t.Errorf("expected sub/ dir marker, got %q", txt) } if !strings.Contains(txt, "main.go") { t.Errorf("expected main.go file, got %q", txt) } details, ok := res.Details.(map[string]any) if !ok { t.Fatalf("details missing: %+v", res.Details) } if details["files"] == nil || details["dirs"] == nil { t.Errorf("expected file/dir counts, got %+v", details) } } func TestLsNotADirectory(t *testing.T) { dir := seedTree(t) tool := &LsTool{Root: dir} res := runSearch(t, tool, map[string]any{"path": "main.go"}) if !strings.Contains(resultText(res), "not a directory") { t.Errorf("expected not-a-directory error, got %q", resultText(res)) } } func TestLsMissing(t *testing.T) { dir := seedTree(t) tool := &LsTool{Root: dir} res := runSearch(t, tool, map[string]any{"path": "nope"}) if !strings.Contains(resultText(res), "does not exist") { t.Errorf("expected does-not-exist error, got %q", resultText(res)) } } func TestSearchPathTraversal(t *testing.T) { dir := seedTree(t) for _, tc := range []struct { name string tool agentcore.AgentTool args map[string]any }{ {"grep", &GrepTool{Root: dir}, map[string]any{"pattern": "x", "path": "../"}}, {"find", &FindTool{Root: dir}, map[string]any{"glob": "*", "path": "../"}}, {"ls", &LsTool{Root: dir}, map[string]any{"path": "../"}}, } { t.Run(tc.name, func(t *testing.T) { res := runSearch(t, tc.tool, tc.args) if !strings.Contains(resultText(res), "outside the workspace root") { t.Errorf("expected boundary error, got %q", resultText(res)) } }) } } func TestSearchToolModes(t *testing.T) { for _, tool := range []agentcore.AgentTool{&GrepTool{}, &FindTool{}, &LsTool{}} { if tool.ExecutionMode() != agentcore.ToolExecutionParallel { t.Errorf("%s should be parallel", tool.Name()) } var schema map[string]any if err := json.Unmarshal(tool.Schema(), &schema); err != nil { t.Errorf("%s schema not valid JSON: %v", tool.Name(), err) } } } func TestGitignoreNegation(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.txt\n!keep.txt\n"), 0o644); err != nil { t.Fatal(err) } gi := loadGitignore(dir) if !gi.ignored("drop.txt", false) { t.Error("*.txt should be ignored") } if gi.ignored("keep.txt", false) { t.Error("!keep.txt should be re-included") } } // TestGitignoreMatchModes locks in the three matching modes after the load-time // precompilation (matchFull / hasSegmentRule): a non-anchored name rule matches // any path segment (so an ignored dir hides everything beneath it); an anchored // rule matches only at the root; and a slash-bearing pattern matches the full // relative path. func TestGitignoreMatchModes(t *testing.T) { dir := t.TempDir() // node_modules: non-anchored → matches any segment (nested too). // /root.log: anchored → only at repo root. // a/b.tmp: contains "/" → full-path match. rules := "node_modules\n/root.log\na/b.tmp\n" if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(rules), 0o644); err != nil { t.Fatal(err) } gi := loadGitignore(dir) cases := []struct { path string dir bool want bool }{ {"node_modules", true, true}, // segment rule, top level {"pkg/node_modules", true, true}, // segment rule, nested {"pkg/node_modules/x/y.js", false, true}, // hidden beneath ignored dir {"root.log", false, true}, // anchored, at root {"sub/root.log", false, false}, // anchored must not match nested {"a/b.tmp", false, true}, // full-path match {"z/a/b.tmp", false, false}, // full-path rule not anchored elsewhere {"keep.go", false, false}, // unrelated } for _, c := range cases { if got := gi.ignored(c.path, c.dir); got != c.want { t.Errorf("ignored(%q, dir=%v) = %v, want %v", c.path, c.dir, got, c.want) } } }