use crate::error::ReleaseError;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const CACHE_VERSION: u32 = 1;
const TTL_SECS: u64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepCache {
pub version: u32,
pub updated_at: u64,
pub steps: BTreeMap<String, BTreeMap<String, String>>,
}
impl Default for StepCache {
fn default() -> Self {
Self {
version: CACHE_VERSION,
updated_at: now_secs(),
steps: BTreeMap::new(),
}
}
}
pub struct StepDiff {
pub changed: Vec<String>,
pub cached: Vec<String>,
}
pub fn cache_dir(repo_root: &Path) -> Option<PathBuf> {
let base = dirs::cache_dir()?;
let repo_id = &sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
Some(base.join("sr").join("hooks").join(repo_id))
}
fn cache_path(repo_root: &Path) -> Option<PathBuf> {
cache_dir(repo_root).map(|d| d.join("step-cache.json"))
}
pub fn load_step_cache(repo_root: &Path) -> StepCache {
let Some(path) = cache_path(repo_root) else {
return StepCache::default();
};
let Ok(data) = std::fs::read_to_string(&path) else {
return StepCache::default();
};
let Ok(cache) = serde_json::from_str::<StepCache>(&data) else {
return StepCache::default();
};
if cache.version != CACHE_VERSION {
return StepCache::default();
}
if now_secs().saturating_sub(cache.updated_at) > TTL_SECS {
return StepCache::default();
}
cache
}
pub fn save_step_cache(repo_root: &Path, cache: &StepCache) -> Result<(), ReleaseError> {
let path = cache_path(repo_root)
.ok_or_else(|| ReleaseError::Config("cannot resolve hook cache directory".into()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ReleaseError::Config(format!("failed to create hook cache dir: {e}")))?;
}
let data = serde_json::to_string_pretty(cache)
.map_err(|e| ReleaseError::Config(format!("failed to serialize hook cache: {e}")))?;
std::fs::write(&path, data)
.map_err(|e| ReleaseError::Config(format!("failed to write hook cache: {e}")))?;
Ok(())
}
pub fn staged_content_hash(repo_root: &Path, file: &str) -> Option<String> {
let spec = format!(":0:{file}");
let output = std::process::Command::new("git")
.args(["-C", repo_root.to_str()?])
.args(["show", &spec])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(sha256_hex(&output.stdout))
}
pub fn hash_staged_files(repo_root: &Path, files: &[String]) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
for file in files {
if let Some(hash) = staged_content_hash(repo_root, file) {
result.insert(file.clone(), hash);
}
}
result
}
pub fn changed_files_for_step(
cache: &StepCache,
step_name: &str,
current_hashes: &BTreeMap<String, String>,
) -> StepDiff {
let cached_step = cache.steps.get(step_name);
let mut changed = Vec::new();
let mut cached = Vec::new();
for (file, hash) in current_hashes {
let is_cached = cached_step
.and_then(|s| s.get(file))
.is_some_and(|h| h == hash);
if is_cached {
cached.push(file.clone());
} else {
changed.push(file.clone());
}
}
StepDiff { changed, cached }
}
pub fn record_step_pass(cache: &mut StepCache, step_name: &str, hashes: &BTreeMap<String, String>) {
let step_entry = cache.steps.entry(step_name.to_string()).or_default();
for (file, hash) in hashes {
step_entry.insert(file.clone(), hash.clone());
}
cache.updated_at = now_secs();
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn step_cache_round_trip() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let mut cache = StepCache::default();
let mut hashes = BTreeMap::new();
hashes.insert("src/main.rs".to_string(), "abc123".to_string());
hashes.insert("src/lib.rs".to_string(), "def456".to_string());
record_step_pass(&mut cache, "format", &hashes);
save_step_cache(repo_root, &cache).unwrap();
let loaded = load_step_cache(repo_root);
assert_eq!(loaded.version, CACHE_VERSION);
assert_eq!(
loaded
.steps
.get("format")
.unwrap()
.get("src/main.rs")
.unwrap(),
"abc123"
);
}
#[test]
fn changed_files_detection() {
let mut cache = StepCache::default();
let mut old_hashes = BTreeMap::new();
old_hashes.insert("a.rs".to_string(), "hash_a".to_string());
old_hashes.insert("b.rs".to_string(), "hash_b".to_string());
record_step_pass(&mut cache, "lint", &old_hashes);
let mut current = BTreeMap::new();
current.insert("a.rs".to_string(), "hash_a".to_string());
current.insert("b.rs".to_string(), "hash_b_new".to_string());
current.insert("c.rs".to_string(), "hash_c".to_string());
let diff = changed_files_for_step(&cache, "lint", ¤t);
assert_eq!(diff.cached, vec!["a.rs"]);
assert_eq!(diff.changed, vec!["b.rs", "c.rs"]);
}
#[test]
fn empty_cache_all_changed() {
let cache = StepCache::default();
let mut current = BTreeMap::new();
current.insert("a.rs".to_string(), "hash_a".to_string());
let diff = changed_files_for_step(&cache, "lint", ¤t);
assert!(diff.cached.is_empty());
assert_eq!(diff.changed, vec!["a.rs"]);
}
#[test]
fn expired_cache_returns_default() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
let mut cache = StepCache::default();
cache.updated_at = 0; save_step_cache(repo_root, &cache).unwrap();
let loaded = load_step_cache(repo_root);
assert!(loaded.steps.is_empty());
}
}