use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const CACHE_DIR: &str = ".zorto/cache";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedBlock {
pub hash: String,
pub output: Option<String>,
pub error: Option<String>,
#[serde(default)]
pub viz: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PageCache {
pub blocks: HashMap<String, CachedBlock>,
}
pub fn hash_source(source: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(source.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn cache_dir(site_root: &Path) -> PathBuf {
site_root.join(CACHE_DIR)
}
fn page_cache_file(site_root: &Path, page_key: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(page_key.as_bytes());
let name = format!("{:x}", hasher.finalize());
cache_dir(site_root).join(format!("{name}.json"))
}
pub fn load_page_cache(site_root: &Path, page_key: &str) -> Option<PageCache> {
let path = page_cache_file(site_root, page_key);
let data = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&data).ok()
}
pub fn save_page_cache(site_root: &Path, page_key: &str, cache: &PageCache) -> anyhow::Result<()> {
let path = page_cache_file(site_root, page_key);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(cache)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn clear_cache(site_root: &Path) -> anyhow::Result<()> {
let dir = cache_dir(site_root);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_hash_source_deterministic() {
let h1 = hash_source("print('hello')");
let h2 = hash_source("print('hello')");
assert_eq!(h1, h2);
}
#[test]
fn test_hash_source_different_inputs() {
let h1 = hash_source("print('hello')");
let h2 = hash_source("print('world')");
assert_ne!(h1, h2);
}
#[test]
fn test_save_and_load_page_cache() {
let tmp = TempDir::new().unwrap();
let page_key = "blog/post.md";
let mut cache = PageCache::default();
cache.blocks.insert(
"0".to_string(),
CachedBlock {
hash: hash_source("echo hello"),
output: Some("hello\n".to_string()),
error: None,
viz: Vec::new(),
},
);
save_page_cache(tmp.path(), page_key, &cache).unwrap();
let loaded = load_page_cache(tmp.path(), page_key).unwrap();
assert_eq!(loaded.blocks.len(), 1);
let block = &loaded.blocks["0"];
assert_eq!(block.output.as_deref(), Some("hello\n"));
assert!(block.error.is_none());
assert!(block.viz.is_empty());
}
#[test]
fn test_load_missing_cache_returns_none() {
let tmp = TempDir::new().unwrap();
assert!(load_page_cache(tmp.path(), "nonexistent.md").is_none());
}
#[test]
fn test_clear_cache() {
let tmp = TempDir::new().unwrap();
let cache = PageCache::default();
save_page_cache(tmp.path(), "test.md", &cache).unwrap();
assert!(cache_dir(tmp.path()).exists());
clear_cache(tmp.path()).unwrap();
assert!(!cache_dir(tmp.path()).exists());
}
#[test]
fn test_clear_cache_noop_when_missing() {
let tmp = TempDir::new().unwrap();
clear_cache(tmp.path()).unwrap();
}
#[test]
fn test_corrupted_json_returns_none() {
let tmp = TempDir::new().unwrap();
let page_key = "test.md";
let cache = PageCache::default();
save_page_cache(tmp.path(), page_key, &cache).unwrap();
let cache_file = page_cache_file(tmp.path(), page_key);
std::fs::write(&cache_file, "not valid json {{{").unwrap();
assert!(load_page_cache(tmp.path(), page_key).is_none());
}
#[test]
fn test_cache_hit_and_miss() {
let tmp = TempDir::new().unwrap();
let page_key = "test.md";
let source = "echo cached";
let source_hash = hash_source(source);
let mut cache = PageCache::default();
cache.blocks.insert(
"0".to_string(),
CachedBlock {
hash: source_hash.clone(),
output: Some("cached\n".to_string()),
error: None,
viz: Vec::new(),
},
);
save_page_cache(tmp.path(), page_key, &cache).unwrap();
let loaded = load_page_cache(tmp.path(), page_key).unwrap();
let block = &loaded.blocks["0"];
assert_eq!(block.hash, source_hash);
assert_eq!(block.output.as_deref(), Some("cached\n"));
let new_hash = hash_source("echo different");
assert_ne!(block.hash, new_hash);
}
}