repotoire 0.5.3

Graph-powered code analysis CLI. 106 detectors for security, architecture, and code quality.
Documentation
//! Cache path utilities - uses ~/.cache/repotoire/<repo-hash>/ instead of .repotoire/

use std::path::{Path, PathBuf};

/// Cache directory for a repository.
/// Uses ~/.cache/repotoire/<repo-hash>/ on Unix, %LOCALAPPDATA%/repotoire/<repo-hash>/ on Windows.
pub fn cache_dir(repo_path: &Path) -> PathBuf {
    let repo_hash = hash_path(repo_path);

    let base = if cfg!(windows) {
        std::env::var("LOCALAPPDATA")
            .map(PathBuf::from)
            .unwrap_or_else(|_| dirs::cache_dir().unwrap_or_else(|| PathBuf::from(".")))
    } else {
        dirs::cache_dir().unwrap_or_else(|| {
            // Fallback to ~/.cache
            dirs::home_dir()
                .map(|h| h.join(".cache"))
                .unwrap_or_else(|| PathBuf::from("."))
        })
    };

    base.join("repotoire").join(&repo_hash)
}

/// Git cache file path for a repository.
pub fn git_cache_path(repo_path: &Path) -> PathBuf {
    cache_dir(repo_path).join("git_cache.json")
}

/// Findings cache file path for a repository.
pub fn findings_cache_path(repo_path: &Path) -> PathBuf {
    cache_dir(repo_path).join("last_findings.json")
}

/// Health report cache file path for a repository (score delta).
pub fn health_cache_path(repo_path: &Path) -> PathBuf {
    cache_dir(repo_path).join("last_health.json")
}

/// Graph database path for a repository.
pub fn graph_db_path(repo_path: &Path) -> PathBuf {
    cache_dir(repo_path).join("graph_db")
}

/// Graph stats cache file path for a repository.
pub fn graph_stats_path(repo_path: &Path) -> PathBuf {
    cache_dir(repo_path).join("graph_stats.json")
}

/// Hash a path to create a unique but deterministic directory name.
/// Uses the canonical path to ensure consistency.
fn hash_path(path: &Path) -> String {
    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    let path_str = canonical.to_string_lossy();

    // Stable cross-version hash (#33). Using DefaultHasher instead of md5 crate.
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut hasher = DefaultHasher::new();
    path_str.as_bytes().hash(&mut hasher);
    let hash = format!("{:016x}", hasher.finish());

    // Use canonical path's file_name for consistent naming (important when path is ".")
    let repo_name = canonical
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("repo")
        .chars()
        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
        .take(20)
        .collect::<String>();

    format!("{}-{}", repo_name, &hash[..12])
}

/// Telemetry state file for a repository
pub fn telemetry_state_path(repo_path: &Path) -> PathBuf {
    cache_dir(repo_path).join("telemetry_state.json")
}

/// Benchmark cache directory (global, not per-repo)
pub fn benchmark_cache_dir() -> PathBuf {
    dirs::cache_dir()
        .unwrap_or_else(|| PathBuf::from(".cache"))
        .join("repotoire")
        .join("benchmarks")
}

/// Ensure the cache directory exists.
pub fn ensure_cache_dir(repo_path: &Path) -> std::io::Result<PathBuf> {
    let cache_dir = cache_dir(repo_path);
    std::fs::create_dir_all(&cache_dir)?;
    Ok(cache_dir)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hash_path_deterministic() {
        let path = Path::new("/tmp/test-repo");
        let hash1 = hash_path(path);
        let hash2 = hash_path(path);
        assert_eq!(hash1, hash2);
    }

    #[test]
    fn test_cache_dir_format() {
        let path = Path::new("/home/user/my-project");
        let cache = cache_dir(path);
        assert!(cache.to_string_lossy().contains("repotoire"));
        assert!(cache.to_string_lossy().contains("my-project"));
    }

    #[test]
    fn test_health_cache_path() {
        let path = Path::new("/home/user/my-project");
        let health_path = health_cache_path(path);
        assert!(health_path.to_string_lossy().ends_with("last_health.json"));
        assert!(health_path.to_string_lossy().contains("repotoire"));
    }
}