Skip to main content

zsh/extensions/
script_cache.rs

1//! rkyv-backed bytecode cache for zsh scripts.
2//!
3//! Single-file shard at `~/.zshrs/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 `fusevm::Chunk` blob.
6//!
7//! Storage layout (rkyv archived):
8//!   ScriptShard {
9//!     header: { magic, format_version, zshrs_version, pointer_width, built_at_secs },
10//!     entries: HashMap<canonical_path, ScriptEntry>,
11//!   }
12//!   ScriptEntry { mtime_secs, mtime_nsecs, binary_mtime_at_cache,
13//!                 binary_len_at_cache, cached_at_secs, chunk_blob: `Vec<u8>` }
14//!
15//! Inner `chunk_blob` is bincode for now — `fusevm::Chunk` is owned by the
16//! upstream `fusevm` crate and only derives `serde::Serialize`/`Deserialize`,
17//! not `rkyv::Archive`, so the inner codec stays bincode inside the rkyv outer
18//! container. Direct rkyv on Chunk would require either forking fusevm or a
19//! mirror archived type — both are large refactors and not needed for the
20//! current "kill SQLite for bytecode" goal.
21//!
22//! Read path:
23//!   - Lazy `mmap` of the shard, kept alive for the process lifetime so repeat
24//!     lookups pay validation once.
25//!   - `rkyv::check_archived_root::<ScriptShard>` validates the byte image.
26//!   - Header validated for magic / format_version / zshrs_version / pointer_width.
27//!   - Per-entry: source mtime must match, and the entry's recorded binary
28//!     identity (`binary_mtime_at_cache`, `binary_len_at_cache`) must EQUAL the
29//!     running binary's (mtime, len). Bytecode is only ever replayed by the
30//!     exact build that emitted it; any rebuild invalidates entries silently.
31//!
32//! Write path:
33//!   - `bin_zsystem_flock(LOCK_EX)` on `scripts.rkyv.lock` so concurrent writers serialize.
34//!   - Read existing shard into owned form, mutate, `rkyv::to_bytes`,
35//!     write to `scripts.rkyv.tmp.<pid>.<nanos>`, fsync, atomic-rename.
36//!   - Drop the in-process `mmap` so the next read picks up the new shard.
37//!
38//! Run log: a cache hit never rewrites the shard. Its time is appended to
39//! `scripts.runs` (`unix_secs\tpath`, flushed with the pending puts) and
40//! overlaid on `dbview scripts`; the next shard write folds it into
41//! `cached_at_secs` and removes the log.
42//!
43//! Ported from `strykelang/strykelang/script_cache.rs` (the user's stryke
44//! language has the same caching pattern; this is the same shape with `ZRSC`
45//! magic, zshrs version pin, and a single `chunk_blob` per entry — zshrs has
46//! no separate AST cache).
47
48use std::collections::HashMap;
49use std::fs::File;
50use std::io::Write as IoWrite;
51use std::path::{Path, PathBuf};
52use std::sync::OnceLock;
53use std::time::{SystemTime, UNIX_EPOCH};
54
55use memmap2::Mmap;
56use parking_lot::Mutex;
57use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
58use std::os::unix::fs::MetadataExt;
59
60/// Magic header bytes — fail-fast if a wrong-format file is mmap'd.
61/// "ZRSC" little-endian.
62pub const SHARD_MAGIC: u32 = 0x5A525343;
63/// Bumped on incompatible rkyv schema changes.
64///
65/// v2 added `ScriptEntry::binary_len_at_cache`; a v1 shard has no length to
66/// compare against, so it is rejected wholesale rather than half-validated.
67/// v3: token chars moved to the Private Use Area (crate::token_char).
68/// v5: `ScriptEntry::env_fingerprint`, and a one-byte kind tag in front of
69/// every `chunk_blob` (a script's single chunk vs a sourced file's events).
70pub const SHARD_FORMAT_VERSION: u32 = 5;
71/// `ShardHeader` — see fields for layout.
72#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
73#[archive(check_bytes)]
74pub struct ShardHeader {
75    /// `magic` field.
76    pub magic: u32,
77    /// `format_version` field.
78    pub format_version: u32,
79    /// `zshrs_version` field.
80    pub zshrs_version: String,
81    /// `pointer_width` field.
82    pub pointer_width: u32,
83    /// `built_at_secs` field.
84    pub built_at_secs: u64,
85}
86/// `ScriptEntry` — see fields for layout.
87#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
88#[archive(check_bytes)]
89pub struct ScriptEntry {
90    /// `mtime_secs` field.
91    pub mtime_secs: i64,
92    /// `mtime_nsecs` field.
93    pub mtime_nsecs: i64,
94    /// mtime of the zshrs binary that compiled `chunk_blob`.
95    pub binary_mtime_at_cache: i64,
96    /// Size of the zshrs binary that compiled `chunk_blob`. Paired with
97    /// `binary_mtime_at_cache` to identify the emitting build: mtime alone
98    /// has one-second granularity and moves in both directions (an older
99    /// binary restored over a newer one keeps its old timestamp), so it
100    /// cannot by itself prove the running build emitted these bytes.
101    pub binary_len_at_cache: u64,
102    /// `cached_at_secs` field.
103    pub cached_at_secs: i64,
104    /// Hash of the lexer-visible shell state (aliases, options, emulation) the
105    /// program was lexed and compiled under. The compiled chunks have alias
106    /// expansions and option-dependent code baked in, so an entry is served
107    /// only to a run that starts from the same state.
108    pub env_fingerprint: u64,
109    /// `chunk_blob` field.
110    pub chunk_blob: Vec<u8>,
111}
112/// `ScriptShard` — see fields for layout.
113#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
114#[archive(check_bytes)]
115pub struct ScriptShard {
116    /// `header` field.
117    pub header: ShardHeader,
118    /// `entries` field.
119    pub entries: HashMap<String, ScriptEntry>,
120}
121
122/// mmap + validated `*const ArchivedScriptShard`. Self-referential — the pointer
123/// is valid for the lifetime of the wrapping struct.
124pub struct MmappedShard {
125    /// `_mmap` field.
126    _mmap: Mmap,
127    /// `archived` field.
128    archived: *const ArchivedScriptShard,
129    /// `(inode, len, mtime_ns)` of the file this mapping was taken from. The
130    /// shard is replaced by atomic rename, so another process's write leaves
131    /// this mapping on the OLD inode; comparing against the path's current
132    /// identity is how a long-lived shell notices.
133    identity: (u64, u64, i64),
134}
135
136// SAFETY: the pointer aliases an immutable mmap that lives as long as Self.
137// rkyv-validated reads are immutable.
138unsafe impl Send for MmappedShard {}
139unsafe impl Sync for MmappedShard {}
140
141impl MmappedShard {
142    /// `open` — see implementation.
143    pub fn open(path: &Path) -> Option<Self> {
144        let file = File::open(path).ok()?;
145        let identity = file_identity(&file.metadata().ok()?);
146        let mmap = unsafe { Mmap::map(&file).ok()? };
147        let archived = rkyv::check_archived_root::<ScriptShard>(&mmap[..]).ok()?;
148        let archived_ptr = archived as *const ArchivedScriptShard;
149        Some(Self {
150            _mmap: mmap,
151            archived: archived_ptr,
152            identity,
153        })
154    }
155
156    fn shard(&self) -> &ArchivedScriptShard {
157        // SAFETY: see Self impl comment.
158        unsafe { &*self.archived }
159    }
160
161    fn header_ok(&self) -> bool {
162        let h = &self.shard().header;
163        let magic: u32 = h.magic.into();
164        let fv: u32 = h.format_version.into();
165        let pw: u32 = h.pointer_width.into();
166        magic == SHARD_MAGIC
167            && fv == SHARD_FORMAT_VERSION
168            && pw as usize == std::mem::size_of::<usize>()
169            && h.zshrs_version.as_str() == env!("CARGO_PKG_VERSION")
170    }
171
172    fn lookup(&self, path: &str) -> Option<&ArchivedScriptEntry> {
173        self.shard().entries.get(path)
174    }
175
176    fn entry_count(&self) -> usize {
177        self.shard().entries.len()
178    }
179}
180
181/// One buffered write: what `try_save_bytes` queues until the next flush.
182#[derive(Clone)]
183pub struct PendingPut {
184    path: String,
185    mtime_secs: i64,
186    mtime_nsecs: i64,
187    env_fingerprint: u64,
188    /// Kind-tagged blob, as stored.
189    blob: Vec<u8>,
190}
191
192/// Writes buffered since the last flush; see [`try_flush_pending`].
193static PENDING: Mutex<Vec<PendingPut>> = Mutex::new(Vec::new());
194
195/// Process that queued the newest entry. A forked child inherits its parent's
196/// buffer; only the process that queued an entry may write it, or every
197/// external command a shell spawns would rewrite the shard.
198static PENDING_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
199
200/// Cache hits since the last flush: `(path, unix_secs)`. A hit leaves the shard
201/// untouched (rewriting it per run would put a multi-megabyte write on every
202/// startup), so the time of the run is appended to `scripts.runs` instead and
203/// overlaid on the listing; see [`ScriptCache::overlay_runs`].
204static RAN: Mutex<Vec<(String, i64)>> = Mutex::new(Vec::new());
205
206/// Process that queued the newest entry of [`RAN`]; same fork rule as
207/// [`PENDING_PID`].
208static RAN_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
209
210/// `scripts.runs` is rewritten deduplicated once it grows past this.
211const RUNS_COMPACT_BYTES: u64 = 64 * 1024;
212
213/// Past this many buffered entries `try_save_bytes` flushes by itself, so a
214/// long non-interactive run cannot hold an unbounded amount of bytecode.
215const PENDING_FLUSH_MAX: usize = 128;
216
217/// Shard cache keyed by canonical script path. One per shard file.
218pub struct ScriptCache {
219    /// `path` field.
220    path: PathBuf,
221    /// `lock_path` field.
222    lock_path: PathBuf,
223    /// `mmap` field.
224    mmap: Mutex<Option<MmappedShard>>,
225}
226
227impl ScriptCache {
228    /// `open` — see implementation.
229    pub fn open(path: &Path) -> std::io::Result<Self> {
230        if let Some(parent) = path.parent() {
231            std::fs::create_dir_all(parent)?;
232        }
233        let parent = path.parent().unwrap_or_else(|| Path::new("/tmp"));
234        let lock_path = parent.join(format!(
235            "{}.lock",
236            path.file_name()
237                .and_then(|s| s.to_str())
238                .unwrap_or("scripts.rkyv")
239        ));
240        Ok(Self {
241            path: path.to_path_buf(),
242            lock_path,
243            mmap: Mutex::new(None),
244        })
245    }
246
247    fn ensure_mmap(&self) {
248        let mut guard = self.mmap.lock();
249        // One `stat`: remap when another process replaced the shard (or it
250        // vanished) since this mapping was taken. `put` and `clear` in THIS
251        // process invalidate directly; a write from a script run elsewhere
252        // only shows up here.
253        let on_disk = std::fs::metadata(&self.path).ok().map(|m| file_identity(&m));
254        let stale = match (guard.as_ref(), on_disk) {
255            (Some(m), Some(id)) => m.identity != id,
256            (Some(_), None) => true,
257            (None, _) => false,
258        };
259        if stale {
260            *guard = None;
261        }
262        if guard.is_none() {
263            *guard = MmappedShard::open(&self.path);
264        }
265    }
266
267    fn invalidate_mmap(&self) {
268        let mut guard = self.mmap.lock();
269        *guard = None;
270    }
271
272    /// Cache lookup. Returns `None` on miss, mtime mismatch, version drift, or
273    /// zshrs binary newer than the cached entry.
274    pub fn get(
275        &self,
276        path: &str,
277        mtime_secs: i64,
278        mtime_nsecs: i64,
279        env_fingerprint: u64,
280    ) -> Option<Vec<u8>> {
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        let entry_fp: u64 = entry.env_fingerprint.into();
295        if entry_fp != env_fingerprint {
296            return None;
297        }
298
299        // Was this chunk emitted by the binary that is running right now?
300        //
301        // EXACT equality on (mtime, len), the same test `autoload_cache`
302        // already applies to its chunks. The previous `cached < running`
303        // comparison only rejected a cache written by an OLDER build, so a
304        // binary whose mtime went BACKWARDS — an earlier build restored over
305        // a later one, a `cp` that preserves timestamps, a checkout of a
306        // previously-built target dir — silently replayed the newer build's
307        // bytecode. Proven by touching the binary's mtime into the past and
308        // watching the shard still hit. The one-second granularity of the
309        // stored mtime has the same effect when a rebuild lands in the same
310        // second as the cache write, which is why the length is compared too.
311        match current_binary_identity() {
312            Some((bin_mtime, bin_len)) => {
313                let cached_bin_mtime: i64 = entry.binary_mtime_at_cache.into();
314                let cached_bin_len: u64 = entry.binary_len_at_cache.into();
315                if cached_bin_mtime != bin_mtime || cached_bin_len != bin_len {
316                    return None;
317                }
318            }
319            // No `current_exe()` — nothing can be proven, so nothing is used.
320            None => return None,
321        }
322
323        Some(entry.chunk_blob.as_slice().to_vec())
324    }
325
326    /// Insert / replace an entry. Serializes the whole shard and atomic-renames.
327    pub fn put(
328        &self,
329        path: &str,
330        mtime_secs: i64,
331        mtime_nsecs: i64,
332        env_fingerprint: u64,
333        chunk_blob: Vec<u8>,
334    ) -> Result<(), String> {
335        let _lock = match acquire_lock(&self.lock_path) {
336            Some(l) => l,
337            None => return Ok(()),
338        };
339
340        let mut shard = match read_owned_shard(&self.path) {
341            Some(s)
342                if s.header.zshrs_version == env!("CARGO_PKG_VERSION")
343                    && s.header.pointer_width as usize == std::mem::size_of::<usize>()
344                    && s.header.format_version == SHARD_FORMAT_VERSION =>
345            {
346                s
347            }
348            _ => fresh_shard(),
349        };
350
351        self.fold_runs(&mut shard);
352        let (bin_mtime, bin_len) = current_binary_identity().unwrap_or((0, 0));
353        let entry = ScriptEntry {
354            mtime_secs,
355            mtime_nsecs,
356            binary_mtime_at_cache: bin_mtime,
357            binary_len_at_cache: bin_len,
358            cached_at_secs: now_secs(),
359            env_fingerprint,
360            chunk_blob,
361        };
362        shard.entries.insert(path.to_string(), entry);
363        shard.header.built_at_secs = now_secs() as u64;
364
365        write_shard_atomic(&self.path, &shard)?;
366        let _ = std::fs::remove_file(self.runs_path());
367        self.invalidate_mmap();
368        Ok(())
369    }
370
371    /// Insert / replace several entries with ONE lock, read and rewrite of the
372    /// shard. `put` costs a full shard rewrite and fsync per call, so a shell
373    /// that sources a hundred files for the first time would pay that a hundred
374    /// times; the sourced-file path buffers and flushes here instead.
375    pub fn put_many(&self, batch: Vec<PendingPut>) -> Result<(), String> {
376        if batch.is_empty() {
377            return Ok(());
378        }
379        let _lock = match acquire_lock(&self.lock_path) {
380            Some(l) => l,
381            None => return Ok(()),
382        };
383        let mut shard = match read_owned_shard(&self.path) {
384            Some(s)
385                if s.header.zshrs_version == env!("CARGO_PKG_VERSION")
386                    && s.header.pointer_width as usize == std::mem::size_of::<usize>()
387                    && s.header.format_version == SHARD_FORMAT_VERSION =>
388            {
389                s
390            }
391            _ => fresh_shard(),
392        };
393        self.fold_runs(&mut shard);
394        let (bin_mtime, bin_len) = current_binary_identity().unwrap_or((0, 0));
395        for p in batch {
396            shard.entries.insert(
397                p.path,
398                ScriptEntry {
399                    mtime_secs: p.mtime_secs,
400                    mtime_nsecs: p.mtime_nsecs,
401                    binary_mtime_at_cache: bin_mtime,
402                    binary_len_at_cache: bin_len,
403                    cached_at_secs: now_secs(),
404                    env_fingerprint: p.env_fingerprint,
405                    chunk_blob: p.blob,
406                },
407            );
408        }
409        shard.header.built_at_secs = now_secs() as u64;
410        write_shard_atomic(&self.path, &shard)?;
411        let _ = std::fs::remove_file(self.runs_path());
412        self.invalidate_mmap();
413        Ok(())
414    }
415
416    /// `(count    /// `(count, total_blob_bytes)` snapshot.
417    pub fn stats(&self) -> (i64, i64) {
418        self.ensure_mmap();
419        let guard = self.mmap.lock();
420        let (mut count, mut bytes) = guard.as_ref().map_or((0, 0), |shard| {
421            let bytes: i64 = shard
422                .shard()
423                .entries
424                .values()
425                .map(|e| e.chunk_blob.len() as i64)
426                .sum();
427            (shard.entry_count() as i64, bytes)
428        });
429        // Buffered, not yet in the shard (see `list_scripts`).
430        let mut seen = std::collections::HashSet::new();
431        for p in PENDING.lock().iter() {
432            let in_shard = guard
433                .as_ref()
434                .is_some_and(|s| s.shard().entries.get(p.path.as_str()).is_some());
435            if !in_shard && seen.insert(p.path.clone()) {
436                count += 1;
437                bytes += p.blob.len() as i64;
438            }
439        }
440        (count, bytes)
441    }
442
443    /// `(path, chunk_kb, version, cached_at_localstr)` per entry,
444    /// sorted by `cached_at` desc.
445    pub fn list_scripts(&self) -> Vec<(String, f64, String, String)> {
446        self.ensure_mmap();
447        let guard = self.mmap.lock();
448        let v = guard
449            .as_ref()
450            .map(|s| s.shard().header.zshrs_version.as_str().to_string())
451            .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
452        let mut out: Vec<(String, f64, String, String, i64)> = guard
453            .as_ref()
454            .map(|shard| {
455                shard
456                    .shard()
457                    .entries
458                    .iter()
459                    .map(|(k, e)| {
460                        let chunk_kb = e.chunk_blob.len() as f64 / 1024.0;
461                        let cached_at: i64 = e.cached_at_secs.into();
462                        let ts = format_local_ts(cached_at);
463                        (k.as_str().to_string(), chunk_kb, v.clone(), ts, cached_at)
464                    })
465                    .collect()
466            })
467            .unwrap_or_default();
468        self.overlay_runs(&mut out);
469        // Entries buffered in this process (or inherited by a forked `$(...)`)
470        // are not in the shard yet; they are the newest word on their path.
471        let now = now_secs();
472        for p in PENDING.lock().iter() {
473            out.retain(|row| row.0 != p.path);
474            out.push((
475                p.path.clone(),
476                p.blob.len() as f64 / 1024.0,
477                v.clone(),
478                format_local_ts(now),
479                now,
480            ));
481        }
482        out.sort_by_key(|x| std::cmp::Reverse(x.4));
483        out.into_iter()
484            .map(|(p, ck, ver, ts, _)| (p, ck, ver, ts))
485            .collect()
486    }
487
488    /// `scripts.runs`, the append-only log of cache hits (`unix_secs\tpath`).
489    fn runs_path(&self) -> PathBuf {
490        self.path.with_file_name("scripts.runs")
491    }
492
493    /// Raise each row's time to the newest logged run of its path, so a script
494    /// replayed from the cache lists as run now rather than as stored then.
495    fn overlay_runs(&self, rows: &mut [(String, f64, String, String, i64)]) {
496        let mut ran = read_runs(&self.runs_path());
497        for (path, ts) in RAN.lock().iter() {
498            let t = ran.entry(path.clone()).or_insert(*ts);
499            *t = (*t).max(*ts);
500        }
501        for row in rows.iter_mut() {
502            if let Some(&ts) = ran.get(&row.0).filter(|&&ts| ts > row.4) {
503                row.3 = format_local_ts(ts);
504                row.4 = ts;
505            }
506        }
507    }
508
509    /// Carry logged runs into the entries about to be rewritten, so the log can
510    /// be dropped with the old shard. Called with the shard lock held.
511    fn fold_runs(&self, shard: &mut ScriptShard) {
512        for (path, ts) in read_runs(&self.runs_path()) {
513            if let Some(e) = shard.entries.get_mut(&path) {
514                e.cached_at_secs = e.cached_at_secs.max(ts);
515            }
516        }
517    }
518
519    /// `scripts.db`, the read-only SQLite mirror kept beside the shard. Never
520    /// read on a cache lookup or while running code — `dbview` only.
521    fn mirror_path(&self) -> PathBuf {
522        self.path.with_file_name("scripts.db")
523    }
524
525    /// Bring `scripts.db` in line with the shard. The mirror is derived data:
526    /// it is rebuilt whole whenever the shard's identity (inode, length, mtime)
527    /// differs from the one recorded in its `meta` table, so writers of the
528    /// shard never touch SQLite and a missing or stale file heals itself.
529    fn refresh_mirror(&self, conn: &rusqlite::Connection) -> rusqlite::Result<()> {
530        use rusqlite::OptionalExtension;
531        conn.execute_batch(
532            "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
533             CREATE TABLE IF NOT EXISTS scripts (
534                 path               TEXT PRIMARY KEY,
535                 kind               TEXT NOT NULL,
536                 events             INTEGER NOT NULL,
537                 bytes              INTEGER NOT NULL,
538                 version            TEXT NOT NULL,
539                 cached_at          INTEGER NOT NULL,
540                 source_mtime_secs  INTEGER NOT NULL,
541                 source_mtime_nsecs INTEGER NOT NULL,
542                 env_fingerprint    TEXT NOT NULL,
543                 binary_len         INTEGER NOT NULL
544             );
545             CREATE INDEX IF NOT EXISTS scripts_cached_at ON scripts(cached_at DESC);",
546        )?;
547        let on_disk = std::fs::metadata(&self.path).ok().map(|m| {
548            let (ino, len, mtime_ns) = file_identity(&m);
549            format!("{ino}:{len}:{mtime_ns}")
550        });
551        let recorded: Option<String> = conn
552            .query_row("SELECT value FROM meta WHERE key = 'shard'", [], |r| r.get(0))
553            .optional()?;
554        if recorded == on_disk {
555            return Ok(());
556        }
557        let tx = conn.unchecked_transaction()?;
558        tx.execute("DELETE FROM scripts", [])?;
559        if let Some(shard) = read_owned_shard(&self.path) {
560            let mut insert = tx.prepare(
561                "INSERT OR REPLACE INTO scripts
562                 (path, kind, events, bytes, version, cached_at,
563                  source_mtime_secs, source_mtime_nsecs, env_fingerprint, binary_len)
564                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
565            )?;
566            for (path, e) in &shard.entries {
567                let (kind, events) = match e.chunk_blob.first() {
568                    // bincode of a `Vec<Chunk>` opens with its u64 length.
569                    Some(1) if e.chunk_blob.len() >= 9 => {
570                        let n = u64::from_le_bytes(e.chunk_blob[1..9].try_into().unwrap());
571                        ("events", n as i64)
572                    }
573                    Some(0) => ("script", 1),
574                    _ => ("unknown", 0),
575                };
576                insert.execute(rusqlite::params![
577                    path,
578                    kind,
579                    events,
580                    e.chunk_blob.len() as i64,
581                    shard.header.zshrs_version,
582                    e.cached_at_secs,
583                    e.mtime_secs,
584                    e.mtime_nsecs,
585                    format!("{:016x}", e.env_fingerprint),
586                    e.binary_len_at_cache as i64,
587                ])?;
588            }
589        }
590        match on_disk {
591            Some(id) => tx.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('shard', ?1)", [id])?,
592            None => tx.execute("DELETE FROM meta WHERE key = 'shard'", [])?,
593        };
594        tx.commit()
595    }
596
597    /// `(path, chunk_kb, version, cached_at_localstr)` for every cached script
598    /// whose path contains `pattern`, newest first — read from `scripts.db`
599    /// after refreshing it, plus entries still buffered in this process.
600    /// `None` when the mirror cannot be opened; callers fall back to
601    /// [`Self::list_scripts`].
602    pub fn mirror_rows(&self, pattern: &str) -> Option<Vec<(String, f64, String, String)>> {
603        let _lowfd = crate::lowfd::LowFdGuard::new();
604        let conn = rusqlite::Connection::open(self.mirror_path()).ok()?;
605        conn.busy_timeout(std::time::Duration::from_secs(5)).ok()?;
606        let _ = conn.pragma_update(None, "journal_mode", "WAL");
607        crate::lowfd::register_internal_fds();
608        self.refresh_mirror(&conn).ok()?;
609        let mut stmt = conn
610            .prepare(
611                "SELECT path, bytes, version, cached_at FROM scripts
612                 WHERE instr(path, ?1) > 0 ORDER BY cached_at DESC, path",
613            )
614            .ok()?;
615        let mut out: Vec<(String, f64, String, String, i64)> = stmt
616            .query_map([pattern], |r| {
617                let path: String = r.get(0)?;
618                let bytes: i64 = r.get(1)?;
619                let version: String = r.get(2)?;
620                let cached_at: i64 = r.get(3)?;
621                Ok((path, bytes as f64 / 1024.0, version, format_local_ts(cached_at), cached_at))
622            })
623            .ok()?
624            .filter_map(Result::ok)
625            .collect();
626        self.overlay_runs(&mut out);
627        // Buffered, not yet in the shard (see `list_scripts`).
628        let now = now_secs();
629        for p in PENDING.lock().iter().filter(|p| p.path.contains(pattern)) {
630            out.retain(|row| row.0 != p.path);
631            out.push((
632                p.path.clone(),
633                p.blob.len() as f64 / 1024.0,
634                env!("CARGO_PKG_VERSION").to_string(),
635                format_local_ts(now),
636                now,
637            ));
638        }
639        out.sort_by_key(|x| std::cmp::Reverse(x.4));
640        Some(out.into_iter().map(|(p, kb, v, ts, _)| (p, kb, v, ts)).collect())
641    }
642
643    /// Drop entries whose source file vanished or whose mtime changed.
644    pub fn evict_stale(&self) -> usize {
645        let _lock = match acquire_lock(&self.lock_path) {
646            Some(l) => l,
647            None => return 0,
648        };
649        let mut shard = match read_owned_shard(&self.path) {
650            Some(s) => s,
651            None => return 0,
652        };
653        let before = shard.entries.len();
654        shard.entries.retain(|p, e| match file_mtime(Path::new(p)) {
655            Some((s, ns)) => s == e.mtime_secs && ns == e.mtime_nsecs,
656            None => false,
657        });
658        let evicted = before - shard.entries.len();
659        if evicted > 0 {
660            let _ = write_shard_atomic(&self.path, &shard);
661            self.invalidate_mmap();
662        }
663        evicted
664    }
665    /// `clear` — see implementation.
666    pub fn clear(&self) -> std::io::Result<()> {
667        let _lock = acquire_lock(&self.lock_path);
668        let res = match std::fs::remove_file(&self.path) {
669            Ok(()) => Ok(()),
670            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
671            Err(e) => Err(e),
672        };
673        let _ = std::fs::remove_file(self.runs_path());
674        self.invalidate_mmap();
675        res
676    }
677}
678
679/// `(inode, len, mtime_ns)` — what distinguishes one atomically-renamed shard
680/// from the next.
681fn file_identity(m: &std::fs::Metadata) -> (u64, u64, i64) {
682    (m.ino(), m.len(), m.mtime() * 1_000_000_000 + m.mtime_nsec())
683}
684
685fn acquire_lock(path: &Path) -> Option<nix::fcntl::Flock<File>> {
686    let f = File::options()
687        .read(true)
688        .write(true)
689        .create(true)
690        .truncate(false)
691        .open(path)
692        .ok()?;
693    nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusive).ok()
694}
695
696fn fresh_shard() -> ScriptShard {
697    ScriptShard {
698        header: ShardHeader {
699            magic: SHARD_MAGIC,
700            format_version: SHARD_FORMAT_VERSION,
701            zshrs_version: env!("CARGO_PKG_VERSION").to_string(),
702            pointer_width: std::mem::size_of::<usize>() as u32,
703            built_at_secs: now_secs() as u64,
704        },
705        entries: HashMap::new(),
706    }
707}
708
709fn read_owned_shard(path: &Path) -> Option<ScriptShard> {
710    let bytes = std::fs::read(path).ok()?;
711    let archived = rkyv::check_archived_root::<ScriptShard>(&bytes[..]).ok()?;
712    archived.deserialize(&mut rkyv::Infallible).ok()
713}
714
715fn write_shard_atomic(path: &Path, shard: &ScriptShard) -> Result<(), String> {
716    let bytes = rkyv::to_bytes::<_, 4096>(shard).map_err(|e| format!("rkyv serialize: {}", e))?;
717    // Shared with `autoload_cache`: same temp-then-rename scheme, and
718    // the same obligation to unlink the temp on a failed write and to
719    // reap temps abandoned by processes that died mid-write.
720    crate::atomic_write::write_bytes_atomic(path, &bytes)
721}
722
723fn now_secs() -> i64 {
724    SystemTime::now()
725        .duration_since(UNIX_EPOCH)
726        .map(|d| d.as_secs() as i64)
727        .unwrap_or(0)
728}
729
730fn format_local_ts(secs: i64) -> String {
731    let dt = chrono::DateTime::<chrono::Local>::from(
732        UNIX_EPOCH + std::time::Duration::from_secs(secs.max(0) as u64),
733    );
734    dt.format("%Y-%m-%d %H:%M:%S").to_string()
735}
736/// `file_mtime` — see implementation.
737pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
738    let meta = std::fs::metadata(path).ok()?;
739    Some((meta.mtime(), meta.mtime_nsec()))
740}
741
742/// `(mtime, len)` of the running zshrs binary — the identity a cached chunk
743/// is stamped with, mirroring `autoload_cache::current_binary_identity`.
744fn current_binary_identity() -> Option<(i64, u64)> {
745    static BIN_ID: OnceLock<Option<(i64, u64)>> = OnceLock::new();
746    *BIN_ID.get_or_init(|| {
747        let exe = std::env::current_exe().ok()?;
748        let meta = std::fs::metadata(&exe).ok()?;
749        Some((meta.mtime(), meta.len()))
750    })
751}
752
753/// Default shard path: `$ZSHRS_HOME/scripts.rkyv` (default
754/// `~/.zshrs/scripts.rkyv`).
755///
756/// This was the one cache that ignored `$ZSHRS_HOME`. Every other
757/// store honours it — `autoload_cache::default_cache_path`,
758/// `compsys::cache::default_cache_path`, the daemon's `CachePaths`
759/// (daemon/paths.rs) — so a test or a session pointed at an isolated
760/// home still read and WROTE the real `~/.zshrs/scripts.rkyv`,
761/// which is both a leak out of the isolation and a writer the
762/// isolated run never accounted for.
763pub fn default_cache_path() -> PathBuf {
764    let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
765        PathBuf::from(custom)
766    } else {
767        dirs::home_dir()
768            .unwrap_or_else(|| PathBuf::from("/tmp"))
769            .join(".zshrs")
770    };
771    root.join("scripts.rkyv")
772}
773
774/// Process-local disable flag set by parity-mode flags (`--zsh` etc.)
775/// in bins/zshrs.rs. Preferred over `ZSHRS_CACHE=0` in env so the
776/// env var doesn't leak into `${(k)parameters}` and inflate the
777/// param count vs reference zsh.
778pub static CACHE_DISABLED: std::sync::atomic::AtomicBool =
779    std::sync::atomic::AtomicBool::new(false);
780
781/// `ZSHRS_CACHE=0|false|no` (env) or `CACHE_DISABLED=true` (process-
782/// local) disables the cache entirely.
783pub fn cache_enabled() -> bool {
784    if CACHE_DISABLED.load(std::sync::atomic::Ordering::Relaxed) {
785        return false;
786    }
787    !matches!(
788        std::env::var("ZSHRS_CACHE").as_deref(),
789        Ok("0") | Ok("false") | Ok("no")
790    )
791}
792
793/// Process-wide `ScriptCache` rooted at `default_cache_path()`. `None` when the
794/// cache is disabled or the path could not be opened.
795pub static CACHE: once_cell::sync::Lazy<Option<ScriptCache>> = once_cell::sync::Lazy::new(|| {
796    if !cache_enabled() {
797        return None;
798    }
799    ScriptCache::open(&default_cache_path()).ok()
800});
801
802/// A program being recorded: the chunks of the events a file's loop has
803/// compiled so far. Lives in a thread-local stack (not in the loop's frame) so
804/// `zexit` can finish it — a script that ends in `exit` never returns to the
805/// loop, and used to leave no cache entry at all.
806struct Capture {
807    path: PathBuf,
808    env_fp: u64,
809    /// `ALIAS_GEN` when recording began: each event stores how far the alias
810    /// tables had moved from here when it was lexed (see [`GuardedEvent`]).
811    alias_base: u64,
812    /// A script run by `zshrs FILE` starts from a fresh, deterministic state;
813    /// a sourced file runs inside whatever session sourced it.
814    toplevel: bool,
815    /// The process that began recording. A forked subshell that exits holds a
816    /// copy of its parent's frames; it must not store them as finished.
817    pid: u32,
818    events: Vec<GuardedEvent>,
819}
820
821/// One executed event of a recorded file: its compiled chunk, plus what a
822/// replay needs to know that the chunk is still the program the lexer would
823/// produce now.
824///
825/// A chunk is lexed text, and lexing depends on the alias tables as the
826/// earlier events left them. `alias_delta` is how many alias-table mutations
827/// had happened since the file began when this event was lexed; a replay that
828/// has made a different number by the time it reaches the event (a conditional
829/// `alias` took the other branch) is no longer running the recorded program,
830/// and carries on from `start`, the byte offset of the event in the file, by
831/// lexing the rest of it afresh.
832#[derive(serde::Serialize, serde::Deserialize)]
833pub struct GuardedEvent {
834    pub chunk: fusevm::Chunk,
835    pub alias_delta: u64,
836    pub start: u64,
837}
838
839thread_local! {
840    static CAPTURES: std::cell::RefCell<Vec<Capture>> = const { std::cell::RefCell::new(Vec::new()) };
841}
842
843/// Start recording the events of `path`, which the caller is about to run.
844pub fn capture_begin(path: &Path, env_fp: u64, toplevel: bool) {
845    // Resolved now, not at store time: a script that `cd`s leaves a relative
846    // `path` pointing nowhere, and the entry was silently dropped.
847    let cap = Capture {
848        path: path.canonicalize().unwrap_or_else(|_| path.to_path_buf()),
849        env_fp,
850        alias_base: env_generation().0,
851        toplevel,
852        pid: std::process::id(),
853        events: Vec::new(),
854    };
855    CAPTURES.with(|c| c.borrow_mut().push(cap));
856}
857
858/// Append the chunk of the event about to run to the innermost recording.
859/// `start` is the event's byte offset in the text the loop is reading. Called
860/// BEFORE the event runs, so an `exit` inside it still finds it, and with the
861/// alias tables exactly as the event was lexed under.
862pub fn capture_push(chunk: &fusevm::Chunk, start: usize) {
863    let now = env_generation().0;
864    CAPTURES.with(|c| {
865        if let Some(top) = c.borrow_mut().last_mut() {
866            top.events.push(GuardedEvent {
867                chunk: chunk.clone(),
868                alias_delta: now.wrapping_sub(top.alias_base),
869                start: start as u64,
870            });
871        }
872    });
873}
874
875/// Finish the innermost recording. `complete` is the caller's verdict that the
876/// file ran to its end or to a deliberate `return` / `exit` — not to a parse
877/// error or a failed event.
878pub fn capture_commit(complete: bool) {
879    let Some(cap) = CAPTURES.with(|c| c.borrow_mut().pop()) else {
880        return;
881    };
882    store_capture(cap, complete);
883}
884
885/// `zexit` is committed: every file still being recorded stops here, at the
886/// event that called `exit`, and a replay stops at the same event. Skipped in a
887/// forked child (see [`Capture::pid`]) and when an error flag is live.
888pub fn capture_commit_all_on_exit(errored: bool) {
889    let caps: Vec<Capture> = CAPTURES.with(|c| std::mem::take(&mut *c.borrow_mut()));
890    for cap in caps.into_iter().rev() {
891        store_capture(cap, !errored);
892    }
893}
894
895fn store_capture(cap: Capture, complete: bool) {
896    // A file that defines aliases is stored like any other: each event
897    // carries the alias-table position it was lexed under (`GuardedEvent`),
898    // and a replay that finds the tables elsewhere finishes the file from
899    // source. Option changes are replayed as the events that make them.
900    if !complete || cap.events.is_empty() || cap.pid != std::process::id() {
901        return;
902    }
903    let _ = cap.toplevel;
904    if let Ok(blob) = bincode::serialize(&cap.events) {
905        let _ = try_save_bytes(&cap.path, BlobKind::Guarded, cap.env_fp, &blob);
906    }
907}
908
909/// What a cached blob holds. A top-level script is one chunk; a sourced file is
910/// the sequence of per-event chunks the `loop()` of c:Src/init.c:155-220
911/// compiled, each lexed under the state the previous ones left behind.
912#[derive(Clone, Copy, PartialEq, Eq, Debug)]
913pub enum BlobKind {
914    /// bincode of one `fusevm::Chunk`.
915    Script = 0,
916    /// bincode of a `Vec<fusevm::Chunk>`, one per executed event. No longer
917    /// written; a stored blob of this kind is simply a miss.
918    Events = 1,
919    /// bincode of a `Vec<GuardedEvent>`: the executed events with the
920    /// alias-table position each was lexed under and its offset in the file.
921    Guarded = 2,
922}
923
924/// Hash of everything the lexer and compiler read from the live shell: the
925/// alias tables, the option store and the emulation mode. Two runs that start
926/// from equal fingerprints lex the same bytes into the same chunks.
927///
928/// The alias tables are large and change rarely, so their hash is reused until
929/// `ALIAS_GEN` moves; the option store is a few hundred bytes and is hashed
930/// whenever `opts_cache::generation` moved since the last call.
931pub fn env_fingerprint() -> u64 {
932    use std::sync::atomic::Ordering;
933    static MEMO: Mutex<Option<(u64, u64, u64, u64)>> = Mutex::new(None);
934    let alias_gen = crate::ported::hashtable::ALIAS_GEN.load(Ordering::Relaxed);
935    let opt_gen = crate::opts_cache::generation();
936    let mut memo = MEMO.lock();
937    if let Some((ag, og, alias_hash, opt_hash)) = *memo {
938        if ag == alias_gen && og == opt_gen {
939            return fnv_mix(alias_hash, opt_hash);
940        }
941    }
942    let alias_hash = hash_alias_tables();
943    let opt_hash = hash_option_store();
944    *memo = Some((alias_gen, opt_gen, alias_hash, opt_hash));
945    fnv_mix(alias_hash, opt_hash)
946}
947
948/// `(ALIAS_GEN, option generation)` — compared before and after a run to learn
949/// whether the run itself changed lexer-visible state.
950pub fn env_generation() -> (u64, u64) {
951    (
952        crate::ported::hashtable::ALIAS_GEN.load(std::sync::atomic::Ordering::Relaxed),
953        crate::opts_cache::generation(),
954    )
955}
956
957fn fnv_mix(a: u64, b: u64) -> u64 {
958    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
959    for byte in a.to_le_bytes().into_iter().chain(b.to_le_bytes()) {
960        h ^= u64::from(byte);
961        h = h.wrapping_mul(0x0000_0100_0000_01b3);
962    }
963    h
964}
965
966fn fnv_bytes(mut h: u64, bytes: &[u8]) -> u64 {
967    for &byte in bytes {
968        h ^= u64::from(byte);
969        h = h.wrapping_mul(0x0000_0100_0000_01b3);
970    }
971    // Field separator, so ("ab","c") and ("a","bc") hash apart.
972    h ^= 0xff;
973    h.wrapping_mul(0x0000_0100_0000_01b3)
974}
975
976fn hash_alias_tables() -> u64 {
977    use crate::ported::hashtable::{aliastab_lock, sufaliastab_lock};
978    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
979    for table in [aliastab_lock(), sufaliastab_lock()] {
980        let Ok(t) = table.read() else { continue };
981        // Bucket order is deterministic for a given insertion history, but the
982        // fingerprint must not depend on history — sort by name.
983        let mut rows: Vec<(&String, &crate::ported::zsh_h::alias)> = t.iter().collect();
984        rows.sort_by(|a, b| a.0.cmp(b.0));
985        for (name, a) in rows {
986            h = fnv_bytes(h, name.as_bytes());
987            h = fnv_bytes(h, a.text.as_bytes());
988            h = fnv_bytes(h, &a.node.flags.to_le_bytes());
989        }
990        h = fnv_bytes(h, b"|");
991    }
992    h
993}
994
995fn hash_option_store() -> u64 {
996    let mut rows: Vec<(String, bool)> = crate::ported::options::opt_state_snapshot()
997        .into_iter()
998        .collect();
999    rows.sort();
1000    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1001    for (name, on) in rows {
1002        h = fnv_bytes(h, name.as_bytes());
1003        h = fnv_bytes(h, &[on as u8]);
1004    }
1005    let emul = crate::ported::options::emulation.load(std::sync::atomic::Ordering::Relaxed);
1006    fnv_bytes(h, &emul.to_le_bytes())
1007}
1008
1009/// Newest logged run time per path in `scripts.runs`; empty when absent.
1010fn read_runs(path: &Path) -> HashMap<String, i64> {
1011    let mut out = HashMap::new();
1012    let text = std::fs::read(path).map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
1013    for line in text.lines() {
1014        let Some((ts, p)) = line.split_once('\t') else { continue };
1015        let Ok(ts) = ts.parse::<i64>() else { continue };
1016        let t = out.entry(p.to_string()).or_insert(ts);
1017        *t = (*t).max(ts);
1018    }
1019    out
1020}
1021
1022/// Append the buffered hits to `scripts.runs` in one write, then deduplicate
1023/// the file when it has grown past [`RUNS_COMPACT_BYTES`].
1024fn append_runs(path: &Path, batch: &[(String, i64)]) {
1025    let lines: String = batch.iter().map(|(p, ts)| format!("{ts}\t{p}\n")).collect();
1026    let appended = std::fs::OpenOptions::new()
1027        .create(true)
1028        .append(true)
1029        .open(path)
1030        .and_then(|mut f| f.write_all(lines.as_bytes()));
1031    if appended.is_err() || std::fs::metadata(path).map_or(0, |m| m.len()) < RUNS_COMPACT_BYTES {
1032        return;
1033    }
1034    let deduped: String = read_runs(path).into_iter().map(|(p, ts)| format!("{ts}\t{p}\n")).collect();
1035    let _ = crate::atomic_write::write_bytes_atomic(path, deduped.as_bytes());
1036}
1037
1038/// Try to load a cached program by source path. Returns `None` on any miss,
1039/// including a different lexer-state fingerprint or a blob of another kind.
1040pub fn try_load_bytes(path: &Path, kind: BlobKind, env_fp: u64) -> Option<Vec<u8>> {
1041    let cache = CACHE.as_ref()?;
1042    let canonical = path.canonicalize().ok()?;
1043    let path_str = canonical.to_string_lossy();
1044    let (mtime_s, mtime_ns) = file_mtime(&canonical)?;
1045    // A file sourced twice in one session: the first run's entry is still
1046    // buffered, and it is the newest word on the path.
1047    let buffered = PENDING.lock().iter().rev().find_map(|p| {
1048        (p.path == path_str && p.mtime_secs == mtime_s && p.mtime_nsecs == mtime_ns && p.env_fingerprint == env_fp)
1049            .then(|| p.blob.clone())
1050    });
1051    let blob = match buffered {
1052        Some(b) => b,
1053        None => cache.get(&path_str, mtime_s, mtime_ns, env_fp)?,
1054    };
1055    let (tag, body) = blob.split_first()?;
1056    if *tag != kind as u8 {
1057        return None;
1058    }
1059    RAN_PID.store(std::process::id(), std::sync::atomic::Ordering::Relaxed);
1060    RAN.lock().push((path_str.into_owned(), now_secs()));
1061    Some(body.to_vec())
1062}
1063
1064/// Store the bincode-encoded program for a script path. `env_fp` is the
1065/// fingerprint taken BEFORE the run: the state the lexer started from, not the
1066/// state the file left behind. Best-effort — cache disabled / canonicalize
1067/// failure / mtime stat failure all return `Ok(())` silently so the caller can
1068/// fire-and-forget.
1069pub fn try_save_bytes(
1070    path: &Path,
1071    kind: BlobKind,
1072    env_fp: u64,
1073    chunk_blob: &[u8],
1074) -> Result<(), String> {
1075    let Some(cache) = CACHE.as_ref() else {
1076        return Ok(());
1077    };
1078    let canonical = match path.canonicalize() {
1079        Ok(p) => p,
1080        Err(_) => return Ok(()),
1081    };
1082    let path_str = canonical.to_string_lossy();
1083    let (mtime_s, mtime_ns) = match file_mtime(&canonical) {
1084        Some(m) => m,
1085        None => return Ok(()),
1086    };
1087    let mut tagged = Vec::with_capacity(chunk_blob.len() + 1);
1088    tagged.push(kind as u8);
1089    tagged.extend_from_slice(chunk_blob);
1090    PENDING_PID.store(std::process::id(), std::sync::atomic::Ordering::Relaxed);
1091    let over = {
1092        let mut pending = PENDING.lock();
1093        pending.push(PendingPut {
1094            path: path_str.into_owned(),
1095            mtime_secs: mtime_s,
1096            mtime_nsecs: mtime_ns,
1097            env_fingerprint: env_fp,
1098            blob: tagged,
1099        });
1100        pending.len() >= PENDING_FLUSH_MAX
1101    };
1102    if over {
1103        try_flush_pending();
1104    }
1105    Ok(())
1106}
1107
1108/// Write out everything `try_save_bytes` buffered, in one shard rewrite. Called
1109/// at the prompt, from `zexit` and from the `atexit` hook, alongside the
1110/// autoload and deparse caches. Cheap when nothing is buffered.
1111pub fn try_flush_pending() {
1112    // Hits first: they are logged beside the shard, never into it, so they
1113    // need neither its lock nor a rewrite.
1114    if RAN_PID.load(std::sync::atomic::Ordering::Relaxed) == std::process::id() {
1115        let ran = std::mem::take(&mut *RAN.lock());
1116        if let (false, Some(cache)) = (ran.is_empty(), CACHE.as_ref()) {
1117            append_runs(&cache.runs_path(), &ran);
1118        }
1119    }
1120    // A forked child leaves its inherited copy alone: the parent writes it, and
1121    // a `dbview scripts` in the child still lists it from the buffer.
1122    if PENDING_PID.load(std::sync::atomic::Ordering::Relaxed) != std::process::id() {
1123        return;
1124    }
1125    let batch = std::mem::take(&mut *PENDING.lock());
1126    if batch.is_empty() {
1127        return;
1128    }
1129    if let Some(cache) = CACHE.as_ref() {
1130        let _ = cache.put_many(batch);
1131    }
1132}
1133/// `stats` — see implementation.
1134pub fn stats() -> Option<(i64, i64)> {
1135    CACHE.as_ref().map(|c| c.stats())
1136}
1137/// `evict_stale` — see implementation.
1138pub fn evict_stale() -> usize {
1139    CACHE.as_ref().map(|c| c.evict_stale()).unwrap_or(0)
1140}
1141/// `clear` — see implementation.
1142pub fn clear() -> bool {
1143    CACHE.as_ref().map(|c| c.clear().is_ok()).unwrap_or(false)
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149    use tempfile::tempdir;
1150
1151    #[test]
1152    fn round_trip() {
1153        let _g = crate::test_util::global_state_lock();
1154        let dir = tempdir().unwrap();
1155        let cache_path = dir.path().join("scripts.rkyv");
1156        let cache = ScriptCache::open(&cache_path).unwrap();
1157
1158        let script_path = dir.path().join("test.zsh");
1159        std::fs::write(&script_path, "echo hi").unwrap();
1160
1161        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
1162        let path_str = script_path.to_string_lossy().to_string();
1163
1164        let blob = vec![1u8, 2, 3, 4, 5];
1165        cache
1166            .put(&path_str, mtime_s, mtime_ns, 0, blob.clone())
1167            .unwrap();
1168
1169        let loaded = cache.get(&path_str, mtime_s, mtime_ns, 0).unwrap();
1170        assert_eq!(loaded, blob);
1171
1172        let (count, _bytes) = cache.stats();
1173        assert_eq!(count, 1);
1174    }
1175
1176    /// `dbview scripts` in a long-lived shell reads through a mapping taken
1177    /// earlier. A script run by ANOTHER process replaces the shard by rename,
1178    /// which this process's mapping never sees on its own: the listing froze
1179    /// at whatever the shard held when it was first read.
1180    #[test]
1181    fn a_write_from_another_process_shows_up_in_a_mapped_reader() {
1182        let _g = crate::test_util::global_state_lock();
1183        let dir = tempdir().unwrap();
1184        let cache_path = dir.path().join("scripts.rkyv");
1185        let reader = ScriptCache::open(&cache_path).unwrap();
1186        let writer = ScriptCache::open(&cache_path).unwrap();
1187
1188        let first = dir.path().join("first.zsh");
1189        let second = dir.path().join("second.zsh");
1190        std::fs::write(&first, "echo 1").unwrap();
1191        std::fs::write(&second, "echo 2").unwrap();
1192        let (s1, n1) = file_mtime(&first).unwrap();
1193        let (s2, n2) = file_mtime(&second).unwrap();
1194
1195        writer.put(&first.to_string_lossy(), s1, n1, 0, vec![1]).unwrap();
1196        assert_eq!(reader.list_scripts().len(), 1, "reader maps the shard");
1197
1198        writer.put(&second.to_string_lossy(), s2, n2, 0, vec![2]).unwrap();
1199        let rows = reader.list_scripts();
1200        assert_eq!(rows.len(), 2, "the replaced shard must be remapped: {rows:?}");
1201        assert_eq!(reader.stats().0, 2);
1202    }
1203
1204    /// `dbview scripts` reads `scripts.db`, which is derived from the shard and
1205    /// rebuilt whenever the shard's identity moved: new entries appear, a
1206    /// replaced entry shows its new state, and a deleted shard empties it.
1207    #[test]
1208    fn the_sqlite_mirror_follows_the_shard() {
1209        let _g = crate::test_util::global_state_lock();
1210        let dir = tempdir().unwrap();
1211        let cache = ScriptCache::open(&dir.path().join("scripts.rkyv")).unwrap();
1212        let one = dir.path().join("one.zsh");
1213        let two = dir.path().join("two.zsh");
1214        std::fs::write(&one, "echo 1").unwrap();
1215        std::fs::write(&two, "echo 2").unwrap();
1216        let (s1, n1) = file_mtime(&one).unwrap();
1217        let (s2, n2) = file_mtime(&two).unwrap();
1218
1219        assert_eq!(cache.mirror_rows("").unwrap().len(), 0, "empty before any write");
1220
1221        cache.put(&one.to_string_lossy(), s1, n1, 0, vec![1u8]).unwrap();
1222        let rows = cache.mirror_rows("").unwrap();
1223        assert_eq!(rows.len(), 1);
1224        assert!(rows[0].0.ends_with("one.zsh"));
1225        assert!(dir.path().join("scripts.db").is_file());
1226
1227        cache.put(&two.to_string_lossy(), s2, n2, 0, vec![2u8; 4096]).unwrap();
1228        assert_eq!(cache.mirror_rows("").unwrap().len(), 2);
1229        let only_two = cache.mirror_rows("two.zsh").unwrap();
1230        assert_eq!(only_two.len(), 1);
1231        assert!(only_two[0].1 > 3.9, "kb is the blob size: {only_two:?}");
1232
1233        cache.clear().unwrap();
1234        assert_eq!(cache.mirror_rows("").unwrap().len(), 0, "deleted shard empties the mirror");
1235    }
1236
1237    /// A script replayed from the cache is a hit, which leaves the shard
1238    /// alone; `dbview scripts` must still list it as run at the hit's time,
1239    /// and the log must be carried into the shard (not lost) when it is
1240    /// next rewritten.
1241    #[test]
1242    fn a_cache_hit_moves_the_listed_time_and_survives_a_rewrite() {
1243        let _g = crate::test_util::global_state_lock();
1244        let dir = tempdir().unwrap();
1245        let cache = ScriptCache::open(&dir.path().join("scripts.rkyv")).unwrap();
1246        let one = dir.path().join("one.zsh");
1247        let two = dir.path().join("two.zsh");
1248        std::fs::write(&one, "echo 1").unwrap();
1249        std::fs::write(&two, "echo 2").unwrap();
1250        let (s1, n1) = file_mtime(&one).unwrap();
1251        let (s2, n2) = file_mtime(&two).unwrap();
1252        let one_key = one.to_string_lossy().to_string();
1253        cache.put(&one_key, s1, n1, 0, vec![1u8]).unwrap();
1254        cache.put(&two.to_string_lossy(), s2, n2, 0, vec![2u8]).unwrap();
1255
1256        let ran_at = now_secs() + 1000;
1257        append_runs(&cache.runs_path(), &[(one_key.clone(), ran_at)]);
1258
1259        let by_path = |rows: Vec<(String, f64, String, String)>| {
1260            rows.into_iter().map(|r| (r.0, r.3)).collect::<Vec<_>>()
1261        };
1262        let listed = by_path(cache.list_scripts());
1263        assert!(listed[0].0.ends_with("one.zsh"), "newest run first: {listed:?}");
1264        assert_eq!(listed[0].1, format_local_ts(ran_at));
1265        let mirrored = by_path(cache.mirror_rows("").unwrap());
1266        assert_eq!(mirrored[0], listed[0]);
1267
1268        // A later store folds the logged run into the entry and drops the log.
1269        let three = dir.path().join("three.zsh");
1270        std::fs::write(&three, "echo 3").unwrap();
1271        let (s3, n3) = file_mtime(&three).unwrap();
1272        cache.put(&three.to_string_lossy(), s3, n3, 0, vec![3u8]).unwrap();
1273        assert!(!cache.runs_path().exists());
1274        let after = by_path(cache.list_scripts());
1275        assert!(after[0].0.ends_with("one.zsh"), "{after:?}");
1276        assert_eq!(after[0].1, format_local_ts(ran_at));
1277    }
1278
1279    #[test]
1280    fn mtime_invalidation() {
1281        let _g = crate::test_util::global_state_lock();
1282        let dir = tempdir().unwrap();
1283        let cache_path = dir.path().join("scripts.rkyv");
1284        let cache = ScriptCache::open(&cache_path).unwrap();
1285
1286        let script_path = dir.path().join("test.zsh");
1287        std::fs::write(&script_path, "echo hi").unwrap();
1288
1289        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
1290        let path_str = script_path.to_string_lossy().to_string();
1291        cache.put(&path_str, mtime_s, mtime_ns, 0, vec![9u8]).unwrap();
1292
1293        assert!(cache.get(&path_str, mtime_s + 1, mtime_ns, 0).is_none());
1294    }
1295
1296    /// Bytecode is not portable between builds, so an entry must be refused
1297    /// whichever way the recorded binary timestamp points. The old
1298    /// `cached < running` test only caught the "cache written by an older
1299    /// build" direction, which meant a binary whose mtime moved BACKWARDS
1300    /// (an earlier build restored over a later one, a `cp -p`, a checkout of
1301    /// a previously-built target dir) replayed the newer build's chunks.
1302    #[test]
1303    fn an_entry_from_another_binary_is_never_served() {
1304        let _g = crate::test_util::global_state_lock();
1305        let dir = tempdir().unwrap();
1306        let cache_path = dir.path().join("scripts.rkyv");
1307        let cache = ScriptCache::open(&cache_path).unwrap();
1308
1309        let script_path = dir.path().join("test.zsh");
1310        std::fs::write(&script_path, "echo hi").unwrap();
1311        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
1312        let path_str = script_path.to_string_lossy().to_string();
1313        cache.put(&path_str, mtime_s, mtime_ns, 0, vec![9u8]).unwrap();
1314        assert_eq!(
1315            cache.get(&path_str, mtime_s, mtime_ns, 0),
1316            Some(vec![9u8]),
1317            "the emitting binary must hit its own entry",
1318        );
1319
1320        // Restamp the entry as if a NEWER build had produced it — the
1321        // direction the old comparison let through.
1322        let mut shard = read_owned_shard(&cache_path).expect("shard readable");
1323        shard
1324            .entries
1325            .get_mut(&path_str)
1326            .expect("entry present")
1327            .binary_mtime_at_cache += 10_000;
1328        write_shard_atomic(&cache_path, &shard).unwrap();
1329        let reopened = ScriptCache::open(&cache_path).unwrap();
1330        assert!(
1331            reopened.get(&path_str, mtime_s, mtime_ns, 0).is_none(),
1332            "a chunk from a newer build was accepted",
1333        );
1334
1335        // Same length, same-second mtime, different build: only the length
1336        // term can reject this one.
1337        let mut shard = read_owned_shard(&cache_path).expect("shard readable");
1338        let entry = shard.entries.get_mut(&path_str).expect("entry present");
1339        entry.binary_mtime_at_cache -= 10_000;
1340        entry.binary_len_at_cache += 1;
1341        write_shard_atomic(&cache_path, &shard).unwrap();
1342        let reopened = ScriptCache::open(&cache_path).unwrap();
1343        assert!(
1344            reopened.get(&path_str, mtime_s, mtime_ns, 0).is_none(),
1345            "a chunk from a same-second build of a different size was accepted",
1346        );
1347    }
1348
1349    #[test]
1350    fn second_put_replaces_first() {
1351        let _g = crate::test_util::global_state_lock();
1352        let dir = tempdir().unwrap();
1353        let cache_path = dir.path().join("scripts.rkyv");
1354        let cache = ScriptCache::open(&cache_path).unwrap();
1355
1356        let p1 = dir.path().join("a.zsh");
1357        let p2 = dir.path().join("b.zsh");
1358        std::fs::write(&p1, "1").unwrap();
1359        std::fs::write(&p2, "2").unwrap();
1360
1361        let (m1s, m1n) = file_mtime(&p1).unwrap();
1362        let (m2s, m2n) = file_mtime(&p2).unwrap();
1363
1364        cache
1365            .put(&p1.to_string_lossy(), m1s, m1n, 0, vec![1u8])
1366            .unwrap();
1367        cache
1368            .put(&p2.to_string_lossy(), m2s, m2n, 0, vec![2u8])
1369            .unwrap();
1370
1371        let (count, _) = cache.stats();
1372        assert_eq!(count, 2);
1373        assert!(cache.get(&p1.to_string_lossy(), m1s, m1n, 0).is_some());
1374        assert!(cache.get(&p2.to_string_lossy(), m2s, m2n, 0).is_some());
1375    }
1376
1377    #[test]
1378    fn corrupt_file_returns_no_mmap() {
1379        let _g = crate::test_util::global_state_lock();
1380        let dir = tempdir().unwrap();
1381        let cache_path = dir.path().join("scripts.rkyv");
1382        std::fs::write(&cache_path, b"this is not a valid rkyv archive").unwrap();
1383        let cache = ScriptCache::open(&cache_path).unwrap();
1384        assert!(cache.get("/nope", 0, 0, 0).is_none());
1385    }
1386
1387    #[test]
1388    fn clear_removes_file() {
1389        let _g = crate::test_util::global_state_lock();
1390        let dir = tempdir().unwrap();
1391        let cache_path = dir.path().join("scripts.rkyv");
1392        let cache = ScriptCache::open(&cache_path).unwrap();
1393
1394        let script_path = dir.path().join("test.zsh");
1395        std::fs::write(&script_path, "echo hi").unwrap();
1396        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
1397        cache
1398            .put(&script_path.to_string_lossy(), mtime_s, mtime_ns, 0, vec![7u8])
1399            .unwrap();
1400        assert!(cache_path.exists());
1401
1402        cache.clear().unwrap();
1403        assert!(!cache_path.exists());
1404    }
1405
1406    // ========================================================
1407    // now_secs — monotonic-ish wall-clock
1408    // ========================================================
1409
1410    #[test]
1411    fn now_secs_is_positive_and_within_realistic_range() {
1412        let _g = crate::test_util::global_state_lock();
1413        let n = now_secs();
1414        // Year 2020 = ~1.58e9 seconds. Year 2100 = ~4.1e9 seconds.
1415        assert!(
1416            (1_577_836_800..4_102_444_800).contains(&n),
1417            "now_secs out of plausible range: {}",
1418            n
1419        );
1420    }
1421
1422    #[test]
1423    fn now_secs_does_not_go_backwards_in_quick_succession() {
1424        let _g = crate::test_util::global_state_lock();
1425        let a = now_secs();
1426        let b = now_secs();
1427        assert!(b >= a, "now_secs went backwards: {} -> {}", a, b);
1428    }
1429
1430    // ========================================================
1431    // format_local_ts — human-readable timestamp
1432    // ========================================================
1433
1434    #[test]
1435    fn format_local_ts_includes_year_and_punctuation() {
1436        let _g = crate::test_util::global_state_lock();
1437        // 2024-01-01 = 1704067200 UTC; local timezone shifts it but
1438        // the year prefix is stable regardless of TZ.
1439        let s = format_local_ts(1_704_067_200);
1440        assert!(s.starts_with("202"), "expected 21st century year: {}", s);
1441        assert!(s.contains('-'), "expected dash separator: {}", s);
1442        assert!(s.contains(':'), "expected colon separator: {}", s);
1443    }
1444
1445    #[test]
1446    fn format_local_ts_length_matches_pattern() {
1447        let _g = crate::test_util::global_state_lock();
1448        let s = format_local_ts(1_700_000_000);
1449        // `YYYY-MM-DD HH:MM:SS` = 19 chars.
1450        assert_eq!(s.len(), 19, "unexpected width: {}", s);
1451    }
1452
1453    #[test]
1454    fn format_local_ts_handles_zero_secs_via_clamp() {
1455        let _g = crate::test_util::global_state_lock();
1456        // 0 → 1970-01-01 in UTC, but local TZ may shift the date.
1457        let s = format_local_ts(0);
1458        assert_eq!(s.len(), 19);
1459        assert!(s.starts_with("19"), "expected 1970-ish year: {}", s);
1460    }
1461
1462    #[test]
1463    fn format_local_ts_negative_clamped_to_zero() {
1464        let _g = crate::test_util::global_state_lock();
1465        // .max(0) prevents negative seconds reaching chrono.
1466        let s = format_local_ts(-1_000_000);
1467        assert_eq!(s.len(), 19);
1468    }
1469
1470    // ========================================================
1471    // file_mtime — pure metadata sniff
1472    // ========================================================
1473
1474    #[test]
1475    fn file_mtime_returns_some_for_real_file() {
1476        let _g = crate::test_util::global_state_lock();
1477        let dir = tempdir().unwrap();
1478        let p = dir.path().join("foo.zsh");
1479        std::fs::write(&p, b"x").unwrap();
1480        let (s, _ns) = file_mtime(&p).unwrap();
1481        assert!(s > 0);
1482    }
1483
1484    #[test]
1485    fn file_mtime_returns_none_for_missing_path() {
1486        let _g = crate::test_util::global_state_lock();
1487        assert!(file_mtime(Path::new("/nonexistent/zshrs/script_cache_missing.bin")).is_none());
1488    }
1489
1490    // ========================================================
1491    // default_cache_path / cache_enabled — config knobs
1492    // ========================================================
1493
1494    #[test]
1495    fn default_cache_path_ends_in_scripts_rkyv() {
1496        let _g = crate::test_util::global_state_lock();
1497        let p = default_cache_path();
1498        assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("scripts.rkyv"));
1499    }
1500
1501    #[test]
1502    fn cache_enabled_true_when_env_unset() {
1503        let _g = crate::test_util::global_state_lock();
1504        let prev = std::env::var_os("ZSHRS_CACHE");
1505        std::env::remove_var("ZSHRS_CACHE");
1506        let on = cache_enabled();
1507        if let Some(v) = prev {
1508            std::env::set_var("ZSHRS_CACHE", v);
1509        }
1510        assert!(on, "cache should be enabled when ZSHRS_CACHE is unset");
1511    }
1512
1513    #[test]
1514    fn cache_enabled_false_when_env_is_zero_false_or_no() {
1515        let _g = crate::test_util::global_state_lock();
1516        let prev = std::env::var_os("ZSHRS_CACHE");
1517        for v in ["0", "false", "no"] {
1518            std::env::set_var("ZSHRS_CACHE", v);
1519            assert!(!cache_enabled(), "ZSHRS_CACHE={} must disable cache", v);
1520        }
1521        if let Some(v) = prev {
1522            std::env::set_var("ZSHRS_CACHE", v);
1523        } else {
1524            std::env::remove_var("ZSHRS_CACHE");
1525        }
1526    }
1527
1528    #[test]
1529    fn cache_enabled_true_for_other_env_values() {
1530        // Truthiness model: only `0|false|no` disable. Anything else
1531        // (including empty string) leaves the cache on.
1532        let _g = crate::test_util::global_state_lock();
1533        let prev = std::env::var_os("ZSHRS_CACHE");
1534        for v in ["1", "true", "yes", "on", ""] {
1535            std::env::set_var("ZSHRS_CACHE", v);
1536            assert!(
1537                cache_enabled(),
1538                "ZSHRS_CACHE={:?} must NOT disable cache",
1539                v
1540            );
1541        }
1542        if let Some(v) = prev {
1543            std::env::set_var("ZSHRS_CACHE", v);
1544        } else {
1545            std::env::remove_var("ZSHRS_CACHE");
1546        }
1547    }
1548}