use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::error::Result;
pub(crate) fn sha256_digest(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
let mut hash = [0u8; 32];
hash.copy_from_slice(&result);
hash
}
type FileHash = [u8; 32];
type Snapshot = HashMap<PathBuf, FileHash>;
#[derive(Debug)]
pub struct FileTracker {
root: PathBuf,
snapshots: HashMap<String, Snapshot>,
}
const EXCLUDED_DIRS: &[&str] = &[".git", "target", "node_modules"];
impl FileTracker {
#[must_use]
pub fn new() -> Self {
Self {
root: PathBuf::from("."),
snapshots: HashMap::new(),
}
}
#[must_use]
pub fn with_root(root: PathBuf) -> Self {
Self {
root,
snapshots: HashMap::new(),
}
}
pub fn take_snapshot(&mut self, step_name: &str) -> Result<()> {
let snapshot = take_current_snapshot(&self.root)?;
self.snapshots.insert(step_name.to_string(), snapshot);
Ok(())
}
#[must_use]
pub fn has_snapshot(&self, step_name: &str) -> bool {
self.snapshots.contains_key(step_name)
}
pub fn has_files_changed(&self, step_name: &str) -> Result<bool> {
let Some(old_snapshot) = self.snapshots.get(step_name) else {
return Ok(false);
};
let current_snapshot = take_current_snapshot(&self.root)?;
if old_snapshot.len() != current_snapshot.len() {
return Ok(true);
}
for (path, old_hash) in old_snapshot {
match current_snapshot.get(path) {
Some(current_hash) => {
if old_hash != current_hash {
return Ok(true);
}
}
None => return Ok(true), }
}
Ok(false)
}
}
impl Default for FileTracker {
fn default() -> Self {
Self::new()
}
}
fn take_current_snapshot(root: impl AsRef<Path>) -> Result<Snapshot> {
let mut snapshot = HashMap::new();
for entry in WalkDir::new(root.as_ref())
.into_iter()
.filter_entry(|e| !is_excluded(e.path()))
{
let entry = entry.map_err(|e| {
crate::error::CruiseError::IoError(std::io::Error::other(e.to_string()))
})?;
if entry.file_type().is_file() {
let path = entry.path().to_path_buf();
let hash = hash_file(&path)?;
snapshot.insert(path, hash);
}
}
Ok(snapshot)
}
fn hash_file(path: &Path) -> Result<FileHash> {
let content = std::fs::read(path)?;
Ok(sha256_digest(&content))
}
fn is_excluded(path: &Path) -> bool {
path.components().any(|component| {
if let std::path::Component::Normal(name) = component {
EXCLUDED_DIRS.contains(&name.to_str().unwrap_or(""))
} else {
false
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_test_dir() -> TempDir {
let dir = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("file1.txt"), "content1")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("file2.txt"), "content2")
.unwrap_or_else(|e| panic!("{e:?}"));
dir
}
#[test]
fn test_snapshot_captures_files() {
let dir = setup_test_dir();
let snapshot = take_current_snapshot(dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(snapshot.len(), 2);
}
#[test]
fn test_excluded_dirs() {
let dir = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::create_dir(dir.path().join(".git")).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join(".git/config"), "git config")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("main.rs"), "fn main() {}")
.unwrap_or_else(|e| panic!("{e:?}"));
let snapshot = take_current_snapshot(dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(snapshot.len(), 1);
}
#[test]
fn test_no_changes_detected() {
let dir = setup_test_dir();
let mut tracker = FileTracker::with_root(dir.path().to_path_buf());
tracker
.take_snapshot("step1")
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
!tracker
.has_files_changed("step1")
.unwrap_or_else(|e| panic!("{e:?}"))
);
}
#[test]
fn test_file_modification_detected() {
let dir = setup_test_dir();
let mut tracker = FileTracker::with_root(dir.path().to_path_buf());
tracker
.take_snapshot("step1")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("file1.txt"), "modified content")
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
tracker
.has_files_changed("step1")
.unwrap_or_else(|e| panic!("{e:?}"))
);
}
#[test]
fn test_file_addition_detected() {
let dir = setup_test_dir();
let mut tracker = FileTracker::with_root(dir.path().to_path_buf());
tracker
.take_snapshot("step1")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("new_file.txt"), "new content")
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
tracker
.has_files_changed("step1")
.unwrap_or_else(|e| panic!("{e:?}"))
);
}
#[test]
fn test_file_deletion_detected() {
let dir = setup_test_dir();
let mut tracker = FileTracker::with_root(dir.path().to_path_buf());
tracker
.take_snapshot("step1")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::remove_file(dir.path().join("file1.txt")).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
tracker
.has_files_changed("step1")
.unwrap_or_else(|e| panic!("{e:?}"))
);
}
#[test]
fn test_no_snapshot_returns_false() {
let tracker = FileTracker::new();
assert!(
!tracker
.has_files_changed("nonexistent")
.unwrap_or_else(|e| panic!("{e:?}"))
);
}
#[test]
fn test_excluded_dirs_target() {
let dir = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::create_dir(dir.path().join("target")).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("target/app.out"), "binary")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("main.rs"), "fn main() {}")
.unwrap_or_else(|e| panic!("{e:?}"));
let snapshot = take_current_snapshot(dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(snapshot.len(), 1, "target/ should be excluded");
let paths: Vec<_> = snapshot.keys().collect();
assert!(
paths[0].ends_with("main.rs"),
"expected main.rs, got: {:?}",
paths[0]
);
}
#[test]
fn test_excluded_dirs_node_modules() {
let dir = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::create_dir(dir.path().join("node_modules")).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("node_modules/lib.js"), "module.exports={}")
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(dir.path().join("index.js"), "console.log('hi')")
.unwrap_or_else(|e| panic!("{e:?}"));
let snapshot = take_current_snapshot(dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(snapshot.len(), 1, "node_modules/ should be excluded");
let paths: Vec<_> = snapshot.keys().collect();
assert!(
paths[0].ends_with("index.js"),
"expected index.js, got: {:?}",
paths[0]
);
}
}