Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/list/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func TestRun_JSONLimit(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("HOME", home)
t.Setenv(session.EnvCacheDir, filepath.Join(t.TempDir(), "scan-cache"))
t.Setenv(source.EnvVar, "")
writeListSession(t, home, cwd, "11111111-1111-1111-1111-111111111111", "2026-05-26T10:00:00Z", "older")
writeListSession(t, home, cwd, "22222222-2222-2222-2222-222222222222", "2026-05-26T11:00:00Z", "newer")
Expand Down Expand Up @@ -249,6 +250,7 @@ func BenchmarkRunJSONThousandSessions(b *testing.B) {
home := b.TempDir()
cwd := b.TempDir()
b.Setenv("HOME", home)
b.Setenv(session.EnvCacheDir, filepath.Join(b.TempDir(), "scan-cache"))
b.Setenv(source.EnvVar, "")
for i := range 1000 {
id := fmt.Sprintf("11111111-1111-1111-1111-%012d", i)
Expand All @@ -269,6 +271,7 @@ func BenchmarkRunGrepJSONHitAndMiss(b *testing.B) {
cwd := b.TempDir()
cache := b.TempDir()
b.Setenv("HOME", home)
b.Setenv(session.EnvCacheDir, filepath.Join(b.TempDir(), "scan-cache"))
b.Setenv(source.EnvVar, "")
b.Setenv("CCSESSION_GREP_CACHE_DIR", cache)
for i := range 512 {
Expand Down
118 changes: 118 additions & 0 deletions internal/session/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package session

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
)

const EnvCacheDir = "CCSESSION_SCAN_CACHE_DIR"

type scanCacheRecord struct {
Path string `json:"path"`
Size int64 `json:"size"`
ModTimeUnixNano int64 `json:"mod_time_unix_nano"`
Empty bool `json:"empty,omitempty"`
Session Session `json:"session,omitempty"`
}

func cachedParseSessionTail(path string) (*Session, error) {
fi, err := os.Stat(path)
if err != nil {
return nil, err
}
dir, err := scanCacheDir()
if err != nil {
return ParseSessionTail(path, TailReadBytes)
}
cachePath := filepath.Join(dir, scanCacheFileName(path))
if rec, ok := readScanCache(cachePath, path, fi); ok {
if rec.Empty {
return nil, ErrSessionEmpty
}
s := rec.Session
return &s, nil
}

s, err := ParseSessionTail(path, TailReadBytes)
if err != nil {
if errors.Is(err, ErrSessionEmpty) {
_ = writeScanCache(cachePath, scanCacheRecord{
Path: path,
Size: fi.Size(),
ModTimeUnixNano: fi.ModTime().UnixNano(),
Empty: true,
})
}
return nil, err
}
_ = writeScanCache(cachePath, scanCacheRecord{
Path: path,
Size: fi.Size(),
ModTimeUnixNano: fi.ModTime().UnixNano(),
Session: *s,
})
return s, nil
}

func scanCacheDir() (string, error) {
if dir := strings.TrimSpace(os.Getenv(EnvCacheDir)); dir != "" {
return dir, nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, "ccsession", "scan"), nil
}

func scanCacheFileName(path string) string {
sum := sha256.Sum256([]byte(path))
return hex.EncodeToString(sum[:]) + ".json"
}

func readScanCache(path, transcriptPath string, fi os.FileInfo) (scanCacheRecord, bool) {
b, err := os.ReadFile(path)
if err != nil {
return scanCacheRecord{}, false
}
var rec scanCacheRecord
if err := json.Unmarshal(b, &rec); err != nil {
return scanCacheRecord{}, false
}
if rec.Path != transcriptPath ||
rec.Size != fi.Size() ||
rec.ModTimeUnixNano != fi.ModTime().UnixNano() {
return scanCacheRecord{}, false
}
return rec, true
}

func writeScanCache(path string, rec scanCacheRecord) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)

if err := json.NewEncoder(tmp).Encode(rec); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
233 changes: 233 additions & 0 deletions internal/session/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package session

import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)

func TestScanCacheHitSkipsReparse(t *testing.T) {
home, projects := makeFakeHome(t)
t.Setenv("HOME", home)

proj := filepath.Join(projects, "-tmp-a")
path := writeSessionFile(t, proj, "11111111-1111-1111-1111-111111111111.jsonl",
"2024-05-26T10:00:00Z", "cached")
got, err := Scan()
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 || got[0].Label != "cached" {
t.Fatalf("first scan = %+v", got)
}

fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if err := os.WriteFile(path, []byte(strings.Repeat("x", int(fi.Size()))), 0o644); err != nil {
t.Fatalf("corrupt source: %v", err)
}
if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil {
t.Fatalf("chtimes: %v", err)
}

got, err = Scan()
if err != nil {
t.Fatalf("Scan cached: %v", err)
}
if len(got) != 1 || got[0].Label != "cached" {
t.Fatalf("cached scan = %+v, want cached session", got)
}
}

func TestScanCacheInvalidatesOnSizeOrMTimeChange(t *testing.T) {
home, projects := makeFakeHome(t)
t.Setenv("HOME", home)

proj := filepath.Join(projects, "-tmp-a")
path := writeSessionFile(t, proj, "11111111-1111-1111-1111-111111111111.jsonl",
"2024-05-26T10:00:00Z", "old")
if _, err := Scan(); err != nil {
t.Fatalf("Scan: %v", err)
}

writeSessionFile(t, proj, filepath.Base(path), "2024-05-26T11:00:00Z", "new label")
got, err := Scan()
if err != nil {
t.Fatalf("Scan after change: %v", err)
}
if len(got) != 1 || got[0].Label != "new label" {
t.Fatalf("scan after change = %+v, want new label", got)
}
}

func TestScanCacheCorruptJSONFallsBackToParse(t *testing.T) {
home, projects := makeFakeHome(t)
t.Setenv("HOME", home)

proj := filepath.Join(projects, "-tmp-a")
path := writeSessionFile(t, proj, "11111111-1111-1111-1111-111111111111.jsonl",
"2024-05-26T10:00:00Z", "parsed")
cacheDir := os.Getenv(EnvCacheDir)
cachePath := filepath.Join(cacheDir, scanCacheFileName(path))
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
t.Fatalf("mkdir cache: %v", err)
}
if err := os.WriteFile(cachePath, []byte("{"), 0o600); err != nil {
t.Fatalf("write corrupt cache: %v", err)
}

got, err := Scan()
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 || got[0].Label != "parsed" {
t.Fatalf("scan = %+v, want parsed session", got)
}
}

func TestScanCacheCachesEmptySessions(t *testing.T) {
home, projects := makeFakeHome(t)
t.Setenv("HOME", home)

proj := filepath.Join(projects, "-tmp-a")
if err := os.MkdirAll(proj, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(proj, "11111111-1111-1111-1111-111111111111.jsonl")
body := `{"type":"assistant","timestamp":"2024-05-26T10:00:00Z"}` + "\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}

got, err := Scan()
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 0 {
t.Fatalf("got %d sessions, want 0", len(got))
}

fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
valid := `{"type":"user","timestamp":"2024-05-26T10:00:00Z","message":{"role":"user","content":"x"}}` + "\n" +
`{"type":"ai-title","aiTitle":"cached empty"}`
if len(valid) < int(fi.Size()) {
valid += strings.Repeat(" ", int(fi.Size())-len(valid))
} else {
valid = strings.Repeat("x", int(fi.Size()))
}
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
if err := os.Chtimes(path, fi.ModTime(), fi.ModTime()); err != nil {
t.Fatalf("chtimes: %v", err)
}

got, err = Scan()
if err != nil {
t.Fatalf("Scan cached empty: %v", err)
}
if len(got) != 0 {
t.Fatalf("cached empty returned %d sessions, want 0", len(got))
}
}

func BenchmarkScanManySessions(b *testing.B) {
home := b.TempDir()
projects := filepath.Join(home, ".claude", "projects")
proj := filepath.Join(projects, "-tmp-bench")
if err := os.MkdirAll(proj, 0o755); err != nil {
b.Fatalf("mkdir: %v", err)
}
for i := range 1000 {
body := `{"type":"user","timestamp":"2024-05-26T10:00:00Z","cwd":"` + proj + `","message":{"role":"user","content":"hi"}}` + "\n" +
`{"type":"ai-title","aiTitle":"session ` + strconv.Itoa(i) + `"}` + "\n"
path := filepath.Join(proj, "session-"+strconv.Itoa(i)+".jsonl")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
b.Fatalf("write: %v", err)
}
}
b.Setenv("HOME", home)
b.Setenv(EnvCacheDir, filepath.Join(b.TempDir(), "scan-cache"))

b.Run("cold-cache", func(b *testing.B) {
for range b.N {
if err := os.RemoveAll(os.Getenv(EnvCacheDir)); err != nil {
b.Fatalf("remove cache: %v", err)
}
if got, err := Scan(); err != nil || len(got) != 1000 {
b.Fatalf("Scan = %d, %v", len(got), err)
}
}
})
if _, err := Scan(); err != nil {
b.Fatalf("warmup Scan: %v", err)
}
b.Run("warm-cache", func(b *testing.B) {
for range b.N {
if got, err := Scan(); err != nil || len(got) != 1000 {
b.Fatalf("Scan = %d, %v", len(got), err)
}
}
})
}

func TestScanCacheDirUsesUserCacheDir(t *testing.T) {
t.Setenv(EnvCacheDir, "")
home := t.TempDir()
t.Setenv("HOME", home)
dir, err := scanCacheDir()
if err != nil {
t.Fatalf("scanCacheDir: %v", err)
}
if !strings.HasSuffix(dir, filepath.Join("ccsession", "scan")) {
t.Fatalf("cache dir = %q", dir)
}
}

func TestScanCacheDirUsesEnv(t *testing.T) {
want := filepath.Join(t.TempDir(), "cache")
t.Setenv(EnvCacheDir, want)
got, err := scanCacheDir()
if err != nil {
t.Fatalf("scanCacheDir: %v", err)
}
if got != want {
t.Fatalf("scanCacheDir = %q, want %q", got, want)
}
}

func TestScanCacheRecordValidatesMetadata(t *testing.T) {
now := time.Now()
fi := fakeFileInfo{size: 1, mod: now}
rec := scanCacheRecord{Path: "a", Size: 1, ModTimeUnixNano: now.UnixNano()}
path := filepath.Join(t.TempDir(), "cache.json")
if err := writeScanCache(path, rec); err != nil {
t.Fatalf("writeScanCache: %v", err)
}
if _, ok := readScanCache(path, "a", fi); !ok {
t.Fatal("cache should be valid")
}
if _, ok := readScanCache(path, "b", fi); ok {
t.Fatal("cache should reject path mismatch")
}
}

type fakeFileInfo struct {
size int64
mod time.Time
}

func (f fakeFileInfo) Name() string { return "" }
func (f fakeFileInfo) Size() int64 { return f.size }
func (f fakeFileInfo) Mode() os.FileMode { return 0 }
func (f fakeFileInfo) ModTime() time.Time { return f.mod }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() any { return nil }
2 changes: 1 addition & 1 deletion internal/session/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func scanPaths(paths []string) []*Session {
go func() {
defer wg.Done()
defer func() { <-sem }()
s, err := ParseSessionTail(p, TailReadBytes)
s, err := cachedParseSessionTail(p)
if err != nil || s == nil {
return
}
Expand Down
Loading
Loading