package tools
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"codey/internal/diag"
)
// stubDiagnoser records what it was asked about and answers with a fixed
// block, so the tests are about the tools' handling and not about any linter.
type stubDiagnoser struct {
mu sync.Mutex
calls []string
roots []string
reply string
}
func (s *stubDiagnoser) Report(_ context.Context, root, path string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, path)
s.roots = append(s.roots, root)
return s.reply
}
const block = "diagnostics (gopls): 1 problem\n sample.go:6:2: undefined: fmt.Printl"
func TestEditAppendsDiagnosticsToASuccessfulResult(t *testing.T) {
root, track, _ := editFixture(t, sample)
stub := &stubDiagnoser{reply: block}
tool := NewEdit(root, track, WithDiagnostics(stub))
out, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: `fmt.Println("hello", name)`, NewStr: `fmt.Printl("hello", name)`})
if err != nil {
t.Fatalf("edit: %v", err)
}
if !strings.HasSuffix(out, "\n\n"+block) {
t.Errorf("diagnostics not appended:\n%s", out)
}
if !strings.Contains(out, "-fmt.Println") {
t.Errorf("the diff should still come first:\n%s", out)
}
if len(stub.calls) != 1 || stub.calls[0] != filepath.Join(root, "sample.go") || stub.roots[0] != root {
t.Errorf("Report called with %v in %v", stub.calls, stub.roots)
}
}
func TestEditSilentDiagnoserLeavesTheResultAlone(t *testing.T) {
root, track, _ := editFixture(t, sample)
plain, err := edit(t, NewEdit(root, track), EditArgs{Path: "sample.go", OldStr: `"world"`, NewStr: `"there"`})
if err != nil {
t.Fatal(err)
}
// Reset the file and the tracker, then do the same edit with a silent
// diagnoser: the result must be byte-identical.
path := filepath.Join(root, "sample.go")
if err := os.WriteFile(path, []byte(sample), 0o644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(path)
track.MarkRead(path, info.ModTime())
stub := &stubDiagnoser{}
withDiag, err := edit(t, NewEdit(root, track, WithDiagnostics(stub)), EditArgs{Path: "sample.go", OldStr: `"world"`, NewStr: `"there"`})
if err != nil {
t.Fatal(err)
}
if withDiag != plain {
t.Errorf("silent diagnoser changed the result:\n%q\nvs\n%q", withDiag, plain)
}
if len(stub.calls) != 1 {
t.Errorf("Report called %d times", len(stub.calls))
}
}
func TestEditFailureDoesNotConsultDiagnostics(t *testing.T) {
root, track, _ := editFixture(t, sample)
stub := &stubDiagnoser{reply: block}
tool := NewEdit(root, track, WithDiagnostics(stub))
_, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "not in the file", NewStr: "x"})
if err == nil {
t.Fatal("expected a miss")
}
if strings.Contains(err.Error(), "diagnostics") {
t.Errorf("diagnostics leaked into an error: %v", err)
}
if len(stub.calls) != 0 {
t.Errorf("Report called on a failed edit: %v", stub.calls)
}
}
func TestWriteAppendsDiagnostics(t *testing.T) {
root := t.TempDir()
stub := &stubDiagnoser{reply: block}
tool := NewWrite(root, newTracker(), WithDiagnostics(stub))
out, err := run(t, tool, map[string]any{"path": "new/sample.go", "content": "package main\n"})
if err != nil {
t.Fatalf("write: %v", err)
}
want := "Created new/sample.go (13 bytes, 1 lines).\n\n" + block
if out != want {
t.Errorf("got %q, want %q", out, want)
}
if len(stub.calls) != 1 || stub.calls[0] != filepath.Join(root, "new", "sample.go") {
t.Errorf("Report called with %v", stub.calls)
}
// A refused write never gets that far: a file that predates the session
// and has never been read through the tracker may not be overwritten.
stub.calls = nil
unread := filepath.Join(root, "unread.go")
if err := os.WriteFile(unread, []byte("package x\n"), 0o644); err != nil {
t.Fatal(err)
}
past := time.Now().Add(-time.Hour)
os.Chtimes(unread, past, past)
if _, err := run(t, tool, map[string]any{"path": "unread.go", "content": "again\n"}); err == nil {
t.Fatal("overwrite of a file never read was allowed")
}
if len(stub.calls) != 0 {
t.Errorf("Report called for a refused write: %v", stub.calls)
}
}
func TestDescriptionMentionsDiagnosticsOnlyWhenWired(t *testing.T) {
root := t.TempDir()
if strings.Contains(NewEdit(root, nil).Description(), "diagnostics") {
t.Error("edit_file promises diagnostics it has no way to produce")
}
if !strings.Contains(NewEdit(root, nil, WithDiagnostics(&stubDiagnoser{})).Description(), "diagnostics") {
t.Error("edit_file with a Diagnoser does not tell the model to expect one")
}
if strings.Contains(NewWrite(root, nil).Description(), "diagnostics") {
t.Error("write_file promises diagnostics it has no way to produce")
}
}
// The wiring main.go does: a *diag.Runner that may be nil when the config
// says off. A nil pointer inside a non-nil interface must still be harmless.
func TestNilRunnerThroughTheOptionIsHarmless(t *testing.T) {
var runner *diag.Runner
root, track, _ := editFixture(t, sample)
tool := NewEdit(root, track, WithDiagnostics(runner))
out, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: `"world"`, NewStr: `"there"`})
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "diagnostics") {
t.Errorf("nil runner produced diagnostics: %s", out)
}
}
// With a real Runner and the Go toolchain, an edit that breaks the build says
// so in the tool result. This is the feature, end to end, minus the model.
func TestEditWithRealRunnerReportsTheCompilerError(t *testing.T) {
if testing.Short() {
t.Skip("runs the go tool")
}
root, track, _ := editFixture(t, sample)
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module sample\n\ngo 1.22\n"), 0o644); err != nil {
t.Fatal(err)
}
runner := diag.NewRunner()
defer runner.Close()
tool := NewEdit(root, track, WithDiagnostics(runner))
out, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: `fmt.Println("hello", name)`, NewStr: `fmt.Printl("hello", name)`})
if err != nil {
t.Fatalf("edit: %v", err)
}
if !strings.Contains(out, "diagnostics (") || !strings.Contains(out, "sample.go:6:6: undefined: fmt.Printl") {
// gopls, if installed, says the same thing from a different source.
t.Errorf("compiler error not reported:\n%s", out)
}
}