use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
const DEFAULT_TTL_SECS: u64 = 3600;
pub struct ConfigCache {
cache_dir: PathBuf,
ttl: Duration,
}
impl ConfigCache {
pub fn new() -> Self {
let cache_dir = dirs::home_dir()
.map(|h| h.join(".jarvy").join("cache").join("configs"))
.unwrap_or_else(|| PathBuf::from(".jarvy/cache/configs"));
Self {
cache_dir,
ttl: Duration::from_secs(DEFAULT_TTL_SECS),
}
}
pub fn with_ttl(mut self, ttl_secs: u64) -> Self {
self.ttl = Duration::from_secs(ttl_secs);
self
}
pub fn with_dir(mut self, dir: PathBuf) -> Self {
self.cache_dir = dir;
self
}
pub fn cache_dir(&self) -> &PathBuf {
&self.cache_dir
}
fn cache_key(&self, url: &str) -> String {
let hash = url
.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
format!("{:016x}.toml", hash)
}
pub fn cache_path(&self, url: &str) -> PathBuf {
self.cache_dir.join(self.cache_key(url))
}
pub fn get(&self, url: &str) -> Option<String> {
let path = self.cache_path(url);
if !path.exists() {
return None;
}
if let Ok(metadata) = fs::metadata(&path) {
if let Ok(modified) = metadata.modified() {
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::MAX);
if age > self.ttl {
return None;
}
}
}
fs::read_to_string(&path).ok()
}
pub fn get_stale(&self, url: &str) -> Option<String> {
let path = self.cache_path(url);
fs::read_to_string(&path).ok()
}
pub fn is_valid(&self, url: &str) -> bool {
self.get(url).is_some()
}
pub fn has_entry(&self, url: &str) -> bool {
self.cache_path(url).exists()
}
pub fn set(&self, url: &str, content: &str) -> Result<(), CacheError> {
let _: toml::Value = toml::from_str(content).map_err(|e| CacheError::InvalidToml {
url: url.to_string(),
error: e.to_string(),
})?;
if let Err(e) = fs::create_dir_all(&self.cache_dir) {
return Err(CacheError::IoError {
path: self.cache_dir.display().to_string(),
error: e.to_string(),
});
}
let path = self.cache_path(url);
fs::write(&path, content).map_err(|e| CacheError::IoError {
path: path.display().to_string(),
error: e.to_string(),
})?;
Ok(())
}
pub fn remove(&self, url: &str) -> Result<(), CacheError> {
let path = self.cache_path(url);
if path.exists() {
fs::remove_file(&path).map_err(|e| CacheError::IoError {
path: path.display().to_string(),
error: e.to_string(),
})?;
}
Ok(())
}
pub fn clear(&self) -> Result<usize, CacheError> {
if !self.cache_dir.exists() {
return Ok(0);
}
let mut count = 0;
for entry in fs::read_dir(&self.cache_dir)
.map_err(|e| CacheError::IoError {
path: self.cache_dir.display().to_string(),
error: e.to_string(),
})?
.flatten()
{
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") && fs::remove_file(&path).is_ok() {
count += 1;
}
}
Ok(count)
}
pub fn stats(&self) -> CacheStats {
let mut stats = CacheStats::default();
if !self.cache_dir.exists() {
return stats;
}
if let Ok(entries) = fs::read_dir(&self.cache_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") {
stats.total_entries += 1;
if let Ok(metadata) = fs::metadata(&path) {
stats.total_size += metadata.len();
if let Ok(modified) = metadata.modified() {
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::MAX);
if age <= self.ttl {
stats.valid_entries += 1;
}
}
}
}
}
}
stats
}
}
impl Default for ConfigCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Default)]
pub struct CacheStats {
pub total_entries: usize,
pub valid_entries: usize,
pub total_size: u64,
}
#[derive(Debug)]
pub enum CacheError {
InvalidToml { url: String, error: String },
IoError { path: String, error: String },
}
impl std::fmt::Display for CacheError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CacheError::InvalidToml { url, error } => {
write!(f, "Invalid TOML from '{}': {}", url, error)
}
CacheError::IoError { path, error } => {
write!(f, "I/O error at '{}': {}", path, error)
}
}
}
}
impl std::error::Error for CacheError {}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_cache() -> (ConfigCache, TempDir) {
let temp_dir = TempDir::new().unwrap();
let cache = ConfigCache::new().with_dir(temp_dir.path().to_path_buf());
(cache, temp_dir)
}
#[test]
fn test_cache_key_deterministic() {
let cache = ConfigCache::new();
let key1 = cache.cache_key("https://example.com/config.toml");
let key2 = cache.cache_key("https://example.com/config.toml");
assert_eq!(key1, key2);
}
#[test]
fn test_cache_key_different_urls() {
let cache = ConfigCache::new();
let key1 = cache.cache_key("https://example.com/config.toml");
let key2 = cache.cache_key("https://other.com/config.toml");
assert_ne!(key1, key2);
}
#[test]
fn test_cache_set_get() {
let (cache, _temp) = test_cache();
let url = "https://example.com/test.toml";
let content = r#"
[provisioner]
git = "latest"
"#;
cache.set(url, content).expect("should cache valid TOML");
let cached = cache.get(url).expect("should get cached content");
assert_eq!(cached.trim(), content.trim());
}
#[test]
fn test_cache_invalid_toml() {
let (cache, _temp) = test_cache();
let url = "https://example.com/invalid.toml";
let content = "this is not valid TOML {{{";
let result = cache.set(url, content);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CacheError::InvalidToml { .. }
));
}
#[test]
fn test_cache_get_nonexistent() {
let (cache, _temp) = test_cache();
let result = cache.get("https://nonexistent.com/config.toml");
assert!(result.is_none());
}
#[test]
fn test_cache_remove() {
let (cache, _temp) = test_cache();
let url = "https://example.com/remove.toml";
let content = "[provisioner]\ngit = \"latest\"";
cache.set(url, content).unwrap();
assert!(cache.has_entry(url));
cache.remove(url).unwrap();
assert!(!cache.has_entry(url));
}
#[test]
fn test_cache_clear() {
let (cache, _temp) = test_cache();
for i in 0..5 {
let url = format!("https://example.com/config{}.toml", i);
let content = format!("[provisioner]\nvar{} = \"value\"", i);
cache.set(&url, &content).unwrap();
}
let cleared = cache.clear().unwrap();
assert_eq!(cleared, 5);
let stats = cache.stats();
assert_eq!(stats.total_entries, 0);
}
#[test]
fn test_cache_stats() {
let (cache, _temp) = test_cache();
for i in 0..3 {
let url = format!("https://example.com/stats{}.toml", i);
let content = format!("[provisioner]\nval{} = \"x\"", i);
cache.set(&url, &content).unwrap();
}
let stats = cache.stats();
assert_eq!(stats.total_entries, 3);
assert_eq!(stats.valid_entries, 3);
assert!(stats.total_size > 0);
}
#[test]
fn test_cache_ttl() {
let (cache, _temp) = test_cache();
let cache = cache.with_ttl(0);
let url = "https://example.com/ttl.toml";
let content = "[provisioner]\ngit = \"latest\"";
cache.set(url, content).unwrap();
assert!(cache.get(url).is_none());
assert!(cache.get_stale(url).is_some());
}
}