Skip to main content

stryke/
script_cache.rs

1//! rkyv-backed bytecode cache for scripts.
2//!
3//! Single-file shard at `~/.stryke/scripts.rkyv`. On 2+ runs of a given
4//! script, lex/parse/compile is skipped — the cache hit is `mmap` + zero-copy
5//! `ArchivedHashMap` lookup + bincode-decode of the inner Program/Chunk blobs.
6//!
7//! Storage layout (rkyv archived):
8//!   ScriptShard {
9//!     header: { magic, format_version, stryke_version, pointer_width, built_at_secs },
10//!     entries: `HashMap<canonical_path, ScriptEntry>`,
11//!   }
12//!   ScriptEntry { mtime_secs, mtime_nsecs, binary_mtime_at_cache, cached_at_secs,
13//!                 program_blob: `Vec<u8>`, chunk_blob: `Vec<u8>` }
14//!
15//! Inner `program_blob` / `chunk_blob` are bincode for now — `StrykeValue`'s
16//! Arc-shared graph and the `CacheConst` adapter aren't trivially rkyv-archivable,
17//! so phase 1 keeps that codec inside the rkyv outer container. Phase 2 can
18//! derive `Archive` directly on `Chunk` / `Program` for true zero-copy load.
19//!
20//! Read path:
21//!   - Lazy `mmap` of the shard, kept alive for the process lifetime so repeat
22//!     lookups (`s test t` running 87 scripts) pay validation once.
23//!   - `rkyv::check_archived_root::<ScriptShard>` validates the byte image.
24//!   - Header validated for magic / format_version / stryke_version / pointer_width.
25//!   - Per-entry: source mtime must match, and `binary_mtime_at_cache` ≥ running
26//!     stryke binary's mtime (any rebuild of stryke invalidates entries silently).
27//!
28//! Write path:
29//!   - `flock(LOCK_EX)` on `scripts.rkyv.lock` so concurrent writers serialize.
30//!   - Read existing shard into owned form, mutate, `rkyv::to_bytes`,
31//!     write to `scripts.rkyv.tmp.<pid>.<nanos>`, fsync, atomic-rename.
32//!   - Drop the in-process `mmap` so the next read picks up the new shard.
33
34use std::collections::HashMap;
35use std::fs::File;
36use std::io::Write as IoWrite;
37use std::path::{Path, PathBuf};
38use std::sync::OnceLock;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41use memmap2::Mmap;
42use parking_lot::Mutex;
43use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
44use serde::{Deserialize, Deserializer, Serialize, Serializer};
45
46use crate::ast::Program;
47use crate::bytecode::Chunk;
48use crate::error::{StrykeError, StrykeResult};
49use crate::value::StrykeValue;
50
51/// Magic header bytes — fail-fast if a wrong-format file is mmap'd.
52pub const SHARD_MAGIC: u32 = 0x53545259; // "STRY"
53/// Bumped on incompatible rkyv schema changes.
54pub const SHARD_FORMAT_VERSION: u32 = 1;
55
56// ── rkyv archived types ──────────────────────────────────────────────────────
57/// `ShardHeader` — see fields for layout.
58#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
59#[archive(check_bytes)]
60pub struct ShardHeader {
61    /// `magic` field.
62    pub magic: u32,
63    /// `format_version` field.
64    pub format_version: u32,
65    /// `stryke_version` field.
66    pub stryke_version: String,
67    /// `pointer_width` field.
68    pub pointer_width: u32,
69    /// `built_at_secs` field.
70    pub built_at_secs: u64,
71}
72/// `ScriptEntry` — see fields for layout.
73#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
74#[archive(check_bytes)]
75pub struct ScriptEntry {
76    /// `mtime_secs` field.
77    pub mtime_secs: i64,
78    /// `mtime_nsecs` field.
79    pub mtime_nsecs: i64,
80    /// `binary_mtime_at_cache` field.
81    pub binary_mtime_at_cache: i64,
82    /// `cached_at_secs` field.
83    pub cached_at_secs: i64,
84    /// `program_blob` field.
85    pub program_blob: Vec<u8>,
86    /// `chunk_blob` field.
87    pub chunk_blob: Vec<u8>,
88}
89/// `ScriptShard` — see fields for layout.
90#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
91#[archive(check_bytes)]
92pub struct ScriptShard {
93    /// `header` field.
94    pub header: ShardHeader,
95    /// `entries` field.
96    pub entries: HashMap<String, ScriptEntry>,
97}
98
99// ── Constant pool codec for serializing StrykeValues in the inner bincode blob ─
100//
101// The inner `chunk_blob` still uses bincode, and `Chunk.constants: Vec<StrykeValue>`
102// can't serialize directly because `StrykeValue` is an Arc-shared heap graph. This
103// codec is referenced by `bytecode.rs:1067` via `#[serde(with = ...)]` and only
104// needs to handle the constants the compiler actually pools — `Undef`, ints,
105// floats, strings.
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109enum CacheConst {
110    Undef,
111    Int(i64),
112    Float(f64),
113    Str(String),
114}
115
116fn cache_const_from_perl(v: &StrykeValue) -> Result<CacheConst, String> {
117    if v.is_undef() {
118        return Ok(CacheConst::Undef);
119    }
120    if let Some(n) = v.as_integer() {
121        return Ok(CacheConst::Int(n));
122    }
123    if let Some(f) = v.as_float() {
124        return Ok(CacheConst::Float(f));
125    }
126    if let Some(s) = v.as_str() {
127        return Ok(CacheConst::Str(s.to_string()));
128    }
129    Err(format!(
130        "constant pool value cannot be cached (type {})",
131        v.ref_type()
132    ))
133}
134
135fn perl_from_cache_const(c: CacheConst) -> StrykeValue {
136    match c {
137        CacheConst::Undef => StrykeValue::UNDEF,
138        CacheConst::Int(n) => StrykeValue::integer(n),
139        CacheConst::Float(f) => StrykeValue::float(f),
140        CacheConst::Str(s) => StrykeValue::string(s),
141    }
142}
143/// `constants_pool_codec` submodule.
144pub mod constants_pool_codec {
145    use super::*;
146    /// `serialize` — see implementation.
147    pub fn serialize<S>(values: &Vec<StrykeValue>, ser: S) -> Result<S::Ok, S::Error>
148    where
149        S: Serializer,
150    {
151        let mut out = Vec::with_capacity(values.len());
152        for v in values {
153            let c = cache_const_from_perl(v).map_err(serde::ser::Error::custom)?;
154            out.push(c);
155        }
156        out.serialize(ser)
157    }
158    /// `deserialize` — see implementation.
159    pub fn deserialize<'de, D>(de: D) -> Result<Vec<StrykeValue>, D::Error>
160    where
161        D: Deserializer<'de>,
162    {
163        let v: Vec<CacheConst> = <Vec<CacheConst> as Deserialize>::deserialize(de)?;
164        Ok(v.into_iter().map(perl_from_cache_const).collect())
165    }
166}
167
168/// Owned bundle handed back from `try_load` / `ScriptCache::get`.
169#[derive(Debug, Clone)]
170pub struct CachedScript {
171    /// `program` field.
172    pub program: Program,
173    /// `chunk` field.
174    pub chunk: Chunk,
175}
176
177// ── mmap'd validated shard view ──────────────────────────────────────────────
178
179/// mmap + validated `*const ArchivedScriptShard`. Self-referential — the pointer
180/// is valid for the lifetime of the wrapping struct.
181pub struct MmappedShard {
182    /// `_mmap` field.
183    _mmap: Mmap,
184    /// `archived` field.
185    archived: *const ArchivedScriptShard,
186}
187
188// SAFETY: the pointer aliases an immutable mmap that lives as long as Self.
189// rkyv-validated reads are immutable.
190unsafe impl Send for MmappedShard {}
191unsafe impl Sync for MmappedShard {}
192
193impl MmappedShard {
194    /// Open + validate a shard file. Returns `None` on any failure (file
195    /// missing, mmap failed, validation failed).
196    pub fn open(path: &Path) -> Option<Self> {
197        let file = File::open(path).ok()?;
198        let mmap = unsafe { Mmap::map(&file).ok()? };
199        let archived = rkyv::check_archived_root::<ScriptShard>(&mmap[..]).ok()?;
200        let archived_ptr = archived as *const ArchivedScriptShard;
201        Some(Self {
202            _mmap: mmap,
203            archived: archived_ptr,
204        })
205    }
206
207    fn shard(&self) -> &ArchivedScriptShard {
208        // SAFETY: see Self impl comment.
209        unsafe { &*self.archived }
210    }
211
212    /// Header passes magic / format / stryke-version / pointer-width checks.
213    fn header_ok(&self) -> bool {
214        let h = &self.shard().header;
215        let magic: u32 = h.magic.into();
216        let fv: u32 = h.format_version.into();
217        let pw: u32 = h.pointer_width.into();
218        magic == SHARD_MAGIC
219            && fv == SHARD_FORMAT_VERSION
220            && pw as usize == std::mem::size_of::<usize>()
221            && h.stryke_version.as_str() == env!("CARGO_PKG_VERSION")
222    }
223
224    fn lookup(&self, path: &str) -> Option<&ArchivedScriptEntry> {
225        self.shard().entries.get(path)
226    }
227
228    fn entry_count(&self) -> usize {
229        self.shard().entries.len()
230    }
231}
232
233// ── ScriptCache: per-instance handle (used by tests and by the global) ───────
234
235/// Shard cache keyed by canonical script path. One per shard file.
236pub struct ScriptCache {
237    /// `path` field.
238    path: PathBuf,
239    /// `lock_path` field.
240    lock_path: PathBuf,
241    /// `mmap` field.
242    mmap: Mutex<Option<MmappedShard>>,
243}
244
245impl ScriptCache {
246    /// Open (or prepare) the cache rooted at `path`. The file does not need to
247    /// exist yet — it will be created on the first `put`.
248    pub fn open(path: &Path) -> std::io::Result<Self> {
249        if let Some(parent) = path.parent() {
250            std::fs::create_dir_all(parent)?;
251        }
252        let parent = path.parent().unwrap_or_else(|| Path::new("/tmp"));
253        let lock_path = parent.join(format!(
254            "{}.lock",
255            path.file_name()
256                .and_then(|s| s.to_str())
257                .unwrap_or("scripts.rkyv")
258        ));
259        Ok(Self {
260            path: path.to_path_buf(),
261            lock_path,
262            mmap: Mutex::new(None),
263        })
264    }
265
266    fn ensure_mmap(&self) {
267        let mut guard = self.mmap.lock();
268        if guard.is_none() {
269            *guard = MmappedShard::open(&self.path);
270        }
271    }
272
273    fn invalidate_mmap(&self) {
274        let mut guard = self.mmap.lock();
275        *guard = None;
276    }
277
278    /// Cache lookup. Returns `None` on miss, mtime mismatch, version drift, or
279    /// stryke-binary newer than the cached entry.
280    pub fn get(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<CachedScript> {
281        self.ensure_mmap();
282        let guard = self.mmap.lock();
283        let shard = guard.as_ref()?;
284        if !shard.header_ok() {
285            return None;
286        }
287        let entry = shard.lookup(path)?;
288
289        let entry_mtime_s: i64 = entry.mtime_secs.into();
290        let entry_mtime_ns: i64 = entry.mtime_nsecs.into();
291        if entry_mtime_s != mtime_secs || entry_mtime_ns != mtime_nsecs {
292            return None;
293        }
294
295        if let Some(bin_mtime) = current_binary_mtime_secs() {
296            let cached_bin_mtime: i64 = entry.binary_mtime_at_cache.into();
297            if cached_bin_mtime < bin_mtime {
298                return None;
299            }
300        }
301
302        let program_bytes: &[u8] = entry.program_blob.as_slice();
303        let chunk_bytes: &[u8] = entry.chunk_blob.as_slice();
304        let program: Program = bincode::deserialize(program_bytes).ok()?;
305        let chunk: Chunk = bincode::deserialize(chunk_bytes).ok()?;
306        Some(CachedScript { program, chunk })
307    }
308
309    /// Insert / replace an entry. Serializes the whole shard and atomic-renames.
310    pub fn put(
311        &self,
312        path: &str,
313        mtime_secs: i64,
314        mtime_nsecs: i64,
315        program: &Program,
316        chunk: &Chunk,
317    ) -> StrykeResult<()> {
318        let program_bytes =
319            bincode::serialize(program).map_err(|e| StrykeError::runtime(e.to_string(), 0))?;
320        let chunk_bytes =
321            bincode::serialize(chunk).map_err(|e| StrykeError::runtime(e.to_string(), 0))?;
322
323        let _lock = match acquire_lock(&self.lock_path) {
324            Some(l) => l,
325            None => return Ok(()),
326        };
327
328        let mut shard = match read_owned_shard(&self.path) {
329            Some(s)
330                if s.header.stryke_version == env!("CARGO_PKG_VERSION")
331                    && s.header.pointer_width as usize == std::mem::size_of::<usize>()
332                    && s.header.format_version == SHARD_FORMAT_VERSION =>
333            {
334                s
335            }
336            _ => fresh_shard(),
337        };
338
339        let bin_mtime = current_binary_mtime_secs().unwrap_or(0);
340        let entry = ScriptEntry {
341            mtime_secs,
342            mtime_nsecs,
343            binary_mtime_at_cache: bin_mtime,
344            cached_at_secs: now_secs(),
345            program_blob: program_bytes,
346            chunk_blob: chunk_bytes,
347        };
348        shard.entries.insert(path.to_string(), entry);
349        shard.header.built_at_secs = now_secs() as u64;
350
351        write_shard_atomic(&self.path, &shard)?;
352        self.invalidate_mmap();
353        Ok(())
354    }
355
356    /// `(count, total_blob_bytes)` snapshot.
357    pub fn stats(&self) -> (i64, i64) {
358        self.ensure_mmap();
359        let guard = self.mmap.lock();
360        let Some(shard) = guard.as_ref() else {
361            return (0, 0);
362        };
363        let count = shard.entry_count() as i64;
364        let bytes: i64 = shard
365            .shard()
366            .entries
367            .values()
368            .map(|e| (e.program_blob.len() + e.chunk_blob.len()) as i64)
369            .sum();
370        (count, bytes)
371    }
372
373    /// `(path, program_kb, chunk_kb, version, cached_at_localstr)` per entry,
374    /// sorted by `cached_at` desc.
375    pub fn list_scripts(&self) -> Vec<(String, f64, f64, String, String)> {
376        self.ensure_mmap();
377        let guard = self.mmap.lock();
378        let Some(shard) = guard.as_ref() else {
379            return Vec::new();
380        };
381        let v = shard.shard().header.stryke_version.as_str().to_string();
382        let mut out: Vec<(String, f64, f64, String, String, i64)> = shard
383            .shard()
384            .entries
385            .iter()
386            .map(|(k, e)| {
387                let prog_kb = e.program_blob.len() as f64 / 1024.0;
388                let chunk_kb = e.chunk_blob.len() as f64 / 1024.0;
389                let cached_at: i64 = e.cached_at_secs.into();
390                let ts = format_local_ts(cached_at);
391                (
392                    k.as_str().to_string(),
393                    prog_kb,
394                    chunk_kb,
395                    v.clone(),
396                    ts,
397                    cached_at,
398                )
399            })
400            .collect();
401        out.sort_by_key(|x| std::cmp::Reverse(x.5));
402        out.into_iter()
403            .map(|(p, pk, ck, ver, ts, _)| (p, pk, ck, ver, ts))
404            .collect()
405    }
406
407    /// Drop entries whose source file vanished or whose mtime changed. Returns
408    /// number of entries evicted.
409    pub fn evict_stale(&self) -> usize {
410        let _lock = match acquire_lock(&self.lock_path) {
411            Some(l) => l,
412            None => return 0,
413        };
414        let mut shard = match read_owned_shard(&self.path) {
415            Some(s) => s,
416            None => return 0,
417        };
418        let before = shard.entries.len();
419        shard.entries.retain(|p, e| match file_mtime(Path::new(p)) {
420            Some((s, ns)) => s == e.mtime_secs && ns == e.mtime_nsecs,
421            None => false,
422        });
423        let evicted = before - shard.entries.len();
424        if evicted > 0 {
425            let _ = write_shard_atomic(&self.path, &shard);
426            self.invalidate_mmap();
427        }
428        evicted
429    }
430
431    /// Delete the shard file. Idempotent.
432    pub fn clear(&self) -> std::io::Result<()> {
433        let _lock = acquire_lock(&self.lock_path);
434        let res = match std::fs::remove_file(&self.path) {
435            Ok(()) => Ok(()),
436            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
437            Err(e) => Err(e),
438        };
439        self.invalidate_mmap();
440        res
441    }
442}
443
444// ── Locking + shard read/write helpers ───────────────────────────────────────
445
446/// Acquire an exclusive `flock` on the lock path. The returned `Flock` releases
447/// the lock and closes the file when dropped.
448fn acquire_lock(path: &Path) -> Option<nix::fcntl::Flock<File>> {
449    let f = File::options()
450        .read(true)
451        .write(true)
452        .create(true)
453        .truncate(false)
454        .open(path)
455        .ok()?;
456    nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusive).ok()
457}
458
459fn fresh_shard() -> ScriptShard {
460    ScriptShard {
461        header: ShardHeader {
462            magic: SHARD_MAGIC,
463            format_version: SHARD_FORMAT_VERSION,
464            stryke_version: env!("CARGO_PKG_VERSION").to_string(),
465            pointer_width: std::mem::size_of::<usize>() as u32,
466            built_at_secs: now_secs() as u64,
467        },
468        entries: HashMap::new(),
469    }
470}
471
472fn read_owned_shard(path: &Path) -> Option<ScriptShard> {
473    let bytes = std::fs::read(path).ok()?;
474    let archived = rkyv::check_archived_root::<ScriptShard>(&bytes[..]).ok()?;
475    archived.deserialize(&mut rkyv::Infallible).ok()
476}
477
478fn write_shard_atomic(path: &Path, shard: &ScriptShard) -> StrykeResult<()> {
479    let bytes = rkyv::to_bytes::<_, 4096>(shard)
480        .map_err(|e| StrykeError::runtime(format!("rkyv serialize: {}", e), 0))?;
481
482    let parent = path.parent().expect("cache path has parent");
483    let _ = std::fs::create_dir_all(parent);
484
485    let pid = std::process::id();
486    let nanos = SystemTime::now()
487        .duration_since(UNIX_EPOCH)
488        .map(|d| d.as_nanos())
489        .unwrap_or(0);
490    let tmp_path = parent.join(format!(
491        "{}.tmp.{}.{}",
492        path.file_name()
493            .and_then(|s| s.to_str())
494            .unwrap_or("scripts.rkyv"),
495        pid,
496        nanos
497    ));
498
499    {
500        let mut f = File::create(&tmp_path).map_err(|e| StrykeError::runtime(e.to_string(), 0))?;
501        f.write_all(&bytes)
502            .map_err(|e| StrykeError::runtime(e.to_string(), 0))?;
503        f.sync_all()
504            .map_err(|e| StrykeError::runtime(e.to_string(), 0))?;
505    }
506
507    std::fs::rename(&tmp_path, path).map_err(|e| StrykeError::runtime(e.to_string(), 0))?;
508    Ok(())
509}
510
511fn now_secs() -> i64 {
512    SystemTime::now()
513        .duration_since(UNIX_EPOCH)
514        .map(|d| d.as_secs() as i64)
515        .unwrap_or(0)
516}
517
518fn format_local_ts(secs: i64) -> String {
519    let dt = chrono::DateTime::<chrono::Local>::from(
520        UNIX_EPOCH + std::time::Duration::from_secs(secs.max(0) as u64),
521    );
522    dt.format("%Y-%m-%d %H:%M:%S").to_string()
523}
524
525// ── Free-standing helpers ────────────────────────────────────────────────────
526
527/// Get mtime from file metadata as `(secs, nsecs)`.
528pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
529    use std::os::unix::fs::MetadataExt;
530    let meta = std::fs::metadata(path).ok()?;
531    Some((meta.mtime(), meta.mtime_nsec()))
532}
533
534/// Mtime of the running stryke binary. Cached for the lifetime of the process.
535fn current_binary_mtime_secs() -> Option<i64> {
536    static BIN_MTIME: OnceLock<Option<i64>> = OnceLock::new();
537    *BIN_MTIME.get_or_init(|| {
538        let exe = std::env::current_exe().ok()?;
539        let (secs, _) = file_mtime(&exe)?;
540        Some(secs)
541    })
542}
543
544/// Default shard path: `~/.stryke/scripts.rkyv`.
545pub fn default_cache_path() -> PathBuf {
546    dirs::home_dir()
547        .unwrap_or_else(|| PathBuf::from("/tmp"))
548        .join(".stryke/scripts.rkyv")
549}
550
551/// `STRYKE_CACHE=0|false|no` disables the cache entirely.
552pub fn cache_enabled() -> bool {
553    !matches!(
554        std::env::var("STRYKE_CACHE").as_deref(),
555        Ok("0") | Ok("false") | Ok("no")
556    )
557}
558
559// ── Process-global cache (lazy-initialized) ──────────────────────────────────
560
561/// Process-wide `ScriptCache` rooted at `default_cache_path()`. `None` when the
562/// cache is disabled or the path could not be opened.
563pub static CACHE: once_cell::sync::Lazy<Option<ScriptCache>> = once_cell::sync::Lazy::new(|| {
564    if !cache_enabled() {
565        return None;
566    }
567    ScriptCache::open(&default_cache_path()).ok()
568});
569
570/// Try to load a cached script by source path. Returns `None` on any miss.
571pub fn try_load(path: &Path) -> Option<CachedScript> {
572    let cache = CACHE.as_ref()?;
573    let canonical = path.canonicalize().ok()?;
574    let path_str = canonical.to_string_lossy();
575    let (mtime_s, mtime_ns) = file_mtime(&canonical)?;
576    cache.get(&path_str, mtime_s, mtime_ns)
577}
578
579/// Store a compiled script in the cache.
580pub fn try_save(path: &Path, program: &Program, chunk: &Chunk) -> StrykeResult<()> {
581    let Some(cache) = CACHE.as_ref() else {
582        return Ok(());
583    };
584    let canonical = match path.canonicalize() {
585        Ok(p) => p,
586        Err(_) => return Ok(()),
587    };
588    let path_str = canonical.to_string_lossy();
589    let (mtime_s, mtime_ns) = match file_mtime(&canonical) {
590        Some(m) => m,
591        None => return Ok(()),
592    };
593    cache.put(&path_str, mtime_s, mtime_ns, program, chunk)
594}
595
596/// Global cache stats.
597pub fn stats() -> Option<(i64, i64)> {
598    CACHE.as_ref().map(|c| c.stats())
599}
600
601/// Evict stale entries from global cache.
602pub fn evict_stale() -> usize {
603    CACHE.as_ref().map(|c| c.evict_stale()).unwrap_or(0)
604}
605
606/// Clear the global cache.
607pub fn clear() -> bool {
608    CACHE.as_ref().map(|c| c.clear().is_ok()).unwrap_or(false)
609}
610
611// ── Tests ────────────────────────────────────────────────────────────────────
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use tempfile::tempdir;
617
618    #[test]
619    fn round_trip() {
620        let dir = tempdir().unwrap();
621        let cache_path = dir.path().join("scripts.rkyv");
622        let cache = ScriptCache::open(&cache_path).unwrap();
623
624        let script_path = dir.path().join("test.stk");
625        std::fs::write(&script_path, "p 42").unwrap();
626
627        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
628        let path_str = script_path.to_string_lossy().to_string();
629
630        let program = crate::parse("p 42").unwrap();
631        let chunk = crate::compiler::Compiler::new()
632            .compile_program(&program)
633            .unwrap();
634
635        cache
636            .put(&path_str, mtime_s, mtime_ns, &program, &chunk)
637            .unwrap();
638
639        let loaded = cache.get(&path_str, mtime_s, mtime_ns).unwrap();
640        assert_eq!(loaded.chunk.ops.len(), chunk.ops.len());
641
642        let (count, _bytes) = cache.stats();
643        assert_eq!(count, 1);
644    }
645
646    #[test]
647    fn mtime_invalidation() {
648        let dir = tempdir().unwrap();
649        let cache_path = dir.path().join("scripts.rkyv");
650        let cache = ScriptCache::open(&cache_path).unwrap();
651
652        let script_path = dir.path().join("test.stk");
653        std::fs::write(&script_path, "p 42").unwrap();
654
655        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
656        let path_str = script_path.to_string_lossy().to_string();
657
658        let program = crate::parse("p 42").unwrap();
659        let chunk = crate::compiler::Compiler::new()
660            .compile_program(&program)
661            .unwrap();
662
663        cache
664            .put(&path_str, mtime_s, mtime_ns, &program, &chunk)
665            .unwrap();
666
667        assert!(cache.get(&path_str, mtime_s + 1, mtime_ns).is_none());
668    }
669
670    #[test]
671    fn second_put_replaces_first() {
672        let dir = tempdir().unwrap();
673        let cache_path = dir.path().join("scripts.rkyv");
674        let cache = ScriptCache::open(&cache_path).unwrap();
675
676        let p1 = dir.path().join("a.stk");
677        let p2 = dir.path().join("b.stk");
678        std::fs::write(&p1, "1").unwrap();
679        std::fs::write(&p2, "2").unwrap();
680
681        let (m1s, m1n) = file_mtime(&p1).unwrap();
682        let (m2s, m2n) = file_mtime(&p2).unwrap();
683
684        let prog1 = crate::parse("1").unwrap();
685        let chunk1 = crate::compiler::Compiler::new()
686            .compile_program(&prog1)
687            .unwrap();
688        let prog2 = crate::parse("2").unwrap();
689        let chunk2 = crate::compiler::Compiler::new()
690            .compile_program(&prog2)
691            .unwrap();
692
693        cache
694            .put(&p1.to_string_lossy(), m1s, m1n, &prog1, &chunk1)
695            .unwrap();
696        cache
697            .put(&p2.to_string_lossy(), m2s, m2n, &prog2, &chunk2)
698            .unwrap();
699
700        let (count, _) = cache.stats();
701        assert_eq!(count, 2);
702        assert!(cache.get(&p1.to_string_lossy(), m1s, m1n).is_some());
703        assert!(cache.get(&p2.to_string_lossy(), m2s, m2n).is_some());
704    }
705
706    #[test]
707    fn corrupt_file_returns_no_mmap() {
708        let dir = tempdir().unwrap();
709        let cache_path = dir.path().join("scripts.rkyv");
710        std::fs::write(&cache_path, b"this is not a valid rkyv archive").unwrap();
711        let cache = ScriptCache::open(&cache_path).unwrap();
712        // get on a missing path with corrupt file: header_ok blocks on the
713        // archived-root validation already failing, so MmappedShard::open
714        // returns None and lookups all miss.
715        assert!(cache.get("/nope", 0, 0).is_none());
716    }
717
718    #[test]
719    fn clear_removes_file() {
720        let dir = tempdir().unwrap();
721        let cache_path = dir.path().join("scripts.rkyv");
722        let cache = ScriptCache::open(&cache_path).unwrap();
723
724        let script_path = dir.path().join("test.stk");
725        std::fs::write(&script_path, "p 42").unwrap();
726        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
727        let program = crate::parse("p 42").unwrap();
728        let chunk = crate::compiler::Compiler::new()
729            .compile_program(&program)
730            .unwrap();
731        cache
732            .put(
733                &script_path.to_string_lossy(),
734                mtime_s,
735                mtime_ns,
736                &program,
737                &chunk,
738            )
739            .unwrap();
740        assert!(cache_path.exists());
741
742        cache.clear().unwrap();
743        assert!(!cache_path.exists());
744    }
745}