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