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;
75
76pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
79
80pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
88
89pub const CACHE_EXTENSION: &str = "harnbc";
91
92pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
97
98const KIND_ENTRY_CHUNK: u8 = 1;
100const KIND_MODULE_ARTIFACT: u8 = 2;
102
103pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
106
107pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
111
112pub struct LookupOutcome {
115 pub key: CacheKey,
116 pub chunk: Option<Chunk>,
117}
118
119#[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 pub compiler_tag: u8,
130}
131
132impl CacheKey {
133 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 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 pub fn filename(&self) -> String {
169 format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
170 }
171
172 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
186pub 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 PathBuf::from(".harn-cache").join("bytecode")
207}
208
209pub 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
229pub 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
240pub 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
268pub 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
280pub 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
289pub 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
322pub 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
333pub 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
339pub struct ModuleLookupOutcome {
342 pub key: CacheKey,
343 pub artifact: Option<ModuleArtifact>,
344}
345
346pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
349 adjacent_path_with_extension(source_path, CACHE_EXTENSION)
350}
351
352pub 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
388pub 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
399pub 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
435struct 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 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
527fn 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
555fn 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 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 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 i = j;
613 break;
614 }
615 _ => j += 1,
616 }
617 }
618 if j >= bytes.len() {
619 break;
620 }
621 if i < j {
622 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
714fn 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
744fn 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
763fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
774 hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
775}
776
777fn 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
793fn 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 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 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
848fn 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 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
878fn 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 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 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 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 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 assert!(!CODEGEN_FINGERPRINT.is_empty());
1073 }
1074
1075 #[test]
1076 fn codegen_fingerprint_changes_cache_key() {
1077 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 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 std::fs::write(&leaf, "pub fn x() -> int { return 222 }\n").unwrap();
1376 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 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}