use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::{Error, Result};
use crate::paths::normalize_for_comparison;
use crate::profiler::{scan, Entry};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiffStrategy {
#[default]
SizeAndModifiedTime,
#[cfg(feature = "checksum")]
Checksum,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SyncPlan {
pub to_copy: Vec<Entry>,
pub to_delete: Vec<Entry>,
}
pub(crate) async fn diff(source: &Path, dest: &Path, strategy: DiffStrategy, tolerance: Duration) -> Result<SyncPlan> {
let source_entries = flat_entries(source).await?;
let dest_entries = match flat_entries(dest).await {
Ok(entries) => entries,
Err(Error::SourceNotFound { .. }) => Vec::new(),
Err(err) => return Err(err),
};
let mut dest_by_relative_path: HashMap<PathBuf, Entry> = dest_entries
.into_iter()
.map(|entry| (normalize_for_comparison(&entry.relative_path), entry))
.collect();
let mut to_copy = Vec::new();
for source_entry in source_entries {
let key = normalize_for_comparison(&source_entry.relative_path);
match dest_by_relative_path.remove(&key) {
None => to_copy.push(source_entry),
Some(dest_entry) => {
if changed(&source_entry, &dest_entry, strategy, tolerance).await? {
to_copy.push(source_entry);
}
}
}
}
let to_delete = dest_by_relative_path.into_values().collect();
Ok(SyncPlan { to_copy, to_delete })
}
async fn flat_entries(root: &Path) -> Result<Vec<Entry>> {
let workload = scan(root, u64::MAX).await?;
let mut entries = workload.small;
entries.extend(workload.large);
Ok(entries)
}
async fn changed(source: &Entry, dest: &Entry, strategy: DiffStrategy, tolerance: Duration) -> Result<bool> {
match strategy {
DiffStrategy::SizeAndModifiedTime => {
let newer = match (source.modified, dest.modified) {
(Some(source_modified), Some(dest_modified)) => source_modified > dest_modified + tolerance,
_ => source.modified > dest.modified,
};
Ok(source.size != dest.size || newer)
}
#[cfg(feature = "checksum")]
DiffStrategy::Checksum => {
let source_hash = hash_file(&source.path).await?;
let dest_hash = hash_file(&dest.path).await?;
Ok(source_hash != dest_hash)
}
}
}
#[cfg(feature = "checksum")]
async fn hash_file(path: &Path) -> Result<blake3::Hash> {
let bytes = tokio::fs::read(path)
.await
.map_err(|e| classify_error(e, path))?;
Ok(blake3::hash(&bytes))
}
#[cfg(feature = "checksum")]
fn classify_error(err: std::io::Error, path: &Path) -> Error {
match err.kind() {
std::io::ErrorKind::NotFound => Error::SourceNotFound { path: path.to_path_buf() },
std::io::ErrorKind::PermissionDenied => Error::PermissionDenied { path: path.to_path_buf() },
_ => Error::Io { path: path.to_path_buf(), source: err },
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::{Duration, SystemTime};
use tempfile::tempdir;
use super::*;
fn touch(path: &Path, contents: &[u8], modified: SystemTime) {
fs::write(path, contents).unwrap();
filetime::set_file_mtime(path, filetime::FileTime::from_system_time(modified)).unwrap();
}
#[tokio::test]
async fn identical_trees_produce_an_empty_plan() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
touch(&source.path().join("a.txt"), b"same", t);
touch(&dest.path().join("a.txt"), b"same", t);
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
assert!(plan.to_copy.is_empty());
assert!(plan.to_delete.is_empty());
}
#[tokio::test]
async fn entries_only_on_one_side_are_classified_correctly() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
fs::write(source.path().join("only_source.txt"), b"x").unwrap();
fs::write(dest.path().join("only_dest.txt"), b"y").unwrap();
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
assert_eq!(plan.to_copy.len(), 1);
assert_eq!(plan.to_copy[0].relative_path, PathBuf::from("only_source.txt"));
assert_eq!(plan.to_delete.len(), 1);
assert_eq!(plan.to_delete[0].relative_path, PathBuf::from("only_dest.txt"));
}
#[tokio::test]
async fn size_and_modified_time_flags_a_newer_source_but_not_an_identical_one() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let older = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
let newer = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
touch(&source.path().join("newer.txt"), b"1234", newer);
touch(&dest.path().join("newer.txt"), b"1234", older);
touch(&source.path().join("unchanged.txt"), b"1234", older);
touch(&dest.path().join("unchanged.txt"), b"1234", older);
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
assert_eq!(plan.to_copy.len(), 1);
assert_eq!(plan.to_copy[0].relative_path, PathBuf::from("newer.txt"));
assert!(plan.to_delete.is_empty());
}
#[tokio::test]
async fn tolerance_absorbs_a_difference_within_it() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
touch(&source.path().join("a.txt"), b"same size", base + Duration::from_millis(900));
touch(&dest.path().join("a.txt"), b"same size", base);
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::from_secs(2))
.await
.unwrap();
assert!(plan.to_copy.is_empty(), "a sub-tolerance mtime difference should not be treated as changed");
}
#[tokio::test]
async fn a_difference_beyond_tolerance_is_still_flagged_as_changed() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
touch(&source.path().join("a.txt"), b"same size", base + Duration::from_secs(5));
touch(&dest.path().join("a.txt"), b"same size", base);
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::from_secs(2))
.await
.unwrap();
assert_eq!(plan.to_copy.len(), 1, "a difference larger than the tolerance should still count as changed");
}
#[tokio::test]
async fn zero_tolerance_preserves_exact_comparison() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
touch(&source.path().join("a.txt"), b"same size", base + Duration::from_millis(1));
touch(&dest.path().join("a.txt"), b"same size", base);
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
assert_eq!(plan.to_copy.len(), 1, "with zero tolerance, any newer mtime at all should count as changed");
}
#[cfg(feature = "checksum")]
#[tokio::test]
async fn checksum_catches_a_content_change_that_size_and_mtime_would_miss() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
touch(&source.path().join("a.txt"), b"aaaa", t);
touch(&dest.path().join("a.txt"), b"bbbb", t);
let plan = diff(source.path(), dest.path(), DiffStrategy::Checksum, Duration::ZERO).await.unwrap();
assert_eq!(plan.to_copy.len(), 1);
assert_eq!(plan.to_copy[0].relative_path, PathBuf::from("a.txt"));
}
#[tokio::test]
async fn every_entry_lands_in_exactly_one_bucket_or_neither() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
touch(&source.path().join("unchanged.txt"), b"same", t);
touch(&dest.path().join("unchanged.txt"), b"same", t);
touch(&source.path().join("changed.txt"), b"new", SystemTime::UNIX_EPOCH + Duration::from_secs(2_000));
touch(&dest.path().join("changed.txt"), b"old", t);
fs::write(source.path().join("added.txt"), b"added").unwrap();
fs::write(dest.path().join("removed.txt"), b"removed").unwrap();
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
let mut copied: Vec<_> = plan.to_copy.iter().map(|e| e.relative_path.clone()).collect();
copied.sort();
assert_eq!(copied, vec![PathBuf::from("added.txt"), PathBuf::from("changed.txt")]);
let mut deleted: Vec<_> = plan.to_delete.iter().map(|e| e.relative_path.clone()).collect();
deleted.sort();
assert_eq!(deleted, vec![PathBuf::from("removed.txt")]);
}
#[tokio::test]
async fn missing_dest_means_everything_needs_copying_not_an_error() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let missing_dest = dest.path().join("does-not-exist-yet");
fs::write(source.path().join("a.txt"), b"a").unwrap();
let plan = diff(source.path(), &missing_dest, DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
assert_eq!(plan.to_copy.len(), 1);
assert!(plan.to_delete.is_empty());
}
#[tokio::test]
async fn nfc_and_nfd_forms_of_the_same_name_are_matched_not_treated_as_add_and_delete() {
use unicode_normalization::UnicodeNormalization;
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
let nfc: String = "café.txt".nfc().collect();
let nfd: String = "café.txt".nfd().collect();
assert_ne!(nfc.as_bytes(), nfd.as_bytes(), "test setup: these must start out byte-different");
touch(&source.path().join(&nfd), b"same", t);
touch(&dest.path().join(&nfc), b"same", t);
let plan = diff(source.path(), dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await.unwrap();
assert!(plan.to_copy.is_empty(), "should be matched as the same file, not re-copied");
assert!(plan.to_delete.is_empty(), "should be matched as the same file, not deleted as an orphan");
}
#[tokio::test]
async fn missing_source_is_still_an_error() {
let source = tempdir().unwrap();
let dest = tempdir().unwrap();
let missing_source = source.path().join("does-not-exist");
let result = diff(&missing_source, dest.path(), DiffStrategy::SizeAndModifiedTime, Duration::ZERO).await;
assert!(matches!(result, Err(Error::SourceNotFound { .. })));
}
}