use crate::model::ProbeResult;
use anyhow::{Context, Result};
use serde_json;
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};
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub cache_dir: PathBuf,
pub enabled: bool,
pub max_age_secs: Option<u64>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
cache_dir: default_cache_dir(),
enabled: true,
max_age_secs: Some(86400 * 7), }
}
}
pub fn default_cache_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".cache").join("help-probe")
}
pub fn generate_cache_key(program: &str, args: &[String]) -> String {
let mut hasher = DefaultHasher::new();
program.hash(&mut hasher);
args.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
fn cache_file_path(cache_dir: &Path, key: &str) -> PathBuf {
cache_dir.join(format!("{}.json", key))
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct CacheEntry {
timestamp: u64,
version_hash: String,
result: ProbeResult,
}
pub fn read_cache(
program: &str,
args: &[String],
config: &CacheConfig,
) -> Result<Option<ProbeResult>> {
if !config.enabled {
return Ok(None);
}
if !config.cache_dir.exists() {
fs::create_dir_all(&config.cache_dir)
.with_context(|| format!("Failed to create cache directory: {:?}", config.cache_dir))?;
}
let key = generate_cache_key(program, args);
let cache_file = cache_file_path(&config.cache_dir, &key);
if !cache_file.exists() {
return Ok(None);
}
let content = fs::read_to_string(&cache_file)
.with_context(|| format!("Failed to read cache file: {:?}", cache_file))?;
let entry: CacheEntry = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse cache file: {:?}", cache_file))?;
if let Some(max_age) = config.max_age_secs {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if now.saturating_sub(entry.timestamp) > max_age {
let _ = fs::remove_file(&cache_file);
return Ok(None);
}
}
let current_version_hash = compute_version_hash(&entry.result);
if current_version_hash != entry.version_hash {
let _ = fs::remove_file(&cache_file);
return Ok(None);
}
Ok(Some(entry.result))
}
pub fn write_cache(
program: &str,
args: &[String],
result: &ProbeResult,
config: &CacheConfig,
) -> Result<()> {
if !config.enabled {
return Ok(());
}
if !config.cache_dir.exists() {
fs::create_dir_all(&config.cache_dir)
.with_context(|| format!("Failed to create cache directory: {:?}", config.cache_dir))?;
}
let key = generate_cache_key(program, args);
let cache_file = cache_file_path(&config.cache_dir, &key);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let version_hash = compute_version_hash(result);
let entry = CacheEntry {
timestamp,
version_hash,
result: result.clone(),
};
let content =
serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
let temp_file = cache_file.with_extension("tmp");
fs::write(&temp_file, content)
.with_context(|| format!("Failed to write cache file: {:?}", temp_file))?;
fs::rename(&temp_file, &cache_file)
.with_context(|| format!("Failed to rename cache file: {:?}", cache_file))?;
Ok(())
}
fn compute_version_hash(result: &ProbeResult) -> String {
let mut hasher = DefaultHasher::new();
result.raw_stdout.hash(&mut hasher);
result.raw_stderr.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn clear_cache(
program: Option<&str>,
args: Option<&[String]>,
config: &CacheConfig,
) -> Result<usize> {
if !config.cache_dir.exists() {
return Ok(0);
}
let mut cleared = 0;
if let Some(prog) = program {
let key = if let Some(arg_list) = args {
generate_cache_key(prog, arg_list)
} else {
return clear_all_cache(config);
};
let cache_file = cache_file_path(&config.cache_dir, &key);
if cache_file.exists() {
fs::remove_file(&cache_file)
.with_context(|| format!("Failed to remove cache file: {:?}", cache_file))?;
cleared = 1;
}
} else {
return clear_all_cache(config);
}
Ok(cleared)
}
fn clear_all_cache(config: &CacheConfig) -> Result<usize> {
if !config.cache_dir.exists() {
return Ok(0);
}
let mut cleared = 0;
let entries = fs::read_dir(&config.cache_dir)
.with_context(|| format!("Failed to read cache directory: {:?}", config.cache_dir))?;
for entry in entries {
let entry = entry.context("Failed to read cache directory entry")?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
fs::remove_file(&path)
.with_context(|| format!("Failed to remove cache file: {:?}", path))?;
cleared += 1;
}
}
Ok(cleared)
}