Skip to main content

shape_vm/
bytecode_cache.rs

1//! Bytecode cache for compiled Shape modules
2//!
3//! Caches compiled bytecode programs on disk as `.shapec` files,
4//! keyed by the SHA-256 hash of the source content + compiler version.
5//! This avoids redundant recompilation when source files haven't changed.
6
7use sha2::{Digest, Sha256};
8use std::path::PathBuf;
9
10use crate::bytecode::BytecodeProgram;
11
12/// Compiler version embedded in cache keys to invalidate on upgrades
13const COMPILER_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15/// On-disk bytecode cache stored under `~/.shape/cache/bytecode/`
16pub struct BytecodeCache {
17    cache_dir: PathBuf,
18}
19
20impl BytecodeCache {
21    /// Create a new bytecode cache, creating the cache directory if needed.
22    ///
23    /// The cache lives at `~/.shape/cache/bytecode/`. Returns `None` if the
24    /// home directory cannot be determined or the directory cannot be created.
25    pub fn new() -> Option<Self> {
26        let home = dirs::home_dir()?;
27        let cache_dir = home.join(".shape").join("cache").join("bytecode");
28        std::fs::create_dir_all(&cache_dir).ok()?;
29        Some(Self { cache_dir })
30    }
31
32    /// Create a cache at a specific directory (for testing).
33    pub fn with_dir(cache_dir: PathBuf) -> std::io::Result<Self> {
34        std::fs::create_dir_all(&cache_dir)?;
35        Ok(Self { cache_dir })
36    }
37
38    /// Look up cached bytecode for the given source content.
39    ///
40    /// Returns `Some(program)` on cache hit, `None` on miss or deserialization error.
41    pub fn get(&self, source: &str) -> Option<BytecodeProgram> {
42        let key = Self::cache_key(source);
43        let path = self.cache_path(&key);
44        let data = std::fs::read(&path).ok()?;
45        rmp_serde::from_slice(&data).ok()
46    }
47
48    /// Store compiled bytecode for the given source content.
49    pub fn put(&self, source: &str, program: &BytecodeProgram) -> std::io::Result<()> {
50        let key = Self::cache_key(source);
51        let path = self.cache_path(&key);
52        let data = rmp_serde::to_vec(program)
53            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
54        std::fs::write(&path, data)
55    }
56
57    /// Remove all cached bytecode files.
58    pub fn clear(&self) -> std::io::Result<()> {
59        for entry in std::fs::read_dir(&self.cache_dir)? {
60            let entry = entry?;
61            if entry
62                .path()
63                .extension()
64                .map_or(false, |ext| ext == "shapec")
65            {
66                std::fs::remove_file(entry.path())?;
67            }
68        }
69        Ok(())
70    }
71
72    /// Compute the cache key for a source string.
73    ///
74    /// Key = SHA-256(source_content + "\0" + compiler_version) as hex.
75    fn cache_key(source: &str) -> String {
76        let mut hasher = Sha256::new();
77        hasher.update(source.as_bytes());
78        hasher.update(b"\0");
79        hasher.update(COMPILER_VERSION.as_bytes());
80        format!("{:x}", hasher.finalize())
81    }
82
83    /// Map a cache key to a file path: `<cache_dir>/<key>.shapec`
84    fn cache_path(&self, key: &str) -> PathBuf {
85        self.cache_dir.join(format!("{}.shapec", key))
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn temp_cache() -> (BytecodeCache, tempfile::TempDir) {
94        let tmp = tempfile::tempdir().unwrap();
95        let cache = BytecodeCache::with_dir(tmp.path().join("bytecode")).unwrap();
96        (cache, tmp)
97    }
98
99    #[test]
100    fn test_put_get_roundtrip() {
101        let (cache, _tmp) = temp_cache();
102        let program = BytecodeProgram::new();
103        cache.put("let x = 1", &program).unwrap();
104        let cached = cache.get("let x = 1");
105        assert!(cached.is_some(), "Cache hit expected after put");
106    }
107
108    #[test]
109    fn test_cache_miss() {
110        let (cache, _tmp) = temp_cache();
111        let result = cache.get("nonexistent source");
112        assert!(result.is_none(), "Cache miss expected for unknown source");
113    }
114
115    #[test]
116    fn test_different_source_different_key() {
117        let (cache, _tmp) = temp_cache();
118        let program = BytecodeProgram::new();
119        cache.put("let x = 1", &program).unwrap();
120        let result = cache.get("let x = 2");
121        assert!(result.is_none(), "Different source should miss cache");
122    }
123
124    #[test]
125    fn test_clear() {
126        let (cache, _tmp) = temp_cache();
127        let program = BytecodeProgram::new();
128        cache.put("source_a", &program).unwrap();
129        cache.put("source_b", &program).unwrap();
130
131        cache.clear().unwrap();
132
133        assert!(
134            cache.get("source_a").is_none(),
135            "Cache should be empty after clear"
136        );
137        assert!(
138            cache.get("source_b").is_none(),
139            "Cache should be empty after clear"
140        );
141    }
142
143    #[test]
144    fn test_cache_key_deterministic() {
145        let key1 = BytecodeCache::cache_key("hello");
146        let key2 = BytecodeCache::cache_key("hello");
147        assert_eq!(key1, key2, "Same source should produce same key");
148    }
149
150    #[test]
151    fn test_cache_key_different_for_different_source() {
152        let key1 = BytecodeCache::cache_key("hello");
153        let key2 = BytecodeCache::cache_key("world");
154        assert_ne!(key1, key2, "Different source should produce different key");
155    }
156}