gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
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;
        // At exactly 1 hour boundary, should not be expired (> not >=)
        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;
        // 48 hours TTL, generated 24 hours ago => not expired
        assert!(!is_expired(one_day_ago, 48));
    }

    #[test]
    fn is_expired_saturating_sub_on_future_timestamp() {
        // generated_at in the "future" (larger than now) => saturating_sub => 0 => not expired
        let future = now_unix_secs() + 10000;
        assert!(!is_expired(future, 1));
    }

    #[test]
    fn now_unix_secs_returns_reasonable_value() {
        let secs = now_unix_secs();
        // Should be after 2024-01-01 (1704067200)
        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"));
    }
}