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//! content hash so `alef verify` can detect staleness without external state.
6
7const HASH_PREFIX: &str = "alef:hash:";
8
9/// The standard header text (without comment delimiters).
10/// Used by [`header`] to produce language-specific comment blocks.
11const HEADER_BODY: &str = "\
12This file is auto-generated by alef — DO NOT EDIT.
13To regenerate: alef generate
14To verify freshness: alef verify --exit-code
15Issues & docs: https://github.com/kreuzberg-dev/alef";
16
17/// Comment style for the generated header.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CommentStyle {
20    /// `// line comment`  (Rust, Go, Java, C#, TypeScript, C, PHP)
21    DoubleSlash,
22    /// `# line comment`   (Python, Ruby, Elixir, R, TOML, Shell, Makefile)
23    Hash,
24    /// `/* block comment */` (C headers)
25    Block,
26}
27
28/// Return the standard alef header as a comment block.
29///
30/// ```text
31/// // This file is auto-generated by alef — DO NOT EDIT.
32/// // To regenerate: alef generate
33/// // To verify freshness: alef verify --exit-code
34/// // Issues & docs: https://github.com/kreuzberg-dev/alef
35/// ```
36pub fn header(style: CommentStyle) -> String {
37    match style {
38        CommentStyle::DoubleSlash => HEADER_BODY.lines().map(|l| format!("// {l}\n")).collect(),
39        CommentStyle::Hash => HEADER_BODY.lines().map(|l| format!("# {l}\n")).collect(),
40        CommentStyle::Block => {
41            let mut out = String::from("/*\n");
42            for line in HEADER_BODY.lines() {
43                out.push_str(&format!(" * {line}\n"));
44            }
45            out.push_str(" */\n");
46            out
47        }
48    }
49}
50
51/// The marker string that `inject_hash_line` and `extract_hash` look for.
52/// Every alef-generated header contains this on the first line.
53const HEADER_MARKER: &str = "auto-generated by alef";
54
55/// Blake3 hash of a content string, returned as hex.
56pub fn hash_content(content: &str) -> String {
57    blake3::hash(content.as_bytes()).to_hex().to_string()
58}
59
60/// Inject an `alef:hash:<hex>` line immediately after the first header marker
61/// line found in the first 10 lines.  The comment syntax is inferred from the
62/// marker line itself.
63///
64/// If no marker line is found, the content is returned unchanged.
65pub fn inject_hash_line(content: &str, hash: &str) -> String {
66    let mut result = String::with_capacity(content.len() + 80);
67    let mut injected = false;
68
69    for (i, line) in content.lines().enumerate() {
70        result.push_str(line);
71        result.push('\n');
72
73        if !injected && i < 10 && line.contains(HEADER_MARKER) {
74            let trimmed = line.trim();
75            let hash_line = if trimmed.starts_with("//") {
76                format!("// {HASH_PREFIX}{hash}")
77            } else if trimmed.starts_with('#') {
78                format!("# {HASH_PREFIX}{hash}")
79            } else if trimmed.starts_with("/*") || trimmed.starts_with(" *") || trimmed.ends_with("*/") {
80                format!(" * {HASH_PREFIX}{hash}")
81            } else {
82                format!("// {HASH_PREFIX}{hash}")
83            };
84            result.push_str(&hash_line);
85            result.push('\n');
86            injected = true;
87        }
88    }
89
90    // Preserve original trailing-newline behavior.
91    if !content.ends_with('\n') && result.ends_with('\n') {
92        result.pop();
93    }
94
95    result
96}
97
98/// Extract the hash from an `alef:hash:<hex>` token in the first 10 lines.
99pub fn extract_hash(content: &str) -> Option<String> {
100    for (i, line) in content.lines().enumerate() {
101        if i >= 10 {
102            break;
103        }
104        if let Some(pos) = line.find(HASH_PREFIX) {
105            let rest = &line[pos + HASH_PREFIX.len()..];
106            // Trim trailing comment closers and whitespace.
107            let hex = rest.trim().trim_end_matches("*/").trim();
108            if !hex.is_empty() {
109                return Some(hex.to_string());
110            }
111        }
112    }
113    None
114}
115
116/// Strip the `alef:hash:` line from content (for fallback comparison).
117pub fn strip_hash_line(content: &str) -> String {
118    let mut result = String::with_capacity(content.len());
119    for line in content.lines() {
120        if line.contains(HASH_PREFIX) {
121            continue;
122        }
123        result.push_str(line);
124        result.push('\n');
125    }
126    // Preserve original trailing-newline behavior.
127    if !content.ends_with('\n') && result.ends_with('\n') {
128        result.pop();
129    }
130    result
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_header_double_slash() {
139        let h = header(CommentStyle::DoubleSlash);
140        assert!(h.contains("// This file is auto-generated by alef"));
141        assert!(h.contains("// Issues & docs: https://github.com/kreuzberg-dev/alef"));
142    }
143
144    #[test]
145    fn test_header_hash() {
146        let h = header(CommentStyle::Hash);
147        assert!(h.contains("# This file is auto-generated by alef"));
148    }
149
150    #[test]
151    fn test_header_block() {
152        let h = header(CommentStyle::Block);
153        assert!(h.starts_with("/*\n"));
154        assert!(h.contains(" * This file is auto-generated by alef"));
155        assert!(h.ends_with(" */\n"));
156    }
157
158    #[test]
159    fn test_inject_and_extract_rust() {
160        let h = header(CommentStyle::DoubleSlash);
161        let content = format!("{h}use foo;\n");
162        let hash = hash_content(&content);
163        let injected = inject_hash_line(&content, &hash);
164        assert!(injected.contains(HASH_PREFIX));
165        assert_eq!(extract_hash(&injected), Some(hash));
166    }
167
168    #[test]
169    fn test_inject_and_extract_python() {
170        let h = header(CommentStyle::Hash);
171        let content = format!("{h}import foo\n");
172        let hash = hash_content(&content);
173        let injected = inject_hash_line(&content, &hash);
174        assert!(injected.contains(&format!("# {HASH_PREFIX}")));
175        assert_eq!(extract_hash(&injected), Some(hash));
176    }
177
178    #[test]
179    fn test_inject_and_extract_c_block() {
180        let h = header(CommentStyle::Block);
181        let content = format!("{h}#include <stdio.h>\n");
182        let hash = hash_content(&content);
183        let injected = inject_hash_line(&content, &hash);
184        assert!(injected.contains(HASH_PREFIX));
185        assert_eq!(extract_hash(&injected), Some(hash));
186    }
187
188    #[test]
189    fn test_inject_php_line2() {
190        let h = header(CommentStyle::DoubleSlash);
191        let content = format!("<?php\n{h}namespace Foo;\n");
192        let hash = hash_content(&content);
193        let injected = inject_hash_line(&content, &hash);
194        let lines: Vec<&str> = injected.lines().collect();
195        assert_eq!(lines[0], "<?php");
196        assert!(lines[1].contains(HEADER_MARKER));
197        assert!(lines.iter().any(|l| l.contains(HASH_PREFIX)));
198        assert_eq!(extract_hash(&injected), Some(hash));
199    }
200
201    #[test]
202    fn test_no_header_returns_unchanged() {
203        let content = "fn main() {}\n";
204        let injected = inject_hash_line(content, "abc123");
205        assert_eq!(injected, content);
206        assert_eq!(extract_hash(&injected), None);
207    }
208
209    #[test]
210    fn test_strip_hash_line() {
211        let content = "// auto-generated by alef\n// alef:hash:abc123\nuse foo;\n";
212        let stripped = strip_hash_line(content);
213        assert_eq!(stripped, "// auto-generated by alef\nuse foo;\n");
214    }
215
216    #[test]
217    fn test_roundtrip() {
218        let h = header(CommentStyle::Hash);
219        let original = format!("{h}import sys\n");
220        let hash = hash_content(&original);
221        let injected = inject_hash_line(&original, &hash);
222        let stripped = strip_hash_line(&injected);
223        assert_eq!(stripped, original);
224        assert_eq!(hash_content(&stripped), hash);
225    }
226}