hotspots_core/touch_cache.rs
1//! On-disk cache for per-function git touch metrics.
2//!
3//! Cache key: `"{sha}:{file}:{start}:{end}"` where file is a repo-relative path
4//! and start/end are 1-based line numbers. Value: `(touch_count_30d, days_since_last_change)`.
5//!
6//! **Line range shift behavior:** If surrounding code changes and a function's line
7//! range moves, the cache key will not match (start/end differ) — it is a miss.
8//! This is correct: the function's git history in its new position must be re-queried.
9//! An unchanged function in an unchanged file hits the parent commit's cache entry
10//! exactly. Do not "fix" this with content hashing — the miss behavior is intentional.
11//!
12//! **Eviction:** After writing, entries whose SHA prefix is not in the provided set
13//! are dropped. At most `MAX_CACHED_SHAS` distinct SHAs are retained to bound file size.
14//!
15//! **Warm-run speedup (SQ-1d benchmark, hyperfine, this repo, ~200 functions):**
16//! cold run 6.2 s ± 0.02 s (one `git log -L` subprocess per function)
17//! warm run 230 ms ± 3 ms (all cache hits — no subprocesses)
18//! file-level 268 ms ± 3 ms (batched git log per file)
19//! Warm is ~27× faster than cold and ~15% faster than file-level. Target was warm ≤ 2×
20//! file-level; this repo achieves 0.86× (warm per-function beats file-level).
21
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result};
26
27/// In-memory touch cache: maps key to `(touch_count_30d, days_since_last_change)`.
28pub type TouchCache = HashMap<String, (usize, Option<u32>)>;
29
30/// Maximum number of distinct commit SHAs to retain in the cache.
31const MAX_CACHED_SHAS: usize = 50;
32
33fn cache_path(repo_root: &Path) -> PathBuf {
34 crate::snapshot::hotspots_dir(repo_root).join("touch-cache.json.zst")
35}
36
37/// Build a cache lookup key from its components.
38pub fn cache_key(sha: &str, file: &str, start: u32, end: u32) -> String {
39 format!("{}:{}:{}:{}", sha, file, start, end)
40}
41
42/// Load the touch cache from disk.
43///
44/// Returns `None` on cold start (file absent) or on read/decompress error (non-fatal).
45/// The caller should treat `None` as an empty cache and proceed normally.
46pub fn read_touch_cache(repo_root: &Path) -> Option<TouchCache> {
47 let path = cache_path(repo_root);
48 if !path.exists() {
49 return None;
50 }
51 match load_compressed_json(&path) {
52 Ok(cache) => Some(cache),
53 Err(e) => {
54 eprintln!("warning: failed to load touch cache (proceeding cold): {e}");
55 None
56 }
57 }
58}
59
60fn load_compressed_json(path: &Path) -> Result<TouchCache> {
61 let compressed = std::fs::read(path)
62 .with_context(|| format!("failed to read touch cache: {}", path.display()))?;
63 let bytes = zstd::decode_all(compressed.as_slice())
64 .with_context(|| format!("failed to decompress touch cache: {}", path.display()))?;
65 let json = std::str::from_utf8(&bytes).context("touch cache is not valid UTF-8")?;
66 serde_json::from_str(json).context("failed to parse touch cache JSON")
67}
68
69/// Write the touch cache to disk (zstd level 3).
70pub fn write_touch_cache(repo_root: &Path, cache: &TouchCache) -> Result<()> {
71 let path = cache_path(repo_root);
72 if let Some(parent) = path.parent() {
73 std::fs::create_dir_all(parent)
74 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
75 }
76 let json = serde_json::to_string(cache).context("failed to serialize touch cache")?;
77 let compressed =
78 zstd::encode_all(json.as_bytes(), 3).context("failed to compress touch cache")?;
79 std::fs::write(&path, &compressed)
80 .with_context(|| format!("failed to write touch cache: {}", path.display()))
81}
82
83/// Evict cache entries whose SHA is not among `known_shas`.
84///
85/// `known_shas` should be ordered most-recent-first; at most `MAX_CACHED_SHAS` are
86/// retained. This bounds file size on repositories with many historic commits.
87pub fn evict_old_entries(cache: &mut TouchCache, known_shas: &[String]) {
88 let allowed: std::collections::HashSet<&str> = known_shas
89 .iter()
90 .take(MAX_CACHED_SHAS)
91 .map(String::as_str)
92 .collect();
93 // Key format: "{sha}:{file}:{start}:{end}" — SHA is the segment before the first ':'
94 cache.retain(|key, _| {
95 key.split(':')
96 .next()
97 .is_some_and(|sha| allowed.contains(sha))
98 });
99}