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