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) = std::env::var_os("HOME") {
157 return PathBuf::from(home)
158 .join(".cache")
159 .join("harn")
160 .join("bytecode");
161 }
162 PathBuf::from(".harn-cache").join("bytecode")
165}
166
167pub fn packs_cache_dir() -> PathBuf {
172 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
173 return PathBuf::from(custom).join("packs");
174 }
175 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
176 let xdg = PathBuf::from(xdg);
177 if !xdg.as_os_str().is_empty() {
178 return xdg.join("harn").join("packs");
179 }
180 }
181 if let Some(home) = std::env::var_os("HOME") {
182 return PathBuf::from(home)
183 .join(".cache")
184 .join("harn")
185 .join("packs");
186 }
187 PathBuf::from(".harn-cache").join("packs")
188}
189
190pub fn cache_enabled() -> bool {
192 match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
193 Some(value) => !matches!(
194 value.to_ascii_lowercase().as_str(),
195 "0" | "false" | "no" | "off"
196 ),
197 None => true,
198 }
199}
200
201pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
205 let key = CacheKey::from_source(source_path, source);
206 if !cache_enabled() {
207 return LookupOutcome { key, chunk: None };
208 }
209 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
210 if let Some(adjacent) = adjacent_cache_path(source_path) {
211 candidates.push(adjacent);
212 }
213 candidates.push(cache_dir().join(key.filename()));
214 for path in candidates {
215 match read_chunk_if_matches(&path, &key) {
216 Ok(Some(chunk)) => {
217 return LookupOutcome {
218 key,
219 chunk: Some(chunk),
220 }
221 }
222 Ok(None) => continue,
223 Err(_) => continue,
224 }
225 }
226 LookupOutcome { key, chunk: None }
227}
228
229pub fn store(key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
233 if !cache_enabled() {
234 return Ok(());
235 }
236 let dir = cache_dir();
237 fs::create_dir_all(&dir)?;
238 write_atomic_chunk(&dir.join(key.filename()), key, chunk)
239}
240
241pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
246 ensure_parent_dir(path)?;
247 write_atomic_chunk(path, key, chunk)
248}
249
250pub fn load_module(source_path: &Path, source: &str) -> ModuleLookupOutcome {
253 let key = CacheKey::from_source(source_path, source);
254 if !cache_enabled() {
255 return ModuleLookupOutcome {
256 key,
257 artifact: None,
258 };
259 }
260 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
261 if let Some(adjacent) = adjacent_module_cache_path(source_path) {
262 candidates.push(adjacent);
263 }
264 candidates.push(cache_dir().join(key.module_filename()));
265 for path in candidates {
266 match read_module_if_matches(&path, &key) {
267 Ok(Some(artifact)) => {
268 return ModuleLookupOutcome {
269 key,
270 artifact: Some(artifact),
271 }
272 }
273 Ok(None) => continue,
274 Err(_) => continue,
275 }
276 }
277 ModuleLookupOutcome {
278 key,
279 artifact: None,
280 }
281}
282
283pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
286 if !cache_enabled() {
287 return Ok(());
288 }
289 let dir = cache_dir();
290 fs::create_dir_all(&dir)?;
291 write_atomic_module(&dir.join(key.module_filename()), key, artifact)
292}
293
294pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
296 ensure_parent_dir(path)?;
297 write_atomic_module(path, key, artifact)
298}
299
300pub struct ModuleLookupOutcome {
303 pub key: CacheKey,
304 pub artifact: Option<ModuleArtifact>,
305}
306
307pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
310 adjacent_path_with_extension(source_path, CACHE_EXTENSION)
311}
312
313pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
316 adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
317}
318
319fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
320 let stem = source_path.file_stem()?;
321 if stem.is_empty() {
322 return None;
323 }
324 let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
325 let mut out = parent.join(stem);
326 out.set_extension(ext);
327 Some(out)
328}
329
330fn ensure_parent_dir(path: &Path) -> io::Result<()> {
331 if let Some(parent) = path.parent() {
332 if !parent.as_os_str().is_empty() {
333 fs::create_dir_all(parent)?;
334 }
335 }
336 Ok(())
337}
338
339fn write_atomic_chunk(target: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
340 let buf = serialize_chunk_artifact(key, chunk)?;
341 write_atomic(target, &buf)
342}
343
344fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
345 let buf = serialize_module_artifact(key, artifact)?;
346 write_atomic(target, &buf)
347}
348
349pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
355 let cached = chunk.freeze_for_cache();
356 let payload = bincode::serde::encode_to_vec(&cached, bincode::config::standard())
357 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
358 Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
359}
360
361pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
364 let payload = bincode::serde::encode_to_vec(artifact, bincode::config::standard())
365 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
366 Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
367}
368
369fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
370 let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
371 buf.extend_from_slice(MAGIC);
372 buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
373 let version_bytes = HARN_VERSION.as_bytes();
374 buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
375 buf.extend_from_slice(version_bytes);
376 buf.push(key.compiler_tag);
377 buf.push(kind);
378 buf.extend_from_slice(&key.source_hash);
379 buf.extend_from_slice(&key.import_graph_hash);
380 buf.extend_from_slice(payload);
381 buf
382}
383
384fn write_atomic(target: &Path, buf: &[u8]) -> io::Result<()> {
385 let tmp_name = match target.file_name() {
386 Some(name) => format!(".{}.{}.tmp", name.to_string_lossy(), std::process::id()),
387 None => format!(".harn-cache.{}.tmp", std::process::id()),
388 };
389 let tmp_path = target.with_file_name(tmp_name);
390 let mut tmp_file = fs::File::create(&tmp_path)?;
391 tmp_file.write_all(buf)?;
392 tmp_file.sync_all()?;
393 drop(tmp_file);
394 match fs::rename(&tmp_path, target) {
395 Ok(()) => Ok(()),
396 Err(err) => {
397 let _ = fs::remove_file(&tmp_path);
398 Err(err)
399 }
400 }
401}
402
403struct ParsedHeader {
406 kind: u8,
407 payload: Vec<u8>,
408}
409
410fn read_header_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ParsedHeader>> {
411 let mut file = match fs::File::open(path) {
412 Ok(f) => f,
413 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
414 Err(err) => return Err(err),
415 };
416 let mut header = [0u8; 8 + 4 + 4];
417 if file.read_exact(&mut header).is_err() {
418 return Ok(None);
419 }
420 if &header[..8] != MAGIC {
421 return Ok(None);
422 }
423 let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
424 if schema != SCHEMA_VERSION {
425 return Ok(None);
426 }
427 let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
428 if version_len > 256 {
429 return Ok(None);
431 }
432 let mut version_buf = vec![0u8; version_len];
433 if file.read_exact(&mut version_buf).is_err() {
434 return Ok(None);
435 }
436 if version_buf != key.harn_version.as_bytes() {
437 return Ok(None);
438 }
439 let mut compiler_and_kind = [0u8; 2];
440 if file.read_exact(&mut compiler_and_kind).is_err() {
441 return Ok(None);
442 }
443 if compiler_and_kind[0] != key.compiler_tag {
444 return Ok(None);
445 }
446 let kind = compiler_and_kind[1];
447 let mut hashes = [0u8; 64];
448 if file.read_exact(&mut hashes).is_err() {
449 return Ok(None);
450 }
451 if hashes[..32] != key.source_hash || hashes[32..] != key.import_graph_hash {
452 return Ok(None);
453 }
454 let mut payload = Vec::new();
455 if file.read_to_end(&mut payload).is_err() {
456 return Ok(None);
457 }
458 Ok(Some(ParsedHeader { kind, payload }))
459}
460
461fn read_chunk_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<Chunk>> {
462 let Some(header) = read_header_if_matches(path, key)? else {
463 return Ok(None);
464 };
465 if header.kind != KIND_ENTRY_CHUNK {
466 return Ok(None);
467 }
468 let cached: CachedChunk =
469 match bincode::serde::decode_from_slice(&header.payload, bincode::config::standard()) {
470 Ok((c, _)) => c,
471 Err(_) => return Ok(None),
472 };
473 Ok(Some(Chunk::from_cached(&cached)))
474}
475
476fn read_module_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ModuleArtifact>> {
477 let Some(header) = read_header_if_matches(path, key)? else {
478 return Ok(None);
479 };
480 if header.kind != KIND_MODULE_ARTIFACT {
481 return Ok(None);
482 }
483 match bincode::serde::decode_from_slice::<ModuleArtifact, _>(
484 &header.payload,
485 bincode::config::standard(),
486 ) {
487 Ok((artifact, _)) => Ok(Some(artifact)),
488 Err(_) => Ok(None),
489 }
490}
491
492fn compiler_options_tag(options: CompilerOptions) -> u8 {
499 let mut tag: u8 = 0;
500 if options.optimizations_enabled() {
501 tag |= 0b0000_0001;
502 }
503 tag
504}
505
506fn sha256(bytes: &[u8]) -> [u8; 32] {
507 let mut hasher = Sha256::new();
508 hasher.update(bytes);
509 hasher.finalize().into()
510}
511
512fn hex(bytes: &[u8]) -> String {
513 let mut out = String::with_capacity(bytes.len() * 2);
514 for byte in bytes {
515 out.push_str(&format!("{byte:02x}"));
516 }
517 out
518}
519
520fn collect_user_imports(source: &str) -> Vec<String> {
526 let scrubbed = strip_comments(source);
527 let mut out: Vec<String> = Vec::new();
528 let bytes = scrubbed.as_bytes();
529 let mut i = 0;
530 while i < bytes.len() {
531 if bytes[i] == b'"' {
532 match read_string_literal(bytes, i) {
535 Some((_, end)) => {
536 i = end;
537 continue;
538 }
539 None => {
540 i += 1;
541 continue;
542 }
543 }
544 }
545 if !matches_keyword(bytes, i, b"import") {
546 i += 1;
547 continue;
548 }
549 let mut j = i + b"import".len();
552 let mut depth = 0i32;
553 while j < bytes.len() {
554 match bytes[j] {
555 b'"' => {
556 if let Some((path, end)) = read_string_literal(bytes, j) {
557 if !path.starts_with("std/") {
558 out.push(path);
559 }
560 i = end;
561 break;
562 }
563 j += 1;
564 }
565 b'{' => {
566 depth += 1;
567 j += 1;
568 }
569 b'}' => {
570 depth -= 1;
571 j += 1;
572 }
573 b'\n' if depth == 0 => {
574 i = j;
578 break;
579 }
580 _ => j += 1,
581 }
582 }
583 if j >= bytes.len() {
584 break;
585 }
586 if i < j {
587 i = j;
590 }
591 }
592 out
593}
594
595fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
596 let end = at + keyword.len();
597 if end > bytes.len() {
598 return false;
599 }
600 if &bytes[at..end] != keyword {
601 return false;
602 }
603 if at > 0 && is_ident_char(bytes[at - 1]) {
604 return false;
605 }
606 if end < bytes.len() && is_ident_char(bytes[end]) {
607 return false;
608 }
609 true
610}
611
612fn is_ident_char(b: u8) -> bool {
613 b.is_ascii_alphanumeric() || b == b'_'
614}
615
616fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
617 debug_assert_eq!(bytes[at], b'"');
618 let mut out = String::new();
619 let mut i = at + 1;
620 while i < bytes.len() {
621 match bytes[i] {
622 b'"' => return Some((out, i + 1)),
623 b'\\' => {
624 if i + 1 >= bytes.len() {
625 return None;
626 }
627 match bytes[i + 1] {
628 b'"' => out.push('"'),
629 b'\\' => out.push('\\'),
630 b'n' => out.push('\n'),
631 b'r' => out.push('\r'),
632 b't' => out.push('\t'),
633 other => out.push(other as char),
634 }
635 i += 2;
636 }
637 b'\n' => return None,
638 byte => {
639 out.push(byte as char);
640 i += 1;
641 }
642 }
643 }
644 None
645}
646
647fn strip_comments(source: &str) -> String {
648 let bytes = source.as_bytes();
649 let mut out = String::with_capacity(source.len());
650 let mut i = 0;
651 while i < bytes.len() {
652 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
653 while i < bytes.len() && bytes[i] != b'\n' {
654 i += 1;
655 }
656 continue;
657 }
658 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
659 i += 2;
660 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
661 i += 1;
662 }
663 i = (i + 2).min(bytes.len());
664 continue;
665 }
666 if bytes[i] == b'"' {
667 if let Some((_, end)) = read_string_literal(bytes, i) {
668 out.push_str(&source[i..end]);
669 i = end;
670 continue;
671 }
672 }
673 out.push(bytes[i] as char);
674 i += 1;
675 }
676 out
677}
678
679fn embedded_stdlib_digest() -> &'static [u8; 32] {
690 use std::sync::OnceLock;
691 static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
692 DIGEST.get_or_init(|| {
693 let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
694 .iter()
695 .map(|src| (src.module, src.source))
696 .collect();
697 entries.sort_by(|a, b| a.0.cmp(b.0));
698 let mut hasher = Sha256::new();
699 for (module, source) in entries {
700 hasher.update(module.as_bytes());
701 hasher.update(b"\0");
702 hasher.update(source.as_bytes());
703 hasher.update(b"\0");
704 }
705 hasher.finalize().into()
706 })
707}
708
709fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
720 hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
721}
722
723fn hash_transitive_user_imports_fingerprinted(
727 source_path: &Path,
728 source: &str,
729 codegen_fingerprint: &str,
730) -> [u8; 32] {
731 let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
732 std::collections::BTreeMap::new();
733 let mut frontier: Vec<(PathBuf, String)> = collect_user_imports(source)
734 .into_iter()
735 .map(|import| (source_path.to_path_buf(), import))
736 .collect();
737
738 while let Some((anchor, import)) = frontier.pop() {
739 let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
740 let sentinel = anchor.join(format!("__unresolved__/{import}"));
744 visited
745 .entry(sentinel)
746 .or_insert(ImportNode::Unresolved { import });
747 continue;
748 };
749 let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
750 if visited.contains_key(&canonical) {
751 continue;
752 }
753 match fs::read_to_string(&resolved) {
754 Ok(content) => {
755 let nested = collect_user_imports(&content);
756 visited.insert(
757 canonical.clone(),
758 ImportNode::Resolved {
759 content: content.clone(),
760 },
761 );
762 for nested_import in nested {
763 frontier.push((resolved.clone(), nested_import));
764 }
765 }
766 Err(err) => {
767 visited.insert(
768 canonical,
769 ImportNode::IoError {
770 kind: err.kind().to_string(),
771 },
772 );
773 }
774 }
775 }
776
777 let mut hasher = Sha256::new();
778 hasher.update(b"stdlib-digest\0");
779 hasher.update(embedded_stdlib_digest());
780 hasher.update(b"\0");
781 hasher.update(b"codegen-fingerprint\0");
786 hasher.update(codegen_fingerprint.as_bytes());
787 hasher.update(b"\0");
788 for (path, node) in &visited {
789 hasher.update(path.to_string_lossy().as_bytes());
790 hasher.update(b"\0");
791 match node {
792 ImportNode::Resolved { content } => {
793 hasher.update(b"resolved\0");
794 hasher.update(content.as_bytes());
795 }
796 ImportNode::Unresolved { import } => {
797 hasher.update(b"unresolved\0");
798 hasher.update(import.as_bytes());
799 }
800 ImportNode::IoError { kind } => {
801 hasher.update(b"ioerror\0");
802 hasher.update(kind.as_bytes());
803 }
804 }
805 hasher.update(b"\0");
806 }
807 hasher.finalize().into()
808}
809
810enum ImportNode {
811 Resolved { content: String },
812 Unresolved { import: String },
813 IoError { kind: String },
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use crate::compile_source;
820
821 #[test]
822 fn header_round_trips_chunk() {
823 let chunk = compile_source("__io_println(\"hello\")").expect("compile");
824 let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
825 let tmp = tempfile::tempdir().unwrap();
826 let path = tmp.path().join("entry.harnbc");
827 store_at(&path, &key, &chunk).expect("write");
828 let loaded = read_chunk_if_matches(&path, &key).unwrap();
829 assert!(loaded.is_some(), "expected cached chunk to load");
830 }
831
832 #[test]
833 fn serialize_chunk_artifact_matches_store_at() {
834 let chunk = compile_source("__io_println(\"hi\")").expect("compile");
840 let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
841 let tmp = tempfile::tempdir().unwrap();
842 let on_disk = tmp.path().join("pack.harnbc");
843 store_at(&on_disk, &key, &chunk).expect("write");
844 let on_disk_bytes = std::fs::read(&on_disk).unwrap();
845 let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
846 assert_eq!(in_memory_bytes, on_disk_bytes);
847 }
848
849 #[test]
850 fn header_mismatch_returns_none() {
851 let chunk = compile_source("1 + 1").expect("compile");
852 let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
853 let tmp = tempfile::tempdir().unwrap();
854 let path = tmp.path().join("a.harnbc");
855 store_at(&path, &key, &chunk).expect("write");
856 let other = CacheKey {
857 source_hash: [0xAB; 32],
858 import_graph_hash: key.import_graph_hash,
859 harn_version: HARN_VERSION,
860 compiler_tag: key.compiler_tag,
861 };
862 assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
863 }
864
865 #[test]
866 fn compiler_tag_mismatch_returns_none() {
867 let chunk = compile_source("1 + 1").expect("compile");
868 let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
869 let tmp = tempfile::tempdir().unwrap();
870 let path = tmp.path().join("b.harnbc");
871 store_at(&path, &key, &chunk).expect("write");
872 let other = CacheKey {
873 compiler_tag: key.compiler_tag ^ 0xFF,
874 ..key
875 };
876 assert!(
877 read_chunk_if_matches(&path, &other).unwrap().is_none(),
878 "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
879 compiled under the opposite setting"
880 );
881 }
882
883 #[test]
884 fn codegen_fingerprint_is_populated() {
885 assert!(!CODEGEN_FINGERPRINT.is_empty());
889 }
890
891 #[test]
892 fn codegen_fingerprint_changes_cache_key() {
893 let tmp = tempfile::tempdir().unwrap();
899 let entry = tmp.path().join("entry.harn");
900 std::fs::write(&entry, "__io_println(\"hi\")\n").unwrap();
901 let source = std::fs::read_to_string(&entry).unwrap();
902 let a = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
903 let b = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-B");
904 let a_again = hash_transitive_user_imports_fingerprinted(&entry, &source, "compiler-A");
905 assert_ne!(
906 a, b,
907 "differing compiler fingerprints must change the cache key"
908 );
909 assert_eq!(
910 a, a_again,
911 "an unchanged compiler fingerprint must be stable"
912 );
913 }
914
915 #[test]
916 fn collect_user_imports_ignores_stdlib_and_comments() {
917 let source = r#"
918 // import "comment/should/be/ignored"
919 import "std/agents"
920 import { foo } from "pkg/bar"
921 import "./relative/path"
922 "#;
923 let imports = collect_user_imports(source);
924 assert_eq!(
925 imports,
926 vec!["pkg/bar".to_string(), "./relative/path".to_string()]
927 );
928 }
929
930 #[test]
931 fn cache_enabled_respects_env() {
932 std::env::set_var(CACHE_ENABLED_ENV, "0");
933 assert!(!cache_enabled());
934 std::env::set_var(CACHE_ENABLED_ENV, "1");
935 assert!(cache_enabled());
936 std::env::remove_var(CACHE_ENABLED_ENV);
937 assert!(cache_enabled());
938 }
939
940 #[test]
941 fn import_path_inside_string_literal_is_ignored() {
942 let source = r#"
943 let payload = "import { foo } from \"./other\""
944 import "./real"
945 "#;
946 let imports = collect_user_imports(source);
947 assert_eq!(imports, vec!["./real".to_string()]);
948 }
949
950 #[test]
951 fn import_hash_is_stable_across_import_order() {
952 let tmp = tempfile::tempdir().unwrap();
953 std::fs::write(
954 tmp.path().join("a.harn"),
955 "pub fn a() -> int { return 1 }\n",
956 )
957 .unwrap();
958 std::fs::write(
959 tmp.path().join("b.harn"),
960 "pub fn b() -> int { return 2 }\n",
961 )
962 .unwrap();
963 let ab = tmp.path().join("entry_ab.harn");
964 std::fs::write(
965 &ab,
966 "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
967 )
968 .unwrap();
969 let ba = tmp.path().join("entry_ba.harn");
970 std::fs::write(
971 &ba,
972 "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
973 )
974 .unwrap();
975 let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
976 let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
977 assert_eq!(
978 hash_ab, hash_ba,
979 "import-graph hash must be order-independent so reordering imports \
980 does not bust the cache"
981 );
982 }
983
984 #[test]
985 fn import_hash_picks_up_nested_imports() {
986 let tmp = tempfile::tempdir().unwrap();
987 std::fs::write(
988 tmp.path().join("leaf.harn"),
989 "pub fn x() -> int { return 1 }\n",
990 )
991 .unwrap();
992 std::fs::write(
993 tmp.path().join("mid.harn"),
994 "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
995 )
996 .unwrap();
997 let entry = tmp.path().join("entry.harn");
998 std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
999
1000 let before =
1001 hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1002 std::fs::write(
1003 tmp.path().join("leaf.harn"),
1004 "pub fn x() -> int { return 999 }\n",
1005 )
1006 .unwrap();
1007 let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
1008 assert_ne!(
1009 before, after,
1010 "editing a transitively-imported file must change the import-graph hash"
1011 );
1012 }
1013}