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 hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
676 let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
677 std::collections::BTreeMap::new();
678 let mut frontier: Vec<(PathBuf, String)> = collect_user_imports(source)
679 .into_iter()
680 .map(|import| (source_path.to_path_buf(), import))
681 .collect();
682
683 while let Some((anchor, import)) = frontier.pop() {
684 let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
685 let sentinel = anchor.join(format!("__unresolved__/{import}"));
689 visited
690 .entry(sentinel)
691 .or_insert(ImportNode::Unresolved { import });
692 continue;
693 };
694 let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
695 if visited.contains_key(&canonical) {
696 continue;
697 }
698 match fs::read_to_string(&resolved) {
699 Ok(content) => {
700 let nested = collect_user_imports(&content);
701 visited.insert(
702 canonical.clone(),
703 ImportNode::Resolved {
704 content: content.clone(),
705 },
706 );
707 for nested_import in nested {
708 frontier.push((resolved.clone(), nested_import));
709 }
710 }
711 Err(err) => {
712 visited.insert(
713 canonical,
714 ImportNode::IoError {
715 kind: err.kind().to_string(),
716 },
717 );
718 }
719 }
720 }
721
722 let mut hasher = Sha256::new();
723 for (path, node) in &visited {
724 hasher.update(path.to_string_lossy().as_bytes());
725 hasher.update(b"\0");
726 match node {
727 ImportNode::Resolved { content } => {
728 hasher.update(b"resolved\0");
729 hasher.update(content.as_bytes());
730 }
731 ImportNode::Unresolved { import } => {
732 hasher.update(b"unresolved\0");
733 hasher.update(import.as_bytes());
734 }
735 ImportNode::IoError { kind } => {
736 hasher.update(b"ioerror\0");
737 hasher.update(kind.as_bytes());
738 }
739 }
740 hasher.update(b"\0");
741 }
742 hasher.finalize().into()
743}
744
745enum ImportNode {
746 Resolved { content: String },
747 Unresolved { import: String },
748 IoError { kind: String },
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::compile_source;
755
756 #[test]
757 fn header_round_trips_chunk() {
758 let chunk = compile_source("__io_println(\"hello\")").expect("compile");
759 let key = CacheKey::from_source(Path::new("/tmp/example.harn"), "__io_println(\"hello\")");
760 let tmp = tempfile::tempdir().unwrap();
761 let path = tmp.path().join("entry.harnbc");
762 store_at(&path, &key, &chunk).expect("write");
763 let loaded = read_chunk_if_matches(&path, &key).unwrap();
764 assert!(loaded.is_some(), "expected cached chunk to load");
765 }
766
767 #[test]
768 fn serialize_chunk_artifact_matches_store_at() {
769 let chunk = compile_source("__io_println(\"hi\")").expect("compile");
775 let key = CacheKey::from_source(Path::new("/tmp/pack.harn"), "__io_println(\"hi\")");
776 let tmp = tempfile::tempdir().unwrap();
777 let on_disk = tmp.path().join("pack.harnbc");
778 store_at(&on_disk, &key, &chunk).expect("write");
779 let on_disk_bytes = std::fs::read(&on_disk).unwrap();
780 let in_memory_bytes = serialize_chunk_artifact(&key, &chunk).expect("serialize");
781 assert_eq!(in_memory_bytes, on_disk_bytes);
782 }
783
784 #[test]
785 fn header_mismatch_returns_none() {
786 let chunk = compile_source("1 + 1").expect("compile");
787 let key = CacheKey::from_source(Path::new("/tmp/a.harn"), "1 + 1");
788 let tmp = tempfile::tempdir().unwrap();
789 let path = tmp.path().join("a.harnbc");
790 store_at(&path, &key, &chunk).expect("write");
791 let other = CacheKey {
792 source_hash: [0xAB; 32],
793 import_graph_hash: key.import_graph_hash,
794 harn_version: HARN_VERSION,
795 compiler_tag: key.compiler_tag,
796 };
797 assert!(read_chunk_if_matches(&path, &other).unwrap().is_none());
798 }
799
800 #[test]
801 fn compiler_tag_mismatch_returns_none() {
802 let chunk = compile_source("1 + 1").expect("compile");
803 let key = CacheKey::from_source(Path::new("/tmp/b.harn"), "1 + 1");
804 let tmp = tempfile::tempdir().unwrap();
805 let path = tmp.path().join("b.harnbc");
806 store_at(&path, &key, &chunk).expect("write");
807 let other = CacheKey {
808 compiler_tag: key.compiler_tag ^ 0xFF,
809 ..key
810 };
811 assert!(
812 read_chunk_if_matches(&path, &other).unwrap().is_none(),
813 "flipped HARN_DISABLE_OPTIMIZATIONS must not reuse a chunk \
814 compiled under the opposite setting"
815 );
816 }
817
818 #[test]
819 fn collect_user_imports_ignores_stdlib_and_comments() {
820 let source = r#"
821 // import "comment/should/be/ignored"
822 import "std/agents"
823 import { foo } from "pkg/bar"
824 import "./relative/path"
825 "#;
826 let imports = collect_user_imports(source);
827 assert_eq!(
828 imports,
829 vec!["pkg/bar".to_string(), "./relative/path".to_string()]
830 );
831 }
832
833 #[test]
834 fn cache_enabled_respects_env() {
835 std::env::set_var(CACHE_ENABLED_ENV, "0");
836 assert!(!cache_enabled());
837 std::env::set_var(CACHE_ENABLED_ENV, "1");
838 assert!(cache_enabled());
839 std::env::remove_var(CACHE_ENABLED_ENV);
840 assert!(cache_enabled());
841 }
842
843 #[test]
844 fn import_path_inside_string_literal_is_ignored() {
845 let source = r#"
846 let payload = "import { foo } from \"./other\""
847 import "./real"
848 "#;
849 let imports = collect_user_imports(source);
850 assert_eq!(imports, vec!["./real".to_string()]);
851 }
852
853 #[test]
854 fn import_hash_is_stable_across_import_order() {
855 let tmp = tempfile::tempdir().unwrap();
856 std::fs::write(
857 tmp.path().join("a.harn"),
858 "pub fn a() -> int { return 1 }\n",
859 )
860 .unwrap();
861 std::fs::write(
862 tmp.path().join("b.harn"),
863 "pub fn b() -> int { return 2 }\n",
864 )
865 .unwrap();
866 let ab = tmp.path().join("entry_ab.harn");
867 std::fs::write(
868 &ab,
869 "import \"./a\"\nimport \"./b\"\n__io_println(\"hi\")\n",
870 )
871 .unwrap();
872 let ba = tmp.path().join("entry_ba.harn");
873 std::fs::write(
874 &ba,
875 "import \"./b\"\nimport \"./a\"\n__io_println(\"hi\")\n",
876 )
877 .unwrap();
878 let hash_ab = hash_transitive_user_imports(&ab, &std::fs::read_to_string(&ab).unwrap());
879 let hash_ba = hash_transitive_user_imports(&ba, &std::fs::read_to_string(&ba).unwrap());
880 assert_eq!(
881 hash_ab, hash_ba,
882 "import-graph hash must be order-independent so reordering imports \
883 does not bust the cache"
884 );
885 }
886
887 #[test]
888 fn import_hash_picks_up_nested_imports() {
889 let tmp = tempfile::tempdir().unwrap();
890 std::fs::write(
891 tmp.path().join("leaf.harn"),
892 "pub fn x() -> int { return 1 }\n",
893 )
894 .unwrap();
895 std::fs::write(
896 tmp.path().join("mid.harn"),
897 "import \"./leaf\"\npub fn y() -> int { return 2 }\n",
898 )
899 .unwrap();
900 let entry = tmp.path().join("entry.harn");
901 std::fs::write(&entry, "import \"./mid\"\n__io_println(\"hi\")\n").unwrap();
902
903 let before =
904 hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
905 std::fs::write(
906 tmp.path().join("leaf.harn"),
907 "pub fn x() -> int { return 999 }\n",
908 )
909 .unwrap();
910 let after = hash_transitive_user_imports(&entry, &std::fs::read_to_string(&entry).unwrap());
911 assert_ne!(
912 before, after,
913 "editing a transitively-imported file must change the import-graph hash"
914 );
915 }
916}