fob_bundler/cache/changes.rs
1//! Change detection for incremental builds.
2//!
3//! Detects which modules have changed between builds by comparing content hashes,
4//! and computes the transitive set of affected modules using dependency graph traversal.
5
6use blake3::Hasher;
7use fob_graph::{ModuleGraph, ModuleId};
8use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
9use std::fs;
10use std::path::Path;
11
12/// Tracks module content hashes for change detection.
13///
14/// Stores BLAKE3 hashes of file contents to detect modifications between builds.
15/// This is used to determine if a cached module graph is still valid or if
16/// re-analysis is required.
17#[derive(Debug, Clone)]
18pub struct ChangeDetector {
19 /// BLAKE3 hashes of module file contents, indexed by ModuleId.
20 pub module_hashes: HashMap<ModuleId, [u8; 32]>,
21}
22
23/// Set of changed modules and their transitive dependents.
24///
25/// This structure represents the result of change detection, including:
26/// - Direct changes (modified, added, removed files)
27/// - Transitive changes (modules that depend on modified modules)
28#[derive(Debug, Clone)]
29pub struct ChangeSet {
30 /// Modules that have been modified (content hash changed).
31 pub modified: HashSet<ModuleId>,
32
33 /// Modules that are new (not present in previous build).
34 pub added: HashSet<ModuleId>,
35
36 /// Modules that were removed (present in previous build but not current).
37 pub removed: HashSet<ModuleId>,
38
39 /// All modules affected by changes (includes transitive dependents).
40 ///
41 /// This is the union of `modified`, `added`, and all modules that
42 /// transitively depend on any modified or added module.
43 pub affected: HashSet<ModuleId>,
44}
45
46impl ChangeSet {
47 /// Returns true if there are any changes (modified, added, or removed).
48 pub fn has_changes(&self) -> bool {
49 !self.modified.is_empty() || !self.added.is_empty() || !self.removed.is_empty()
50 }
51
52 /// Returns the total number of affected modules.
53 pub fn affected_count(&self) -> usize {
54 self.affected.len()
55 }
56}
57
58impl ChangeDetector {
59 /// Create a new empty change detector.
60 pub fn new() -> Self {
61 Self {
62 module_hashes: HashMap::default(),
63 }
64 }
65
66 /// Create a change detector from an existing module graph.
67 ///
68 /// Computes BLAKE3 hashes for all modules in the graph by reading their
69 /// file contents from disk.
70 ///
71 /// # Errors
72 ///
73 /// Returns an error if:
74 /// - Any module file cannot be read
75 /// - Module path is invalid
76 ///
77 /// Note: Errors are non-fatal for incremental caching - if we can't hash
78 /// a file, we should fall back to a full rebuild.
79 pub fn from_graph(graph: &ModuleGraph) -> crate::Result<Self> {
80 let modules = graph.modules()?;
81 let mut module_hashes = HashMap::default();
82
83 for module in modules {
84 // Skip virtual modules (they have no file on disk)
85 if module.id.is_virtual() {
86 continue;
87 }
88
89 // Hash the file contents
90 match hash_file(&module.path) {
91 Ok(hash) => {
92 module_hashes.insert(module.id.clone(), hash);
93 }
94 Err(e) => {
95 // File read errors are non-fatal - we'll just mark this as changed
96 eprintln!(
97 "Warning: Failed to hash file {:?}: {}. Treating as changed.",
98 module.path, e
99 );
100 // Use zero hash to ensure it's detected as changed
101 module_hashes.insert(module.id.clone(), [0u8; 32]);
102 }
103 }
104 }
105
106 Ok(Self { module_hashes })
107 }
108
109 /// Detect changes between the cached state and current files.
110 ///
111 /// Compares the stored hashes against the current file contents to determine
112 /// which modules have been modified, added, or removed.
113 ///
114 /// # Arguments
115 ///
116 /// * `current_files` - List of (ModuleId, content_hash) pairs for current state
117 ///
118 /// # Returns
119 ///
120 /// A `ChangeSet` containing all detected changes. Note that this does NOT
121 /// include transitive dependents - use `compute_affected` for that.
122 pub fn detect_changes(&self, current_files: &[(ModuleId, [u8; 32])]) -> ChangeSet {
123 let mut modified = HashSet::default();
124 let mut added = HashSet::default();
125 let current_ids: HashSet<ModuleId> =
126 current_files.iter().map(|(id, _)| id.clone()).collect();
127
128 // Check for modifications and additions
129 for (current_id, current_hash) in current_files {
130 match self.module_hashes.get(current_id) {
131 Some(cached_hash) => {
132 // Module exists in cache - check if content changed
133 if cached_hash != current_hash {
134 modified.insert(current_id.clone());
135 }
136 }
137 None => {
138 // Module is new
139 added.insert(current_id.clone());
140 }
141 }
142 }
143
144 // Check for removals
145 let removed: HashSet<ModuleId> = self
146 .module_hashes
147 .keys()
148 .filter(|id| !current_ids.contains(id))
149 .cloned()
150 .collect();
151
152 // Initial affected set is just the direct changes
153 // Caller needs to use compute_affected() to get transitive closure
154 let mut affected = HashSet::default();
155 affected.extend(modified.iter().cloned());
156 affected.extend(added.iter().cloned());
157 affected.extend(removed.iter().cloned());
158
159 ChangeSet {
160 modified,
161 added,
162 removed,
163 affected,
164 }
165 }
166
167 /// Compute the transitive closure of affected modules.
168 ///
169 /// Given a set of directly changed modules, compute all modules that
170 /// transitively depend on those changes using the dependency graph.
171 ///
172 /// # Algorithm
173 ///
174 /// 1. Start with the set of directly changed modules
175 /// 2. For each changed module, find all its dependents (reverse edges)
176 /// 3. Recursively traverse dependents until no new modules are found
177 ///
178 /// # Arguments
179 ///
180 /// * `changed` - Set of directly changed module IDs
181 /// * `graph` - The module graph containing dependency information
182 ///
183 /// # Returns
184 ///
185 /// A set containing all changed modules plus their transitive dependents.
186 pub fn compute_affected(
187 &self,
188 changed: &HashSet<ModuleId>,
189 graph: &ModuleGraph,
190 ) -> HashSet<ModuleId> {
191 let mut affected = changed.clone();
192 let mut to_visit: Vec<ModuleId> = changed.iter().cloned().collect();
193
194 // BFS through reverse edges (dependents)
195 while let Some(module_id) = to_visit.pop() {
196 // Get all modules that depend on this one
197 if let Ok(dependents) = graph.dependents(&module_id) {
198 for dependent in dependents {
199 // Only visit each dependent once
200 if affected.insert(dependent.clone()) {
201 to_visit.push(dependent);
202 }
203 }
204 }
205 }
206
207 affected
208 }
209}
210
211impl Default for ChangeDetector {
212 fn default() -> Self {
213 Self::new()
214 }
215}
216
217/// Compute BLAKE3 hash of a file's contents.
218///
219/// # Errors
220///
221/// Returns an error if the file cannot be read.
222fn hash_file(path: &Path) -> std::io::Result<[u8; 32]> {
223 let contents = fs::read(path)?;
224 let mut hasher = Hasher::new();
225 hasher.update(&contents);
226 Ok(*hasher.finalize().as_bytes())
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn test_change_detector_new() {
235 let detector = ChangeDetector::new();
236 assert!(detector.module_hashes.is_empty());
237 }
238
239 #[test]
240 fn test_changeset_has_changes() {
241 let empty = ChangeSet {
242 modified: HashSet::default(),
243 added: HashSet::default(),
244 removed: HashSet::default(),
245 affected: HashSet::default(),
246 };
247 assert!(!empty.has_changes());
248
249 let mut with_changes = empty.clone();
250 with_changes.modified.insert(ModuleId::new_virtual("test"));
251 assert!(with_changes.has_changes());
252 }
253
254 #[test]
255 fn test_changeset_affected_count() {
256 let mut changeset = ChangeSet {
257 modified: HashSet::default(),
258 added: HashSet::default(),
259 removed: HashSet::default(),
260 affected: HashSet::default(),
261 };
262
263 assert_eq!(changeset.affected_count(), 0);
264
265 changeset.affected.insert(ModuleId::new_virtual("a"));
266 changeset.affected.insert(ModuleId::new_virtual("b"));
267 assert_eq!(changeset.affected_count(), 2);
268 }
269}