Skip to main content

alef_core/
hash.rs

1//! Content hashing and generated-file headers.
2//!
3//! Every file produced by alef gets a standard header that identifies it as
4//! generated, tells agents/developers how to fix issues, and embeds a blake3
5//! hash so `alef verify` can detect staleness without external state.
6//!
7//! # Hash semantics
8//!
9//! As of alef v0.10.1, the embedded `alef:hash:<hex>` value is a **per-file
10//! source+output fingerprint** produced by [`compute_file_hash`]:
11//!
12//! ```text
13//! blake3(sources_hash || file_content_without_hash_line)
14//! ```
15//!
16//! Where `sources_hash` is [`compute_sources_hash`] over the sorted Rust source
17//! files alef parses to build the IR. The hash deliberately does **not**
18//! include the alef version or `alef.toml`: any input change that affects the
19//! generated bytes is already reflected by hashing the file content itself,
20//! and excluding the alef version makes `alef verify` idempotent across
21//! `alef` upgrades — a CI run on a tagged repo continues to pass after the
22//! alef CLI is bumped, as long as the rust sources and emitted file contents
23//! are unchanged.
24//!
25//! `alef generate` finalises the embedded hash *after* downstream formatters
26//! (rustfmt, rubocop, dotnet format, spotless, oxfmt, mix format, php-cs-fixer,
27//! mix format, …) have run, so the embedded hash describes the actual
28//! on-disk byte-content. `alef verify` reads the file, strips the
29//! `alef:hash:` line, recomputes the same hash, and compares — no
30//! regeneration, no writes.
31//!
32//! Pre-v0.10.1 alef used a single input-deterministic hash that incorporated
33//! the alef CLI version, which forced every consumer repo to re-run
34//! `alef generate` after every alef bump even when nothing else changed.
35
36const HASH_PREFIX: &str = "alef:hash:";
37const DEFAULT_REGENERATE_COMMAND: &str = "alef generate";
38const DEFAULT_VERIFY_COMMAND: &str = "alef verify --exit-code";
39const DEFAULT_ISSUES_URL: &str = "https://github.com/kreuzberg-dev/alef";
40
41/// Comment style for the generated header.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CommentStyle {
44    /// `// line comment`  (Rust, Go, Java, C#, TypeScript, C, PHP)
45    DoubleSlash,
46    /// `# line comment`   (Python, Ruby, Elixir, R, TOML, Shell, Makefile)
47    Hash,
48    /// `/* block comment */` (C headers)
49    Block,
50}
51
52/// Return the standard alef header as a comment block.
53///
54/// ```text
55/// // This file is auto-generated by alef — DO NOT EDIT.
56/// // To regenerate: alef generate
57/// // To verify freshness: alef verify --exit-code
58/// // Issues & docs: https://github.com/kreuzberg-dev/alef
59/// ```
60pub fn header(style: CommentStyle) -> String {
61    render_header(style, &default_header_body())
62}
63
64/// Return the standard alef header using metadata from a resolved crate config.
65pub fn header_for_config(style: CommentStyle, config: &crate::config::ResolvedCrateConfig) -> String {
66    let header_config = config.scaffold.as_ref().and_then(|s| s.generated_header.as_ref());
67    let body = match header_config {
68        Some(header) => {
69            let regenerate = header
70                .regenerate_command
71                .as_deref()
72                .unwrap_or(DEFAULT_REGENERATE_COMMAND);
73            let verify = header.verify_command.as_deref().unwrap_or(DEFAULT_VERIFY_COMMAND);
74            let issues_url = header.issues_url.as_deref().unwrap_or(DEFAULT_ISSUES_URL);
75            format!(
76                "This file is auto-generated by alef — DO NOT EDIT.\n\
77To regenerate: {regenerate}\n\
78To verify freshness: {verify}\n\
79Issues & docs: {issues_url}"
80            )
81        }
82        None => default_header_body(),
83    };
84    render_header(style, &body)
85}
86
87fn default_header_body() -> String {
88    format!(
89        "This file is auto-generated by alef — DO NOT EDIT.\n\
90To regenerate: {DEFAULT_REGENERATE_COMMAND}\n\
91To verify freshness: {DEFAULT_VERIFY_COMMAND}\n\
92Issues & docs: {DEFAULT_ISSUES_URL}"
93    )
94}
95
96fn render_header(style: CommentStyle, body: &str) -> String {
97    match style {
98        CommentStyle::DoubleSlash => body.lines().map(|l| format!("// {l}\n")).collect(),
99        CommentStyle::Hash => body.lines().map(|l| format!("# {l}\n")).collect(),
100        CommentStyle::Block => {
101            let mut out = String::from("/*\n");
102            for line in body.lines() {
103                out.push_str(&format!(" * {line}\n"));
104            }
105            out.push_str(" */\n");
106            out
107        }
108    }
109}
110
111/// The marker string that `inject_hash_line` and `extract_hash` look for.
112/// Every alef-generated header contains this on the first line.
113const HEADER_MARKER: &str = "auto-generated by alef";
114
115/// Blake3 hash of a content string, returned as hex.
116///
117/// Used by the IR / language caches and any caller that needs a hash of an
118/// in-memory string. **Not used for the embedded `alef:hash:` header** — that
119/// is computed by [`compute_file_hash`].
120pub fn hash_content(content: &str) -> String {
121    blake3::hash(content.as_bytes()).to_hex().to_string()
122}
123
124/// Compute a stable hash over the Rust source files that alef extracts.
125///
126/// This is the "source side" of the per-file verify hash. Sources are sorted
127/// by path so the hash is stable regardless of ordering in
128/// `alef.toml`'s `[crate].sources`. The path is mixed in alongside the
129/// content because the same byte-content at a different path produces
130/// different IR (the `rust_path` on extracted types differs).
131///
132/// Used by [`compute_file_hash`]; not by itself the value embedded in any
133/// file header.
134///
135/// # Errors
136/// Returns an error if any source file is missing or unreadable.
137pub fn compute_sources_hash(sources: &[std::path::PathBuf]) -> std::io::Result<String> {
138    let mut hasher = blake3::Hasher::new();
139    let mut sorted: Vec<&std::path::PathBuf> = sources.iter().collect();
140    sorted.sort();
141    for source in sorted {
142        let content = std::fs::read(source)?;
143        hasher.update(b"src\0");
144        hasher.update(source.to_string_lossy().as_bytes());
145        hasher.update(b"\0");
146        hasher.update(&content);
147    }
148    Ok(hasher.finalize().to_hex().to_string())
149}
150
151/// Compute a stable hex-encoded Blake3 hash over all Rust source files
152/// belonging to a [`crate::config::resolved::ResolvedCrateConfig`].
153///
154/// Returns a hex string so callers can feed the result directly to
155/// [`compute_file_hash`], matching [`compute_sources_hash`]'s return type.
156///
157/// The hash covers the union of:
158/// - `crate_cfg.sources` (direct sources on the crate)
159/// - every `source_crates[*].sources` entry
160///
161/// All paths are sorted before hashing so the result is independent of the
162/// order they appear in `alef.toml`.  The path string is mixed in alongside
163/// the file content because the same byte-content at a different path produces
164/// different IR (the `rust_path` on extracted types differs).
165///
166/// # Phase 3 migration note
167///
168/// Phase 3 callers should migrate from the per-file `compute_sources_hash` to
169/// this function when they have a `ResolvedCrateConfig` available, so that
170/// multi-source-crate workspaces produce a single stable hash across all
171/// contributing source files.
172///
173/// # Errors
174///
175/// Returns an error if any source file is missing or unreadable.
176pub fn compute_crate_sources_hash(crate_cfg: &crate::config::resolved::ResolvedCrateConfig) -> std::io::Result<String> {
177    let mut all_sources: Vec<&std::path::PathBuf> = Vec::new();
178
179    for src in &crate_cfg.sources {
180        all_sources.push(src);
181    }
182    for sc in &crate_cfg.source_crates {
183        for src in &sc.sources {
184            all_sources.push(src);
185        }
186    }
187
188    // Stable sort by path so the hash is order-independent.
189    all_sources.sort();
190    all_sources.dedup();
191
192    let mut hasher = blake3::Hasher::new();
193    for source in all_sources {
194        let content = std::fs::read(source)?;
195        hasher.update(b"src\0");
196        hasher.update(source.to_string_lossy().as_bytes());
197        hasher.update(b"\0");
198        hasher.update(&content);
199    }
200    Ok(hasher.finalize().to_hex().to_string())
201}
202
203/// Compute the per-file verify hash that alef embeds in each generated file.
204///
205/// `sources_hash` comes from [`compute_sources_hash`]. `content` is the file
206/// content; any pre-existing `alef:hash:` line is stripped before hashing so
207/// the function is idempotent — calling it on file content that already has a
208/// hash line returns the same value as calling it on the same content with no
209/// hash line. This makes the verify path symmetric with the generate path:
210///
211/// - **Generate**: write the file, run formatters, then call this with the
212///   on-disk content and inject the result.
213/// - **Verify**: read the file, extract the existing hash line, call this
214///   with the on-disk content, compare.
215pub fn compute_file_hash(sources_hash: &str, content: &str) -> String {
216    let stripped = strip_hash_line(content);
217    let mut hasher = blake3::Hasher::new();
218    hasher.update(b"sources\0");
219    hasher.update(sources_hash.as_bytes());
220    hasher.update(b"\0content\0");
221    hasher.update(stripped.as_bytes());
222    hasher.finalize().to_hex().to_string()
223}
224
225/// Inject an `alef:hash:<hex>` line immediately after the first header marker
226/// line found in the first 10 lines.  The comment syntax is inferred from the
227/// marker line itself.
228///
229/// If no marker line is found, the content is returned unchanged.
230pub fn inject_hash_line(content: &str, hash: &str) -> String {
231    let mut result = String::with_capacity(content.len() + 80);
232    let mut injected = false;
233
234    for (i, line) in content.lines().enumerate() {
235        result.push_str(line);
236        result.push('\n');
237
238        if !injected && i < 10 && line.contains(HEADER_MARKER) {
239            let trimmed = line.trim();
240            let hash_line = if trimmed.starts_with("<!--") {
241                // XML comment: inject hash line as XML comment
242                format!("<!-- {HASH_PREFIX}{hash} -->")
243            } else if trimmed.starts_with("//") {
244                format!("// {HASH_PREFIX}{hash}")
245            } else if trimmed.starts_with('#') {
246                format!("# {HASH_PREFIX}{hash}")
247            } else if trimmed.starts_with("/*") || trimmed.starts_with('*') || trimmed.ends_with("*/") {
248                format!(" * {HASH_PREFIX}{hash}")
249            } else {
250                format!("// {HASH_PREFIX}{hash}")
251            };
252            result.push_str(&hash_line);
253            result.push('\n');
254            injected = true;
255        }
256    }
257
258    // Preserve original trailing-newline behavior.
259    if !content.ends_with('\n') && result.ends_with('\n') {
260        result.pop();
261    }
262
263    result
264}
265
266/// Extract the hash from an `alef:hash:<hex>` token in the first 10 lines.
267pub fn extract_hash(content: &str) -> Option<String> {
268    for (i, line) in content.lines().enumerate() {
269        if i >= 10 {
270            break;
271        }
272        if let Some(pos) = line.find(HASH_PREFIX) {
273            let rest = &line[pos + HASH_PREFIX.len()..];
274            // Trim trailing comment closers and whitespace.
275            let hex = rest.trim().trim_end_matches("*/").trim_end_matches("-->").trim();
276            if !hex.is_empty() {
277                return Some(hex.to_string());
278            }
279        }
280    }
281    None
282}
283
284/// Strip the `alef:hash:` line from content (for fallback comparison).
285pub fn strip_hash_line(content: &str) -> String {
286    let mut result = String::with_capacity(content.len());
287    for line in content.lines() {
288        if line.contains(HASH_PREFIX) {
289            continue;
290        }
291        result.push_str(line);
292        result.push('\n');
293    }
294    // Preserve original trailing-newline behavior.
295    if !content.ends_with('\n') && result.ends_with('\n') {
296        result.pop();
297    }
298    result
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_header_double_slash() {
307        let h = header(CommentStyle::DoubleSlash);
308        assert!(h.contains("// This file is auto-generated by alef"));
309        assert!(h.contains("// Issues & docs: https://github.com/kreuzberg-dev/alef"));
310    }
311
312    #[test]
313    fn test_header_for_config_uses_configured_metadata() {
314        let cfg: crate::config::NewAlefConfig = toml::from_str(
315            r#"
316[workspace]
317languages = ["python"]
318
319[workspace.generated_header]
320issues_url = "https://docs.example.invalid/alef"
321regenerate_command = "task generate"
322verify_command = "task verify"
323
324[[crates]]
325name = "demo"
326sources = ["src/lib.rs"]
327"#,
328        )
329        .unwrap();
330        let resolved = cfg.resolve().unwrap().remove(0);
331
332        let h = header_for_config(CommentStyle::DoubleSlash, &resolved);
333
334        assert!(h.contains("// To regenerate: task generate"));
335        assert!(h.contains("// To verify freshness: task verify"));
336        assert!(h.contains("// Issues & docs: https://docs.example.invalid/alef"));
337    }
338
339    #[test]
340    fn test_header_hash() {
341        let h = header(CommentStyle::Hash);
342        assert!(h.contains("# This file is auto-generated by alef"));
343    }
344
345    #[test]
346    fn test_header_block() {
347        let h = header(CommentStyle::Block);
348        assert!(h.starts_with("/*\n"));
349        assert!(h.contains(" * This file is auto-generated by alef"));
350        assert!(h.ends_with(" */\n"));
351    }
352
353    #[test]
354    fn test_inject_and_extract_rust() {
355        let h = header(CommentStyle::DoubleSlash);
356        let content = format!("{h}use foo;\n");
357        let hash = hash_content(&content);
358        let injected = inject_hash_line(&content, &hash);
359        assert!(injected.contains(HASH_PREFIX));
360        assert_eq!(extract_hash(&injected), Some(hash));
361    }
362
363    #[test]
364    fn test_inject_and_extract_python() {
365        let h = header(CommentStyle::Hash);
366        let content = format!("{h}import foo\n");
367        let hash = hash_content(&content);
368        let injected = inject_hash_line(&content, &hash);
369        assert!(injected.contains(&format!("# {HASH_PREFIX}")));
370        assert_eq!(extract_hash(&injected), Some(hash));
371    }
372
373    #[test]
374    fn test_inject_and_extract_c_block() {
375        let h = header(CommentStyle::Block);
376        let content = format!("{h}#include <stdio.h>\n");
377        let hash = hash_content(&content);
378        let injected = inject_hash_line(&content, &hash);
379        assert!(injected.contains(HASH_PREFIX));
380        // Hash line must use C block-comment continuation (` * `), not `//`.
381        // Mixing `//` inside a `/* ... */` block produces a malformed header
382        // and would (depending on formatter) break verify.
383        assert!(
384            injected.contains(&format!(" * {HASH_PREFIX}")),
385            "expected ' * {HASH_PREFIX}' in block-comment header, got:\n{injected}"
386        );
387        assert!(
388            !injected.contains(&format!("// {HASH_PREFIX}")),
389            "block-comment header must not use '//' for the hash line, got:\n{injected}"
390        );
391        assert_eq!(extract_hash(&injected), Some(hash));
392    }
393
394    #[test]
395    fn test_inject_php_line2() {
396        let h = header(CommentStyle::DoubleSlash);
397        let content = format!("<?php\n{h}namespace Foo;\n");
398        let hash = hash_content(&content);
399        let injected = inject_hash_line(&content, &hash);
400        let lines: Vec<&str> = injected.lines().collect();
401        assert_eq!(lines[0], "<?php");
402        assert!(lines[1].contains(HEADER_MARKER));
403        assert!(lines.iter().any(|l| l.contains(HASH_PREFIX)));
404        assert_eq!(extract_hash(&injected), Some(hash));
405    }
406
407    #[test]
408    fn test_no_header_returns_unchanged() {
409        let content = "fn main() {}\n";
410        let injected = inject_hash_line(content, "abc123");
411        assert_eq!(injected, content);
412        assert_eq!(extract_hash(&injected), None);
413    }
414
415    #[test]
416    fn test_strip_hash_line() {
417        let content = "// auto-generated by alef\n// alef:hash:abc123\nuse foo;\n";
418        let stripped = strip_hash_line(content);
419        assert_eq!(stripped, "// auto-generated by alef\nuse foo;\n");
420    }
421
422    #[test]
423    fn test_roundtrip() {
424        let h = header(CommentStyle::Hash);
425        let original = format!("{h}import sys\n");
426        let hash = hash_content(&original);
427        let injected = inject_hash_line(&original, &hash);
428        let stripped = strip_hash_line(&injected);
429        assert_eq!(stripped, original);
430        assert_eq!(hash_content(&stripped), hash);
431    }
432
433    // ----- compute_sources_hash / compute_file_hash --------------------------
434
435    use std::path::{Path, PathBuf};
436    use tempfile::tempdir;
437
438    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
439        let path = dir.join(name);
440        std::fs::write(&path, content).unwrap();
441        path
442    }
443
444    #[test]
445    fn sources_hash_changes_when_path_changes_even_if_content_same() {
446        let dir = tempdir().unwrap();
447        let s_a = write_file(dir.path(), "a.rs", "fn a() {}");
448        std::fs::create_dir_all(dir.path().join("moved")).unwrap();
449        let s_b = write_file(dir.path(), "moved/a.rs", "fn a() {}");
450        let h_a = compute_sources_hash(&[s_a]).unwrap();
451        let h_b = compute_sources_hash(&[s_b]).unwrap();
452        assert_ne!(
453            h_a, h_b,
454            "same content at a different path can produce different IR (rust_path differs)"
455        );
456    }
457
458    #[test]
459    fn sources_hash_errors_on_missing_source() {
460        let dir = tempdir().unwrap();
461        let bogus = dir.path().join("does-not-exist.rs");
462        assert!(compute_sources_hash(&[bogus]).is_err());
463    }
464
465    #[test]
466    fn sources_hash_stable_across_runs() {
467        let dir = tempdir().unwrap();
468        let s1 = write_file(dir.path(), "a.rs", "fn a() {}");
469        let s2 = write_file(dir.path(), "b.rs", "fn b() {}");
470        let sources = vec![s1, s2];
471        let h1 = compute_sources_hash(&sources).unwrap();
472        let h2 = compute_sources_hash(&sources).unwrap();
473        assert_eq!(h1, h2);
474    }
475
476    #[test]
477    fn sources_hash_path_order_independent() {
478        let dir = tempdir().unwrap();
479        let s1 = write_file(dir.path(), "a.rs", "fn a() {}");
480        let s2 = write_file(dir.path(), "b.rs", "fn b() {}");
481        let h_forward = compute_sources_hash(&[s1.clone(), s2.clone()]).unwrap();
482        let h_reverse = compute_sources_hash(&[s2, s1]).unwrap();
483        assert_eq!(h_forward, h_reverse);
484    }
485
486    #[test]
487    fn sources_hash_changes_with_content() {
488        let dir = tempdir().unwrap();
489        let s = write_file(dir.path(), "a.rs", "fn a() {}");
490        let h_before = compute_sources_hash(std::slice::from_ref(&s)).unwrap();
491        std::fs::write(&s, "fn a() { let _ = 1; }").unwrap();
492        let h_after = compute_sources_hash(&[s]).unwrap();
493        assert_ne!(h_before, h_after);
494    }
495
496    #[test]
497    fn file_hash_idempotent_under_strip_hash_line() {
498        // The defining property: hash(content with hash line) == hash(content without hash line).
499        // This is what makes the verify path symmetric with the generate path.
500        let sources_hash = "abc123";
501        let bare = "// auto-generated by alef\nfn body() {}\n";
502        let with_line = "// auto-generated by alef\n// alef:hash:deadbeef\nfn body() {}\n";
503
504        let h1 = compute_file_hash(sources_hash, bare);
505        let h2 = compute_file_hash(sources_hash, with_line);
506        assert_eq!(h1, h2, "hash must ignore an existing alef:hash: line");
507    }
508
509    #[test]
510    fn file_hash_changes_when_sources_change() {
511        let content = "// auto-generated by alef\nfn body() {}\n";
512        let h_a = compute_file_hash("sources_a", content);
513        let h_b = compute_file_hash("sources_b", content);
514        assert_ne!(h_a, h_b);
515    }
516
517    #[test]
518    fn file_hash_changes_when_content_changes() {
519        let sources_hash = "abc123";
520        let h_a = compute_file_hash(sources_hash, "fn a() {}\n");
521        let h_b = compute_file_hash(sources_hash, "fn b() {}\n");
522        assert_ne!(h_a, h_b);
523    }
524
525    #[test]
526    fn file_hash_independent_of_alef_version() {
527        // Idempotency property: the hash is purely a function of (sources, content).
528        // Bumping the alef CLI version must not change it. Encoded by the type
529        // signature — there is no version parameter — but make it explicit so
530        // a future regression that re-introduces a version dimension is caught.
531        let h = compute_file_hash("sources_hash", "fn a() {}\n");
532        assert_eq!(h.len(), 64, "blake3 hex output is 64 chars");
533    }
534
535    #[test]
536    fn crate_sources_hash_differs_across_crates_with_disjoint_sources() {
537        use crate::config::resolved::ResolvedCrateConfig;
538
539        let dir = tempdir().unwrap();
540        let a = write_file(dir.path(), "a.rs", "fn a() {}");
541        let b = write_file(dir.path(), "b.rs", "fn b() {}");
542
543        // Build two minimal ResolvedCrateConfig values using the builder pattern
544        // isn't available, so we construct via serde round-trip from JSON to avoid
545        // requiring Default on the struct.  Instead, use helper that constructs the
546        // minimal required fields directly.
547        let make_cfg = |name: &str, sources: Vec<std::path::PathBuf>| ResolvedCrateConfig {
548            name: name.to_string(),
549            sources,
550            source_crates: vec![],
551            version_from: "Cargo.toml".to_string(),
552            core_import: None,
553            workspace_root: None,
554            skip_core_import: false,
555            error_type: None,
556            error_constructor: None,
557            features: vec![],
558            path_mappings: Default::default(),
559            extra_dependencies: Default::default(),
560            auto_path_mappings: true,
561            languages: vec![],
562            python: None,
563            node: None,
564            ruby: None,
565            php: None,
566            elixir: None,
567            wasm: None,
568            ffi: None,
569            go: None,
570            java: None,
571            dart: None,
572            kotlin: None,
573            kotlin_android: None,
574            jni: None,
575            swift: None,
576            gleam: None,
577            csharp: None,
578            r: None,
579            zig: None,
580            exclude: Default::default(),
581            include: Default::default(),
582            output_paths: Default::default(),
583            explicit_output: Default::default(),
584            lint: Default::default(),
585            test: Default::default(),
586            setup: Default::default(),
587            update: Default::default(),
588            clean: Default::default(),
589            build_commands: Default::default(),
590            generate: Default::default(),
591            generate_overrides: Default::default(),
592            format: Default::default(),
593            format_overrides: Default::default(),
594            dto: Default::default(),
595            tools: Default::default(),
596            opaque_types: Default::default(),
597            client_constructors: Default::default(),
598            sync: None,
599            citation: None,
600            publish: None,
601            e2e: None,
602            adapters: vec![],
603            trait_bridges: vec![],
604            scaffold: None,
605            readme: None,
606            custom_files: Default::default(),
607            custom_modules: Default::default(),
608            custom_registrations: Default::default(),
609        };
610
611        let cfg_a = make_cfg("alpha", vec![a]);
612        let cfg_b = make_cfg("beta", vec![b]);
613
614        let hash_a = compute_crate_sources_hash(&cfg_a).unwrap();
615        let hash_b = compute_crate_sources_hash(&cfg_b).unwrap();
616
617        assert_ne!(
618            hash_a, hash_b,
619            "crates with disjoint sources must produce different hashes"
620        );
621    }
622
623    #[test]
624    fn crate_sources_hash_includes_source_crates() {
625        use crate::config::{SourceCrate, resolved::ResolvedCrateConfig};
626
627        let dir = tempdir().unwrap();
628        let a = write_file(dir.path(), "a.rs", "fn a() {}");
629        let b = write_file(dir.path(), "b.rs", "fn b() {}");
630
631        let make_cfg =
632            |sources: Vec<std::path::PathBuf>, source_crate_sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
633                let source_crates = if source_crate_sources.is_empty() {
634                    vec![]
635                } else {
636                    vec![SourceCrate {
637                        name: "extra-crate".to_string(),
638                        sources: source_crate_sources,
639                    }]
640                };
641                ResolvedCrateConfig {
642                    name: "test".to_string(),
643                    sources,
644                    source_crates,
645                    version_from: "Cargo.toml".to_string(),
646                    core_import: None,
647                    workspace_root: None,
648                    skip_core_import: false,
649                    error_type: None,
650                    error_constructor: None,
651                    features: vec![],
652                    path_mappings: Default::default(),
653                    extra_dependencies: Default::default(),
654                    auto_path_mappings: true,
655                    languages: vec![],
656                    python: None,
657                    node: None,
658                    ruby: None,
659                    php: None,
660                    elixir: None,
661                    wasm: None,
662                    ffi: None,
663                    go: None,
664                    java: None,
665                    dart: None,
666                    kotlin: None,
667                    kotlin_android: None,
668                    jni: None,
669                    swift: None,
670                    gleam: None,
671                    csharp: None,
672                    r: None,
673                    zig: None,
674                    exclude: Default::default(),
675                    include: Default::default(),
676                    output_paths: Default::default(),
677                    explicit_output: Default::default(),
678                    lint: Default::default(),
679                    test: Default::default(),
680                    setup: Default::default(),
681                    update: Default::default(),
682                    clean: Default::default(),
683                    build_commands: Default::default(),
684                    generate: Default::default(),
685                    generate_overrides: Default::default(),
686                    format: Default::default(),
687                    format_overrides: Default::default(),
688                    dto: Default::default(),
689                    tools: Default::default(),
690                    opaque_types: Default::default(),
691                    client_constructors: Default::default(),
692                    sync: None,
693                    citation: None,
694                    publish: None,
695                    e2e: None,
696                    adapters: vec![],
697                    trait_bridges: vec![],
698                    scaffold: None,
699                    readme: None,
700                    custom_files: Default::default(),
701                    custom_modules: Default::default(),
702                    custom_registrations: Default::default(),
703                }
704            };
705
706        let cfg_without_extra = make_cfg(vec![a.clone()], vec![]);
707        let cfg_with_extra = make_cfg(vec![a.clone()], vec![b.clone()]);
708
709        let hash_without = compute_crate_sources_hash(&cfg_without_extra).unwrap();
710        let hash_with = compute_crate_sources_hash(&cfg_with_extra).unwrap();
711
712        assert_ne!(
713            hash_without, hash_with,
714            "adding a source_crate source file must change the hash"
715        );
716    }
717
718    #[test]
719    fn compute_crate_sources_hash_dedupes_overlapping_paths() {
720        use crate::config::{SourceCrate, resolved::ResolvedCrateConfig};
721        // A source path appearing in both `sources` and a `source_crates` entry
722        // (or repeated within `sources`) is hashed once: the hash equals the
723        // hash of the same crate config with the duplicates removed.
724        let dir = tempdir().unwrap();
725        let a = write_file(dir.path(), "a.rs", "fn a() {}");
726        let b = write_file(dir.path(), "b.rs", "fn b() {}");
727
728        let make_cfg =
729            |sources: Vec<std::path::PathBuf>, source_crate_sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
730                let source_crates = if source_crate_sources.is_empty() {
731                    vec![]
732                } else {
733                    vec![SourceCrate {
734                        name: "extra-crate".to_string(),
735                        sources: source_crate_sources,
736                    }]
737                };
738                ResolvedCrateConfig {
739                    name: "test".to_string(),
740                    sources,
741                    source_crates,
742                    version_from: "Cargo.toml".to_string(),
743                    core_import: None,
744                    workspace_root: None,
745                    skip_core_import: false,
746                    error_type: None,
747                    error_constructor: None,
748                    features: vec![],
749                    path_mappings: Default::default(),
750                    extra_dependencies: Default::default(),
751                    auto_path_mappings: true,
752                    languages: vec![],
753                    python: None,
754                    node: None,
755                    ruby: None,
756                    php: None,
757                    elixir: None,
758                    wasm: None,
759                    ffi: None,
760                    go: None,
761                    java: None,
762                    dart: None,
763                    kotlin: None,
764                    kotlin_android: None,
765                    jni: None,
766                    swift: None,
767                    gleam: None,
768                    csharp: None,
769                    r: None,
770                    zig: None,
771                    exclude: Default::default(),
772                    include: Default::default(),
773                    output_paths: Default::default(),
774                    explicit_output: Default::default(),
775                    lint: Default::default(),
776                    test: Default::default(),
777                    setup: Default::default(),
778                    update: Default::default(),
779                    clean: Default::default(),
780                    build_commands: Default::default(),
781                    generate: Default::default(),
782                    generate_overrides: Default::default(),
783                    format: Default::default(),
784                    format_overrides: Default::default(),
785                    dto: Default::default(),
786                    tools: Default::default(),
787                    opaque_types: Default::default(),
788                    client_constructors: Default::default(),
789                    sync: None,
790                    citation: None,
791                    publish: None,
792                    e2e: None,
793                    adapters: vec![],
794                    trait_bridges: vec![],
795                    scaffold: None,
796                    readme: None,
797                    custom_files: Default::default(),
798                    custom_modules: Default::default(),
799                    custom_registrations: Default::default(),
800                }
801            };
802
803        // `sources` lists `a` twice and `source_crates` also references `a`.
804        let cfg_with_dupes = make_cfg(vec![a.clone(), a.clone(), b.clone()], vec![a.clone()]);
805        let cfg_unique = make_cfg(vec![a.clone(), b.clone()], vec![]);
806
807        let hash_dup = compute_crate_sources_hash(&cfg_with_dupes).unwrap();
808        let hash_unique = compute_crate_sources_hash(&cfg_unique).unwrap();
809        assert_eq!(
810            hash_dup, hash_unique,
811            "duplicate source paths must not affect the per-crate sources hash"
812        );
813    }
814
815    #[test]
816    fn compute_crate_sources_hash_is_order_independent() {
817        use crate::config::resolved::ResolvedCrateConfig;
818        // Reordering `sources` (or the entries inside a `source_crates` entry)
819        // does not change the per-crate sources hash.
820        let dir = tempdir().unwrap();
821        let a = write_file(dir.path(), "a.rs", "fn a() {}");
822        let b = write_file(dir.path(), "b.rs", "fn b() {}");
823        let c = write_file(dir.path(), "c.rs", "fn c() {}");
824
825        let make_cfg = |sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
826            ResolvedCrateConfig {
827                name: "test".to_string(),
828                sources,
829                source_crates: vec![],
830                version_from: "Cargo.toml".to_string(),
831                core_import: None,
832                workspace_root: None,
833                skip_core_import: false,
834                error_type: None,
835                error_constructor: None,
836                features: vec![],
837                path_mappings: Default::default(),
838                extra_dependencies: Default::default(),
839                auto_path_mappings: true,
840                languages: vec![],
841                python: None,
842                node: None,
843                ruby: None,
844                php: None,
845                elixir: None,
846                wasm: None,
847                ffi: None,
848                go: None,
849                java: None,
850                dart: None,
851                kotlin: None,
852                kotlin_android: None,
853                jni: None,
854                swift: None,
855                gleam: None,
856                csharp: None,
857                r: None,
858                zig: None,
859                exclude: Default::default(),
860                include: Default::default(),
861                output_paths: Default::default(),
862                explicit_output: Default::default(),
863                lint: Default::default(),
864                test: Default::default(),
865                setup: Default::default(),
866                update: Default::default(),
867                clean: Default::default(),
868                build_commands: Default::default(),
869                generate: Default::default(),
870                generate_overrides: Default::default(),
871                format: Default::default(),
872                format_overrides: Default::default(),
873                dto: Default::default(),
874                tools: Default::default(),
875                opaque_types: Default::default(),
876                client_constructors: Default::default(),
877                sync: None,
878                citation: None,
879                publish: None,
880                e2e: None,
881                adapters: vec![],
882                trait_bridges: vec![],
883                scaffold: None,
884                readme: None,
885                custom_files: Default::default(),
886                custom_modules: Default::default(),
887                custom_registrations: Default::default(),
888            }
889        };
890
891        let cfg1 = make_cfg(vec![a.clone(), b.clone(), c.clone()]);
892        let cfg2 = make_cfg(vec![c.clone(), a.clone(), b.clone()]);
893        let cfg3 = make_cfg(vec![b.clone(), c.clone(), a.clone()]);
894
895        let h1 = compute_crate_sources_hash(&cfg1).unwrap();
896        let h2 = compute_crate_sources_hash(&cfg2).unwrap();
897        let h3 = compute_crate_sources_hash(&cfg3).unwrap();
898        assert_eq!(h1, h2, "reordering sources must not change the hash");
899        assert_eq!(h2, h3, "reordering sources must not change the hash");
900    }
901
902    #[test]
903    fn file_hash_round_trip_via_inject_extract() {
904        // Simulate the full generate/verify cycle:
905        // 1. generate: compute hash from stripped content, inject into header
906        // 2. verify: read back, extract hash, recompute from content, compare
907        let sources_hash = "abc123";
908        let raw = "// auto-generated by alef\nfn body() {}\n";
909        let file_hash = compute_file_hash(sources_hash, raw);
910        let on_disk = inject_hash_line(raw, &file_hash);
911
912        let extracted = extract_hash(&on_disk).expect("hash line should be present");
913        let recomputed = compute_file_hash(sources_hash, &on_disk);
914        assert_eq!(extracted, file_hash);
915        assert_eq!(recomputed, file_hash);
916        assert_eq!(extracted, recomputed, "verify must reproduce the embedded hash");
917    }
918}