//! Incremental build cache. //! //! Stores BLAKE3 hashes of source files in `.el/build-cache.json` so the //! build system can skip recompiling files that haven't changed. use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; /// The on-disk structure of the build cache. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct BuildCache { /// Map of file path (relative to workspace root) → BLAKE3 hex hash. pub file_hashes: HashMap, } impl BuildCache { /// Load the build cache from `.el/build-cache.json`. /// /// Returns an empty cache if the file doesn't exist yet. pub fn load(workspace_root: &Path) -> Self { let path = cache_path(workspace_root); if !path.exists() { return Self::default(); } let text = std::fs::read_to_string(&path).unwrap_or_default(); serde_json::from_str(&text).unwrap_or_default() } /// Persist the cache back to disk. pub fn save(&self, workspace_root: &Path) -> Result<(), std::io::Error> { let path = cache_path(workspace_root); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(self)?; std::fs::write(&path, json) } /// Hash a single file and return the BLAKE3 hex string. pub fn hash_file(path: &Path) -> Result { let bytes = std::fs::read(path)?; Ok(hex_encode(blake3::hash(&bytes).as_bytes())) } /// Returns `true` if the file's current hash matches the cached hash. pub fn is_up_to_date(&self, rel_path: &str, current_hash: &str) -> bool { self.file_hashes .get(rel_path) .map(|cached| cached == current_hash) .unwrap_or(false) } /// Update the cached hash for a file. pub fn record(&mut self, rel_path: impl Into, hash: impl Into) { self.file_hashes.insert(rel_path.into(), hash.into()); } /// Collect all `.el` source files under a directory recursively. pub fn collect_sources(root: &Path) -> Vec { let mut sources = Vec::new(); collect_el_files(root, &mut sources); sources.sort(); sources } } fn cache_path(workspace_root: &Path) -> PathBuf { workspace_root.join(".el").join("build-cache.json") } fn collect_el_files(dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { // Skip hidden dirs and build outputs let name = path.file_name().unwrap_or_default().to_string_lossy(); if name.starts_with('.') || name == "dist" || name == "target" { continue; } collect_el_files(&path, out); } else if path.extension().map(|e| e == "el").unwrap_or(false) { out.push(path); } } } fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::TempDir; fn temp_dir() -> TempDir { tempfile::TempDir::new().unwrap() } #[test] fn test_cache_empty_by_default() { let dir = temp_dir(); let cache = BuildCache::load(dir.path()); assert!(cache.file_hashes.is_empty()); } #[test] fn test_cache_save_and_load() { let dir = temp_dir(); let mut cache = BuildCache::default(); cache.record("src/main.el", "abc123"); cache.save(dir.path()).unwrap(); let loaded = BuildCache::load(dir.path()); assert_eq!(loaded.file_hashes.get("src/main.el").map(|s| s.as_str()), Some("abc123")); } #[test] fn test_is_up_to_date() { let mut cache = BuildCache::default(); cache.record("src/main.el", "deadbeef"); assert!(cache.is_up_to_date("src/main.el", "deadbeef")); assert!(!cache.is_up_to_date("src/main.el", "different")); assert!(!cache.is_up_to_date("src/other.el", "deadbeef")); } #[test] fn test_hash_file() { let dir = temp_dir(); let path = dir.path().join("test.el"); std::fs::write(&path, b"let x = 1").unwrap(); let hash1 = BuildCache::hash_file(&path).unwrap(); let hash2 = BuildCache::hash_file(&path).unwrap(); assert_eq!(hash1, hash2); // deterministic std::fs::write(&path, b"let x = 2").unwrap(); let hash3 = BuildCache::hash_file(&path).unwrap(); assert_ne!(hash1, hash3); // different content → different hash } #[test] fn test_collect_sources() { let dir = temp_dir(); let src = dir.path().join("src"); std::fs::create_dir(&src).unwrap(); std::fs::write(src.join("main.el"), b"fn main() {}").unwrap(); std::fs::write(src.join("lib.el"), b"fn helper() {}").unwrap(); std::fs::write(src.join("README.md"), b"# README").unwrap(); let sources = BuildCache::collect_sources(dir.path()); assert_eq!(sources.len(), 2); assert!(sources.iter().any(|p| p.file_name().unwrap() == "main.el")); assert!(sources.iter().any(|p| p.file_name().unwrap() == "lib.el")); } }