//! Local cache for remote claims (offline fallback). //! //! When remote mode is enabled, Aphoria caches fetched claims locally //! in `.aphoria/cache.toml`. This allows scans to continue when the //! remote server is unreachable. use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use serde::{Deserialize, Serialize}; use crate::types::AuthoredClaim; use crate::AphoriaError; /// Local cache for claims fetched from remote server. pub struct ClaimCache { cache_path: PathBuf, } /// Cache file structure (TOML). #[derive(Debug, Clone, Serialize, Deserialize)] struct ClaimCacheFile { /// Timestamp when cache was last updated (Unix seconds). last_updated: u64, /// URL of the remote server (for staleness detection). remote_url: String, /// Cached claims. claims: Vec, } impl ClaimCache { /// Create a new claim cache. /// /// Defaults to `.aphoria/cache.toml` in the current directory. pub fn new() -> Self { Self { cache_path: PathBuf::from(".aphoria/cache.toml") } } /// Create a claim cache with a custom path. pub fn with_path(cache_path: PathBuf) -> Self { Self { cache_path } } /// Save claims to the cache. pub fn save(&self, claims: &[AuthoredClaim], remote_url: &str) -> Result<(), AphoriaError> { let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map_err(|e| AphoriaError::Io(std::io::Error::other(e)))? .as_secs(); let cache = ClaimCacheFile { last_updated: now, remote_url: remote_url.to_string(), claims: claims.to_vec(), }; // Ensure .aphoria directory exists if let Some(parent) = self.cache_path.parent() { std::fs::create_dir_all(parent)?; } let toml = toml::to_string_pretty(&cache) .map_err(|e| AphoriaError::Config(format!("Failed to serialize cache: {e}")))?; std::fs::write(&self.cache_path, toml)?; Ok(()) } /// Load claims from the cache. /// /// Returns an empty vector if the cache doesn't exist. pub fn load(&self) -> Result, AphoriaError> { if !self.cache_path.exists() { return Ok(vec![]); } let toml = std::fs::read_to_string(&self.cache_path)?; let cache: ClaimCacheFile = toml::from_str(&toml) .map_err(|e| AphoriaError::Config(format!("Failed to parse cache: {e}")))?; Ok(cache.claims) } /// Check if the cache is stale (older than max_age). pub fn is_stale(&self, max_age: Duration) -> bool { let metadata = match self.cache_path.metadata() { Ok(m) => m, Err(_) => return true, // Missing cache is stale }; let modified = match metadata.modified() { Ok(t) => t, Err(_) => return true, // Can't get modified time = stale }; let age = match SystemTime::now().duration_since(modified) { Ok(d) => d, Err(_) => return true, // Clock skew = stale }; age > max_age } /// Get the cache path. pub fn path(&self) -> &Path { &self.cache_path } /// Check if the cache exists. pub fn exists(&self) -> bool { self.cache_path.exists() } } impl Default for ClaimCache { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; use crate::types::{AuthoredValue, ClaimStatus}; use std::time::Duration; use tempfile::TempDir; fn make_test_claim(id: &str) -> AuthoredClaim { AuthoredClaim { id: id.to_string(), concept_path: "test/path".to_string(), predicate: "enabled".to_string(), value: AuthoredValue::Bool(true), comparison: Default::default(), provenance: "test".to_string(), invariant: "test invariant".to_string(), consequence: "test consequence".to_string(), authority_tier: "expert".to_string(), evidence: vec![], category: "test".to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "test-user".to_string(), created_at: "2026-02-13T00:00:00Z".to_string(), updated_at: None, } } #[test] fn test_cache_save_and_load() { let temp_dir = TempDir::new().unwrap(); let cache_path = temp_dir.path().join("cache.toml"); let cache = ClaimCache::with_path(cache_path); let claims = vec![make_test_claim("test-001"), make_test_claim("test-002")]; // Save cache.save(&claims, "https://example.com").unwrap(); // Load let loaded = cache.load().unwrap(); assert_eq!(loaded.len(), 2); assert_eq!(loaded[0].id, "test-001"); assert_eq!(loaded[1].id, "test-002"); } #[test] fn test_cache_load_missing_returns_empty() { let temp_dir = TempDir::new().unwrap(); let cache_path = temp_dir.path().join("nonexistent.toml"); let cache = ClaimCache::with_path(cache_path); let loaded = cache.load().unwrap(); assert_eq!(loaded.len(), 0); } #[test] fn test_cache_staleness() { let temp_dir = TempDir::new().unwrap(); let cache_path = temp_dir.path().join("cache.toml"); let cache = ClaimCache::with_path(cache_path); // Save let claims = vec![make_test_claim("test-001")]; cache.save(&claims, "https://example.com").unwrap(); // Fresh cache is not stale assert!(!cache.is_stale(Duration::from_secs(3600))); // But it is stale if we set max_age to 0 assert!(cache.is_stale(Duration::from_secs(0))); } #[test] fn test_cache_nonexistent_is_stale() { let temp_dir = TempDir::new().unwrap(); let cache_path = temp_dir.path().join("nonexistent.toml"); let cache = ClaimCache::with_path(cache_path); assert!(cache.is_stale(Duration::from_secs(3600))); } #[test] fn test_cache_accessors() { let cache_path = PathBuf::from("/tmp/test-cache.toml"); let cache = ClaimCache::with_path(cache_path.clone()); assert_eq!(cache.path(), cache_path.as_path()); assert!(!cache.exists()); } }