use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime};
const SAME_TIME: Duration = Duration::from_secs(2);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Side {
pub is_dir: bool,
pub size: u64,
pub modified: Option<SystemTime>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Difference {
pub path: PathBuf,
pub left: Option<Side>,
pub right: Option<Side>,
}
impl Difference {
pub fn left_newer(&self) -> Option<bool> {
let (Some(left), Some(right)) = (self.left, self.right) else {
return None;
};
match (left.modified, right.modified) {
(Some(mine), Some(theirs)) if mine > theirs + SAME_TIME => Some(true),
(Some(mine), Some(theirs)) if theirs > mine + SAME_TIME => Some(false),
_ => None,
}
}
}
pub enum Update {
Looking(PathBuf),
Found(Difference),
Finished {
unreadable: usize,
},
}
pub fn compare(left: &Path, right: &Path, show_hidden: bool, cancel: &AtomicBool, send: &mut dyn FnMut(Update) -> bool) {
let mut unreadable = 0;
let mut reported = Instant::now();
let mut stack = vec![PathBuf::new()];
while let Some(relative) = stack.pop() {
if cancel.load(Ordering::Relaxed) {
return;
}
if reported.elapsed() >= crate::constants::SIZE_PROGRESS_INTERVAL {
reported = Instant::now();
if !send(Update::Looking(relative.clone())) {
return;
}
}
let (Some(mine), Some(theirs)) = (entries(&left.join(&relative), show_hidden), entries(&right.join(&relative), show_hidden)) else {
unreadable += 1;
continue;
};
let mut names: Vec<&OsString> = mine.keys().chain(theirs.keys().filter(|name| !mine.contains_key(*name))).collect();
names.sort();
let mut below = Vec::new();
for name in names {
let (left, right) = (mine.get(name).copied(), theirs.get(name).copied());
let path = relative.join(name);
if let (Some(l), Some(r)) = (left, right) {
if l.is_dir && r.is_dir {
below.push(path);
continue;
}
if !l.is_dir && !r.is_dir && same(l, r) {
continue;
}
}
if !send(Update::Found(Difference { path, left, right })) {
return;
}
}
stack.extend(below.into_iter().rev());
}
send(Update::Finished { unreadable });
}
fn same(left: Side, right: Side) -> bool {
left.size == right.size
&& match (left.modified, right.modified) {
(Some(mine), Some(theirs)) => mine.max(theirs) <= mine.min(theirs) + SAME_TIME,
_ => true,
}
}
fn entries(dir: &Path, show_hidden: bool) -> Option<BTreeMap<OsString, Side>> {
let mut found = BTreeMap::new();
for entry in fs::read_dir(dir).ok()?.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
let name = entry.file_name();
if !show_hidden && crate::fs_ops::is_hidden(&name.to_string_lossy(), Some(&metadata)) {
continue;
}
let is_dir = metadata.is_dir();
found.insert(
name,
Side {
is_dir,
size: if is_dir { 0 } else { metadata.len() },
modified: metadata.modified().ok(),
},
);
}
Some(found)
}
#[cfg(test)]
mod tests {
use super::*;
fn run(left: &Path, right: &Path) -> (Vec<Difference>, usize) {
let mut found = Vec::new();
let mut unreadable = None;
compare(left, right, false, &AtomicBool::new(false), &mut |update| {
match update {
Update::Found(difference) => found.push(difference),
Update::Finished { unreadable: count } => unreadable = Some(count),
Update::Looking(_) => {}
}
true
});
(found, unreadable.expect("the walk never said it was over"))
}
fn write(path: &Path, body: &str, age: u64) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
let file = fs::File::options().write(true).open(path).unwrap();
file.set_modified(SystemTime::now() - Duration::from_secs(age)).unwrap();
}
#[test]
fn differences_all_the_way_down_and_nothing_alike() {
let root = std::env::temp_dir().join(format!("fm84-sync-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let (left, right) = (root.join("left"), root.join("right"));
write(&left.join("same.txt"), "same", 100);
write(&right.join("same.txt"), "same", 101);
write(&left.join("newer-left.txt"), "new", 10);
write(&right.join("newer-left.txt"), "old", 1000);
write(&left.join("sub/deep/newer-right.txt"), "old", 1000);
write(&right.join("sub/deep/newer-right.txt"), "new!", 10);
write(&left.join("sub/only-left.txt"), "", 10);
write(&right.join("only-right/inside.txt"), "", 10);
write(&left.join("sizes.txt"), "four", 50);
write(&right.join("sizes.txt"), "three", 50);
write(&left.join("clash"), "", 10);
fs::create_dir_all(right.join("clash")).unwrap();
write(&left.join(".hidden"), "", 10);
fs::create_dir_all(left.join("both-empty")).unwrap();
fs::create_dir_all(right.join("both-empty")).unwrap();
let (found, unreadable) = run(&left, &right);
assert_eq!(unreadable, 0);
let paths: Vec<String> = found.iter().map(|difference| difference.path.to_string_lossy().replace('\\', "/")).collect();
assert_eq!(paths, ["clash", "newer-left.txt", "only-right", "sizes.txt", "sub/only-left.txt", "sub/deep/newer-right.txt"]);
let by = |path: &str| found.iter().find(|difference| difference.path == Path::new(path)).unwrap();
assert_eq!(by("newer-left.txt").left_newer(), Some(true));
assert_eq!(by("sub/deep/newer-right.txt").left_newer(), Some(false));
assert_eq!(by("sizes.txt").left_newer(), None);
assert!(by("only-right").left.is_none() && by("only-right").right.is_some_and(|side| side.is_dir));
assert!(by("clash").left.is_some_and(|side| !side.is_dir) && by("clash").right.is_some_and(|side| side.is_dir));
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn a_cancelled_compare_stops() {
let mut updates = 0;
compare(Path::new("/"), Path::new("/"), false, &AtomicBool::new(true), &mut |_| {
updates += 1;
true
});
assert_eq!(updates, 0);
}
}