Skip to main content

snapper_fmt/parser/pandoc/
cache.rs

1//! Content-addressed cache of pandoc JSON ASTs.
2//!
3//! Obtain-AST dominates cost. After the first successful parse of a given
4//! (format, source) pair, reuse JSON from:
5//! 1. Process-local memory (hash → Arc<str>)
6//! 2. Disk under `$SNAPPER_PANDOC_CACHE_DIR` or
7//!    `$XDG_CACHE_HOME/snapper/pandoc-ast` (disable with `SNAPPER_PANDOC_CACHE=0`)
8//!
9//! Keys are SHA-256 over `format \\0 input`. Values are raw pandoc JSON bytes.
10//! This is the practical speed lever for CLI re-runs and multi-file batches
11//! without changing the AST→region model.
12
13use std::collections::HashMap;
14use std::fs;
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex, OnceLock};
17
18use sha2::{Digest, Sha256};
19
20static MEM: OnceLock<Mutex<HashMap<[u8; 32], Arc<str>>>> = OnceLock::new();
21
22fn mem() -> &'static Mutex<HashMap<[u8; 32], Arc<str>>> {
23    MEM.get_or_init(|| Mutex::new(HashMap::new()))
24}
25
26/// Stable key for (format, input).
27pub fn cache_key(format: &str, input: &str) -> [u8; 32] {
28    let mut h = Sha256::new();
29    h.update(format.as_bytes());
30    h.update([0u8]);
31    h.update(input.as_bytes());
32    h.finalize().into()
33}
34
35fn cache_enabled() -> bool {
36    match std::env::var("SNAPPER_PANDOC_CACHE") {
37        Ok(v) => {
38            let v = v.to_ascii_lowercase();
39            !(v == "0" || v == "off" || v == "false" || v == "no")
40        }
41        Err(_) => true,
42    }
43}
44
45fn disk_dir() -> Option<PathBuf> {
46    if !cache_enabled() {
47        return None;
48    }
49    if let Ok(p) = std::env::var("SNAPPER_PANDOC_CACHE_DIR") {
50        return Some(PathBuf::from(p));
51    }
52    // Prefer XDG, then home, then temp.
53    if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
54        return Some(PathBuf::from(xdg).join("snapper/pandoc-ast"));
55    }
56    if let Ok(home) = std::env::var("HOME") {
57        return Some(PathBuf::from(home).join(".cache/snapper/pandoc-ast"));
58    }
59    Some(std::env::temp_dir().join("snapper-pandoc-ast"))
60}
61
62fn disk_path(key: &[u8; 32]) -> Option<PathBuf> {
63    let dir = disk_dir()?;
64    let name = key.iter().map(|b| format!("{b:02x}")).collect::<String>();
65    Some(dir.join(name).with_extension("json"))
66}
67
68/// Look up cached pandoc JSON for (format, input).
69pub fn get_json(format: &str, input: &str) -> Option<Arc<str>> {
70    if !cache_enabled() {
71        return None;
72    }
73    let key = cache_key(format, input);
74    if let Ok(guard) = mem().lock() {
75        if let Some(v) = guard.get(&key) {
76            return Some(Arc::clone(v));
77        }
78    }
79    let path = disk_path(&key)?;
80    let bytes = fs::read(&path).ok()?;
81    let s = String::from_utf8(bytes).ok()?;
82    let arc: Arc<str> = Arc::from(s);
83    if let Ok(mut guard) = mem().lock() {
84        guard.insert(key, Arc::clone(&arc));
85    }
86    Some(arc)
87}
88
89/// Store pandoc JSON for (format, input) in memory and on disk.
90pub fn put_json(format: &str, input: &str, json: &str) {
91    if !cache_enabled() {
92        return;
93    }
94    let key = cache_key(format, input);
95    let arc: Arc<str> = Arc::from(json);
96    if let Ok(mut guard) = mem().lock() {
97        guard.insert(key, Arc::clone(&arc));
98    }
99    if let Some(path) = disk_path(&key) {
100        if let Some(parent) = path.parent() {
101            let _ = fs::create_dir_all(parent);
102        }
103        let _ = fs::write(path, arc.as_bytes());
104    }
105}
106
107/// Clear process-local memory cache (tests / benchmarks).
108pub fn clear_memory() {
109    if let Ok(mut guard) = mem().lock() {
110        guard.clear();
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn cache_roundtrip_memory() {
120        // Unique key so parallel tests / disk under default XDG cannot poison the miss.
121        let fmt = "markdown";
122        let input = format!(
123            "Hello cache unit-{}-{}. Second.",
124            std::process::id(),
125            std::time::SystemTime::now()
126                .duration_since(std::time::UNIX_EPOCH)
127                .map(|d| d.as_nanos())
128                .unwrap_or(0)
129        );
130        let dir = std::env::temp_dir().join(format!("snapper-cache-ut-{}", std::process::id()));
131        let _ = fs::create_dir_all(&dir);
132        // SAFETY: test-only env isolation for cache dir.
133        unsafe {
134            std::env::set_var("SNAPPER_PANDOC_CACHE", "1");
135            std::env::set_var("SNAPPER_PANDOC_CACHE_DIR", &dir);
136        }
137        clear_memory();
138        assert!(get_json(fmt, &input).is_none());
139        put_json(
140            fmt,
141            &input,
142            r#"{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[]}"#,
143        );
144        let hit = get_json(fmt, &input).expect("memory hit");
145        assert!(hit.contains("pandoc-api-version"));
146        clear_memory();
147        let _ = fs::remove_dir_all(&dir);
148    }
149
150    #[test]
151    fn different_inputs_different_keys() {
152        assert_ne!(cache_key("markdown", "a"), cache_key("markdown", "b"));
153        assert_ne!(cache_key("org", "a"), cache_key("markdown", "a"));
154    }
155}