1const HASH_PREFIX: &str = "alef:hash:";
37
38const HEADER_BODY: &str = "\
41This file is auto-generated by alef — DO NOT EDIT.
42To regenerate: alef generate
43To verify freshness: alef verify --exit-code
44Issues & docs: https://github.com/kreuzberg-dev/alef";
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum CommentStyle {
49 DoubleSlash,
51 Hash,
53 Block,
55}
56
57pub fn header(style: CommentStyle) -> String {
66 match style {
67 CommentStyle::DoubleSlash => HEADER_BODY.lines().map(|l| format!("// {l}\n")).collect(),
68 CommentStyle::Hash => HEADER_BODY.lines().map(|l| format!("# {l}\n")).collect(),
69 CommentStyle::Block => {
70 let mut out = String::from("/*\n");
71 for line in HEADER_BODY.lines() {
72 out.push_str(&format!(" * {line}\n"));
73 }
74 out.push_str(" */\n");
75 out
76 }
77 }
78}
79
80const HEADER_MARKER: &str = "auto-generated by alef";
83
84pub fn hash_content(content: &str) -> String {
90 blake3::hash(content.as_bytes()).to_hex().to_string()
91}
92
93pub fn compute_sources_hash(sources: &[std::path::PathBuf]) -> std::io::Result<String> {
107 let mut hasher = blake3::Hasher::new();
108 let mut sorted: Vec<&std::path::PathBuf> = sources.iter().collect();
109 sorted.sort();
110 for source in sorted {
111 let content = std::fs::read(source)?;
112 hasher.update(b"src\0");
113 hasher.update(source.to_string_lossy().as_bytes());
114 hasher.update(b"\0");
115 hasher.update(&content);
116 }
117 Ok(hasher.finalize().to_hex().to_string())
118}
119
120pub fn compute_crate_sources_hash(crate_cfg: &crate::config::resolved::ResolvedCrateConfig) -> std::io::Result<String> {
146 let mut all_sources: Vec<&std::path::PathBuf> = Vec::new();
147
148 for src in &crate_cfg.sources {
149 all_sources.push(src);
150 }
151 for sc in &crate_cfg.source_crates {
152 for src in &sc.sources {
153 all_sources.push(src);
154 }
155 }
156
157 all_sources.sort();
159 all_sources.dedup();
160
161 let mut hasher = blake3::Hasher::new();
162 for source in all_sources {
163 let content = std::fs::read(source)?;
164 hasher.update(b"src\0");
165 hasher.update(source.to_string_lossy().as_bytes());
166 hasher.update(b"\0");
167 hasher.update(&content);
168 }
169 Ok(hasher.finalize().to_hex().to_string())
170}
171
172pub fn compute_file_hash(sources_hash: &str, content: &str) -> String {
185 let stripped = strip_hash_line(content);
186 let mut hasher = blake3::Hasher::new();
187 hasher.update(b"sources\0");
188 hasher.update(sources_hash.as_bytes());
189 hasher.update(b"\0content\0");
190 hasher.update(stripped.as_bytes());
191 hasher.finalize().to_hex().to_string()
192}
193
194pub fn inject_hash_line(content: &str, hash: &str) -> String {
200 let mut result = String::with_capacity(content.len() + 80);
201 let mut injected = false;
202
203 for (i, line) in content.lines().enumerate() {
204 result.push_str(line);
205 result.push('\n');
206
207 if !injected && i < 10 && line.contains(HEADER_MARKER) {
208 let trimmed = line.trim();
209 let hash_line = if trimmed.starts_with("<!--") {
210 format!("<!-- {HASH_PREFIX}{hash} -->")
212 } else if trimmed.starts_with("//") {
213 format!("// {HASH_PREFIX}{hash}")
214 } else if trimmed.starts_with('#') {
215 format!("# {HASH_PREFIX}{hash}")
216 } else if trimmed.starts_with("/*") || trimmed.starts_with(" *") || trimmed.ends_with("*/") {
217 format!(" * {HASH_PREFIX}{hash}")
218 } else {
219 format!("// {HASH_PREFIX}{hash}")
220 };
221 result.push_str(&hash_line);
222 result.push('\n');
223 injected = true;
224 }
225 }
226
227 if !content.ends_with('\n') && result.ends_with('\n') {
229 result.pop();
230 }
231
232 result
233}
234
235pub fn extract_hash(content: &str) -> Option<String> {
237 for (i, line) in content.lines().enumerate() {
238 if i >= 10 {
239 break;
240 }
241 if let Some(pos) = line.find(HASH_PREFIX) {
242 let rest = &line[pos + HASH_PREFIX.len()..];
243 let hex = rest.trim().trim_end_matches("*/").trim_end_matches("-->").trim();
245 if !hex.is_empty() {
246 return Some(hex.to_string());
247 }
248 }
249 }
250 None
251}
252
253pub fn strip_hash_line(content: &str) -> String {
255 let mut result = String::with_capacity(content.len());
256 for line in content.lines() {
257 if line.contains(HASH_PREFIX) {
258 continue;
259 }
260 result.push_str(line);
261 result.push('\n');
262 }
263 if !content.ends_with('\n') && result.ends_with('\n') {
265 result.pop();
266 }
267 result
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_header_double_slash() {
276 let h = header(CommentStyle::DoubleSlash);
277 assert!(h.contains("// This file is auto-generated by alef"));
278 assert!(h.contains("// Issues & docs: https://github.com/kreuzberg-dev/alef"));
279 }
280
281 #[test]
282 fn test_header_hash() {
283 let h = header(CommentStyle::Hash);
284 assert!(h.contains("# This file is auto-generated by alef"));
285 }
286
287 #[test]
288 fn test_header_block() {
289 let h = header(CommentStyle::Block);
290 assert!(h.starts_with("/*\n"));
291 assert!(h.contains(" * This file is auto-generated by alef"));
292 assert!(h.ends_with(" */\n"));
293 }
294
295 #[test]
296 fn test_inject_and_extract_rust() {
297 let h = header(CommentStyle::DoubleSlash);
298 let content = format!("{h}use foo;\n");
299 let hash = hash_content(&content);
300 let injected = inject_hash_line(&content, &hash);
301 assert!(injected.contains(HASH_PREFIX));
302 assert_eq!(extract_hash(&injected), Some(hash));
303 }
304
305 #[test]
306 fn test_inject_and_extract_python() {
307 let h = header(CommentStyle::Hash);
308 let content = format!("{h}import foo\n");
309 let hash = hash_content(&content);
310 let injected = inject_hash_line(&content, &hash);
311 assert!(injected.contains(&format!("# {HASH_PREFIX}")));
312 assert_eq!(extract_hash(&injected), Some(hash));
313 }
314
315 #[test]
316 fn test_inject_and_extract_c_block() {
317 let h = header(CommentStyle::Block);
318 let content = format!("{h}#include <stdio.h>\n");
319 let hash = hash_content(&content);
320 let injected = inject_hash_line(&content, &hash);
321 assert!(injected.contains(HASH_PREFIX));
322 assert_eq!(extract_hash(&injected), Some(hash));
323 }
324
325 #[test]
326 fn test_inject_php_line2() {
327 let h = header(CommentStyle::DoubleSlash);
328 let content = format!("<?php\n{h}namespace Foo;\n");
329 let hash = hash_content(&content);
330 let injected = inject_hash_line(&content, &hash);
331 let lines: Vec<&str> = injected.lines().collect();
332 assert_eq!(lines[0], "<?php");
333 assert!(lines[1].contains(HEADER_MARKER));
334 assert!(lines.iter().any(|l| l.contains(HASH_PREFIX)));
335 assert_eq!(extract_hash(&injected), Some(hash));
336 }
337
338 #[test]
339 fn test_no_header_returns_unchanged() {
340 let content = "fn main() {}\n";
341 let injected = inject_hash_line(content, "abc123");
342 assert_eq!(injected, content);
343 assert_eq!(extract_hash(&injected), None);
344 }
345
346 #[test]
347 fn test_strip_hash_line() {
348 let content = "// auto-generated by alef\n// alef:hash:abc123\nuse foo;\n";
349 let stripped = strip_hash_line(content);
350 assert_eq!(stripped, "// auto-generated by alef\nuse foo;\n");
351 }
352
353 #[test]
354 fn test_roundtrip() {
355 let h = header(CommentStyle::Hash);
356 let original = format!("{h}import sys\n");
357 let hash = hash_content(&original);
358 let injected = inject_hash_line(&original, &hash);
359 let stripped = strip_hash_line(&injected);
360 assert_eq!(stripped, original);
361 assert_eq!(hash_content(&stripped), hash);
362 }
363
364 use std::path::{Path, PathBuf};
367 use tempfile::tempdir;
368
369 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
370 let path = dir.join(name);
371 std::fs::write(&path, content).unwrap();
372 path
373 }
374
375 #[test]
376 fn sources_hash_changes_when_path_changes_even_if_content_same() {
377 let dir = tempdir().unwrap();
378 let s_a = write_file(dir.path(), "a.rs", "fn a() {}");
379 std::fs::create_dir_all(dir.path().join("moved")).unwrap();
380 let s_b = write_file(dir.path(), "moved/a.rs", "fn a() {}");
381 let h_a = compute_sources_hash(&[s_a]).unwrap();
382 let h_b = compute_sources_hash(&[s_b]).unwrap();
383 assert_ne!(
384 h_a, h_b,
385 "same content at a different path can produce different IR (rust_path differs)"
386 );
387 }
388
389 #[test]
390 fn sources_hash_errors_on_missing_source() {
391 let dir = tempdir().unwrap();
392 let bogus = dir.path().join("does-not-exist.rs");
393 assert!(compute_sources_hash(&[bogus]).is_err());
394 }
395
396 #[test]
397 fn sources_hash_stable_across_runs() {
398 let dir = tempdir().unwrap();
399 let s1 = write_file(dir.path(), "a.rs", "fn a() {}");
400 let s2 = write_file(dir.path(), "b.rs", "fn b() {}");
401 let sources = vec![s1, s2];
402 let h1 = compute_sources_hash(&sources).unwrap();
403 let h2 = compute_sources_hash(&sources).unwrap();
404 assert_eq!(h1, h2);
405 }
406
407 #[test]
408 fn sources_hash_path_order_independent() {
409 let dir = tempdir().unwrap();
410 let s1 = write_file(dir.path(), "a.rs", "fn a() {}");
411 let s2 = write_file(dir.path(), "b.rs", "fn b() {}");
412 let h_forward = compute_sources_hash(&[s1.clone(), s2.clone()]).unwrap();
413 let h_reverse = compute_sources_hash(&[s2, s1]).unwrap();
414 assert_eq!(h_forward, h_reverse);
415 }
416
417 #[test]
418 fn sources_hash_changes_with_content() {
419 let dir = tempdir().unwrap();
420 let s = write_file(dir.path(), "a.rs", "fn a() {}");
421 let h_before = compute_sources_hash(std::slice::from_ref(&s)).unwrap();
422 std::fs::write(&s, "fn a() { let _ = 1; }").unwrap();
423 let h_after = compute_sources_hash(&[s]).unwrap();
424 assert_ne!(h_before, h_after);
425 }
426
427 #[test]
428 fn file_hash_idempotent_under_strip_hash_line() {
429 let sources_hash = "abc123";
432 let bare = "// auto-generated by alef\nfn body() {}\n";
433 let with_line = "// auto-generated by alef\n// alef:hash:deadbeef\nfn body() {}\n";
434
435 let h1 = compute_file_hash(sources_hash, bare);
436 let h2 = compute_file_hash(sources_hash, with_line);
437 assert_eq!(h1, h2, "hash must ignore an existing alef:hash: line");
438 }
439
440 #[test]
441 fn file_hash_changes_when_sources_change() {
442 let content = "// auto-generated by alef\nfn body() {}\n";
443 let h_a = compute_file_hash("sources_a", content);
444 let h_b = compute_file_hash("sources_b", content);
445 assert_ne!(h_a, h_b);
446 }
447
448 #[test]
449 fn file_hash_changes_when_content_changes() {
450 let sources_hash = "abc123";
451 let h_a = compute_file_hash(sources_hash, "fn a() {}\n");
452 let h_b = compute_file_hash(sources_hash, "fn b() {}\n");
453 assert_ne!(h_a, h_b);
454 }
455
456 #[test]
457 fn file_hash_independent_of_alef_version() {
458 let h = compute_file_hash("sources_hash", "fn a() {}\n");
463 assert_eq!(h.len(), 64, "blake3 hex output is 64 chars");
464 }
465
466 #[test]
467 fn crate_sources_hash_differs_across_crates_with_disjoint_sources() {
468 use crate::config::resolved::ResolvedCrateConfig;
469
470 let dir = tempdir().unwrap();
471 let a = write_file(dir.path(), "a.rs", "fn a() {}");
472 let b = write_file(dir.path(), "b.rs", "fn b() {}");
473
474 let make_cfg = |name: &str, sources: Vec<std::path::PathBuf>| ResolvedCrateConfig {
479 name: name.to_string(),
480 sources,
481 source_crates: vec![],
482 version_from: "Cargo.toml".to_string(),
483 core_import: None,
484 workspace_root: None,
485 skip_core_import: false,
486 error_type: None,
487 error_constructor: None,
488 features: vec![],
489 path_mappings: Default::default(),
490 extra_dependencies: Default::default(),
491 auto_path_mappings: true,
492 languages: vec![],
493 python: None,
494 node: None,
495 ruby: None,
496 php: None,
497 elixir: None,
498 wasm: None,
499 ffi: None,
500 gleam: None,
501 go: None,
502 java: None,
503 dart: None,
504 kotlin: None,
505 swift: None,
506 csharp: None,
507 r: None,
508 zig: None,
509 exclude: Default::default(),
510 include: Default::default(),
511 output_paths: Default::default(),
512 explicit_output: Default::default(),
513 lint: Default::default(),
514 test: Default::default(),
515 setup: Default::default(),
516 update: Default::default(),
517 clean: Default::default(),
518 build_commands: Default::default(),
519 generate: Default::default(),
520 generate_overrides: Default::default(),
521 format: Default::default(),
522 format_overrides: Default::default(),
523 dto: Default::default(),
524 tools: Default::default(),
525 opaque_types: Default::default(),
526 sync: None,
527 publish: None,
528 e2e: None,
529 adapters: vec![],
530 trait_bridges: vec![],
531 scaffold: None,
532 readme: None,
533 custom_files: Default::default(),
534 custom_modules: Default::default(),
535 custom_registrations: Default::default(),
536 };
537
538 let cfg_a = make_cfg("alpha", vec![a]);
539 let cfg_b = make_cfg("beta", vec![b]);
540
541 let hash_a = compute_crate_sources_hash(&cfg_a).unwrap();
542 let hash_b = compute_crate_sources_hash(&cfg_b).unwrap();
543
544 assert_ne!(
545 hash_a, hash_b,
546 "crates with disjoint sources must produce different hashes"
547 );
548 }
549
550 #[test]
551 fn crate_sources_hash_includes_source_crates() {
552 use crate::config::{SourceCrate, resolved::ResolvedCrateConfig};
553
554 let dir = tempdir().unwrap();
555 let a = write_file(dir.path(), "a.rs", "fn a() {}");
556 let b = write_file(dir.path(), "b.rs", "fn b() {}");
557
558 let make_cfg =
559 |sources: Vec<std::path::PathBuf>, source_crate_sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
560 let source_crates = if source_crate_sources.is_empty() {
561 vec![]
562 } else {
563 vec![SourceCrate {
564 name: "extra-crate".to_string(),
565 sources: source_crate_sources,
566 }]
567 };
568 ResolvedCrateConfig {
569 name: "test".to_string(),
570 sources,
571 source_crates,
572 version_from: "Cargo.toml".to_string(),
573 core_import: None,
574 workspace_root: None,
575 skip_core_import: false,
576 error_type: None,
577 error_constructor: None,
578 features: vec![],
579 path_mappings: Default::default(),
580 extra_dependencies: Default::default(),
581 auto_path_mappings: true,
582 languages: vec![],
583 python: None,
584 node: None,
585 ruby: None,
586 php: None,
587 elixir: None,
588 wasm: None,
589 ffi: None,
590 gleam: None,
591 go: None,
592 java: None,
593 dart: None,
594 kotlin: None,
595 swift: None,
596 csharp: None,
597 r: None,
598 zig: None,
599 exclude: Default::default(),
600 include: Default::default(),
601 output_paths: Default::default(),
602 explicit_output: Default::default(),
603 lint: Default::default(),
604 test: Default::default(),
605 setup: Default::default(),
606 update: Default::default(),
607 clean: Default::default(),
608 build_commands: Default::default(),
609 generate: Default::default(),
610 generate_overrides: Default::default(),
611 format: Default::default(),
612 format_overrides: Default::default(),
613 dto: Default::default(),
614 tools: Default::default(),
615 opaque_types: Default::default(),
616 sync: None,
617 publish: None,
618 e2e: None,
619 adapters: vec![],
620 trait_bridges: vec![],
621 scaffold: None,
622 readme: None,
623 custom_files: Default::default(),
624 custom_modules: Default::default(),
625 custom_registrations: Default::default(),
626 }
627 };
628
629 let cfg_without_extra = make_cfg(vec![a.clone()], vec![]);
630 let cfg_with_extra = make_cfg(vec![a.clone()], vec![b.clone()]);
631
632 let hash_without = compute_crate_sources_hash(&cfg_without_extra).unwrap();
633 let hash_with = compute_crate_sources_hash(&cfg_with_extra).unwrap();
634
635 assert_ne!(
636 hash_without, hash_with,
637 "adding a source_crate source file must change the hash"
638 );
639 }
640
641 #[test]
642 fn compute_crate_sources_hash_dedupes_overlapping_paths() {
643 use crate::config::{SourceCrate, resolved::ResolvedCrateConfig};
644 let dir = tempdir().unwrap();
648 let a = write_file(dir.path(), "a.rs", "fn a() {}");
649 let b = write_file(dir.path(), "b.rs", "fn b() {}");
650
651 let make_cfg =
652 |sources: Vec<std::path::PathBuf>, source_crate_sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
653 let source_crates = if source_crate_sources.is_empty() {
654 vec![]
655 } else {
656 vec![SourceCrate {
657 name: "extra-crate".to_string(),
658 sources: source_crate_sources,
659 }]
660 };
661 ResolvedCrateConfig {
662 name: "test".to_string(),
663 sources,
664 source_crates,
665 version_from: "Cargo.toml".to_string(),
666 core_import: None,
667 workspace_root: None,
668 skip_core_import: false,
669 error_type: None,
670 error_constructor: None,
671 features: vec![],
672 path_mappings: Default::default(),
673 extra_dependencies: Default::default(),
674 auto_path_mappings: true,
675 languages: vec![],
676 python: None,
677 node: None,
678 ruby: None,
679 php: None,
680 elixir: None,
681 wasm: None,
682 ffi: None,
683 gleam: None,
684 go: None,
685 java: None,
686 dart: None,
687 kotlin: None,
688 swift: None,
689 csharp: None,
690 r: None,
691 zig: None,
692 exclude: Default::default(),
693 include: Default::default(),
694 output_paths: Default::default(),
695 explicit_output: Default::default(),
696 lint: Default::default(),
697 test: Default::default(),
698 setup: Default::default(),
699 update: Default::default(),
700 clean: Default::default(),
701 build_commands: Default::default(),
702 generate: Default::default(),
703 generate_overrides: Default::default(),
704 format: Default::default(),
705 format_overrides: Default::default(),
706 dto: Default::default(),
707 tools: Default::default(),
708 opaque_types: Default::default(),
709 sync: None,
710 publish: None,
711 e2e: None,
712 adapters: vec![],
713 trait_bridges: vec![],
714 scaffold: None,
715 readme: None,
716 custom_files: Default::default(),
717 custom_modules: Default::default(),
718 custom_registrations: Default::default(),
719 }
720 };
721
722 let cfg_with_dupes = make_cfg(vec![a.clone(), a.clone(), b.clone()], vec![a.clone()]);
724 let cfg_unique = make_cfg(vec![a.clone(), b.clone()], vec![]);
725
726 let hash_dup = compute_crate_sources_hash(&cfg_with_dupes).unwrap();
727 let hash_unique = compute_crate_sources_hash(&cfg_unique).unwrap();
728 assert_eq!(
729 hash_dup, hash_unique,
730 "duplicate source paths must not affect the per-crate sources hash"
731 );
732 }
733
734 #[test]
735 fn compute_crate_sources_hash_is_order_independent() {
736 use crate::config::resolved::ResolvedCrateConfig;
737 let dir = tempdir().unwrap();
740 let a = write_file(dir.path(), "a.rs", "fn a() {}");
741 let b = write_file(dir.path(), "b.rs", "fn b() {}");
742 let c = write_file(dir.path(), "c.rs", "fn c() {}");
743
744 let make_cfg = |sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
745 ResolvedCrateConfig {
746 name: "test".to_string(),
747 sources,
748 source_crates: vec![],
749 version_from: "Cargo.toml".to_string(),
750 core_import: None,
751 workspace_root: None,
752 skip_core_import: false,
753 error_type: None,
754 error_constructor: None,
755 features: vec![],
756 path_mappings: Default::default(),
757 extra_dependencies: Default::default(),
758 auto_path_mappings: true,
759 languages: vec![],
760 python: None,
761 node: None,
762 ruby: None,
763 php: None,
764 elixir: None,
765 wasm: None,
766 ffi: None,
767 gleam: None,
768 go: None,
769 java: None,
770 dart: None,
771 kotlin: None,
772 swift: None,
773 csharp: None,
774 r: None,
775 zig: None,
776 exclude: Default::default(),
777 include: Default::default(),
778 output_paths: Default::default(),
779 explicit_output: Default::default(),
780 lint: Default::default(),
781 test: Default::default(),
782 setup: Default::default(),
783 update: Default::default(),
784 clean: Default::default(),
785 build_commands: Default::default(),
786 generate: Default::default(),
787 generate_overrides: Default::default(),
788 format: Default::default(),
789 format_overrides: Default::default(),
790 dto: Default::default(),
791 tools: Default::default(),
792 opaque_types: Default::default(),
793 sync: None,
794 publish: None,
795 e2e: None,
796 adapters: vec![],
797 trait_bridges: vec![],
798 scaffold: None,
799 readme: None,
800 custom_files: Default::default(),
801 custom_modules: Default::default(),
802 custom_registrations: Default::default(),
803 }
804 };
805
806 let cfg1 = make_cfg(vec![a.clone(), b.clone(), c.clone()]);
807 let cfg2 = make_cfg(vec![c.clone(), a.clone(), b.clone()]);
808 let cfg3 = make_cfg(vec![b.clone(), c.clone(), a.clone()]);
809
810 let h1 = compute_crate_sources_hash(&cfg1).unwrap();
811 let h2 = compute_crate_sources_hash(&cfg2).unwrap();
812 let h3 = compute_crate_sources_hash(&cfg3).unwrap();
813 assert_eq!(h1, h2, "reordering sources must not change the hash");
814 assert_eq!(h2, h3, "reordering sources must not change the hash");
815 }
816
817 #[test]
818 fn file_hash_round_trip_via_inject_extract() {
819 let sources_hash = "abc123";
823 let raw = "// auto-generated by alef\nfn body() {}\n";
824 let file_hash = compute_file_hash(sources_hash, raw);
825 let on_disk = inject_hash_line(raw, &file_hash);
826
827 let extracted = extract_hash(&on_disk).expect("hash line should be present");
828 let recomputed = compute_file_hash(sources_hash, &on_disk);
829 assert_eq!(extracted, file_hash);
830 assert_eq!(recomputed, file_hash);
831 assert_eq!(extracted, recomputed, "verify must reproduce the embedded hash");
832 }
833}