diskr-cli 1.0.0

Save your disk space, without fear of deleting the wrong thing.
Documentation
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))
}