Skip to main content

bock_build/
cache.rs

1//! Build cache management in `.bock/cache/`.
2//!
3//! Persists build state (content hashes) between builds so that incremental
4//! rebuilds can quickly determine what changed. The cache is stored as JSON
5//! in the project's `.bock/cache/` directory.
6
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use crate::content_hash::HashManifest;
12
13/// Name of the hash manifest file within the cache directory.
14const MANIFEST_FILE: &str = "hash_manifest.json";
15
16/// Manages the build cache stored in `.bock/cache/`.
17#[derive(Debug, Clone)]
18pub struct BuildCache {
19    /// Root of the cache directory (e.g., `<project>/.bock/cache/`).
20    cache_dir: PathBuf,
21}
22
23impl BuildCache {
24    /// Creates a new `BuildCache` pointing at the given project root.
25    ///
26    /// The cache directory will be `<project_root>/.bock/cache/`.
27    /// Does not create the directory; call [`ensure_cache_dir`](Self::ensure_cache_dir) first.
28    #[must_use]
29    pub fn new(project_root: &Path) -> Self {
30        Self {
31            cache_dir: project_root.join(".bock").join("cache"),
32        }
33    }
34
35    /// Creates a `BuildCache` with a custom cache directory path.
36    #[must_use]
37    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
38        Self { cache_dir }
39    }
40
41    /// Returns the path to the cache directory.
42    #[must_use]
43    pub fn cache_dir(&self) -> &Path {
44        &self.cache_dir
45    }
46
47    /// Ensures the cache directory exists, creating it if necessary.
48    ///
49    /// # Errors
50    ///
51    /// Returns an IO error if the directory cannot be created.
52    pub fn ensure_cache_dir(&self) -> io::Result<()> {
53        fs::create_dir_all(&self.cache_dir)
54    }
55
56    /// Loads the cached hash manifest from disk.
57    ///
58    /// Returns an empty manifest if the cache file does not exist.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the file exists but cannot be read or parsed.
63    pub fn load_manifest(&self) -> Result<HashManifest, CacheError> {
64        let path = self.cache_dir.join(MANIFEST_FILE);
65        if !path.exists() {
66            return Ok(HashManifest::new());
67        }
68
69        let content = fs::read_to_string(&path).map_err(CacheError::Io)?;
70        serde_json::from_str(&content).map_err(CacheError::Parse)
71    }
72
73    /// Saves the hash manifest to disk.
74    ///
75    /// Creates the cache directory if it does not exist.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the directory cannot be created or the file cannot be written.
80    pub fn save_manifest(&self, manifest: &HashManifest) -> Result<(), CacheError> {
81        self.ensure_cache_dir().map_err(CacheError::Io)?;
82
83        let path = self.cache_dir.join(MANIFEST_FILE);
84        let content = serde_json::to_string_pretty(manifest).map_err(CacheError::Serialize)?;
85        fs::write(&path, content).map_err(CacheError::Io)?;
86
87        Ok(())
88    }
89
90    /// Clears the entire build cache.
91    ///
92    /// # Errors
93    ///
94    /// Returns an IO error if the cache directory cannot be removed.
95    pub fn clear(&self) -> io::Result<()> {
96        if self.cache_dir.exists() {
97            fs::remove_dir_all(&self.cache_dir)?;
98        }
99        Ok(())
100    }
101
102    /// Returns true if a cached manifest exists on disk.
103    #[must_use]
104    pub fn has_manifest(&self) -> bool {
105        self.cache_dir.join(MANIFEST_FILE).exists()
106    }
107}
108
109/// Errors that can occur during cache operations.
110#[derive(Debug)]
111pub enum CacheError {
112    /// IO error reading or writing cache files.
113    Io(io::Error),
114    /// Error parsing cached JSON data.
115    Parse(serde_json::Error),
116    /// Error serializing data to JSON.
117    Serialize(serde_json::Error),
118}
119
120impl std::fmt::Display for CacheError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            Self::Io(e) => write!(f, "cache I/O error: {e}"),
124            Self::Parse(e) => write!(f, "cache parse error: {e}"),
125            Self::Serialize(e) => write!(f, "cache serialization error: {e}"),
126        }
127    }
128}
129
130impl std::error::Error for CacheError {
131    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
132        match self {
133            Self::Io(e) => Some(e),
134            Self::Parse(e) => Some(e),
135            Self::Serialize(e) => Some(e),
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::content_hash::ContentHash;
144
145    #[test]
146    fn cache_roundtrip() {
147        let dir = tempfile::tempdir().unwrap();
148        let cache = BuildCache::new(dir.path());
149
150        let mut manifest = HashManifest::new();
151        manifest.insert("Main".to_string(), ContentHash::of_str("fn main() {}"));
152        manifest.insert("Lib".to_string(), ContentHash::of_str("fn helper() {}"));
153
154        cache.save_manifest(&manifest).unwrap();
155        assert!(cache.has_manifest());
156
157        let loaded = cache.load_manifest().unwrap();
158        assert_eq!(loaded.len(), 2);
159        assert_eq!(loaded.get("Main"), manifest.get("Main"));
160        assert_eq!(loaded.get("Lib"), manifest.get("Lib"));
161    }
162
163    #[test]
164    fn cache_empty_when_no_file() {
165        let dir = tempfile::tempdir().unwrap();
166        let cache = BuildCache::new(dir.path());
167
168        let manifest = cache.load_manifest().unwrap();
169        assert!(manifest.is_empty());
170        assert!(!cache.has_manifest());
171    }
172
173    #[test]
174    fn cache_clear() {
175        let dir = tempfile::tempdir().unwrap();
176        let cache = BuildCache::new(dir.path());
177
178        let manifest = HashManifest::new();
179        cache.save_manifest(&manifest).unwrap();
180        assert!(cache.has_manifest());
181
182        cache.clear().unwrap();
183        assert!(!cache.has_manifest());
184        assert!(!cache.cache_dir().exists());
185    }
186
187    #[test]
188    fn cache_dir_path() {
189        let cache = BuildCache::new(Path::new("/project"));
190        assert_eq!(cache.cache_dir(), Path::new("/project/.bock/cache"));
191    }
192
193    #[test]
194    fn cache_overwrite() {
195        let dir = tempfile::tempdir().unwrap();
196        let cache = BuildCache::new(dir.path());
197
198        let mut m1 = HashManifest::new();
199        m1.insert("A".to_string(), ContentHash::of_str("v1"));
200        cache.save_manifest(&m1).unwrap();
201
202        let mut m2 = HashManifest::new();
203        m2.insert("A".to_string(), ContentHash::of_str("v2"));
204        m2.insert("B".to_string(), ContentHash::of_str("v1"));
205        cache.save_manifest(&m2).unwrap();
206
207        let loaded = cache.load_manifest().unwrap();
208        assert_eq!(loaded.len(), 2);
209        assert_eq!(loaded.get("A"), m2.get("A"));
210    }
211
212    #[test]
213    fn ensure_cache_dir_creates_nested() {
214        let dir = tempfile::tempdir().unwrap();
215        let cache = BuildCache::new(dir.path());
216
217        assert!(!cache.cache_dir().exists());
218        cache.ensure_cache_dir().unwrap();
219        assert!(cache.cache_dir().exists());
220    }
221}