Skip to main content

sr_core/
hook_cache.rs

1//! Per-step, per-file hook result cache.
2//!
3//! Tracks which staged files have already passed each hook step (by content
4//! hash), enabling partial retries and skipping unchanged work on big merges.
5//!
6//! Cache location: `~/.cache/sr/hooks/<repo-id>/step-cache.json`
7
8use crate::error::ReleaseError;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15const CACHE_VERSION: u32 = 1;
16const TTL_SECS: u64 = 7 * 24 * 60 * 60; // 7 days
17
18/// Per-step cache of file content hashes that have passed hook checks.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StepCache {
21    pub version: u32,
22    pub updated_at: u64,
23    /// step_name → (file_path → content_hash)
24    pub steps: BTreeMap<String, BTreeMap<String, String>>,
25}
26
27impl Default for StepCache {
28    fn default() -> Self {
29        Self {
30            version: CACHE_VERSION,
31            updated_at: now_secs(),
32            steps: BTreeMap::new(),
33        }
34    }
35}
36
37/// Result of checking which files need re-running for a step.
38pub struct StepDiff {
39    /// Files whose content hash differs from cache (or not in cache).
40    pub changed: Vec<String>,
41    /// Files whose content hash matches the cache.
42    pub cached: Vec<String>,
43}
44
45/// Resolve cache directory: `~/.cache/sr/hooks/<repo-id>/`
46pub fn cache_dir(repo_root: &Path) -> Option<PathBuf> {
47    let base = dirs::cache_dir()?;
48    let repo_id = &sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
49    Some(base.join("sr").join("hooks").join(repo_id))
50}
51
52fn cache_path(repo_root: &Path) -> Option<PathBuf> {
53    cache_dir(repo_root).map(|d| d.join("step-cache.json"))
54}
55
56/// Load the step cache from disk. Returns `Default` on any error (graceful
57/// degradation — worst case is a full re-run).
58pub fn load_step_cache(repo_root: &Path) -> StepCache {
59    let Some(path) = cache_path(repo_root) else {
60        return StepCache::default();
61    };
62
63    let Ok(data) = std::fs::read_to_string(&path) else {
64        return StepCache::default();
65    };
66
67    let Ok(cache) = serde_json::from_str::<StepCache>(&data) else {
68        return StepCache::default();
69    };
70
71    // Version mismatch — start fresh
72    if cache.version != CACHE_VERSION {
73        return StepCache::default();
74    }
75
76    // TTL expired — start fresh
77    if now_secs().saturating_sub(cache.updated_at) > TTL_SECS {
78        return StepCache::default();
79    }
80
81    cache
82}
83
84/// Save the step cache to disk.
85pub fn save_step_cache(repo_root: &Path, cache: &StepCache) -> Result<(), ReleaseError> {
86    let path = cache_path(repo_root)
87        .ok_or_else(|| ReleaseError::Config("cannot resolve hook cache directory".into()))?;
88
89    if let Some(parent) = path.parent() {
90        std::fs::create_dir_all(parent)
91            .map_err(|e| ReleaseError::Config(format!("failed to create hook cache dir: {e}")))?;
92    }
93
94    let data = serde_json::to_string_pretty(cache)
95        .map_err(|e| ReleaseError::Config(format!("failed to serialize hook cache: {e}")))?;
96
97    std::fs::write(&path, data)
98        .map_err(|e| ReleaseError::Config(format!("failed to write hook cache: {e}")))?;
99
100    Ok(())
101}
102
103/// Hash the staged blob content for a file using `git show :0:<path>`.
104///
105/// Deterministic — unlike diff-based hashing, the result depends only on the
106/// staged content, not the base.
107pub fn staged_content_hash(repo_root: &Path, file: &str) -> Option<String> {
108    let spec = format!(":0:{file}");
109    let output = std::process::Command::new("git")
110        .args(["-C", repo_root.to_str()?])
111        .args(["show", &spec])
112        .output()
113        .ok()?;
114
115    if !output.status.success() {
116        return None;
117    }
118
119    Some(sha256_hex(&output.stdout))
120}
121
122/// Compute content hashes for a set of staged files.
123pub fn hash_staged_files(repo_root: &Path, files: &[String]) -> BTreeMap<String, String> {
124    let mut result = BTreeMap::new();
125    for file in files {
126        if let Some(hash) = staged_content_hash(repo_root, file) {
127            result.insert(file.clone(), hash);
128        }
129    }
130    result
131}
132
133/// Determine which files have changed since they were last cached for a step.
134pub fn changed_files_for_step(
135    cache: &StepCache,
136    step_name: &str,
137    current_hashes: &BTreeMap<String, String>,
138) -> StepDiff {
139    let cached_step = cache.steps.get(step_name);
140    let mut changed = Vec::new();
141    let mut cached = Vec::new();
142
143    for (file, hash) in current_hashes {
144        let is_cached = cached_step
145            .and_then(|s| s.get(file))
146            .is_some_and(|h| h == hash);
147
148        if is_cached {
149            cached.push(file.clone());
150        } else {
151            changed.push(file.clone());
152        }
153    }
154
155    StepDiff { changed, cached }
156}
157
158/// Record that all files with the given hashes passed a step.
159pub fn record_step_pass(cache: &mut StepCache, step_name: &str, hashes: &BTreeMap<String, String>) {
160    let step_entry = cache.steps.entry(step_name.to_string()).or_default();
161    for (file, hash) in hashes {
162        step_entry.insert(file.clone(), hash.clone());
163    }
164    cache.updated_at = now_secs();
165}
166
167fn sha256_hex(data: &[u8]) -> String {
168    let mut hasher = Sha256::new();
169    hasher.update(data);
170    format!("{:x}", hasher.finalize())
171}
172
173fn now_secs() -> u64 {
174    SystemTime::now()
175        .duration_since(UNIX_EPOCH)
176        .unwrap_or_default()
177        .as_secs()
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn step_cache_round_trip() {
186        let dir = tempfile::tempdir().unwrap();
187        let repo_root = dir.path();
188
189        let mut cache = StepCache::default();
190        let mut hashes = BTreeMap::new();
191        hashes.insert("src/main.rs".to_string(), "abc123".to_string());
192        hashes.insert("src/lib.rs".to_string(), "def456".to_string());
193        record_step_pass(&mut cache, "format", &hashes);
194
195        save_step_cache(repo_root, &cache).unwrap();
196        let loaded = load_step_cache(repo_root);
197
198        assert_eq!(loaded.version, CACHE_VERSION);
199        assert_eq!(
200            loaded
201                .steps
202                .get("format")
203                .unwrap()
204                .get("src/main.rs")
205                .unwrap(),
206            "abc123"
207        );
208    }
209
210    #[test]
211    fn changed_files_detection() {
212        let mut cache = StepCache::default();
213        let mut old_hashes = BTreeMap::new();
214        old_hashes.insert("a.rs".to_string(), "hash_a".to_string());
215        old_hashes.insert("b.rs".to_string(), "hash_b".to_string());
216        record_step_pass(&mut cache, "lint", &old_hashes);
217
218        // b.rs changed, c.rs is new
219        let mut current = BTreeMap::new();
220        current.insert("a.rs".to_string(), "hash_a".to_string());
221        current.insert("b.rs".to_string(), "hash_b_new".to_string());
222        current.insert("c.rs".to_string(), "hash_c".to_string());
223
224        let diff = changed_files_for_step(&cache, "lint", &current);
225        assert_eq!(diff.cached, vec!["a.rs"]);
226        assert_eq!(diff.changed, vec!["b.rs", "c.rs"]);
227    }
228
229    #[test]
230    fn empty_cache_all_changed() {
231        let cache = StepCache::default();
232        let mut current = BTreeMap::new();
233        current.insert("a.rs".to_string(), "hash_a".to_string());
234
235        let diff = changed_files_for_step(&cache, "lint", &current);
236        assert!(diff.cached.is_empty());
237        assert_eq!(diff.changed, vec!["a.rs"]);
238    }
239
240    #[test]
241    fn expired_cache_returns_default() {
242        let dir = tempfile::tempdir().unwrap();
243        let repo_root = dir.path();
244
245        let mut cache = StepCache::default();
246        cache.updated_at = 0; // epoch — definitely expired
247        save_step_cache(repo_root, &cache).unwrap();
248
249        let loaded = load_step_cache(repo_root);
250        assert!(loaded.steps.is_empty());
251    }
252}