use std::cmp::Ordering;
use crate::config::Verify;
use super::{
plan::{Entry as PlanEntry, EntryKind, Plan},
walker::Entry as WalkEntry,
};
const MTIME_TOLERANCE_NS: i128 = 2_000_000_000;
pub fn compare(src: &[WalkEntry], dst: &[WalkEntry], verify: Verify) -> Plan {
let mut entries = Vec::with_capacity(src.len().max(dst.len()));
let mut si = 0usize;
let mut di = 0usize;
while si < src.len() && di < dst.len() {
match src[si].rel.cmp(&dst[di].rel) {
Ordering::Less => {
entries.push(PlanEntry {
kind: EntryKind::New,
path: src[si].rel.clone(),
size_bytes: src[si].size,
transcode_from: src[si].src_ext.clone(),
});
si += 1;
}
Ordering::Greater => {
entries.push(PlanEntry {
kind: EntryKind::Orphan,
path: dst[di].rel.clone(),
size_bytes: dst[di].size,
transcode_from: None,
});
di += 1;
}
Ordering::Equal => {
let kind = classify(&src[si], &dst[di], verify);
entries.push(PlanEntry {
kind,
path: src[si].rel.clone(),
size_bytes: src[si].size,
transcode_from: src[si].src_ext.clone(),
});
si += 1;
di += 1;
}
}
}
for e in &src[si..] {
entries.push(PlanEntry {
kind: EntryKind::New,
path: e.rel.clone(),
size_bytes: e.size,
transcode_from: e.src_ext.clone(),
});
}
for e in &dst[di..] {
entries.push(PlanEntry {
kind: EntryKind::Orphan,
path: e.rel.clone(),
size_bytes: e.size,
transcode_from: None,
});
}
Plan {
entries,
warnings: Vec::new(),
}
}
fn classify(src: &WalkEntry, dst: &WalkEntry, verify: Verify) -> EntryKind {
if src.src_ext.is_some() {
return if dst.mtime_ns >= src.mtime_ns {
EntryKind::Same
} else {
EntryKind::Modified
};
}
match verify {
Verify::None => EntryKind::Same,
Verify::SizeMtime => {
if same_size_mtime(src, dst) {
EntryKind::Same
} else {
EntryKind::Modified
}
}
Verify::Checksum => {
if src.size != dst.size {
return EntryKind::Modified;
}
match (src.hash, dst.hash) {
(Some(sh), Some(dh)) => {
if sh == dh {
EntryKind::Same
} else {
EntryKind::Modified
}
}
_ => {
if same_mtime(src, dst) {
EntryKind::Same
} else {
EntryKind::Modified
}
}
}
}
}
}
fn same_size_mtime(src: &WalkEntry, dst: &WalkEntry) -> bool {
if src.size != dst.size {
return false;
}
same_mtime(src, dst)
}
fn same_mtime(src: &WalkEntry, dst: &WalkEntry) -> bool {
(src.mtime_ns - dst.mtime_ns).abs() <= MTIME_TOLERANCE_NS
}
#[cfg(test)]
mod tests {
use super::*;
use camino::Utf8PathBuf;
fn entry(path: &str, size: u64, mtime_ns: i128) -> WalkEntry {
WalkEntry {
rel: Utf8PathBuf::from(path),
size,
mtime_ns,
hash: None,
src_ext: None,
}
}
fn entry_hashed(path: &str, size: u64, data: &[u8]) -> WalkEntry {
let hash = Some(blake3::hash(data));
WalkEntry {
rel: Utf8PathBuf::from(path),
size,
mtime_ns: 0,
hash,
src_ext: None,
}
}
fn transcoded_entry(path: &str, size: u64, mtime_ns: i128, src_ext: &str) -> WalkEntry {
WalkEntry {
rel: Utf8PathBuf::from(path),
size,
mtime_ns,
hash: None,
src_ext: Some(src_ext.to_owned()),
}
}
const T: i128 = 1_000_000_000_000;
#[test]
fn empty_both_produces_empty_plan() {
let plan = compare(&[], &[], Verify::SizeMtime);
assert!(plan.entries.is_empty());
}
#[test]
fn all_new_when_dst_empty() {
let src = vec![entry("a.flac", 100, T), entry("b.flac", 200, T)];
let plan = compare(&src, &[], Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::New), 2);
assert_eq!(plan.count(EntryKind::Orphan), 0);
}
#[test]
fn all_orphan_when_src_empty() {
let dst = vec![entry("a.flac", 100, T), entry("b.flac", 200, T)];
let plan = compare(&[], &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Orphan), 2);
assert_eq!(plan.count(EntryKind::New), 0);
}
#[test]
fn identical_entries_are_same() {
let src = vec![entry("a.flac", 100, T)];
let dst = vec![entry("a.flac", 100, T)];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Same), 1);
}
#[test]
fn different_size_is_modified() {
let src = vec![entry("a.flac", 200, T)];
let dst = vec![entry("a.flac", 100, T)];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn mtime_diff_beyond_tolerance_is_modified() {
let src = vec![entry("a.flac", 100, T + 3_000_000_001)];
let dst = vec![entry("a.flac", 100, T)];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn mtime_diff_within_fat32_tolerance_is_same() {
let src = vec![entry("a.flac", 100, T + 2_000_000_000)];
let dst = vec![entry("a.flac", 100, T)];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Same), 1);
}
#[test]
fn verify_none_always_same() {
let src = vec![entry("a.flac", 999, T + 10_000_000_000)];
let dst = vec![entry("a.flac", 100, T)];
let plan = compare(&src, &dst, Verify::None);
assert_eq!(plan.count(EntryKind::Same), 1);
assert_eq!(plan.count(EntryKind::Modified), 0);
}
#[test]
fn mixed_plan_counts_correctly() {
let src = vec![
entry("album/01.flac", 100, T), entry("album/02.flac", 200, T), entry("album/03.flac", 300, T), ];
let dst = vec![
entry("album/01.flac", 100, T), entry("album/02.flac", 150, T), entry("album/04.flac", 400, T), ];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::New), 1, "new");
assert_eq!(plan.count(EntryKind::Modified), 1, "modified");
assert_eq!(plan.count(EntryKind::Orphan), 1, "orphan");
assert_eq!(plan.count(EntryKind::Same), 1, "same");
}
#[test]
fn transcoded_dst_newer_is_same() {
let src = vec![transcoded_entry("song.flac", 1000, T, "dsf")];
let dst = vec![entry("song.flac", 5000, T + 1_000_000_000)]; let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Same), 1);
assert_eq!(plan.count(EntryKind::Modified), 0);
}
#[test]
fn transcoded_src_newer_is_modified() {
let src = vec![transcoded_entry(
"song.flac",
1000,
T + 5_000_000_000,
"dsf",
)];
let dst = vec![entry("song.flac", 5000, T)];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn transcoded_new_entry_carries_transcode_from() {
let src = vec![transcoded_entry("song.flac", 1000, T, "dsf")];
let plan = compare(&src, &[], Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::New), 1);
assert_eq!(plan.entries[0].transcode_from.as_deref(), Some("dsf"));
}
#[test]
fn checksum_same_hash_is_same() {
let data = b"identical content";
let src = vec![entry_hashed("a.flac", data.len() as u64, data)];
let dst = vec![entry_hashed("a.flac", data.len() as u64, data)];
let plan = compare(&src, &dst, Verify::Checksum);
assert_eq!(plan.count(EntryKind::Same), 1);
assert_eq!(plan.count(EntryKind::Modified), 0);
}
#[test]
fn checksum_different_hash_same_size_is_modified() {
let src = vec![entry_hashed("a.flac", 10, b"source data")];
let dst = vec![entry_hashed("a.flac", 10, b"dest__data")];
let plan = compare(&src, &dst, Verify::Checksum);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn checksum_no_hashes_falls_back_to_mtime() {
let src = vec![entry("a.flac", 100, T + 5_000_000_000)];
let dst = vec![entry("a.flac", 100, T)];
let plan = compare(&src, &dst, Verify::Checksum);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn transfer_bytes_counts_new_and_modified_only() {
let src = vec![
entry("new.flac", 500, T),
entry("modified.flac", 300, T + 5_000_000_000),
entry("same.flac", 100, T),
];
let dst = vec![
entry("modified.flac", 300, T),
entry("orphan.flac", 200, T),
entry("same.flac", 100, T),
];
let plan = compare(&src, &dst, Verify::SizeMtime);
assert_eq!(plan.transfer_bytes(), 800);
}
}