Skip to main content

fob_bundler/cache/
key.rs

1//! Cache key computation using BLAKE3 content-addressed hashing.
2//!
3//! The cache key is a deterministic hash of all build inputs, ensuring
4//! automatic invalidation when any input changes.
5
6use blake3::Hasher;
7use std::path::Path;
8
9use super::{CacheConfig, CacheResult};
10use crate::builders::common::BundlePlan;
11
12/// Current cache format version. Increment when cache format changes.
13const CACHE_FORMAT_VERSION: u32 = 1;
14
15/// Rolldown version for cache key.
16/// TODO: Get this from rolldown crate metadata.
17const ROLLDOWN_VERSION: &str = env!("CARGO_PKG_VERSION");
18
19/// Content-addressed cache key (BLAKE3 hash).
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct CacheKey(String);
22
23impl CacheKey {
24    /// Create a cache key from a hex string.
25    pub fn from_hex(hex: impl Into<String>) -> Self {
26        Self(hex.into())
27    }
28
29    /// Get the cache key as a hex string.
30    pub fn as_hex(&self) -> &str {
31        &self.0
32    }
33}
34
35impl std::fmt::Display for CacheKey {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "{}", self.0)
38    }
39}
40
41/// Compute the cache key for a build plan.
42///
43/// The key is a BLAKE3 hash of:
44/// 1. Cache format version
45/// 2. Rolldown version
46/// 3. Sorted entry paths + content hashes
47/// 4. Serialized BundlerOptions (deterministic subset)
48/// 5. Virtual files (sorted path + content hash)
49/// 6. Specified environment variables (sorted)
50pub fn compute_cache_key(plan: &BundlePlan, config: &CacheConfig) -> CacheResult<CacheKey> {
51    let mut hasher = Hasher::new();
52
53    // 1. Cache format version
54    hasher.update(&CACHE_FORMAT_VERSION.to_le_bytes());
55
56    // 2. Rolldown version
57    hasher.update(ROLLDOWN_VERSION.as_bytes());
58
59    // 3. Entry paths + content hashes (sorted for determinism)
60    hash_entries(&mut hasher, plan)?;
61
62    // 4. BundlerOptions (deterministic subset)
63    hash_bundler_options(&mut hasher, &plan.options);
64
65    // 5. Virtual files (sorted)
66    hash_virtual_files(&mut hasher, &plan.virtual_files);
67
68    // 6. Environment variables (sorted)
69    hash_env_vars(&mut hasher, &config.env_vars);
70
71    let hash = hasher.finalize();
72    Ok(CacheKey(hash.to_hex().to_string()))
73}
74
75/// Hash entry files (paths + content).
76fn hash_entries(hasher: &mut Hasher, plan: &BundlePlan) -> CacheResult<()> {
77    // Collect and sort entry imports for determinism
78    let mut entries: Vec<&str> = plan.entries.iter().map(|e| e.import.as_str()).collect();
79    entries.sort();
80
81    for entry in entries {
82        // Hash the normalized path
83        hasher.update(entry.as_bytes());
84        hasher.update(b"\0"); // separator
85
86        // Hash entry content if it's a file path (not virtual)
87        if !entry.starts_with("virtual:") {
88            if let Ok(content) = read_file_content(entry, plan) {
89                let content_hash = blake3::hash(&content);
90                hasher.update(content_hash.as_bytes());
91            }
92            // If file doesn't exist, just use path hash (will fail at build time anyway)
93        }
94    }
95
96    Ok(())
97}
98
99/// Read file content, checking virtual files first.
100fn read_file_content(path: &str, plan: &BundlePlan) -> std::io::Result<Vec<u8>> {
101    // Check virtual files first
102    if let Some(content) = plan.virtual_files.get(path) {
103        return Ok(content.as_bytes().to_vec());
104    }
105
106    // Resolve path relative to cwd if needed
107    let resolved_path = if Path::new(path).is_absolute() {
108        Path::new(path).to_path_buf()
109    } else if let Some(cwd) = &plan.cwd {
110        cwd.join(path)
111    } else {
112        Path::new(path).to_path_buf()
113    };
114
115    std::fs::read(&resolved_path)
116}
117
118/// Hash BundlerOptions (deterministic subset).
119///
120/// We exclude fields that don't affect bundle content:
121/// - `cwd` (already captured via entry paths)
122/// - `input` (captured separately via entries)
123fn hash_bundler_options(hasher: &mut Hasher, options: &rolldown::BundlerOptions) {
124    // Format
125    if let Some(format) = &options.format {
126        hasher.update(format!("{:?}", format).as_bytes());
127    }
128
129    // Platform
130    if let Some(platform) = &options.platform {
131        hasher.update(format!("{:?}", platform).as_bytes());
132    }
133
134    // Sourcemap
135    if let Some(sourcemap) = &options.sourcemap {
136        hasher.update(format!("{:?}", sourcemap).as_bytes());
137    }
138
139    // Minify
140    if let Some(minify) = &options.minify {
141        hasher.update(format!("{:?}", minify).as_bytes());
142    }
143
144    // External packages (sorted for determinism)
145    if let Some(external) = &options.external {
146        hasher.update(format!("{:?}", external).as_bytes());
147    }
148
149    // Globals (sorted)
150    if let Some(globals) = &options.globals {
151        hasher.update(format!("{:?}", globals).as_bytes());
152    }
153
154    // Resolve options
155    if let Some(resolve) = &options.resolve {
156        // Hash alias mappings
157        if let Some(alias) = &resolve.alias {
158            let mut aliases: Vec<_> = alias.iter().collect();
159            aliases.sort_by_key(|(k, _)| k.clone());
160            for (key, values) in aliases {
161                hasher.update(key.as_bytes());
162                hasher.update(format!("{:?}", values).as_bytes());
163            }
164        }
165
166        // Hash condition names
167        if let Some(conditions) = &resolve.condition_names {
168            let mut conds: Vec<_> = conditions.iter().collect();
169            conds.sort();
170            for cond in conds {
171                hasher.update(cond.as_bytes());
172            }
173        }
174    }
175
176    // Advanced chunks (code splitting config)
177    if let Some(advanced_chunks) = &options.advanced_chunks {
178        hasher.update(format!("{:?}", advanced_chunks).as_bytes());
179    }
180
181    // Transform options (decorators, etc.)
182    if let Some(transform) = &options.transform {
183        hasher.update(format!("{:?}", transform).as_bytes());
184    }
185}
186
187/// Hash virtual files (sorted for determinism).
188fn hash_virtual_files(hasher: &mut Hasher, virtual_files: &rustc_hash::FxHashMap<String, String>) {
189    // Sort paths for deterministic ordering
190    let mut paths: Vec<_> = virtual_files.keys().collect();
191    paths.sort();
192
193    for path in paths {
194        let content = &virtual_files[path];
195
196        // Hash path
197        hasher.update(path.as_bytes());
198        hasher.update(b"\0");
199
200        // Hash content
201        let content_hash = blake3::hash(content.as_bytes());
202        hasher.update(content_hash.as_bytes());
203    }
204}
205
206/// Hash environment variables (sorted for determinism).
207fn hash_env_vars(hasher: &mut Hasher, env_vars: &[String]) {
208    // Sort env var names
209    let mut vars: Vec<_> = env_vars.iter().collect();
210    vars.sort();
211
212    for var in vars {
213        hasher.update(var.as_bytes());
214        hasher.update(b"=");
215
216        // Include value if set, empty otherwise
217        if let Ok(value) = std::env::var(var) {
218            hasher.update(value.as_bytes());
219        }
220        hasher.update(b"\0");
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn make_test_plan(entries: Vec<&str>, vfiles: Vec<(&str, &str)>) -> BundlePlan {
229        BundlePlan {
230            entries: entries
231                .into_iter()
232                .map(|e| crate::builders::common::EntrySpec {
233                    name: None,
234                    import: e.to_string(),
235                })
236                .collect(),
237            options: rolldown::BundlerOptions::default(),
238            plugins: vec![],
239            cwd: None,
240            virtual_files: vfiles
241                .into_iter()
242                .map(|(k, v)| (k.to_string(), v.to_string()))
243                .collect(),
244            runtime: None,
245            cache: None,
246            incremental: None,
247        }
248    }
249
250    #[test]
251    fn test_cache_key_deterministic() {
252        let plan1 = make_test_plan(
253            vec!["virtual:a.js", "virtual:b.js"],
254            vec![
255                ("virtual:a.js", "export const a = 1;"),
256                ("virtual:b.js", "export const b = 2;"),
257            ],
258        );
259        let plan2 = make_test_plan(
260            vec!["virtual:b.js", "virtual:a.js"],
261            vec![
262                ("virtual:b.js", "export const b = 2;"),
263                ("virtual:a.js", "export const a = 1;"),
264            ],
265        );
266
267        let config = CacheConfig::default();
268
269        let key1 = compute_cache_key(&plan1, &config).unwrap();
270        let key2 = compute_cache_key(&plan2, &config).unwrap();
271
272        // Same inputs in different order should produce same key
273        assert_eq!(key1, key2);
274    }
275
276    #[test]
277    fn test_cache_key_changes_on_content_change() {
278        let plan1 = make_test_plan(
279            vec!["virtual:a.js"],
280            vec![("virtual:a.js", "export const a = 1;")],
281        );
282        let plan2 = make_test_plan(
283            vec!["virtual:a.js"],
284            vec![("virtual:a.js", "export const a = 2;")],
285        );
286
287        let config = CacheConfig::default();
288
289        let key1 = compute_cache_key(&plan1, &config).unwrap();
290        let key2 = compute_cache_key(&plan2, &config).unwrap();
291
292        // Different content should produce different key
293        assert_ne!(key1, key2);
294    }
295
296    #[test]
297    fn test_cache_key_changes_on_entry_change() {
298        let plan1 = make_test_plan(
299            vec!["virtual:a.js"],
300            vec![("virtual:a.js", "export const a = 1;")],
301        );
302        let plan2 = make_test_plan(
303            vec!["virtual:b.js"],
304            vec![("virtual:b.js", "export const a = 1;")],
305        );
306
307        let config = CacheConfig::default();
308
309        let key1 = compute_cache_key(&plan1, &config).unwrap();
310        let key2 = compute_cache_key(&plan2, &config).unwrap();
311
312        // Different entry should produce different key
313        assert_ne!(key1, key2);
314    }
315}