Skip to main content

sui_cache_eval/
lib.rs

1//! Content-addressed evaluation cache.
2//!
3//! Caches forced Nix expression results by hashing the source content
4//! and (optionally) the flake lock. Lookup is O(1) via `HashMap`.
5//! Persistence is best-effort JSON to `~/.cache/sui/eval-cache.json`.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Errors that can occur during cache operations.
14#[derive(Error, Debug)]
15pub enum CacheError {
16    /// Filesystem I/O failure.
17    #[error("I/O error: {0}")]
18    Io(#[from] std::io::Error),
19    /// JSON (de)serialization failure.
20    #[error("serialization error: {0}")]
21    Serde(#[from] serde_json::Error),
22}
23
24/// A cache key combining source hash and optional lock hash.
25#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
26pub struct CacheKey {
27    /// BLAKE3 hash of the source expression.
28    pub source_hash: String,
29    /// BLAKE3 hash of `flake.lock` (if present).
30    pub lock_hash: Option<String>,
31}
32
33/// A cached evaluation result.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CachedValue {
36    /// JSON representation of the evaluated value.
37    pub value_json: String,
38    /// Unix timestamp when the value was cached.
39    pub timestamp: i64,
40}
41
42/// Content-addressed evaluation cache.
43///
44/// In-memory `HashMap` backed by optional persistent JSON file.
45pub struct EvalCache {
46    memory: HashMap<CacheKey, CachedValue>,
47    db_path: Option<PathBuf>,
48    enabled: bool,
49}
50
51impl EvalCache {
52    /// Create a new cache. If `persist` is true, loads/saves to
53    /// `~/.cache/sui/eval-cache.json`.
54    #[must_use]
55    pub fn new(persist: bool) -> Self {
56        let db_path = if persist {
57            dirs_next::cache_dir().map(|d| d.join("sui").join("eval-cache.json"))
58        } else {
59            None
60        };
61
62        let memory = db_path
63            .as_ref()
64            .map_or_else(HashMap::new, |p| Self::load_from(p));
65
66        Self {
67            memory,
68            db_path,
69            enabled: true,
70        }
71    }
72
73    /// Create a cache with a custom database path (for testing).
74    #[must_use]
75    pub fn with_path(path: PathBuf) -> Self {
76        let memory = Self::load_from(&path);
77
78        Self {
79            memory,
80            db_path: Some(path),
81            enabled: true,
82        }
83    }
84
85    /// Compute a cache key for a file on disk.
86    ///
87    /// # Errors
88    ///
89    /// Returns `CacheError::Io` if the file (or lock file) cannot be read.
90    pub fn key_for_file(path: &Path, lock_path: Option<&Path>) -> Result<CacheKey, CacheError> {
91        let source = std::fs::read(path)?;
92        let source_hash = blake3::hash(&source).to_hex().to_string();
93
94        let lock_hash = if let Some(lp) = lock_path {
95            let lock = std::fs::read(lp)?;
96            Some(blake3::hash(&lock).to_hex().to_string())
97        } else {
98            None
99        };
100
101        Ok(CacheKey {
102            source_hash,
103            lock_hash,
104        })
105    }
106
107    /// Compute a cache key from a string expression.
108    #[must_use]
109    pub fn key_for_expr(expr: &str) -> CacheKey {
110        CacheKey {
111            source_hash: blake3::hash(expr.as_bytes()).to_hex().to_string(),
112            lock_hash: None,
113        }
114    }
115
116    /// Look up a cached value.
117    #[must_use]
118    pub fn get(&self, key: &CacheKey) -> Option<&CachedValue> {
119        if !self.enabled {
120            return None;
121        }
122        self.memory.get(key)
123    }
124
125    /// Store a value in the cache.
126    pub fn put(&mut self, key: CacheKey, value: CachedValue) {
127        if !self.enabled {
128            return;
129        }
130        self.memory.insert(key, value);
131        self.persist();
132    }
133
134    /// Number of cached entries.
135    #[must_use]
136    pub fn len(&self) -> usize {
137        self.memory.len()
138    }
139
140    /// Whether the cache is empty.
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        self.memory.is_empty()
144    }
145
146    /// Clear all cached entries.
147    pub fn clear(&mut self) {
148        self.memory.clear();
149        self.persist();
150    }
151
152    /// Load entries from a JSON file into the in-memory map.
153    fn load_from(path: &Path) -> HashMap<CacheKey, CachedValue> {
154        let Ok(data) = std::fs::read_to_string(path) else {
155            return HashMap::new();
156        };
157        let Ok(entries): Result<Vec<(CacheKey, CachedValue)>, _> = serde_json::from_str(&data)
158        else {
159            return HashMap::new();
160        };
161        entries.into_iter().collect()
162    }
163
164    /// Best-effort persist to disk.
165    fn persist(&self) {
166        if let Some(ref path) = self.db_path {
167            if let Some(parent) = path.parent() {
168                let _ = std::fs::create_dir_all(parent);
169            }
170            let entries: Vec<(&CacheKey, &CachedValue)> = self.memory.iter().collect();
171            if let Ok(json) = serde_json::to_string(&entries) {
172                let _ = std::fs::write(path, json);
173            }
174        }
175    }
176}
177
178impl Default for EvalCache {
179    fn default() -> Self {
180        Self::new(false)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn empty_cache() {
190        let cache = EvalCache::default();
191        assert!(cache.is_empty());
192        assert_eq!(cache.len(), 0);
193    }
194
195    #[test]
196    fn put_and_get() {
197        let mut cache = EvalCache::default();
198        let key = CacheKey {
199            source_hash: "abc".into(),
200            lock_hash: None,
201        };
202        let val = CachedValue {
203            value_json: "42".into(),
204            timestamp: 0,
205        };
206        cache.put(key.clone(), val);
207        assert_eq!(cache.get(&key).unwrap().value_json, "42");
208        assert_eq!(cache.len(), 1);
209    }
210
211    #[test]
212    fn key_for_expr_deterministic() {
213        let k1 = EvalCache::key_for_expr("1 + 2");
214        let k2 = EvalCache::key_for_expr("1 + 2");
215        let k3 = EvalCache::key_for_expr("1 + 3");
216        assert_eq!(k1, k2);
217        assert_ne!(k1, k3);
218    }
219
220    #[test]
221    fn different_lock_hash_is_different_key() {
222        let k1 = CacheKey {
223            source_hash: "same".into(),
224            lock_hash: Some("lock_a".into()),
225        };
226        let k2 = CacheKey {
227            source_hash: "same".into(),
228            lock_hash: Some("lock_b".into()),
229        };
230        assert_ne!(k1, k2);
231    }
232
233    #[test]
234    fn clear_removes_all() {
235        let mut cache = EvalCache::default();
236        cache.put(
237            CacheKey {
238                source_hash: "a".into(),
239                lock_hash: None,
240            },
241            CachedValue {
242                value_json: "1".into(),
243                timestamp: 0,
244            },
245        );
246        cache.put(
247            CacheKey {
248                source_hash: "b".into(),
249                lock_hash: None,
250            },
251            CachedValue {
252                value_json: "2".into(),
253                timestamp: 0,
254            },
255        );
256        assert_eq!(cache.len(), 2);
257        cache.clear();
258        assert!(cache.is_empty());
259    }
260
261    #[test]
262    fn disabled_cache_returns_none() {
263        let mut cache = EvalCache::default();
264        cache.enabled = false;
265        let key = CacheKey {
266            source_hash: "x".into(),
267            lock_hash: None,
268        };
269        cache.put(
270            key.clone(),
271            CachedValue {
272                value_json: "v".into(),
273                timestamp: 0,
274            },
275        );
276        assert!(cache.get(&key).is_none());
277        assert!(cache.is_empty());
278    }
279
280    #[test]
281    fn persistence_roundtrip() {
282        let dir = tempfile::TempDir::new().unwrap();
283        let path = dir.path().join("cache.json");
284
285        let key = CacheKey {
286            source_hash: "test".into(),
287            lock_hash: Some("lock123".into()),
288        };
289
290        // Write phase.
291        {
292            let mut cache = EvalCache::with_path(path.clone());
293            cache.put(
294                key.clone(),
295                CachedValue {
296                    value_json: r#""hello""#.into(),
297                    timestamp: 123,
298                },
299            );
300        }
301
302        // Read-back phase.
303        {
304            let cache = EvalCache::with_path(path);
305            let cached = cache.get(&key).expect("should load persisted entry");
306            assert_eq!(cached.value_json, r#""hello""#);
307            assert_eq!(cached.timestamp, 123);
308        }
309    }
310
311    #[test]
312    fn key_for_file_works() {
313        let dir = tempfile::TempDir::new().unwrap();
314        let src = dir.path().join("expr.nix");
315        let lock = dir.path().join("flake.lock");
316
317        std::fs::write(&src, "builtins.add 1 2").unwrap();
318        std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
319
320        let k1 = EvalCache::key_for_file(&src, Some(&lock)).unwrap();
321        let k2 = EvalCache::key_for_file(&src, Some(&lock)).unwrap();
322        assert_eq!(k1, k2);
323        assert!(k1.lock_hash.is_some());
324
325        // Without lock.
326        let k3 = EvalCache::key_for_file(&src, None).unwrap();
327        assert!(k3.lock_hash.is_none());
328        assert_eq!(k1.source_hash, k3.source_hash);
329    }
330
331    #[test]
332    fn key_for_file_missing_returns_error() {
333        let result = EvalCache::key_for_file(Path::new("/nonexistent/file.nix"), None);
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn overwrite_existing_key() {
339        let mut cache = EvalCache::default();
340        let key = CacheKey {
341            source_hash: "same".into(),
342            lock_hash: None,
343        };
344        cache.put(
345            key.clone(),
346            CachedValue {
347                value_json: "old".into(),
348                timestamp: 1,
349            },
350        );
351        cache.put(
352            key.clone(),
353            CachedValue {
354                value_json: "new".into(),
355                timestamp: 2,
356            },
357        );
358        assert_eq!(cache.len(), 1);
359        assert_eq!(cache.get(&key).unwrap().value_json, "new");
360        assert_eq!(cache.get(&key).unwrap().timestamp, 2);
361    }
362}