1use 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; #[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StepCache {
21 pub version: u32,
22 pub updated_at: u64,
23 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
37pub struct StepDiff {
39 pub changed: Vec<String>,
41 pub cached: Vec<String>,
43}
44
45pub 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
56pub 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 if cache.version != CACHE_VERSION {
73 return StepCache::default();
74 }
75
76 if now_secs().saturating_sub(cache.updated_at) > TTL_SECS {
78 return StepCache::default();
79 }
80
81 cache
82}
83
84pub 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
103pub 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
122pub 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
133pub 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
158pub 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 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", ¤t);
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", ¤t);
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; save_step_cache(repo_root, &cache).unwrap();
248
249 let loaded = load_step_cache(repo_root);
250 assert!(loaded.steps.is_empty());
251 }
252}