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 block_cache_key(
language: &str,
source: &str,
file_ref: Option<&str>,
working_dir: &Path,
) -> String {
let Some(file) = file_ref else {
return hash_source(&format!("{language}:{source}"));
};
let mut hasher = Sha256::new();
hasher.update(b"v1\x00");
hasher.update(language.as_bytes());
hasher.update(b"\x00");
hasher.update(source.as_bytes());
hasher.update(b"\x00file=");
hasher.update(file.as_bytes());
hasher.update(b"\x00");
match std::fs::read(working_dir.join(file)) {
Ok(bytes) => hasher.update(&bytes),
Err(_) => hasher.update(b"<unreadable>"),
}
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);
}
#[test]
fn test_hash_source_deterministic_across_calls() {
let source = "fn main() { println!(\"hello\"); }";
let reference = hash_source(source);
for _ in 0..100 {
assert_eq!(hash_source(source), reference);
}
}
#[test]
fn test_block_cache_key_inline_matches_legacy() {
let tmp = TempDir::new().unwrap();
let key = block_cache_key("python", "print('hi')", None, tmp.path());
let legacy = hash_source("python:print('hi')");
assert_eq!(key, legacy);
}
#[test]
fn test_block_cache_key_file_ref_includes_contents() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.py"), "print('a')").unwrap();
std::fs::write(tmp.path().join("b.py"), "print('b')").unwrap();
let key_a = block_cache_key("python", "", Some("a.py"), tmp.path());
let key_b = block_cache_key("python", "", Some("b.py"), tmp.path());
assert_ne!(
key_a, key_b,
"two file_ref blocks pointing at different files must not collide"
);
}
#[test]
fn test_block_cache_key_busts_on_file_change() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("script.py");
std::fs::write(&path, "print('v1')").unwrap();
let key_v1 = block_cache_key("python", "", Some("script.py"), tmp.path());
std::fs::write(&path, "print('v2')").unwrap();
let key_v2 = block_cache_key("python", "", Some("script.py"), tmp.path());
assert_ne!(
key_v1, key_v2,
"editing the referenced file must invalidate the cache key"
);
}
#[test]
fn test_block_cache_key_unreadable_file_is_deterministic() {
let tmp = TempDir::new().unwrap();
let key1 = block_cache_key("python", "", Some("missing.py"), tmp.path());
let key2 = block_cache_key("python", "", Some("missing.py"), tmp.path());
assert_eq!(key1, key2, "missing-file key must be deterministic");
std::fs::write(tmp.path().join("missing.py"), "print('appeared')").unwrap();
let key3 = block_cache_key("python", "", Some("missing.py"), tmp.path());
assert_ne!(
key1, key3,
"key must change once the previously-missing file appears"
);
}
#[test]
fn test_block_cache_key_inline_vs_file_ref_distinct() {
let tmp = TempDir::new().unwrap();
let body = "print('hi')";
std::fs::write(tmp.path().join("x.py"), body).unwrap();
let inline = block_cache_key("python", body, None, tmp.path());
let from_file = block_cache_key("python", "", Some("x.py"), tmp.path());
assert_ne!(inline, from_file);
}
}