mirror of
https://github.com/sreedevk/deduplicator.git
synced 2026-08-26 18:15:33 +00:00
Compare commits
26 Commits
experiment
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245e910c39 | ||
|
|
312d66a5e1 | ||
|
|
d102d92bfa | ||
|
|
68c2491fd0 | ||
|
|
da24efbb36 | ||
|
|
2191763b4d | ||
|
|
b84510f18e | ||
|
|
bfb89edd16 | ||
|
|
21f0eb3cb0 | ||
|
|
f5d2d4e22c | ||
|
|
360af25318 | ||
|
|
d06fe93171 | ||
|
|
6a24ca5ecf | ||
|
|
6f23a51a53 | ||
|
|
4894d1b4db | ||
|
|
c0286b17cc | ||
|
|
e78dfc4211 | ||
|
|
66f49a9aee | ||
|
|
711eb36fb9 | ||
|
|
130a8f99ba | ||
|
|
d97af6b8f6 | ||
|
|
90198502a3 | ||
|
|
e1b81b9981 | ||
|
|
48e7c62036 | ||
|
|
58e33cc8da | ||
|
|
f9e86f8522 |
208
.github/workflows/release.yml
vendored
208
.github/workflows/release.yml
vendored
@@ -1,137 +1,121 @@
|
|||||||
# CI that:
|
name: Deduplicator Release Build CI Pipeline
|
||||||
#
|
|
||||||
# * 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*-?v[0-9]+*'
|
- 'v*.*.*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Create the Github Release™ so the packages have something to be uploaded to
|
lint_test:
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
has-releases: ${{ steps.create-release.outputs.has-releases }}
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
RUSTFLAGS: "-C target-feature=+aes,+sse2"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
|
uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Install cargo-dist
|
with:
|
||||||
run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
|
components: clippy
|
||||||
- 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
|
- name: Run cargo check
|
||||||
ANNOUNCEMENT_TITLE=$(jq --raw-output ".announcement_title" dist-manifest.json)
|
run: cargo check
|
||||||
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™
|
- name: Run cargo clippy
|
||||||
gh release upload ${{ github.ref_name }} dist-manifest.json
|
run: cargo clippy -- -D warnings
|
||||||
echo "uploaded manifest!"
|
|
||||||
|
|
||||||
# Disable all the upload-artifacts tasks if we have no actual releases
|
- name: Run tests
|
||||||
HAS_RELEASES=$(jq --raw-output ".releases != null" dist-manifest.json)
|
run: cargo test -- --test-threads=1
|
||||||
echo "has-releases=$HAS_RELEASES" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Build and packages all the things
|
build:
|
||||||
upload-artifacts:
|
needs: lint_test
|
||||||
# Let the initial task tell us to not run (currently very blunt)
|
runs-on: ${{ matrix.os }}
|
||||||
needs: create-release
|
|
||||||
if: ${{ needs.create-release.outputs.has-releases == 'true' }}
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
# For these target platforms
|
|
||||||
include:
|
include:
|
||||||
- os: macos-11
|
- target: aarch64-unknown-linux-gnu
|
||||||
dist-args: --artifacts=local --target=aarch64-apple-darwin --target=x86_64-apple-darwin
|
os: ubuntu-latest
|
||||||
install-dist: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
|
artifact_name: linux-aarch64
|
||||||
- os: ubuntu-20.04
|
rustflags: "-C target-feature=+aes,+neon"
|
||||||
dist-args: --artifacts=local --target=x86_64-unknown-linux-gnu
|
- 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: ubuntu-latest
|
||||||
- os: windows-2019
|
artifact_name: linux-amd64
|
||||||
dist-args: --artifacts=local --target=x86_64-pc-windows-msvc
|
rustflags: "-C target-feature=+aes,+sse2"
|
||||||
install-dist: irm https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.ps1 | iex
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-14
|
||||||
|
artifact_name: macos-amd64
|
||||||
|
rustflags: "-C target-feature=+aes,+sse2"
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-14
|
||||||
|
artifact_name: macos-arm64
|
||||||
|
rustflags: "-C target-feature=+aes,+neon"
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
|
uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Install cargo-dist
|
|
||||||
run: ${{ matrix.install-dist }}
|
- name: Add target
|
||||||
- name: Run cargo-dist
|
run: rustup target add ${{ matrix.target }}
|
||||||
# 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!
|
- name: Install cross-compilation toolchain (Linux ARM only)
|
||||||
# The two platforms don't agree on how to talk about env vars but they
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
# do agree on 'cat' and '$()' so we use that to marshal values between commands.
|
|
||||||
run: |
|
run: |
|
||||||
# Actually do builds and make zips and whatnot
|
sudo apt-get update
|
||||||
cargo dist build --tag=${{ github.ref_name }} --output-format=json ${{ matrix.dist-args }} > dist-manifest.json
|
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
echo "dist ran successfully"
|
|
||||||
cat dist-manifest.json
|
|
||||||
|
|
||||||
# Parse out what we just built and upload it to the Github Release™
|
- name: Build release binary
|
||||||
jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json > uploads.txt
|
env:
|
||||||
echo "uploading..."
|
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||||
cat uploads.txt
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }}
|
||||||
gh release upload ${{ github.ref_name }} $(cat uploads.txt)
|
run: |
|
||||||
echo "uploaded!"
|
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
|
||||||
|
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||||
|
fi
|
||||||
|
cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
# Mark the Github Release™ as a non-draft now that everything has succeeded!
|
- name: Package binary
|
||||||
publish-release:
|
run: |
|
||||||
# Only run after all the other tasks, but it's ok if upload-artifacts was skipped
|
if [[ "${{ matrix.os }}" == macos* ]]; then
|
||||||
needs: [create-release, upload-artifacts]
|
BINARY_NAME=$(find target/${{ matrix.target }}/release -maxdepth 1 -type f -perm +111 -print0 | xargs -0 basename | head -1)
|
||||||
if: ${{ always() && needs.create-release.result == 'success' && (needs.upload-artifacts.result == 'skipped' || needs.upload-artifacts.result == 'success') }}
|
else
|
||||||
|
BINARY_NAME=$(find target/${{ matrix.target }}/release -maxdepth 1 -type f -executable -print0 | xargs -0 basename | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Packaging binary: $BINARY_NAME"
|
||||||
|
mkdir -p release
|
||||||
|
cp target/${{ matrix.target }}/release/$BINARY_NAME release/$BINARY_NAME
|
||||||
|
tar -C release -czf ${{ matrix.artifact_name }}.tar.gz $BINARY_NAME
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.artifact_name }}-binary
|
||||||
|
path: ${{ matrix.artifact_name }}.tar.gz
|
||||||
|
|
||||||
|
create_release:
|
||||||
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Checkout repository
|
||||||
- name: mark release as non-draft
|
uses: actions/checkout@v4
|
||||||
run: |
|
|
||||||
gh release edit ${{ github.ref_name }} --draft=false
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
id: create_release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: Release ${{ github.ref_name }}
|
||||||
|
body: "Automated release for version ${{ github.ref_name }}"
|
||||||
|
files: |
|
||||||
|
artifacts/*/*.tar.gz
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/Cargo.lock
|
/Cargo.lock
|
||||||
/.bacon-locations
|
|
||||||
/result-bin
|
/result-bin
|
||||||
|
/.bacon-locations
|
||||||
|
|||||||
903
Cargo.lock
generated
903
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
54
Cargo.toml
54
Cargo.toml
@@ -1,51 +1,59 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "deduplicator"
|
name = "deduplicator"
|
||||||
version = "0.2.2"
|
version = "0.3.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "find,filter,delete Duplicates"
|
description = "find,filter and delete duplicate files"
|
||||||
repository = "https://github.com/sreedevk/deduplicator"
|
repository = "https://github.com/sreedevk/deduplicator"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = [
|
authors = [
|
||||||
"Sreedev Kodichath <sreedevpadmakumar@gmail.com>",
|
"Sreedev Kodichath <sreedevpadmakumar@gmail.com>",
|
||||||
"Valentin Bersier <vbersier@gmail.com>",
|
"Valentin Bersier <vbersier@gmail.com>",
|
||||||
"Dhruva Sagar <dhruva.sagar@gmail.com>",
|
"Dhruva Sagar <dhruva.sagar@gmail.com>",
|
||||||
]
|
]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
[[bin]]
|
||||||
|
name = "deduplicator"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.68"
|
anyhow = "1.0.68"
|
||||||
bytesize = "1.1.0"
|
bytesize = "2.0.1"
|
||||||
chrono = "0.4.23"
|
chrono = "0.4.23"
|
||||||
clap = { version = "4.0.32", features = ["derive"] }
|
clap = { version = "4.0.32", features = ["derive"] }
|
||||||
colored = "2.0.0"
|
dashmap = { version = "6.1.0", features = ["rayon"] }
|
||||||
dashmap = { version = "5.4.0", features = ["rayon"] }
|
globwalk = "0.9.1"
|
||||||
globwalk = "0.8.1"
|
gxhash = { version = "3.4.1", default-features = false }
|
||||||
gxhash = "3.4.1"
|
indicatif = { version = "0.18.0", features = ["rayon"] }
|
||||||
indicatif = { version = "0.17.2", features = ["rayon"] }
|
memmap2 = "0.9.7"
|
||||||
itertools = "0.10.5"
|
|
||||||
memmap2 = "0.5.8"
|
|
||||||
pathdiff = "0.2.1"
|
pathdiff = "0.2.1"
|
||||||
prettytable-rs = "0.10.0"
|
prettytable-rs = "0.10.0"
|
||||||
ratatui = "0.29.0"
|
rand = "0.9.1"
|
||||||
rayon = "1.6.1"
|
rayon = "1.6.1"
|
||||||
serde = { version = "1.0.192", features = ["derive"] }
|
|
||||||
serde_json = "1.0.108"
|
|
||||||
tempfile = "3.20.0"
|
|
||||||
threadpool = "1.8.1"
|
threadpool = "1.8.1"
|
||||||
unicode-segmentation = "1.10.0"
|
unicode-segmentation = "1.12.0"
|
||||||
uuid = { version = "1.17.0", features = ["v4"] }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
debug = false
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
# generated by 'cargo dist init'
|
# generated by 'cargo dist init'
|
||||||
[profile.dist]
|
[profile.dist]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
lto = "thin"
|
|
||||||
|
|
||||||
[workspace.metadata.dist]
|
[workspace.metadata.dist]
|
||||||
rust-toolchain-version = "1.78.0"
|
rust-toolchain-version = "1.87.0"
|
||||||
ci = ["github"]
|
ci = ["github"]
|
||||||
targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
|
targets = [
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
"x86_64-pc-windows-msvc",
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
]
|
||||||
cargo-dist-version = "0.0.7"
|
cargo-dist-version = "0.0.7"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.20.0"
|
||||||
|
|||||||
237
README.md
237
README.md
@@ -4,24 +4,31 @@
|
|||||||
Find, Sort, Filter & Delete duplicate files
|
Find, Sort, Filter & Delete duplicate files
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This project is maintained with the assistance of AI tools. All changes are subject to manual review and a comprehensive test suite to ensure stability and quality.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
find,filter and delete duplicate files
|
||||||
|
|
||||||
Usage: deduplicator [OPTIONS] [scan_dir_path]
|
Usage: deduplicator [OPTIONS] [scan_dir_path]
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
[scan_dir_path] Run Deduplicator on dir different from pwd (e.g., ~/Pictures )
|
[scan_dir_path] Run Deduplicator on dir different from pwd (e.g., ~/Pictures )
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-t, --types <TYPES> Filetypes to deduplicate [default = all]
|
-T, --exclude-types <EXCLUDE_TYPES> Exclude Filetypes [default = none]
|
||||||
-i, --interactive Delete files interactively
|
-t, --types <TYPES> Filetypes to deduplicate [default = all]
|
||||||
-s, --min-size <MIN_SIZE> Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T) [default: 1b]
|
-i, --interactive Delete files interactively
|
||||||
-d, --max-depth <MAX_DEPTH> Max Depth to scan while looking for duplicates
|
-m, --min-size <MIN_SIZE> Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T) [default: 1b]
|
||||||
--min-depth <MIN_DEPTH> Min Depth to scan while looking for duplicates
|
-D, --max-depth <MAX_DEPTH> Max Depth to scan while looking for duplicates
|
||||||
-f, --follow-links Follow links while scanning directories
|
-d, --min-depth <MIN_DEPTH> Min Depth to scan while looking for duplicates
|
||||||
-h, --help Print help information
|
-f, --follow-links Follow links while scanning directories
|
||||||
-V, --version Print version information
|
-s, --strict Guarantees that two files are duplicate (performs a full hash)
|
||||||
--json
|
-p, --progress Show Progress spinners & metrics
|
||||||
|
-h, --help Print help
|
||||||
|
-V, --version Print version
|
||||||
```
|
```
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
@@ -29,6 +36,9 @@ Options:
|
|||||||
# Scan for duplicates recursively from the current dir, only look for png, jpg & pdf file types & interactively delete files
|
# 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
|
deduplicator -t pdf,jpg,png -i
|
||||||
|
|
||||||
|
# Scan for duplicates recursively from current dir, exclude png and jpg file types
|
||||||
|
deduplicator -T jpg,png
|
||||||
|
|
||||||
# Scan for duplicates recursively from the ~/Pictures dir, only look for png, jpeg, jpg & pdf file types & interactively delete files
|
# 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
|
deduplicator ~/Pictures/ -t png,jpeg,jpg,pdf -i
|
||||||
|
|
||||||
@@ -42,94 +52,165 @@ deduplicator ~/.config --follow-links
|
|||||||
deduplicator ~/Media --min-size 100mb
|
deduplicator ~/Media --min-size 100mb
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Demo
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
Currently, you can only install deduplicator using cargo package manager.
|
||||||
|
|
||||||
### Cargo Install
|
### Cargo
|
||||||
|
> GxHash relies on aes hardware acceleration, so please set `RUSTFLAGS` to `"-C target-feature=+aes"` or `"-C target-cpu=native"` before
|
||||||
|
> installing.
|
||||||
|
|
||||||
#### Stable
|
#### install from crates.io (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
|
```bash
|
||||||
$ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator
|
$ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator
|
||||||
|
|
||||||
|
# or
|
||||||
|
|
||||||
|
$ RUSTFLAGS="-C target-feature=+aes,+sse2" cargo install deduplicator
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!]
|
#### install from git (nightly)
|
||||||
|
|
||||||
#### Nightly
|
|
||||||
|
|
||||||
if you'd like to install with nightly features, you can use
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cargo install --git https://github.com/sreedevk/deduplicator
|
$ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator --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)
|
# or
|
||||||
|
|
||||||
you can download the pre-built binary from the [Releases](https://github.com/sreedevk/deduplicator/releases) page.
|
$ RUSTFLAGS="-C target-feature=+aes,+sse2" cargo install --git https://github.com/sreedevk/deduplicator
|
||||||
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)
|
### Manual Installation
|
||||||
|
- Download the right pre-compiled binary archive for your platform from [github release page](https://github.com/sreedevk/deduplicator/releases/tag/latest).
|
||||||
you can download the pre-build binary from the [Releases](https://github.com/sreedevk/deduplicator/releases) page.
|
- Decompress it using `tar -zxvf <archive>.tar.gz`
|
||||||
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:
|
- Move it to a directory included in `$PATH`.
|
||||||
|
- ideally `/usr/local/bin/`.
|
||||||
```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
|
## Performance
|
||||||
|
Deduplicator uses size comparison and [GxHash](https://docs.rs/gxhash/latest/gxhash/) to quickly check a large number of files to find duplicates. its also heavily parallelized. The default behavior of deduplicator is to only hash the first page (4K) of the file. This is to ensure that performance is the default priority. You can modify this behavior by using the `--strict` flag which will hash the whole file and ensure that 2 files are indeed duplicates. I'll add benchmarks in future versions.
|
||||||
|
|
||||||
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
|
||||||
|
I've used hyperfine to run deduplicator on files generated by the rake file at `rakelib/benchmark.rake`. The Benchmarking accuracy can further be improved by isolating runs inside restricted docker containers. I'll include that in the future. For now, here's the hyperfine output on my i7-12800H laptop with 32G of RAM.
|
||||||
## 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:
|
|
||||||
|
|
||||||
|
#### Fewer Large Files
|
||||||
```
|
```
|
||||||
OS: Arch Linux x86_64
|
# hyperfine -N --warmup 80 './target/release/deduplicator bench_artifacts'
|
||||||
Host: Precision 5540
|
Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||||
Kernel: 5.15.89-1-lts
|
Time (mean ± σ): 2.2 ms ± 0.4 ms [User: 2.2 ms, System: 4.4 ms]
|
||||||
Uptime: 4 hours, 44 mins
|
Range (min … max): 1.3 ms … 7.1 ms 1522 runs
|
||||||
Shell: zsh 5.9
|
|
||||||
Terminal: kitty
|
dust 'bench_artifacts'
|
||||||
CPU: Intel i9-9880H (16) @ 4.800GHz
|
|
||||||
GPU: NVIDIA Quadro T2000 Mobile / Max-Q
|
54M ┌── file_0_fwds.bin │████ │ 2%
|
||||||
GPU: Intel CoffeeLake-H GT2 [UHD Graphics 630]
|
122M ├── file_1_fwds.bin │████████ │ 5%
|
||||||
Memory: 31731MiB (~32GiB)
|
390M ├── file_0_fwdcbss.bin│██████████████████████████ │ 15%
|
||||||
|
390M ├── file_0_fwscas.bin │██████████████████████████ │ 15%
|
||||||
|
390M ├── file_0_fwss.bin │██████████████████████████ │ 15%
|
||||||
|
390M ├── file_1_fwdcbss.bin│██████████████████████████ │ 15%
|
||||||
|
390M ├── file_1_fwscas.bin │██████████████████████████ │ 15%
|
||||||
|
390M ├── file_1_fwss.bin │██████████████████████████ │ 15%
|
||||||
|
2.5G ┌─┴ bench_artifacts │██████████████████████████████████████████████████████████████████ │ 100%
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
#### Many Small Files
|
||||||
|
```
|
||||||
|
# hyperfine --warmup 20 './target/release/deduplicator bench_artifacts'
|
||||||
|
Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||||
|
Time (mean ± σ): 40.1 ms ± 2.3 ms [User: 251.0 ms, System: 277.3 ms]
|
||||||
|
Range (min … max): 35.0 ms … 45.9 ms 72 runs
|
||||||
|
|
||||||

|
dust 'bench_artifacts'
|
||||||
|
3.9M ┌── file_992_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_992_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_993_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_993_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_993_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_994_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_994_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_994_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_995_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_995_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_995_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_996_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_996_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_996_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_997_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_997_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_997_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_998_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_998_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_998_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_999_fwdcbss.bin│█ │ 0%
|
||||||
|
3.9M ├── file_999_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_999_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_99_fwdcbss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_99_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_99_fwss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_9_fwdcbss.bin │█ │ 0%
|
||||||
|
3.9M ├── file_9_fwscas.bin │█ │ 0%
|
||||||
|
3.9M ├── file_9_fwss.bin │█ │ 0%
|
||||||
|
11G ┌─┴ bench_artifacts │████████████████████████████████████████████████████████████████ │ 100%
|
||||||
|
```
|
||||||
|
|
||||||
## Roadmap
|
## proposed
|
||||||
- Tree format output for duplicate file listing
|
- [ ] parallelization
|
||||||
- GUI
|
- [ ] scanning + processing sw + processing hw + formatting + printing
|
||||||
- Packages for different operating system repositories (currently only installable via cargo)
|
- [ ] user supplied cache file path for faster re-runs
|
||||||
- TUI: Improve Key Handling
|
- [ ] hardlinks / symlinks support
|
||||||
|
- [ ] max file path size should use the last set of duplicates
|
||||||
|
- [ ] add more unit tests
|
||||||
|
- [ ] test against different filesystems
|
||||||
|
- [ ] test against different file name encodings
|
||||||
|
- [ ] restore json output (was removed in 0.3 due to quality issues)
|
||||||
|
- [ ] fix memory leak on very large filesystems
|
||||||
|
- [ ] maybe use a bloom filter
|
||||||
|
- [ ] reduce FileInfo size
|
||||||
|
- [ ] tui
|
||||||
|
- [ ] change the default hashing method to include the first & last page of a file (8K)
|
||||||
|
- [ ] provide option to localize duplicate detection to arbitrary levels relative to current directory
|
||||||
|
- [ ] localize file meta store locks to sub path levels to avoid global lock contention from multiple threads.
|
||||||
|
- [ ] bulk operations
|
||||||
|
- [ ] --keep-latest
|
||||||
|
- [ ] --keep-oldest
|
||||||
|
- [ ] --keep-last-modified
|
||||||
|
- [ ] --keep-first-modified
|
||||||
|
|
||||||
|
- [ ] fix: partial hash collision - a file full of null bytes ("\0") and an empty file. This is a known trade off in gxhash.
|
||||||
|
- [ ] include initial pages and final pages of the file
|
||||||
|
- [ ] append the offset between the last initial page hashed and the first final page hashed in the content passed to the hasher.
|
||||||
|
- [ ] potential optimizations
|
||||||
|
- [ ] lookup the memory efficiency gains if instead of directly inserting into a hashmap, deduplicator looksup the file in a bloom filter. this way, the duplicate store hashmap does
|
||||||
|
not require to be locked for every single file. The bloom filter can be stored in an atomically updateable type to improve performance as well.
|
||||||
|
|
||||||
|
## v0.3.2
|
||||||
|
- [x] fix: single file groups are printed to screen
|
||||||
|
- [x] fix: --exclude-types and --types flag behave identically.
|
||||||
|
|
||||||
|
## v0.3.1
|
||||||
|
- [x] parallelization
|
||||||
|
- [x] (scanning + processing sw + processing hw) & formatting & printing
|
||||||
|
- [x] remove formatting step and write directly to stdout
|
||||||
|
- [x] simplify output to improve performance
|
||||||
|
- [x] increase the number of pages hashed in partial hashing
|
||||||
|
- [x] updated dependencies
|
||||||
|
- [x] fix: full file hash collision between a file full of null bytes ("\0") and an empty file. This is a known trade off in gxhash.
|
||||||
|
- [x] appending the file size at the end of content before hashing.
|
||||||
|
- [x] created an automated release pipeline
|
||||||
|
|
||||||
|
## v0.3.0
|
||||||
|
- [x] parallelization
|
||||||
|
- [x] (scanning) + (processing sw & processing hw & formatting & printing)
|
||||||
|
- [x] reduce cloning values on the heap
|
||||||
|
- [x] add a partial hashing mode (--strict)
|
||||||
|
- [x] add unit tests
|
||||||
|
- [x] add silent mode
|
||||||
|
- [x] update documentation
|
||||||
|
- [x] remove color output
|
||||||
|
- [x] progress bar improvements
|
||||||
|
- [x] use progress bar groups
|
||||||
|
- [x] remove broken json rendering
|
||||||
|
- [x] add benchmarks
|
||||||
|
|||||||
102
rakelib/benchmark.rake
Normal file
102
rakelib/benchmark.rake
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
require 'tempfile'
|
||||||
|
require 'fileutils'
|
||||||
|
require 'securerandom'
|
||||||
|
|
||||||
|
namespace :benchmark do
|
||||||
|
file 'target/release/deduplicator' do
|
||||||
|
sh "cargo build --release"
|
||||||
|
end
|
||||||
|
|
||||||
|
task :few_large_files => 'target/release/deduplicator' do
|
||||||
|
# Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||||
|
# Time (mean ± σ): 2.5 ms ± 0.6 ms [User: 2.4 ms, System: 4.8 ms]
|
||||||
|
# Range (min … max): 1.8 ms … 9.7 ms 1474 runs
|
||||||
|
|
||||||
|
root = "bench_artifacts"
|
||||||
|
FileUtils.rm_rf(root)
|
||||||
|
Dir.mkdir(root)
|
||||||
|
|
||||||
|
# files with same size
|
||||||
|
puts "generating files of same size ..."
|
||||||
|
2.times.map do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f|
|
||||||
|
f.write(SecureRandom.bytes(4096 * 100_000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# files with different sizes
|
||||||
|
puts "generating files of different sizes ..."
|
||||||
|
2.times.map do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f|
|
||||||
|
f.write(SecureRandom.bytes(4096 * (rand * 100_000).ceil))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# files with same content & size
|
||||||
|
puts "generating files of same content and sizes ..."
|
||||||
|
2.times.each do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f|
|
||||||
|
f.write("\0" * (4096 * 100_000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# files with different content but same size
|
||||||
|
puts "generating files of different content but same sizes ..."
|
||||||
|
2.times.each do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f|
|
||||||
|
f.write(SecureRandom.bytes(4096 * 100_000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
sh("hyperfine -N --warmup 80 './target/release/deduplicator #{root}'")
|
||||||
|
sh("dust '#{root}'")
|
||||||
|
|
||||||
|
FileUtils.rm_rf(root)
|
||||||
|
end
|
||||||
|
|
||||||
|
task :many_small_files => 'target/release/deduplicator' do
|
||||||
|
# Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||||
|
# Time (mean ± σ): 10.6 ms ± 1.0 ms [User: 20.0 ms, System: 22.5 ms]
|
||||||
|
# Range (min … max): 8.4 ms … 14.2 ms 235 runs
|
||||||
|
|
||||||
|
root = "bench_artifacts"
|
||||||
|
Dir.mkdir(root)
|
||||||
|
|
||||||
|
# files with same size
|
||||||
|
puts "generating 1000 files of the same size ... "
|
||||||
|
1000.times.each do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f|
|
||||||
|
f.write(SecureRandom.bytes(4096 * 1000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# files with different sizes
|
||||||
|
puts "generating 1000 files of different sizes ... "
|
||||||
|
1000.times.each do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f|
|
||||||
|
f.write(SecureRandom.bytes(4096 * (rand * 100).ceil))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# files with same content & size
|
||||||
|
puts "generating files of same content and sizes ..."
|
||||||
|
1000.times.each do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f|
|
||||||
|
f.write("\0" * (4096 * 1000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# files with different content but same size
|
||||||
|
puts "generating files of different content but same sizes ..."
|
||||||
|
1000.times.each do |i|
|
||||||
|
File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f|
|
||||||
|
f.write(SecureRandom.bytes(4096 * 1000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
sh("hyperfine --warmup 20 './target/release/deduplicator #{root}'")
|
||||||
|
sh("dust '#{root}'")
|
||||||
|
|
||||||
|
FileUtils.rm_rf(root)
|
||||||
|
end
|
||||||
|
end
|
||||||
72
src/app.rs
72
src/app.rs
@@ -1,72 +0,0 @@
|
|||||||
use std::sync::mpsc::channel;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
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 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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
109
src/fileinfo.rs
109
src/fileinfo.rs
@@ -1,43 +1,104 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use gxhash::GxHasher;
|
use gxhash::gxhash128;
|
||||||
use memmap2::Mmap;
|
use memmap2::Mmap;
|
||||||
use serde::Serialize;
|
use std::{
|
||||||
use std::fs;
|
fs,
|
||||||
use std::hash::Hasher;
|
io::Read,
|
||||||
use std::{fs::Metadata, path::PathBuf};
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::SystemTime,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum FileState {
|
||||||
|
Unprocessed,
|
||||||
|
SwProcessed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileInfo {
|
pub struct FileInfo {
|
||||||
pub path: PathBuf,
|
pub path: Box<Path>,
|
||||||
pub hash: Option<String>,
|
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
#[serde(skip)]
|
pub modified: SystemTime,
|
||||||
pub filemeta: Metadata,
|
pub state: Arc<Mutex<FileState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileInfo {
|
impl FileInfo {
|
||||||
pub fn hash(&self) -> Result<Self> {
|
pub fn hash(&self, seed: i64) -> Result<u128> {
|
||||||
let file = fs::File::open(self.path.clone())?;
|
if self.size == 0 {
|
||||||
|
return Ok(0u128);
|
||||||
|
};
|
||||||
|
|
||||||
|
let file = fs::File::open(&self.path)?;
|
||||||
let mapper = unsafe { Mmap::map(&file)? };
|
let mapper = unsafe { Mmap::map(&file)? };
|
||||||
let mut primhasher = GxHasher::default();
|
let content_hash = mapper
|
||||||
|
.chunks(4096)
|
||||||
|
.fold(0u128, |acc, chunk: &[u8]| acc ^ gxhash128(chunk, seed));
|
||||||
|
|
||||||
mapper
|
// NOTE: avoids collision bw an empty file & a file full of null bytes.
|
||||||
.chunks(1_000_000)
|
Ok(content_hash ^ gxhash128(&self.size.to_ne_bytes(), seed))
|
||||||
.for_each(|chunk| primhasher.write(chunk));
|
}
|
||||||
|
|
||||||
Ok(Self {
|
pub fn initpages_hash(&self, seed: i64) -> Result<u128> {
|
||||||
hash: Some(primhasher.finish().to_string()),
|
let mut file = fs::File::open(&self.path)?;
|
||||||
..self.clone()
|
let mut buffer = [0; 16384];
|
||||||
})
|
let bytes_read = file.read(&mut buffer)?;
|
||||||
|
|
||||||
|
Ok(gxhash128(&buffer[..bytes_read], seed))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(path: PathBuf) -> Result<Self> {
|
pub fn new(path: PathBuf) -> Result<Self> {
|
||||||
let filemeta = std::fs::metadata(path.clone())?;
|
let filemeta = std::fs::metadata(&path)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path: path.into_boxed_path(),
|
||||||
filemeta: filemeta.clone(),
|
|
||||||
hash: None,
|
|
||||||
size: filemeta.len(),
|
size: filemeta.len(),
|
||||||
|
modified: filemeta.modified()?,
|
||||||
|
state: Arc::new(Mutex::new(FileState::Unprocessed)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sw_processed(&self) {
|
||||||
|
let mut self_state = self.state.lock().unwrap();
|
||||||
|
*self_state = FileState::SwProcessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_sw_processed(&self) -> bool {
|
||||||
|
let self_state = self.state.lock().unwrap();
|
||||||
|
*self_state == FileState::SwProcessed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
fn generate_null_bytes(size: usize) -> Vec<u8> {
|
||||||
|
(0..size).map(|_| 0).collect::<Vec<u8>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_differentiates_between_a_file_of_null_bytes_vs_an_empty_file() -> Result<()> {
|
||||||
|
let root = TempDir::new()?;
|
||||||
|
let empty_file_name = root.path().join("empty_file.bin");
|
||||||
|
|
||||||
|
File::create_new(&empty_file_name)?;
|
||||||
|
|
||||||
|
let file_with_null_bytes_name = root.path().join("file_with_null_bytes.bin");
|
||||||
|
let mut file_with_null_bytes = File::create_new(&file_with_null_bytes_name)?;
|
||||||
|
|
||||||
|
file_with_null_bytes.write_all(&generate_null_bytes(1000 * 4096))?;
|
||||||
|
|
||||||
|
let empty_file_info = FileInfo::new(empty_file_name)?;
|
||||||
|
let file_with_empty_bytes_info = FileInfo::new(file_with_null_bytes_name)?;
|
||||||
|
|
||||||
|
let seed: i64 = 246910456374;
|
||||||
|
|
||||||
|
assert_ne!(empty_file_info.hash(seed)?, file_with_empty_bytes_info.hash(seed)?);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/formatter.rs
154
src/formatter.rs
@@ -1,33 +1,25 @@
|
|||||||
pub struct Formatter;
|
use crate::{fileinfo::FileInfo, params::Params};
|
||||||
use crate::fileinfo::FileInfo;
|
|
||||||
use crate::params::Params;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use colored::Colorize;
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use indicatif::{
|
|
||||||
ParallelProgressIterator, ProgressBar, ProgressFinish, ProgressIterator, ProgressStyle,
|
|
||||||
};
|
|
||||||
use pathdiff::diff_paths;
|
use pathdiff::diff_paths;
|
||||||
use prettytable::{format, row, Table};
|
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use std::borrow::Cow;
|
use std::sync::atomic::AtomicU64;
|
||||||
use std::path::PathBuf;
|
use std::{path::PathBuf, sync::Arc};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
|
const YELLOW: &str = "\x1b[33m";
|
||||||
|
const RESET: &str = "\x1b[0m";
|
||||||
|
|
||||||
|
pub struct Formatter;
|
||||||
impl Formatter {
|
impl Formatter {
|
||||||
pub fn human_path(
|
pub fn human_path(file: &FileInfo, aargs: &Params, max_path_length: usize) -> Result<String> {
|
||||||
file: &FileInfo,
|
let base_directory: PathBuf = aargs.get_directory()?;
|
||||||
app_args: &Params,
|
let relative_path = diff_paths(&file.path, base_directory).unwrap_or_default();
|
||||||
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!(
|
let formatted_path = format!(
|
||||||
"{:<0width$}",
|
"{:<0width$}",
|
||||||
relative_path.to_str().unwrap_or_default().to_string(),
|
relative_path.to_str().unwrap_or_default().to_string(),
|
||||||
width = min_path_length
|
width = max_path_length
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(formatted_path)
|
Ok(formatted_path)
|
||||||
@@ -38,97 +30,51 @@ impl Formatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn human_mtime(file: &FileInfo) -> Result<String> {
|
pub fn human_mtime(file: &FileInfo) -> Result<String> {
|
||||||
let modified_time: DateTime<Utc> = file.filemeta.modified()?.into();
|
let modified_time: DateTime<Utc> = file.modified.into();
|
||||||
Ok(modified_time.format("%Y-%m-%d %H:%M:%S").to_string())
|
Ok(modified_time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_table(raw: Vec<FileInfo>, app_args: &Params) -> Result<Table> {
|
pub fn print(raw: Arc<DashMap<u128, Vec<FileInfo>>>, max_path_len: u64, aargs: &Params) {
|
||||||
let basepath_length = app_args.get_directory()?.to_str().unwrap_or_default().len();
|
print!("{}", "\n".repeat(if aargs.progress { 2 } else { 1 })); // spacing
|
||||||
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() {
|
if raw.is_empty() {
|
||||||
println!(
|
println!("No duplicates found matching your search criteria.");
|
||||||
"\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 {
|
} else {
|
||||||
let output_table = Self::generate_table(raw, app_args)?;
|
let printed_count: AtomicU64 = AtomicU64::new(0);
|
||||||
output_table.printstd();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
raw.par_iter().for_each(|sref| {
|
||||||
|
if sref.value().len() > 1 {
|
||||||
|
printed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut ostring = format!("{}{:32x}{}\n", YELLOW, sref.key(), RESET);
|
||||||
|
let subfields = sref
|
||||||
|
.value()
|
||||||
|
.par_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, finfo)| {
|
||||||
|
let nodechar = if i == sref.value().len() - 1 {
|
||||||
|
"└─"
|
||||||
|
} else {
|
||||||
|
"├─"
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}\t{}\t{}\t{}\n",
|
||||||
|
nodechar,
|
||||||
|
Self::human_path(finfo, aargs, max_path_len as usize)
|
||||||
|
.expect("path formatting failed."),
|
||||||
|
Self::human_filesize(finfo).expect("filesize formatting failed."),
|
||||||
|
Self::human_mtime(finfo).expect("modified time formatting failed.")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
ostring.push_str(&subfields);
|
||||||
|
println!("{ostring}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if printed_count.load(std::sync::atomic::Ordering::Relaxed) < 1 {
|
||||||
|
println!("No duplicates found matching your search criteria.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +1,137 @@
|
|||||||
use crate::formatter::Formatter;
|
use crate::{fileinfo::FileInfo, formatter::Formatter, params::Params};
|
||||||
use crate::{fileinfo::FileInfo, params::Params};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use colored::Colorize;
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressFinish, ProgressStyle};
|
|
||||||
use prettytable::{format, row, Table};
|
use prettytable::{format, row, Table};
|
||||||
use rayon::prelude::*;
|
use std::sync::atomic::AtomicU64;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
time::Duration,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn scan_group_confirmation() -> Result<bool> {
|
pub struct Interactive;
|
||||||
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() {
|
impl Interactive {
|
||||||
"Y" | "y" => Ok(true),
|
pub fn init(result: Arc<DashMap<u128, Vec<FileInfo>>>, app_args: &Params) -> Result<()> {
|
||||||
_ => Ok(false),
|
let store = result.clone();
|
||||||
}
|
if store.is_empty() {
|
||||||
}
|
println!("No duplicates found matching your search criteria.");
|
||||||
|
}
|
||||||
pub fn scan_group_instruction() -> Result<String> {
|
|
||||||
println!("\nEnter the indices of the files you want to delete.");
|
let printed_count: AtomicU64 = AtomicU64::new(0);
|
||||||
println!("You can enter multiple files using commas to seperate file indices.");
|
|
||||||
println!("example: 1,2");
|
store
|
||||||
print!("\n> ");
|
.iter()
|
||||||
std::io::stdout().flush()?;
|
.filter(|i| i.value().len() > 1)
|
||||||
let mut user_input = String::new();
|
.enumerate()
|
||||||
io::stdin().read_line(&mut user_input)?;
|
.for_each(|(gindex, i)| {
|
||||||
|
printed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
Ok(user_input)
|
let group = i.value();
|
||||||
}
|
let mut itable = Table::new();
|
||||||
|
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
|
||||||
pub fn init(result: Vec<FileInfo>, app_args: &Params) -> Result<()> {
|
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
|
||||||
let basepath_length = app_args.get_directory()?.to_str().unwrap_or_default().len();
|
|
||||||
let max_filepath_length = result
|
let max_path_size = group
|
||||||
.iter()
|
.iter()
|
||||||
.map(|file| file.path.to_str().unwrap_or_default().len())
|
.map(|f| f.path.iter().count())
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let min_path_length = if max_filepath_length > basepath_length {
|
group.iter().enumerate().for_each(|(index, file)| {
|
||||||
max_filepath_length - basepath_length
|
itable.add_row(row![
|
||||||
} else {
|
index,
|
||||||
0
|
Formatter::human_path(file, app_args, max_path_size).unwrap_or_default(),
|
||||||
};
|
Formatter::human_filesize(file).unwrap_or_default(),
|
||||||
|
Formatter::human_mtime(file).unwrap_or_default()
|
||||||
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);
|
Self::process_group_action(group, gindex, result.len(), itable);
|
||||||
progress_bar.set_style(progress_style);
|
});
|
||||||
progress_bar.enable_steady_tick(Duration::from_millis(50));
|
|
||||||
progress_bar.set_message("reconciling data");
|
if printed_count.load(std::sync::atomic::Ordering::Relaxed) < 1 {
|
||||||
|
println!("No duplicates found matching your search criteria.");
|
||||||
let duplicates: DashMap<String, Vec<FileInfo>> = DashMap::new();
|
}
|
||||||
result
|
|
||||||
.into_par_iter()
|
Ok(())
|
||||||
.progress_with(progress_bar)
|
}
|
||||||
.with_finish(ProgressFinish::WithMessage(Cow::from("data reconciled")))
|
|
||||||
.map(|file| file.hash())
|
pub fn scan_group_confirmation() -> Result<bool> {
|
||||||
.filter_map(Result::ok)
|
print!("\nconfirm? [y/N]: ");
|
||||||
.for_each(|file| {
|
std::io::stdout().flush()?;
|
||||||
duplicates
|
let mut user_input = String::new();
|
||||||
.entry(file.hash.clone().unwrap_or_default())
|
io::stdin().read_line(&mut user_input)?;
|
||||||
.and_modify(|fileset| fileset.push(file.clone()))
|
|
||||||
.or_insert_with(|| vec![file]);
|
match user_input.trim() {
|
||||||
});
|
"Y" | "y" => Ok(true),
|
||||||
|
_ => Ok(false),
|
||||||
duplicates
|
}
|
||||||
.clone()
|
}
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
pub fn scan_group_instruction() -> Result<String> {
|
||||||
.for_each(|(gindex, (_, group))| {
|
println!("\nEnter the indices of the files you want to delete.");
|
||||||
let mut itable = Table::new();
|
println!("You can enter multiple files using commas to seperate file indices.");
|
||||||
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
|
println!("example: 1,2");
|
||||||
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
|
print!("\n> ");
|
||||||
group.iter().enumerate().for_each(|(index, file)| {
|
std::io::stdout().flush()?;
|
||||||
itable.add_row(row![
|
let mut user_input = String::new();
|
||||||
index,
|
io::stdin().read_line(&mut user_input)?;
|
||||||
Formatter::human_path(file, app_args, min_path_length)
|
|
||||||
.unwrap_or_default()
|
Ok(user_input)
|
||||||
.blue(),
|
}
|
||||||
Formatter::human_filesize(file).unwrap_or_default().red(),
|
|
||||||
Formatter::human_mtime(file).unwrap_or_default().yellow()
|
pub fn process_group_action(
|
||||||
]);
|
duplicates: &Vec<FileInfo>,
|
||||||
});
|
dup_index: usize,
|
||||||
|
dup_size: usize,
|
||||||
process_group_action(&group, gindex, duplicates.len(), itable);
|
table: Table,
|
||||||
});
|
) {
|
||||||
|
println!("\nDuplicate Set {} of {}\n", dup_index + 1, dup_size);
|
||||||
Ok(())
|
table.printstd();
|
||||||
}
|
let files_to_delete = Self::scan_group_instruction().unwrap_or_default();
|
||||||
|
let parsed_file_indices = files_to_delete
|
||||||
pub fn process_group_action(
|
.trim()
|
||||||
duplicates: &Vec<FileInfo>,
|
.split(',')
|
||||||
dup_index: usize,
|
.filter(|element| !element.is_empty())
|
||||||
dup_size: usize,
|
.map(|index| index.parse::<usize>().unwrap_or_default())
|
||||||
table: Table,
|
.collect::<Vec<usize>>();
|
||||||
) {
|
|
||||||
println!("\nDuplicate Set {} of {}\n", dup_index + 1, dup_size);
|
if parsed_file_indices
|
||||||
table.printstd();
|
.clone()
|
||||||
let files_to_delete = scan_group_instruction().unwrap_or_default();
|
.into_iter()
|
||||||
let parsed_file_indices = files_to_delete
|
.any(|index| index > (duplicates.len() - 1))
|
||||||
.trim()
|
{
|
||||||
.split(',')
|
println!("Err: File Index Out of Bounds!");
|
||||||
.filter(|element| !element.is_empty())
|
return Self::process_group_action(duplicates, dup_index, dup_size, table);
|
||||||
.map(|index| index.parse::<usize>().unwrap_or_default())
|
}
|
||||||
.collect::<Vec<usize>>();
|
|
||||||
|
print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
|
||||||
if parsed_file_indices
|
|
||||||
.clone()
|
if parsed_file_indices.is_empty() {
|
||||||
.into_iter()
|
return;
|
||||||
.any(|index| index > (duplicates.len() - 1))
|
}
|
||||||
{
|
|
||||||
println!("{}", "Err: File Index Out of Bounds!".red());
|
let files_to_delete = parsed_file_indices
|
||||||
return process_group_action(duplicates, dup_index, dup_size, table);
|
.into_iter()
|
||||||
}
|
.map(|index| duplicates[index].clone());
|
||||||
|
|
||||||
print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
|
println!("\nThe following files will be deleted:");
|
||||||
|
files_to_delete
|
||||||
if parsed_file_indices.is_empty() {
|
.clone()
|
||||||
return;
|
.enumerate()
|
||||||
}
|
.for_each(|(index, file)| {
|
||||||
|
println!("{}: {}", index, file.path.display());
|
||||||
let files_to_delete = parsed_file_indices
|
});
|
||||||
.into_iter()
|
|
||||||
.map(|index| duplicates[index].clone());
|
match Self::scan_group_confirmation().unwrap() {
|
||||||
|
true => {
|
||||||
println!("\n{}", "The following files will be deleted:".red());
|
files_to_delete.into_iter().for_each(|file| {
|
||||||
files_to_delete
|
match std::fs::remove_file(file.path.clone()) {
|
||||||
.clone()
|
Ok(_) => println!("DELETED: {}", file.path.display()),
|
||||||
.enumerate()
|
Err(_) => println!("FAILED: {}", file.path.display()),
|
||||||
.for_each(|(index, file)| {
|
}
|
||||||
println!("{}: {}", index.to_string().blue(), file.path.display());
|
});
|
||||||
});
|
}
|
||||||
|
false => println!("\nCancelled Delete Operation."),
|
||||||
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()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/main.rs
37
src/main.rs
@@ -1,40 +1,35 @@
|
|||||||
mod app;
|
|
||||||
mod fileinfo;
|
mod fileinfo;
|
||||||
mod formatter;
|
mod formatter;
|
||||||
mod interactive;
|
mod interactive;
|
||||||
mod params;
|
mod params;
|
||||||
mod processor;
|
mod processor;
|
||||||
mod scanner;
|
mod scanner;
|
||||||
|
|
||||||
/* version 2.0 modules*/
|
|
||||||
mod cli;
|
|
||||||
mod server;
|
mod server;
|
||||||
mod tui;
|
|
||||||
|
|
||||||
|
use self::{formatter::Formatter, interactive::Interactive, server::Server};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use self::app::App;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use params::Params;
|
use params::Params;
|
||||||
use std::sync::Arc;
|
use std::sync::atomic::Ordering;
|
||||||
// use formatter::Formatter;
|
|
||||||
// use processor::Processor;
|
|
||||||
// use scanner::Scanner;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let app_args = Params::parse();
|
let app_args = Params::parse();
|
||||||
// let scan_results = Scanner::build(&app_args)?.scan()?;
|
let server = Server::new(app_args.clone());
|
||||||
// let processor = Processor::new(scan_results);
|
|
||||||
// let results = processor.sizewise()?.hashwise()?;
|
|
||||||
|
|
||||||
// match app_args.interactive {
|
server.start()?;
|
||||||
// false => Formatter::print(results.files, &app_args)?,
|
|
||||||
// true => interactive::init(results.files, &app_args)?,
|
|
||||||
// }
|
|
||||||
|
|
||||||
App::new(Arc::new(app_args))
|
match app_args.interactive {
|
||||||
.start()
|
false => {
|
||||||
.expect("app init failed.");
|
Formatter::print(
|
||||||
|
server.hw_duplicate_set,
|
||||||
|
server.max_file_path_len.load(Ordering::Acquire),
|
||||||
|
&app_args,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true => {
|
||||||
|
Interactive::init(server.hw_duplicate_set, &app_args)?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ use std::{fs, path::PathBuf};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, ValueHint};
|
use clap::{Parser, ValueHint};
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Default, Clone)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
|
/// Exclude Filetypes [default = none]
|
||||||
|
#[arg(short = 'T', long)]
|
||||||
|
pub exclude_types: Option<String>,
|
||||||
/// Filetypes to deduplicate [default = all]
|
/// Filetypes to deduplicate [default = all]
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
pub types: Option<String>,
|
pub types: Option<String>,
|
||||||
@@ -16,20 +19,23 @@ pub struct Params {
|
|||||||
#[arg(long, short)]
|
#[arg(long, short)]
|
||||||
pub interactive: bool,
|
pub interactive: bool,
|
||||||
/// Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T).
|
/// Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T).
|
||||||
#[arg(long, short = 's', default_value = "1b")]
|
#[arg(long, short = 'm', default_value = "1b")]
|
||||||
pub min_size: Option<String>,
|
pub min_size: Option<String>,
|
||||||
/// Max Depth to scan while looking for duplicates
|
/// Max Depth to scan while looking for duplicates
|
||||||
#[arg(long, short = 'd')]
|
#[arg(long, short = 'D')]
|
||||||
pub max_depth: Option<usize>,
|
pub max_depth: Option<usize>,
|
||||||
/// Min Depth to scan while looking for duplicates
|
/// Min Depth to scan while looking for duplicates
|
||||||
#[arg(long)]
|
#[arg(long, short = 'd')]
|
||||||
pub min_depth: Option<usize>,
|
pub min_depth: Option<usize>,
|
||||||
/// 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
|
/// Guarantees that two files are duplicate (performs a full hash)
|
||||||
#[arg(long)]
|
#[arg(long, short = 's', default_value = "false")]
|
||||||
pub json: bool,
|
pub strict: bool,
|
||||||
|
/// Show Progress spinners & metrics
|
||||||
|
#[arg(long, short = 'p', default_value = "false")]
|
||||||
|
pub progress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Params {
|
impl Params {
|
||||||
@@ -49,8 +55,4 @@ impl Params {
|
|||||||
let dir = fs::canonicalize(dir_path)?;
|
let dir = fs::canonicalize(dir_path)?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_types(&self) -> Option<String> {
|
|
||||||
self.types.clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
447
src/processor.rs
447
src/processor.rs
@@ -1,107 +1,382 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle, ProgressFinish};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
use rayon::iter::IntoParallelRefMutIterator;
|
||||||
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
|
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
|
||||||
use std::{time::Duration, borrow::Cow};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, TryLockError, TryLockResult};
|
||||||
|
use std::time::Duration;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
use crate::fileinfo::FileInfo;
|
use crate::fileinfo::FileInfo;
|
||||||
|
use crate::params::Params;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub struct Processor {}
|
||||||
pub enum State {
|
|
||||||
Initial,
|
|
||||||
SizeWise,
|
|
||||||
HashWise,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Processor {
|
|
||||||
pub files: Vec<FileInfo>,
|
|
||||||
pub state: State,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Processor {
|
impl Processor {
|
||||||
pub fn new(files: Vec<FileInfo>) -> Self {
|
pub fn hashwise(
|
||||||
Self {
|
app_args: Arc<Params>,
|
||||||
files,
|
sw_store: Arc<DashMap<u64, Vec<FileInfo>>>,
|
||||||
state: State::Initial,
|
hw_store: Arc<DashMap<u128, Vec<FileInfo>>>,
|
||||||
|
progress_bar_box: Arc<MultiProgress>,
|
||||||
|
max_file_size: Arc<AtomicU64>,
|
||||||
|
seed: i64,
|
||||||
|
sw_sorting_finished: Arc<AtomicBool>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let progress_bar = match app_args.progress {
|
||||||
|
true => progress_bar_box.add(ProgressBar::new_spinner()),
|
||||||
|
false => ProgressBar::hidden(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {pos:>7} {msg}")?;
|
||||||
|
progress_bar.set_style(progress_style);
|
||||||
|
progress_bar.enable_steady_tick(Duration::from_millis(50));
|
||||||
|
progress_bar.set_message("files grouped by hash.");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let keys: Vec<u64> = sw_store
|
||||||
|
.clone()
|
||||||
|
.iter()
|
||||||
|
.filter(|i| !i.value().iter().all(|x| x.is_sw_processed()))
|
||||||
|
.filter(|i| i.value().len() > 1)
|
||||||
|
.map(|i| *i.key())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if keys.is_empty() {
|
||||||
|
match sw_sorting_finished.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
true => {
|
||||||
|
progress_bar.finish_with_message("files grouped by hash.");
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
false => continue,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keys.into_par_iter().for_each(|key| {
|
||||||
|
let mut group: Vec<FileInfo> = sw_store.get(&key).unwrap().to_vec();
|
||||||
|
if group.len() > 1 {
|
||||||
|
group.par_iter_mut().for_each(|file| {
|
||||||
|
progress_bar.inc(1);
|
||||||
|
file.sw_processed();
|
||||||
|
|
||||||
|
let fhash = match app_args.strict {
|
||||||
|
true => file.hash(seed).expect("hashing file failed."),
|
||||||
|
false => file.initpages_hash(seed).expect("hashing file failed."),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::compare_and_update_max_path_len(
|
||||||
|
max_file_size.clone(),
|
||||||
|
file.path.to_string_lossy().graphemes(true).count() as u64,
|
||||||
|
);
|
||||||
|
|
||||||
|
hw_store
|
||||||
|
.entry(fhash)
|
||||||
|
.and_modify(|fileset| fileset.push(file.clone()))
|
||||||
|
.or_insert_with(|| vec![file.clone()]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hashwise(&self) -> Result<Self> {
|
pub fn compare_and_update_max_path_len(current: Arc<AtomicU64>, next: u64) {
|
||||||
if self.files.is_empty() {
|
if current.load(Ordering::Relaxed) < next {
|
||||||
return Ok(self.clone());
|
current.store(next, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
pub fn sizewise(
|
||||||
if self.files.is_empty() {
|
app_args: Arc<Params>,
|
||||||
return Ok(self.clone());
|
scanner_finished: Arc<AtomicBool>,
|
||||||
}
|
store: Arc<DashMap<u64, Vec<FileInfo>>>,
|
||||||
|
files: Arc<Mutex<Vec<FileInfo>>>,
|
||||||
|
progress_bar_box: Arc<MultiProgress>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let progress_bar = match app_args.progress {
|
||||||
|
true => progress_bar_box.add(ProgressBar::new_spinner()),
|
||||||
|
false => ProgressBar::hidden(),
|
||||||
|
};
|
||||||
|
|
||||||
let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")?;
|
let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {pos:>7} {msg}")?;
|
||||||
let progress_bar = ProgressBar::new(self.files.len() as u64);
|
|
||||||
progress_bar.set_style(progress_style);
|
progress_bar.set_style(progress_style);
|
||||||
progress_bar.enable_steady_tick(Duration::from_millis(50));
|
progress_bar.enable_steady_tick(Duration::from_millis(50));
|
||||||
progress_bar.set_message("indexing file sizes");
|
progress_bar.set_message("files grouped by size");
|
||||||
|
|
||||||
let duplicates_table: DashMap<u64, Vec<FileInfo>> = DashMap::new();
|
loop {
|
||||||
self.files
|
let fileopt: Option<FileInfo> = {
|
||||||
.clone()
|
match files.try_lock() {
|
||||||
.into_par_iter()
|
Ok(mut flist) => flist.pop(),
|
||||||
.progress_with(progress_bar)
|
TryLockResult::Err(TryLockError::WouldBlock) => None,
|
||||||
.with_finish(ProgressFinish::WithMessage(Cow::from("indexed files sizes")))
|
_ => None,
|
||||||
.for_each(|file| {
|
}
|
||||||
duplicates_table
|
};
|
||||||
.entry(file.size)
|
|
||||||
.and_modify(|fileset| fileset.push(file.clone()))
|
|
||||||
.or_insert_with(|| vec![file]);
|
|
||||||
});
|
|
||||||
|
|
||||||
let files = duplicates_table
|
match fileopt {
|
||||||
.into_read_only()
|
Some(file) => {
|
||||||
.values()
|
progress_bar.inc(1);
|
||||||
.cloned()
|
store
|
||||||
.filter(|subfiles| subfiles.len() > 1)
|
.entry(file.size)
|
||||||
.flatten()
|
.and_modify(|fileset| fileset.push(file.clone()))
|
||||||
.collect::<Vec<FileInfo>>();
|
.or_insert_with(|| vec![file]);
|
||||||
|
continue;
|
||||||
Ok(Self {
|
}
|
||||||
files,
|
None => match scanner_finished.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
state: State::SizeWise,
|
true => {
|
||||||
})
|
progress_bar.finish_with_message("files grouped by size");
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
false => continue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use anyhow::Result;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use indicatif::MultiProgress;
|
||||||
|
use rand::Rng;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::{fileinfo::FileInfo, params::Params};
|
||||||
|
|
||||||
|
use super::Processor;
|
||||||
|
|
||||||
|
fn generate_bytes(size: usize) -> Vec<u8> {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
(0..size).map(|_| rng.random::<u8>()).collect::<Vec<u8>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashwise_sorting_two_files_with_identical_init_pages_only_strict_mode() -> Result<()> {
|
||||||
|
let root = TempDir::new()?;
|
||||||
|
let content = generate_bytes(16384);
|
||||||
|
|
||||||
|
let mut content_x = content.clone();
|
||||||
|
let mut content_y = content.clone();
|
||||||
|
|
||||||
|
content_x.extend(generate_bytes(1720320));
|
||||||
|
content_y.extend(generate_bytes(1720320));
|
||||||
|
|
||||||
|
let files = [
|
||||||
|
(root.path().join("fileone.bin"), content_x),
|
||||||
|
(root.path().join("filetwo.bin"), content_y),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (fpath, content) in files.iter() {
|
||||||
|
let mut f = File::create_new(fpath)?;
|
||||||
|
f.write_all(content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dupstore = Arc::new(DashMap::new());
|
||||||
|
let file_queue = Arc::new(Mutex::new(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| FileInfo::new(f.0.clone()).unwrap())
|
||||||
|
.collect::<Vec<FileInfo>>(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let hw_dupstore = Arc::new(DashMap::new());
|
||||||
|
Processor::sizewise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
dupstore.clone(),
|
||||||
|
file_queue,
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let args = Params {
|
||||||
|
strict: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Processor::hashwise(
|
||||||
|
Arc::new(args),
|
||||||
|
dupstore.clone(),
|
||||||
|
hw_dupstore.clone(),
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
Arc::new(AtomicU64::new(32)),
|
||||||
|
300,
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
assert_eq!(hw_dupstore.len(), 2);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashwise_sorting_two_files_with_identical_init_pages_only_fast_mode() -> Result<()> {
|
||||||
|
let root = TempDir::new()?;
|
||||||
|
let content = generate_bytes(16384);
|
||||||
|
|
||||||
|
let mut content_x = content.clone();
|
||||||
|
let mut content_y = content.clone();
|
||||||
|
|
||||||
|
content_x.extend(generate_bytes(1720320));
|
||||||
|
content_y.extend(generate_bytes(1720320));
|
||||||
|
|
||||||
|
let files = [
|
||||||
|
(root.path().join("fileone.bin"), content_x),
|
||||||
|
(root.path().join("filetwo.bin"), content_y),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (fpath, content) in files.iter() {
|
||||||
|
let mut f = File::create_new(fpath)?;
|
||||||
|
f.write_all(content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dupstore = Arc::new(DashMap::new());
|
||||||
|
let file_queue = Arc::new(Mutex::new(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| FileInfo::new(f.0.clone()).unwrap())
|
||||||
|
.collect::<Vec<FileInfo>>(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let hw_dupstore = Arc::new(DashMap::new());
|
||||||
|
Processor::sizewise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
dupstore.clone(),
|
||||||
|
file_queue,
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Processor::hashwise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
dupstore.clone(),
|
||||||
|
hw_dupstore.clone(),
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
Arc::new(AtomicU64::new(32)),
|
||||||
|
300,
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
assert_eq!(hw_dupstore.len(), 1);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashwise_sorting_two_files_with_identical_data() -> Result<()> {
|
||||||
|
let root = TempDir::new()?;
|
||||||
|
let content = generate_bytes(282624);
|
||||||
|
let files = [
|
||||||
|
(root.path().join("fileone.bin"), content.clone()),
|
||||||
|
(root.path().join("filetwo.bin"), content.clone()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (fpath, content) in files.iter() {
|
||||||
|
let mut f = File::create_new(fpath)?;
|
||||||
|
f.write_all(content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dupstore = Arc::new(DashMap::new());
|
||||||
|
let file_queue = Arc::new(Mutex::new(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| FileInfo::new(f.0.clone()).unwrap())
|
||||||
|
.collect::<Vec<FileInfo>>(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let hw_dupstore = Arc::new(DashMap::new());
|
||||||
|
Processor::sizewise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
dupstore.clone(),
|
||||||
|
file_queue,
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Processor::hashwise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
dupstore.clone(),
|
||||||
|
hw_dupstore.clone(),
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
Arc::new(AtomicU64::new(32)),
|
||||||
|
300,
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
assert_eq!(hw_dupstore.len(), 1);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sizewise_sorting_two_files_of_different_sizes() -> Result<()> {
|
||||||
|
let root = TempDir::new()?;
|
||||||
|
let files = [
|
||||||
|
(root.path().join("fileone.bin"), generate_bytes(282624)),
|
||||||
|
(root.path().join("filetwo.bin"), generate_bytes(1720320)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (fpath, content) in files.iter() {
|
||||||
|
let mut f = File::create_new(fpath)?;
|
||||||
|
f.write_all(content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_queue = Arc::new(Mutex::new(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| FileInfo::new(f.0.clone()).unwrap())
|
||||||
|
.collect::<Vec<FileInfo>>(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let dupstore = Arc::new(DashMap::new());
|
||||||
|
|
||||||
|
Processor::sizewise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
dupstore.clone(),
|
||||||
|
file_queue,
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
assert_eq!(dupstore.len(), 2);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sizewise_sorting_two_files_of_same_size() -> Result<()> {
|
||||||
|
let root = TempDir::new()?;
|
||||||
|
let files = [
|
||||||
|
(root.path().join("fileone.bin"), generate_bytes(282624)),
|
||||||
|
(root.path().join("filetwo.bin"), generate_bytes(282624)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (fpath, content) in files.iter() {
|
||||||
|
let mut f = File::create_new(fpath)?;
|
||||||
|
f.write_all(content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_queue = Arc::new(Mutex::new(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| FileInfo::new(f.0.clone()).unwrap())
|
||||||
|
.collect::<Vec<FileInfo>>(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let dupstore = Arc::new(DashMap::new());
|
||||||
|
|
||||||
|
Processor::sizewise(
|
||||||
|
Arc::new(Params::default()),
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
dupstore.clone(),
|
||||||
|
file_queue,
|
||||||
|
Arc::new(MultiProgress::new()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
assert_eq!(dupstore.len(), 1);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
311
src/scanner.rs
311
src/scanner.rs
@@ -1,118 +1,51 @@
|
|||||||
#![allow(unused)]
|
|
||||||
use crate::{fileinfo::FileInfo, params::Params};
|
use crate::{fileinfo::FileInfo, params::Params};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::{fs, path::PathBuf, time::Duration};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::{path::Path, time::Duration};
|
||||||
|
|
||||||
use globwalk::{GlobWalker, GlobWalkerBuilder};
|
use globwalk::{GlobWalker, GlobWalkerBuilder};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Scanner {
|
pub struct Scanner {
|
||||||
pub directory: Option<PathBuf>,
|
pub directory: Box<Path>,
|
||||||
pub filetypes: Option<String>,
|
|
||||||
pub min_depth: Option<usize>,
|
pub min_depth: Option<usize>,
|
||||||
pub max_depth: Option<usize>,
|
pub max_depth: Option<usize>,
|
||||||
|
pub include_types: Option<String>,
|
||||||
|
pub exclude_types: Option<String>,
|
||||||
pub min_size: Option<u64>,
|
pub min_size: Option<u64>,
|
||||||
pub follow_links: bool,
|
pub follow_links: bool,
|
||||||
|
pub progress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scanner {
|
impl Scanner {
|
||||||
pub fn new() -> Self {
|
pub fn new(app_args: Arc<Params>) -> Result<Self> {
|
||||||
Self {
|
Ok(Self {
|
||||||
directory: None,
|
directory: app_args.get_directory()?.into_boxed_path(),
|
||||||
filetypes: None,
|
include_types: app_args.types.clone(),
|
||||||
min_depth: None,
|
exclude_types: app_args.exclude_types.clone(),
|
||||||
max_depth: None,
|
min_depth: app_args.min_depth,
|
||||||
min_size: None,
|
max_depth: app_args.max_depth,
|
||||||
follow_links: true,
|
min_size: app_args.get_min_size(),
|
||||||
}
|
follow_links: app_args.follow_links,
|
||||||
}
|
progress: app_args.progress,
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn min_size(&self, min_size: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
min_size: Some(min_size),
|
|
||||||
..self.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn min_depth(&self, min_depth: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
min_depth: Some(min_depth),
|
|
||||||
..self.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn max_depth(&self, max_depth: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
max_depth: Some(max_depth),
|
|
||||||
..self.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn directory(&self, dir: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scan_dir(&self) -> Result<PathBuf> {
|
fn scan_patterns(&self) -> Result<Vec<String>> {
|
||||||
let scan_dir = match self.directory.clone() {
|
let include_types = match &self.include_types {
|
||||||
Some(path) => path,
|
Some(ftypes) => Some(format!("**/*.{{{ftypes}}}")),
|
||||||
None => std::env::current_dir()?,
|
None => Some("**/*".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(fs::canonicalize(scan_dir)?)
|
let exclude_types = self
|
||||||
|
.exclude_types
|
||||||
|
.as_ref()
|
||||||
|
.map(|ftypes| format!("!**/*.{{{ftypes}}}"));
|
||||||
|
|
||||||
|
Ok(vec![include_types, exclude_types]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attach_link_opts(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
|
fn attach_link_opts(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
|
||||||
@@ -134,8 +67,8 @@ impl Scanner {
|
|||||||
}
|
}
|
||||||
fn build_walker(&self) -> Result<GlobWalker> {
|
fn build_walker(&self) -> Result<GlobWalker> {
|
||||||
let walker = Ok(GlobWalkerBuilder::from_patterns(
|
let walker = Ok(GlobWalkerBuilder::from_patterns(
|
||||||
self.scan_dir()?,
|
self.directory.clone(),
|
||||||
&[self.scan_patterns()?],
|
&self.scan_patterns()?,
|
||||||
))
|
))
|
||||||
.and_then(|walker| self.attach_walker_min_depth(walker))
|
.and_then(|walker| self.attach_walker_min_depth(walker))
|
||||||
.and_then(|walker| self.attach_walker_max_depth(walker))
|
.and_then(|walker| self.attach_walker_max_depth(walker))
|
||||||
@@ -144,30 +77,184 @@ impl Scanner {
|
|||||||
Ok(walker.build()?)
|
Ok(walker.build()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scan(&self) -> Result<Vec<FileInfo>> {
|
pub fn scan(
|
||||||
|
&self,
|
||||||
|
files: Arc<Mutex<Vec<FileInfo>>>,
|
||||||
|
progress_bar_box: Arc<MultiProgress>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let progress_bar = match self.progress {
|
||||||
|
true => progress_bar_box.add(ProgressBar::new_spinner()),
|
||||||
|
false => ProgressBar::hidden(),
|
||||||
|
};
|
||||||
|
|
||||||
let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {pos:>7} {msg}")?;
|
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.set_style(progress_style);
|
||||||
progress_bar.enable_steady_tick(Duration::from_millis(50));
|
progress_bar.enable_steady_tick(Duration::from_millis(50));
|
||||||
progress_bar.set_message("paths mapped");
|
progress_bar.set_message("paths mapped");
|
||||||
let min_size = self.min_size.unwrap_or_default();
|
let min_size = self.min_size.unwrap_or(0);
|
||||||
|
|
||||||
let results = self
|
self.build_walker()?
|
||||||
.build_walker()?
|
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.map(|entity| entity.into_path())
|
.map(|entity| entity.into_path())
|
||||||
.map(|path| {
|
.inspect(|_path| progress_bar.inc(1))
|
||||||
progress_bar.inc(1);
|
|
||||||
path
|
|
||||||
})
|
|
||||||
.filter(|path| path.is_file())
|
.filter(|path| path.is_file())
|
||||||
.map(FileInfo::new)
|
.map(FileInfo::new)
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.filter(|file| file.size > min_size)
|
.filter(|file| file.size >= min_size)
|
||||||
.collect::<Vec<FileInfo>>();
|
.for_each(|file| {
|
||||||
|
let mut flock = files.lock().unwrap();
|
||||||
|
flock.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
progress_bar.finish_with_message("paths mapped");
|
progress_bar.finish_with_message("paths mapped");
|
||||||
|
Ok(())
|
||||||
Ok(results)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::fileinfo::FileInfo;
|
||||||
|
use crate::params::Params;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use super::Scanner;
|
||||||
|
use indicatif::MultiProgress;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_file_include_type_filter_includes_expected_file_types() {
|
||||||
|
let root =
|
||||||
|
TempDir::with_prefix("deduplicator_test_root").expect("unable to create tempdir");
|
||||||
|
[
|
||||||
|
"this-is-a-js-file.js",
|
||||||
|
"this-is-a-css-file.css",
|
||||||
|
"this-is-a-csv-file.csv",
|
||||||
|
"this-is-a-rust-file.rs",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.for_each(|path| {
|
||||||
|
File::create_new(root.path().join(path)).unwrap_or_else(|_| {
|
||||||
|
panic!("unable to create file {path}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let params = Params {
|
||||||
|
types: Some(String::from("js,csv")),
|
||||||
|
dir: Some(root.path().into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress = Arc::new(MultiProgress::new());
|
||||||
|
let scanlist = Arc::new(Mutex::<Vec<FileInfo>>::new(vec![]));
|
||||||
|
let scanner = Scanner::new(Arc::new(params)).expect("scanner initialization failed");
|
||||||
|
|
||||||
|
scanner
|
||||||
|
.scan(scanlist.clone(), progress)
|
||||||
|
.expect("scanning failed.");
|
||||||
|
|
||||||
|
let scan_list_mg = scanlist.lock().unwrap();
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().any(|f| f.path.to_str().unwrap()
|
||||||
|
== root.path().join("this-is-a-js-file.js").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().any(|f| f.path.to_str().unwrap()
|
||||||
|
== root.path().join("this-is-a-csv-file.csv").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().all(|f| f.path.to_str().unwrap()
|
||||||
|
!= root.path().join("this-is-a-css-file.css").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().all(|f| f.path.to_str().unwrap()
|
||||||
|
!= root.path().join("this-is-a-rust-file.rs").to_str().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_file_exclude_type_filter_excludes_expected_file_types() {
|
||||||
|
let root =
|
||||||
|
TempDir::with_prefix("deduplicator_test_root").expect("unable to create tempdir");
|
||||||
|
[
|
||||||
|
"this-is-a-js-file.js",
|
||||||
|
"this-is-a-css-file.css",
|
||||||
|
"this-is-a-csv-file.csv",
|
||||||
|
"this-is-a-rust-file.rs",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.for_each(|path| {
|
||||||
|
File::create_new(root.path().join(path)).unwrap_or_else(|_| {
|
||||||
|
panic!("unable to create file {path}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let params = Params {
|
||||||
|
exclude_types: Some(String::from("js,csv")),
|
||||||
|
dir: Some(root.path().into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress = Arc::new(MultiProgress::new());
|
||||||
|
let scanlist = Arc::new(Mutex::<Vec<FileInfo>>::new(vec![]));
|
||||||
|
let scanner = Scanner::new(Arc::new(params)).expect("scanner initialization failed");
|
||||||
|
|
||||||
|
scanner
|
||||||
|
.scan(scanlist.clone(), progress)
|
||||||
|
.expect("scanning failed.");
|
||||||
|
|
||||||
|
let scan_list_mg = scanlist.lock().unwrap();
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().all(|f| f.path.to_str().unwrap()
|
||||||
|
!= root.path().join("this-is-a-js-file.js").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().all(|f| f.path.to_str().unwrap()
|
||||||
|
!= root.path().join("this-is-a-csv-file.csv").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().any(|f| f.path.to_str().unwrap()
|
||||||
|
== root.path().join("this-is-a-css-file.css").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().any(|f| f.path.to_str().unwrap()
|
||||||
|
== root.path().join("this-is-a-rust-file.rs").to_str().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complex_file_type_params() {
|
||||||
|
let root =
|
||||||
|
TempDir::with_prefix("deduplicator_test_root").expect("unable to create tempdir");
|
||||||
|
[
|
||||||
|
"this-is-a-js-file.js",
|
||||||
|
"this-is-a-css-file.css",
|
||||||
|
"this-is-a-csv-file.csv",
|
||||||
|
"this-is-a-rust-file.rs",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.for_each(|path| {
|
||||||
|
File::create_new(root.path().join(path)).unwrap_or_else(|_| {
|
||||||
|
panic!("unable to create file {path}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let params = Params {
|
||||||
|
types: Some(String::from("js,csv,rs")),
|
||||||
|
exclude_types: Some(String::from("csv")),
|
||||||
|
dir: Some(root.path().into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress = Arc::new(MultiProgress::new());
|
||||||
|
let scanlist = Arc::new(Mutex::<Vec<FileInfo>>::new(vec![]));
|
||||||
|
let scanner = Scanner::new(Arc::new(params)).expect("scanner initialization failed");
|
||||||
|
|
||||||
|
scanner
|
||||||
|
.scan(scanlist.clone(), progress)
|
||||||
|
.expect("scanning failed.");
|
||||||
|
|
||||||
|
let scan_list_mg = scanlist.lock().unwrap();
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().any(|f| f.path.to_str().unwrap()
|
||||||
|
== root.path().join("this-is-a-js-file.js").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().all(|f| f.path.to_str().unwrap()
|
||||||
|
!= root.path().join("this-is-a-csv-file.csv").to_str().unwrap()));
|
||||||
|
|
||||||
|
assert!(scan_list_mg.iter().any(|f| f.path.to_str().unwrap()
|
||||||
|
== root.path().join("this-is-a-rust-file.rs").to_str().unwrap()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/server.rs
Normal file
117
src/server.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::processor::Processor;
|
||||||
|
use crate::scanner::Scanner;
|
||||||
|
use anyhow::Result;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use indicatif::{MultiProgress, ProgressDrawTarget};
|
||||||
|
use rand::Rng;
|
||||||
|
use threadpool::ThreadPool;
|
||||||
|
|
||||||
|
use crate::fileinfo::FileInfo;
|
||||||
|
use crate::params::Params;
|
||||||
|
|
||||||
|
pub struct Server {
|
||||||
|
filequeue: Arc<Mutex<Vec<FileInfo>>>,
|
||||||
|
sw_duplicate_set: Arc<DashMap<u64, Vec<FileInfo>>>,
|
||||||
|
pub hw_duplicate_set: Arc<DashMap<u128, Vec<FileInfo>>>,
|
||||||
|
threadpool: ThreadPool,
|
||||||
|
app_args: Arc<Params>,
|
||||||
|
pub max_file_path_len: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn new(opts: Params) -> Self {
|
||||||
|
Self {
|
||||||
|
filequeue: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
sw_duplicate_set: Arc::new(DashMap::new()),
|
||||||
|
hw_duplicate_set: Arc::new(DashMap::new()),
|
||||||
|
threadpool: ThreadPool::new(4),
|
||||||
|
app_args: Arc::new(opts),
|
||||||
|
max_file_path_len: Arc::new(AtomicU64::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&self) -> Result<()> {
|
||||||
|
let progbarbox = Arc::new(MultiProgress::new());
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let seed: i64 = rng.random();
|
||||||
|
|
||||||
|
if !self.app_args.progress {
|
||||||
|
progbarbox.set_draw_target(ProgressDrawTarget::hidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (app_args_sc, app_args_sw, app_args_hw) = (
|
||||||
|
Arc::clone(&self.app_args),
|
||||||
|
Arc::clone(&self.app_args),
|
||||||
|
Arc::clone(&self.app_args),
|
||||||
|
);
|
||||||
|
let (file_queue_sc, file_queue_pr) = (
|
||||||
|
Arc::clone(&self.filequeue),
|
||||||
|
Arc::clone(&self.filequeue),
|
||||||
|
);
|
||||||
|
let scanner_finished = Arc::new(AtomicBool::new(false));
|
||||||
|
let sw_sort_finished = Arc::new(AtomicBool::new(false));
|
||||||
|
let (sfin_sc, sfin_pr) = (
|
||||||
|
Arc::clone(&scanner_finished),
|
||||||
|
Arc::clone(&scanner_finished),
|
||||||
|
);
|
||||||
|
let (swfin_pr_sw, swfin_pr_hw) = (
|
||||||
|
Arc::clone(&sw_sort_finished),
|
||||||
|
Arc::clone(&sw_sort_finished),
|
||||||
|
);
|
||||||
|
let (store_sw, store_sw2, store_hw) = (
|
||||||
|
Arc::clone(&self.sw_duplicate_set),
|
||||||
|
Arc::clone(&self.sw_duplicate_set),
|
||||||
|
Arc::clone(&self.hw_duplicate_set),
|
||||||
|
);
|
||||||
|
let max_file_path_len = Arc::clone(&self.max_file_path_len);
|
||||||
|
let (prog_sc, prog_sw, prog_hw) = (
|
||||||
|
Arc::clone(&progbarbox),
|
||||||
|
Arc::clone(&progbarbox),
|
||||||
|
Arc::clone(&progbarbox),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.threadpool.execute(move || {
|
||||||
|
Scanner::new(app_args_sc)
|
||||||
|
.expect("unable to initialize scanner.")
|
||||||
|
.scan(file_queue_sc, prog_sc)
|
||||||
|
.expect("scanner failed.");
|
||||||
|
|
||||||
|
sfin_sc.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.threadpool.execute(move || {
|
||||||
|
Processor::sizewise(
|
||||||
|
app_args_sw,
|
||||||
|
sfin_pr,
|
||||||
|
store_sw,
|
||||||
|
file_queue_pr,
|
||||||
|
prog_sw,
|
||||||
|
)
|
||||||
|
.expect("sizewise scanner failed.");
|
||||||
|
|
||||||
|
swfin_pr_sw.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.threadpool.execute(move || {
|
||||||
|
Processor::hashwise(
|
||||||
|
app_args_hw,
|
||||||
|
store_sw2,
|
||||||
|
store_hw,
|
||||||
|
prog_hw,
|
||||||
|
max_file_path_len,
|
||||||
|
seed,
|
||||||
|
swfin_pr_hw,
|
||||||
|
)
|
||||||
|
.expect("sizewise scanner failed.");
|
||||||
|
});
|
||||||
|
|
||||||
|
progbarbox.clear()?;
|
||||||
|
|
||||||
|
self.threadpool.join();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user