fm84 0.43.0

A synthwave dual-pane TUI file manager
//! Alt+S: the two panels' directories compared all the way down, for a plan
//! of what to copy which way and what to delete. Walked on a thread of its
//! own, as Find files is, so a large tree or a slow disk holds up nothing.

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};

/// Modified times this close count as the same, as for Shift+F2: FAT keeps
/// them only to two seconds.
const SAME_TIME: Duration = Duration::from_secs(2);

/// An entry on one side, as it was when compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Side {
    pub is_dir: bool,
    pub size: u64,
    pub modified: Option<SystemTime>,
}

/// A path, from both directories, whose entries differ: on one side only,
/// one newer than the other, or the same age and not the same size, or a
/// file on one side and a directory on the other.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Difference {
    pub path: PathBuf,
    pub left: Option<Side>,
    pub right: Option<Side>,
}

impl Difference {
    /// Which side is newer, of two files: Some(true) the left, Some(false)
    /// the right, None when neither is - the same age, or no age to go by.
    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,
        }
    }
}

/// What the walk reports as it goes.
pub enum Update {
    /// Comparing in this directory, by its path from both. Sent only now and
    /// then.
    Looking(PathBuf),
    Found(Difference),
    /// Over: how many directories could not be read on either side, which
    /// are counted and passed over.
    Finished {
        unreadable: usize,
    },
}

/// Compare `left` and `right` all the way down, sending each difference as
/// it is found. A directory on one side only is one difference, not gone
/// into; one on both is gone into, and is not one itself. Links are
/// compared as the entries they are, never followed: one pointing back up
/// the tree would never end. Hidden entries are left out unless the panels
/// show them. Stops when `cancel` is set or `send` returns false.
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();
        // Gone into in name order: the last pushed is the first popped.
        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 });
}

/// Two files alike: the same size, and the same age or no age to tell by.
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,
        }
}

/// A directory's entries by name, as the entries themselves - a link as a
/// link. None when it cannot be read.
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"));
        // Alike, a second apart: FAT's two seconds.
        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);
    }
}