use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::error::Result;
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 {
pub fn new() -> Self {
Self {
root: PathBuf::from("."),
snapshots: HashMap::new(),
}
}
pub(crate) 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(())
}
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)?;
let mut hasher = Sha256::new();
hasher.update(&content);
let result = hasher.finalize();
let mut hash = [0u8; 32];
hash.copy_from_slice(&result);
Ok(hash)
}
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();
std::fs::write(dir.path().join("file1.txt"), "content1").unwrap();
std::fs::write(dir.path().join("file2.txt"), "content2").unwrap();
dir
}
#[test]
fn test_snapshot_captures_files() {
let dir = setup_test_dir();
let snapshot = take_current_snapshot(dir.path()).unwrap();
assert_eq!(snapshot.len(), 2);
}
#[test]
fn test_excluded_dirs() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".git/config"), "git config").unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let snapshot = take_current_snapshot(dir.path()).unwrap();
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();
assert!(!tracker.has_files_changed("step1").unwrap());
}
#[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();
std::fs::write(dir.path().join("file1.txt"), "modified content").unwrap();
assert!(tracker.has_files_changed("step1").unwrap());
}
#[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();
std::fs::write(dir.path().join("new_file.txt"), "new content").unwrap();
assert!(tracker.has_files_changed("step1").unwrap());
}
#[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();
std::fs::remove_file(dir.path().join("file1.txt")).unwrap();
assert!(tracker.has_files_changed("step1").unwrap());
}
#[test]
fn test_no_snapshot_returns_false() {
let tracker = FileTracker::new();
assert!(!tracker.has_files_changed("nonexistent").unwrap());
}
}