1use std::fs;
42use std::io::{self, Read as _, Write 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
63pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
65
66pub const SCHEMA_VERSION: u32 = 6;
71
72pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
75
76pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
84
85pub const CACHE_EXTENSION: &str = "harnbc";
87
88pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
93
94const KIND_ENTRY_CHUNK: u8 = 1;
96const KIND_MODULE_ARTIFACT: u8 = 2;
98
99pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
102
103pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
107
108pub struct LookupOutcome {
111 pub key: CacheKey,
112 pub chunk: Option<Chunk>,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct CacheKey {
119 pub source_hash: [u8; 32],
120 pub context_hash: [u8; 32],
121 pub harn_version: &'static str,
122 pub compiler_tag: u8,
126}
127
128impl CacheKey {
129 pub fn from_source(source_path: &Path, source: &str) -> Self {
133 let source_hash = sha256(source.as_bytes());
134 let context_hash = hash_transitive_user_imports(source_path, source);
135 Self {
136 source_hash,
137 context_hash,
138 harn_version: HARN_VERSION,
139 compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
140 }
141 }
142
143 pub fn from_module_source(source: &str) -> Self {
152 Self {
153 source_hash: sha256(source.as_bytes()),
154 context_hash: module_compilation_context_hash(),
155 harn_version: HARN_VERSION,
156 compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
157 }
158 }
159
160 pub fn filename(&self) -> String {
165 format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
166 }
167
168 pub fn module_filename(&self) -> String {
172 let mut hasher = Sha256::new();
173 hasher.update(self.source_hash);
174 hasher.update(self.context_hash);
175 hasher.update(self.harn_version.as_bytes());
176 hasher.update([self.compiler_tag]);
177 let identity: [u8; 32] = hasher.finalize().into();
178 format!("{}.{}", hex(&identity), MODULE_CACHE_EXTENSION)
179 }
180}
181
182pub fn cache_dir() -> PathBuf {
188 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
189 return PathBuf::from(custom);
190 }
191 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
192 let xdg = PathBuf::from(xdg);
193 if !xdg.as_os_str().is_empty() {
194 return xdg.join("harn").join("bytecode");
195 }
196 }
197 if let Some(home) = crate::user_dirs::home_dir() {
198 return home.join(".cache").join("harn").join("bytecode");
199 }
200 PathBuf::from(".harn-cache").join("bytecode")
203}
204
205pub fn packs_cache_dir() -> PathBuf {
210 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
211 return PathBuf::from(custom).join("packs");
212 }
213 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
214 let xdg = PathBuf::from(xdg);
215 if !xdg.as_os_str().is_empty() {
216 return xdg.join("harn").join("packs");
217 }
218 }
219 if let Some(home) = crate::user_dirs::home_dir() {
220 return home.join(".cache").join("harn").join("packs");
221 }
222 PathBuf::from(".harn-cache").join("packs")
223}
224
225pub fn cache_enabled() -> bool {
227 match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
228 Some(value) => !matches!(
229 value.to_ascii_lowercase().as_str(),
230 "0" | "false" | "no" | "off"
231 ),
232 None => true,
233 }
234}
235
236pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
240 let key = CacheKey::from_source(source_path, source);
241 if !cache_enabled() {
242 return LookupOutcome { key, chunk: None };
243 }
244 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
245 if let Some(adjacent) = adjacent_cache_path(source_path) {
246 candidates.push(adjacent);
247 }
248 candidates.push(cache_dir().join(key.filename()));
249 for path in candidates {
250 match read_chunk_if_matches(&path, &key) {
251 Ok(Some(chunk)) => {
252 return LookupOutcome {
253 key,
254 chunk: Some(chunk),
255 }
256 }
257 Ok(None) => continue,
258 Err(_) => continue,
259 }
260 }
261 LookupOutcome { key, chunk: None }
262}
263
264pub fn store(key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
268 if !cache_enabled() {
269 return Ok(());
270 }
271 let dir = cache_dir();
272 fs::create_dir_all(&dir)?;
273 write_atomic_chunk(&dir.join(key.filename()), key, chunk)
274}
275
276pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
281 ensure_parent_dir(path)?;
282 write_atomic_chunk(path, key, chunk)
283}
284
285pub fn load_module(source_path: &Path, source: &str) -> ModuleLookupOutcome {
288 let key = CacheKey::from_module_source(source);
289 if !cache_enabled() {
290 return ModuleLookupOutcome {
291 key,
292 artifact: None,
293 };
294 }
295 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
296 if let Some(adjacent) = adjacent_module_cache_path(source_path) {
297 candidates.push(adjacent);
298 }
299 candidates.push(cache_dir().join(key.module_filename()));
300 for path in candidates {
301 match read_module_if_matches(&path, &key, source_path) {
302 Ok(Some(artifact)) => {
303 return ModuleLookupOutcome {
304 key,
305 artifact: Some(artifact),
306 }
307 }
308 Ok(None) => continue,
309 Err(_) => continue,
310 }
311 }
312 ModuleLookupOutcome {
313 key,
314 artifact: None,
315 }
316}
317
318pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
321 if !cache_enabled() {
322 return Ok(());
323 }
324 let dir = cache_dir();
325 fs::create_dir_all(&dir)?;
326 write_atomic_module(&dir.join(key.module_filename()), key, artifact)
327}
328
329pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
331 ensure_parent_dir(path)?;
332 write_atomic_module(path, key, artifact)
333}
334
335pub struct ModuleLookupOutcome {
338 pub key: CacheKey,
339 pub artifact: Option<ModuleArtifact>,
340}
341
342pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
345 adjacent_path_with_extension(source_path, CACHE_EXTENSION)
346}
347
348pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
351 adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
352}
353
354fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
355 let stem = source_path.file_stem()?;
356 if stem.is_empty() {
357 return None;
358 }
359 let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
360 let mut out = parent.join(stem);
361 out.set_extension(ext);
362 Some(out)
363}
364
365fn ensure_parent_dir(path: &Path) -> io::Result<()> {
366 if let Some(parent) = path.parent() {
367 if !parent.as_os_str().is_empty() {
368 fs::create_dir_all(parent)?;
369 }
370 }
371 Ok(())
372}
373
374fn write_atomic_chunk(target: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
375 let buf = serialize_chunk_artifact(key, chunk)?;
376 write_atomic(target, &buf)
377}
378
379fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
380 let buf = serialize_module_artifact(key, artifact)?;
381 write_atomic(target, &buf)
382}
383
384pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
390 let cached = chunk.freeze_for_cache();
391 let payload = serialize_cache_payload(&cached)?;
392 Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
393}
394
395pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
398 let payload = serialize_cache_payload(artifact)?;
399 Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
400}
401
402fn serialize_cache_payload<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
403 postcard::to_allocvec(value)
404 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
405}
406
407fn deserialize_cache_payload<T: DeserializeOwned>(payload: &[u8]) -> Result<T, String> {
408 let (value, remaining) = postcard::take_from_bytes(payload).map_err(|err| err.to_string())?;
409 if remaining.is_empty() {
410 Ok(value)
411 } else {
412 Err("cache payload contains trailing bytes".to_string())
413 }
414}
415
416fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
417 let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
418 buf.extend_from_slice(MAGIC);
419 buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
420 let version_bytes = HARN_VERSION.as_bytes();
421 buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
422 buf.extend_from_slice(version_bytes);
423 buf.push(key.compiler_tag);
424 buf.push(kind);
425 buf.extend_from_slice(&key.source_hash);
426 buf.extend_from_slice(&key.context_hash);
427 buf.extend_from_slice(payload);
428 buf
429}
430
431fn write_atomic(target: &Path, buf: &[u8]) -> io::Result<()> {
432 let tmp_path = atomic_tmp_path(target);
433 let mut tmp_file = fs::File::create(&tmp_path)?;
434 tmp_file.write_all(buf)?;
435 tmp_file.sync_all()?;
436 drop(tmp_file);
437 match fs::rename(&tmp_path, target) {
438 Ok(()) => Ok(()),
439 Err(err) => {
440 let _ = fs::remove_file(&tmp_path);
441 Err(err)
442 }
443 }
444}
445
446fn atomic_tmp_path(target: &Path) -> PathBuf {
447 static NEXT_TMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
448 let id = NEXT_TMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
449 let tmp_name = match target.file_name() {
450 Some(name) => format!(
451 ".{}.{}.{}.tmp",
452 name.to_string_lossy(),
453 std::process::id(),
454 id
455 ),
456 None => format!(".harn-cache.{}.{}.tmp", std::process::id(), id),
457 };
458 target.with_file_name(tmp_name)
459}
460
461struct ParsedHeader {
464 kind: u8,
465 payload: Vec<u8>,
466}
467
468fn read_header_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ParsedHeader>> {
469 let mut file = match fs::File::open(path) {
470 Ok(f) => f,
471 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
472 Err(err) => return Err(err),
473 };
474 let mut header = [0u8; 8 + 4 + 4];
475 if file.read_exact(&mut header).is_err() {
476 return Ok(None);
477 }
478 if &header[..8] != MAGIC {
479 return Ok(None);
480 }
481 let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
482 if schema != SCHEMA_VERSION {
483 return Ok(None);
484 }
485 let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
486 if version_len > 256 {
487 return Ok(None);
489 }
490 let mut version_buf = vec![0u8; version_len];
491 if file.read_exact(&mut version_buf).is_err() {
492 return Ok(None);
493 }
494 if version_buf != key.harn_version.as_bytes() {
495 return Ok(None);
496 }
497 let mut compiler_and_kind = [0u8; 2];
498 if file.read_exact(&mut compiler_and_kind).is_err() {
499 return Ok(None);
500 }
501 if compiler_and_kind[0] != key.compiler_tag {
502 return Ok(None);
503 }
504 let kind = compiler_and_kind[1];
505 let mut hashes = [0u8; 64];
506 if file.read_exact(&mut hashes).is_err() {
507 return Ok(None);
508 }
509 if hashes[..32] != key.source_hash || hashes[32..] != key.context_hash {
510 return Ok(None);
511 }
512 let mut payload = Vec::new();
513 if file.read_to_end(&mut payload).is_err() {
514 return Ok(None);
515 }
516 Ok(Some(ParsedHeader { kind, payload }))
517}
518
519fn read_chunk_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<Chunk>> {
520 let Some(header) = read_header_if_matches(path, key)? else {
521 return Ok(None);
522 };
523 if header.kind != KIND_ENTRY_CHUNK {
524 return Ok(None);
525 }
526 let cached: CachedChunk = match deserialize_cache_payload(&header.payload) {
527 Ok(c) => c,
528 Err(_) => return Ok(None),
529 };
530 Ok(Some(Chunk::from_cached(cached)))
531}
532
533fn read_module_if_matches(
534 path: &Path,
535 key: &CacheKey,
536 source_path: &Path,
537) -> io::Result<Option<ModuleArtifact>> {
538 let Some(header) = read_header_if_matches(path, key)? else {
539 return Ok(None);
540 };
541 if header.kind != KIND_MODULE_ARTIFACT {
542 return Ok(None);
543 }
544 match deserialize_cache_payload::<ModuleArtifact>(&header.payload) {
545 Ok(mut artifact) => {
546 artifact.bind_source_file(source_path);
547 Ok(Some(artifact))
548 }
549 Err(_) => Ok(None),
550 }
551}
552
553fn compiler_options_tag(options: CompilerOptions) -> u8 {
560 let mut tag: u8 = 0;
561 if options.optimizations_enabled() {
562 tag |= 0b0000_0001;
563 }
564 tag
565}
566
567fn sha256(bytes: &[u8]) -> [u8; 32] {
568 let mut hasher = Sha256::new();
569 hasher.update(bytes);
570 hasher.finalize().into()
571}
572
573fn hex(bytes: &[u8]) -> String {
574 let mut out = String::with_capacity(bytes.len() * 2);
575 for byte in bytes {
576 out.push_str(&format!("{byte:02x}"));
577 }
578 out
579}
580
581fn collect_user_imports(source: &str) -> Vec<String> {
587 let scrubbed = strip_comments(source);
588 let mut out: Vec<String> = Vec::new();
589 let bytes = scrubbed.as_bytes();
590 let mut i = 0;
591 while i < bytes.len() {
592 if bytes[i] == b'"' {
593 match read_string_literal(bytes, i) {
596 Some((_, end)) => {
597 i = end;
598 continue;
599 }
600 None => {
601 i += 1;
602 continue;
603 }
604 }
605 }
606 if !matches_keyword(bytes, i, b"import") {
607 i += 1;
608 continue;
609 }
610 let mut j = i + b"import".len();
613 let mut depth = 0i32;
614 while j < bytes.len() {
615 match bytes[j] {
616 b'"' => {
617 if let Some((path, end)) = read_string_literal(bytes, j) {
618 if !path.starts_with("std/") {
619 out.push(path);
620 }
621 i = end;
622 break;
623 }
624 j += 1;
625 }
626 b'{' => {
627 depth += 1;
628 j += 1;
629 }
630 b'}' => {
631 depth -= 1;
632 j += 1;
633 }
634 b'\n' if depth == 0 => {
635 i = j;
639 break;
640 }
641 _ => j += 1,
642 }
643 }
644 if j >= bytes.len() {
645 break;
646 }
647 if i < j {
648 i = j;
651 }
652 }
653 out
654}
655
656fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
657 let end = at + keyword.len();
658 if end > bytes.len() {
659 return false;
660 }
661 if &bytes[at..end] != keyword {
662 return false;
663 }
664 if at > 0 && is_ident_char(bytes[at - 1]) {
665 return false;
666 }
667 if end < bytes.len() && is_ident_char(bytes[end]) {
668 return false;
669 }
670 true
671}
672
673fn is_ident_char(b: u8) -> bool {
674 b.is_ascii_alphanumeric() || b == b'_'
675}
676
677fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
678 debug_assert_eq!(bytes[at], b'"');
679 let mut out = String::new();
680 let mut i = at + 1;
681 while i < bytes.len() {
682 match bytes[i] {
683 b'"' => return Some((out, i + 1)),
684 b'\\' => {
685 if i + 1 >= bytes.len() {
686 return None;
687 }
688 match bytes[i + 1] {
689 b'"' => out.push('"'),
690 b'\\' => out.push('\\'),
691 b'n' => out.push('\n'),
692 b'r' => out.push('\r'),
693 b't' => out.push('\t'),
694 other => out.push(other as char),
695 }
696 i += 2;
697 }
698 b'\n' => return None,
699 byte => {
700 out.push(byte as char);
701 i += 1;
702 }
703 }
704 }
705 None
706}
707
708fn strip_comments(source: &str) -> String {
709 let bytes = source.as_bytes();
710 let mut out = String::with_capacity(source.len());
711 let mut i = 0;
712 while i < bytes.len() {
713 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
714 while i < bytes.len() && bytes[i] != b'\n' {
715 i += 1;
716 }
717 continue;
718 }
719 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
720 i += 2;
721 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
722 i += 1;
723 }
724 i = (i + 2).min(bytes.len());
725 continue;
726 }
727 if bytes[i] == b'"' {
728 if let Some((_, end)) = read_string_literal(bytes, i) {
729 out.push_str(&source[i..end]);
730 i = end;
731 continue;
732 }
733 }
734 out.push(bytes[i] as char);
735 i += 1;
736 }
737 out
738}
739
740fn embedded_stdlib_digest() -> &'static [u8; 32] {
751 use std::sync::OnceLock;
752 static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
753 DIGEST.get_or_init(|| {
754 let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
755 .iter()
756 .map(|src| (src.module, src.source))
757 .collect();
758 entries.sort_by(|a, b| a.0.cmp(b.0));
759 let mut hasher = Sha256::new();
760 for (module, source) in entries {
761 hasher.update(module.as_bytes());
762 hasher.update(b"\0");
763 hasher.update(source.as_bytes());
764 hasher.update(b"\0");
765 }
766 hasher.finalize().into()
767 })
768}
769
770fn module_compilation_context_hash() -> [u8; 32] {
776 module_compilation_context_hash_fingerprinted(CODEGEN_FINGERPRINT)
777}
778
779fn module_compilation_context_hash_fingerprinted(codegen_fingerprint: &str) -> [u8; 32] {
780 let mut hasher = Sha256::new();
781 hasher.update(b"module-artifact-source-local-v3\0");
782 hasher.update(b"stdlib-digest\0");
783 hasher.update(embedded_stdlib_digest());
784 hasher.update(b"\0codegen-fingerprint\0");
785 hasher.update(codegen_fingerprint.as_bytes());
786 hasher.finalize().into()
787}
788
789fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
800 hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
801}
802
803fn imports_file_memo() -> &'static ImportsFileMemo {
814 use std::sync::OnceLock;
815 static MEMO: OnceLock<ImportsFileMemo> = OnceLock::new();
816 MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
817}
818
819fn canonicalize_cached(path: &Path) -> PathBuf {
829 use std::sync::OnceLock;
830 static MEMO: OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>> =
831 OnceLock::new();
832 let memo = MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
833 if let Some(hit) = memo.lock().unwrap().get(path).cloned() {
834 return hit;
835 }
836 match path.canonicalize() {
837 Ok(canonical) => {
838 memo.lock()
839 .unwrap()
840 .insert(path.to_path_buf(), canonical.clone());
841 canonical
842 }
843 Err(_) => path.to_path_buf(),
846 }
847}
848
849fn file_stat_identity(path: &Path) -> Option<(u64, i128)> {
850 let meta = fs::metadata(path).ok()?;
851 let len = meta.len();
852 let mtime_ns = meta
855 .modified()
856 .ok()
857 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
858 .map(|d| d.as_nanos() as i128)
859 .unwrap_or(0);
860 Some((len, mtime_ns))
861}
862
863fn scan_imports(content: String) -> SharedImportScan {
864 let imports = collect_user_imports(&content)
865 .into_iter()
866 .map(Arc::from)
867 .collect();
868 Arc::new(ImportScan {
869 content: Arc::from(content),
870 imports,
871 })
872}
873
874fn read_and_scan_imports_cached(path: &Path) -> Result<SharedImportScan, String> {
878 if let Some((len, mtime_ns)) = file_stat_identity(path) {
879 let key = (path.to_path_buf(), len, mtime_ns);
880 if let Some(hit) = imports_file_memo().lock().unwrap().get(&key).cloned() {
881 return Ok(hit);
882 }
883 match fs::read_to_string(path) {
884 Ok(content) => {
885 let entry = scan_imports(content);
886 imports_file_memo()
887 .lock()
888 .unwrap()
889 .insert(key, Arc::clone(&entry));
890 Ok(entry)
891 }
892 Err(err) => Err(err.kind().to_string()),
893 }
894 } else {
895 match fs::read_to_string(path) {
898 Ok(content) => Ok(scan_imports(content)),
899 Err(err) => Err(err.kind().to_string()),
900 }
901 }
902}
903
904fn hash_transitive_user_imports_fingerprinted(
908 source_path: &Path,
909 source: &str,
910 codegen_fingerprint: &str,
911) -> [u8; 32] {
912 let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
913 std::collections::BTreeMap::new();
914 let mut frontier: Vec<(PathBuf, Arc<str>)> = collect_user_imports(source)
915 .into_iter()
916 .map(|import| (source_path.to_path_buf(), Arc::from(import)))
917 .collect();
918
919 while let Some((anchor, import)) = frontier.pop() {
920 let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
921 let sentinel = anchor.join(format!("__unresolved__/{import}"));
925 visited
926 .entry(sentinel)
927 .or_insert(ImportNode::Unresolved { import });
928 continue;
929 };
930 let canonical = canonicalize_cached(&resolved);
931 if visited.contains_key(&canonical) {
932 continue;
933 }
934 match read_and_scan_imports_cached(&resolved) {
945 Ok(scan) => {
946 visited.insert(
947 canonical.clone(),
948 ImportNode::Resolved {
949 content: Arc::clone(&scan.content),
950 },
951 );
952 for nested_import in &scan.imports {
953 frontier.push((resolved.clone(), Arc::clone(nested_import)));
954 }
955 }
956 Err(kind) => {
957 visited.insert(canonical, ImportNode::IoError { kind });
958 }
959 }
960 }
961
962 let mut hasher = Sha256::new();
963 hasher.update(b"stdlib-digest\0");
964 hasher.update(embedded_stdlib_digest());
965 hasher.update(b"\0");
966 hasher.update(b"codegen-fingerprint\0");
971 hasher.update(codegen_fingerprint.as_bytes());
972 hasher.update(b"\0");
973 for (path, node) in &visited {
974 hasher.update(path.to_string_lossy().as_bytes());
975 hasher.update(b"\0");
976 match node {
977 ImportNode::Resolved { content } => {
978 hasher.update(b"resolved\0");
979 hasher.update(content.as_bytes());
980 }
981 ImportNode::Unresolved { import } => {
982 hasher.update(b"unresolved\0");
983 hasher.update(import.as_bytes());
984 }
985 ImportNode::IoError { kind } => {
986 hasher.update(b"ioerror\0");
987 hasher.update(kind.as_bytes());
988 }
989 }
990 hasher.update(b"\0");
991 }
992 hasher.finalize().into()
993}
994
995enum ImportNode {
996 Resolved { content: Arc<str> },
997 Unresolved { import: Arc<str> },
998 IoError { kind: String },
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004 use crate::compile_source;
1005
1006 #[test]
1007 fn header_round_trips_chunk() {
1008 let chunk = compile_source("__io_println(\"hello\")").expect("compile");
1009 let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
1010 let tmp = tempfile::tempdir().unwrap();
1011 let path = tmp.path().join("entry.harnbc");
1012 store_at(&path, &key, &chunk).expect("write");
1013 let loaded = read_chunk_if_matches(&path, &key).unwrap();
1014 assert!(loaded.is_some(), "expected cached chunk to load");
1015 }
1016
1017 #[test]
1018 fn serialize_chunk_artifact_matches_store_at() {
1019 let chunk = compile_source("__io_println(\"hi\")").expect("compile");
1025 let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
1026 let tmp = tempfile::tempdir().unwrap();
1027 let on_disk = tmp.path().join("pack.harnbc");
1028 store_at(&on_disk, &key, &chunk).expect("write");
1029 let on_disk_bytes = std::fs::read(&on_disk).unwrap();
1030 let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
1031 assert_eq!(in_memory_bytes, on_disk_bytes);
1032 }
1033
1034 #[test]
1035 fn cache_payload_rejects_trailing_bytes() {
1036 let chunk = compile_source("1 + 1").expect("compile");
1037 let cached = chunk.freeze_for_cache();
1038 let mut payload = serialize_cache_payload(&cached).expect("serialize");
1039 payload.push(0xFF);
1040
1041 assert!(deserialize_cache_payload::<CachedChunk>(&payload).is_err());
1042 }
1043
1044 #[test]
1045 fn atomic_temp_paths_are_unique_within_process() {
1046 let target = Path::new("entry.harnbc");
1047 let first = atomic_tmp_path(target);
1048 let second = atomic_tmp_path(target);
1049 assert_ne!(
1050 first, second,
1051 "same-process concurrent cache writes must not share a temp file"
1052 );
1053 }
1054
1055 #[test]
1056 fn header_mismatch_returns_none() {
1057 let chunk = compile_source("1 + 1").expect("compile");
1058 let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
1059 let tmp = tempfile::tempdir().unwrap();
1060 let path = tmp.path().join("a.harnbc");
1061 store_at(&path, &key, &chunk).expect("write");
1062 let other = CacheKey {
1063 source_hash: [0xAB; 32],
1064 context_hash: key.context_hash,
1065 harn_version: HARN_VERSION,
1066 compiler_tag: key.compiler_tag,
1067 };
1068 assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
1069 }
1070
1071 #[test]
1072 fn schema_mismatch_returns_none() {
1073 let chunk = compile_source("1 + 1").expect("compile");
1074 let key = CacheKey::from_source(Path::new("/tmp/schema.harn"), "1 + 1");
1075 let tmp = tempfile::tempdir().unwrap();
1076 let path = tmp.path().join("schema.harnbc");
1077 store_at(&path, &key, &chunk).expect("write");
1078
1079 let mut bytes = std::fs::read(&path).expect("read cache");
1080 bytes[8..12].copy_from_slice(&(SCHEMA_VERSION - 1).to_le_bytes());
1081 std::fs::write(&path, bytes).expect("rewrite cache");
1082
1083 assert!(read_chunk_if_matches(&path, &key).unwrap().is_none());
1084 }
1085
1086 #[test]
1087 fn compiler_tag_mismatch_returns_none() {
1088 let chunk = compile_source("1 + 1").expect("compile");
1089 let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
1090 let tmp = tempfile::tempdir().unwrap();
1091 let path = tmp.path().join("b.harnbc");
1092 store_at(&path, &key, &chunk).expect("write");
1093 let other = CacheKey {
1094 compiler_tag: key.compiler_tag ^ 0xFF,
1095 ..key
1096 };
1097 assert!(
1098 read_chunk_if_matches(&path, &other).unwrap().is_none(),
1099 "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
1100 compiled under the opposite setting"
1101 );
1102 }
1103
1104 #[test]
1105 fn codegen_fingerprint_is_populated() {
1106 assert!(!CODEGEN_FINGERPRINT.is_empty());
1110 }
1111
1112 #[test]
1113 fn codegen_fingerprint_changes_cache_key() {
1114 let tmp = tempfile::tempdir().unwrap();
1120 let entry = tmp.path().join("entry.harn");
1121 std::fs::write(&entry, "__io_println(\"hi\")\n").unwrap();
1122 let source = std::fs::read_to_string(&entry).unwrap();
1123 let a = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
1124 let b = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-B");
1125 let a_again = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
1126 assert_ne!(
1127 a, b,
1128 "differing compiler fingerprints must change the cache key"
1129 );
1130 assert_eq!(
1131 a, a_again,
1132 "an unchanged compiler fingerprint must be stable"
1133 );
1134 }
1135
1136 #[test]
1137 fn module_context_hash_tracks_codegen_fingerprint() {
1138 let first = module_compilation_context_hash_fingerprinted("compiler-A");
1139 let second = module_compilation_context_hash_fingerprinted("compiler-B");
1140 assert_ne!(
1141 first, second,
1142 "module artifacts must miss when compiler code generation changes"
1143 );
1144 assert_eq!(
1145 first,
1146 module_compilation_context_hash_fingerprinted("compiler-A"),
1147 "an unchanged module compilation context must be stable"
1148 );
1149 }
1150
1151 #[test]
1152 fn module_key_excludes_dependency_graph_while_entry_key_tracks_it() {
1153 let tmp = tempfile::tempdir().unwrap();
1154 let dependency = tmp.path().join("value.harn");
1155 let importer = tmp.path().join("reader.harn");
1156 let importer_source =
1157 "import { value } from \"./value\"\npub fn read() { return value() }\n";
1158 std::fs::write(&dependency, "pub fn value() { return 1 }\n").unwrap();
1159 std::fs::write(&importer, importer_source).unwrap();
1160
1161 let entry_before = CacheKey::from_source(&importer, importer_source);
1162 let module_before = CacheKey::from_module_source(importer_source);
1163 let dependency_before =
1164 CacheKey::from_module_source(&std::fs::read_to_string(&dependency).unwrap());
1165
1166 std::fs::write(&dependency, "pub fn value() { return 2 }\n").unwrap();
1167 let future = std::fs::metadata(&dependency).unwrap().modified().unwrap()
1168 + std::time::Duration::from_secs(10);
1169 std::fs::OpenOptions::new()
1170 .write(true)
1171 .open(&dependency)
1172 .unwrap()
1173 .set_times(std::fs::FileTimes::new().set_modified(future))
1174 .unwrap();
1175
1176 let entry_after = CacheKey::from_source(&importer, importer_source);
1177 let module_after = CacheKey::from_module_source(importer_source);
1178 let dependency_after =
1179 CacheKey::from_module_source(&std::fs::read_to_string(&dependency).unwrap());
1180
1181 assert_ne!(
1182 entry_before, entry_after,
1183 "entry chunks compile the full graph and must track dependency edits"
1184 );
1185 assert_eq!(
1186 module_before, module_after,
1187 "a parent module artifact must not be invalidated by dependency contents"
1188 );
1189 assert_ne!(
1190 dependency_before, dependency_after,
1191 "the edited dependency must invalidate its own module artifact"
1192 );
1193 }
1194
1195 #[test]
1196 fn module_artifact_is_relocatable_and_rebinds_exact_source_path() {
1197 let source = "pub fn answer() { fn inner() { return 42 }; return inner() }\n";
1198 let first_path = Path::new("/workspace/first/module.harn");
1199 let second_path = Path::new("/workspace/second/module.harn");
1200 let key = CacheKey::from_module_source(source);
1201
1202 let artifact =
1203 crate::module_artifact::compile_module_artifact_from_source(first_path, source)
1204 .expect("compile module");
1205 let first_source_file = first_path.display().to_string();
1206 let second_source_file = second_path.display().to_string();
1207 assert_eq!(
1208 artifact.functions["answer"].chunk.source_file.as_deref(),
1209 Some(first_source_file.as_str())
1210 );
1211
1212 let tmp = tempfile::tempdir().unwrap();
1213 let cache_path = tmp.path().join(key.module_filename());
1214 store_module_at(&cache_path, &key, &artifact).expect("store module");
1215 let first_loaded = read_module_if_matches(&cache_path, &key, first_path)
1216 .expect("read first module")
1217 .expect("first module key matches");
1218 let second_loaded = read_module_if_matches(&cache_path, &key, second_path)
1219 .expect("read second module")
1220 .expect("second module key matches");
1221 assert_eq!(
1222 first_loaded.functions["answer"]
1223 .chunk
1224 .source_file
1225 .as_deref(),
1226 Some(first_source_file.as_str())
1227 );
1228 assert_eq!(
1229 second_loaded.functions["answer"]
1230 .chunk
1231 .source_file
1232 .as_deref(),
1233 Some(second_source_file.as_str())
1234 );
1235 let nested = second_loaded.functions["answer"]
1236 .chunk
1237 .functions
1238 .first()
1239 .expect("nested function survives artifact roundtrip");
1240 assert_eq!(
1241 nested.chunk.source_file.as_deref(),
1242 Some(second_source_file.as_str()),
1243 "rebinding must reach nested compiled functions"
1244 );
1245 }
1246
1247 #[test]
1248 fn source_local_module_artifact_round_trips() {
1249 let source = "import \"./dependency\"\npub fn answer() { return 42 }\n";
1250 let source_path = Path::new("/tmp/source-local-module.harn");
1251 let artifact =
1252 crate::module_artifact::compile_module_artifact_from_source(source_path, source)
1253 .expect("compile module");
1254 let key = CacheKey::from_module_source(source);
1255 let tmp = tempfile::tempdir().unwrap();
1256 let path = tmp.path().join("source-local-module.harnmod");
1257
1258 store_module_at(&path, &key, &artifact).expect("write module artifact");
1259 let loaded = read_module_if_matches(&path, &key, source_path)
1260 .expect("read module artifact")
1261 .expect("matching artifact");
1262
1263 assert_eq!(loaded.imports.len(), 1);
1264 assert_eq!(loaded.imports[0].path, "./dependency");
1265 assert!(loaded.public_names.contains("answer"));
1266 }
1267
1268 #[test]
1269 fn module_artifact_payload_round_trips() {
1270 let source = "pub fn answer() { fn inner() { return 42 }; return inner() }\n";
1271 let source_path = Path::new("/tmp/module-payload.harn");
1272 let artifact =
1273 crate::module_artifact::compile_module_artifact_from_source(source_path, source)
1274 .expect("compile module");
1275
1276 let payload = serialize_cache_payload(&artifact).expect("serialize module artifact");
1277 let round_tripped: ModuleArtifact =
1278 deserialize_cache_payload(&payload).expect("deserialize module artifact");
1279
1280 assert!(round_tripped.public_names.contains("answer"));
1281 assert!(round_tripped.functions["answer"]
1282 .chunk
1283 .functions
1284 .iter()
1285 .any(|function| function.name == "inner"));
1286 }
1287
1288 #[test]
1289 fn collect_user_imports_ignores_stdlib_and_comments() {
1290 let source = r#"
1291 // import "comment/should/be/ignored"
1292 import "std/agents"
1293 import { foo } from "pkg/bar"
1294 import "./relative/path"
1295 "#;
1296 let imports = collect_user_imports(source);
1297 assert_eq!(
1298 imports,
1299 vec!["pkg/bar".to_string(), "./relative/path".to_string()]
1300 );
1301 }
1302
1303 #[test]
1304 fn cache_enabled_respects_env() {
1305 std::env::set_var(CACHE_ENABLED_ENV, "0");
1306 assert!(!cache_enabled());
1307 std::env::set_var(CACHE_ENABLED_ENV, "1");
1308 assert!(cache_enabled());
1309 std::env::remove_var(CACHE_ENABLED_ENV);
1310 assert!(cache_enabled());
1311 }
1312
1313 #[test]
1314 fn import_path_inside_string_literal_is_ignored() {
1315 let source = r#"
1316 const payload = "import { foo } from \"./other\""
1317 import "./real"
1318 "#;
1319 let imports = collect_user_imports(source);
1320 assert_eq!(imports, vec!["./real".to_string()]);
1321 }
1322
1323 #[test]
1324 fn import_hash_is_stable_across_import_order() {
1325 let tmp = tempfile::tempdir().unwrap();
1326 std::fs::write(
1327 tmp.path().join("a.harn"),
1328 "pub fn a() -> int { return 1 }\n",
1329 )
1330 .unwrap();
1331 std::fs::write(
1332 tmp.path().join("b.harn"),
1333 "pub fn b() -> int { return 2 }\n",
1334 )
1335 .unwrap();
1336 let ab = tmp.path().join("entry_ab.harn");
1337 std::fs::write(
1338 &ab,
1339 "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
1340 )
1341 .unwrap();
1342 let ba = tmp.path().join("entry_ba.harn");
1343 std::fs::write(
1344 &ba,
1345 "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
1346 )
1347 .unwrap();
1348 let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
1349 let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
1350 assert_eq!(
1351 hash_ab, hash_ba,
1352 "import-graph hash must be order-independent so reordering imports \
1353 does not bust the cache"
1354 );
1355 }
1356
1357 #[test]
1358 fn import_hash_picks_up_nested_imports() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 std::fs::write(
1361 tmp.path().join("leaf.harn"),
1362 "pub fn x() -> int { return 1 }\n",
1363 )
1364 .unwrap();
1365 std::fs::write(
1366 tmp.path().join("mid.harn"),
1367 "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
1368 )
1369 .unwrap();
1370 let entry = tmp.path().join("entry.harn");
1371 std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
1372
1373 let before =
1374 hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1375 std::fs::write(
1376 tmp.path().join("leaf.harn"),
1377 "pub fn x() -> int { return 999 }\n",
1378 )
1379 .unwrap();
1380 let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1381 assert_ne!(
1382 before, after,
1383 "editing a transitively-imported file must change the import-graph hash"
1384 );
1385 }
1386
1387 #[test]
1388 fn import_hash_busts_on_same_length_edit_in_same_process() {
1389 let tmp = tempfile::tempdir().unwrap();
1396 let leaf = tmp.path().join("leaf.harn");
1397 std::fs::write(&leaf, "pub fn x() -> int { return 111 }\n").unwrap();
1398 let entry = tmp.path().join("entry.harn");
1399 std::fs::write(&entry, "import \"./leaf\"\n__io_println(\"hi\")\n").unwrap();
1400
1401 let before =
1402 hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1403
1404 std::fs::write(&leaf, "pub fn x() -> int { return 222 }\n").unwrap();
1410 let future = std::fs::metadata(&leaf).unwrap().modified().unwrap()
1413 + std::time::Duration::from_secs(10);
1414 std::fs::OpenOptions::new()
1415 .write(true)
1416 .open(&leaf)
1417 .unwrap()
1418 .set_times(std::fs::FileTimes::new().set_modified(future))
1419 .unwrap();
1420 assert_eq!(
1421 std::fs::metadata(&leaf).unwrap().len(),
1422 33,
1423 "the two leaf versions must be the same byte length for this test to \
1424 exercise the mtime path"
1425 );
1426
1427 let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1428 assert_ne!(
1429 before, after,
1430 "a same-length edit to a transitively-imported file must still change \
1431 the import-graph hash when recomputed in a warm process"
1432 );
1433 }
1434
1435 #[test]
1436 fn import_scan_memo_shares_source_and_import_allocations() {
1437 let tmp = tempfile::tempdir().unwrap();
1438 let source_path = tmp.path().join("module.harn");
1439 std::fs::write(
1440 &source_path,
1441 "import \"./first\"\nimport \"./second\"\npub fn value() -> int { return 7 }\n",
1442 )
1443 .unwrap();
1444
1445 let first = read_and_scan_imports_cached(&source_path).unwrap();
1446 let second = read_and_scan_imports_cached(&source_path).unwrap();
1447
1448 assert!(
1449 std::sync::Arc::ptr_eq(&first, &second),
1450 "a memo hit must reuse the complete scan instead of copying its source and imports"
1451 );
1452 assert_eq!(first.imports.len(), 2);
1453 }
1454
1455 #[test]
1456 fn import_hash_stable_across_repeated_calls_same_process() {
1457 let tmp = tempfile::tempdir().unwrap();
1461 std::fs::write(
1462 tmp.path().join("dep.harn"),
1463 "pub fn d() -> int { return 7 }\n",
1464 )
1465 .unwrap();
1466 let entry = tmp.path().join("entry.harn");
1467 std::fs::write(&entry, "import \"./dep\"\n__io_println(\"hi\")\n").unwrap();
1468 let src = std::fs::read_to_string(&entry).unwrap();
1469 let first = hash_transitive_user_imports(&entry, &src);
1470 for _ in 0..50 {
1471 assert_eq!(
1472 hash_transitive_user_imports(&entry, &src),
1473 first,
1474 "repeated import-graph hashing over an unchanged tree must be stable"
1475 );
1476 }
1477 }
1478}