use std::collections::HashMap;
use std::fs::{self, File};
use std::hash::Hasher;
use std::io::{BufReader, Read, Write, BufWriter};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use twox_hash::XxHash64;
use sha2::{Digest, Sha256};
use indicatif::{ProgressBar};
use fern::Dispatch;
use log::{info};
use chrono::{Local};
use std::error::Error;
#[must_use]
pub fn setup_logger() -> Result<(), fern::InitError> {
Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{}] [{}] {}",
Local::now().format("%Y%m%d %H:%M:%S"),
record.level(),
message
))
})
.level(log::LevelFilter::Info)
.chain(fern::log_file("duplicate_finder.log")?)
.apply()?;
Ok(())
}
pub fn find_duplicates(dir: &Path) -> HashMap<u64, Vec<PathBuf>> {
let mut size_map: HashMap<u64, Vec<PathBuf>> = HashMap::new(); let files: Vec<PathBuf> = WalkDir::new(dir) .into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.path().is_file()) .map(|entry| entry.path().to_path_buf()) .collect();
info!("{} files identified in {}", files.len(), dir.display());
println!("{} files identified in {}", files.len(), dir.display());
println!("Sorting files by size...");
let progress = ProgressBar::new(files.len() as u64);
for file in &files {
if let Ok(metadata) = file.metadata() {
size_map.entry(metadata.len()).or_default().push(file.clone()); }
progress.inc(1); }
progress.finish(); info!("{} file sizes identified.", size_map.len());
println!("{} file sizes identified.", size_map.len());
println!("Computing quick hashes..");
let mut potential_dupes: HashMap<u64, Vec<PathBuf>> = HashMap::new();
let progress = ProgressBar::new(size_map.len() as u64);
for (_size, files) in size_map.into_iter().filter(|(_, f)| f.len() > 1) {
let mut quick_hash_map: HashMap<u64, Vec<PathBuf>> = HashMap::new();
for file in files {
if let Some(qh) = quick_hash(&file) {
quick_hash_map.entry(qh).or_default().push(file); }
}
for (_qh, group) in quick_hash_map.into_iter().filter(|(_, g)| g.len() > 1) {
potential_dupes.insert(_qh, group); }
progress.inc(1); }
progress.finish(); info!("{} unique quick hashes identified.", potential_dupes.len());
println!("{} unique quick hashes identified.", potential_dupes.len());
println!("Computing full hashes..");
let mut duplicates: HashMap<u64, Vec<PathBuf>> = HashMap::new();
let total_files = potential_dupes.values().map(Vec::len).sum::<usize>() as u64;
let progress = ProgressBar::new(total_files);
for (_qh, files) in potential_dupes {
let mut hash_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
for file in files {
if let Some(fh) = full_hash(&file) {
hash_map.entry(fh).or_default().push(file); }
progress.inc(1); }
for (_fh, group) in hash_map.into_iter().filter(|(_, g)| g.len() > 1) {
let size = fs::metadata(&group[0]).ok().map(|m| m.len()).unwrap_or(0);
duplicates.insert(size, group); }
}
progress.finish();
info!("{} duplicate files identified.", duplicates.len());
duplicates }
pub fn write_output(
duplicates: HashMap<u64, Vec<PathBuf>>,
output_file: &str,
start_time: &str,
base_dir: &Path,
) -> Result<(), Box<dyn Error>> {
let mut entries: Vec<(u64, Vec<PathBuf>)> = duplicates.into_iter().collect();
entries.sort_by(|a, b| b.0.cmp(&a.0));
let username = whoami::username();
let end_time = Local::now().format("%Y%m%d %H:%M:%S").to_string();
let file = File::create(output_file)?;
let mut writer = BufWriter::new(file);
writeln!(writer, "Duplicate File Finder Report")?;
writeln!(writer, "Generated by: {}", username)?;
writeln!(writer, "Start Time: {}", start_time)?;
writeln!(writer, "End Time: {}", end_time)?;
writeln!(writer, "Base Directory: {}", base_dir.display())?;
writeln!(writer)?;
let total_savings: u64 = entries.iter()
.map(|(size, paths)| size * (paths.len().saturating_sub(1) as u64))
.sum();
writeln!(writer, "Total Potential Space Savings: {}", format_size(total_savings))?;
writeln!(writer)?;
for (size, paths) in entries {
writeln!(writer, "Size: {}", format_size(size))?;
for path in paths {
writeln!(writer, "{}", path.display())?;
}
writeln!(writer)?;
}
info!("Duplicate files saved to {}", output_file);
Ok(())
}
fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if size >= TB {
format!("{:.2} TB", size as f64 / TB as f64)
} else if size >= GB {
format!("{:.2} GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.2} MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.2} KB", size as f64 / KB as f64)
} else {
format!("{} bytes", size)
}
}
fn quick_hash(file_path: &Path) -> Option<u64> {
let mut hasher = XxHash64::with_seed(0);
let file = File::open(file_path).ok()?;
let mut reader = BufReader::new(file);
let mut buffer = [0; 8192];
let bytes_read = reader.read(&mut buffer).ok()?;
hasher.write(&buffer[..bytes_read]);
Some(hasher.finish())
}
fn full_hash(file_path: &Path) -> Option<String> {
let file = File::open(file_path).ok()?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0; 65536];
while let Ok(bytes_read) = reader.read(&mut buffer) {
if bytes_read == 0 { break; }
hasher.update(&buffer[..bytes_read]);
}
Some(format!("{:x}", hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500 bytes");
assert_eq!(format_size(1500), "1.46 KB");
assert_eq!(format_size(1_500_000), "1.43 MB");
assert_eq!(format_size(1_500_000_000), "1.40 GB");
assert_eq!(format_size(1_500_000_000_000), "1.36 TB");
}
#[test]
fn test_quick_hash() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test_file.txt");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "Hello, world!").unwrap();
let hash = quick_hash(&file_path);
assert!(hash.is_some());
}
#[test]
fn test_full_hash() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test_file.txt");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "Hello, world!").unwrap();
let hash = full_hash(&file_path);
assert!(hash.is_some());
assert_eq!(
hash.unwrap(),
"d9014c4624844aa5bac314773d6b689ad467fa4e1d1a50a1b8a99d5a95f72ff5"
); }
#[test]
fn test_find_duplicates() {
let dir = tempdir().unwrap();
let file1 = dir.path().join("file1.txt");
let file2 = dir.path().join("file2.txt");
let unique_file = dir.path().join("unique.txt");
fs::write(&file1, "Duplicate content").unwrap();
fs::write(&file2, "Duplicate content").unwrap();
fs::write(&unique_file, "Unique content").unwrap();
let duplicates = find_duplicates(dir.path());
assert_eq!(duplicates.len(), 1); let duplicate_group = duplicates.values().next().unwrap();
assert_eq!(duplicate_group.len(), 2);
assert!(duplicate_group.contains(&file1));
assert!(duplicate_group.contains(&file2));
}
#[test]
fn test_write_output() {
let dir = tempdir().unwrap();
let file1 = dir.path().join("file1.txt");
let file2 = dir.path().join("file2.txt");
fs::write(&file1, "Duplicate content").unwrap();
fs::write(&file2, "Duplicate content").unwrap();
let mut duplicates = HashMap::new();
duplicates.insert(
file1.metadata().unwrap().len(),
vec![file1.clone(), file2.clone()],
);
let output_file = dir.path().join("output.txt");
let res = write_output(
duplicates,
output_file.to_str().unwrap(),
"20250101 12:00:00",
dir.path(),
);
let output = fs::read_to_string(&output_file).unwrap();
assert!(output.contains("Duplicate File Finder Report"));
assert!(output.contains(file1.to_str().unwrap()));
assert!(output.contains(file2.to_str().unwrap()));
}
}