Skip to main content

batuta/stack/publish_status/
cache.rs

1//! Cache implementation for publish status.
2//!
3//! Provides persistent caching with content-addressable keys for O(1) lookups.
4
5use anyhow::{anyhow, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::hash::{DefaultHasher, Hash, Hasher};
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use super::types::CacheEntry;
13
14// ============================================================================
15// PUB-002: Cache Implementation
16// ============================================================================
17
18/// Persistent cache for publish status
19#[derive(Debug, Default, Serialize, Deserialize)]
20pub struct PublishStatusCache {
21    /// Cache entries by crate name
22    pub(crate) entries: HashMap<String, CacheEntry>,
23    /// Cache file path
24    #[serde(skip)]
25    pub(crate) cache_path: Option<PathBuf>,
26}
27
28impl PublishStatusCache {
29    /// Default cache path
30    pub(crate) fn default_cache_path() -> PathBuf {
31        dirs::cache_dir()
32            .unwrap_or_else(|| PathBuf::from("."))
33            .join("batuta")
34            .join("publish-status.json")
35    }
36
37    /// Load cache from disk
38    #[must_use]
39    pub fn load() -> Self {
40        let path = Self::default_cache_path();
41        Self::load_from(&path).unwrap_or_default()
42    }
43
44    /// Load cache from specific path
45    pub fn load_from(path: &Path) -> Result<Self> {
46        if !path.exists() {
47            return Ok(Self::default());
48        }
49        let data = std::fs::read_to_string(path)?;
50        let mut cache: Self = serde_json::from_str(&data)?;
51        cache.cache_path = Some(path.to_path_buf());
52        Ok(cache)
53    }
54
55    /// Save cache to disk
56    pub fn save(&self) -> Result<()> {
57        let path = self.cache_path.clone().unwrap_or_else(Self::default_cache_path);
58        if let Some(parent) = path.parent() {
59            std::fs::create_dir_all(parent)?;
60        }
61        let data = serde_json::to_string_pretty(self)?;
62        std::fs::write(&path, data)?;
63        Ok(())
64    }
65
66    /// Get cached entry if valid
67    #[must_use]
68    pub fn get(&self, name: &str, cache_key: &str) -> Option<&CacheEntry> {
69        self.entries.get(name).filter(|e| e.cache_key == cache_key)
70    }
71
72    /// Insert or update entry
73    pub fn insert(&mut self, name: String, entry: CacheEntry) {
74        self.entries.insert(name, entry);
75    }
76
77    /// Clear all entries
78    pub fn clear(&mut self) {
79        self.entries.clear();
80    }
81}
82
83// ============================================================================
84// PUB-003: Cache Key Computation
85// ============================================================================
86
87/// Compute cache key for a repo
88/// Key = hash(Cargo.toml content || git HEAD SHA || Cargo.toml mtime)
89pub fn compute_cache_key(repo_path: &Path) -> Result<String> {
90    let cargo_toml = repo_path.join("Cargo.toml");
91
92    if !cargo_toml.exists() {
93        return Err(anyhow!("No Cargo.toml found at {:?}", repo_path));
94    }
95
96    // Read Cargo.toml content
97    let content = std::fs::read(&cargo_toml)?;
98
99    // Get mtime
100    let mtime = std::fs::metadata(&cargo_toml)?
101        .modified()?
102        .duration_since(UNIX_EPOCH)
103        .unwrap_or_default()
104        .as_secs();
105
106    // Get git HEAD (if available)
107    let head_sha = get_git_head(repo_path).unwrap_or_else(|_| "no-git".to_string());
108
109    // Compute hash using DefaultHasher (fast, good enough for cache keys)
110    let mut hasher = DefaultHasher::new();
111    content.hash(&mut hasher);
112    head_sha.hash(&mut hasher);
113    mtime.hash(&mut hasher);
114
115    Ok(format!("{:016x}", hasher.finish()))
116}
117
118/// Get git HEAD commit SHA
119pub(crate) fn get_git_head(repo_path: &Path) -> Result<String> {
120    let output = std::process::Command::new("git")
121        .args(["rev-parse", "--short", "HEAD"])
122        .current_dir(repo_path)
123        .output()?;
124
125    if !output.status.success() {
126        return Err(anyhow!("git rev-parse failed"));
127    }
128
129    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
130}