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 TestReadFileEmptyFile(t *testing.T) { dir := t.TempDir() abs := writeFile(t, dir, "empty.txt", "") out, err := readCall(t, NewRead(dir, nil), ReadArgs{Path: abs}) if err != nil { t.Fatalf("an absolute path inside the root should work: %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) } }