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
160 changes: 86 additions & 74 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,131 +22,143 @@ var skipDirs = map[string]bool{
".vscode": true,
}

type Entry struct {
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
SourcePath string `json:"source_path"`
SessionID string `json:"session_id,omitempty"`
Data format.Output `json:"data"`
}

// SameSession reports whether this entry was written by the same session as
// the current process (same terminal, editor, or CI job).
func (e *Entry) SameSession(c *Config) bool {
return e.SessionID != "" && e.SessionID == c.SessionID
}

// Key returns a cache key derived from the absolute path (first 16 hex chars of SHA256).
func Key(absPath string) string {
h := sha256.Sum256([]byte(absPath))
return hex.EncodeToString(h[:])[:16]
}

// Write writes the output data to the cache for the given absolute path.
// Write writes the output data to the cache for the given absolute path and
// updates the manifest accordingly.
func (c *Config) Write(absPath string, data *format.Output) error {
if err := os.MkdirAll(c.Cache, 0700); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}

entry := Entry{
Version: 1,
CreatedAt: time.Now(),
SourcePath: absPath,
SessionID: c.SessionID,
Data: *data,
}
key := Key(absPath)

b, err := json.Marshal(entry)
b, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal cache entry: %w", err)
return fmt.Errorf("failed to marshal cache data: %w", err)
}

path := filepath.Join(c.Cache, Key(absPath)+".json")
path := filepath.Join(c.Cache, key+".json")
if err := os.WriteFile(path, b, 0600); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}

m, err := c.LoadManifest()
if err != nil {
return fmt.Errorf("failed to load manifest: %w", err)
}

m.Entries[key] = ManifestEntry{
Version: 1,
CreatedAt: time.Now(),
SourcePath: absPath,
}

if err := c.SaveManifest(m); err != nil {
return fmt.Errorf("failed to save manifest: %w", err)
}

return nil
}

// Read reads a cached entry for the given absolute path, returning an error if missing or expired.
// ForPath returns cached results for the given absolute path.
//
// allowChanged can be set to true to return the entry even if the files have changed (so the cache is stale). This is
// useful for tracking changes in the results between runs, but should be set to false when inspecting results to make
// sure results are up-to-date.
func (c *Config) Read(absPath string, allowChanged bool) (*Entry, error) {
path := filepath.Join(c.Cache, Key(absPath)+".json")
return readEntryFile(path, c.TTL, allowChanged)
// The entry must be within TTL and the source files must not have been modified
// since the entry was created.
func (c *Config) ForPath(absPath string) (*format.Output, error) {
m, err := c.LoadManifest()
if err != nil {
return nil, fmt.Errorf("failed to load manifest: %w", err)
}

key := Key(absPath)

e, ok := m.Entries[key]
if !ok || c.TTL <= 0 || time.Since(e.CreatedAt) > c.TTL {
return nil, fmt.Errorf("no cached results found")
}

if e.SourcePath != "" {
if changed := newerFile(e.SourcePath, e.CreatedAt); changed != "" {
return nil, fmt.Errorf("cached results stale (source file changed: %s)", changed)
}
}

return readDataFile(c.Cache, key)
}

// ReadLatest reads the most recently modified cache entry, returning an error if missing or expired.
func (c *Config) ReadLatest() (*Entry, error) {
entries, err := os.ReadDir(c.Cache)
// Latest returns the most recent cached result within TTL.
//
// When allowStale is false, the entry is also rejected if any source file has
// been modified since the entry was created. Set allowStale to true when
// reading for comparison (e.g. diffing against a prior run).
func (c *Config) Latest(allowStale bool) (*format.Output, error) {
m, err := c.LoadManifest()
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("no cached results found")
}
return nil, fmt.Errorf("failed to read cache directory: %w", err)
return nil, fmt.Errorf("failed to load manifest: %w", err)
}

var newest os.DirEntry
var newestKey string
var newestTime time.Time
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
for key, e := range m.Entries {
if c.TTL > 0 && time.Since(e.CreatedAt) > c.TTL {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if newest == nil || info.ModTime().After(newestTime) {
newest = e
newestTime = info.ModTime()
if newestKey == "" || e.CreatedAt.After(newestTime) {
newestKey = key
newestTime = e.CreatedAt
}
}

if newest == nil {
if newestKey == "" {
return nil, fmt.Errorf("no cached results found")
}

return readEntryFile(filepath.Join(c.Cache, newest.Name()), c.TTL, false)
if !allowStale {
best := m.Entries[newestKey]
if best.SourcePath != "" {
if changed := newerFile(best.SourcePath, best.CreatedAt); changed != "" {
return nil, fmt.Errorf("cached results stale (source file changed: %s)", changed)
}
}
}

return readDataFile(c.Cache, newestKey)
}

func readEntryFile(path string, ttl time.Duration, allowChanged bool) (*Entry, error) {
func readDataFile(cacheDir, key string) (*format.Output, error) {
path := filepath.Join(cacheDir, key+".json")

// nolint:gosec // G304: Cache path is derived internally.
b, err := os.ReadFile(path)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("no cached results found")
}
return nil, fmt.Errorf("failed to read cache file: %w", err)
}
defer func() {
_ = f.Close()
}()

var entry Entry
if err := json.Unmarshal(b, &entry); err != nil {
return nil, fmt.Errorf("failed to decode cache entry: %w", err)
}

if ttl > 0 && time.Since(entry.CreatedAt) > ttl {
return nil, fmt.Errorf("cached results expired")
}

if entry.SourcePath != "" && !allowChanged {
if changed := newerFile(entry.SourcePath, entry.CreatedAt); changed != "" {
return nil, fmt.Errorf("cached results stale (source file changed: %s)", changed)
}
var output format.Output
if err := json.NewDecoder(f).Decode(&output); err != nil {
return nil, fmt.Errorf("failed to decode cache data: %w", err)
}

return &entry, nil
return &output, nil
}

// hasNewerFile walks the source directory and returns true as soon as it finds
// any file modified after the given time. Skips known heavy directories.
// newerFile walks root looking for any file modified after since. It returns
// the path of the first such file, or "" if none are found. If root does not
// exist (e.g. a temporary directory that has been cleaned up), the walk
// silently returns "" so the cached entry is still considered fresh.
func newerFile(root string, since time.Time) string {
var changed string
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // best-effort, skip errors
return nil
}
if d.IsDir() {
if skipDirs[d.Name()] {
Expand Down Expand Up @@ -181,4 +193,4 @@ func ReadFile(path string) (*format.Output, error) {
}

return &output, nil
}
}
Loading
Loading