17 Commits
0.1.6 ... 0.2.1

Author SHA1 Message Date
Sreedev Kodichath
a56d194ee3 Merge pull request #56 from sreedevk/feature/add-json-output
Version 0.2.1
2023-11-14 18:38:50 -05:00
sreedev
080cd791dc removed early return 2023-11-14 18:37:48 -05:00
sreedev
b16236763c updated readme + version number 2023-11-14 18:36:27 -05:00
sreedev
3e407f69c8 added json output for further processing using other tools 2023-11-14 18:33:40 -05:00
Sreedev Kodichath
7d386c9420 Merge pull request #54 from sreedevk/distribution-improvements
Improve Distribution Methods & Create Compiled Binaries for More Platforms
2023-08-10 19:54:29 -04:00
sreedev
0dc681d4e7 added release yml 2023-08-10 19:51:57 -04:00
sreedev
442cb4b519 added cargo dist options 2023-08-10 13:05:29 -04:00
sreedev
8463e72f2d removed debug information from distrbution & release profiles to reduce binary size 2023-08-10 12:59:07 -04:00
sreedev
05c95bb67a added roadmap to readme 2023-08-02 09:41:15 -04:00
Sreedev Kodichath
062d44acd9 Merge pull request #53 from sreedevk/v0.2.0
v0.2.0  Architecture Improvements
2023-07-17 18:17:57 -04:00
sreedev
ee6655de9b removed examples 2023-07-17 18:14:13 -04:00
sreedev
ffa5295598 clippy 2023-07-17 14:40:10 -04:00
sreedev
6be8596992 restored interactive mode 2023-07-17 14:34:42 -04:00
sreedev
a40f251e30 subtract overflow issue fixed 2023-07-17 14:19:57 -04:00
sreedev
02d05172da minsize issues fixed 2023-07-17 14:13:18 -04:00
sreedev
dcc709a666 added filetype filter 2023-07-17 14:06:57 -04:00
sreedev
e3d48ec505 v0.2.0 2023-07-17 13:59:58 -04:00
16 changed files with 1079 additions and 842 deletions

View File

@@ -1,12 +1,12 @@
# CI that: # CI that:
# #
# * checks for a Git Tag that looks like a release ("v1.2.0") # * checks for a Git Tag that looks like a release
# * creates a Github Release™ # * creates a Github Release™ and fills in its text
# * builds binaries/packages with cargo-dist # * builds artifacts with cargo-dist (executable-zips, installers)
# * uploads those packages to the Github Release™ # * uploads those artifacts to the Github Release™
# #
# Note that the Github Release™ will be created before the packages, # Note that the Github Release™ will be created before the artifacts,
# so there will be a few minutes where the release has no packages # so there will be a few minutes where the release has no artifacts
# and then they will slowly trickle in, possibly failing. To make # and then they will slowly trickle in, possibly failing. To make
# this more pleasant we mark the release as a "draft" until all # this more pleasant we mark the release as a "draft" until all
# artifacts have been successfully uploaded. This allows you to # artifacts have been successfully uploaded. This allows you to
@@ -17,114 +17,116 @@ name: Release
permissions: permissions:
contents: write contents: write
# This task will run whenever you push a git tag that looks like # This task will run whenever you push a git tag that looks like a version
# a version number. We just look for `v` followed by at least one number # like "v1", "v1.2.0", "v0.1.0-prerelease01", "my-app-v1.0.0", etc.
# and then whatever. so `v1`, `v1.0.0`, and `v1.0.0-prerelease` all work. # The version will be roughly parsed as ({PACKAGE_NAME}-)?v{VERSION}, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version.
# #
# If there's a prerelease-style suffix to the version then the Github Release™ # If PACKAGE_NAME is specified, then we will create a Github Release™ for that
# will be marked as a prerelease (handled by taiki-e/create-gh-release-action). # package (erroring out if it doesn't have the given version or isn't cargo-dist-able).
# #
# Note that when generating links to uploaded artifacts, cargo-dist will currently # If PACKAGE_NAME isn't specified, then we will create a Github Release™ for all
# assume that your git tag is always v{VERSION} where VERSION is the version in # (cargo-dist-able) packages in the workspace with that version (this is mode is
# the published package's Cargo.toml (this is the default behaviour of cargo-release). # intended for workspaces with only one dist-able package, or with all dist-able
# In the future this may be made more robust/configurable. # packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent Github Release™ for each one.
#
# If there's a prerelease-style suffix to the version then the Github Release™
# will be marked as a prerelease.
on: on:
push: push:
tags: tags:
- v[0-9]+.* - '*-?v[0-9]+*'
env:
ALL_CARGO_DIST_TARGET_ARGS: --target=x86_64-unknown-linux-gnu --target=x86_64-apple-darwin --target=x86_64-pc-windows-msvc
ALL_CARGO_DIST_INSTALLER_ARGS:
jobs: jobs:
# Create the Github Release™ so the packages have something to be uploaded to # Create the Github Release™ so the packages have something to be uploaded to
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tag: ${{ steps.create-gh-release.outputs.computed-prefix }}${{ steps.create-gh-release.outputs.version }} has-releases: ${{ steps.create-release.outputs.has-releases }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- id: create-gh-release - name: Install Rust
uses: taiki-e/create-gh-release-action@v1 run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
with: - name: Install cargo-dist
draft: true run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
# (required) GitHub token for creating GitHub Releases. - id: create-release
token: ${{ secrets.GITHUB_TOKEN }} run: |
cargo dist plan --tag=${{ github.ref_name }} --output-format=json > dist-manifest.json
echo "dist plan ran successfully"
cat dist-manifest.json
# Create the Github Release™ based on what cargo-dist thinks it should be
ANNOUNCEMENT_TITLE=$(jq --raw-output ".announcement_title" dist-manifest.json)
IS_PRERELEASE=$(jq --raw-output ".announcement_is_prerelease" dist-manifest.json)
jq --raw-output ".announcement_github_body" dist-manifest.json > new_dist_announcement.md
gh release create ${{ github.ref_name }} --draft --prerelease="$IS_PRERELEASE" --title="$ANNOUNCEMENT_TITLE" --notes-file=new_dist_announcement.md
echo "created announcement!"
# Upload the manifest to the Github Release™
gh release upload ${{ github.ref_name }} dist-manifest.json
echo "uploaded manifest!"
# Disable all the upload-artifacts tasks if we have no actual releases
HAS_RELEASES=$(jq --raw-output ".releases != null" dist-manifest.json)
echo "has-releases=$HAS_RELEASES" >> "$GITHUB_OUTPUT"
# Build and packages all the things # Build and packages all the things
upload-artifacts: upload-artifacts:
# Let the initial task tell us to not run (currently very blunt)
needs: create-release needs: create-release
if: ${{ needs.create-release.outputs.has-releases == 'true' }}
strategy: strategy:
matrix: matrix:
# For these target platforms # For these target platforms
include: include:
- target: x86_64-unknown-linux-gnu - os: macos-11
os: ubuntu-20.04 dist-args: --artifacts=local --target=aarch64-apple-darwin --target=x86_64-apple-darwin
install-dist: curl --proto '=https' --tlsv1.2 -L -sSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.2/installer.sh | sh install-dist: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
- target: x86_64-apple-darwin - os: ubuntu-20.04
os: macos-11 dist-args: --artifacts=local --target=x86_64-unknown-linux-gnu
install-dist: curl --proto '=https' --tlsv1.2 -L -sSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.2/installer.sh | sh install-dist: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
- target: x86_64-pc-windows-msvc - os: windows-2019
os: windows-2019 dist-args: --artifacts=local --target=x86_64-pc-windows-msvc
install-dist: irm 'https://github.com/axodotdev/cargo-dist/releases/download/v0.0.2/installer.ps1' | iex install-dist: irm https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.ps1 | iex
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Install Rust - name: Install Rust
run: rustup update stable && rustup default stable run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
- name: Install cargo-dist - name: Install cargo-dist
run: ${{ matrix.install-dist }} run: ${{ matrix.install-dist }}
- name: Run cargo-dist - name: Run cargo-dist
# This logic is a bit janky because it's trying to be a polyglot between # This logic is a bit janky because it's trying to be a polyglot between
# powershell and bash since this will run on windows, macos, and linux! # powershell and bash since this will run on windows, macos, and linux!
# The two platforms don't agree on how to talk about env vars but they # The two platforms don't agree on how to talk about env vars but they
# do agree on 'cat' and '$()' so we use that to marshal values between commmands. # do agree on 'cat' and '$()' so we use that to marshal values between commands.
run: | run: |
# Actually do builds and make zips and whatnot # Actually do builds and make zips and whatnot
cargo dist --target=${{ matrix.target }} --output-format=json > dist-manifest.json cargo dist build --tag=${{ github.ref_name }} --output-format=json ${{ matrix.dist-args }} > dist-manifest.json
echo "dist ran successfully" echo "dist ran successfully"
cat dist-manifest.json cat dist-manifest.json
# Parse out what we just built and upload it to the Github Release™
cat dist-manifest.json | jq --raw-output ".releases[].artifacts[].path" > uploads.txt # Parse out what we just built and upload it to the Github Release™
jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json > uploads.txt
echo "uploading..." echo "uploading..."
cat uploads.txt cat uploads.txt
gh release upload ${{ needs.create-release.outputs.tag }} $(cat uploads.txt) gh release upload ${{ github.ref_name }} $(cat uploads.txt)
echo "uploaded!" echo "uploaded!"
# Compute and upload the manifest for everything # Mark the Github Release™ as a non-draft now that everything has succeeded!
upload-manifest:
needs: create-release
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v3
- name: Install Rust
run: rustup update stable && rustup default stable
- name: Install cargo-dist
run: curl --proto '=https' --tlsv1.2 -L -sSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.2/installer.sh | sh
- name: Run cargo-dist manifest
run: |
# Generate a manifest describing everything
cargo dist manifest --no-local-paths --output-format=json $ALL_CARGO_DIST_TARGET_ARGS $ALL_CARGO_DIST_INSTALLER_ARGS > dist-manifest.json
echo "dist manifest ran successfully"
cat dist-manifest.json
# Upload the manifest to the Github Release™
gh release upload ${{ needs.create-release.outputs.tag }} dist-manifest.json
echo "uploaded manifest!"
# Edit the Github Release™ title/body to match what cargo-dist thinks it should be
CHANGELOG_TITLE=$(cat dist-manifest.json | jq --raw-output ".releases[].changelog_title")
cat dist-manifest.json | jq --raw-output ".releases[].changelog_body" > new_dist_changelog.md
gh release edit ${{ needs.create-release.outputs.tag }} --title="$CHANGELOG_TITLE" --notes-file=new_dist_changelog.md
echo "updated release notes!"
# Mark the Github Release™ as a non-draft now that everything has succeeded!
publish-release: publish-release:
needs: [create-release, upload-artifacts, upload-manifest] # Only run after all the other tasks, but it's ok if upload-artifacts was skipped
needs: [create-release, upload-artifacts]
if: ${{ always() && needs.create-release.result == 'success' && (needs.upload-artifacts.result == 'skipped' || needs.upload-artifacts.result == 'success') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -132,5 +134,4 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: mark release as non-draft - name: mark release as non-draft
run: | run: |
gh release edit ${{ needs.create-release.outputs.tag }} --draft=false gh release edit ${{ github.ref_name }} --draft=false

3
.gitignore vendored
View File

@@ -1,3 +1,2 @@
/target /target
/test_data /Cargo.lock
.envrc

686
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
[package] [package]
name = "deduplicator" name = "deduplicator"
version = "0.1.6" version = "0.2.1"
edition = "2021" edition = "2021"
description = "find,filter,delete Duplicates" description = "find,filter,delete Duplicates"
repository = "https://github.com/sreedevk/deduplicator"
license = "MIT" license = "MIT"
authors = [ authors = [
"Sreedev Kodichath <sreedevpadmakumar@gmail.com>", "Sreedev Kodichath <sreedevpadmakumar@gmail.com>",
@@ -24,13 +25,23 @@ globwalk = "0.8.1"
indicatif = { version = "0.17.2", features = ["rayon"] } indicatif = { version = "0.17.2", features = ["rayon"] }
itertools = "0.10.5" itertools = "0.10.5"
memmap2 = "0.5.8" memmap2 = "0.5.8"
pathdiff = "0.2.1"
prettytable-rs = "0.10.0" prettytable-rs = "0.10.0"
rayon = "1.6.1" rayon = "1.6.1"
serde = { version = "1.0.192", features = ["derive"] }
serde_json = "1.0.108"
unicode-segmentation = "1.10.0" unicode-segmentation = "1.10.0"
[profile.release]
strip = true
# generated by 'cargo dist init' # generated by 'cargo dist init'
[profile.dist] [profile.dist]
inherits = "release" inherits = "release"
debug = true lto = "thin"
split-debuginfo = "packed"
[workspace.metadata.dist]
rust-toolchain-version = "1.71.0"
ci = ["github"]
targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
cargo-dist-version = "0.0.7"

View File

@@ -21,6 +21,7 @@ Options:
-f, --follow-links Follow links while scanning directories -f, --follow-links Follow links while scanning directories
-h, --help Print help information -h, --help Print help information
-V, --version Print version information -V, --version Print version information
--json
``` ```
### Examples ### Examples
@@ -121,3 +122,8 @@ Memory: 31731MiB (~32GiB)
## Screenshots ## Screenshots
![](https://user-images.githubusercontent.com/36154121/213618143-e5182e39-731e-4817-87dd-1a6a0f38a449.gif) ![](https://user-images.githubusercontent.com/36154121/213618143-e5182e39-731e-4817-87dd-1a6a0f38a449.gif)
## Roadmap
- Tree format output for duplicate file listing
- GUI
- Packages for different operating system repositories (currently only installable via cargo)

View File

@@ -1,18 +0,0 @@
use crate::output;
use crate::params::Params;
use crate::scanner;
use anyhow::Result;
pub struct App;
impl App {
pub fn init(app_args: &Params) -> Result<()> {
let duplicates = scanner::duplicates(app_args)?;
match app_args.interactive {
true => output::interactive(duplicates, app_args),
false => output::print(duplicates, app_args),
}
Ok(())
}
}

View File

@@ -1,21 +0,0 @@
use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct File {
pub path: PathBuf,
pub size: Option<u64>,
pub hash: Option<String>,
}
pub fn delete_files(files: Vec<File>) -> Result<()> {
files.into_iter().for_each(|file| {
match std::fs::remove_file(file.path.clone()) {
Ok(_) => println!("{}: {}", "DELETED".green(), file.path.display()),
Err(_) => println!("{}: {}", "FAILED".red(), file.path.display())
}
});
Ok(())
}

42
src/fileinfo.rs Normal file
View File

@@ -0,0 +1,42 @@
use anyhow::Result;
use memmap2::Mmap;
use std::fs;
use std::hash::Hasher;
use std::{fs::Metadata, path::PathBuf};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct FileInfo {
pub path: PathBuf,
pub hash: Option<String>,
pub size: u64,
#[serde(skip)]
pub filemeta: Metadata,
}
impl FileInfo {
pub fn hash(&self) -> Result<Self> {
let file = fs::File::open(self.path.clone())?;
let mapper = unsafe { Mmap::map(&file)? };
let mut primhasher = fxhash::FxHasher::default();
mapper
.chunks(1_000_000)
.for_each(|chunk| primhasher.write(chunk));
Ok(Self {
hash: Some(primhasher.finish().to_string()),
..self.clone()
})
}
pub fn new(path: PathBuf) -> Result<Self> {
let filemeta = std::fs::metadata(path.clone())?;
Ok(Self {
path,
filemeta: filemeta.clone(),
hash: None,
size: filemeta.len(),
})
}
}

View File

@@ -1,12 +0,0 @@
use crate::file_manager::File;
use crate::params::Params;
pub fn is_file_gt_min_size(app_opts: &Params, file: &File) -> bool {
match app_opts.get_min_size() {
Some(msize) => match file.size {
Some(fsize) => fsize >= msize,
None => true,
},
None => true,
}
}

134
src/formatter.rs Normal file
View File

@@ -0,0 +1,134 @@
pub struct Formatter;
use crate::fileinfo::FileInfo;
use crate::params::Params;
use anyhow::Result;
use chrono::{DateTime, Utc};
use colored::Colorize;
use dashmap::DashMap;
use indicatif::{
ParallelProgressIterator, ProgressBar, ProgressFinish, ProgressIterator, ProgressStyle,
};
use pathdiff::diff_paths;
use prettytable::{format, row, Table};
use rayon::prelude::*;
use std::borrow::Cow;
use std::path::PathBuf;
use std::time::Duration;
impl Formatter {
pub fn human_path(
file: &FileInfo,
app_args: &Params,
min_path_length: usize,
) -> Result<String> {
let base_directory: PathBuf = app_args.get_directory()?;
let relative_path = diff_paths(file.path.clone(), base_directory).unwrap_or_default();
let formatted_path = format!(
"{:<0width$}",
relative_path.to_str().unwrap_or_default().to_string(),
width = min_path_length
);
Ok(formatted_path)
}
pub fn human_filesize(file: &FileInfo) -> Result<String> {
Ok(format!("{:>12}", bytesize::ByteSize::b(file.size)))
}
pub fn human_mtime(file: &FileInfo) -> Result<String> {
let modified_time: DateTime<Utc> = file.filemeta.modified()?.into();
Ok(modified_time.format("%Y-%m-%d %H:%M:%S").to_string())
}
pub fn generate_table(raw: Vec<FileInfo>, app_args: &Params) -> Result<Table> {
let basepath_length = app_args.get_directory()?.to_str().unwrap_or_default().len();
let max_filepath_length = raw
.iter()
.map(|file| file.path.to_str().unwrap_or_default().len())
.max()
.unwrap_or_default();
let min_path_length = if max_filepath_length > basepath_length {
max_filepath_length - basepath_length
} else {
0
};
let progress_style = ProgressStyle::with_template(
"[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}",
)?;
let progress_bar = ProgressBar::new(raw.len() as u64);
progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("reconciling data");
let duplicates_table: DashMap<String, Vec<FileInfo>> = DashMap::new();
raw.into_par_iter()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from("data reconciled")))
.map(|file| file.hash())
.filter_map(Result::ok)
.for_each(|file| {
duplicates_table
.entry(file.hash.clone().unwrap_or_default())
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
});
let mut output_table = Table::new();
output_table.set_titles(row!["hash", "duplicates"]);
let progress_style = ProgressStyle::with_template(
"[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}",
)?;
let progress_bar = ProgressBar::new(duplicates_table.len() as u64);
progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("generating output");
duplicates_table
.into_iter()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from("output generated")))
.for_each(|(hash, group)| {
let mut inner_table = Table::new();
inner_table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
group.iter().for_each(|file| {
inner_table.add_row(row![
Self::human_path(file, app_args, min_path_length)
.unwrap_or_default()
.blue(),
Self::human_filesize(file).unwrap_or_default().red(),
Self::human_mtime(file).unwrap_or_default().yellow()
]);
});
output_table.add_row(row![hash.green(), inner_table]);
});
Ok(output_table)
}
pub fn print(raw: Vec<FileInfo>, app_args: &Params) -> Result<()> {
if raw.is_empty() {
println!(
"\n\n{}\n",
"No duplicates found matching your search criteria.".green()
);
return Ok(());
}
if app_args.json {
let output_json = serde_json::to_string_pretty(&raw)?;
println!("{}", output_json);
} else {
let output_table = Self::generate_table(raw, app_args)?;
output_table.printstd();
}
Ok(())
}
}

154
src/interactive.rs Normal file
View File

@@ -0,0 +1,154 @@
use crate::formatter::Formatter;
use crate::{fileinfo::FileInfo, params::Params};
use anyhow::Result;
use colored::Colorize;
use dashmap::DashMap;
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressFinish, ProgressStyle};
use prettytable::{format, row, Table};
use rayon::prelude::*;
use std::{
borrow::Cow,
io::{self, Write},
time::Duration,
};
pub fn scan_group_confirmation() -> Result<bool> {
print!("\nconfirm? [y/N]: ");
std::io::stdout().flush()?;
let mut user_input = String::new();
io::stdin().read_line(&mut user_input)?;
match user_input.trim() {
"Y" | "y" => Ok(true),
_ => Ok(false),
}
}
pub fn scan_group_instruction() -> Result<String> {
println!("\nEnter the indices of the files you want to delete.");
println!("You can enter multiple files using commas to seperate file indices.");
println!("example: 1,2");
print!("\n> ");
std::io::stdout().flush()?;
let mut user_input = String::new();
io::stdin().read_line(&mut user_input)?;
Ok(user_input)
}
pub fn init(result: Vec<FileInfo>, app_args: &Params) -> Result<()> {
let basepath_length = app_args.get_directory()?.to_str().unwrap_or_default().len();
let max_filepath_length = result
.iter()
.map(|file| file.path.to_str().unwrap_or_default().len())
.max()
.unwrap_or_default();
let min_path_length = if max_filepath_length > basepath_length {
max_filepath_length - basepath_length
} else {
0
};
let progress_style = ProgressStyle::with_template(
"[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}",
)?;
let progress_bar = ProgressBar::new(result.len() as u64);
progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("reconciling data");
let duplicates: DashMap<String, Vec<FileInfo>> = DashMap::new();
result
.into_par_iter()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from("data reconciled")))
.map(|file| file.hash())
.filter_map(Result::ok)
.for_each(|file| {
duplicates
.entry(file.hash.clone().unwrap_or_default())
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
});
duplicates
.clone()
.into_iter()
.enumerate()
.for_each(|(gindex, (_, group))| {
let mut itable = Table::new();
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
group.iter().enumerate().for_each(|(index, file)| {
itable.add_row(row![
index,
Formatter::human_path(file, app_args, min_path_length)
.unwrap_or_default()
.blue(),
Formatter::human_filesize(file).unwrap_or_default().red(),
Formatter::human_mtime(file).unwrap_or_default().yellow()
]);
});
process_group_action(&group, gindex, duplicates.len(), itable);
});
Ok(())
}
pub fn process_group_action(
duplicates: &Vec<FileInfo>,
dup_index: usize,
dup_size: usize,
table: Table,
) {
println!("\nDuplicate Set {} of {}\n", dup_index + 1, dup_size);
table.printstd();
let files_to_delete = scan_group_instruction().unwrap_or_default();
let parsed_file_indices = files_to_delete
.trim()
.split(',')
.filter(|element| !element.is_empty())
.map(|index| index.parse::<usize>().unwrap_or_default())
.collect::<Vec<usize>>();
if parsed_file_indices
.clone()
.into_iter()
.any(|index| index > (duplicates.len() - 1))
{
println!("{}", "Err: File Index Out of Bounds!".red());
return process_group_action(duplicates, dup_index, dup_size, table);
}
print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
if parsed_file_indices.is_empty() {
return;
}
let files_to_delete = parsed_file_indices
.into_iter()
.map(|index| duplicates[index].clone());
println!("\n{}", "The following files will be deleted:".red());
files_to_delete
.clone()
.enumerate()
.for_each(|(index, file)| {
println!("{}: {}", index.to_string().blue(), file.path.display());
});
match scan_group_confirmation().unwrap() {
true => {
files_to_delete.into_iter().for_each(|file| {
match std::fs::remove_file(file.path.clone()) {
Ok(_) => println!("{}: {}", "DELETED".green(), file.path.display()),
Err(_) => println!("{}: {}", "FAILED".red(), file.path.display()),
}
});
}
false => println!("{}", "\nCancelled Delete Operation.".red()),
}
}

View File

@@ -1,14 +1,31 @@
mod app; mod fileinfo;
mod file_manager; mod formatter;
mod output; mod interactive;
mod params; mod params;
mod processor;
mod scanner; mod scanner;
mod filters;
use anyhow::Result; use anyhow::Result;
use app::App;
use clap::Parser; use clap::Parser;
use formatter::Formatter;
use params::Params;
use processor::Processor;
use scanner::Scanner;
fn main() -> Result<()> { fn main() -> Result<()> {
App::init(&params::Params::parse()) let app_args = Params::parse();
let scan_results = Scanner::build(&app_args)?.scan()?;
let processor = Processor::new(scan_results);
let results = processor.sizewise()?.hashwise()?;
match app_args.interactive {
false => {
Formatter::print(results.files, &app_args)?;
}
true => {
interactive::init(results.files, &app_args)?;
}
}
Ok(())
} }

View File

@@ -1,217 +0,0 @@
use crate::file_manager::{self, File};
use crate::params::Params;
use anyhow::Result;
use chrono::offset::Utc;
use chrono::DateTime;
use colored::Colorize;
use dashmap::DashMap;
use indicatif::{ProgressBar, ProgressIterator, ProgressStyle};
use itertools::Itertools;
use prettytable::{format, row, Table};
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use std::{fs, io};
use unicode_segmentation::UnicodeSegmentation;
fn format_path(path: &Path, opts: &Params) -> Result<String> {
let display_path = path
.to_string_lossy()
.replace(opts.get_directory()?.to_string_lossy().as_ref(), "");
let display_range = if display_path.chars().count() > 32 {
display_path
.graphemes(true)
.collect::<Vec<&str>>()
.into_iter()
.rev()
.take(32)
.rev()
.collect()
} else {
display_path
};
Ok(format!("...{display_range:<32}"))
}
fn file_size(file: &File) -> Result<String> {
Ok(format!("{:>12}", bytesize::ByteSize::b(file.size.unwrap())))
}
fn modified_time(path: &Path) -> Result<String> {
let mdata = fs::metadata(path)?;
let modified_time: DateTime<Utc> = mdata.modified()?.into();
Ok(modified_time.format("%Y-%m-%d %H:%M:%S").to_string())
}
fn scan_group_instruction() -> Result<String> {
println!("\nEnter the indices of the files you want to delete.");
println!("You can enter multiple files using commas to seperate file indices.");
println!("example: 1,2");
print!("\n> ");
std::io::stdout().flush()?;
let mut user_input = String::new();
io::stdin().read_line(&mut user_input)?;
Ok(user_input)
}
fn scan_group_confirmation() -> Result<bool> {
print!("\nconfirm? [y/N]: ");
std::io::stdout().flush()?;
let mut user_input = String::new();
io::stdin().read_line(&mut user_input)?;
match user_input.trim() {
"Y" | "y" => Ok(true),
_ => Ok(false),
}
}
fn process_group_action(duplicates: &Vec<File>, dup_index: usize, dup_size: usize, table: Table) {
println!("\nDuplicate Set {} of {}\n", dup_index + 1, dup_size);
table.printstd();
let files_to_delete = scan_group_instruction().unwrap_or_default();
let parsed_file_indices = files_to_delete
.trim()
.split(',')
.filter(|element| !element.is_empty())
.map(|index| index.parse::<usize>().unwrap_or_default())
.collect::<Vec<usize>>();
if parsed_file_indices
.clone()
.into_iter()
.any(|index| index > (duplicates.len() - 1))
{
println!("{}", "Err: File Index Out of Bounds!".red());
return process_group_action(duplicates, dup_index, dup_size, table);
}
print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
if parsed_file_indices.is_empty() {
return;
}
let files_to_delete = parsed_file_indices
.into_iter()
.map(|index| duplicates[index].clone());
println!("\n{}", "The following files will be deleted:".red());
files_to_delete
.clone()
.enumerate()
.for_each(|(index, file)| {
println!("{}: {}", index.to_string().blue(), file.path.display());
});
match scan_group_confirmation().unwrap() {
true => {
file_manager::delete_files(files_to_delete.collect::<Vec<File>>()).ok();
}
false => println!("{}", "\nCancelled Delete Operation.".red()),
}
}
pub fn interactive(duplicates: DashMap<String, Vec<File>>, opts: &Params) {
if duplicates.is_empty() {
println!(
"\n{}",
"No duplicates found matching your search criteria.".green()
);
return;
}
duplicates
.clone()
.into_iter()
.sorted_unstable_by_key(|(_, f)| {
-(f.first().and_then(|ff| ff.size).unwrap_or_default() as i64)
}) // sort by descending file size in interactive mode
.enumerate()
.for_each(|(gindex, (_, group))| {
let mut itable = Table::new();
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
group.iter().enumerate().for_each(|(index, file)| {
itable.add_row(row![
index,
format_path(&file.path, opts).unwrap_or_default().blue(),
file_size(file).unwrap_or_default().red(),
modified_time(&file.path).unwrap_or_default().yellow()
]);
});
process_group_action(&group, gindex, duplicates.len(), itable);
});
}
pub fn print(duplicates: DashMap<String, Vec<File>>, opts: &Params) {
if duplicates.is_empty() {
println!(
"\n{}",
"No duplicates found matching your search criteria.".green()
);
return;
}
let mut output_table = Table::new();
let progress_bar = ProgressBar::new(duplicates.len() as u64);
progress_bar.enable_steady_tick(Duration::from_millis(50));
let progress_style = ProgressStyle::default_bar()
.template("{spinner:.green} [generating output] [{wide_bar:.cyan/blue}] {pos}/{len} files")
.unwrap();
progress_bar.set_style(progress_style);
output_table.set_titles(row!["hash", "duplicates"]);
duplicates
.into_iter()
.sorted_unstable_by_key(|(_, f)| f.first().and_then(|ff| ff.size).unwrap_or_default())
.progress_with(progress_bar)
.for_each(|(hash, group)| {
let mut inner_table = Table::new();
inner_table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
group.iter().for_each(|file| {
inner_table.add_row(row![
format_path(&file.path, opts).unwrap_or_default().blue(),
file_size(file).unwrap_or_default().red(),
modified_time(&file.path).unwrap_or_default().yellow()
]);
});
output_table.add_row(row![hash.green(), inner_table]);
});
output_table.printstd();
}
#[allow(unused)]
pub fn raw(duplicates: DashMap<String, Vec<File>>, opts: &Params) -> Result<()> {
if duplicates.is_empty() {
println!(
"\n{}",
"No duplicates found matching your search criteria.".green()
);
return Ok(());
}
duplicates
.into_iter()
.sorted_unstable_by_key(|(_, f)| f.first().and_then(|ff| ff.size).unwrap_or_default())
.for_each(|(_hash, group)| {
group.iter().for_each(|file| {
println!(
"{}\t{}\t{}",
format_path(&file.path, opts).unwrap_or_default().blue(),
file_size(file).unwrap_or_default().red(),
modified_time(&file.path).unwrap_or_default().yellow()
)
});
println!("---");
});
Ok(())
}

View File

@@ -1,10 +1,9 @@
use std::{fs, path::PathBuf}; use std::{fs, path::PathBuf};
use anyhow::{anyhow, Result}; use anyhow::Result;
use clap::{Parser, ValueHint}; use clap::{Parser, ValueHint};
use globwalk::{GlobWalker, GlobWalkerBuilder};
#[derive(Parser, Debug)] #[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
pub struct Params { pub struct Params {
/// Filetypes to deduplicate [default = all] /// Filetypes to deduplicate [default = all]
@@ -28,6 +27,9 @@ pub struct Params {
/// Follow links while scanning directories /// Follow links while scanning directories
#[arg(long, short)] #[arg(long, short)]
pub follow_links: bool, pub follow_links: bool,
/// print json output
#[arg(long)]
pub json: bool,
} }
impl Params { impl Params {
@@ -48,41 +50,7 @@ impl Params {
Ok(dir) Ok(dir)
} }
fn add_glob_min_depth(&self, builder: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> { pub fn get_types(&self) -> Option<String> {
match self.min_depth { self.types.clone()
Some(mindepth) => Ok(builder.min_depth(mindepth)),
None => Ok(builder),
}
}
fn add_glob_max_depth(&self, builder: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
match self.max_depth {
Some(maxdepth) => Ok(builder.max_depth(maxdepth)),
None => Ok(builder),
}
}
fn add_glob_follow_links(&self, builder: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
match self.follow_links {
true => Ok(builder.follow_links(true)),
false => Ok(builder.follow_links(false)),
}
}
pub fn get_glob_walker(&self) -> Result<GlobWalker> {
let pattern: String = match self.types.as_ref() {
Some(filetypes) => format!("**/*{{{filetypes}}}"),
None => "**/*".to_string(),
};
let glob_walker_builder = self
.add_glob_min_depth(GlobWalkerBuilder::from_patterns(
self.get_directory()?,
&[pattern],
))
.and_then(|builder| self.add_glob_max_depth(builder))
.and_then(|builder| self.add_glob_follow_links(builder))?;
glob_walker_builder.build().map_err(|e| anyhow!(e))
} }
} }

107
src/processor.rs Normal file
View File

@@ -0,0 +1,107 @@
use anyhow::Result;
use dashmap::DashMap;
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle, ProgressFinish};
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
use std::{time::Duration, borrow::Cow};
use crate::fileinfo::FileInfo;
#[derive(Debug, Clone)]
pub enum State {
Initial,
SizeWise,
HashWise,
}
#[derive(Debug, Clone)]
pub struct Processor {
pub files: Vec<FileInfo>,
pub state: State,
}
impl Processor {
pub fn new(files: Vec<FileInfo>) -> Self {
Self {
files,
state: State::Initial,
}
}
pub fn hashwise(&self) -> Result<Self> {
if self.files.is_empty() {
return Ok(self.clone());
}
let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")?;
let progress_bar = ProgressBar::new(self.files.len() as u64);
progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("indexing file hashes");
let duplicates_table: DashMap<String, Vec<FileInfo>> = DashMap::new();
self.files
.clone()
.into_par_iter()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from("indexed files hashes")))
.map(|file| file.hash())
.filter_map(Result::ok)
.for_each(|file| {
duplicates_table
.entry(file.hash.clone().unwrap_or_default())
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
});
let files = duplicates_table
.into_read_only()
.values()
.cloned()
.filter(|subfiles| subfiles.len() > 1)
.flatten()
.collect::<Vec<FileInfo>>();
Ok(Self {
files,
state: State::HashWise,
})
}
pub fn sizewise(&self) -> Result<Self> {
if self.files.is_empty() {
return Ok(self.clone());
}
let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")?;
let progress_bar = ProgressBar::new(self.files.len() as u64);
progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("indexing file sizes");
let duplicates_table: DashMap<u64, Vec<FileInfo>> = DashMap::new();
self.files
.clone()
.into_par_iter()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from("indexed files sizes")))
.for_each(|file| {
duplicates_table
.entry(file.size)
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
});
let files = duplicates_table
.into_read_only()
.values()
.cloned()
.filter(|subfiles| subfiles.len() > 1)
.flatten()
.collect::<Vec<FileInfo>>();
Ok(Self {
files,
state: State::SizeWise,
})
}
}

View File

@@ -1,151 +1,173 @@
use crate::{file_manager::File, filters, params::Params}; #![allow(unused)]
use crate::{fileinfo::FileInfo, params::Params};
use anyhow::Result; use anyhow::Result;
use dashmap::DashMap; use indicatif::{ProgressBar, ProgressStyle};
use fxhash::hash64 as hasher; use std::{fs, path::PathBuf, time::Duration};
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressIterator, ProgressStyle};
use memmap2::Mmap;
use rayon::prelude::*;
use std::hash::Hasher;
use std::time::Duration;
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Clone, Copy)] use globwalk::{GlobWalker, GlobWalkerBuilder};
enum IndexCritera {
Size, #[derive(Debug, Clone)]
Hash, pub struct Scanner {
pub directory: Option<PathBuf>,
pub filetypes: Option<String>,
pub min_depth: Option<usize>,
pub max_depth: Option<usize>,
pub min_size: Option<u64>,
pub follow_links: bool,
} }
pub fn duplicates(app_opts: &Params) -> Result<DashMap<String, Vec<File>>> { impl Scanner {
let scan_results = scan(app_opts)?; pub fn new() -> Self {
let size_index_store = index_files(scan_results, IndexCritera::Size)?; Self {
directory: None,
let sizewize_duplicate_files = size_index_store filetypes: None,
.into_par_iter() min_depth: None,
.filter(|(_, files)| files.len() > 1) max_depth: None,
.map(|(_, files)| files) min_size: None,
.flatten() follow_links: true,
.collect::<Vec<File>>(); }
if sizewize_duplicate_files.len() > 1 {
let hash_index_store = index_files(sizewize_duplicate_files, IndexCritera::Hash)?;
let duplicate_files = hash_index_store
.into_par_iter()
.filter(|(_, files)| files.len() > 1)
.collect();
Ok(duplicate_files)
} else {
Ok(DashMap::new())
} }
}
fn scan(app_opts: &Params) -> Result<Vec<File>> { pub fn build(app_args: &Params) -> Result<Self> {
let walker = app_opts.get_glob_walker()?; let scan_directory = app_args.get_directory()?;
let progress = ProgressBar::new_spinner(); Ok(Scanner::new())
let progress_style = .map(|scanner| scanner.directory(scan_directory))
ProgressStyle::with_template("{spinner:.green} [mapping paths] {pos} paths")?; .map(|scanner| match app_args.get_min_size() {
progress.set_style(progress_style); Some(min_size) => scanner.min_size(min_size),
progress.enable_steady_tick(Duration::from_millis(50)); None => scanner,
})
.map(|scanner| match app_args.get_types() {
Some(ftypes) => scanner.filetypes(ftypes),
None => scanner,
})
.map(|scanner| match app_args.min_depth {
Some(min_depth) => scanner.min_depth(min_depth),
None => scanner,
})
.map(|scanner| match app_args.max_depth {
Some(max_depth) => scanner.max_depth(max_depth),
None => scanner,
})
}
let files = walker pub fn min_size(&self, min_size: u64) -> Self {
.progress_with(progress) Self {
.filter_map(Result::ok) min_size: Some(min_size),
.map(|file| file.into_path()) ..self.clone()
.filter(|fpath| fpath.is_file()) }
.collect::<Vec<PathBuf>>(); }
let scan_progress = ProgressBar::new(files.len() as u64); pub fn min_depth(&self, min_depth: usize) -> Self {
let scan_progress_style = ProgressStyle::with_template( Self {
"{spinner:.green} [processing mapped paths] [{wide_bar:.cyan/blue}] {pos}/{len} files", min_depth: Some(min_depth),
)?; ..self.clone()
scan_progress.set_style(scan_progress_style); }
scan_progress.enable_steady_tick(Duration::from_millis(50)); }
let scan_results = files pub fn max_depth(&self, max_depth: usize) -> Self {
.into_par_iter() Self {
.progress_with(scan_progress) max_depth: Some(max_depth),
.map(|fpath| File { ..self.clone()
path: fpath.clone(), }
hash: None, }
size: Some(
fs::metadata(fpath) pub fn directory(&self, dir: PathBuf) -> Self {
.map(|metadata| metadata.len()) Self {
.unwrap_or_default(), directory: Some(dir),
), ..self.clone()
}
}
pub fn filetypes(&self, patterns: String) -> Self {
Self {
filetypes: Some(patterns),
..self.clone()
}
}
pub fn ignore_links(&self) -> Self {
Self {
follow_links: false,
..self.clone()
}
}
pub fn follow_links(&self) -> Self {
Self {
follow_links: true,
..self.clone()
}
}
fn scan_patterns(&self) -> Result<String> {
Ok(match self.filetypes.clone() {
Some(ftypes) => format!("**/*{{{ftypes}}}"),
None => "**/*".to_string(),
}) })
.filter(|file| filters::is_file_gt_min_size(app_opts, file)) }
.collect();
Ok(scan_results) fn scan_dir(&self) -> Result<PathBuf> {
} let scan_dir = match self.directory.clone() {
Some(path) => path,
None => std::env::current_dir()?,
};
fn process_file_index( Ok(fs::canonicalize(scan_dir)?)
mut file: File, }
store: &DashMap<String, Vec<File>>,
index_criteria: IndexCritera, fn attach_link_opts(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
) { Ok(walker.follow_links(self.follow_links))
match index_criteria { }
IndexCritera::Size => {
store fn attach_walker_min_depth(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
.entry(file.size.unwrap_or_default().to_string()) match self.min_depth {
.and_modify(|fileset| fileset.push(file.clone())) Some(min_depth) => Ok(walker.min_depth(min_depth)),
.or_insert_with(|| vec![file]); None => Ok(walker),
}
IndexCritera::Hash => {
file.hash = Some(hash_file(&file.path).unwrap_or_default());
store
.entry(file.clone().hash.unwrap_or_default())
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
} }
} }
}
fn index_files( fn attach_walker_max_depth(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
files: Vec<File>, match self.max_depth {
index_criteria: IndexCritera, Some(max_depth) => Ok(walker.max_depth(max_depth)),
) -> Result<DashMap<String, Vec<File>>> { None => Ok(walker),
let store: DashMap<String, Vec<File>> = DashMap::new(); }
let index_progress = ProgressBar::new(files.len() as u64); }
let index_progress_style = ProgressStyle::with_template( fn build_walker(&self) -> Result<GlobWalker> {
"{spinner:.green} [indexing files] [{wide_bar:.cyan/blue}] {pos}/{len} files", let walker = Ok(GlobWalkerBuilder::from_patterns(
)?; self.scan_dir()?,
index_progress.set_style(index_progress_style); &[self.scan_patterns()?],
index_progress.enable_steady_tick(Duration::from_millis(50)); ))
.and_then(|walker| self.attach_walker_min_depth(walker))
.and_then(|walker| self.attach_walker_max_depth(walker))
.and_then(|walker| self.attach_link_opts(walker))?;
files Ok(walker.build()?)
.into_par_iter() }
.progress_with(index_progress)
.for_each(|file| process_file_index(file, &store, index_criteria));
Ok(store) pub fn scan(&self) -> Result<Vec<FileInfo>> {
} let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {pos:>7} {msg}")?;
let progress_bar = ProgressBar::new_spinner();
progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("paths mapped");
let min_size = self.min_size.unwrap_or_default();
fn incremental_hashing(filepath: &Path) -> Result<String> { let results = self
let file = fs::File::open(filepath)?; .build_walker()?
let fmap = unsafe { Mmap::map(&file)? }; .filter_map(Result::ok)
let mut inchasher = fxhash::FxHasher::default(); .map(|entity| entity.into_path())
.map(|path| {
progress_bar.inc(1);
path
})
.filter(|path| path.is_file())
.map(FileInfo::new)
.filter_map(Result::ok)
.filter(|file| file.size > min_size)
.collect::<Vec<FileInfo>>();
fmap.chunks(1_000_000) progress_bar.finish_with_message("paths mapped");
.for_each(|mega| inchasher.write(mega));
Ok(format!("{}", inchasher.finish())) Ok(results)
}
fn standard_hashing(filepath: &Path) -> Result<String> {
let file = fs::read(filepath)?;
Ok(hasher(&*file).to_string())
}
fn hash_file(filepath: &Path) -> Result<String> {
let filemeta = fs::metadata(filepath)?;
// NOTE: USE INCREMENTAL HASHING ONLY FOR FILES > 100MB
match filemeta.len() < 100_000_000 {
true => standard_hashing(filepath),
false => incremental_hashing(filepath),
} }
} }