1use std::fs;
40use std::io::{self, Read as _, Write as _};
41use std::path::{Path, PathBuf};
42
43use sha2::{Digest, Sha256};
44
45use crate::chunk::{CachedChunk, Chunk};
46use crate::compiler::CompilerOptions;
47use crate::module_artifact::ModuleArtifact;
48
49pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
51
52pub const SCHEMA_VERSION: u32 = 4;
55
56pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
59
60pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
68
69pub const CACHE_EXTENSION: &str = "harnbc";
71
72pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
77
78const KIND_ENTRY_CHUNK: u8 = 1;
80const KIND_MODULE_ARTIFACT: u8 = 2;
82
83pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
86
87pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
91
92pub struct LookupOutcome {
95 pub key: CacheKey,
96 pub chunk: Option<Chunk>,
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct CacheKey {
103 pub source_hash: [u8; 32],
104 pub import_graph_hash: [u8; 32],
105 pub harn_version: &'static str,
106 pub compiler_tag: u8,
110}
111
112impl CacheKey {
113 pub fn from_source(source_path: &Path, source: &str) -> Self {
117 let source_hash = sha256(source.as_bytes());
118 let import_graph_hash = hash_transitive_user_imports(source_path, source);
119 Self {
120 source_hash,
121 import_graph_hash,
122 harn_version: HARN_VERSION,
123 compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
124 }
125 }
126
127 pub fn filename(&self) -> String {
132 format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
133 }
134
135 pub fn module_filename(&self) -> String {
137 format!("{}.{}", hex(&self.source_hash), MODULE_CACHE_EXTENSION)
138 }
139}
140
141pub fn cache_dir() -> PathBuf {
147 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
148 return PathBuf::from(custom);
149 }
150 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
151 let xdg = PathBuf::from(xdg);
152 if !xdg.as_os_str().is_empty() {
153 return xdg.join("harn").join("bytecode");
154 }
155 }
156 if let Some(home) = crate::user_dirs::home_dir() {
157 return home.join(".cache").join("harn").join("bytecode");
158 }
159 PathBuf::from(".harn-cache").join("bytecode")
162}
163
164pub fn packs_cache_dir() -> PathBuf {
169 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
170 return PathBuf::from(custom).join("packs");
171 }
172 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
173 let xdg = PathBuf::from(xdg);
174 if !xdg.as_os_str().is_empty() {
175 return xdg.join("harn").join("packs");
176 }
177 }
178 if let Some(home) = crate::user_dirs::home_dir() {
179 return home.join(".cache").join("harn").join("packs");
180 }
181 PathBuf::from(".harn-cache").join("packs")
182}
183
184pub fn cache_enabled() -> bool {
186 match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
187 Some(value) => !matches!(
188 value.to_ascii_lowercase().as_str(),
189 "0" | "false" | "no" | "off"
190 ),
191 None => true,
192 }
193}
194
195pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
199 let key = CacheKey::from_source(source_path, source);
200 if !cache_enabled() {
201 return LookupOutcome { key, chunk: None };
202 }
203 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
204 if let Some(adjacent) = adjacent_cache_path(source_path) {
205 candidates.push(adjacent);
206 }
207 candidates.push(cache_dir().join(key.filename()));
208 for path in candidates {
209 match read_chunk_if_matches(&path, &key) {
210 Ok(Some(chunk)) => {
211 return LookupOutcome {
212 key,
213 chunk: Some(chunk),
214 }
215 }
216 Ok(None) => continue,
217 Err(_) => continue,
218 }
219 }
220 LookupOutcome { key, chunk: None }
221}
222
223pub fn store(key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
227 if !cache_enabled() {
228 return Ok(());
229 }
230 let dir = cache_dir();
231 fs::create_dir_all(&dir)?;
232 write_atomic_chunk(&dir.join(key.filename()), key, chunk)
233}
234
235pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
240 ensure_parent_dir(path)?;
241 write_atomic_chunk(path, key, chunk)
242}
243
244pub fn load_module(source_path: &Path, source: &str) -> ModuleLookupOutcome {
247 let key = CacheKey::from_source(source_path, source);
248 if !cache_enabled() {
249 return ModuleLookupOutcome {
250 key,
251 artifact: None,
252 };
253 }
254 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
255 if let Some(adjacent) = adjacent_module_cache_path(source_path) {
256 candidates.push(adjacent);
257 }
258 candidates.push(cache_dir().join(key.module_filename()));
259 for path in candidates {
260 match read_module_if_matches(&path, &key) {
261 Ok(Some(artifact)) => {
262 return ModuleLookupOutcome {
263 key,
264 artifact: Some(artifact),
265 }
266 }
267 Ok(None) => continue,
268 Err(_) => continue,
269 }
270 }
271 ModuleLookupOutcome {
272 key,
273 artifact: None,
274 }
275}
276
277pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
280 if !cache_enabled() {
281 return Ok(());
282 }
283 let dir = cache_dir();
284 fs::create_dir_all(&dir)?;
285 write_atomic_module(&dir.join(key.module_filename()), key, artifact)
286}
287
288pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
290 ensure_parent_dir(path)?;
291 write_atomic_module(path, key, artifact)
292}
293
294pub struct ModuleLookupOutcome {
297 pub key: CacheKey,
298 pub artifact: Option<ModuleArtifact>,
299}
300
301pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
304 adjacent_path_with_extension(source_path, CACHE_EXTENSION)
305}
306
307pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
310 adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
311}
312
313fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
314 let stem = source_path.file_stem()?;
315 if stem.is_empty() {
316 return None;
317 }
318 let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
319 let mut out = parent.join(stem);
320 out.set_extension(ext);
321 Some(out)
322}
323
324fn ensure_parent_dir(path: &Path) -> io::Result<()> {
325 if let Some(parent) = path.parent() {
326 if !parent.as_os_str().is_empty() {
327 fs::create_dir_all(parent)?;
328 }
329 }
330 Ok(())
331}
332
333fn write_atomic_chunk(target: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
334 let buf = serialize_chunk_artifact(key, chunk)?;
335 write_atomic(target, &buf)
336}
337
338fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
339 let buf = serialize_module_artifact(key, artifact)?;
340 write_atomic(target, &buf)
341}
342
343pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
349 let cached = chunk.freeze_for_cache();
350 let payload = bincode::serde::encode_to_vec(&cached, bincode::config::standard())
351 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
352 Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
353}
354
355pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
358 let payload = bincode::serde::encode_to_vec(artifact, bincode::config::standard())
359 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
360 Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
361}
362
363fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
364 let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
365 buf.extend_from_slice(MAGIC);
366 buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
367 let version_bytes = HARN_VERSION.as_bytes();
368 buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
369 buf.extend_from_slice(version_bytes);
370 buf.push(key.compiler_tag);
371 buf.push(kind);
372 buf.extend_from_slice(&key.source_hash);
373 buf.extend_from_slice(&key.import_graph_hash);
374 buf.extend_from_slice(payload);
375 buf
376}
377
378fn write_atomic(target: &Path, buf: &[u8]) -> io::Result<()> {
379 let tmp_name = match target.file_name() {
380 Some(name) => format!(".{}.{}.tmp", name.to_string_lossy(), std::process::id()),
381 None => format!(".harn-cache.{}.tmp", std::process::id()),
382 };
383 let tmp_path = target.with_file_name(tmp_name);
384 let mut tmp_file = fs::File::create(&tmp_path)?;
385 tmp_file.write_all(buf)?;
386 tmp_file.sync_all()?;
387 drop(tmp_file);
388 match fs::rename(&tmp_path, target) {
389 Ok(()) => Ok(()),
390 Err(err) => {
391 let _ = fs::remove_file(&tmp_path);
392 Err(err)
393 }
394 }
395}
396
397struct ParsedHeader {
400 kind: u8,
401 payload: Vec<u8>,
402}
403
404fn read_header_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ParsedHeader>> {
405 let mut file = match fs::File::open(path) {
406 Ok(f) => f,
407 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
408 Err(err) => return Err(err),
409 };
410 let mut header = [0u8; 8 + 4 + 4];
411 if file.read_exact(&mut header).is_err() {
412 return Ok(None);
413 }
414 if &header[..8] != MAGIC {
415 return Ok(None);
416 }
417 let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
418 if schema != SCHEMA_VERSION {
419 return Ok(None);
420 }
421 let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
422 if version_len > 256 {
423 return Ok(None);
425 }
426 let mut version_buf = vec![0u8; version_len];
427 if file.read_exact(&mut version_buf).is_err() {
428 return Ok(None);
429 }
430 if version_buf != key.harn_version.as_bytes() {
431 return Ok(None);
432 }
433 let mut compiler_and_kind = [0u8; 2];
434 if file.read_exact(&mut compiler_and_kind).is_err() {
435 return Ok(None);
436 }
437 if compiler_and_kind[0] != key.compiler_tag {
438 return Ok(None);
439 }
440 let kind = compiler_and_kind[1];
441 let mut hashes = [0u8; 64];
442 if file.read_exact(&mut hashes).is_err() {
443 return Ok(None);
444 }
445 if hashes[..32] != key.source_hash || hashes[32..] != key.import_graph_hash {
446 return Ok(None);
447 }
448 let mut payload = Vec::new();
449 if file.read_to_end(&mut payload).is_err() {
450 return Ok(None);
451 }
452 Ok(Some(ParsedHeader { kind, payload }))
453}
454
455fn read_chunk_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<Chunk>> {
456 let Some(header) = read_header_if_matches(path, key)? else {
457 return Ok(None);
458 };
459 if header.kind != KIND_ENTRY_CHUNK {
460 return Ok(None);
461 }
462 let cached: CachedChunk =
463 match bincode::serde::decode_from_slice(&header.payload, bincode::config::standard()) {
464 Ok((c, _)) => c,
465 Err(_) => return Ok(None),
466 };
467 Ok(Some(Chunk::from_cached(&cached)))
468}
469
470fn read_module_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ModuleArtifact>> {
471 let Some(header) = read_header_if_matches(path, key)? else {
472 return Ok(None);
473 };
474 if header.kind != KIND_MODULE_ARTIFACT {
475 return Ok(None);
476 }
477 match bincode::serde::decode_from_slice::<ModuleArtifact, _>(
478 &header.payload,
479 bincode::config::standard(),
480 ) {
481 Ok((artifact, _)) => Ok(Some(artifact)),
482 Err(_) => Ok(None),
483 }
484}
485
486fn compiler_options_tag(options: CompilerOptions) -> u8 {
493 let mut tag: u8 = 0;
494 if options.optimizations_enabled() {
495 tag |= 0b0000_0001;
496 }
497 tag
498}
499
500fn sha256(bytes: &[u8]) -> [u8; 32] {
501 let mut hasher = Sha256::new();
502 hasher.update(bytes);
503 hasher.finalize().into()
504}
505
506fn hex(bytes: &[u8]) -> String {
507 let mut out = String::with_capacity(bytes.len() * 2);
508 for byte in bytes {
509 out.push_str(&format!("{byte:02x}"));
510 }
511 out
512}
513
514fn collect_user_imports(source: &str) -> Vec<String> {
520 let scrubbed = strip_comments(source);
521 let mut out: Vec<String> = Vec::new();
522 let bytes = scrubbed.as_bytes();
523 let mut i = 0;
524 while i < bytes.len() {
525 if bytes[i] == b'"' {
526 match read_string_literal(bytes, i) {
529 Some((_, end)) => {
530 i = end;
531 continue;
532 }
533 None => {
534 i += 1;
535 continue;
536 }
537 }
538 }
539 if !matches_keyword(bytes, i, b"import") {
540 i += 1;
541 continue;
542 }
543 let mut j = i + b"import".len();
546 let mut depth = 0i32;
547 while j < bytes.len() {
548 match bytes[j] {
549 b'"' => {
550 if let Some((path, end)) = read_string_literal(bytes, j) {
551 if !path.starts_with("std/") {
552 out.push(path);
553 }
554 i = end;
555 break;
556 }
557 j += 1;
558 }
559 b'{' => {
560 depth += 1;
561 j += 1;
562 }
563 b'}' => {
564 depth -= 1;
565 j += 1;
566 }
567 b'\n' if depth == 0 => {
568 i = j;
572 break;
573 }
574 _ => j += 1,
575 }
576 }
577 if j >= bytes.len() {
578 break;
579 }
580 if i < j {
581 i = j;
584 }
585 }
586 out
587}
588
589fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
590 let end = at + keyword.len();
591 if end > bytes.len() {
592 return false;
593 }
594 if &bytes[at..end] != keyword {
595 return false;
596 }
597 if at > 0 && is_ident_char(bytes[at - 1]) {
598 return false;
599 }
600 if end < bytes.len() && is_ident_char(bytes[end]) {
601 return false;
602 }
603 true
604}
605
606fn is_ident_char(b: u8) -> bool {
607 b.is_ascii_alphanumeric() || b == b'_'
608}
609
610fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
611 debug_assert_eq!(bytes[at], b'"');
612 let mut out = String::new();
613 let mut i = at + 1;
614 while i < bytes.len() {
615 match bytes[i] {
616 b'"' => return Some((out, i + 1)),
617 b'\\' => {
618 if i + 1 >= bytes.len() {
619 return None;
620 }
621 match bytes[i + 1] {
622 b'"' => out.push('"'),
623 b'\\' => out.push('\\'),
624 b'n' => out.push('\n'),
625 b'r' => out.push('\r'),
626 b't' => out.push('\t'),
627 other => out.push(other as char),
628 }
629 i += 2;
630 }
631 b'\n' => return None,
632 byte => {
633 out.push(byte as char);
634 i += 1;
635 }
636 }
637 }
638 None
639}
640
641fn strip_comments(source: &str) -> String {
642 let bytes = source.as_bytes();
643 let mut out = String::with_capacity(source.len());
644 let mut i = 0;
645 while i < bytes.len() {
646 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
647 while i < bytes.len() && bytes[i] != b'\n' {
648 i += 1;
649 }
650 continue;
651 }
652 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
653 i += 2;
654 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
655 i += 1;
656 }
657 i = (i + 2).min(bytes.len());
658 continue;
659 }
660 if bytes[i] == b'"' {
661 if let Some((_, end)) = read_string_literal(bytes, i) {
662 out.push_str(&source[i..end]);
663 i = end;
664 continue;
665 }
666 }
667 out.push(bytes[i] as char);
668 i += 1;
669 }
670 out
671}
672
673fn embedded_stdlib_digest() -> &'static [u8; 32] {
684 use std::sync::OnceLock;
685 static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
686 DIGEST.get_or_init(|| {
687 let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
688 .iter()
689 .map(|src| (src.module, src.source))
690 .collect();
691 entries.sort_by(|a, b| a.0.cmp(b.0));
692 let mut hasher = Sha256::new();
693 for (module, source) in entries {
694 hasher.update(module.as_bytes());
695 hasher.update(b"\0");
696 hasher.update(source.as_bytes());
697 hasher.update(b"\0");
698 }
699 hasher.finalize().into()
700 })
701}
702
703fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
714 hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
715}
716
717fn hash_transitive_user_imports_fingerprinted(
721 source_path: &Path,
722 source: &str,
723 codegen_fingerprint: &str,
724) -> [u8; 32] {
725 let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
726 std::collections::BTreeMap::new();
727 let mut frontier: Vec<(PathBuf, String)> = collect_user_imports(source)
728 .into_iter()
729 .map(|import| (source_path.to_path_buf(), import))
730 .collect();
731
732 while let Some((anchor, import)) = frontier.pop() {
733 let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
734 let sentinel = anchor.join(format!("__unresolved__/{import}"));
738 visited
739 .entry(sentinel)
740 .or_insert(ImportNode::Unresolved { import });
741 continue;
742 };
743 let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
744 if visited.contains_key(&canonical) {
745 continue;
746 }
747 match fs::read_to_string(&resolved) {
748 Ok(content) => {
749 let nested = collect_user_imports(&content);
750 visited.insert(
751 canonical.clone(),
752 ImportNode::Resolved {
753 content: content.clone(),
754 },
755 );
756 for nested_import in nested {
757 frontier.push((resolved.clone(), nested_import));
758 }
759 }
760 Err(err) => {
761 visited.insert(
762 canonical,
763 ImportNode::IoError {
764 kind: err.kind().to_string(),
765 },
766 );
767 }
768 }
769 }
770
771 let mut hasher = Sha256::new();
772 hasher.update(b"stdlib-digest\0");
773 hasher.update(embedded_stdlib_digest());
774 hasher.update(b"\0");
775 hasher.update(b"codegen-fingerprint\0");
780 hasher.update(codegen_fingerprint.as_bytes());
781 hasher.update(b"\0");
782 for (path, node) in &visited {
783 hasher.update(path.to_string_lossy().as_bytes());
784 hasher.update(b"\0");
785 match node {
786 ImportNode::Resolved { content } => {
787 hasher.update(b"resolved\0");
788 hasher.update(content.as_bytes());
789 }
790 ImportNode::Unresolved { import } => {
791 hasher.update(b"unresolved\0");
792 hasher.update(import.as_bytes());
793 }
794 ImportNode::IoError { kind } => {
795 hasher.update(b"ioerror\0");
796 hasher.update(kind.as_bytes());
797 }
798 }
799 hasher.update(b"\0");
800 }
801 hasher.finalize().into()
802}
803
804enum ImportNode {
805 Resolved { content: String },
806 Unresolved { import: String },
807 IoError { kind: String },
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use crate::compile_source;
814
815 #[test]
816 fn header_round_trips_chunk() {
817 let chunk = compile_source("__io_println(\"hello\")").expect("compile");
818 let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
819 let tmp = tempfile::tempdir().unwrap();
820 let path = tmp.path().join("entry.harnbc");
821 store_at(&path, &key, &chunk).expect("write");
822 let loaded = read_chunk_if_matches(&path, &key).unwrap();
823 assert!(loaded.is_some(), "expected cached chunk to load");
824 }
825
826 #[test]
827 fn serialize_chunk_artifact_matches_store_at() {
828 let chunk = compile_source("__io_println(\"hi\")").expect("compile");
834 let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
835 let tmp = tempfile::tempdir().unwrap();
836 let on_disk = tmp.path().join("pack.harnbc");
837 store_at(&on_disk, &key, &chunk).expect("write");
838 let on_disk_bytes = std::fs::read(&on_disk).unwrap();
839 let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
840 assert_eq!(in_memory_bytes, on_disk_bytes);
841 }
842
843 #[test]
844 fn header_mismatch_returns_none() {
845 let chunk = compile_source("1 + 1").expect("compile");
846 let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
847 let tmp = tempfile::tempdir().unwrap();
848 let path = tmp.path().join("a.harnbc");
849 store_at(&path, &key, &chunk).expect("write");
850 let other = CacheKey {
851 source_hash: [0xAB; 32],
852 import_graph_hash: key.import_graph_hash,
853 harn_version: HARN_VERSION,
854 compiler_tag: key.compiler_tag,
855 };
856 assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
857 }
858
859 #[test]
860 fn compiler_tag_mismatch_returns_none() {
861 let chunk = compile_source("1 + 1").expect("compile");
862 let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
863 let tmp = tempfile::tempdir().unwrap();
864 let path = tmp.path().join("b.harnbc");
865 store_at(&path, &key, &chunk).expect("write");
866 let other = CacheKey {
867 compiler_tag: key.compiler_tag ^ 0xFF,
868 ..key
869 };
870 assert!(
871 read_chunk_if_matches(&path, &other).unwrap().is_none(),
872 "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
873 compiled under the opposite setting"
874 );
875 }
876
877 #[test]
878 fn codegen_fingerprint_is_populated() {
879 assert!(!CODEGEN_FINGERPRINT.is_empty());
883 }
884
885 #[test]
886 fn codegen_fingerprint_changes_cache_key() {
887 let tmp = tempfile::tempdir().unwrap();
893 let entry = tmp.path().join("entry.harn");
894 std::fs::write(&entry, "__io_println(\"hi\")\n").unwrap();
895 let source = std::fs::read_to_string(&entry).unwrap();
896 let a = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
897 let b = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-B");
898 let a_again = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
899 assert_ne!(
900 a, b,
901 "differing compiler fingerprints must change the cache key"
902 );
903 assert_eq!(
904 a, a_again,
905 "an unchanged compiler fingerprint must be stable"
906 );
907 }
908
909 #[test]
910 fn collect_user_imports_ignores_stdlib_and_comments() {
911 let source = r#"
912 // import "comment/should/be/ignored"
913 import "std/agents"
914 import { foo } from "pkg/bar"
915 import "./relative/path"
916 "#;
917 let imports = collect_user_imports(source);
918 assert_eq!(
919 imports,
920 vec!["pkg/bar".to_string(), "./relative/path".to_string()]
921 );
922 }
923
924 #[test]
925 fn cache_enabled_respects_env() {
926 std::env::set_var(CACHE_ENABLED_ENV, "0");
927 assert!(!cache_enabled());
928 std::env::set_var(CACHE_ENABLED_ENV, "1");
929 assert!(cache_enabled());
930 std::env::remove_var(CACHE_ENABLED_ENV);
931 assert!(cache_enabled());
932 }
933
934 #[test]
935 fn import_path_inside_string_literal_is_ignored() {
936 let source = r#"
937 let payload = "import { foo } from \"./other\""
938 import "./real"
939 "#;
940 let imports = collect_user_imports(source);
941 assert_eq!(imports, vec!["./real".to_string()]);
942 }
943
944 #[test]
945 fn import_hash_is_stable_across_import_order() {
946 let tmp = tempfile::tempdir().unwrap();
947 std::fs::write(
948 tmp.path().join("a.harn"),
949 "pub fn a() -> int { return 1 }\n",
950 )
951 .unwrap();
952 std::fs::write(
953 tmp.path().join("b.harn"),
954 "pub fn b() -> int { return 2 }\n",
955 )
956 .unwrap();
957 let ab = tmp.path().join("entry_ab.harn");
958 std::fs::write(
959 &ab,
960 "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
961 )
962 .unwrap();
963 let ba = tmp.path().join("entry_ba.harn");
964 std::fs::write(
965 &ba,
966 "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
967 )
968 .unwrap();
969 let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
970 let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
971 assert_eq!(
972 hash_ab, hash_ba,
973 "import-graph hash must be order-independent so reordering imports \
974 does not bust the cache"
975 );
976 }
977
978 #[test]
979 fn import_hash_picks_up_nested_imports() {
980 let tmp = tempfile::tempdir().unwrap();
981 std::fs::write(
982 tmp.path().join("leaf.harn"),
983 "pub fn x() -> int { return 1 }\n",
984 )
985 .unwrap();
986 std::fs::write(
987 tmp.path().join("mid.harn"),
988 "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
989 )
990 .unwrap();
991 let entry = tmp.path().join("entry.harn");
992 std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
993
994 let before =
995 hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
996 std::fs::write(
997 tmp.path().join("leaf.harn"),
998 "pub fn x() -> int { return 999 }\n",
999 )
1000 .unwrap();
1001 let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1002 assert_ne!(
1003 before, after,
1004 "editing a transitively-imported file must change the import-graph hash"
1005 );
1006 }
1007}