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