83 Commits

Author SHA1 Message Date
sreedevk
9effdc9681 tui improvements 2025-07-11 22:46:20 +00:00
sreedevk
936c631623 added another test for processor 2025-07-11 14:24:21 +00:00
sreedevk
0d62361132 added processor test 2025-07-11 14:22:36 +00:00
sreedevk
b103d7d634 test finally passes 2025-07-11 01:26:59 +00:00
sreedevk
c42538a8c6 removed vfs 2025-07-10 01:19:14 +00:00
sreedevk
05437e0256 added a failing test - need to fix it 2025-07-09 21:49:44 +00:00
sreedevk
5942cf0b6c minor improvements 2025-07-08 20:20:55 +00:00
sreedevk
79c0c2be3d render filequeue 2025-07-07 13:33:24 +00:00
sreedevk
49b46f37f3 reorganization 2025-07-07 11:21:52 +00:00
sreedevk
a37d59308f application wrapper added 2025-07-07 10:52:45 +00:00
sreedevk
8a5b540e40 added application module 2025-07-07 01:49:36 +00:00
sreedevk
5ce0a2774a added server threadpool and message passing 2025-07-07 00:00:25 +00:00
sreedevk
8d32ac5151 fix: proc count decrement added 2025-07-06 13:16:25 +00:00
sreedevk
0c70e27835 ignore .bacon-locations 2025-07-06 12:45:31 +00:00
sreedevk
53282c50df implemented new multi threaded scanner 2025-07-06 12:45:06 +00:00
sreedev
b4bf5d4fb3 docs updated 2024-07-05 04:34:12 +00:00
Sreedev Kodichath
028b868ea9 version 0.2.2 (#59)
* replaced fxhash with gxhash
2024-07-04 23:23:01 -04:00
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
Sreedev Kodichath
7bd88f1642 Merge pull request #49 from sreedevk/development
Version 0.1.6
2023-02-06 09:37:01 -05:00
sreedev
a65e22269c stabilize ui progress bars 2023-02-06 09:35:24 -05:00
sreedev
6e3fe4a37d cargo dist 2023-02-03 10:18:26 -05:00
sreedev
eadffb5fea doc fixes 2023-01-27 09:56:10 -05:00
sreedev
5532ded331 updated benchmarks 2023-01-27 09:54:39 -05:00
sreedev
7119f019d3 benchmark updates 2023-01-27 09:52:14 -05:00
sreedev
493cac1762 upgraded dependencies 2023-01-26 14:17:54 -05:00
sreedev
f0dbf05705 removed tokio & other unused dependencies 2023-01-26 14:07:50 -05:00
sreedev
ef1e9a1fce replace path String in File type with PathBuf 2023-01-26 00:04:17 -05:00
sreedev
d06ca7897d fix critical error 2023-01-25 23:12:52 -05:00
sreedev
b49940998b version 0.1.6 2023-01-25 21:27:31 -05:00
Sreedev Kodichath
ab44d1ed04 Merge pull request #47 from sreedevk/development
Version 0.1.5
2023-01-25 20:35:09 -05:00
sreedev
6b06798e8e updated README.md 2023-01-23 20:15:24 -05:00
sreedev
4d27da99f3 updated README.md 2023-01-23 20:09:15 -05:00
Sreedev Kodichath
91dce29bb4 Merge pull request #46 from sreedevk/feature/add_scan_control_args
[Feature] Add scan control args
2023-01-23 20:06:54 -05:00
sreedev
f9b6d57968 added short params 2023-01-23 20:05:14 -05:00
sreedev
5c86a080c4 Merge branch 'development' into feature/add_scan_control_args 2023-01-23 20:03:30 -05:00
sreedev
9f4d9139b6 added --min-depth --max-depth --follow-links and renamed --minsize to --min-size 2023-01-23 20:03:08 -05:00
Sreedev Kodichath
153109ef57 Merge pull request #45 from sreedevk/feature/make_dir_positional
make --dir a positional argument
2023-01-23 20:02:00 -05:00
sreedev
fa12f85b6a make --dir a positional argument 2023-01-23 19:12:55 -05:00
sreedev
7d66aeef6e version 0.1.5 2023-01-23 19:02:09 -05:00
Sreedev Kodichath
163e8d85c0 Merge pull request #38 from sreedevk/development
Version 0.1.4
2023-01-23 18:35:28 -05:00
sreedev
b1c508e9e7 versioning changes 2023-01-23 18:33:06 -05:00
Sreedev Kodichath
4125b18d67 Merge pull request #42 from sreedevk/feature/add-outputgen-spinner
Added more progress bars to avoid blank states
2023-01-23 18:30:56 -05:00
sreedev
10329015f7 added more progress bars to avoid blank states 2023-01-23 18:29:41 -05:00
Sreedev Kodichath
de6d2e325e Merge pull request #41 from beeb/globwalk-builder
refactor: use globwalk builder pattern
2023-01-23 17:50:09 -05:00
beeb
850c274adc refactor: use globwalk builder pattern 2023-01-23 22:28:48 +01:00
sreedev
31d35aae51 Merge branch 'main' into development 2023-01-23 16:07:26 -05:00
Sreedev Kodichath
087f36ac52 Merge pull request #37 from beeb/globwalk
Switch to globwalk
2023-01-23 16:06:14 -05:00
beeb
6f8b1d55df feat: switch to globwalk 2023-01-23 21:53:44 +01:00
sreedev
c6318a310b added benchmarks 2023-01-23 15:37:34 -05:00
Sreedev Kodichath
76c69ef8a0 Update README.md 2023-01-23 15:14:31 -05:00
sreedev
fd4e882aa8 updated README 2023-01-23 14:54:19 -05:00
sreedev
fd1d214d62 updated github workflows 2023-01-23 03:36:47 -05:00
sreedev
1a573fbf3f release github flow changes 2023-01-23 03:24:12 -05:00
sreedev
b12b662ba1 workflow updates 2023-01-23 03:19:57 -05:00
sreedev
96337b6b48 artifacts upload options added to github workflows 2023-01-23 02:39:37 -05:00
sreedev
02f70aa55f updated release github actions 2023-01-23 02:28:57 -05:00
Sreedev Kodichath
4a18b3fd91 Merge pull request #33 from sreedevk/development
Version 0.1.3
2023-01-23 02:20:59 -05:00
sreedev
876fdc69ac added version changes 2023-01-23 02:20:42 -05:00
sreedev
8acc3f8c8f added github workflow for building binaries with releases 2023-01-23 02:18:23 -05:00
Sreedev Kodichath
38f6c48452 Merge pull request #35 from sreedevk/dependabot/cargo/tokio-1.23.1
build(deps): bump tokio from 1.23.0 to 1.23.1
2023-01-21 19:31:49 -05:00
sreedev
3e1e9c0e2e Merge branch 'main' into development 2023-01-20 21:01:50 -05:00
Sreedev Kodichath
0674ab55bf Update README.md 2023-01-20 21:01:47 -05:00
dependabot[bot]
ccff95dfc5 build(deps): bump tokio from 1.23.0 to 1.23.1
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.23.0 to 1.23.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.23.0...tokio-1.23.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-21 01:57:43 +00:00
Sreedev Kodichath
53d28abb62 Merge pull request #32 from sreedevk/performance/glob_improvements
Performance/glob improvements
2023-01-20 19:17:39 -05:00
Sreedev Kodichath
ea6cf94952 Update README.md 2023-01-19 23:37:13 -05:00
sreedev
7b275d40d0 min opts 2 2023-01-19 23:27:40 -05:00
sreedev
30398c3ca9 min opts 2023-01-19 23:25:16 -05:00
25 changed files with 2375 additions and 897 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
rustflags = ["-C", "target-feature=+aes,+sse2"]

137
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,137 @@
# CI that:
#
# * checks for a Git Tag that looks like a release
# * creates a Github Release™ and fills in its text
# * builds artifacts with cargo-dist (executable-zips, installers)
# * uploads those artifacts to the Github Release™
#
# Note that the Github Release™ will be created before the artifacts,
# so there will be a few minutes where the release has no artifacts
# and then they will slowly trickle in, possibly failing. To make
# this more pleasant we mark the release as a "draft" until all
# artifacts have been successfully uploaded. This allows you to
# choose what to do with partial successes and avoids spamming
# anyone with notifications before the release is actually ready.
name: Release
permissions:
contents: write
# This task will run whenever you push a git tag that looks like a version
# like "v1", "v1.2.0", "v0.1.0-prerelease01", "my-app-v1.0.0", etc.
# 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 PACKAGE_NAME is specified, then we will create a Github Release™ for that
# package (erroring out if it doesn't have the given version or isn't cargo-dist-able).
#
# If PACKAGE_NAME isn't specified, then we will create a Github Release™ for all
# (cargo-dist-able) packages in the workspace with that version (this is mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# 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:
push:
tags:
- '*-?v[0-9]+*'
jobs:
# Create the Github Release™ so the packages have something to be uploaded to
create-release:
runs-on: ubuntu-latest
outputs:
has-releases: ${{ steps.create-release.outputs.has-releases }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v3
- name: Install Rust
run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
- name: Install cargo-dist
run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
- id: create-release
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
upload-artifacts:
# Let the initial task tell us to not run (currently very blunt)
needs: create-release
if: ${{ needs.create-release.outputs.has-releases == 'true' }}
strategy:
matrix:
# For these target platforms
include:
- os: macos-11
dist-args: --artifacts=local --target=aarch64-apple-darwin --target=x86_64-apple-darwin
install-dist: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
- os: ubuntu-20.04
dist-args: --artifacts=local --target=x86_64-unknown-linux-gnu
install-dist: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
- 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.7/cargo-dist-installer.ps1 | iex
runs-on: ${{ matrix.os }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v3
- name: Install Rust
run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
- name: Install cargo-dist
run: ${{ matrix.install-dist }}
- name: Run cargo-dist
# 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!
# 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 commands.
run: |
# Actually do builds and make zips and whatnot
cargo dist build --tag=${{ github.ref_name }} --output-format=json ${{ matrix.dist-args }} > dist-manifest.json
echo "dist ran successfully"
cat dist-manifest.json
# 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..."
cat uploads.txt
gh release upload ${{ github.ref_name }} $(cat uploads.txt)
echo "uploaded!"
# Mark the Github Release™ as a non-draft now that everything has succeeded!
publish-release:
# 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v3
- name: mark release as non-draft
run: |
gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -1,20 +0,0 @@
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
/test_data
/Cargo.lock
/.bacon-locations
/result-bin

1277
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
[package]
name = "deduplicator"
version = "0.1.2"
version = "0.2.2"
edition = "2021"
description = "find,filter,delete Duplicates"
repository = "https://github.com/sreedevk/deduplicator"
license = "MIT"
authors = ["Sreedev Kodichath <sreedevpadmakumar@gmail.com>", "Valentin Bersier <vbersier@gmail.com>", "Dhruva Sagar <dhruva.sagar@gmail.com>"]
authors = [
"Sreedev Kodichath <sreedevpadmakumar@gmail.com>",
"Valentin Bersier <vbersier@gmail.com>",
"Dhruva Sagar <dhruva.sagar@gmail.com>",
]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -15,13 +20,32 @@ chrono = "0.4.23"
clap = { version = "4.0.32", features = ["derive"] }
colored = "2.0.0"
dashmap = { version = "5.4.0", features = ["rayon"] }
fxhash = "0.2.1"
glob = "0.3.0"
indicatif = { version = "0.17.2", features = ["rayon", "tokio"] }
globwalk = "0.8.1"
gxhash = "3.4.1"
indicatif = { version = "0.17.2", features = ["rayon"] }
itertools = "0.10.5"
memmap2 = "0.5.8"
pathdiff = "0.2.1"
prettytable-rs = "0.10.0"
ratatui = "0.29.0"
rayon = "1.6.1"
thiserror = "1.0.38"
tokio = { version = "1.23.0", features = ["full"] }
serde = { version = "1.0.192", features = ["derive"] }
serde_json = "1.0.108"
tempfile = "3.20.0"
threadpool = "1.8.1"
unicode-segmentation = "1.10.0"
uuid = { version = "1.17.0", features = ["v4"] }
[profile.release]
strip = true
# generated by 'cargo dist init'
[profile.dist]
inherits = "release"
lto = "thin"
[workspace.metadata.dist]
rust-toolchain-version = "1.78.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"

148
README.md
View File

@@ -4,48 +4,132 @@
Find, Sort, Filter & Delete duplicate files
</p>
<p align="center">
NOTE: This project is still being developed. At the moment, as shown in the screenshot below, deduplicator is able to scan through and list duplicates with and without caching. Contributions are welcome.
</p>
<h2 align="center">Usage</h2>
## Usage
```bash
Usage: deduplicator [OPTIONS]
Usage: deduplicator [OPTIONS] [scan_dir_path]
Arguments:
[scan_dir_path] Run Deduplicator on dir different from pwd (e.g., ~/Pictures )
Options:
-t, --types <TYPES> Filetypes to deduplicate (default = all)
--dir <DIR> Run Deduplicator on dir different from pwd
-i, --interactive Delete files interactively
-m, --minsize <MINSIZE> Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T). [default = 0]
-h, --help Print help information
-V, --version Print version information
-t, --types <TYPES> Filetypes to deduplicate [default = all]
-i, --interactive Delete files interactively
-s, --min-size <MIN_SIZE> Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T) [default: 1b]
-d, --max-depth <MAX_DEPTH> Max Depth to scan while looking for duplicates
--min-depth <MIN_DEPTH> Min Depth to scan while looking for duplicates
-f, --follow-links Follow links while scanning directories
-h, --help Print help information
-V, --version Print version information
--json
```
### Examples
```bash
# Scan for duplicates recursively from the current dir, only look for png, jpg & pdf file types & interactively delete files
deduplicator -t pdf,jpg,png -i
# Scan for duplicates recursively from the ~/Pictures dir, only look for png, jpeg, jpg & pdf file types & interactively delete files
deduplicator ~/Pictures/ -t png,jpeg,jpg,pdf -i
# Scan for duplicates in the ~/Pictures without recursing into subdirectories
deduplicator ~/Pictures --max-depth 0
# look for duplicates in the ~/.config directory while also recursing into symbolic link paths
deduplicator ~/.config --follow-links
# scan for duplicates that are greater than 100mb in the ~/Media directory
deduplicator ~/Media --min-size 100mb
```
<h2 align="center">Installation</h2>
## Installation
<p align="center">Currently, deduplicator is only installable via rust's cargo package manager</p>
### Cargo Install
#### Stable
> [!WARNING] Note from GxHash: GxHash relies on aes hardware acceleration, you must make sure the aes feature is enabled when building (otherwise it won't build). This can be done by setting the RUSTFLAGS environment variable to -C target-feature=+aes or -C target-cpu=native (the latter should work if your CPU is properly recognized by rustc, which is the case most of the time).
> please install version `0.2.1` if you are unable to install `0.2.2`
```bash
$ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator
```
> [!]
#### Nightly
if you'd like to install with nightly features, you can use
```bash
$ cargo install --git https://github.com/sreedevk/deduplicator
```
Please note that if you use a version manager to install rust (like asdf), you need to reshim (`asdf reshim rust`).
### Linux (Pre-built Binary)
you can download the pre-built binary from the [Releases](https://github.com/sreedevk/deduplicator/releases) page.
download the `deduplicator-x86_64-unknown-linux-gnu.tar.gz` for linux. Once you have the tarball file with the executable,
you can follow these steps to install:
```bash
$ tar -zxvf deduplicator-x86_64-unknown-linux-gnu.tar.gz
$ sudo mv deduplicator /usr/bin/
```
### Mac OS (Pre-built Binary)
you can download the pre-build binary from the [Releases](https://github.com/sreedevk/deduplicator/releases) page.
download the `deduplicator-x86_64-apple-darwin.tar.gz` tarball for mac os. Once you have the tarball file with the executable, you can follow these steps to install:
```bash
$ tar -zxvf deduplicator-x86_64-unknown-linux-gnu.tar.gz
$ sudo mv deduplicator /usr/bin/
```
### Windows (Pre-built Binary)
you can download the pre-build binary from the [Releases](https://github.com/sreedevk/deduplicator/releases) page.
download the `deduplicator-x86_64-pc-windows-msvc.zip` zip file for windows. unzip the `zip` file & move the `deduplicator.exe` to a location in the PATH system environment variable.
Note: If you Run into an msvc error, please install MSCV from [here](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)
## Performance
Deduplicator uses size comparison and fxhash (a non non-cryptographic hashing algo) to quickly scan through large number of files to find duplicates. its also highly parallel (uses rayon and dashmap). I was able to scan through 120GB of files (Videos, PDFs, Images) in ~300ms. checkout the benchmarks
## benchmarks
| Command | Dirsize | Filecount | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|:---|---:|---:|---:|---:|---:|
| `deduplicator ~/Data/tmp` | (~120G) | 721 files | 33.5 ± 28.6 | 25.3 | 151.5 | 1.87 ± 1.60 |
| `deduplicator ~/Data/books` | (~8.6G) | 1419 files | 24.5 ± 1.0 | 22.9 | 28.1 | 1.37 ± 0.08 |
| `deduplicator ~/Data/books --min-size 10M` | (~8.6G) | 1419 files | 17.9 ± 0.7 | 16.8 | 20.0 | 1.00 |
| `deduplicator ~/Data/ --types pdf,jpg,png,jpeg` | (~290G) | 104222 files | 1207.2 ± 37.0 | 1172.2 | 1287.7 | 67.27 ± 3.33 |
* The last entry is lower because of the number of files deduplicator had to go through (~660895 Files). The average size of the files rarely affect the performance of deduplicator.
These benchmarks were run using [hyperfine](https://github.com/sharkdp/hyperfine). Here are the specs of the machine used to benchmark deduplicator:
```
cargo install deduplicator
OS: Arch Linux x86_64
Host: Precision 5540
Kernel: 5.15.89-1-lts
Uptime: 4 hours, 44 mins
Shell: zsh 5.9
Terminal: kitty
CPU: Intel i9-9880H (16) @ 4.800GHz
GPU: NVIDIA Quadro T2000 Mobile / Max-Q
GPU: Intel CoffeeLake-H GT2 [UHD Graphics 630]
Memory: 31731MiB (~32GiB)
```
<p align="center">
note that if you use a version manager to install rust (like asdf), you need to reshim (`asdf reshim rust`).
</p>
<h2 align="center">Performance</h2>
## Screenshots
<p align="center">
Deduplicator uses fxhash (a non-cryptographic hashing algorithm) which is extremely fast. As a result, deduplicator is able to process huge amounts of data in a <del>couple of seconds.</del> few milliseconds.</p>
<p align="center">
<del>While testing, Deduplicator was able to go through 8.6GB of pdf files and detect duplicates in 2.9 seconds</del>
As of version 0.1.1, on testing locally, deduplicator was able to process and find duplicates in 120GB of files (Videos, PDFs, Images) in ~300ms
</p>
<h2 align="center">Screenshots</h2>
<img src="https://user-images.githubusercontent.com/36154121/211948081-63c12b94-6251-487b-a49f-ac5418169d5a.gif" />
<img src="https://user-images.githubusercontent.com/36154121/211458077-90092aa3-496c-492f-a061-618059890d5f.png" />
![](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)
- TUI: Improve Key Handling

View File

@@ -1,17 +1,71 @@
use crate::output;
use crate::params::Params;
use crate::scanner;
use anyhow::Result;
use std::sync::mpsc::channel;
use std::sync::Arc;
pub struct App;
use anyhow::{anyhow, Result};
use threadpool::ThreadPool;
use crate::params::Params;
use crate::server::{Message, Server};
use crate::tui::Tui;
pub struct App {
tpool: ThreadPool,
server: Arc<Server>,
app_opts: Arc<Params>,
}
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),
pub fn new(app_opts: Arc<Params>) -> Self {
Self {
tpool: ThreadPool::new(8),
server: Arc::new(Server::new().expect("server init failed")),
app_opts,
}
}
pub fn start(&self) -> Result<()> {
let (server_tx, server_rx) = channel::<Message>();
let (app_tx, app_rx) = channel::<Message>();
let server_ptr = self.server.clone();
let tui_server_ptr = self.server.clone();
let mut ui = Tui::new(app_tx.clone(), tui_server_ptr);
let root_dir = self
.app_opts
.get_directory()?
.into_os_string()
.into_string()
.map_err(|_| anyhow!("path to str conv failed"))?
.into_boxed_str();
self.tpool.execute(move || {
server_ptr.start(server_rx).expect("server init failed");
});
self.tpool.execute(move || {
ui.start().expect("ui init failed");
});
self.tpool.execute(move || loop {
match app_rx.try_recv() {
Ok(Message::Exit) => {
server_tx
.send(Message::Exit)
.expect("message passing to app from ui failed.");
break;
}
Ok(Message::AddScanDirectory(dir)) => {
server_tx
.send(Message::AddScanDirectory(dir))
.expect("message passing to server failed.");
}
_ => continue,
}
});
app_tx.send(Message::AddScanDirectory(root_dir))?;
self.tpool.join();
Ok(())
}

0
src/cli/mod.rs Normal file
View File

View File

@@ -1,20 +0,0 @@
use anyhow::Result;
use colored::Colorize;
#[derive(Debug, Clone)]
pub struct File {
pub path: String,
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),
Err(_) => println!("{}: {}", "FAILED".red(), file.path)
}
});
Ok(())
}

43
src/fileinfo.rs Normal file
View File

@@ -0,0 +1,43 @@
use anyhow::Result;
use gxhash::GxHasher;
use memmap2::Mmap;
use serde::Serialize;
use std::fs;
use std::hash::Hasher;
use std::{fs::Metadata, path::PathBuf};
#[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 = GxHasher::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_minsize(app_opts: &Params, file: &File) -> bool {
match app_opts.get_minsize() {
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,15 +1,40 @@
mod app;
mod file_manager;
mod output;
mod fileinfo;
mod formatter;
mod interactive;
mod params;
mod processor;
mod scanner;
mod filters;
/* version 2.0 modules*/
mod cli;
mod server;
mod tui;
use anyhow::Result;
use app::App;
use clap::Parser;
#[tokio::main]
async fn main() -> Result<()> {
App::init(&params::Params::parse())
use self::app::App;
use clap::Parser;
use params::Params;
use std::sync::Arc;
// use formatter::Formatter;
// use processor::Processor;
// use scanner::Scanner;
fn main() -> Result<()> {
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)?,
// }
App::new(Arc::new(app_args))
.start()
.expect("app init failed.");
Ok(())
}

View File

@@ -1,182 +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 itertools::Itertools;
use prettytable::{format, row, Table};
use std::io::Write;
use std::{fs, io};
use unicode_segmentation::UnicodeSegmentation;
fn format_path(path: &str, opts: &Params) -> Result<String> {
let display_path = path.replace(&opts.get_directory()?, "");
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!("...{:<32}", display_range))
}
fn file_size(file: &File) -> Result<String> {
Ok(format!("{:>12}", bytesize::ByteSize::b(file.size.unwrap())))
}
fn modified_time(path: &String) -> 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 print_meta_info() {
println!("Deduplicator v{}", std::env!("CARGO_PKG_VERSION"));
}
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();
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);
});
match scan_group_confirmation().unwrap() {
true => {
file_manager::delete_files(files_to_delete.collect_vec()).ok();
}
false => println!("{}", "\nCancelled Delete Operation.".red()),
}
}
pub fn interactive(duplicates: DashMap<String, Vec<File>>, opts: &Params) {
print_meta_info();
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.1.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) {
print_meta_info();
if duplicates.is_empty() {
println!(
"\n{}",
"No duplicates found matching your search criteria.".green()
);
return;
}
let mut output_table = Table::new();
output_table.set_titles(row!["hash", "duplicates"]);
duplicates
.into_iter()
.sorted_unstable_by_key(|f| f.1.first().and_then(|ff| ff.size).unwrap_or_default()) // sort by ascending size
.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();
}

View File

@@ -1,27 +1,40 @@
use anyhow::{anyhow, Result};
use clap::{Parser, ValueHint};
use std::{fs, path::PathBuf};
#[derive(Parser, Debug)]
use anyhow::Result;
use clap::{Parser, ValueHint};
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
pub struct Params {
/// Filetypes to deduplicate (default = all)
/// Filetypes to deduplicate [default = all]
#[arg(short, long)]
pub types: Option<String>,
/// Run Deduplicator on dir different from pwd
#[arg(long, value_hint = ValueHint::DirPath)]
/// Run Deduplicator on dir different from pwd (e.g., ~/Pictures )
#[arg(value_hint = ValueHint::DirPath, value_name = "scan_dir_path")]
pub dir: Option<PathBuf>,
/// Delete files interactively
#[arg(long, short)]
pub interactive: bool,
/// Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T). [default = 0]
/// Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T).
#[arg(long, short = 's', default_value = "1b")]
pub min_size: Option<String>,
/// Max Depth to scan while looking for duplicates
#[arg(long, short = 'd')]
pub max_depth: Option<usize>,
/// Min Depth to scan while looking for duplicates
#[arg(long)]
pub min_depth: Option<usize>,
/// Follow links while scanning directories
#[arg(long, short)]
pub minsize: Option<String>,
pub follow_links: bool,
/// print json output
#[arg(long)]
pub json: bool,
}
impl Params {
pub fn get_minsize(&self) -> Option<u64> {
match &self.minsize {
pub fn get_min_size(&self) -> Option<u64> {
match &self.min_size {
Some(msize) => match msize.parse::<bytesize::ByteSize>() {
Ok(units) => Some(units.0),
Err(_) => None,
@@ -30,34 +43,14 @@ impl Params {
}
}
pub fn get_directory(&self) -> Result<String> {
let dir_pathbuf: PathBuf = self
.dir
.clone()
.unwrap_or(std::env::current_dir()?)
.as_os_str()
.into();
let dir = fs::canonicalize(dir_pathbuf)?
.as_os_str()
.to_str()
.ok_or_else(|| anyhow!("Invalid directory"))?
.to_string();
pub fn get_directory(&self) -> Result<PathBuf> {
let current_dir = std::env::current_dir()?;
let dir_path = self.dir.as_ref().unwrap_or(&current_dir).as_path();
let dir = fs::canonicalize(dir_path)?;
Ok(dir)
}
pub fn get_glob_patterns(&self) -> Vec<PathBuf> {
self.types
.clone()
.unwrap_or_else(|| String::from("*"))
.split(',')
.map(|filetype| format!("*.{}", filetype))
.map(|filetype| {
vec![self.get_directory().unwrap(), String::from("**"), filetype]
.iter()
.collect()
})
.collect()
pub fn get_types(&self) -> Option<String> {
self.types.clone()
}
}

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,142 +1,173 @@
use crate::{file_manager::File, filters, params::Params};
#![allow(unused)]
use crate::{fileinfo::FileInfo, params::Params};
use anyhow::Result;
use dashmap::DashMap;
use fxhash::hash64 as hasher;
use glob::glob;
use indicatif::{ParallelProgressIterator, ProgressStyle};
use memmap2::Mmap;
use rayon::prelude::*;
use std::hash::Hasher;
use std::{fs, path::PathBuf};
use indicatif::{ProgressBar, ProgressStyle};
use std::{fs, path::PathBuf, time::Duration};
#[derive(Clone, Copy)]
enum IndexCritera {
Size,
Hash,
use globwalk::{GlobWalker, GlobWalkerBuilder};
#[derive(Debug, Clone)]
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>>> {
let scan_results = scan(app_opts)?;
let size_index_store = index_files(scan_results, IndexCritera::Size)?;
let sizewize_duplicate_files = size_index_store
.into_par_iter()
.filter(|(_, files)| files.len() > 1)
.map(|(_, files)| files)
.flatten()
.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>> {
let glob_patterns: Vec<PathBuf> = app_opts.get_glob_patterns();
let files: Vec<File> = glob_patterns
.par_iter()
.progress_with_style(ProgressStyle::with_template(
"{spinner:.green} [scanning files] [{wide_bar:.cyan/blue}] {pos}/{len} files",
)?)
.filter_map(|glob_pattern| glob(glob_pattern.as_os_str().to_str()?).ok())
.flat_map(|file_vec| {
file_vec
.filter_map(|x| Some(x.ok()?.as_os_str().to_str()?.to_string()))
.filter(|glob_result| {
fs::metadata(glob_result)
.map(|f| f.is_file())
.unwrap_or(false)
})
.collect::<Vec<String>>()
})
.map(|file_path| File {
path: file_path.clone(),
hash: None,
size: Some(fs::metadata(file_path).unwrap().len()),
})
.filter(|file| filters::is_file_gt_minsize(app_opts, file))
.collect();
Ok(files)
}
fn process_file_hash_index(file: &File) -> Result<File> {
Ok(File {
path: file.path.clone(),
size: file.size,
hash: Some(hash_file(&file.path).unwrap_or_default()),
})
}
fn process_file_index(
file: File,
store: &DashMap<String, Vec<File>>,
index_criteria: IndexCritera,
) {
match index_criteria {
IndexCritera::Size => {
store
.entry(file.size.unwrap_or_default().to_string())
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
}
IndexCritera::Hash => {
let processed_file = process_file_hash_index(&file).unwrap();
let indexhash = processed_file.clone().hash.unwrap_or_default();
store
.entry(indexhash)
.and_modify(|fileset| fileset.push(processed_file.clone()))
.or_insert_with(|| vec![processed_file]);
impl Scanner {
pub fn new() -> Self {
Self {
directory: None,
filetypes: None,
min_depth: None,
max_depth: None,
min_size: None,
follow_links: true,
}
}
}
fn index_files(
files: Vec<File>,
index_criteria: IndexCritera,
) -> Result<DashMap<String, Vec<File>>> {
let store: DashMap<String, Vec<File>> = DashMap::new();
files
.into_par_iter()
.progress_with_style(ProgressStyle::with_template(
"{spinner:.green} [indexing files] [{wide_bar:.cyan/blue}] {pos}/{len} files",
)?)
.for_each(|file| process_file_index(file, &store, index_criteria));
pub fn build(app_args: &Params) -> Result<Self> {
let scan_directory = app_args.get_directory()?;
Ok(Scanner::new())
.map(|scanner| scanner.directory(scan_directory))
.map(|scanner| match app_args.get_min_size() {
Some(min_size) => scanner.min_size(min_size),
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,
})
}
Ok(store)
}
pub fn min_size(&self, min_size: u64) -> Self {
Self {
min_size: Some(min_size),
..self.clone()
}
}
fn incremental_hashing(filepath: &str) -> Result<String> {
let file = fs::File::open(filepath)?;
let fmap = unsafe { Mmap::map(&file)? };
let mut inchasher = fxhash::FxHasher::default();
pub fn min_depth(&self, min_depth: usize) -> Self {
Self {
min_depth: Some(min_depth),
..self.clone()
}
}
fmap.chunks(1_000_000)
.for_each(|mega| inchasher.write(mega));
pub fn max_depth(&self, max_depth: usize) -> Self {
Self {
max_depth: Some(max_depth),
..self.clone()
}
}
Ok(format!("{}", inchasher.finish()))
}
pub fn directory(&self, dir: PathBuf) -> Self {
Self {
directory: Some(dir),
..self.clone()
}
}
fn standard_hashing(filepath: &str) -> Result<String> {
let file = fs::read(filepath)?;
Ok(hasher(&*file).to_string())
}
pub fn filetypes(&self, patterns: String) -> Self {
Self {
filetypes: Some(patterns),
..self.clone()
}
}
fn hash_file(filepath: &str) -> Result<String> {
let filemeta = fs::metadata(filepath)?;
pub fn ignore_links(&self) -> Self {
Self {
follow_links: false,
..self.clone()
}
}
// NOTE: USE INCREMENTAL HASHING ONLY FOR FILES > 100MB
match filemeta.len() < 100_000_000 {
true => standard_hashing(filepath),
false => incremental_hashing(filepath),
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(),
})
}
fn scan_dir(&self) -> Result<PathBuf> {
let scan_dir = match self.directory.clone() {
Some(path) => path,
None => std::env::current_dir()?,
};
Ok(fs::canonicalize(scan_dir)?)
}
fn attach_link_opts(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
Ok(walker.follow_links(self.follow_links))
}
fn attach_walker_min_depth(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
match self.min_depth {
Some(min_depth) => Ok(walker.min_depth(min_depth)),
None => Ok(walker),
}
}
fn attach_walker_max_depth(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
match self.max_depth {
Some(max_depth) => Ok(walker.max_depth(max_depth)),
None => Ok(walker),
}
}
fn build_walker(&self) -> Result<GlobWalker> {
let walker = Ok(GlobWalkerBuilder::from_patterns(
self.scan_dir()?,
&[self.scan_patterns()?],
))
.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))?;
Ok(walker.build()?)
}
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();
let results = self
.build_walker()?
.filter_map(Result::ok)
.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>>();
progress_bar.finish_with_message("paths mapped");
Ok(results)
}
}

44
src/server/file.rs Normal file
View File

@@ -0,0 +1,44 @@
use anyhow::Result;
use std::fs::File;
use std::io::Read;
use std::os::unix::fs::MetadataExt;
use std::sync::Arc;
use uuid::Uuid;
const PARTIAL_SIZE: u64 = 4096;
#[allow(unused)]
#[derive(Clone, Debug)]
pub struct FileMeta {
pub id: Uuid,
pub path: Box<str>,
pub size: u64,
pub modtime: i64,
pub partial: Arc<[u8]>,
pub full_hash: Arc<[u8]>,
}
impl FileMeta {
fn create_partial(path: &str) -> Result<Arc<[u8]>> {
let mut partial_take = File::open(path)?.take(PARTIAL_SIZE);
let mut partial_buffer = Vec::with_capacity(PARTIAL_SIZE as usize);
partial_take.read_to_end(&mut partial_buffer)?;
Ok(Arc::from(partial_buffer))
}
pub fn new(path: Box<str>) -> Result<Self> {
let pstr = path.to_string();
let filemeta = std::fs::metadata(&pstr)?;
let partial = Self::create_partial(&pstr)?;
Ok(Self {
id: Uuid::new_v4(),
size: filemeta.size(),
modtime: filemeta.mtime(),
full_hash: Arc::new([]),
path,
partial,
})
}
}

79
src/server/mod.rs Normal file
View File

@@ -0,0 +1,79 @@
pub mod file;
mod processor;
mod scanner;
mod store;
use anyhow::Result;
use std::sync::mpsc;
use std::sync::mpsc::channel;
use std::sync::Arc;
use std::sync::Mutex;
use threadpool::ThreadPool;
use self::processor::Processor;
use self::scanner::Scanner;
use self::store::Store;
pub type FileQueue = Arc<Mutex<Vec<Box<str>>>>;
pub enum Message {
AddScanDirectory(Box<str>),
Exit,
None,
}
pub struct Server {
pub fq: FileQueue,
pub dupstore: Arc<Store>,
pub tpool: ThreadPool,
}
impl Server {
pub fn new() -> Result<Self> {
Ok(Self {
fq: Arc::new(Mutex::new(vec![])),
dupstore: Arc::new(Store::new()),
tpool: ThreadPool::new(4),
})
}
pub fn start(&self, rx: mpsc::Receiver<Message>) -> Result<()> {
let processor_fq = self.fq.clone();
let processor_store = self.dupstore.clone();
let (processor_tx, processor_rx) = channel::<Message>();
self.tpool.execute(move || {
Processor::new(processor_fq, processor_store, processor_rx)
.process()
.expect("processer execution interrupted.");
});
let scanner_fq = self.fq.clone();
let (scanner_tx, scanner_rx) = channel::<Message>();
self.tpool.execute(move || {
Scanner::new(scanner_fq, scanner_rx)
.index()
.expect("scanner indexing interrupted.");
});
self.tpool.execute(move || loop {
match rx.recv() {
Ok(Message::AddScanDirectory(path)) => {
scanner_tx
.send(Message::AddScanDirectory(path))
.expect("scanner tx message passing failed.");
}
Ok(Message::None) => {}
Ok(Message::Exit) | Err(_) => {
scanner_tx.send(Message::Exit).unwrap_or_default();
processor_tx.send(Message::Exit).unwrap_or_default();
break;
}
};
});
self.tpool.join();
Ok(())
}
}

146
src/server/processor.rs Normal file
View File

@@ -0,0 +1,146 @@
use anyhow::Result;
use std::sync::mpsc::{Receiver, TryRecvError};
use std::sync::Arc;
use super::file::FileMeta;
use super::store::{Index, Store};
use super::{FileQueue, Message};
pub struct Processor {
files: FileQueue,
duplicates: Arc<Store>,
msg_rx: Receiver<Message>,
}
impl Processor {
pub fn new(files: FileQueue, duplicates: Arc<Store>, msg_rx: Receiver<Message>) -> Self {
Self {
files,
duplicates,
msg_rx,
}
}
pub fn process(&self) -> Result<()> {
loop {
match self.msg_rx.try_recv() {
Ok(Message::Exit) => break,
Err(TryRecvError::Empty) => {},
_ => {}
}
let next_file = {
let mut cfiles = self.files.lock().unwrap();
cfiles.pop()
};
match next_file {
None => continue,
Some(file_res) => match FileMeta::new(file_res) {
Err(_) => continue,
Ok(fm) => {
let fm_arc = Arc::new(fm);
self.duplicates
.add(Index::Size(fm_arc.size), fm_arc.clone());
}
},
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use tempfile::TempDir;
use std::fs::File;
use std::io::Write;
#[test]
fn processor_differentiates_files_with_different_sizes() -> Result<()> {
let file_queue = Arc::new(Mutex::new(vec![]));
let (tx, rx) = mpsc::channel::<Message>();
let root = TempDir::new()?;
let store = Arc::new(Store::new());
let fq_c = file_queue.clone();
let store_c = store.clone();
let proc_thread = thread::spawn(move || {
let processor = Processor::new(fq_c, store_c, rx);
processor.process().expect("processor failed");
});
let files =
[("hello.txt", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat"),
("hello_dup.txt", "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum")];
for (filename, content) in files.into_iter() {
let fpath = root.path().join(filename);
let mut tf = File::create(fpath.clone())?;
tf.write_all(content.as_bytes())?;
let mut mfq = file_queue.lock().unwrap();
mfq.push(fpath.into_os_string().into_string().unwrap().into_boxed_str());
}
for _ in 0..10 {
if store.entries().len() < 2 {
thread::sleep(std::time::Duration::from_millis(100));
}
}
tx.send(Message::Exit).expect("unable to send msg to processor");
proc_thread.join().expect("failed to join on thread!");
assert!(store.entries().len() == 2);
Ok(())
}
#[test]
fn processor_groups_files_with_same_size() -> Result<()> {
let file_queue = Arc::new(Mutex::new(vec![]));
let (tx, rx) = mpsc::channel::<Message>();
let root = TempDir::new()?;
let store = Arc::new(Store::new());
let fq_c = file_queue.clone();
let store_c = store.clone();
let proc_thread = thread::spawn(move || {
let processor = Processor::new(fq_c, store_c, rx);
processor.process().expect("processor failed");
});
let files =
[("hello.txt", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum"),
("hello_dup.txt", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum")];
for (filename, content) in files.into_iter() {
let fpath = root.path().join(filename);
let mut tf = File::create(fpath.clone())?;
tf.write_all(content.as_bytes())?;
let mut mfq = file_queue.lock().unwrap();
mfq.push(fpath.into_os_string().into_string().unwrap().into_boxed_str());
}
for _ in 0..10 {
if store.entries().is_empty() {
thread::sleep(std::time::Duration::from_millis(100));
}
}
tx.send(Message::Exit).expect("unable to send msg to processor");
proc_thread.join().expect("failed to join on thread!");
assert!(store.entries().len() == 1);
Ok(())
}
}

128
src/server/scanner.rs Normal file
View File

@@ -0,0 +1,128 @@
use super::{FileQueue, Message};
use anyhow::Result;
use std::fs;
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::sync::Mutex;
#[derive(Debug)]
pub struct Scanner {
files: FileQueue,
proc_queue: FileQueue,
msg_rx: Receiver<Message>,
}
impl Scanner {
pub fn new(fq: FileQueue, rx: Receiver<Message>) -> Self {
Self {
files: fq,
proc_queue: Arc::new(Mutex::new(vec![])),
msg_rx: rx,
}
}
pub fn index(&self) -> Result<()> {
loop {
match self.msg_rx.try_recv() {
Ok(Message::AddScanDirectory(path)) => {
let mut mfq = self.proc_queue.lock().unwrap();
mfq.push(path);
}
Err(mpsc::TryRecvError::Empty) | Ok(Message::None) => {}
Ok(Message::Exit) | Err(_) => break,
}
let npath = {
match self.proc_queue.try_lock() {
Ok(mut q) => q.pop(),
Err(_) => None,
}
};
match npath {
None => continue,
Some(path) => {
std::fs::read_dir(path.as_ref())?
.filter_map(Result::ok)
.for_each(|entry: fs::DirEntry| {
let mdata =
fs::metadata(entry.path()).expect("unable to read file metadata.");
let mpath = entry
.path()
.into_os_string()
.into_string()
.expect("invalid path conversion failed.")
.into_boxed_str();
match mdata.is_dir() {
true => {
let mut pq = self
.proc_queue
.lock()
.expect("proc queue lock acq failed.");
pq.push(mpath);
}
false => {
let mut fq = self
.files
.lock()
.expect("file queue lock acq failed.");
fq.push(mpath)
}
}
});
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::fs::File;
use std::io::Write;
use std::sync::mpsc::channel;
use tempfile::TempDir;
use std::thread;
#[test]
fn scanner_scans_files() -> Result<()> {
let files =
[("hello.txt", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum"),
("hello_dup.txt", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum")];
let file_queue: FileQueue = Arc::new(Mutex::new(vec![]));
let (tx, rx) = channel::<Message>();
let root = TempDir::new()?;
for (filename, content) in files.into_iter() {
let mut tf = File::create(root.path().join(filename))?;
tf.write_all(content.as_bytes())?;
}
let rpath = root.path().to_str().unwrap().to_string().into_boxed_str();
let fq_clone = file_queue.clone();
let scanner_thread = thread::spawn(move || {
let scanner = Scanner::new(fq_clone, rx);
scanner.index().unwrap();
});
tx.send(Message::AddScanDirectory(rpath))?;
tx.send(Message::Exit)?;
scanner_thread.join().unwrap();
let fq_len = {
let v = file_queue.lock().unwrap();
v.len()
};
assert_eq!(files.len(), fq_len);
Ok(())
}
}

35
src/server/store.rs Normal file
View File

@@ -0,0 +1,35 @@
use super::file::FileMeta;
use dashmap::DashMap;
use std::sync::Arc;
#[allow(unused)]
#[derive(Debug, Hash, PartialEq, Eq)]
pub enum Index {
Size(u64),
Partial(Arc<[u8]>),
Full(Box<str>),
}
#[derive(Debug)]
pub struct Store {
internal: Arc<DashMap<Index, Vec<Arc<FileMeta>>>>,
}
impl Store {
pub fn new() -> Self {
Self {
internal: Arc::new(DashMap::new()),
}
}
pub fn entries(&self) -> Arc<DashMap<Index, Vec<Arc<FileMeta>>>> {
self.internal.clone()
}
pub fn add(&self, index: Index, file: Arc<FileMeta>) {
self.internal
.entry(index)
.and_modify(|fg| fg.push(file.clone()))
.or_insert(vec![file]);
}
}

99
src/tui/mod.rs Normal file
View File

@@ -0,0 +1,99 @@
use std::sync::mpsc::Sender;
use std::time::Duration;
use anyhow::{Context, Result};
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use crate::server::{Message, Server};
use std::sync::Arc;
pub struct Tui {
app_tx: Sender<Message>,
server: Arc<Server>,
}
impl Tui {
pub fn new(app_tx: Sender<Message>, server: Arc<Server>) -> Self {
Self { app_tx, server }
}
pub fn start(&mut self) -> Result<()> {
let terminal = ratatui::init();
self.run(terminal).expect("ui loop failed.");
ratatui::restore();
Ok(())
}
fn poll_events() -> Result<Message> {
match event::poll(Duration::from_millis(100)).context("event polling failed.")? {
true => match event::read().context("event read failed.")? {
Event::Key(key) => match key.code {
KeyCode::Char('q') => Ok(Message::Exit),
_ => Ok(Message::None),
},
_ => Ok(Message::None),
},
false => Ok(Message::None),
}
}
fn handle_events(&self) -> Result<Message> {
match Self::poll_events() {
Ok(Message::Exit) => {
self.app_tx
.send(Message::Exit)
.expect("app event send failed.");
Ok(Message::Exit)
}
_ => Ok(Message::None),
}
}
fn draw(&mut self, frame: &mut Frame) {
let listitems = self
.server
.dupstore
.entries()
.iter()
.flat_map(|val| {
let mut group = vec![ListItem::new(format!("{:?}", val.key()))];
val.value().iter().for_each(|f| {
group.push(ListItem::new(format!("|-{}", f.path)));
});
group
})
.collect::<Vec<ListItem>>();
let list = List::new(listitems.clone());
let layout_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(frame.area());
frame.render_widget(
list.block(Block::new().borders(Borders::ALL)),
layout_chunks[0],
);
frame.render_widget(
Paragraph::new(String::new()).block(Block::new().borders(Borders::ALL)),
layout_chunks[1],
);
}
fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
loop {
terminal.draw(|f| self.draw(f))?;
match self.handle_events() {
Ok(Message::Exit) => break,
_ => {}
}
}
Ok(())
}
}