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) } }