package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// memTracker is a FileTracker backed by a map.
type memTracker struct {
mu sync.Mutex
recs map[string]ReadRecord
}
func newTracker() *memTracker { return &memTracker{recs: map[string]ReadRecord{}} }
func (m *memTracker) MarkRead(path string, mod time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
m.recs[path] = ReadRecord{At: time.Now(), Mod: mod}
}
func (m *memTracker) LastRead(path string) (ReadRecord, bool) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.recs[path]
return r, ok
}
// editFixture writes a file and marks it read, which is the normal precondition.
func editFixture(t *testing.T, content string) (root string, track *memTracker, tool Tool) {
t.Helper()
root = t.TempDir()
path := writeFile(t, root, "sample.go", content)
track = newTracker()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
track.MarkRead(path, info.ModTime())
return root, track, NewEdit(root, track)
}
func edit(t *testing.T, tool Tool, args EditArgs) (string, error) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
return tool.Execute(context.Background(), raw)
}
const sample = `package main
import "fmt"
func greet(name string) {
fmt.Println("hello", name)
}
func main() {
greet("world")
}
`
func TestEditExactlyOneMatchAppliesAndReturnsADiff(t *testing.T) {
root, _, tool := editFixture(t, sample)
out, err := edit(t, tool, EditArgs{
Path: "sample.go",
OldStr: ` fmt.Println("hello", name)`,
NewStr: ` fmt.Printf("hello %s\n", name)`,
})
if err != nil {
t.Fatalf("edit: %v", err)
}
got, err := os.ReadFile(filepath.Join(root, "sample.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), `fmt.Printf("hello %s\n", name)`) {
t.Errorf("file was not changed:\n%s", got)
}
if strings.Contains(string(got), `fmt.Println("hello", name)`) {
t.Errorf("old text still present:\n%s", got)
}
// The diff is what the model uses to confirm it did what it meant.
for _, want := range []string{"--- sample.go", "@@ -", `- fmt.Println("hello", name)`, `+ fmt.Printf`, " func greet(name string) {"} {
if !strings.Contains(out, want) {
t.Errorf("diff missing %q:\n%s", want, out)
}
}
}
func TestEditZeroMatchesCarriesRecoveryContext(t *testing.T) {
tests := []struct {
name string
content string
oldStr string
want []string
}{
{
name: "indentation differs: say so and show the real line",
content: sample,
oldStr: ` fmt.Println("hello", name)`, // spaces, but the file uses a tab
want: []string{"different whitespace at lines 6-6", "Copy that text exactly", ` fmt.Println("hello", name)`},
},
{
name: "internal whitespace differs beyond recognition",
content: sample,
oldStr: `fmt.Println( "hello", name )`,
want: []string{"nothing in the file resembles it"},
},
{
name: "near miss shows the neighbourhood with line numbers",
content: sample,
oldStr: ` fmt.Println("goodbye", name)`,
want: []string{"closest thing in the file is around line", " 6\t", "read_file a wider range"},
},
{
name: "nothing similar at all",
content: sample,
oldStr: "zzz qqq vvv nothing like this",
want: []string{"nothing in the file resembles it"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root, _, tool := editFixture(t, tc.content)
_, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: tc.oldStr, NewStr: "replacement"})
if err == nil {
t.Fatal("want an error")
}
for _, w := range tc.want {
if !strings.Contains(err.Error(), w) {
t.Errorf("error missing %q:\n%s", w, err)
}
}
after, _ := os.ReadFile(filepath.Join(root, "sample.go"))
if string(after) != tc.content {
t.Error("a failed edit must leave the file untouched")
}
})
}
}
func TestEditMultipleMatchesRefusesAndListsEveryLine(t *testing.T) {
const dup = "a := 1\nb := 2\na := 1\nc := 3\na := 1\n"
root, _, tool := editFixture(t, dup)
_, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "a := 1", NewStr: "a := 9"})
if err == nil {
t.Fatal("want a refusal: silently taking the first match corrupts files")
}
for _, want := range []string{"appears 3 times", "lines: 1, 3, 5", "replace_all"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error missing %q:\n%s", want, err)
}
}
after, _ := os.ReadFile(filepath.Join(root, "sample.go"))
if string(after) != dup {
t.Error("an ambiguous edit must change nothing")
}
}
func TestEditReplaceAll(t *testing.T) {
root, _, tool := editFixture(t, "a := 1\nb := 2\na := 1\n")
out, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "a := 1", NewStr: "a := 9", ReplaceAll: true})
if err != nil {
t.Fatalf("edit: %v", err)
}
got, _ := os.ReadFile(filepath.Join(root, "sample.go"))
if string(got) != "a := 9\nb := 2\na := 9\n" {
t.Errorf("got:\n%q", got)
}
if !strings.Contains(out, "Replaced 2 occurrences") {
t.Errorf("summary should say how many changed:\n%s", out)
}
}
func TestEditRequiresAReadFirst(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "unread.go", sample)
track := newTracker()
_, err := edit(t, NewEdit(root, track), EditArgs{Path: "unread.go", OldStr: "package main", NewStr: "package x"})
if err == nil || !strings.Contains(err.Error(), "has not been read this session") {
t.Fatalf("error = %v, want a read-before-edit refusal", err)
}
if got, _ := os.ReadFile(filepath.Join(root, "unread.go")); string(got) != sample {
t.Error("file must be untouched")
}
}
func TestEditForcesReReadWhenTheFileChangedUnderneath(t *testing.T) {
root, track, tool := editFixture(t, sample)
path := filepath.Join(root, "sample.go")
// Something else wrote the file after the model read it.
track.MarkRead(path, time.Now().Add(-time.Hour))
os.WriteFile(path, []byte(sample+"\n// appended\n"), 0o644)
_, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "package main", NewStr: "package x"})
if err == nil || !strings.Contains(err.Error(), "changed on disk since you read it") {
t.Fatalf("error = %v, want a stale-read refusal", err)
}
}
func TestEditPreservesCRLF(t *testing.T) {
root, track, _ := editFixture(t, "")
path := writeFile(t, root, "crlf.go", "package main\r\n\r\nfunc main() {}\r\n")
info, _ := os.Stat(path)
track.MarkRead(path, info.ModTime())
out, err := edit(t, NewEdit(root, track), EditArgs{
Path: "crlf.go",
OldStr: "func main() {}",
NewStr: "func main() {\n\tprintln(\"hi\")\n}",
})
if err != nil {
t.Fatalf("edit: %v", err)
}
got, _ := os.ReadFile(path)
if strings.Contains(out, "\r") {
t.Error("the diff shown to the model should not carry carriage returns")
}
if want := "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n}\r\n"; string(got) != want {
t.Errorf("CRLF not preserved:\ngot %q\nwant %q", got, want)
}
if strings.Contains(strings.ReplaceAll(string(got), "\r\n", ""), "\n") {
t.Error("mixed line endings were introduced")
}
}
func TestEditPreservesFileMode(t *testing.T) {
root, track, _ := editFixture(t, "")
path := writeFile(t, root, "run.sh", "#!/bin/sh\necho hi\n")
os.Chmod(path, 0o755)
info, _ := os.Stat(path)
track.MarkRead(path, info.ModTime())
if _, err := edit(t, NewEdit(root, track), EditArgs{Path: "run.sh", OldStr: "echo hi", NewStr: "echo bye"}); err != nil {
t.Fatalf("edit: %v", err)
}
after, _ := os.Stat(path)
if after.Mode().Perm() != 0o755 {
t.Errorf("mode = %v, want 0755 preserved", after.Mode().Perm())
}
}
func TestEditRefreshesTheReadRecord(t *testing.T) {
root, track, tool := editFixture(t, sample)
path := filepath.Join(root, "sample.go")
if _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "package main", NewStr: "package other"}); err != nil {
t.Fatalf("first edit: %v", err)
}
// A second edit must not be blocked by the mtime change the first one caused.
if _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: `import "fmt"`, NewStr: `import "os"`}); err != nil {
t.Fatalf("second edit should be allowed: %v", err)
}
rec, ok := track.LastRead(path)
if !ok {
t.Fatal("read record disappeared")
}
info, _ := os.Stat(path)
if !rec.Mod.Equal(info.ModTime()) {
t.Error("read record should track the file as the edit left it")
}
}
func TestEditArgumentValidation(t *testing.T) {
_, _, tool := editFixture(t, sample)
tests := []struct {
name, want string
args EditArgs
}{
{name: "missing path", want: "path is required", args: EditArgs{OldStr: "a", NewStr: "b"}},
{name: "missing old_str", want: "old_str is required", args: EditArgs{Path: "sample.go", NewStr: "b"}},
{name: "no-op edit", want: "identical", args: EditArgs{Path: "sample.go", OldStr: "a", NewStr: "a"}},
{name: "missing file", want: "no such file", args: EditArgs{Path: "ghost.go", OldStr: "a", NewStr: "b"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := edit(t, tool, tc.args); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %v, want %q", err, tc.want)
}
})
}
}
func TestEditDeletion(t *testing.T) {
root, _, tool := editFixture(t, "keep\ndrop me\nkeep\n")
if _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "drop me\n", NewStr: ""}); err != nil {
t.Fatalf("edit: %v", err)
}
got, _ := os.ReadFile(filepath.Join(root, "sample.go"))
if string(got) != "keep\nkeep\n" {
t.Errorf("got %q", got)
}
}
func TestUnifiedDiffShape(t *testing.T) {
got := unifiedDiff("f.go", []string{"ctx"}, []string{"a", "b"}, []string{"a", "c"}, []string{"tail"}, 5)
want := strings.Join([]string{
"--- f.go",
"+++ f.go",
"@@ -4,4 +4,4 @@",
" ctx",
" a",
"-b",
"+c",
" tail",
}, "\n")
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}