package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type fakeTracker struct {
path string
mod time.Time
n int
}
func (f *fakeTracker) MarkRead(path string, mod time.Time) {
f.path, f.mod, f.n = path, mod, f.n+1
}
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func readCall(t *testing.T, tool Tool, args any) (string, error) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
return tool.Execute(context.Background(), raw)
}
func TestReadFileNumbersLines(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "main.go", "package main\n\nfunc main() {}\n")
out, err := readCall(t, NewRead(dir, nil), ReadArgs{Path: "main.go"})
if err != nil {
t.Fatalf("read: %v", err)
}
want := " 1\tpackage main\n 2\t\n 3\tfunc main() {}\n"
if out != want {
t.Errorf("got:\n%q\nwant:\n%q", out, want)
}
}
func TestReadFileOffsetAndLimit(t *testing.T) {
dir := t.TempDir()
var sb strings.Builder
for i := 1; i <= 10; i++ {
fmt.Fprintf(&sb, "line %d\n", i)
}
writeFile(t, dir, "ten.txt", sb.String())
tool := NewRead(dir, nil)
out, err := readCall(t, tool, ReadArgs{Path: "ten.txt", Offset: 4, Limit: 2})
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(out, " 4\tline 4") || !strings.Contains(out, " 5\tline 5") {
t.Errorf("window should be lines 4-5, got:\n%s", out)
}
if strings.Contains(out, "line 6") {
t.Errorf("limit not respected:\n%s", out)
}
if !strings.Contains(out, "offset=6") {
t.Errorf("a partial read should tell the model how to continue, got:\n%s", out)
}
}
func TestReadFileErrorsCarryEnoughToRecover(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "sub/a.go", "x\n")
tool := NewRead(dir, nil)
tests := []struct {
name string
args ReadArgs
want string
}{
{name: "missing file names the path", args: ReadArgs{Path: "nope.go"}, want: "no such file: nope.go"},
{name: "directory suggests another tool", args: ReadArgs{Path: "sub"}, want: "is a directory"},
{name: "offset past EOF reports the real length", args: ReadArgs{Path: "sub/a.go", Offset: 99}, want: "which has 1 lines"},
{name: "empty path", args: ReadArgs{}, want: "path is required"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := readCall(t, tool, tc.args)
if err == nil {
t.Fatal("want an error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %q, want it to contain %q", err, tc.want)
}
})
}
}
func TestReadFileMarksTheReadWithFileMtime(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, "tracked.go", "a\n")
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
tr := &fakeTracker{}
if _, err := readCall(t, NewRead(dir, tr), ReadArgs{Path: "tracked.go"}); err != nil {
t.Fatalf("read: %v", err)
}
if tr.n != 1 {
t.Fatalf("MarkRead called %d times, want 1", tr.n)
}
if tr.path != path {
t.Errorf("tracked path = %q, want the resolved path %q", tr.path, path)
}
if !tr.mod.Equal(info.ModTime()) {
t.Errorf("tracked mtime = %v, want the file's %v", tr.mod, info.ModTime())
}
}
func TestReadFileDoesNotTrackFailedReads(t *testing.T) {
tr := &fakeTracker{}
if _, err := readCall(t, NewRead(t.TempDir(), tr), ReadArgs{Path: "ghost.go"}); err == nil {
t.Fatal("want an error")
}
if tr.n != 0 {
t.Error("a failed read must not count as having read the file")
}
}
func TestReadFileEmptyAndAbsolutePaths(t *testing.T) {
dir := t.TempDir()
abs := writeFile(t, dir, "empty.txt", "")
out, err := readCall(t, NewRead("/nonexistent-root", nil), ReadArgs{Path: abs})
if err != nil {
t.Fatalf("absolute paths should bypass the root: %v", err)
}
if !strings.Contains(out, "is empty") {
t.Errorf("out = %q, want an explicit empty-file note rather than a blank result", out)
}
}
func TestReadFileCapsRunawayLines(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "min.js", strings.Repeat("x", maxLineRunes+500)+"\n")
out, err := readCall(t, NewRead(dir, nil), ReadArgs{Path: "min.js"})
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(out, "500 more characters on this line") {
t.Errorf("long line should be capped with a count, got %d bytes", len(out))
}
}
func TestHeadTailKeepsTheEnd(t *testing.T) {
var lines []string
for i := 1; i <= 50; i++ {
lines = append(lines, fmt.Sprintf("line %d", i))
}
got := headTail(strings.Join(lines, "\n"), 2, 2)
want := "line 1\nline 2\n… 46 lines elided …\nline 49\nline 50"
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
if short := headTail("a\nb\nc", 2, 2); short != "a\nb\nc" {
t.Errorf("short input should pass through unchanged, got %q", short)
}
}