Skip to main content

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}