first commit
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user