Files
2026-07-13 13:00:08 +08:00

508 lines
14 KiB
Go

package builtin
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// --- write_file extended tests ---
func TestWriteFileCreatesParentDirs(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "a", "b", "c", "file.txt")
runTool(t, writeFile{}, map[string]any{"path": f, "content": "nested"})
got, _ := os.ReadFile(f)
if string(got) != "nested" {
t.Errorf("content = %q", got)
}
}
func TestWriteFileOverwrites(t *testing.T) {
f := filepath.Join(t.TempDir(), "x.txt")
os.WriteFile(f, []byte("old"), 0o644)
runTool(t, writeFile{}, map[string]any{"path": f, "content": "new"})
got, _ := os.ReadFile(f)
if string(got) != "new" {
t.Errorf("after overwrite = %q", got)
}
}
func TestWriteFileSameContentNoOp(t *testing.T) {
f := filepath.Join(t.TempDir(), "x.txt")
os.WriteFile(f, []byte("same"), 0o644)
out, err := writeFile{}.Execute(context.Background(), argsJSON(t, map[string]any{"path": f, "content": "same"}))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out, "already contains the exact content") {
t.Fatalf("same-content write should return a no-op signal, got %q", out)
}
got, _ := os.ReadFile(f)
if string(got) != "same" {
t.Errorf("content changed = %q", got)
}
}
func TestWriteFileEmptyContent(t *testing.T) {
f := filepath.Join(t.TempDir(), "empty.txt")
runTool(t, writeFile{}, map[string]any{"path": f, "content": ""})
got, _ := os.ReadFile(f)
if len(got) != 0 {
t.Errorf("expected empty file, got %d bytes", len(got))
}
}
func TestWriteFileMissingPath(t *testing.T) {
_, err := writeFile{}.Execute(context.Background(), argsJSON(t, map[string]any{"content": "x"}))
if err == nil {
t.Fatal("expected error for missing path")
}
}
func TestWriteFileMissingContent(t *testing.T) {
f := filepath.Join(t.TempDir(), "x.txt")
// Missing content field should write empty file (content defaults to "").
runTool(t, writeFile{}, map[string]any{"path": f})
got, _ := os.ReadFile(f)
if len(got) != 0 {
t.Errorf("missing content should write empty file, got %d bytes", len(got))
}
}
func TestWriteFileInvalidArgs(t *testing.T) {
_, err := writeFile{}.Execute(context.Background(), json.RawMessage(`{invalid`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
// --- move_file tests ---
func TestMoveFileMovesIntoParentDir(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a.md")
dst := filepath.Join(dir, "docs", "a.md")
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
out := runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
if !strings.Contains(out, "moved") {
t.Fatalf("move_file output = %q, want moved", out)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists or stat failed: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("destination content = %q, want hello", got)
}
}
func TestMoveFileRejectsDestinationExists(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a.md")
dst := filepath.Join(dir, "b.md")
os.WriteFile(src, []byte("a"), 0o644)
os.WriteFile(dst, []byte("b"), 0o644)
if _, err := (moveFile{}).Execute(context.Background(), argsJSON(t, map[string]any{"source_path": src, "destination_path": dst})); err == nil {
t.Fatal("expected error for existing destination")
}
}
func TestMoveFileRejectsEscape(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
src := filepath.Join(dir, "a.md")
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := (moveFile{roots: []string{dir}}).Execute(context.Background(), argsJSON(t, map[string]any{
"source_path": src,
"destination_path": filepath.Join(outside, "a.md"),
})); err == nil {
t.Fatal("expected error for destination outside workspace")
}
if _, err := os.Stat(src); err != nil {
t.Fatalf("source should remain after refused move: %v", err)
}
}
func TestMoveFileSamePathRequiresExistingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing.md")
_, err := (moveFile{}).Execute(context.Background(), argsJSON(t, map[string]any{
"source_path": missing,
"destination_path": missing,
}))
if err == nil {
t.Fatal("expected error for missing source even when source and destination match")
}
}
func TestMoveFileAllowsCaseOnlyRename(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "caseonly.txt")
dst := filepath.Join(dir, "CASEONLY.txt")
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
srcInfo, err := os.Stat(src)
if err != nil {
t.Fatal(err)
}
dstInfo, err := os.Stat(dst)
if os.IsNotExist(err) {
t.Skip("filesystem is case-sensitive")
}
if err != nil {
t.Fatal(err)
}
if !os.SameFile(srcInfo, dstInfo) {
t.Skip("source and destination do not resolve to the same file")
}
runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("destination content = %q, want hello", got)
}
}
func TestMoveFileFallsBackWhenSameFileDestinationRenameFails(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a.md")
dst := filepath.Join(dir, "same-file.md")
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Link(src, dst); err != nil {
t.Skipf("hard links unavailable: %v", err)
}
oldRename := renameFile
renameFile = func(oldpath, newpath string) error {
if oldpath == src && newpath == dst {
return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: os.ErrExist}
}
return oldRename(oldpath, newpath)
}
t.Cleanup(func() { renameFile = oldRename })
runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists or stat failed: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("destination content = %q, want hello", got)
}
}
func TestMoveFileFallsBackForCrossDeviceRename(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a.md")
dst := filepath.Join(dir, "docs", "a.md")
if err := os.WriteFile(src, []byte("hello"), 0o640); err != nil {
t.Fatal(err)
}
oldRename := renameFile
renameFile = func(oldpath, newpath string) error {
return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: errors.New("invalid cross-device link")}
}
t.Cleanup(func() { renameFile = oldRename })
out := runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
if !strings.Contains(out, "moved") {
t.Fatalf("move_file output = %q, want moved", out)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists or stat failed: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("destination content = %q, want hello", got)
}
}
// --- edit_file extended tests ---
func TestEditFileNotFound(t *testing.T) {
f := filepath.Join(t.TempDir(), "missing.txt")
_, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f, "old_string": "x", "new_string": "y",
}))
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestEditFileOldStringNotFound(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("hello world"), 0o644)
_, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f, "old_string": "nonexistent", "new_string": "x",
}))
if err == nil {
t.Fatal("expected error for old_string not found")
}
if !strings.Contains(err.Error(), "Re-read the current file") {
t.Fatalf("not-found error should include recovery hint, got: %v", err)
}
// File should be unchanged.
got, _ := os.ReadFile(f)
if string(got) != "hello world" {
t.Errorf("file modified despite error: %q", got)
}
}
func TestEditFileNotUniqueReportsMatchingLines(t *testing.T) {
f := filepath.Join(t.TempDir(), "map.html")
separator := " // ═══════════════════════════════════════"
body := strings.Join([]string{
separator,
"const a = 1;",
separator,
"const b = 2;",
separator,
"",
}, "\n")
os.WriteFile(f, []byte(body), 0o644)
_, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f, "old_string": separator, "new_string": "// section",
}))
if err == nil {
t.Fatal("expected not-unique error")
}
for _, want := range []string{"not unique", "matching lines include 1, 3, 5", "repeated separator lines"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %q: %v", want, err)
}
}
}
func TestEditFileDelete(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("remove this line\nkeep this\n"), 0o644)
runTool(t, editFile{}, map[string]any{
"path": f, "old_string": "remove this line\n", "new_string": "",
})
got, _ := os.ReadFile(f)
if string(got) != "keep this\n" {
t.Errorf("after delete = %q", got)
}
}
func TestEditFileMissingOldString(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("content"), 0o644)
_, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f, "new_string": "x",
}))
if err == nil {
t.Fatal("expected error for missing old_string")
}
}
func TestEditFileMissingPath(t *testing.T) {
_, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
"old_string": "x", "new_string": "y",
}))
if err == nil {
t.Fatal("expected error for missing path")
}
}
func TestEditFileInvalidArgs(t *testing.T) {
_, err := editFile{}.Execute(context.Background(), json.RawMessage(`{invalid`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
// --- multi_edit extended tests ---
func TestMultiEditEmptyEdits(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("content"), 0o644)
_, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f, "edits": []map[string]any{},
}))
if err == nil {
t.Fatal("expected error for empty edits")
}
}
func TestMultiEditMissingPath(t *testing.T) {
_, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
"edits": []map[string]any{{"old_string": "x", "new_string": "y"}},
}))
if err == nil {
t.Fatal("expected error for missing path")
}
}
func TestMultiEditStepNotFound(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("alpha\nbeta\n"), 0o644)
_, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f,
"edits": []map[string]any{
{"old_string": "alpha", "new_string": "ALPHA"},
{"old_string": "nonexistent", "new_string": "x"},
},
}))
if err == nil {
t.Fatal("expected error for missing edit step")
}
for _, want := range []string{"edit 2", "Re-read the current file"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("multi_edit error should mention %q, got: %v", want, err)
}
}
// File should be unchanged (atomicity).
got, _ := os.ReadFile(f)
if string(got) != "alpha\nbeta\n" {
t.Errorf("file modified despite error: %q", got)
}
}
func TestMultiEditReplaceAll(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("foo bar foo baz foo"), 0o644)
runTool(t, multiEdit{}, map[string]any{
"path": f,
"edits": []map[string]any{
{"old_string": "foo", "new_string": "qux", "replace_all": true},
},
})
got, _ := os.ReadFile(f)
if string(got) != "qux bar qux baz qux" {
t.Errorf("after replace_all = %q", got)
}
}
func TestMultiEditReplaceAllNotFound(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("hello"), 0o644)
_, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f,
"edits": []map[string]any{
{"old_string": "nonexistent", "new_string": "x", "replace_all": true},
},
}))
if err == nil {
t.Fatal("expected error for replace_all with no matches")
}
}
func TestMultiEditMissingOldString(t *testing.T) {
f := filepath.Join(t.TempDir(), "a.txt")
os.WriteFile(f, []byte("content"), 0o644)
_, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
"path": f,
"edits": []map[string]any{
{"new_string": "x"},
},
}))
if err == nil {
t.Fatal("expected error for missing old_string in edit step")
}
}
func TestMultiEditInvalidArgs(t *testing.T) {
_, err := multiEdit{}.Execute(context.Background(), json.RawMessage(`{invalid`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestMultiEditChained(t *testing.T) {
f := filepath.Join(t.TempDir(), "code.go")
os.WriteFile(f, []byte("package old\n\nfunc Old() {\n\tOld()\n}\n"), 0o644)
runTool(t, multiEdit{}, map[string]any{
"path": f,
"edits": []map[string]any{
{"old_string": "package old", "new_string": "package new"},
{"old_string": "Old", "new_string": "New", "replace_all": true},
},
})
got, _ := os.ReadFile(f)
want := "package new\n\nfunc New() {\n\tNew()\n}\n"
if string(got) != want {
t.Errorf("after chained edits = %q\nwant %q", got, want)
}
}
// --- confine tests ---
func TestConfineRejectsEscape(t *testing.T) {
dir := t.TempDir()
err := confine([]string{dir}, filepath.Join(dir, "..", "outside", "file.txt"))
if err == nil {
t.Fatal("expected error for path escaping workspace")
}
}
func TestConfineAllowsInside(t *testing.T) {
dir := t.TempDir()
// confine uses realPath which resolves symlinks, so we need to resolve too.
real, _ := filepath.EvalSymlinks(dir)
target := filepath.Join(real, "inside", "file.txt")
err := confine([]string{real}, target)
if err != nil {
t.Errorf("should allow path inside workspace: %v", err)
}
}
func TestConfineEmptyRootsAllowsAll(t *testing.T) {
err := confine(nil, "/any/path")
if err != nil {
t.Errorf("empty roots should allow all: %v", err)
}
}
// --- resolveIn tests ---
func TestResolveInAbsolute(t *testing.T) {
abs := filepath.Join(t.TempDir(), "absolute", "path")
got := resolveIn("/workdir", abs)
if got != abs {
t.Errorf("resolveIn absolute = %q, want %q", got, abs)
}
}
func TestResolveInRelative(t *testing.T) {
got := resolveIn("/workdir", "relative/path")
if got != filepath.Join("/workdir", "relative/path") {
t.Errorf("resolveIn relative = %q", got)
}
}
func TestResolveInEmptyWorkDir(t *testing.T) {
got := resolveIn("", "relative/path")
if got != "relative/path" {
t.Errorf("resolveIn empty workdir = %q", got)
}
}