Skip to main content

harn_vm/
bytecode_cache.rs

1//! Content-addressed on-disk cache for compiled `.harn` pipelines.
2//!
3//! Cold-start `harn run` re-parses, type-checks, and compiles the entry
4//! pipeline before the VM gets a single instruction to execute. For short
5//! Harn subcommands that wrap a few `llm_call`s in a small pipeline, that
6//! compile cost dominates wall-clock time.
7//!
8//! This module persists [`Chunk`] bytecode under
9//! `$HARN_CACHE_DIR/<source-hash>.harnbc` (XDG-aware). The cache key is
10//! derived from the source plus its compilation context. Entry chunks include
11//! the content of every transitively-imported user file because they compile
12//! the complete program. Module artifacts compile exactly one file and retain
13//! unresolved import specs, so their context includes compiler and embedded
14//! stdlib identity but deliberately excludes user dependencies. Any change to
15//! an artifact's actual compilation inputs flips the key and recompiles it.
16//!
17//! File layout — little-endian throughout:
18//!
19//! ```text
20//! magic        : [u8; 8]   = "HARNBC\0\0"
21//! schema_ver   : u32       = SCHEMA_VERSION
22//! version_len  : u32
23//! harn_version : [u8; version_len]
24//! compiler_tag : u8        bitmask of active CompilerOptions
25//! kind         : u8        1 = entry chunk, 2 = module artifact
26//! source_hash  : [u8; 32]
27//! context_hash : [u8; 32]
28//! payload      : postcard-serialized payload for `kind`
29//! ```
30//!
31//! The header lets a stale binary detect a future-version artifact
32//! without crashing: a magic mismatch, schema mismatch, or version
33//! mismatch is returned as `Ok(None)` so the caller transparently
34//! recompiles. Real I/O errors propagate.
35//!
36//! Concurrency: writes go through [`crate::atomic_io`] (write-tmp, fsync,
37//! rename, fsync parent dir), and parallel invocations on a cache miss race
38//! safely — the last writer wins, but every reader observes a consistent file
39//! because the rename is atomic on every supported filesystem.
40
41use std::fs;
42use std::io::{self, Read as _};
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45
46use serde::{de::DeserializeOwned, Serialize};
47use sha2::{Digest, Sha256};
48
49use crate::chunk::{CachedChunk, Chunk};
50use crate::compiler::CompilerOptions;
51use crate::module_artifact::ModuleArtifact;
52
53struct ImportScan {
54    content: Arc<str>,
55    imports: Vec<Arc<str>>,
56}
57
58type SharedImportScan = Arc<ImportScan>;
59type ImportsFileMemoKey = (PathBuf, u64, i128);
60type ImportsFileMemo =
61    std::sync::Mutex<std::collections::HashMap<ImportsFileMemoKey, SharedImportScan>>;
62
63/// Header magic for all bytecode-cache artifact families.
64pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
65
66/// On-disk format version. Bump when [`CachedChunk`] or the header
67/// layout changes in a backwards-incompatible way.
68/// v5: `ModuleArtifact` gained `public_type_names` (`pub type` exports).
69/// v6: payload encoding replaced with postcard.
70/// v7: exported type schemas moved from eager JSON strings to an initializer
71/// chunk that resolves imported aliases in the module environment.
72pub const SCHEMA_VERSION: u32 = 7;
73
74/// Compile-time Harn release. Cache files written by a different release
75/// are rejected on load.
76pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
77
78/// Build-time fingerprint of the compiler front-end — the lexer, parser, IR,
79/// and code generator — computed in `build.rs` from those crates' source and
80/// baked in via `cargo:rustc-env`. Folded into the cache key so a compiler
81/// change that alters emitted bytecode for unchanged source invalidates stale
82/// entries automatically, within a single version, with no manual cache wipe.
83/// `HARN_VERSION` only busts the cache across release bumps; this closes the
84/// same gap for the within-version compiler edits that masked #2610. See #2621.
85pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
86
87/// Conventional extension for entry-chunk cache files.
88pub const CACHE_EXTENSION: &str = "harnbc";
89
90/// Conventional extension for module-artifact cache files. Distinct from
91/// [`CACHE_EXTENSION`] so the same `.harn` source can have both shipped
92/// adjacent if needed (e.g. when a file is both an executable entry and
93/// imported by other files).
94pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
95
96/// On-disk discriminant for a [`Chunk`] payload.
97const KIND_ENTRY_CHUNK: u8 = 1;
98/// On-disk discriminant for a [`ModuleArtifact`] payload.
99const KIND_MODULE_ARTIFACT: u8 = 2;
100
101/// Environment override for the cache directory. When set, takes
102/// precedence over the XDG and home-directory fallbacks.
103pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
104
105/// Environment override that turns the cache off entirely. Setting this
106/// to `0`, `false`, `no`, or `off` skips both reads and writes; useful
107/// when debugging compiler changes.
108pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
109
110/// Result of a cache lookup. Carries the precomputed key so the caller
111/// can write it back on a miss without rehashing.
112pub struct LookupOutcome {
113    pub key: CacheKey,
114    pub chunk: Option<Chunk>,
115}
116
117/// Cache key components for a single pipeline source. Equality of all
118/// fields is necessary and sufficient for cache reuse.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct CacheKey {
121    pub source_hash: [u8; 32],
122    pub context_hash: [u8; 32],
123    pub harn_version: &'static str,
124    /// Compact tag for active [`CompilerOptions`]. Flipping
125    /// `HARN_DISABLE_OPTIMIZATIONS` between runs would otherwise reuse a
126    /// chunk compiled under the wrong setting.
127    pub compiler_tag: u8,
128}
129
130impl CacheKey {
131    /// Compute the cache key for a `.harn` source file plus its transitive
132    /// user imports. `source` is the entry-file contents; the import
133    /// graph is walked from disk relative to `source_path`.
134    pub fn from_source(source_path: &Path, source: &str) -> Self {
135        let source_hash = sha256(source.as_bytes());
136        let context_hash = hash_transitive_user_imports(source_path, source);
137        Self {
138            source_hash,
139            context_hash,
140            harn_version: HARN_VERSION,
141            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
142        }
143    }
144
145    /// Compute the cache key for one independently-compiled module artifact.
146    ///
147    /// A [`ModuleArtifact`] stores unresolved import specs and never compiles
148    /// dependency contents into the parent artifact. Walking the transitive
149    /// graph here therefore adds cold-start I/O without protecting correctness:
150    /// the runtime loads every dependency under its own source-local key.
151    /// Diagnostic paths are rebound when the artifact is loaded, so adjacent
152    /// and packaged artifacts remain relocatable without aliasing attribution.
153    pub fn from_module_source(source: &str) -> Self {
154        Self {
155            source_hash: sha256(source.as_bytes()),
156            context_hash: module_compilation_context_hash(),
157            harn_version: HARN_VERSION,
158            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
159        }
160    }
161
162    /// Entry-chunk filename for this key. We hash by source content
163    /// alone so two invocations of the same source from different paths
164    /// share a cache entry; the header's compilation-context hash still gates
165    /// reuse on a per-load basis.
166    pub fn filename(&self) -> String {
167        format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
168    }
169
170    /// Module-artifact filename for this complete compilation key. Diagnostic
171    /// source paths are rebound at load time, so identical source and compiler
172    /// inputs share one relocatable artifact across paths.
173    pub fn module_filename(&self) -> String {
174        let mut hasher = Sha256::new();
175        hasher.update(self.source_hash);
176        hasher.update(self.context_hash);
177        hasher.update(self.harn_version.as_bytes());
178        hasher.update([self.compiler_tag]);
179        let identity: [u8; 32] = hasher.finalize().into();
180        format!("{}.{}", hex(&identity), MODULE_CACHE_EXTENSION)
181    }
182}
183
184/// Returns the directory the shared cache lives in. Honors
185/// `$HARN_CACHE_DIR`, then `$XDG_CACHE_HOME/harn/bytecode`, then
186/// `$HOME/.cache/harn/bytecode`. The directory is *not* created here —
187/// [`store`] creates it lazily on write so read-only environments don't
188/// pay an mkdir cost.
189pub fn cache_dir() -> PathBuf {
190    if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
191        return PathBuf::from(custom);
192    }
193    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
194        let xdg = PathBuf::from(xdg);
195        if !xdg.as_os_str().is_empty() {
196            return xdg.join("harn").join("bytecode");
197        }
198    }
199    if let Some(home) = crate::user_dirs::home_dir() {
200        return home.join(".cache").join("harn").join("bytecode");
201    }
202    // Final fallback: a directory beside the binary's working dir. Mostly
203    // hit in tests that scrub HOME from the environment.
204    PathBuf::from(".harn-cache").join("bytecode")
205}
206
207/// Root for `.harnpack` archives unpacked by `harn run <bundle.harnpack>`.
208/// Each verified bundle is replayed into `<root>/<sanitized-bundle-hash>/`
209/// so re-runs reuse the unpacked tree. Honors `$HARN_CACHE_DIR/packs`
210/// when set, otherwise XDG / `$HOME/.cache/harn/packs`.
211pub fn packs_cache_dir() -> PathBuf {
212    if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
213        return PathBuf::from(custom).join("packs");
214    }
215    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
216        let xdg = PathBuf::from(xdg);
217        if !xdg.as_os_str().is_empty() {
218            return xdg.join("harn").join("packs");
219        }
220    }
221    if let Some(home) = crate::user_dirs::home_dir() {
222        return home.join(".cache").join("harn").join("packs");
223    }
224    PathBuf::from(".harn-cache").join("packs")
225}
226
227/// True when the cache is enabled by the current environment.
228pub fn cache_enabled() -> bool {
229    match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
230        Some(value) => !matches!(
231            value.to_ascii_lowercase().as_str(),
232            "0" | "false" | "no" | "off"
233        ),
234        None => true,
235    }
236}
237
238/// Try to load a cached chunk for `source_path` whose contents are
239/// `source`. Returns the key alongside the (optional) chunk so callers
240/// avoid recomputing the key on miss.
241pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
242    let key = CacheKey::from_source(source_path, source);
243    if !cache_enabled() {
244        return LookupOutcome { key, chunk: None };
245    }
246    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
247    if let Some(adjacent) = adjacent_cache_path(source_path) {
248        candidates.push(adjacent);
249    }
250    candidates.push(cache_dir().join(key.filename()));
251    for path in candidates {
252        match read_chunk_if_matches(&path, &key) {
253            Ok(Some(chunk)) => {
254                return LookupOutcome {
255                    key,
256                    chunk: Some(chunk),
257                }
258            }
259            Ok(None) => continue,
260            Err(_) => continue,
261        }
262    }
263    LookupOutcome { key, chunk: None }
264}
265
266/// Persist `chunk` to the shared cache directory under `key`. Atomic: a
267/// temp file is written then renamed into place. Concurrent invocations
268/// on the same key race safely.
269pub fn store(key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
270    if !cache_enabled() {
271        return Ok(());
272    }
273    let dir = cache_dir();
274    fs::create_dir_all(&dir)?;
275    write_atomic_chunk(&dir.join(key.filename()), key, chunk)
276}
277
278/// Write a precompiled entry-chunk artifact to an explicit path, for
279/// use by the `harn precompile` subcommand. The header still records
280/// the key, so adjacent artifacts shipped with source are validated
281/// like any other cache hit.
282pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
283    ensure_parent_dir(path)?;
284    write_atomic_chunk(path, key, chunk)
285}
286
287/// Look up the [`ModuleArtifact`] for `source_path` (whose contents are
288/// `source`). Mirrors [`load`] but for the `.harnmod` family.
289pub fn load_module(source_path: &Path, source: &str) -> ModuleLookupOutcome {
290    let key = CacheKey::from_module_source(source);
291    if !cache_enabled() {
292        return ModuleLookupOutcome {
293            key,
294            artifact: None,
295        };
296    }
297    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
298    if let Some(adjacent) = adjacent_module_cache_path(source_path) {
299        candidates.push(adjacent);
300    }
301    candidates.push(cache_dir().join(key.module_filename()));
302    for path in candidates {
303        match read_module_if_matches(&path, &key, source_path) {
304            Ok(Some(artifact)) => {
305                return ModuleLookupOutcome {
306                    key,
307                    artifact: Some(artifact),
308                }
309            }
310            Ok(None) => continue,
311            Err(_) => continue,
312        }
313    }
314    ModuleLookupOutcome {
315        key,
316        artifact: None,
317    }
318}
319
320/// Persist `artifact` to the shared cache under `key`. Atomic;
321/// concurrent invocations race safely.
322pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
323    if !cache_enabled() {
324        return Ok(());
325    }
326    let dir = cache_dir();
327    fs::create_dir_all(&dir)?;
328    write_atomic_module(&dir.join(key.module_filename()), key, artifact)
329}
330
331/// Write a module artifact to an explicit path.
332pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
333    ensure_parent_dir(path)?;
334    write_atomic_module(path, key, artifact)
335}
336
337/// Result of a [`load_module`] lookup. Carries the precomputed key so
338/// the caller can write it back on a miss without rehashing.
339pub struct ModuleLookupOutcome {
340    pub key: CacheKey,
341    pub artifact: Option<ModuleArtifact>,
342}
343
344/// Path to the adjacent precompiled entry-chunk artifact for
345/// `source_path`. `foo.harn` → `foo.harnbc`.
346pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
347    adjacent_path_with_extension(source_path, CACHE_EXTENSION)
348}
349
350/// Path to the adjacent precompiled module-artifact for `source_path`.
351/// `foo.harn` → `foo.harnmod`.
352pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
353    adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
354}
355
356fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
357    let stem = source_path.file_stem()?;
358    if stem.is_empty() {
359        return None;
360    }
361    let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
362    let mut out = parent.join(stem);
363    out.set_extension(ext);
364    Some(out)
365}
366
367fn ensure_parent_dir(path: &Path) -> io::Result<()> {
368    if let Some(parent) = path.parent() {
369        if !parent.as_os_str().is_empty() {
370            fs::create_dir_all(parent)?;
371        }
372    }
373    Ok(())
374}
375
376fn write_atomic_chunk(target: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
377    let buf = serialize_chunk_artifact(key, chunk)?;
378    crate::atomic_io::atomic_write(target, &buf)
379}
380
381fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
382    let buf = serialize_module_artifact(key, artifact)?;
383    crate::atomic_io::atomic_write(target, &buf)
384}
385
386/// Serialize an entry-chunk artifact (header + payload) to bytes. The
387/// resulting buffer is byte-identical to the file [`store_at`] would
388/// have written for the same `(key, chunk)`. Use this when packaging
389/// artifacts into a container (e.g. `harn pack`) without going through
390/// the filesystem.
391pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
392    let cached = chunk.freeze_for_cache();
393    let payload = serialize_cache_payload(&cached)?;
394    Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
395}
396
397/// Serialize a module artifact (header + payload) to bytes. Companion
398/// to [`serialize_chunk_artifact`] for the `.harnmod` family.
399pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
400    let payload = serialize_cache_payload(artifact)?;
401    Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
402}
403
404fn serialize_cache_payload<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
405    postcard::to_allocvec(value)
406        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
407}
408
409fn deserialize_cache_payload<T: DeserializeOwned>(payload: &[u8]) -> Result<T, String> {
410    let (value, remaining) = postcard::take_from_bytes(payload).map_err(|err| err.to_string())?;
411    if remaining.is_empty() {
412        Ok(value)
413    } else {
414        Err("cache payload contains trailing bytes".to_string())
415    }
416}
417
418fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
419    let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
420    buf.extend_from_slice(MAGIC);
421    buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
422    let version_bytes = HARN_VERSION.as_bytes();
423    buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
424    buf.extend_from_slice(version_bytes);
425    buf.push(key.compiler_tag);
426    buf.push(kind);
427    buf.extend_from_slice(&key.source_hash);
428    buf.extend_from_slice(&key.context_hash);
429    buf.extend_from_slice(payload);
430    buf
431}
432
433/// Parsed cache header. Read by both the chunk and module loaders so the
434/// header-validation logic stays in one place.
435struct ParsedHeader {
436    kind: u8,
437    payload: Vec<u8>,
438}
439
440fn read_header_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ParsedHeader>> {
441    let mut file = match fs::File::open(path) {
442        Ok(f) => f,
443        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
444        Err(err) => return Err(err),
445    };
446    let mut header = [0u8; 8 + 4 + 4];
447    if file.read_exact(&mut header).is_err() {
448        return Ok(None);
449    }
450    if &header[..8] != MAGIC {
451        return Ok(None);
452    }
453    let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
454    if schema != SCHEMA_VERSION {
455        return Ok(None);
456    }
457    let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
458    if version_len > 256 {
459        // Bound the alloc so a corrupted file cannot force an unbounded read.
460        return Ok(None);
461    }
462    let mut version_buf = vec![0u8; version_len];
463    if file.read_exact(&mut version_buf).is_err() {
464        return Ok(None);
465    }
466    if version_buf != key.harn_version.as_bytes() {
467        return Ok(None);
468    }
469    let mut compiler_and_kind = [0u8; 2];
470    if file.read_exact(&mut compiler_and_kind).is_err() {
471        return Ok(None);
472    }
473    if compiler_and_kind[0] != key.compiler_tag {
474        return Ok(None);
475    }
476    let kind = compiler_and_kind[1];
477    let mut hashes = [0u8; 64];
478    if file.read_exact(&mut hashes).is_err() {
479        return Ok(None);
480    }
481    if hashes[..32] != key.source_hash || hashes[32..] != key.context_hash {
482        return Ok(None);
483    }
484    let mut payload = Vec::new();
485    if file.read_to_end(&mut payload).is_err() {
486        return Ok(None);
487    }
488    Ok(Some(ParsedHeader { kind, payload }))
489}
490
491fn read_chunk_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<Chunk>> {
492    let Some(header) = read_header_if_matches(path, key)? else {
493        return Ok(None);
494    };
495    if header.kind != KIND_ENTRY_CHUNK {
496        return Ok(None);
497    }
498    let cached: CachedChunk = match deserialize_cache_payload(&header.payload) {
499        Ok(c) => c,
500        Err(_) => return Ok(None),
501    };
502    Ok(Some(Chunk::from_cached(cached)))
503}
504
505fn read_module_if_matches(
506    path: &Path,
507    key: &CacheKey,
508    source_path: &Path,
509) -> io::Result<Option<ModuleArtifact>> {
510    let Some(header) = read_header_if_matches(path, key)? else {
511        return Ok(None);
512    };
513    if header.kind != KIND_MODULE_ARTIFACT {
514        return Ok(None);
515    }
516    match deserialize_cache_payload::<ModuleArtifact>(&header.payload) {
517        Ok(mut artifact) => {
518            artifact.bind_source_file(source_path);
519            Ok(Some(artifact))
520        }
521        Err(_) => Ok(None),
522    }
523}
524
525/// Compact representation of [`CompilerOptions`] for the cache header.
526/// Independent flags get distinct bits so adding a new flag never
527/// silently changes existing keys when an old binary reads a new
528/// artifact — the header check will fail-closed before we get there
529/// anyway, but mapping to bits also keeps the tag a stable function
530/// of the option set.
531fn compiler_options_tag(options: CompilerOptions) -> u8 {
532    let mut tag: u8 = 0;
533    if options.optimizations_enabled() {
534        tag |= 0b0000_0001;
535    }
536    tag
537}
538
539fn sha256(bytes: &[u8]) -> [u8; 32] {
540    let mut hasher = Sha256::new();
541    hasher.update(bytes);
542    hasher.finalize().into()
543}
544
545fn hex(bytes: &[u8]) -> String {
546    let mut out = String::with_capacity(bytes.len() * 2);
547    for byte in bytes {
548        out.push_str(&format!("{byte:02x}"));
549    }
550    out
551}
552
553/// Lightweight regex-free scan that surfaces user imports without paying
554/// a full lex+parse. False positives only increase cache churn, never
555/// correctness; comments and string literals are skipped so neither a
556/// commented-out import nor a `"import …"` value appearing inside an
557/// unrelated string gates the hash.
558fn collect_user_imports(source: &str) -> Vec<String> {
559    let scrubbed = strip_comments(source);
560    let mut out: Vec<String> = Vec::new();
561    let bytes = scrubbed.as_bytes();
562    let mut i = 0;
563    while i < bytes.len() {
564        if bytes[i] == b'"' {
565            // Skip past any string literal so identifiers inside string
566            // values cannot trigger the keyword match below.
567            match read_string_literal(bytes, i) {
568                Some((_, end)) => {
569                    i = end;
570                    continue;
571                }
572                None => {
573                    i += 1;
574                    continue;
575                }
576            }
577        }
578        if !matches_keyword(bytes, i, b"import") {
579            i += 1;
580            continue;
581        }
582        // Skip past `import` and any selective `{ ... } from` clause; we
583        // only need the source-position of the path string literal.
584        let mut j = i + b"import".len();
585        let mut depth = 0i32;
586        while j < bytes.len() {
587            match bytes[j] {
588                b'"' => {
589                    if let Some((path, end)) = read_string_literal(bytes, j) {
590                        if !path.starts_with("std/") {
591                            out.push(path);
592                        }
593                        i = end;
594                        break;
595                    }
596                    j += 1;
597                }
598                b'{' => {
599                    depth += 1;
600                    j += 1;
601                }
602                b'}' => {
603                    depth -= 1;
604                    j += 1;
605                }
606                b'\n' if depth == 0 => {
607                    // No string literal on this logical line; bail and
608                    // continue scanning after the keyword to avoid an
609                    // infinite loop.
610                    i = j;
611                    break;
612                }
613                _ => j += 1,
614            }
615        }
616        if j >= bytes.len() {
617            break;
618        }
619        if i < j {
620            // Defensive: ensure forward progress when the inner loop
621            // exited without setting `i`.
622            i = j;
623        }
624    }
625    out
626}
627
628fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
629    let end = at + keyword.len();
630    if end > bytes.len() {
631        return false;
632    }
633    if &bytes[at..end] != keyword {
634        return false;
635    }
636    if at > 0 && is_ident_char(bytes[at - 1]) {
637        return false;
638    }
639    if end < bytes.len() && is_ident_char(bytes[end]) {
640        return false;
641    }
642    true
643}
644
645fn is_ident_char(b: u8) -> bool {
646    b.is_ascii_alphanumeric() || b == b'_'
647}
648
649fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
650    debug_assert_eq!(bytes[at], b'"');
651    let mut out = String::new();
652    let mut i = at + 1;
653    while i < bytes.len() {
654        match bytes[i] {
655            b'"' => return Some((out, i + 1)),
656            b'\\' => {
657                if i + 1 >= bytes.len() {
658                    return None;
659                }
660                match bytes[i + 1] {
661                    b'"' => out.push('"'),
662                    b'\\' => out.push('\\'),
663                    b'n' => out.push('\n'),
664                    b'r' => out.push('\r'),
665                    b't' => out.push('\t'),
666                    other => out.push(other as char),
667                }
668                i += 2;
669            }
670            b'\n' => return None,
671            byte => {
672                out.push(byte as char);
673                i += 1;
674            }
675        }
676    }
677    None
678}
679
680fn strip_comments(source: &str) -> String {
681    let bytes = source.as_bytes();
682    let mut out = String::with_capacity(source.len());
683    let mut i = 0;
684    while i < bytes.len() {
685        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
686            while i < bytes.len() && bytes[i] != b'\n' {
687                i += 1;
688            }
689            continue;
690        }
691        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
692            i += 2;
693            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
694                i += 1;
695            }
696            i = (i + 2).min(bytes.len());
697            continue;
698        }
699        if bytes[i] == b'"' {
700            if let Some((_, end)) = read_string_literal(bytes, i) {
701                out.push_str(&source[i..end]);
702                i = end;
703                continue;
704            }
705        }
706        out.push(bytes[i] as char);
707        i += 1;
708    }
709    out
710}
711
712/// Stable digest over every embedded stdlib source. Folded into the
713/// user-file cache key so that bumping a stdlib module (changing its
714/// embedded `.harn` content) invalidates cached user bytecode that may
715/// reference stale function-pool layouts from a prior stdlib snapshot.
716/// `HARN_VERSION` already busts the cache across release bumps; this
717/// closes the same gap for within-version stdlib edits (a frequent
718/// pattern during local development).
719///
720/// Cached in a `OnceLock` because `STDLIB_SOURCES` is a static `const`
721/// slice — the digest is identical for the lifetime of the process.
722fn embedded_stdlib_digest() -> &'static [u8; 32] {
723    use std::sync::OnceLock;
724    static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
725    DIGEST.get_or_init(|| {
726        let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
727            .iter()
728            .map(|src| (src.module, src.source))
729            .collect();
730        entries.sort_by(|a, b| a.0.cmp(b.0));
731        let mut hasher = Sha256::new();
732        for (module, source) in entries {
733            hasher.update(module.as_bytes());
734            hasher.update(b"\0");
735            hasher.update(source.as_bytes());
736            hasher.update(b"\0");
737        }
738        hasher.finalize().into()
739    })
740}
741
742/// Stable compilation context for a source-local module artifact.
743///
744/// Module compilation does not inspect user dependencies. Artifact-local
745/// compiler and stdlib identity belongs in the key; the source path does not,
746/// because it is load context and is rebound after deserialization.
747fn module_compilation_context_hash() -> [u8; 32] {
748    module_compilation_context_hash_fingerprinted(CODEGEN_FINGERPRINT)
749}
750
751fn module_compilation_context_hash_fingerprinted(codegen_fingerprint: &str) -> [u8; 32] {
752    let mut hasher = Sha256::new();
753    hasher.update(b"module-artifact-source-local-v3\0");
754    hasher.update(b"stdlib-digest\0");
755    hasher.update(embedded_stdlib_digest());
756    hasher.update(b"\0codegen-fingerprint\0");
757    hasher.update(codegen_fingerprint.as_bytes());
758    hasher.finalize().into()
759}
760
761/// Walk the user-import graph rooted at `source_path` and produce a
762/// stable hash of every transitively-reachable file. The hash is
763/// order-independent: each visited file is keyed by canonical path and
764/// emitted in sorted order, so reordering imports inside a file does
765/// not invalidate the cache while changing any file's content does.
766///
767/// Embedded stdlib content is folded into the hash too — `collect_user_imports`
768/// deliberately skips `std/*` paths (they resolve to in-binary sources, not
769/// disk files), so without this fold a stdlib edit between development
770/// builds would leave user-file caches pinned to a stale stdlib snapshot.
771fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
772    hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
773}
774
775/// Process-wide memo of `(file content, collect_user_imports(content))` keyed by
776/// the resolved file path plus its stat identity `(len, mtime_ns)`. Walking a
777/// large pipeline's import graph re-encounters the same shared library files for
778/// nearly every module, so without this memo `from_source` re-reads and
779/// re-scans those files hundreds of times in a single cold run. Because the key
780/// includes `(len, mtime_ns)`, any on-disk edit produces a fresh key and the
781/// stale entry is never reused — a warm long-lived process recompiles edited
782/// pipelines correctly. Source and import strings stay shared across graph
783/// walks, while the returned bytes remain identical to the un-memoized path, so
784/// cache keys are byte-for-byte unchanged.
785fn imports_file_memo() -> &'static ImportsFileMemo {
786    use std::sync::OnceLock;
787    static MEMO: OnceLock<ImportsFileMemo> = OnceLock::new();
788    MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
789}
790
791/// Process-wide memo of `Path::canonicalize`. The import-graph walk canonicalizes
792/// the same resolved module paths hundreds of times across a cold `from_source`
793/// fan-out, and each call is a `realpath(3)` syscall. A successful
794/// canonicalization is stable for the process lifetime (the pipeline tree is not
795/// moved mid-run), so it is memoized. A *failed* canonicalization (the path does
796/// not exist yet) is NOT memoized: a file that later appears — or a symlink that
797/// is created — must canonicalize freshly so the folded path key matches what a
798/// cold process would produce. This keeps the memo a pure speed optimization with
799/// byte-identical output.
800fn canonicalize_cached(path: &Path) -> PathBuf {
801    use std::sync::OnceLock;
802    static MEMO: OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>> =
803        OnceLock::new();
804    let memo = MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
805    if let Some(hit) = memo.lock().unwrap().get(path).cloned() {
806        return hit;
807    }
808    match path.canonicalize() {
809        Ok(canonical) => {
810            memo.lock()
811                .unwrap()
812                .insert(path.to_path_buf(), canonical.clone());
813            canonical
814        }
815        // Unresolved path: fall back to the input, but do not memoize, so a file
816        // that appears later canonicalizes correctly on the next walk.
817        Err(_) => path.to_path_buf(),
818    }
819}
820
821fn file_stat_identity(path: &Path) -> Option<(u64, i128)> {
822    let meta = fs::metadata(path).ok()?;
823    let len = meta.len();
824    // Nanosecond mtime where available; fall back to coarse seconds. Any change
825    // to either component on disk invalidates the memo entry.
826    let mtime_ns = meta
827        .modified()
828        .ok()
829        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
830        .map(|d| d.as_nanos() as i128)
831        .unwrap_or(0);
832    Some((len, mtime_ns))
833}
834
835fn scan_imports(content: String) -> SharedImportScan {
836    let imports = collect_user_imports(&content)
837        .into_iter()
838        .map(Arc::from)
839        .collect();
840    Arc::new(ImportScan {
841        content: Arc::from(content),
842        imports,
843    })
844}
845
846/// Read `path` and scan its user imports, memoized by stat identity. On an I/O
847/// error, returns the `ErrorKind` string the un-memoized path folded in (errors
848/// are not memoized — a transient failure should not be sticky).
849fn read_and_scan_imports_cached(path: &Path) -> Result<SharedImportScan, String> {
850    if let Some((len, mtime_ns)) = file_stat_identity(path) {
851        let key = (path.to_path_buf(), len, mtime_ns);
852        if let Some(hit) = imports_file_memo().lock().unwrap().get(&key).cloned() {
853            return Ok(hit);
854        }
855        match fs::read_to_string(path) {
856            Ok(content) => {
857                let entry = scan_imports(content);
858                imports_file_memo()
859                    .lock()
860                    .unwrap()
861                    .insert(key, Arc::clone(&entry));
862                Ok(entry)
863            }
864            Err(err) => Err(err.kind().to_string()),
865        }
866    } else {
867        // No stat (file vanished between resolve and read): fall back to a direct
868        // read so behavior matches the un-memoized path exactly.
869        match fs::read_to_string(path) {
870            Ok(content) => Ok(scan_imports(content)),
871            Err(err) => Err(err.kind().to_string()),
872        }
873    }
874}
875
876/// Inner form of [`hash_transitive_user_imports`] parameterized on the compiler
877/// fingerprint so tests can vary it; production always passes
878/// [`CODEGEN_FINGERPRINT`].
879fn hash_transitive_user_imports_fingerprinted(
880    source_path: &Path,
881    source: &str,
882    codegen_fingerprint: &str,
883) -> [u8; 32] {
884    let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
885        std::collections::BTreeMap::new();
886    let mut frontier: Vec<(PathBuf, Arc<str>)> = collect_user_imports(source)
887        .into_iter()
888        .map(|import| (source_path.to_path_buf(), Arc::from(import)))
889        .collect();
890
891    while let Some((anchor, import)) = frontier.pop() {
892        let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
893            // Unresolved imports get a sentinel keyed by their resolution
894            // anchor so that dropping a real file under that anchor later
895            // produces a different key.
896            let sentinel = anchor.join(format!("__unresolved__/{import}"));
897            visited
898                .entry(sentinel)
899                .or_insert(ImportNode::Unresolved { import });
900            continue;
901        };
902        let canonical = canonicalize_cached(&resolved);
903        if visited.contains_key(&canonical) {
904            continue;
905        }
906        // Per-file read + import-scan is memoized process-wide, keyed by the
907        // file's identity stat `(len, mtime)`. The same handful of core library
908        // modules (`lib/host/*`, `lib/runtime/*`, ...) sit on the import graph of
909        // nearly every module, so a cold `from_source` over a large pipeline used
910        // to re-read and re-scan the same files hundreds of times across the
911        // module-load fan-out. The memo is invalidated automatically the moment a
912        // file's stat changes on disk, so a warm long-lived process still recompiles
913        // edited pipelines correctly. The folded hash bytes are byte-identical to
914        // the un-memoized path (same content + same `collect_user_imports` output),
915        // so cache keys are unchanged. See `imports_file_memo`.
916        match read_and_scan_imports_cached(&resolved) {
917            Ok(scan) => {
918                visited.insert(
919                    canonical.clone(),
920                    ImportNode::Resolved {
921                        content: Arc::clone(&scan.content),
922                    },
923                );
924                for nested_import in &scan.imports {
925                    frontier.push((resolved.clone(), Arc::clone(nested_import)));
926                }
927            }
928            Err(kind) => {
929                visited.insert(canonical, ImportNode::IoError { kind });
930            }
931        }
932    }
933
934    let mut hasher = Sha256::new();
935    hasher.update(b"stdlib-digest\0");
936    hasher.update(embedded_stdlib_digest());
937    hasher.update(b"\0");
938    // Fold in the compiler's code-generation fingerprint so a compiler change
939    // that alters emitted bytecode for unchanged source busts stale cache
940    // entries within a single version — the gap that masked the #2610 fix until
941    // the cache was cleared by hand. See `build.rs` and `CODEGEN_FINGERPRINT`.
942    hasher.update(b"codegen-fingerprint\0");
943    hasher.update(codegen_fingerprint.as_bytes());
944    hasher.update(b"\0");
945    for (path, node) in &visited {
946        hasher.update(path.to_string_lossy().as_bytes());
947        hasher.update(b"\0");
948        match node {
949            ImportNode::Resolved { content } => {
950                hasher.update(b"resolved\0");
951                hasher.update(content.as_bytes());
952            }
953            ImportNode::Unresolved { import } => {
954                hasher.update(b"unresolved\0");
955                hasher.update(import.as_bytes());
956            }
957            ImportNode::IoError { kind } => {
958                hasher.update(b"ioerror\0");
959                hasher.update(kind.as_bytes());
960            }
961        }
962        hasher.update(b"\0");
963    }
964    hasher.finalize().into()
965}
966
967enum ImportNode {
968    Resolved { content: Arc<str> },
969    Unresolved { import: Arc<str> },
970    IoError { kind: String },
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use crate::compile_source;
977
978    #[test]
979    fn header_round_trips_chunk() {
980        let chunk = compile_source("__io_println(\"hello\")").expect("compile");
981        let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
982        let tmp = tempfile::tempdir().unwrap();
983        let path = tmp.path().join("entry.harnbc");
984        store_at(&path, &key, &chunk).expect("write");
985        let loaded = read_chunk_if_matches(&path, &key).unwrap();
986        assert!(loaded.is_some(), "expected cached chunk to load");
987    }
988
989    #[test]
990    fn serialize_chunk_artifact_matches_store_at() {
991        // `serialize_chunk_artifact` packages an artifact into a buffer for
992        // in-memory consumers (e.g. `harn pack` writing into a tar.zst
993        // bundle). The contract is: the resulting bytes match what
994        // `store_at` would have written for the same key+chunk, so the
995        // shipped artifact is byte-identical to the on-disk cache form.
996        let chunk = compile_source("__io_println(\"hi\")").expect("compile");
997        let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
998        let tmp = tempfile::tempdir().unwrap();
999        let on_disk = tmp.path().join("pack.harnbc");
1000        store_at(&on_disk, &key, &chunk).expect("write");
1001        let on_disk_bytes = std::fs::read(&on_disk).unwrap();
1002        let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
1003        assert_eq!(in_memory_bytes, on_disk_bytes);
1004    }
1005
1006    #[test]
1007    fn cache_payload_rejects_trailing_bytes() {
1008        let chunk = compile_source("1 + 1").expect("compile");
1009        let cached = chunk.freeze_for_cache();
1010        let mut payload = serialize_cache_payload(&cached).expect("serialize");
1011        payload.push(0xFF);
1012
1013        assert!(deserialize_cache_payload::<CachedChunk>(&payload).is_err());
1014    }
1015
1016    #[test]
1017    fn header_mismatch_returns_none() {
1018        let chunk = compile_source("1 + 1").expect("compile");
1019        let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
1020        let tmp = tempfile::tempdir().unwrap();
1021        let path = tmp.path().join("a.harnbc");
1022        store_at(&path, &key, &chunk).expect("write");
1023        let other = CacheKey {
1024            source_hash: [0xAB; 32],
1025            context_hash: key.context_hash,
1026            harn_version: HARN_VERSION,
1027            compiler_tag: key.compiler_tag,
1028        };
1029        assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
1030    }
1031
1032    #[test]
1033    fn schema_mismatch_returns_none() {
1034        let chunk = compile_source("1 + 1").expect("compile");
1035        let key = CacheKey::from_source(Path::new("/tmp/schema.harn"), "1 + 1");
1036        let tmp = tempfile::tempdir().unwrap();
1037        let path = tmp.path().join("schema.harnbc");
1038        store_at(&path, &key, &chunk).expect("write");
1039
1040        let mut bytes = std::fs::read(&path).expect("read cache");
1041        bytes[8..12].copy_from_slice(&(SCHEMA_VERSION - 1).to_le_bytes());
1042        std::fs::write(&path, bytes).expect("rewrite cache");
1043
1044        assert!(read_chunk_if_matches(&path, &key).unwrap().is_none());
1045    }
1046
1047    #[test]
1048    fn compiler_tag_mismatch_returns_none() {
1049        let chunk = compile_source("1 + 1").expect("compile");
1050        let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
1051        let tmp = tempfile::tempdir().unwrap();
1052        let path = tmp.path().join("b.harnbc");
1053        store_at(&path, &key, &chunk).expect("write");
1054        let other = CacheKey {
1055            compiler_tag: key.compiler_tag ^ 0xFF,
1056            ..key
1057        };
1058        assert!(
1059            read_chunk_if_matches(&path, &other).unwrap().is_none(),
1060            "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
1061             compiled under the opposite setting"
1062        );
1063    }
1064
1065    #[test]
1066    fn codegen_fingerprint_is_populated() {
1067        // In-workspace builds always hash real compiler sources, so the
1068        // fingerprint must be a non-empty digest; an empty value would silently
1069        // disable the within-version compiler-staleness guard.
1070        assert!(!CODEGEN_FINGERPRINT.is_empty());
1071    }
1072
1073    #[test]
1074    fn codegen_fingerprint_changes_cache_key() {
1075        // A compiler whose code-generation source differs must produce a
1076        // different cache key for the *same* user source, so a stale artifact
1077        // compiled by a prior compiler at the same version misses on load
1078        // rather than being replayed (#2621). The fingerprint is a compile-time
1079        // constant, so exercise the parameterized inner hash directly.
1080        let tmp = tempfile::tempdir().unwrap();
1081        let entry = tmp.path().join("entry.harn");
1082        std::fs::write(&entry, "__io_println(\"hi\")\n").unwrap();
1083        let source = std::fs::read_to_string(&entry).unwrap();
1084        let a = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
1085        let b = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-B");
1086        let a_again = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
1087        assert_ne!(
1088            a, b,
1089            "differing compiler fingerprints must change the cache key"
1090        );
1091        assert_eq!(
1092            a, a_again,
1093            "an unchanged compiler fingerprint must be stable"
1094        );
1095    }
1096
1097    #[test]
1098    fn module_context_hash_tracks_codegen_fingerprint() {
1099        let first = module_compilation_context_hash_fingerprinted("compiler-A");
1100        let second = module_compilation_context_hash_fingerprinted("compiler-B");
1101        assert_ne!(
1102            first, second,
1103            "module artifacts must miss when compiler code generation changes"
1104        );
1105        assert_eq!(
1106            first,
1107            module_compilation_context_hash_fingerprinted("compiler-A"),
1108            "an unchanged module compilation context must be stable"
1109        );
1110    }
1111
1112    #[test]
1113    fn module_key_excludes_dependency_graph_while_entry_key_tracks_it() {
1114        let tmp = tempfile::tempdir().unwrap();
1115        let dependency = tmp.path().join("value.harn");
1116        let importer = tmp.path().join("reader.harn");
1117        let importer_source =
1118            "import { value } from \"./value\"\npub fn read() { return value() }\n";
1119        std::fs::write(&dependency, "pub fn value() { return 1 }\n").unwrap();
1120        std::fs::write(&importer, importer_source).unwrap();
1121
1122        let entry_before = CacheKey::from_source(&importer, importer_source);
1123        let module_before = CacheKey::from_module_source(importer_source);
1124        let dependency_before =
1125            CacheKey::from_module_source(&std::fs::read_to_string(&dependency).unwrap());
1126
1127        std::fs::write(&dependency, "pub fn value() { return 2 }\n").unwrap();
1128        let future = std::fs::metadata(&dependency).unwrap().modified().unwrap()
1129            + std::time::Duration::from_secs(10);
1130        std::fs::OpenOptions::new()
1131            .write(true)
1132            .open(&dependency)
1133            .unwrap()
1134            .set_times(std::fs::FileTimes::new().set_modified(future))
1135            .unwrap();
1136
1137        let entry_after = CacheKey::from_source(&importer, importer_source);
1138        let module_after = CacheKey::from_module_source(importer_source);
1139        let dependency_after =
1140            CacheKey::from_module_source(&std::fs::read_to_string(&dependency).unwrap());
1141
1142        assert_ne!(
1143            entry_before, entry_after,
1144            "entry chunks compile the full graph and must track dependency edits"
1145        );
1146        assert_eq!(
1147            module_before, module_after,
1148            "a parent module artifact must not be invalidated by dependency contents"
1149        );
1150        assert_ne!(
1151            dependency_before, dependency_after,
1152            "the edited dependency must invalidate its own module artifact"
1153        );
1154    }
1155
1156    #[test]
1157    fn module_artifact_is_relocatable_and_rebinds_exact_source_path() {
1158        let source = "pub fn answer() { fn inner() { return 42 }; return inner() }\n";
1159        let first_path = Path::new("/workspace/first/module.harn");
1160        let second_path = Path::new("/workspace/second/module.harn");
1161        let key = CacheKey::from_module_source(source);
1162
1163        let artifact =
1164            crate::module_artifact::compile_module_artifact_from_source(first_path, source)
1165                .expect("compile module");
1166        let first_source_file = first_path.display().to_string();
1167        let second_source_file = second_path.display().to_string();
1168        assert_eq!(
1169            artifact.functions["answer"].chunk.source_file.as_deref(),
1170            Some(first_source_file.as_str())
1171        );
1172
1173        let tmp = tempfile::tempdir().unwrap();
1174        let cache_path = tmp.path().join(key.module_filename());
1175        store_module_at(&cache_path, &key, &artifact).expect("store module");
1176        let first_loaded = read_module_if_matches(&cache_path, &key, first_path)
1177            .expect("read first module")
1178            .expect("first module key matches");
1179        let second_loaded = read_module_if_matches(&cache_path, &key, second_path)
1180            .expect("read second module")
1181            .expect("second module key matches");
1182        assert_eq!(
1183            first_loaded.functions["answer"]
1184                .chunk
1185                .source_file
1186                .as_deref(),
1187            Some(first_source_file.as_str())
1188        );
1189        assert_eq!(
1190            second_loaded.functions["answer"]
1191                .chunk
1192                .source_file
1193                .as_deref(),
1194            Some(second_source_file.as_str())
1195        );
1196        let nested = second_loaded.functions["answer"]
1197            .chunk
1198            .functions
1199            .first()
1200            .expect("nested function survives artifact roundtrip");
1201        assert_eq!(
1202            nested.chunk.source_file.as_deref(),
1203            Some(second_source_file.as_str()),
1204            "rebinding must reach nested compiled functions"
1205        );
1206    }
1207
1208    #[test]
1209    fn source_local_module_artifact_round_trips() {
1210        let source = "import \"./dependency\"\npub fn answer() { return 42 }\n";
1211        let source_path = Path::new("/tmp/source-local-module.harn");
1212        let artifact =
1213            crate::module_artifact::compile_module_artifact_from_source(source_path, source)
1214                .expect("compile module");
1215        let key = CacheKey::from_module_source(source);
1216        let tmp = tempfile::tempdir().unwrap();
1217        let path = tmp.path().join("source-local-module.harnmod");
1218
1219        store_module_at(&path, &key, &artifact).expect("write module artifact");
1220        let loaded = read_module_if_matches(&path, &key, source_path)
1221            .expect("read module artifact")
1222            .expect("matching artifact");
1223
1224        assert_eq!(loaded.imports.len(), 1);
1225        assert_eq!(loaded.imports[0].path, "./dependency");
1226        assert!(loaded.public_names.contains("answer"));
1227    }
1228
1229    #[test]
1230    fn module_artifact_payload_round_trips() {
1231        let source = "pub fn answer() { fn inner() { return 42 }; return inner() }\n";
1232        let source_path = Path::new("/tmp/module-payload.harn");
1233        let artifact =
1234            crate::module_artifact::compile_module_artifact_from_source(source_path, source)
1235                .expect("compile module");
1236
1237        let payload = serialize_cache_payload(&artifact).expect("serialize module artifact");
1238        let round_tripped: ModuleArtifact =
1239            deserialize_cache_payload(&payload).expect("deserialize module artifact");
1240
1241        assert!(round_tripped.public_names.contains("answer"));
1242        assert!(round_tripped.functions["answer"]
1243            .chunk
1244            .functions
1245            .iter()
1246            .any(|function| function.name == "inner"));
1247    }
1248
1249    #[test]
1250    fn collect_user_imports_ignores_stdlib_and_comments() {
1251        let source = r#"
1252            // import "comment/should/be/ignored"
1253            import "std/agents"
1254            import { foo } from "pkg/bar"
1255            import "./relative/path"
1256        "#;
1257        let imports = collect_user_imports(source);
1258        assert_eq!(
1259            imports,
1260            vec!["pkg/bar".to_string(), "./relative/path".to_string()]
1261        );
1262    }
1263
1264    #[test]
1265    fn cache_enabled_respects_env() {
1266        std::env::set_var(CACHE_ENABLED_ENV, "0");
1267        assert!(!cache_enabled());
1268        std::env::set_var(CACHE_ENABLED_ENV, "1");
1269        assert!(cache_enabled());
1270        std::env::remove_var(CACHE_ENABLED_ENV);
1271        assert!(cache_enabled());
1272    }
1273
1274    #[test]
1275    fn import_path_inside_string_literal_is_ignored() {
1276        let source = r#"
1277            const payload = "import { foo } from \"./other\""
1278            import "./real"
1279        "#;
1280        let imports = collect_user_imports(source);
1281        assert_eq!(imports, vec!["./real".to_string()]);
1282    }
1283
1284    #[test]
1285    fn import_hash_is_stable_across_import_order() {
1286        let tmp = tempfile::tempdir().unwrap();
1287        std::fs::write(
1288            tmp.path().join("a.harn"),
1289            "pub fn a() -> int { return 1 }\n",
1290        )
1291        .unwrap();
1292        std::fs::write(
1293            tmp.path().join("b.harn"),
1294            "pub fn b() -> int { return 2 }\n",
1295        )
1296        .unwrap();
1297        let ab = tmp.path().join("entry_ab.harn");
1298        std::fs::write(
1299            &ab,
1300            "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
1301        )
1302        .unwrap();
1303        let ba = tmp.path().join("entry_ba.harn");
1304        std::fs::write(
1305            &ba,
1306            "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
1307        )
1308        .unwrap();
1309        let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
1310        let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
1311        assert_eq!(
1312            hash_ab, hash_ba,
1313            "import-graph hash must be order-independent so reordering imports \
1314             does not bust the cache"
1315        );
1316    }
1317
1318    #[test]
1319    fn import_hash_picks_up_nested_imports() {
1320        let tmp = tempfile::tempdir().unwrap();
1321        std::fs::write(
1322            tmp.path().join("leaf.harn"),
1323            "pub fn x() -> int { return 1 }\n",
1324        )
1325        .unwrap();
1326        std::fs::write(
1327            tmp.path().join("mid.harn"),
1328            "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
1329        )
1330        .unwrap();
1331        let entry = tmp.path().join("entry.harn");
1332        std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
1333
1334        let before =
1335            hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1336        std::fs::write(
1337            tmp.path().join("leaf.harn"),
1338            "pub fn x() -> int { return 999 }\n",
1339        )
1340        .unwrap();
1341        let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1342        assert_ne!(
1343            before, after,
1344            "editing a transitively-imported file must change the import-graph hash"
1345        );
1346    }
1347
1348    #[test]
1349    fn import_hash_busts_on_same_length_edit_in_same_process() {
1350        // The per-file read/scan memo is keyed by `(path, len, mtime_ns)`. The
1351        // hardest case for that key is an edit that preserves byte length: only
1352        // the mtime distinguishes the two versions. Guard that a same-length edit
1353        // to a transitively-imported file, recomputed in the SAME process so the
1354        // memo is warm, still busts the import-graph hash. Without a working
1355        // staleness check a warm long-lived process would replay stale bytecode.
1356        let tmp = tempfile::tempdir().unwrap();
1357        let leaf = tmp.path().join("leaf.harn");
1358        std::fs::write(&leaf, "pub fn x() -> int { return 111 }\n").unwrap();
1359        let entry = tmp.path().join("entry.harn");
1360        std::fs::write(&entry, "import \"./leaf\"\n__io_println(\"hi\")\n").unwrap();
1361
1362        let before =
1363            hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1364
1365        // Same byte length (`111` -> `222`), so the memo must rely on mtime.
1366        // Instead of sleeping out the coarsest plausible mtime granularity,
1367        // push the rewritten file's mtime deterministically into the future so
1368        // the `(path, len, mtime_ns)` stat key changes instantly on every
1369        // filesystem this runs on.
1370        std::fs::write(&leaf, "pub fn x() -> int { return 222 }\n").unwrap();
1371        // Bump from the file's own current mtime by a fixed margin instead of
1372        // sleeping or using a large absolute timestamp literal.
1373        let future = std::fs::metadata(&leaf).unwrap().modified().unwrap()
1374            + std::time::Duration::from_secs(10);
1375        std::fs::OpenOptions::new()
1376            .write(true)
1377            .open(&leaf)
1378            .unwrap()
1379            .set_times(std::fs::FileTimes::new().set_modified(future))
1380            .unwrap();
1381        assert_eq!(
1382            std::fs::metadata(&leaf).unwrap().len(),
1383            33,
1384            "the two leaf versions must be the same byte length for this test to \
1385             exercise the mtime path"
1386        );
1387
1388        let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1389        assert_ne!(
1390            before, after,
1391            "a same-length edit to a transitively-imported file must still change \
1392             the import-graph hash when recomputed in a warm process"
1393        );
1394    }
1395
1396    #[test]
1397    fn import_scan_memo_shares_source_and_import_allocations() {
1398        let tmp = tempfile::tempdir().unwrap();
1399        let source_path = tmp.path().join("module.harn");
1400        std::fs::write(
1401            &source_path,
1402            "import \"./first\"\nimport \"./second\"\npub fn value() -> int { return 7 }\n",
1403        )
1404        .unwrap();
1405
1406        let first = read_and_scan_imports_cached(&source_path).unwrap();
1407        let second = read_and_scan_imports_cached(&source_path).unwrap();
1408
1409        assert!(
1410            std::sync::Arc::ptr_eq(&first, &second),
1411            "a memo hit must reuse the complete scan instead of copying its source and imports"
1412        );
1413        assert_eq!(first.imports.len(), 2);
1414    }
1415
1416    #[test]
1417    fn import_hash_stable_across_repeated_calls_same_process() {
1418        // The memo must be a pure speed optimization: repeated `from_source`
1419        // calls over an unchanged tree (the cold-start module-load fan-out
1420        // pattern) must return byte-identical hashes.
1421        let tmp = tempfile::tempdir().unwrap();
1422        std::fs::write(
1423            tmp.path().join("dep.harn"),
1424            "pub fn d() -> int { return 7 }\n",
1425        )
1426        .unwrap();
1427        let entry = tmp.path().join("entry.harn");
1428        std::fs::write(&entry, "import \"./dep\"\n__io_println(\"hi\")\n").unwrap();
1429        let src = std::fs::read_to_string(&entry).unwrap();
1430        let first = hash_transitive_user_imports(&entry, &src);
1431        for _ in 0..50 {
1432            assert_eq!(
1433                hash_transitive_user_imports(&entry, &src),
1434                first,
1435                "repeated import-graph hashing over an unchanged tree must be stable"
1436            );
1437        }
1438    }
1439}