use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::core::recipe::{DetectionFailure, FileAnalysis};
const CACHE_DIR: &str = ".morph-cli/cache";
static CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
static CACHE_MISSES: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFileDetection {
pub hash: String,
pub metadata: CachedFileMetadata,
pub outcome: CachedDetectionOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFileMetadata {
pub path: PathBuf,
pub size: u64,
pub modified_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CachedDetectionOutcome {
Analysis(FileAnalysis),
Skipped(PathBuf),
Failure(DetectionFailure),
}
#[derive(Debug, Clone, Copy)]
pub struct CacheStats {
pub hits: usize,
pub misses: usize,
}
pub fn load_detection(recipe_name: &str, path: &Path) -> Option<CachedDetectionOutcome> {
let cache_path = cache_path(recipe_name, path);
if !cache_path.exists() {
return None;
}
let content = fs::read_to_string(&cache_path).ok()?;
let cached = serde_json::from_str::<CachedFileDetection>(&content).ok()?;
if let Ok(meta) = fs::metadata(path) {
let modified_secs = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or_default();
if meta.len() == cached.metadata.size && modified_secs == cached.metadata.modified_secs {
CACHE_HITS.fetch_add(1, Ordering::Relaxed);
return Some(cached.outcome);
}
}
let hash = file_hash(path).ok()?;
if cached.hash == hash {
CACHE_HITS.fetch_add(1, Ordering::Relaxed);
Some(cached.outcome)
} else {
CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
None
}
}
pub fn save_detection(
recipe_name: &str,
path: &Path,
outcome: &CachedDetectionOutcome,
) -> Result<()> {
let cache_path = cache_path(recipe_name, path);
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
let cached = CachedFileDetection {
hash: file_hash(path)?,
metadata: file_metadata(path)?,
outcome: outcome.clone(),
};
let json = serde_json::to_string_pretty(&cached).context("failed to serialize cache entry")?;
fs::write(cache_path, json).context("failed to write cache entry")?;
Ok(())
}
pub fn record_miss() {
CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
}
pub fn stats() -> CacheStats {
CacheStats {
hits: CACHE_HITS.load(Ordering::Relaxed),
misses: CACHE_MISSES.load(Ordering::Relaxed),
}
}
pub fn print_stats() {
let stats = stats();
println!("cache hits: {}", stats.hits);
println!("cache misses: {}", stats.misses);
}
fn cache_path(recipe_name: &str, path: &Path) -> PathBuf {
PathBuf::from(CACHE_DIR)
.join(sanitize(recipe_name))
.join(format!("{}.json", sanitize(&path.to_string_lossy())))
}
fn sanitize(value: &str) -> String {
value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
.collect()
}
fn file_hash(path: &Path) -> Result<String> {
let content = fs::read(path)?;
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Ok(format!("{:016x}", hasher.finish()))
}
fn file_metadata(path: &Path) -> Result<CachedFileMetadata> {
let metadata = fs::metadata(path)?;
let modified_secs = metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
Ok(CachedFileMetadata {
path: path.to_path_buf(),
size: metadata.len(),
modified_secs,
})
}