use blake3::Hasher;
use fob_graph::{ModuleGraph, ModuleId};
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ChangeDetector {
pub module_hashes: HashMap<ModuleId, [u8; 32]>,
}
#[derive(Debug, Clone)]
pub struct ChangeSet {
pub modified: HashSet<ModuleId>,
pub added: HashSet<ModuleId>,
pub removed: HashSet<ModuleId>,
pub affected: HashSet<ModuleId>,
}
impl ChangeSet {
pub fn has_changes(&self) -> bool {
!self.modified.is_empty() || !self.added.is_empty() || !self.removed.is_empty()
}
pub fn affected_count(&self) -> usize {
self.affected.len()
}
}
impl ChangeDetector {
pub fn new() -> Self {
Self {
module_hashes: HashMap::default(),
}
}
pub fn from_graph(graph: &ModuleGraph) -> crate::Result<Self> {
let modules = graph.modules()?;
let mut module_hashes = HashMap::default();
for module in modules {
if module.id.is_virtual() {
continue;
}
match hash_file(&module.path) {
Ok(hash) => {
module_hashes.insert(module.id.clone(), hash);
}
Err(e) => {
eprintln!(
"Warning: Failed to hash file {:?}: {}. Treating as changed.",
module.path, e
);
module_hashes.insert(module.id.clone(), [0u8; 32]);
}
}
}
Ok(Self { module_hashes })
}
pub fn detect_changes(&self, current_files: &[(ModuleId, [u8; 32])]) -> ChangeSet {
let mut modified = HashSet::default();
let mut added = HashSet::default();
let current_ids: HashSet<ModuleId> =
current_files.iter().map(|(id, _)| id.clone()).collect();
for (current_id, current_hash) in current_files {
match self.module_hashes.get(current_id) {
Some(cached_hash) => {
if cached_hash != current_hash {
modified.insert(current_id.clone());
}
}
None => {
added.insert(current_id.clone());
}
}
}
let removed: HashSet<ModuleId> = self
.module_hashes
.keys()
.filter(|id| !current_ids.contains(id))
.cloned()
.collect();
let mut affected = HashSet::default();
affected.extend(modified.iter().cloned());
affected.extend(added.iter().cloned());
affected.extend(removed.iter().cloned());
ChangeSet {
modified,
added,
removed,
affected,
}
}
pub fn compute_affected(
&self,
changed: &HashSet<ModuleId>,
graph: &ModuleGraph,
) -> HashSet<ModuleId> {
let mut affected = changed.clone();
let mut to_visit: Vec<ModuleId> = changed.iter().cloned().collect();
while let Some(module_id) = to_visit.pop() {
if let Ok(dependents) = graph.dependents(&module_id) {
for dependent in dependents {
if affected.insert(dependent.clone()) {
to_visit.push(dependent);
}
}
}
}
affected
}
}
impl Default for ChangeDetector {
fn default() -> Self {
Self::new()
}
}
fn hash_file(path: &Path) -> std::io::Result<[u8; 32]> {
let contents = fs::read(path)?;
let mut hasher = Hasher::new();
hasher.update(&contents);
Ok(*hasher.finalize().as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_change_detector_new() {
let detector = ChangeDetector::new();
assert!(detector.module_hashes.is_empty());
}
#[test]
fn test_changeset_has_changes() {
let empty = ChangeSet {
modified: HashSet::default(),
added: HashSet::default(),
removed: HashSet::default(),
affected: HashSet::default(),
};
assert!(!empty.has_changes());
let mut with_changes = empty.clone();
with_changes.modified.insert(ModuleId::new_virtual("test"));
assert!(with_changes.has_changes());
}
#[test]
fn test_changeset_affected_count() {
let mut changeset = ChangeSet {
modified: HashSet::default(),
added: HashSet::default(),
removed: HashSet::default(),
affected: HashSet::default(),
};
assert_eq!(changeset.affected_count(), 0);
changeset.affected.insert(ModuleId::new_virtual("a"));
changeset.affected.insert(ModuleId::new_virtual("b"));
assert_eq!(changeset.affected_count(), 2);
}
}