Skip to main content

trident/package/
cache.rs

1//! Compilation and verification cache for Trident.
2//!
3//! Caches are keyed by content hashes from `hash.rs`:
4//! - **Compilation cache**: (source hash, target) → compiled TASM + cost info
5//! - **Verification cache**: source hash → verification report
6//!
7//! Cache location: `~/.trident/cache/` (or `$TRIDENT_CACHE_DIR`)
8//!
9//! Layout:
10//! ```text
11//! ~/.trident/cache/
12//! ├── compile/
13//! │   └── <source_hash_hex>.<target>.tasm
14//! └── verify/
15//!     └── <source_hash_hex>.json
16//! ```
17//!
18//! Cache entries are append-only: once written, never modified. A hash
19//! uniquely identifies content, so the same hash always maps to the same
20//! result.
21
22use std::collections::BTreeMap;
23use std::path::PathBuf;
24
25use crate::hash::ContentHash;
26
27// ─── Cache Directory ───────────────────────────────────────────────
28
29/// Resolve the cache directory.
30///
31/// Priority:
32/// 1. `$TRIDENT_CACHE_DIR` environment variable
33/// 2. `~/.trident/cache/`
34pub fn cache_dir() -> Option<PathBuf> {
35    if let Ok(dir) = std::env::var("TRIDENT_CACHE_DIR") {
36        return Some(PathBuf::from(dir));
37    }
38
39    dirs_home().map(|home| home.join(".trident").join("cache"))
40}
41
42/// Get the user's home directory.
43fn dirs_home() -> Option<PathBuf> {
44    std::env::var("HOME").ok().map(PathBuf::from)
45}
46
47/// Ensure a cache subdirectory exists.
48fn ensure_cache_subdir(subdir: &str) -> Option<PathBuf> {
49    let dir = cache_dir()?.join(subdir);
50    std::fs::create_dir_all(&dir).ok()?;
51    Some(dir)
52}
53
54// ─── Compilation Cache ─────────────────────────────────────────────
55
56/// A cached compilation result.
57#[derive(Clone, Debug)]
58pub struct CachedCompilation {
59    /// The compiled TASM output.
60    pub tasm: String,
61    /// Padded height (if known).
62    pub padded_height: Option<u64>,
63}
64
65/// Look up a cached compilation result.
66pub fn lookup_compilation(source_hash: &ContentHash, target: &str) -> Option<CachedCompilation> {
67    let dir = cache_dir()?.join("compile");
68    let filename = format!("{}.{}.tasm", source_hash.to_hex(), target);
69    let path = dir.join(&filename);
70
71    let tasm = std::fs::read_to_string(&path).ok()?;
72
73    // Check for metadata file
74    let meta_path = dir.join(format!("{}.{}.meta", source_hash.to_hex(), target));
75    let padded_height = std::fs::read_to_string(&meta_path)
76        .ok()
77        .and_then(|s| s.trim().parse::<u64>().ok());
78
79    Some(CachedCompilation {
80        tasm,
81        padded_height,
82    })
83}
84
85/// Store a compilation result in the cache.
86pub fn store_compilation(
87    source_hash: &ContentHash,
88    target: &str,
89    tasm: &str,
90    padded_height: Option<u64>,
91) -> Result<PathBuf, String> {
92    let dir = ensure_cache_subdir("compile")
93        .ok_or_else(|| "cannot create cache directory".to_string())?;
94
95    let filename = format!("{}.{}.tasm", source_hash.to_hex(), target);
96    let path = dir.join(&filename);
97
98    // Don't overwrite existing cache entries (append-only semantics)
99    if path.exists() {
100        return Ok(path);
101    }
102
103    std::fs::write(&path, tasm).map_err(|e| format!("cannot write cache file: {}", e))?;
104
105    // Store metadata
106    if let Some(height) = padded_height {
107        let meta_path = dir.join(format!("{}.{}.meta", source_hash.to_hex(), target));
108        let _ = std::fs::write(&meta_path, height.to_string());
109    }
110
111    Ok(path)
112}
113
114// ─── Verification Cache ────────────────────────────────────────────
115
116/// A cached verification result.
117#[derive(Clone, Debug)]
118pub struct CachedVerification {
119    /// Whether the program was verified safe.
120    pub is_safe: bool,
121    /// Number of constraints.
122    pub constraints: usize,
123    /// Number of variables.
124    pub variables: u32,
125    /// Verdict string.
126    pub verdict: String,
127    /// Timestamp of verification.
128    pub timestamp: String,
129}
130
131impl CachedVerification {
132    /// Serialize to a simple text format.
133    fn serialize(&self) -> String {
134        format!(
135            "safe={}\nconstraints={}\nvariables={}\nverdict={}\ntimestamp={}\n",
136            self.is_safe, self.constraints, self.variables, self.verdict, self.timestamp,
137        )
138    }
139
140    /// Deserialize from text format.
141    fn deserialize(text: &str) -> Option<Self> {
142        let mut map = BTreeMap::new();
143        for line in text.lines() {
144            if let Some((key, value)) = line.split_once('=') {
145                map.insert(key.trim().to_string(), value.trim().to_string());
146            }
147        }
148
149        Some(CachedVerification {
150            is_safe: map.get("safe")?.parse().ok()?,
151            constraints: map.get("constraints")?.parse().ok()?,
152            variables: map.get("variables")?.parse().ok()?,
153            verdict: map.get("verdict")?.clone(),
154            timestamp: map.get("timestamp").cloned().unwrap_or_default(),
155        })
156    }
157}
158
159/// Look up a cached verification result.
160pub fn lookup_verification(source_hash: &ContentHash) -> Option<CachedVerification> {
161    let dir = cache_dir()?.join("verify");
162    let filename = format!("{}.verify", source_hash.to_hex());
163    let path = dir.join(&filename);
164
165    let text = std::fs::read_to_string(&path).ok()?;
166    CachedVerification::deserialize(&text)
167}
168
169/// Store a verification result in the cache.
170pub fn store_verification(
171    source_hash: &ContentHash,
172    result: &CachedVerification,
173) -> Result<PathBuf, String> {
174    let dir =
175        ensure_cache_subdir("verify").ok_or_else(|| "cannot create cache directory".to_string())?;
176
177    let filename = format!("{}.verify", source_hash.to_hex());
178    let path = dir.join(&filename);
179
180    // Don't overwrite existing cache entries
181    if path.exists() {
182        return Ok(path);
183    }
184
185    std::fs::write(&path, result.serialize())
186        .map_err(|e| format!("cannot write cache file: {}", e))?;
187
188    Ok(path)
189}
190
191// ─── Cache Statistics ──────────────────────────────────────────────
192
193/// Statistics about the cache.
194#[derive(Clone, Debug, Default)]
195pub struct CacheStats {
196    /// Number of cached compilations.
197    pub compilations: usize,
198    /// Number of cached verifications.
199    pub verifications: usize,
200    /// Total size in bytes.
201    pub total_bytes: u64,
202}
203
204/// Get cache statistics.
205pub fn stats() -> CacheStats {
206    let mut stats = CacheStats::default();
207
208    if let Some(base) = cache_dir() {
209        let compile_dir = base.join("compile");
210        if compile_dir.is_dir() {
211            if let Ok(entries) = std::fs::read_dir(&compile_dir) {
212                for entry in entries.flatten() {
213                    let path = entry.path();
214                    if path.extension().is_some_and(|e| e == "tasm") {
215                        stats.compilations += 1;
216                    }
217                    if let Ok(meta) = std::fs::metadata(&path) {
218                        stats.total_bytes += meta.len();
219                    }
220                }
221            }
222        }
223
224        let verify_dir = base.join("verify");
225        if verify_dir.is_dir() {
226            if let Ok(entries) = std::fs::read_dir(&verify_dir) {
227                for entry in entries.flatten() {
228                    let path = entry.path();
229                    if path.extension().is_some_and(|e| e == "verify") {
230                        stats.verifications += 1;
231                    }
232                    if let Ok(meta) = std::fs::metadata(&path) {
233                        stats.total_bytes += meta.len();
234                    }
235                }
236            }
237        }
238    }
239
240    stats
241}
242
243/// Clear the entire cache.
244pub fn clear() -> Result<(), String> {
245    if let Some(base) = cache_dir() {
246        if base.exists() {
247            std::fs::remove_dir_all(&base).map_err(|e| format!("cannot clear cache: {}", e))?;
248        }
249    }
250    Ok(())
251}
252
253/// Get the current timestamp as a string.
254pub fn timestamp() -> String {
255    format!("{}", crate::package::unix_timestamp())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn test_hash() -> ContentHash {
263        ContentHash([0xAB; 32])
264    }
265
266    #[test]
267    fn test_cache_dir_resolution() {
268        // Just verify it doesn't panic
269        let _ = cache_dir();
270    }
271
272    #[test]
273    fn test_cached_verification_round_trip() {
274        let result = CachedVerification {
275            is_safe: true,
276            constraints: 42,
277            variables: 10,
278            verdict: "Safe".to_string(),
279            timestamp: "1234567890".to_string(),
280        };
281
282        let serialized = result.serialize();
283        let deserialized = CachedVerification::deserialize(&serialized).unwrap();
284
285        assert_eq!(deserialized.is_safe, true);
286        assert_eq!(deserialized.constraints, 42);
287        assert_eq!(deserialized.variables, 10);
288        assert_eq!(deserialized.verdict, "Safe");
289    }
290
291    /// Tests below use direct file I/O to a unique temp dir instead of
292    /// the `TRIDENT_CACHE_DIR` env var (which races in parallel tests).
293
294    fn store_compilation_at(
295        dir: &std::path::Path,
296        hash: &ContentHash,
297        target: &str,
298        tasm: &str,
299        height: Option<u64>,
300    ) {
301        let compile_dir = dir.join("compile");
302        std::fs::create_dir_all(&compile_dir).unwrap();
303        let filename = format!("{}.{}.tasm", hash.to_hex(), target);
304        let path = compile_dir.join(&filename);
305        if !path.exists() {
306            std::fs::write(&path, tasm).unwrap();
307        }
308        if let Some(h) = height {
309            let meta = compile_dir.join(format!("{}.{}.meta", hash.to_hex(), target));
310            std::fs::write(&meta, h.to_string()).unwrap();
311        }
312    }
313
314    fn lookup_compilation_at(
315        dir: &std::path::Path,
316        hash: &ContentHash,
317        target: &str,
318    ) -> Option<CachedCompilation> {
319        let compile_dir = dir.join("compile");
320        let filename = format!("{}.{}.tasm", hash.to_hex(), target);
321        let path = compile_dir.join(&filename);
322        let tasm = std::fs::read_to_string(&path).ok()?;
323        let meta = compile_dir.join(format!("{}.{}.meta", hash.to_hex(), target));
324        let padded_height = std::fs::read_to_string(&meta)
325            .ok()
326            .and_then(|s| s.trim().parse().ok());
327        Some(CachedCompilation {
328            tasm,
329            padded_height,
330        })
331    }
332
333    fn store_verification_at(
334        dir: &std::path::Path,
335        hash: &ContentHash,
336        result: &CachedVerification,
337    ) {
338        let verify_dir = dir.join("verify");
339        std::fs::create_dir_all(&verify_dir).unwrap();
340        let filename = format!("{}.verify", hash.to_hex());
341        let path = verify_dir.join(&filename);
342        if !path.exists() {
343            std::fs::write(&path, result.serialize()).unwrap();
344        }
345    }
346
347    fn lookup_verification_at(
348        dir: &std::path::Path,
349        hash: &ContentHash,
350    ) -> Option<CachedVerification> {
351        let verify_dir = dir.join("verify");
352        let filename = format!("{}.verify", hash.to_hex());
353        let path = verify_dir.join(&filename);
354        let text = std::fs::read_to_string(&path).ok()?;
355        CachedVerification::deserialize(&text)
356    }
357
358    #[test]
359    fn test_store_and_lookup_compilation() {
360        let tmp = tempfile::tempdir().unwrap();
361        let dir = tmp.path();
362        let hash = test_hash();
363        let tasm = "push 1\npush 2\nadd\n";
364
365        store_compilation_at(dir, &hash, "triton", tasm, Some(32));
366
367        let cached = lookup_compilation_at(dir, &hash, "triton").unwrap();
368        assert_eq!(cached.tasm, tasm);
369        assert_eq!(cached.padded_height, Some(32));
370
371        // Lookup non-existent target
372        assert!(lookup_compilation_at(dir, &hash, "miden").is_none());
373    }
374
375    #[test]
376    fn test_store_and_lookup_verification() {
377        let tmp = tempfile::tempdir().unwrap();
378        let dir = tmp.path();
379        let hash = test_hash();
380        let result = CachedVerification {
381            is_safe: true,
382            constraints: 5,
383            variables: 3,
384            verdict: "Safe".to_string(),
385            timestamp: "12345".to_string(),
386        };
387
388        store_verification_at(dir, &hash, &result);
389
390        let cached = lookup_verification_at(dir, &hash).unwrap();
391        assert!(cached.is_safe);
392        assert_eq!(cached.constraints, 5);
393    }
394
395    #[test]
396    fn test_cache_stats_direct() {
397        let tmp = tempfile::tempdir().unwrap();
398        let dir = tmp.path();
399        let hash = test_hash();
400
401        store_compilation_at(dir, &hash, "triton", "push 1\n", None);
402        let result = CachedVerification {
403            is_safe: true,
404            constraints: 1,
405            variables: 1,
406            verdict: "Safe".to_string(),
407            timestamp: "12345".to_string(),
408        };
409        store_verification_at(dir, &hash, &result);
410
411        // Count files manually
412        let compile_count = std::fs::read_dir(dir.join("compile"))
413            .unwrap()
414            .filter_map(|e| e.ok())
415            .filter(|e| e.path().extension().is_some_and(|ext| ext == "tasm"))
416            .count();
417        let verify_count = std::fs::read_dir(dir.join("verify"))
418            .unwrap()
419            .filter_map(|e| e.ok())
420            .filter(|e| e.path().extension().is_some_and(|ext| ext == "verify"))
421            .count();
422        assert_eq!(compile_count, 1);
423        assert_eq!(verify_count, 1);
424    }
425
426    #[test]
427    fn test_append_only_semantics() {
428        let tmp = tempfile::tempdir().unwrap();
429        let dir = tmp.path();
430        let hash = test_hash();
431
432        // First write
433        store_compilation_at(dir, &hash, "triton", "push 1\n", None);
434
435        // Second write with different content — should NOT overwrite
436        store_compilation_at(dir, &hash, "triton", "push 2\n", None);
437
438        let cached = lookup_compilation_at(dir, &hash, "triton").unwrap();
439        assert_eq!(cached.tasm, "push 1\n", "append-only: first write wins");
440    }
441}