Skip to main content

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}