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 CACHE_EXTENSION: &str = "harnbc";
62
63pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
68
69const KIND_ENTRY_CHUNK: u8 = 1;
71const KIND_MODULE_ARTIFACT: u8 = 2;
73
74pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
77
78pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
82
83pub struct LookupOutcome {
86 pub key: CacheKey,
87 pub chunk: Option<Chunk>,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct CacheKey {
94 pub source_hash: [u8; 32],
95 pub import_graph_hash: [u8; 32],
96 pub harn_version: &'static str,
97 pub compiler_tag: u8,
101}
102
103impl CacheKey {
104 pub fn from_source(source_path: &Path, source: &str) -> Self {
108 let source_hash = sha256(source.as_bytes());
109 let import_graph_hash = hash_transitive_user_imports(source_path, source);
110 Self {
111 source_hash,
112 import_graph_hash,
113 harn_version: HARN_VERSION,
114 compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
115 }
116 }
117
118 pub fn filename(&self) -> String {
123 format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
124 }
125
126 pub fn module_filename(&self) -> String {
128 format!("{}.{}", hex(&self.source_hash), MODULE_CACHE_EXTENSION)
129 }
130}
131
132pub fn cache_dir() -> PathBuf {
138 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
139 return PathBuf::from(custom);
140 }
141 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
142 let xdg = PathBuf::from(xdg);
143 if !xdg.as_os_str().is_empty() {
144 return xdg.join("harn").join("bytecode");
145 }
146 }
147 if let Some(home) = std::env::var_os("HOME") {
148 return PathBuf::from(home)
149 .join(".cache")
150 .join("harn")
151 .join("bytecode");
152 }
153 PathBuf::from(".harn-cache").join("bytecode")
156}
157
158pub fn packs_cache_dir() -> PathBuf {
163 if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
164 return PathBuf::from(custom).join("packs");
165 }
166 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
167 let xdg = PathBuf::from(xdg);
168 if !xdg.as_os_str().is_empty() {
169 return xdg.join("harn").join("packs");
170 }
171 }
172 if let Some(home) = std::env::var_os("HOME") {
173 return PathBuf::from(home)
174 .join(".cache")
175 .join("harn")
176 .join("packs");
177 }
178 PathBuf::from(".harn-cache").join("packs")
179}
180
181pub fn cache_enabled() -> bool {
183 match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
184 Some(value) => !matches!(
185 value.to_ascii_lowercase().as_str(),
186 "0" | "false" | "no" | "off"
187 ),
188 None => true,
189 }
190}
191
192pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
196 let key = CacheKey::from_source(source_path, source);
197 if !cache_enabled() {
198 return LookupOutcome { key, chunk: None };
199 }
200 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
201 if let Some(adjacent) = adjacent_cache_path(source_path) {
202 candidates.push(adjacent);
203 }
204 candidates.push(cache_dir().join(key.filename()));
205 for path in candidates {
206 match read_chunk_if_matches(&path, &key) {
207 Ok(Some(chunk)) => {
208 return LookupOutcome {
209 key,
210 chunk: Some(chunk),
211 }
212 }
213 Ok(None) => continue,
214 Err(_) => continue,
215 }
216 }
217 LookupOutcome { key, chunk: None }
218}
219
220pub fn store(key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
224 if !cache_enabled() {
225 return Ok(());
226 }
227 let dir = cache_dir();
228 fs::create_dir_all(&dir)?;
229 write_atomic_chunk(&dir.join(key.filename()), key, chunk)
230}
231
232pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
237 ensure_parent_dir(path)?;
238 write_atomic_chunk(path, key, chunk)
239}
240
241pub fn load_module(source_path: &Path, source: &str) -> ModuleLookupOutcome {
244 let key = CacheKey::from_source(source_path, source);
245 if !cache_enabled() {
246 return ModuleLookupOutcome {
247 key,
248 artifact: None,
249 };
250 }
251 let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
252 if let Some(adjacent) = adjacent_module_cache_path(source_path) {
253 candidates.push(adjacent);
254 }
255 candidates.push(cache_dir().join(key.module_filename()));
256 for path in candidates {
257 match read_module_if_matches(&path, &key) {
258 Ok(Some(artifact)) => {
259 return ModuleLookupOutcome {
260 key,
261 artifact: Some(artifact),
262 }
263 }
264 Ok(None) => continue,
265 Err(_) => continue,
266 }
267 }
268 ModuleLookupOutcome {
269 key,
270 artifact: None,
271 }
272}
273
274pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
277 if !cache_enabled() {
278 return Ok(());
279 }
280 let dir = cache_dir();
281 fs::create_dir_all(&dir)?;
282 write_atomic_module(&dir.join(key.module_filename()), key, artifact)
283}
284
285pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
287 ensure_parent_dir(path)?;
288 write_atomic_module(path, key, artifact)
289}
290
291pub struct ModuleLookupOutcome {
294 pub key: CacheKey,
295 pub artifact: Option<ModuleArtifact>,
296}
297
298pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
301 adjacent_path_with_extension(source_path, CACHE_EXTENSION)
302}
303
304pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
307 adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
308}
309
310fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
311 let stem = source_path.file_stem()?;
312 if stem.is_empty() {
313 return None;
314 }
315 let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
316 let mut out = parent.join(stem);
317 out.set_extension(ext);
318 Some(out)
319}
320
321fn ensure_parent_dir(path: &Path) -> io::Result<()> {
322 if let Some(parent) = path.parent() {
323 if !parent.as_os_str().is_empty() {
324 fs::create_dir_all(parent)?;
325 }
326 }
327 Ok(())
328}
329
330fn write_atomic_chunk(target: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
331 let buf = serialize_chunk_artifact(key, chunk)?;
332 write_atomic(target, &buf)
333}
334
335fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
336 let buf = serialize_module_artifact(key, artifact)?;
337 write_atomic(target, &buf)
338}
339
340pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
346 let cached = chunk.freeze_for_cache();
347 let payload = bincode::serde::encode_to_vec(&cached, bincode::config::standard())
348 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
349 Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
350}
351
352pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
355 let payload = bincode::serde::encode_to_vec(artifact, bincode::config::standard())
356 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
357 Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
358}
359
360fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
361 let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
362 buf.extend_from_slice(MAGIC);
363 buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
364 let version_bytes = HARN_VERSION.as_bytes();
365 buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
366 buf.extend_from_slice(version_bytes);
367 buf.push(key.compiler_tag);
368 buf.push(kind);
369 buf.extend_from_slice(&key.source_hash);
370 buf.extend_from_slice(&key.import_graph_hash);
371 buf.extend_from_slice(payload);
372 buf
373}
374
375fn write_atomic(target: &Path, buf: &[u8]) -> io::Result<()> {
376 let tmp_name = match target.file_name() {
377 Some(name) => format!(".{}.{}.tmp", name.to_string_lossy(), std::process::id()),
378 None => format!(".harn-cache.{}.tmp", std::process::id()),
379 };
380 let tmp_path = target.with_file_name(tmp_name);
381 let mut tmp_file = fs::File::create(&tmp_path)?;
382 tmp_file.write_all(buf)?;
383 tmp_file.sync_all()?;
384 drop(tmp_file);
385 match fs::rename(&tmp_path, target) {
386 Ok(()) => Ok(()),
387 Err(err) => {
388 let _ = fs::remove_file(&tmp_path);
389 Err(err)
390 }
391 }
392}
393
394struct ParsedHeader {
397 kind: u8,
398 payload: Vec<u8>,
399}
400
401fn read_header_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ParsedHeader>> {
402 let mut file = match fs::File::open(path) {
403 Ok(f) => f,
404 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
405 Err(err) => return Err(err),
406 };
407 let mut header = [0u8; 8 + 4 + 4];
408 if file.read_exact(&mut header).is_err() {
409 return Ok(None);
410 }
411 if &header[..8] != MAGIC {
412 return Ok(None);
413 }
414 let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
415 if schema != SCHEMA_VERSION {
416 return Ok(None);
417 }
418 let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
419 if version_len > 256 {
420 return Ok(None);
422 }
423 let mut version_buf = vec![0u8; version_len];
424 if file.read_exact(&mut version_buf).is_err() {
425 return Ok(None);
426 }
427 if version_buf != key.harn_version.as_bytes() {
428 return Ok(None);
429 }
430 let mut compiler_and_kind = [0u8; 2];
431 if file.read_exact(&mut compiler_and_kind).is_err() {
432 return Ok(None);
433 }
434 if compiler_and_kind[0] != key.compiler_tag {
435 return Ok(None);
436 }
437 let kind = compiler_and_kind[1];
438 let mut hashes = [0u8; 64];
439 if file.read_exact(&mut hashes).is_err() {
440 return Ok(None);
441 }
442 if hashes[..32] != key.source_hash || hashes[32..] != key.import_graph_hash {
443 return Ok(None);
444 }
445 let mut payload = Vec::new();
446 if file.read_to_end(&mut payload).is_err() {
447 return Ok(None);
448 }
449 Ok(Some(ParsedHeader { kind, payload }))
450}
451
452fn read_chunk_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<Chunk>> {
453 let Some(header) = read_header_if_matches(path, key)? else {
454 return Ok(None);
455 };
456 if header.kind != KIND_ENTRY_CHUNK {
457 return Ok(None);
458 }
459 let cached: CachedChunk =
460 match bincode::serde::decode_from_slice(&header.payload, bincode::config::standard()) {
461 Ok((c, _)) => c,
462 Err(_) => return Ok(None),
463 };
464 Ok(Some(Chunk::from_cached(&cached)))
465}
466
467fn read_module_if_matches(path: &Path, key: &CacheKey) -> io::Result<Option<ModuleArtifact>> {
468 let Some(header) = read_header_if_matches(path, key)? else {
469 return Ok(None);
470 };
471 if header.kind != KIND_MODULE_ARTIFACT {
472 return Ok(None);
473 }
474 match bincode::serde::decode_from_slice::<ModuleArtifact, _>(
475 &header.payload,
476 bincode::config::standard(),
477 ) {
478 Ok((artifact, _)) => Ok(Some(artifact)),
479 Err(_) => Ok(None),
480 }
481}
482
483fn compiler_options_tag(options: CompilerOptions) -> u8 {
490 let mut tag: u8 = 0;
491 if options.optimizations_enabled() {
492 tag |= 0b0000_0001;
493 }
494 tag
495}
496
497fn sha256(bytes: &[u8]) -> [u8; 32] {
498 let mut hasher = Sha256::new();
499 hasher.update(bytes);
500 hasher.finalize().into()
501}
502
503fn hex(bytes: &[u8]) -> String {
504 let mut out = String::with_capacity(bytes.len() * 2);
505 for byte in bytes {
506 out.push_str(&format!("{byte:02x}"));
507 }
508 out
509}
510
511fn collect_user_imports(source: &str) -> Vec<String> {
517 let scrubbed = strip_comments(source);
518 let mut out: Vec<String> = Vec::new();
519 let bytes = scrubbed.as_bytes();
520 let mut i = 0;
521 while i < bytes.len() {
522 if bytes[i] == b'"' {
523 match read_string_literal(bytes, i) {
526 Some((_, end)) => {
527 i = end;
528 continue;
529 }
530 None => {
531 i += 1;
532 continue;
533 }
534 }
535 }
536 if !matches_keyword(bytes, i, b"import") {
537 i += 1;
538 continue;
539 }
540 let mut j = i + b"import".len();
543 let mut depth = 0i32;
544 while j < bytes.len() {
545 match bytes[j] {
546 b'"' => {
547 if let Some((path, end)) = read_string_literal(bytes, j) {
548 if !path.starts_with("std/") {
549 out.push(path);
550 }
551 i = end;
552 break;
553 }
554 j += 1;
555 }
556 b'{' => {
557 depth += 1;
558 j += 1;
559 }
560 b'}' => {
561 depth -= 1;
562 j += 1;
563 }
564 b'\n' if depth == 0 => {
565 i = j;
569 break;
570 }
571 _ => j += 1,
572 }
573 }
574 if j >= bytes.len() {
575 break;
576 }
577 if i < j {
578 i = j;
581 }
582 }
583 out
584}
585
586fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
587 let end = at + keyword.len();
588 if end > bytes.len() {
589 return false;
590 }
591 if &bytes[at..end] != keyword {
592 return false;
593 }
594 if at > 0 && is_ident_char(bytes[at - 1]) {
595 return false;
596 }
597 if end < bytes.len() && is_ident_char(bytes[end]) {
598 return false;
599 }
600 true
601}
602
603fn is_ident_char(b: u8) -> bool {
604 b.is_ascii_alphanumeric() || b == b'_'
605}
606
607fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
608 debug_assert_eq!(bytes[at], b'"');
609 let mut out = String::new();
610 let mut i = at + 1;
611 while i < bytes.len() {
612 match bytes[i] {
613 b'"' => return Some((out, i + 1)),
614 b'\\' => {
615 if i + 1 >= bytes.len() {
616 return None;
617 }
618 match bytes[i + 1] {
619 b'"' => out.push('"'),
620 b'\\' => out.push('\\'),
621 b'n' => out.push('\n'),
622 b'r' => out.push('\r'),
623 b't' => out.push('\t'),
624 other => out.push(other as char),
625 }
626 i += 2;
627 }
628 b'\n' => return None,
629 byte => {
630 out.push(byte as char);
631 i += 1;
632 }
633 }
634 }
635 None
636}
637
638fn strip_comments(source: &str) -> String {
639 let bytes = source.as_bytes();
640 let mut out = String::with_capacity(source.len());
641 let mut i = 0;
642 while i < bytes.len() {
643 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
644 while i < bytes.len() && bytes[i] != b'\n' {
645 i += 1;
646 }
647 continue;
648 }
649 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
650 i += 2;
651 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
652 i += 1;
653 }
654 i = (i + 2).min(bytes.len());
655 continue;
656 }
657 if bytes[i] == b'"' {
658 if let Some((_, end)) = read_string_literal(bytes, i) {
659 out.push_str(&source[i..end]);
660 i = end;
661 continue;
662 }
663 }
664 out.push(bytes[i] as char);
665 i += 1;
666 }
667 out
668}
669
670fn embedded_stdlib_digest() -> &'static [u8; 32] {
681 use std::sync::OnceLock;
682 static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
683 DIGEST.get_or_init(|| {
684 let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
685 .iter()
686 .map(|src| (src.module, src.source))
687 .collect();
688 entries.sort_by(|a, b| a.0.cmp(b.0));
689 let mut hasher = Sha256::new();
690 for (module, source) in entries {
691 hasher.update(module.as_bytes());
692 hasher.update(b"\0");
693 hasher.update(source.as_bytes());
694 hasher.update(b"\0");
695 }
696 hasher.finalize().into()
697 })
698}
699
700fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
711 let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
712 std::collections::BTreeMap::new();
713 let mut frontier: Vec<(PathBuf, String)> = collect_user_imports(source)
714 .into_iter()
715 .map(|import| (source_path.to_path_buf(), import))
716 .collect();
717
718 while let Some((anchor, import)) = frontier.pop() {
719 let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
720 let sentinel = anchor.join(format!("__unresolved__/{import}"));
724 visited
725 .entry(sentinel)
726 .or_insert(ImportNode::Unresolved { import });
727 continue;
728 };
729 let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
730 if visited.contains_key(&canonical) {
731 continue;
732 }
733 match fs::read_to_string(&resolved) {
734 Ok(content) => {
735 let nested = collect_user_imports(&content);
736 visited.insert(
737 canonical.clone(),
738 ImportNode::Resolved {
739 content: content.clone(),
740 },
741 );
742 for nested_import in nested {
743 frontier.push((resolved.clone(), nested_import));
744 }
745 }
746 Err(err) => {
747 visited.insert(
748 canonical,
749 ImportNode::IoError {
750 kind: err.kind().to_string(),
751 },
752 );
753 }
754 }
755 }
756
757 let mut hasher = Sha256::new();
758 hasher.update(b"stdlib-digest\0");
759 hasher.update(embedded_stdlib_digest());
760 hasher.update(b"\0");
761 for (path, node) in &visited {
762 hasher.update(path.to_string_lossy().as_bytes());
763 hasher.update(b"\0");
764 match node {
765 ImportNode::Resolved { content } => {
766 hasher.update(b"resolved\0");
767 hasher.update(content.as_bytes());
768 }
769 ImportNode::Unresolved { import } => {
770 hasher.update(b"unresolved\0");
771 hasher.update(import.as_bytes());
772 }
773 ImportNode::IoError { kind } => {
774 hasher.update(b"ioerror\0");
775 hasher.update(kind.as_bytes());
776 }
777 }
778 hasher.update(b"\0");
779 }
780 hasher.finalize().into()
781}
782
783enum ImportNode {
784 Resolved { content: String },
785 Unresolved { import: String },
786 IoError { kind: String },
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::compile_source;
793
794 #[test]
795 fn header_round_trips_chunk() {
796 let chunk = compile_source("__io_println(\"hello\")").expect("compile");
797 let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
798 let tmp = tempfile::tempdir().unwrap();
799 let path = tmp.path().join("entry.harnbc");
800 store_at(&path, &key, &chunk).expect("write");
801 let loaded = read_chunk_if_matches(&path, &key).unwrap();
802 assert!(loaded.is_some(), "expected cached chunk to load");
803 }
804
805 #[test]
806 fn serialize_chunk_artifact_matches_store_at() {
807 let chunk = compile_source("__io_println(\"hi\")").expect("compile");
813 let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
814 let tmp = tempfile::tempdir().unwrap();
815 let on_disk = tmp.path().join("pack.harnbc");
816 store_at(&on_disk, &key, &chunk).expect("write");
817 let on_disk_bytes = std::fs::read(&on_disk).unwrap();
818 let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
819 assert_eq!(in_memory_bytes, on_disk_bytes);
820 }
821
822 #[test]
823 fn header_mismatch_returns_none() {
824 let chunk = compile_source("1 + 1").expect("compile");
825 let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
826 let tmp = tempfile::tempdir().unwrap();
827 let path = tmp.path().join("a.harnbc");
828 store_at(&path, &key, &chunk).expect("write");
829 let other = CacheKey {
830 source_hash: [0xAB; 32],
831 import_graph_hash: key.import_graph_hash,
832 harn_version: HARN_VERSION,
833 compiler_tag: key.compiler_tag,
834 };
835 assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
836 }
837
838 #[test]
839 fn compiler_tag_mismatch_returns_none() {
840 let chunk = compile_source("1 + 1").expect("compile");
841 let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
842 let tmp = tempfile::tempdir().unwrap();
843 let path = tmp.path().join("b.harnbc");
844 store_at(&path, &key, &chunk).expect("write");
845 let other = CacheKey {
846 compiler_tag: key.compiler_tag ^ 0xFF,
847 ..key
848 };
849 assert!(
850 read_chunk_if_matches(&path, &other).unwrap().is_none(),
851 "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
852 compiled under the opposite setting"
853 );
854 }
855
856 #[test]
857 fn collect_user_imports_ignores_stdlib_and_comments() {
858 let source = r#"
859 // import "comment/should/be/ignored"
860 import "std/agents"
861 import { foo } from "pkg/bar"
862 import "./relative/path"
863 "#;
864 let imports = collect_user_imports(source);
865 assert_eq!(
866 imports,
867 vec!["pkg/bar".to_string(), "./relative/path".to_string()]
868 );
869 }
870
871 #[test]
872 fn cache_enabled_respects_env() {
873 std::env::set_var(CACHE_ENABLED_ENV, "0");
874 assert!(!cache_enabled());
875 std::env::set_var(CACHE_ENABLED_ENV, "1");
876 assert!(cache_enabled());
877 std::env::remove_var(CACHE_ENABLED_ENV);
878 assert!(cache_enabled());
879 }
880
881 #[test]
882 fn import_path_inside_string_literal_is_ignored() {
883 let source = r#"
884 let payload = "import { foo } from \"./other\""
885 import "./real"
886 "#;
887 let imports = collect_user_imports(source);
888 assert_eq!(imports, vec!["./real".to_string()]);
889 }
890
891 #[test]
892 fn import_hash_is_stable_across_import_order() {
893 let tmp = tempfile::tempdir().unwrap();
894 std::fs::write(
895 tmp.path().join("a.harn"),
896 "pub fn a() -> int { return 1 }\n",
897 )
898 .unwrap();
899 std::fs::write(
900 tmp.path().join("b.harn"),
901 "pub fn b() -> int { return 2 }\n",
902 )
903 .unwrap();
904 let ab = tmp.path().join("entry_ab.harn");
905 std::fs::write(
906 &ab,
907 "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
908 )
909 .unwrap();
910 let ba = tmp.path().join("entry_ba.harn");
911 std::fs::write(
912 &ba,
913 "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
914 )
915 .unwrap();
916 let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
917 let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
918 assert_eq!(
919 hash_ab, hash_ba,
920 "import-graph hash must be order-independent so reordering imports \
921 does not bust the cache"
922 );
923 }
924
925 #[test]
926 fn import_hash_picks_up_nested_imports() {
927 let tmp = tempfile::tempdir().unwrap();
928 std::fs::write(
929 tmp.path().join("leaf.harn"),
930 "pub fn x() -> int { return 1 }\n",
931 )
932 .unwrap();
933 std::fs::write(
934 tmp.path().join("mid.harn"),
935 "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
936 )
937 .unwrap();
938 let entry = tmp.path().join("entry.harn");
939 std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
940
941 let before =
942 hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
943 std::fs::write(
944 tmp.path().join("leaf.harn"),
945 "pub fn x() -> int { return 999 }\n",
946 )
947 .unwrap();
948 let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
949 assert_ne!(
950 before, after,
951 "editing a transitively-imported file must change the import-graph hash"
952 );
953 }
954}