use crate::scanner::{hash, FsNode};
use rayon::prelude::*;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
const DEFAULT_IGNORE_SUBSTRINGS: &[&str] = &[
"/.local/share/flatpak/",
"/.var/app/",
"/.cache/",
"/steamapps/compatdata/",
"/steamapps/shadercache/",
"/Steam/compatibilitytools.d/",
"/.steam/",
"/.local/share/Steam/",
"/lutris/runners/",
"/lutris/runtime/",
"/.local/share/Trash/",
"/snap/",
"/var/lib/snapd/",
"/target/",
"/.cargo/registry/",
"/.wine/",
"/.local/share/gem/",
"/.config/cosmic/",
"/.config/google-chrome/",
"/.config/Code/",
"/.mozilla/",
"/.local/share/Trash/",
];
#[derive(Debug, Clone, Serialize)]
pub struct DuplicateGroup {
pub size: u64,
pub paths: Vec<PathBuf>,
pub wasted: u64,
pub hardlinked: usize,
}
pub fn find_duplicates(root: &FsNode, min_size: u64) -> Vec<DuplicateGroup> {
find_duplicates_filtered(root, min_size, &[], true)
}
pub fn find_duplicates_filtered(
root: &FsNode,
min_size: u64,
extra_ignores: &[String],
use_default_ignores: bool,
) -> Vec<DuplicateGroup> {
let mut files = Vec::new();
root.flatten_files(&mut files);
let is_ignored = |p: &Path| {
let s = p.to_string_lossy();
(use_default_ignores && DEFAULT_IGNORE_SUBSTRINGS.iter().any(|pat| s.contains(pat)))
|| extra_ignores.iter().any(|pat| !pat.is_empty() && s.contains(pat.as_str()))
};
let mut by_size: HashMap<u64, Vec<(PathBuf, u64)>> = HashMap::new();
for f in files {
if f.size >= min_size && !is_ignored(&f.path) {
by_size.entry(f.size).or_default().push((f.path.clone(), f.inode));
}
}
by_size.retain(|_, v| v.len() > 1);
let quick_groups: Vec<(u64, Vec<(PathBuf, u64)>)> = by_size
.into_par_iter()
.flat_map(|(size, nodes)| {
let mut by_quick: HashMap<blake3::Hash, Vec<(PathBuf, u64)>> = HashMap::new();
for (path, inode) in nodes {
if let Ok(h) = hash::quick_hash(&path) {
by_quick.entry(h).or_default().push((path, inode));
}
}
by_quick
.into_iter()
.filter(|(_, v)| v.len() > 1)
.map(|(_, v)| (size, v))
.collect::<Vec<_>>()
})
.collect();
let final_groups: Vec<DuplicateGroup> = quick_groups
.into_par_iter()
.flat_map(|(size, entries)| {
let mut by_full: HashMap<blake3::Hash, Vec<(PathBuf, u64)>> = HashMap::new();
for (path, inode) in entries {
if let Ok(h) = hash::full_hash(&path) {
by_full.entry(h).or_default().push((path, inode));
}
}
by_full
.into_iter()
.filter(|(_, v)| v.len() > 1)
.filter(|(_, v)| {
let paths: Vec<PathBuf> = v.iter().map(|(p, _)| p.clone()).collect();
verify_group(&paths)
})
.filter(|(_, v)| {
let unique_inodes: HashSet<u64> = v.iter().map(|(_, i)| *i).collect();
unique_inodes.len() > 1
})
.map(|(_, v)| {
let unique_inodes: HashSet<u64> = v.iter().map(|(_, i)| *i).collect();
let hardlinked = v.len() - unique_inodes.len();
let wasted = size * (unique_inodes.len() as u64 - 1);
let paths = v.into_iter().map(|(p, _)| p).collect();
DuplicateGroup { size, paths, wasted, hardlinked }
})
.collect::<Vec<_>>()
})
.collect();
let mut sorted = final_groups;
sorted.sort_by(|a, b| b.wasted.cmp(&a.wasted));
sorted
}
fn verify_group(paths: &[PathBuf]) -> bool {
if paths.len() < 2 {
return false;
}
let first = &paths[0];
paths[1..].iter().all(|p| hash::bytes_equal(first, p).unwrap_or(false))
}