Vectrum
Sign In
324 lines · 9.7 KB · Go
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)
	}
}