package tools import ( "context" "os" "path/filepath" "strings" "testing" ) func writeFileT(t *testing.T, root, rel, content string) { t.Helper() path := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func runTool(t *testing.T, reg *Registry, name, args string) Result { t.Helper() res := reg.Execute(context.Background(), name, []byte(args)) return res } func TestReadFileLineRange(t *testing.T) { root := t.TempDir() writeFileT(t, root, "a.txt", "l1\nl2\nl3\nl4\nl5") reg := WorkspaceTools(root) res := runTool(t, reg, "read_file", `{"path":"a.txt","offset":2,"limit":2}`) if res.IsError || res.Content != "2\tl2\n3\tl3" { t.Fatalf("want numbered lines 2-3, got %+v", res) } res = runTool(t, reg, "read_file", `{"path":"a.txt","offset":4}`) if res.IsError || res.Content != "4\tl4\n5\tl5" { t.Fatalf("want tail from line 4, got %+v", res) } res = runTool(t, reg, "read_file", `{"path":"a.txt","offset":99}`) if !res.IsError || !strings.Contains(res.Content, "beyond end") { t.Fatalf("want beyond-end error, got %+v", res) } // 无 offset/limit 保持旧行为:整文件原文。 res = runTool(t, reg, "read_file", `{"path":"a.txt"}`) if res.IsError || res.Content != "l1\nl2\nl3\nl4\nl5" { t.Fatalf("legacy whole-file read changed, got %+v", res) } } func TestGrepRegexIncludeAndCase(t *testing.T) { root := t.TempDir() writeFileT(t, root, "src/a.go", "func Alpha() {}\nfunc beta() {}\n") writeFileT(t, root, "src/b.go", "func Gamma() {}\n") writeFileT(t, root, "doc/a.md", "func Alpha docs\n") reg := WorkspaceTools(root) res := runTool(t, reg, "grep", `{"pattern":"func [A-Z][a-z]+","regex":true,"include":"*.go"}`) if res.IsError { t.Fatal(res.Content) } if !strings.Contains(res.Content, "src/a.go:1:func Alpha() {}") || !strings.Contains(res.Content, "src/b.go:1:func Gamma() {}") { t.Fatalf("regex+include should hit go files only, got:\n%s", res.Content) } if strings.Contains(res.Content, "a.md") || strings.Contains(res.Content, "beta") { t.Fatalf("md file / lowercase must not match, got:\n%s", res.Content) } res = runTool(t, reg, "grep", `{"pattern":"FUNC BETA","regex":true,"ignore_case":true}`) if res.IsError || !strings.Contains(res.Content, "func beta() {}") { t.Fatalf("ignore_case regex failed, got %+v", res) } res = runTool(t, reg, "grep", `{"pattern":"func [","regex":true}`) if !res.IsError { t.Fatal("invalid regex must be an error result") } // 字面默认路径不变。 res = runTool(t, reg, "grep", `{"pattern":"Alpha"}`) if res.IsError || !strings.Contains(res.Content, "a.go:1") { t.Fatalf("literal default failed, got %+v", res) } } func TestGlobDoubleStar(t *testing.T) { root := t.TempDir() writeFileT(t, root, "docs/x/deep/a.md", "x") writeFileT(t, root, "docs/b.md", "x") writeFileT(t, root, "src/main.go", "x") writeFileT(t, root, "main.go", "x") reg := WorkspaceTools(root) res := runTool(t, reg, "glob", `{"pattern":"**/*.go"}`) if res.IsError { t.Fatal(res.Content) } if !strings.Contains(res.Content, "src/main.go") || !strings.Contains(res.Content, "main.go") || strings.Contains(res.Content, ".md") { t.Fatalf("**/*.go wrong, got:\n%s", res.Content) } res = runTool(t, reg, "glob", `{"pattern":"docs/**/*.md"}`) if res.IsError || !strings.Contains(res.Content, "docs/x/deep/a.md") || !strings.Contains(res.Content, "docs/b.md") { t.Fatalf("docs/**/*.md wrong, got:\n%s", res.Content) } res = runTool(t, reg, "glob", `{"pattern":"docs/**"}`) if res.IsError || !strings.Contains(res.Content, "docs/b.md") || !strings.Contains(res.Content, "docs/x/deep/a.md") { t.Fatalf("trailing ** must match everything below, got:\n%s", res.Content) } res = runTool(t, reg, "glob", `{"pattern":"**/nope.txt"}`) if res.IsError || res.Content != "no matches" { t.Fatalf("want no matches, got %+v", res) } } func TestGlobEscapingOutsideWorkspace(t *testing.T) { reg := WorkspaceTools(t.TempDir()) // glob 只在工作区内匹配(WalkDir 从 root 出发),模式里的 ../ 无从逃逸; // 字面 .. 也只是找不到匹配。 res := runTool(t, reg, "glob", `{"pattern":"../**/*.go"}`) if res.IsError && res.Content == "" { t.Fatal("unexpected empty error") } } func TestMultiEditAtomic(t *testing.T) { root := t.TempDir() writeFileT(t, root, "a.md", "alpha\nbeta\ngamma\n") reg := WorkspaceTools(root) res := runTool(t, reg, "multi_edit", `{"path":"a.md","edits":[{"old":"alpha","new":"ALPHA"},{"old":"gamma","new":"GAMMA"}]}`) if res.IsError || res.Content != "applied 2 edits to a.md" { t.Fatalf("want both applied, got %+v", res) } data, _ := os.ReadFile(filepath.Join(root, "a.md")) if string(data) != "ALPHA\nbeta\nGAMMA\n" { t.Fatalf("file content wrong: %q", data) } // 第二处替换失败 → 文件保持原样(全有或全无)。 writeFileT(t, root, "b.md", "one\ntwo\n") res = runTool(t, reg, "multi_edit", `{"path":"b.md","edits":[{"old":"one","new":"ONE"},{"old":"three","new":"THREE"}]}`) if !res.IsError || !strings.Contains(res.Content, "found 0") || !strings.Contains(res.Content, "unchanged") { t.Fatalf("want atomic failure, got %+v", res) } data, _ = os.ReadFile(filepath.Join(root, "b.md")) if string(data) != "one\ntwo\n" { t.Fatalf("failed batch must not modify file, got %q", data) } res = runTool(t, reg, "multi_edit", `{"path":"b.md","edits":[]}`) if !res.IsError { t.Fatal("empty edits must be an error") } }