use std::collections::HashMap;
use std::fs::File;
use std::io::Write as IoWrite;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use memmap2::Mmap;
use parking_lot::Mutex;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use crate::bytecode::CompiledProgram;
pub const SHARD_MAGIC: u32 = 0x41574B52; pub const SHARD_FORMAT_VERSION: u32 = 2;
#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct ShardHeader {
pub magic: u32,
pub format_version: u32,
pub awkrs_version: String,
pub pointer_width: u32,
pub built_at_secs: u64,
}
#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct ScriptEntry {
pub mtime_secs: i64,
pub mtime_nsecs: i64,
pub binary_mtime_at_cache: i64,
pub cached_at_secs: i64,
pub cp_blob: Vec<u8>,
}
#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct ScriptShard {
pub header: ShardHeader,
pub entries: HashMap<String, ScriptEntry>,
}
#[derive(Debug, Clone)]
pub struct CachedScript {
pub cp: CompiledProgram,
}
pub struct MmappedShard {
_mmap: Mmap,
archived: *const ArchivedScriptShard,
}
unsafe impl Send for MmappedShard {}
unsafe impl Sync for MmappedShard {}
impl MmappedShard {
pub fn open(path: &Path) -> Option<Self> {
let file = File::open(path).ok()?;
let mmap = unsafe { Mmap::map(&file).ok()? };
let archived = rkyv::check_archived_root::<ScriptShard>(&mmap[..]).ok()?;
let archived_ptr = archived as *const ArchivedScriptShard;
Some(Self {
_mmap: mmap,
archived: archived_ptr,
})
}
fn shard(&self) -> &ArchivedScriptShard {
unsafe { &*self.archived }
}
fn header_ok(&self) -> bool {
let h = &self.shard().header;
let magic: u32 = h.magic.into();
let fv: u32 = h.format_version.into();
let pw: u32 = h.pointer_width.into();
magic == SHARD_MAGIC
&& fv == SHARD_FORMAT_VERSION
&& pw as usize == std::mem::size_of::<usize>()
&& h.awkrs_version.as_str() == env!("CARGO_PKG_VERSION")
}
fn lookup(&self, path: &str) -> Option<&ArchivedScriptEntry> {
self.shard().entries.get(path)
}
fn entry_count(&self) -> usize {
self.shard().entries.len()
}
}
pub struct ScriptCache {
path: PathBuf,
lock_path: PathBuf,
mmap: Mutex<Option<MmappedShard>>,
}
#[allow(dead_code)]
impl ScriptCache {
pub fn open(path: &Path) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let parent = path.parent().unwrap_or_else(|| Path::new("/tmp"));
let lock_path = parent.join(format!(
"{}.lock",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("scripts.rkyv")
));
Ok(Self {
path: path.to_path_buf(),
lock_path,
mmap: Mutex::new(None),
})
}
fn ensure_mmap(&self) {
let mut guard = self.mmap.lock();
if guard.is_none() {
*guard = MmappedShard::open(&self.path);
}
}
fn invalidate_mmap(&self) {
let mut guard = self.mmap.lock();
*guard = None;
}
pub fn get(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<CachedScript> {
self.ensure_mmap();
let guard = self.mmap.lock();
let shard = guard.as_ref()?;
if !shard.header_ok() {
return None;
}
let entry = shard.lookup(path)?;
let entry_mtime_s: i64 = entry.mtime_secs.into();
let entry_mtime_ns: i64 = entry.mtime_nsecs.into();
if entry_mtime_s != mtime_secs || entry_mtime_ns != mtime_nsecs {
return None;
}
if let Some(bin_mtime) = current_binary_mtime_secs() {
let cached_bin_mtime: i64 = entry.binary_mtime_at_cache.into();
if cached_bin_mtime < bin_mtime {
return None;
}
}
let cp_bytes: &[u8] = entry.cp_blob.as_slice();
let cp: CompiledProgram = bincode::deserialize(cp_bytes).ok()?;
Some(CachedScript { cp })
}
pub fn put(
&self,
path: &str,
mtime_secs: i64,
mtime_nsecs: i64,
cp: &CompiledProgram,
) -> std::io::Result<()> {
let cp_blob = bincode::serialize(cp).map_err(|e| std::io::Error::other(e.to_string()))?;
let _lock = match acquire_lock(&self.lock_path) {
Some(l) => l,
None => return Ok(()),
};
let mut shard = match read_owned_shard(&self.path) {
Some(s)
if s.header.awkrs_version == env!("CARGO_PKG_VERSION")
&& s.header.pointer_width as usize == std::mem::size_of::<usize>()
&& s.header.format_version == SHARD_FORMAT_VERSION =>
{
s
}
_ => fresh_shard(),
};
let bin_mtime = current_binary_mtime_secs().unwrap_or(0);
let entry = ScriptEntry {
mtime_secs,
mtime_nsecs,
binary_mtime_at_cache: bin_mtime,
cached_at_secs: now_secs(),
cp_blob,
};
shard.entries.insert(path.to_string(), entry);
shard.header.built_at_secs = now_secs() as u64;
write_shard_atomic(&self.path, &shard)?;
self.invalidate_mmap();
Ok(())
}
pub fn stats(&self) -> (i64, i64) {
self.ensure_mmap();
let guard = self.mmap.lock();
let Some(shard) = guard.as_ref() else {
return (0, 0);
};
let count = shard.entry_count() as i64;
let bytes: i64 = shard
.shard()
.entries
.values()
.map(|e| e.cp_blob.len() as i64)
.sum();
(count, bytes)
}
pub fn evict_stale(&self) -> usize {
let _lock = match acquire_lock(&self.lock_path) {
Some(l) => l,
None => return 0,
};
let mut shard = match read_owned_shard(&self.path) {
Some(s) => s,
None => return 0,
};
let before = shard.entries.len();
shard.entries.retain(|p, e| match file_mtime(Path::new(p)) {
Some((s, ns)) => s == e.mtime_secs && ns == e.mtime_nsecs,
None => false,
});
let evicted = before - shard.entries.len();
if evicted > 0 {
let _ = write_shard_atomic(&self.path, &shard);
self.invalidate_mmap();
}
evicted
}
pub fn clear(&self) -> std::io::Result<()> {
let _lock = acquire_lock(&self.lock_path);
let res = match std::fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
};
self.invalidate_mmap();
res
}
}
fn acquire_lock(path: &Path) -> Option<nix::fcntl::Flock<File>> {
let f = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.ok()?;
nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusive).ok()
}
fn fresh_shard() -> ScriptShard {
ScriptShard {
header: ShardHeader {
magic: SHARD_MAGIC,
format_version: SHARD_FORMAT_VERSION,
awkrs_version: env!("CARGO_PKG_VERSION").to_string(),
pointer_width: std::mem::size_of::<usize>() as u32,
built_at_secs: now_secs() as u64,
},
entries: HashMap::new(),
}
}
fn read_owned_shard(path: &Path) -> Option<ScriptShard> {
let bytes = std::fs::read(path).ok()?;
let archived = rkyv::check_archived_root::<ScriptShard>(&bytes[..]).ok()?;
archived.deserialize(&mut rkyv::Infallible).ok()
}
fn write_shard_atomic(path: &Path, shard: &ScriptShard) -> std::io::Result<()> {
let bytes = rkyv::to_bytes::<_, 4096>(shard)
.map_err(|e| std::io::Error::other(format!("rkyv serialize: {}", e)))?;
let parent = path.parent().expect("cache path has parent");
let _ = std::fs::create_dir_all(parent);
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_path = parent.join(format!(
"{}.tmp.{}.{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("scripts.rkyv"),
pid,
nanos
));
{
let mut f = File::create(&tmp_path)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp_path, path)?;
Ok(())
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).ok()?;
Some((meta.mtime(), meta.mtime_nsec()))
}
fn current_binary_mtime_secs() -> Option<i64> {
static BIN_MTIME: OnceLock<Option<i64>> = OnceLock::new();
*BIN_MTIME.get_or_init(|| {
let exe = std::env::current_exe().ok()?;
let (secs, _) = file_mtime(&exe)?;
Some(secs)
})
}
pub fn default_cache_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".awkrs/scripts.rkyv")
}
pub fn cache_enabled() -> bool {
!matches!(
std::env::var("AWKRS_CACHE").as_deref(),
Ok("0") | Ok("false") | Ok("no")
)
}
pub static CACHE: once_cell::sync::Lazy<Option<ScriptCache>> = once_cell::sync::Lazy::new(|| {
if !cache_enabled() {
return None;
}
ScriptCache::open(&default_cache_path()).ok()
});
pub fn try_load(path: &Path) -> Option<CompiledProgram> {
let cache = CACHE.as_ref()?;
let canonical = path.canonicalize().ok()?;
let path_str = canonical.to_string_lossy();
let (mtime_s, mtime_ns) = file_mtime(&canonical)?;
cache.get(&path_str, mtime_s, mtime_ns).map(|c| c.cp)
}
pub fn try_save(path: &Path, cp: &CompiledProgram) {
let Some(cache) = CACHE.as_ref() else {
return;
};
let Ok(canonical) = path.canonicalize() else {
return;
};
let path_str = canonical.to_string_lossy();
let Some((mtime_s, mtime_ns)) = file_mtime(&canonical) else {
return;
};
let _ = cache.put(&path_str, mtime_s, mtime_ns, cp);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::Compiler;
use crate::parser::parse_program;
use tempfile::tempdir;
fn compile(src: &str) -> CompiledProgram {
Compiler::compile_program(&parse_program(src).unwrap()).unwrap()
}
#[test]
fn round_trip() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { print 42 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
let cp = compile("BEGIN { print 42 }");
cache
.put(&script_path.to_string_lossy(), s, ns, &cp)
.unwrap();
let loaded = cache.get(&script_path.to_string_lossy(), s, ns).unwrap();
assert_eq!(loaded.cp.begin_chunks.len(), cp.begin_chunks.len());
assert_eq!(loaded.cp.slot_count, cp.slot_count);
}
#[test]
fn mtime_invalidation() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { print 1 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
let cp = compile("BEGIN { print 1 }");
cache
.put(&script_path.to_string_lossy(), s, ns, &cp)
.unwrap();
assert!(cache
.get(&script_path.to_string_lossy(), s + 1, ns)
.is_none());
}
#[test]
fn second_put_adds_entry() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p1 = dir.path().join("a.awk");
let p2 = dir.path().join("b.awk");
std::fs::write(&p1, "BEGIN { print 1 }").unwrap();
std::fs::write(&p2, "BEGIN { print 2 }").unwrap();
let (s1, n1) = file_mtime(&p1).unwrap();
let (s2, n2) = file_mtime(&p2).unwrap();
cache
.put(&p1.to_string_lossy(), s1, n1, &compile("BEGIN { print 1 }"))
.unwrap();
cache
.put(&p2.to_string_lossy(), s2, n2, &compile("BEGIN { print 2 }"))
.unwrap();
let (count, _) = cache.stats();
assert_eq!(count, 2);
assert!(cache.get(&p1.to_string_lossy(), s1, n1).is_some());
assert!(cache.get(&p2.to_string_lossy(), s2, n2).is_some());
}
#[test]
fn corrupt_file_returns_none() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
std::fs::write(&cache_path, b"garbage not a real rkyv archive").unwrap();
let cache = ScriptCache::open(&cache_path).unwrap();
assert!(cache.get("/nope", 0, 0).is_none());
}
#[test]
fn clear_removes_file() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
cache
.put(
&script_path.to_string_lossy(),
s,
ns,
&compile("BEGIN { 1 }"),
)
.unwrap();
assert!(cache_path.exists());
cache.clear().unwrap();
assert!(!cache_path.exists());
}
fn write_shard_with_header(
dir: &std::path::Path,
header: ShardHeader,
script_path: &str,
mtime_s: i64,
mtime_ns: i64,
bin_mtime: i64,
cp: &CompiledProgram,
) -> PathBuf {
let cache_path = dir.join("scripts.rkyv");
let mut entries = HashMap::new();
entries.insert(
script_path.to_string(),
ScriptEntry {
mtime_secs: mtime_s,
mtime_nsecs: mtime_ns,
binary_mtime_at_cache: bin_mtime,
cached_at_secs: 0,
cp_blob: bincode::serialize(cp).unwrap(),
},
);
let shard = ScriptShard { header, entries };
write_shard_atomic(&cache_path, &shard).unwrap();
cache_path
}
#[test]
fn format_version_drift_rejects() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
let header = ShardHeader {
magic: SHARD_MAGIC,
format_version: SHARD_FORMAT_VERSION + 1,
awkrs_version: env!("CARGO_PKG_VERSION").to_string(),
pointer_width: std::mem::size_of::<usize>() as u32,
built_at_secs: 0,
};
let cache_path = write_shard_with_header(
dir.path(),
header,
&script_path.to_string_lossy(),
s,
ns,
i64::MAX, &compile("BEGIN { 1 }"),
);
let cache = ScriptCache::open(&cache_path).unwrap();
assert!(
cache.get(&script_path.to_string_lossy(), s, ns).is_none(),
"format_version drift must invalidate cached entries"
);
}
#[test]
fn awkrs_version_drift_rejects() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
let header = ShardHeader {
magic: SHARD_MAGIC,
format_version: SHARD_FORMAT_VERSION,
awkrs_version: "999.999.999".to_string(),
pointer_width: std::mem::size_of::<usize>() as u32,
built_at_secs: 0,
};
let cache_path = write_shard_with_header(
dir.path(),
header,
&script_path.to_string_lossy(),
s,
ns,
i64::MAX,
&compile("BEGIN { 1 }"),
);
let cache = ScriptCache::open(&cache_path).unwrap();
assert!(
cache.get(&script_path.to_string_lossy(), s, ns).is_none(),
"awkrs_version drift must invalidate cached entries"
);
}
#[test]
fn pointer_width_drift_rejects() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
let wrong_width = if std::mem::size_of::<usize>() == 8 {
4
} else {
8
};
let header = ShardHeader {
magic: SHARD_MAGIC,
format_version: SHARD_FORMAT_VERSION,
awkrs_version: env!("CARGO_PKG_VERSION").to_string(),
pointer_width: wrong_width as u32,
built_at_secs: 0,
};
let cache_path = write_shard_with_header(
dir.path(),
header,
&script_path.to_string_lossy(),
s,
ns,
i64::MAX,
&compile("BEGIN { 1 }"),
);
let cache = ScriptCache::open(&cache_path).unwrap();
assert!(
cache.get(&script_path.to_string_lossy(), s, ns).is_none(),
"pointer_width drift must invalidate cached entries"
);
}
#[test]
fn binary_mtime_invalidation_rejects() {
let dir = tempdir().unwrap();
let script_path = dir.path().join("t.awk");
std::fs::write(&script_path, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&script_path).unwrap();
let header = ShardHeader {
magic: SHARD_MAGIC,
format_version: SHARD_FORMAT_VERSION,
awkrs_version: env!("CARGO_PKG_VERSION").to_string(),
pointer_width: std::mem::size_of::<usize>() as u32,
built_at_secs: 0,
};
let cache_path = write_shard_with_header(
dir.path(),
header,
&script_path.to_string_lossy(),
s,
ns,
0, &compile("BEGIN { 1 }"),
);
let cache = ScriptCache::open(&cache_path).unwrap();
if current_binary_mtime_secs().unwrap_or(0) > 0 {
assert!(
cache.get(&script_path.to_string_lossy(), s, ns).is_none(),
"entry with bin_mtime_at_cache < running binary mtime must miss"
);
}
}
#[test]
fn evict_stale_removes_vanished_sources() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p1 = dir.path().join("keep.awk");
let p2 = dir.path().join("vanish.awk");
std::fs::write(&p1, "BEGIN { 1 }").unwrap();
std::fs::write(&p2, "BEGIN { 2 }").unwrap();
let (s1, n1) = file_mtime(&p1).unwrap();
let (s2, n2) = file_mtime(&p2).unwrap();
cache
.put(&p1.to_string_lossy(), s1, n1, &compile("BEGIN { 1 }"))
.unwrap();
cache
.put(&p2.to_string_lossy(), s2, n2, &compile("BEGIN { 2 }"))
.unwrap();
std::fs::remove_file(&p2).unwrap();
let evicted = cache.evict_stale();
assert_eq!(evicted, 1, "vanished source must be evicted");
assert!(cache.get(&p1.to_string_lossy(), s1, n1).is_some());
assert!(cache.get(&p2.to_string_lossy(), s2, n2).is_none());
}
#[test]
fn evict_stale_removes_mtime_changed() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p = dir.path().join("t.awk");
std::fs::write(&p, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&p).unwrap();
cache
.put(&p.to_string_lossy(), s, ns, &compile("BEGIN { 1 }"))
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&p, "BEGIN { 2 }").unwrap();
let (s2, _ns2) = file_mtime(&p).unwrap();
assert!(s2 >= s, "rewrite did not advance mtime — fs precision?");
let evicted = cache.evict_stale();
assert_eq!(evicted, 1, "mtime-changed source must be evicted");
}
#[test]
fn evict_stale_returns_zero_when_clean() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p = dir.path().join("t.awk");
std::fs::write(&p, "BEGIN { 1 }").unwrap();
let (s, ns) = file_mtime(&p).unwrap();
cache
.put(&p.to_string_lossy(), s, ns, &compile("BEGIN { 1 }"))
.unwrap();
assert_eq!(cache.evict_stale(), 0);
}
#[test]
fn stats_sums_blob_bytes_across_entries() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
for (i, src) in [
"BEGIN { 1 }",
"BEGIN { print 2 }",
"BEGIN { x = 3; print x }",
]
.iter()
.enumerate()
{
let p = dir.path().join(format!("s{i}.awk"));
std::fs::write(&p, src).unwrap();
let (s, ns) = file_mtime(&p).unwrap();
cache
.put(&p.to_string_lossy(), s, ns, &compile(src))
.unwrap();
}
let (count, bytes) = cache.stats();
assert_eq!(count, 3);
assert!(bytes > 0, "stats must sum non-zero cp_blob bytes");
}
#[test]
fn parallel_safe_round_trips_true() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p = dir.path().join("t.awk");
std::fs::write(&p, "{ print $1 }").unwrap();
let (s, ns) = file_mtime(&p).unwrap();
let cp = compile("{ print $1 }");
assert!(
cp.parallel_safe,
"control: simple field-print is parallel-safe"
);
cache.put(&p.to_string_lossy(), s, ns, &cp).unwrap();
let loaded = cache.get(&p.to_string_lossy(), s, ns).unwrap();
assert!(
loaded.cp.parallel_safe,
"parallel_safe must survive cache roundtrip"
);
assert_eq!(loaded.cp.prog_rules_len, cp.prog_rules_len);
}
#[test]
fn parallel_safe_round_trips_false() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p = dir.path().join("t.awk");
let src = "/a/,/b/ { print }";
std::fs::write(&p, src).unwrap();
let (s, ns) = file_mtime(&p).unwrap();
let cp = compile(src);
assert!(
!cp.parallel_safe,
"control: range patterns are not parallel-safe"
);
cache.put(&p.to_string_lossy(), s, ns, &cp).unwrap();
let loaded = cache.get(&p.to_string_lossy(), s, ns).unwrap();
assert!(
!loaded.cp.parallel_safe,
"parallel_safe=false must survive cache roundtrip"
);
}
#[test]
fn prog_rules_len_round_trips_multiple_rules() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join("scripts.rkyv");
let cache = ScriptCache::open(&cache_path).unwrap();
let p = dir.path().join("t.awk");
let src = "{ print $1 } { print $2 } /foo/ { print $3 }";
std::fs::write(&p, src).unwrap();
let (s, ns) = file_mtime(&p).unwrap();
let cp = compile(src);
assert_eq!(cp.prog_rules_len, 3, "control: source has 3 rules");
cache.put(&p.to_string_lossy(), s, ns, &cp).unwrap();
let loaded = cache.get(&p.to_string_lossy(), s, ns).unwrap();
assert_eq!(
loaded.cp.prog_rules_len, 3,
"prog_rules_len must survive cache roundtrip"
);
}
}