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).map_err(std::io::Error::other)?;
55        std::fs::write(path, data)
56    }
57
58    /// Remove a cached entry for the given fingerprint.
59    ///
60    /// Returns `true` if a file was removed, `false` if it did not exist.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if the file exists but cannot be removed.
65    pub fn remove(&self, fingerprint: &[u8]) -> Result<bool, std::io::Error> {
66        let key = hex_encode(fingerprint);
67        let path = self.cache_dir.join(format!("{key}.air.json"));
68        if path.exists() {
69            std::fs::remove_file(path)?;
70            Ok(true)
71        } else {
72            Ok(false)
73        }
74    }
75
76    /// Get the cache directory path.
77    #[must_use]
78    pub fn cache_dir(&self) -> &Path {
79        &self.cache_dir
80    }
81}
82
83/// Encode bytes as lowercase hex string (no `0x` prefix).
84fn hex_encode(bytes: &[u8]) -> String {
85    use std::fmt::Write;
86    bytes
87        .iter()
88        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
89            let _ = write!(s, "{b:02x}");
90            s
91        })
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::air::AirModule;
98    use crate::ids::ModuleId;
99
100    /// Helper to create a minimal `AirBundle` for tests.
101    fn minimal_bundle() -> AirBundle {
102        let module = AirModule::new(ModuleId::derive(b"cache_test"));
103        AirBundle::new("test", module)
104    }
105
106    #[test]
107    fn hex_encode_empty() {
108        assert_eq!(hex_encode(&[]), "");
109    }
110
111    #[test]
112    fn hex_encode_bytes() {
113        assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
114    }
115
116    #[test]
117    fn cache_miss_returns_none() {
118        let dir = tempfile::tempdir().unwrap();
119        let cache = BundleCache::new(dir.path());
120        assert!(cache.get(b"nonexistent").is_none());
121    }
122
123    #[test]
124    fn cache_roundtrip() {
125        let dir = tempfile::tempdir().unwrap();
126        let cache = BundleCache::new(dir.path());
127
128        let bundle = minimal_bundle();
129        let fingerprint = b"test_fingerprint_123";
130
131        cache.put(fingerprint, &bundle).unwrap();
132        let retrieved = cache.get(fingerprint).expect("should find cached bundle");
133        assert_eq!(retrieved, bundle);
134    }
135
136    #[test]
137    fn cache_remove() {
138        let dir = tempfile::tempdir().unwrap();
139        let cache = BundleCache::new(dir.path());
140
141        let bundle = minimal_bundle();
142        let fingerprint = b"removable";
143
144        cache.put(fingerprint, &bundle).unwrap();
145        assert!(cache.get(fingerprint).is_some());
146
147        assert!(cache.remove(fingerprint).unwrap());
148        assert!(cache.get(fingerprint).is_none());
149
150        // Removing again returns false
151        assert!(!cache.remove(fingerprint).unwrap());
152    }
153
154    #[test]
155    fn cache_creates_directory() {
156        let dir = tempfile::tempdir().unwrap();
157        let cache_dir = dir.path().join("nested").join("cache");
158        let cache = BundleCache::new(&cache_dir);
159
160        let bundle = minimal_bundle();
161        cache.put(b"test", &bundle).unwrap();
162
163        assert!(cache_dir.exists());
164    }
165}