use std::path::Path;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use anyhow::Result;
use rkyv::rancor::Error as RkyvError;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use crate::template::catalog::TemplateCatalog;
use earl_core::with::AsJson;
pub const CACHE_VERSION: u32 = 2;
#[derive(Archive, RkyvSerialize, RkyvDeserialize)]
pub struct CacheFile {
pub version: u32,
#[rkyv(with = AsJson)]
pub fingerprint: Vec<(PathBuf, u64)>,
pub catalog: TemplateCatalog,
}
pub fn collect_fingerprint(global_dir: &Path, local_dir: &Path) -> Result<Vec<(PathBuf, u64)>> {
let mut entries: Vec<(PathBuf, u64)> = Vec::new();
for dir in [global_dir, local_dir] {
for path in super::files::template_files_in_dir(dir)? {
let mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
entries.push((path, mtime));
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries.dedup_by(|a, b| a.0 == b.0);
Ok(entries)
}
pub fn try_load_cache(
cache_path: &Path,
fingerprint: &[(PathBuf, u64)],
) -> Option<TemplateCatalog> {
let bytes = std::fs::read(cache_path).ok()?;
let cached: CacheFile = rkyv::from_bytes::<CacheFile, RkyvError>(&bytes).ok()?;
if cached.version != CACHE_VERSION {
return None;
}
if cached.fingerprint != fingerprint {
return None;
}
Some(cached.catalog)
}
pub fn save_cache(
cache_path: &Path,
fingerprint: &[(PathBuf, u64)],
catalog: &TemplateCatalog,
) -> Result<()> {
let file = CacheFile {
version: CACHE_VERSION,
fingerprint: fingerprint.to_vec(),
catalog: catalog.clone(),
};
let bytes = rkyv::to_bytes::<RkyvError>(&file)?;
let tmp = cache_path.with_extension(format!("{}.tmp", std::process::id()));
std::fs::write(&tmp, &bytes)?;
if let Err(e) = std::fs::rename(&tmp, cache_path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::template::catalog::TemplateCatalog;
use std::path::PathBuf;
#[test]
fn empty_dirs_give_empty_fingerprint() {
let tmp = tempfile::tempdir().unwrap();
let fp = collect_fingerprint(tmp.path(), tmp.path()).unwrap();
assert!(fp.is_empty());
}
#[test]
fn fingerprint_changes_when_file_added() {
let tmp = tempfile::tempdir().unwrap();
let fp1 = collect_fingerprint(tmp.path(), tmp.path()).unwrap();
std::fs::write(tmp.path().join("new.hcl"), "content").unwrap();
let fp2 = collect_fingerprint(tmp.path(), tmp.path()).unwrap();
assert_ne!(fp1, fp2);
}
#[test]
fn fingerprint_has_one_entry_for_one_hcl_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("new.hcl"), "content").unwrap();
let fp = collect_fingerprint(tmp.path(), tmp.path()).unwrap();
assert_eq!(fp.len(), 1);
}
#[test]
fn saved_catalog_is_returned_on_load() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join("catalog-1.bin");
let fp = vec![(PathBuf::from("/tmp/foo.hcl"), 12345u64)];
save_cache(&cache_path, &fp, &TemplateCatalog::empty()).unwrap();
let result = try_load_cache(&cache_path, &fp);
assert!(result.is_some());
}
#[test]
fn stale_mtime_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join("catalog-1.bin");
let fp = vec![(PathBuf::from("/tmp/foo.hcl"), 12345u64)];
save_cache(&cache_path, &fp, &TemplateCatalog::empty()).unwrap();
let stale = vec![(PathBuf::from("/tmp/foo.hcl"), 99999u64)];
assert!(try_load_cache(&cache_path, &stale).is_none());
}
#[test]
fn version_mismatch_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join("catalog-1.bin");
let fp = vec![(PathBuf::from("/tmp/foo.hcl"), 12345u64)];
let file = CacheFile {
version: CACHE_VERSION + 1,
fingerprint: fp.clone(),
catalog: TemplateCatalog::empty(),
};
let bytes = rkyv::to_bytes::<RkyvError>(&file).unwrap();
std::fs::write(&cache_path, &bytes).unwrap();
assert!(try_load_cache(&cache_path, &fp).is_none());
}
#[test]
fn missing_cache_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join("catalog-1.bin");
assert!(try_load_cache(&cache_path, &[]).is_none());
}
#[test]
fn corrupt_cache_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join("catalog-1.bin");
std::fs::write(&cache_path, b"garbage").unwrap();
assert!(try_load_cache(&cache_path, &[]).is_none());
}
}