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