use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use git2::Repository;
use serde::{Deserialize, Serialize};
const SECONDS_PER_HOUR: u64 = 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CacheEnvelope<T> {
generated_at_unix: u64,
head_hash: String,
payload: T,
}
pub fn clear_analysis_cache() -> Result<()> {
let dir = analysis_cache_dir()?;
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
Ok(())
}
pub fn load_or_compute(
key: &str,
ttl_hours: u64,
compute: impl FnOnce() -> Result<String>,
) -> Result<String> {
load_or_compute_with_repo(None, key, ttl_hours, compute)
}
pub fn load_or_compute_with_repo(
repo: Option<&Repository>,
key: &str,
ttl_hours: u64,
compute: impl FnOnce() -> Result<String>,
) -> Result<String> {
let owned_repo;
let repo = match repo {
Some(r) => r,
None => {
owned_repo = Repository::discover(".")?;
&owned_repo
}
};
let head_hash = repo
.head()?
.target()
.map(|oid| oid.to_string())
.unwrap_or_else(|| "HEAD".to_string());
let path = cache_file_path(repo, key)?;
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(envelope) = serde_json::from_str::<CacheEnvelope<String>>(&content) {
if envelope.head_hash == head_hash && !is_expired(envelope.generated_at_unix, ttl_hours)
{
return Ok(envelope.payload);
}
}
}
let payload = compute()?;
let envelope = CacheEnvelope {
generated_at_unix: now_unix_secs(),
head_hash,
payload,
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(serialized) = serde_json::to_string(&envelope) {
let _ = fs::write(&path, serialized);
}
Ok(envelope.payload)
}
fn is_expired(generated_at_unix: u64, ttl_hours: u64) -> bool {
if ttl_hours == 0 {
return true;
}
let now = now_unix_secs();
now.saturating_sub(generated_at_unix) > ttl_hours.saturating_mul(SECONDS_PER_HOUR)
}
fn cache_file_path(repo: &Repository, key: &str) -> Result<PathBuf> {
Ok(analysis_cache_dir()?
.join(repo_cache_key(repo)?)
.join(format!("{}.json", key)))
}
fn analysis_cache_dir() -> Result<PathBuf> {
if let Some(base) = dirs::cache_dir() {
return Ok(base.join("gitstack").join("analysis"));
}
Ok(std::env::temp_dir().join("gitstack").join("analysis"))
}
fn repo_cache_key(repo: &Repository) -> Result<String> {
let path = repo
.workdir()
.or_else(|| repo.path().parent())
.unwrap_or_else(|| Path::new("."));
let mut hasher = DefaultHasher::new();
path.to_string_lossy().hash(&mut hasher);
Ok(format!("{:x}", hasher.finish()))
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_expired_returns_true_when_ttl_is_zero() {
assert!(is_expired(now_unix_secs(), 0));
}
#[test]
fn is_expired_returns_false_for_fresh_entry() {
let now = now_unix_secs();
assert!(!is_expired(now, 1));
}
#[test]
fn is_expired_returns_true_for_old_entry() {
let two_hours_ago = now_unix_secs() - 7200;
assert!(is_expired(two_hours_ago, 1));
}
#[test]
fn is_expired_boundary_exactly_at_ttl() {
let now = now_unix_secs();
let generated_at = now - 3600;
assert!(!is_expired(generated_at, 1));
}
#[test]
fn is_expired_boundary_one_second_past_ttl() {
let now = now_unix_secs();
let generated_at = now - 3601;
assert!(is_expired(generated_at, 1));
}
#[test]
fn is_expired_large_ttl() {
let one_day_ago = now_unix_secs() - 86400;
assert!(!is_expired(one_day_ago, 48));
}
#[test]
fn is_expired_saturating_sub_on_future_timestamp() {
let future = now_unix_secs() + 10000;
assert!(!is_expired(future, 1));
}
#[test]
fn now_unix_secs_returns_reasonable_value() {
let secs = now_unix_secs();
assert!(secs > 1_704_067_200);
}
#[test]
fn cache_envelope_serialization_roundtrip() {
let envelope = CacheEnvelope {
generated_at_unix: 1_700_000_000,
head_hash: "abc123".to_string(),
payload: "test payload".to_string(),
};
let json = serde_json::to_string(&envelope).unwrap();
let deserialized: CacheEnvelope<String> = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.generated_at_unix, 1_700_000_000);
assert_eq!(deserialized.head_hash, "abc123");
assert_eq!(deserialized.payload, "test payload");
}
#[test]
fn cache_envelope_deserialization_from_known_json() {
let json = r#"{"generated_at_unix":1700000000,"head_hash":"def456","payload":"hello"}"#;
let envelope: CacheEnvelope<String> = serde_json::from_str(json).unwrap();
assert_eq!(envelope.generated_at_unix, 1_700_000_000);
assert_eq!(envelope.head_hash, "def456");
assert_eq!(envelope.payload, "hello");
}
#[test]
fn cache_envelope_invalid_json_returns_error() {
let result = serde_json::from_str::<CacheEnvelope<String>>("not json");
assert!(result.is_err());
}
#[test]
fn analysis_cache_dir_returns_path_with_gitstack() {
let dir = analysis_cache_dir().unwrap();
let dir_str = dir.to_string_lossy();
assert!(dir_str.contains("gitstack"));
assert!(dir_str.contains("analysis"));
}
}