use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSummary<T> {
pub project_id: String,
pub generation: String,
pub phase: u8,
#[serde(default)]
pub options_hash: Option<String>,
pub payload: T,
}
pub struct PhaseCache {
root: PathBuf,
}
impl PhaseCache {
pub fn new(project_root: &Path) -> Self {
Self {
root: project_root.join(".leindex").join("phase_cache"),
}
}
fn path_for(
&self,
project_id: &str,
generation: &str,
phase: u8,
options_hash: Option<&str>,
) -> PathBuf {
let suffix = options_hash.map(|h| format!("_{}", h)).unwrap_or_default();
self.root
.join(project_id)
.join(format!("{}_phase{}{}.json", generation, phase, suffix))
}
pub fn load<T: for<'de> Deserialize<'de>>(
&self,
project_id: &str,
generation: &str,
phase: u8,
) -> Result<Option<CachedSummary<T>>> {
self.load_with_options(project_id, generation, phase, None)
}
pub fn load_with_options<T: for<'de> Deserialize<'de>>(
&self,
project_id: &str,
generation: &str,
phase: u8,
options_hash: Option<&str>,
) -> Result<Option<CachedSummary<T>>> {
let path = self.path_for(project_id, generation, phase, options_hash);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
let cached: CachedSummary<T> = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(_) => {
let _ = fs::remove_file(&path);
return Ok(None);
}
};
if cached.project_id != project_id
|| cached.generation != generation
|| cached.phase != phase
|| cached.options_hash.as_deref() != options_hash
{
return Ok(None);
}
Ok(Some(cached))
}
pub fn save<T: Serialize>(
&self,
project_id: &str,
generation: &str,
phase: u8,
payload: &T,
) -> Result<()> {
self.save_with_options(project_id, generation, phase, None, payload)
}
pub fn save_with_options<T: Serialize>(
&self,
project_id: &str,
generation: &str,
phase: u8,
options_hash: Option<&str>,
payload: &T,
) -> Result<()> {
let path = self.path_for(project_id, generation, phase, options_hash);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let envelope = CachedSummary {
project_id: project_id.to_string(),
generation: generation.to_string(),
phase,
options_hash: options_hash.map(|v| v.to_string()),
payload,
};
fs::write(path, serde_json::to_vec_pretty(&envelope)?)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn cache_corruption_is_treated_as_miss() {
let dir = tempdir().expect("tempdir");
let cache = PhaseCache::new(dir.path());
let path = cache.path_for("proj", "gen", 1, None);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, b"{invalid json").expect("write corrupted cache");
let loaded = cache
.load::<serde_json::Value>("proj", "gen", 1)
.expect("load should not fail");
assert!(loaded.is_none(), "corrupted cache should return miss");
assert!(!path.exists(), "corrupted cache file should be removed");
}
#[test]
fn cache_envelope_mismatch_is_miss() {
let dir = tempdir().expect("tempdir");
let cache = PhaseCache::new(dir.path());
let path = cache.path_for("proj", "gen", 2, None);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
let payload = serde_json::json!({
"project_id": "other_project",
"generation": "gen",
"phase": 2,
"payload": {"ok": true}
});
std::fs::write(&path, serde_json::to_vec(&payload).expect("json")).expect("write");
let loaded = cache
.load::<serde_json::Value>("proj", "gen", 2)
.expect("load mismatch");
assert!(loaded.is_none());
}
#[test]
fn options_sensitive_cache_uses_hash_discriminator() {
let dir = tempdir().expect("tempdir");
let cache = PhaseCache::new(dir.path());
cache
.save_with_options(
"proj",
"gen",
3,
Some("abcd1234"),
&serde_json::json!({"n":1}),
)
.expect("save");
let hit = cache
.load_with_options::<serde_json::Value>("proj", "gen", 3, Some("abcd1234"))
.expect("load hit");
assert!(hit.is_some());
let miss = cache
.load_with_options::<serde_json::Value>("proj", "gen", 3, Some("deadbeef"))
.expect("load miss");
assert!(miss.is_none());
}
}