use std::time::{Duration, UNIX_EPOCH};
use assert_fs::prelude::*;
use assert_fs::TempDir;
use camino::Utf8PathBuf;
use globset::GlobSetBuilder;
use dapctl::config::{Filters, Verify};
use dapctl::diff::compare::compare;
use dapctl::diff::walker::walk;
use dapctl::diff::EntryKind;
fn empty_globset() -> globset::GlobSet {
GlobSetBuilder::new().build().unwrap()
}
fn write_file(dir: &TempDir, rel: &str, content: &[u8]) {
dir.child(rel).write_binary(content).unwrap();
}
fn set_mtime(dir: &TempDir, rel: &str, secs_since_epoch: u64) {
let path = dir.child(rel).path().to_owned();
let t = UNIX_EPOCH + Duration::from_secs(secs_since_epoch);
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(t)).unwrap();
}
fn no_tag_filters() -> Filters {
Filters::default()
}
fn walk_dir(dir: &TempDir) -> Vec<dapctl::diff::walker::Entry> {
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
walk(&root, &empty_globset(), None, false, &no_tag_filters(), &[]).unwrap()
}
fn walk_dir_hashed(dir: &TempDir) -> Vec<dapctl::diff::walker::Entry> {
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
walk(&root, &empty_globset(), None, true, &no_tag_filters(), &[]).unwrap()
}
#[test]
fn walker_empty_dir_returns_empty() {
let dir = TempDir::new().unwrap();
let entries = walk_dir(&dir);
assert!(entries.is_empty());
}
#[test]
fn walker_missing_dir_returns_empty() {
let root = Utf8PathBuf::from("/nonexistent/path/that/does/not/exist");
let entries = walk(&root, &empty_globset(), None, false, &no_tag_filters(), &[]).unwrap();
assert!(entries.is_empty());
}
#[test]
fn walker_finds_nested_files() {
let dir = TempDir::new().unwrap();
write_file(&dir, "a.flac", b"data");
write_file(&dir, "artist/album/track.flac", b"data2");
let entries = walk_dir(&dir);
assert_eq!(entries.len(), 2);
let paths: Vec<_> = entries.iter().map(|e| e.rel.as_str()).collect();
assert!(paths.contains(&"a.flac"), "root file missing");
assert!(
paths.contains(&"artist/album/track.flac"),
"nested file missing"
);
}
#[test]
fn walker_entries_are_sorted() {
let dir = TempDir::new().unwrap();
write_file(&dir, "z.flac", b"z");
write_file(&dir, "a.flac", b"a");
write_file(&dir, "m.flac", b"m");
let entries = walk_dir(&dir);
let paths: Vec<_> = entries.iter().map(|e| e.rel.as_str()).collect();
let mut sorted = paths.clone();
sorted.sort_unstable();
assert_eq!(paths, sorted, "walker must return sorted entries");
}
#[test]
fn walker_exclude_glob_skips_matching_files() {
let dir = TempDir::new().unwrap();
write_file(&dir, "music/track.flac", b"flac");
write_file(&dir, "music/cover.jpg", b"jpg");
write_file(&dir, ".DS_Store", b"junk");
let exclude = {
let mut b = GlobSetBuilder::new();
b.add(globset::Glob::new("**/*.jpg").unwrap());
b.add(globset::Glob::new(".DS_Store").unwrap());
b.build().unwrap()
};
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
let entries = walk(&root, &exclude, None, false, &no_tag_filters(), &[]).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].rel.as_str(), "music/track.flac");
}
#[test]
fn walker_include_glob_filters_to_matching_only() {
let dir = TempDir::new().unwrap();
write_file(&dir, "track.flac", b"flac");
write_file(&dir, "track.mp3", b"mp3");
write_file(&dir, "cover.jpg", b"jpg");
let include = {
let mut b = GlobSetBuilder::new();
b.add(globset::Glob::new("**/*.flac").unwrap());
b.build().unwrap()
};
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
let entries = walk(
&root,
&empty_globset(),
Some(&include),
false,
&no_tag_filters(),
&[],
)
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].rel.as_str(), "track.flac");
}
#[test]
fn walker_records_correct_file_size() {
let dir = TempDir::new().unwrap();
write_file(&dir, "track.flac", &[0u8; 12345]);
let entries = walk_dir(&dir);
assert_eq!(entries[0].size, 12345);
}
#[test]
fn walker_handles_unicode_filenames() {
let dir = TempDir::new().unwrap();
write_file(&dir, "Therion - A\u{2019}Arab Zaraq/01.flac", b"data");
let entries = walk_dir(&dir);
assert_eq!(entries.len(), 1);
assert!(entries[0].rel.as_str().contains('\u{2019}'));
}
#[test]
fn diff_empty_src_empty_dst() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert!(plan.entries.is_empty());
}
#[test]
fn diff_all_new_when_dst_is_empty() {
let src = TempDir::new().unwrap();
write_file(&src, "01.flac", &[0u8; 1000]);
write_file(&src, "02.flac", &[0u8; 2000]);
let dst = TempDir::new().unwrap();
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::New), 2);
assert_eq!(plan.count(EntryKind::Orphan), 0);
assert_eq!(plan.transfer_bytes(), 3000);
}
#[test]
fn diff_all_orphan_when_src_is_empty() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
write_file(&dst, "old.flac", &[0u8; 500]);
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Orphan), 1);
assert_eq!(plan.count(EntryKind::New), 0);
}
#[test]
fn diff_same_file_is_same() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let data = [0u8; 500];
write_file(&src, "track.flac", &data);
write_file(&dst, "track.flac", &data);
set_mtime(&src, "track.flac", 1_700_000_000);
set_mtime(&dst, "track.flac", 1_700_000_000);
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Same), 1);
assert_eq!(plan.count(EntryKind::Modified), 0);
}
#[test]
fn diff_size_mismatch_is_modified() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
write_file(&src, "track.flac", &[0u8; 800]);
write_file(&dst, "track.flac", &[0u8; 400]);
set_mtime(&src, "track.flac", 1_700_000_000);
set_mtime(&dst, "track.flac", 1_700_000_000);
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn diff_mtime_beyond_fat32_tolerance_is_modified() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let data = [0u8; 300];
write_file(&src, "track.flac", &data);
write_file(&dst, "track.flac", &data);
set_mtime(&src, "track.flac", 1_700_000_010);
set_mtime(&dst, "track.flac", 1_700_000_000);
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Modified), 1);
}
#[test]
fn diff_mtime_within_fat32_tolerance_is_same() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let data = [0u8; 300];
write_file(&src, "track.flac", &data);
write_file(&dst, "track.flac", &data);
set_mtime(&src, "track.flac", 1_700_000_001);
set_mtime(&dst, "track.flac", 1_700_000_000);
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Same), 1);
assert_eq!(plan.count(EntryKind::Modified), 0);
}
#[test]
fn diff_mixed_scenario() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
write_file(&src, "same.flac", &[1u8; 100]);
write_file(&dst, "same.flac", &[1u8; 100]);
set_mtime(&src, "same.flac", 1_700_000_000);
set_mtime(&dst, "same.flac", 1_700_000_000);
write_file(&src, "modified.flac", &[2u8; 200]);
write_file(&dst, "modified.flac", &[2u8; 200]);
set_mtime(&src, "modified.flac", 1_700_000_100);
set_mtime(&dst, "modified.flac", 1_700_000_000);
write_file(&src, "new.flac", &[3u8; 300]);
write_file(&dst, "orphan.flac", &[4u8; 400]);
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, 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");
assert_eq!(
plan.transfer_bytes(),
200 + 300,
"transfer bytes = modified + new"
);
}
#[test]
fn diff_checksum_detects_silent_corruption() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
write_file(&src, "track.flac", b"original audio data");
write_file(&dst, "track.flac", b"corrupted_audio!!!!"); set_mtime(&src, "track.flac", 1_700_000_000);
set_mtime(&dst, "track.flac", 1_700_000_000);
let se = walk_dir_hashed(&src);
let de = walk_dir_hashed(&dst);
assert!(se[0].hash.is_some(), "src hash should be populated");
assert!(de[0].hash.is_some(), "dst hash should be populated");
assert_ne!(
se[0].hash, de[0].hash,
"hashes should differ for different content"
);
let plan = compare(&se, &de, Verify::Checksum);
assert_eq!(
plan.count(EntryKind::Modified),
1,
"checksum must detect content mismatch"
);
assert_eq!(plan.count(EntryKind::Same), 0);
}
#[test]
fn diff_checksum_same_content_is_same() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let data = b"identical audio data";
write_file(&src, "track.flac", data);
write_file(&dst, "track.flac", data);
set_mtime(&src, "track.flac", 1_700_000_000);
set_mtime(&dst, "track.flac", 1_700_000_100);
let se = walk_dir_hashed(&src);
let de = walk_dir_hashed(&dst);
let plan = compare(&se, &de, Verify::Checksum);
assert_eq!(
plan.count(EntryKind::Same),
1,
"identical content → Same even with mtime drift"
);
assert_eq!(plan.count(EntryKind::Modified), 0);
}
#[test]
fn tag_filter_inactive_by_default() {
let dir = TempDir::new().unwrap();
write_file(&dir, "a.flac", b"data");
write_file(&dir, "b.mp3", b"data");
let entries = walk_dir(&dir);
assert_eq!(entries.len(), 2, "default filters must not drop any files");
}
#[test]
fn tag_filter_unreadable_file_passes_gracefully() {
let dir = TempDir::new().unwrap();
write_file(&dir, "not_audio.flac", b"this is not a real FLAC file");
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
let filters = Filters {
min_sample_rate_hz: Some(44100),
..Filters::default()
};
let entries = walk(&root, &empty_globset(), None, false, &filters, &[]).unwrap();
assert_eq!(
entries.len(),
1,
"unreadable file must pass tag filters (graceful degradation)"
);
}
#[test]
fn tag_filter_sample_rate_applied_when_readable() {
let dir = TempDir::new().unwrap();
write_file(&dir, "garbage.flac", b"not a flac");
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
let filters = Filters {
min_sample_rate_hz: Some(96000),
..Filters::default()
};
let entries = walk(&root, &empty_globset(), None, false, &filters, &[]).unwrap();
assert_eq!(entries.len(), 1);
}
#[test]
fn tag_filter_include_artist_excludes_non_matching() {
let dir = TempDir::new().unwrap();
write_file(&dir, "track.flac", b"garbage bytes - not a real FLAC");
let root = Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap();
let filters = Filters {
include_artists: vec!["Tool".to_string()],
..Filters::default()
};
let entries = walk(&root, &empty_globset(), None, false, &filters, &[]).unwrap();
assert_eq!(
entries.len(),
1,
"unreadable file passes even artist include filter"
);
}
#[test]
fn diff_idempotent_after_sync() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let files = [("a/b.flac", 100u64), ("c/d.flac", 200u64)];
for (name, size) in &files {
let data = vec![0u8; *size as usize];
write_file(&src, name, &data);
write_file(&dst, name, &data);
set_mtime(&src, name, 1_700_000_000);
set_mtime(&dst, name, 1_700_000_000);
}
let se = walk_dir(&src);
let de = walk_dir(&dst);
let plan = compare(&se, &de, Verify::SizeMtime);
assert_eq!(plan.count(EntryKind::Same), files.len());
assert_eq!(plan.transfer_bytes(), 0);
}