Skip to main content

saf_core/
cache.rs

1//! Fingerprint-keyed disk cache for [`AirBundle`] results.
2//!
3//! Provides opt-in filesystem caching to avoid re-parsing/ingesting
4//! the same input files. Consumers choose when to use it — it is
5//! not wired into any frontend automatically.
6
7use std::path::{Path, PathBuf};
8
9use crate::air::AirBundle;
10
11/// Simple filesystem cache for [`AirBundle`] results.
12///
13/// Stores and retrieves [`AirBundle`] instances keyed by a BLAKE3
14/// fingerprint of the input. Cache entries are stored as JSON files
15/// in the configured directory.
16pub struct BundleCache {
17    cache_dir: PathBuf,
18}
19
20impl BundleCache {
21    /// Create a new cache backed by the given directory.
22    ///
23    /// The directory will be created on first write if it does not exist.
24    #[must_use]
25    pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
26        Self {
27            cache_dir: cache_dir.into(),
28        }
29    }
30
31    /// Try to load a cached bundle for the given fingerprint.
32    ///
33    /// Returns `None` if no cache entry exists or if deserialization fails.
34    #[must_use]
35    pub fn get(&self, fingerprint: &[u8]) -> Option<AirBundle> {
36        let key = hex_encode(fingerprint);
37        let path = self.cache_dir.join(format!("{key}.air.json"));
38        let data = std::fs::read_to_string(path).ok()?;
39        serde_json::from_str(&data).ok()
40    }
41
42    /// Store a bundle under the given fingerprint.
43    ///
44    /// Creates the cache directory if it does not exist.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if the cache directory cannot be created or the
49    /// bundle cannot be serialized/written.
50    pub fn put(&self, fingerprint: &[u8], bundle: &AirBundle) -> Result<(), std::io::Error> {
51        std::fs::create_dir_all(&self.cache_dir)?;
52        let key = hex_encode(fingerprint);
53        let path = self.cache_dir.join(format!("{key}.air.json"));
54        let data = serde_json::to_string(bundle)
55            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
56        std::fs::write(path, data)
57    }
58
59    /// Remove a cached entry for the given fingerprint.
60    ///
61    /// Returns `true` if a file was removed, `false` if it did not exist.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the file exists but cannot be removed.
66    pub fn remove(&self, fingerprint: &[u8]) -> Result<bool, std::io::Error> {
67        let key = hex_encode(fingerprint);
68        let path = self.cache_dir.join(format!("{key}.air.json"));
69        if path.exists() {
70            std::fs::remove_file(path)?;
71            Ok(true)
72        } else {
73            Ok(false)
74        }
75    }
76
77    /// Get the cache directory path.
78    #[must_use]
79    pub fn cache_dir(&self) -> &Path {
80        &self.cache_dir
81    }
82}
83
84/// Encode bytes as lowercase hex string (no `0x` prefix).
85fn hex_encode(bytes: &[u8]) -> String {
86    use std::fmt::Write;
87    bytes
88        .iter()
89        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
90            let _ = write!(s, "{b:02x}");
91            s
92        })
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::air::AirModule;
99    use crate::ids::ModuleId;
100
101    /// Helper to create a minimal `AirBundle` for tests.
102    fn minimal_bundle() -> AirBundle {
103        let module = AirModule::new(ModuleId::derive(b"cache_test"));
104        AirBundle::new("test", module)
105    }
106
107    #[test]
108    fn hex_encode_empty() {
109        assert_eq!(hex_encode(&[]), "");
110    }
111
112    #[test]
113    fn hex_encode_bytes() {
114        assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
115    }
116
117    #[test]
118    fn cache_miss_returns_none() {
119        let dir = tempfile::tempdir().unwrap();
120        let cache = BundleCache::new(dir.path());
121        assert!(cache.get(b"nonexistent").is_none());
122    }
123
124    #[test]
125    fn cache_roundtrip() {
126        let dir = tempfile::tempdir().unwrap();
127        let cache = BundleCache::new(dir.path());
128
129        let bundle = minimal_bundle();
130        let fingerprint = b"test_fingerprint_123";
131
132        cache.put(fingerprint, &bundle).unwrap();
133        let retrieved = cache.get(fingerprint).expect("should find cached bundle");
134        assert_eq!(retrieved, bundle);
135    }
136
137    #[test]
138    fn cache_remove() {
139        let dir = tempfile::tempdir().unwrap();
140        let cache = BundleCache::new(dir.path());
141
142        let bundle = minimal_bundle();
143        let fingerprint = b"removable";
144
145        cache.put(fingerprint, &bundle).unwrap();
146        assert!(cache.get(fingerprint).is_some());
147
148        assert!(cache.remove(fingerprint).unwrap());
149        assert!(cache.get(fingerprint).is_none());
150
151        // Removing again returns false
152        assert!(!cache.remove(fingerprint).unwrap());
153    }
154
155    #[test]
156    fn cache_creates_directory() {
157        let dir = tempfile::tempdir().unwrap();
158        let cache_dir = dir.path().join("nested").join("cache");
159        let cache = BundleCache::new(&cache_dir);
160
161        let bundle = minimal_bundle();
162        cache.put(b"test", &bundle).unwrap();
163
164        assert!(cache_dir.exists());
165    }
166}