24 Commits

Author SHA1 Message Date
sreedevk
e41e5d35fb typo fix 2025-07-13 23:02:06 +00:00
Sreedev Kodichath
884f88d749 Update README.md 2025-07-13 19:00:58 -04:00
sreedevk
ab15cdfcd7 added benchmarks 2025-07-13 22:53:26 +00:00
sreedevk
7c5b106784 add more goals on readme 2025-07-13 21:42:22 +00:00
sreedevk
add53a3be1 updates documentation 2025-07-13 21:40:18 +00:00
sreedevk
d99d783327 add more tests 2025-07-13 21:24:31 +00:00
sreedevk
ed55aaa005 speeeeeeeeeeed 2025-07-13 20:21:17 +00:00
sreedevk
fe79a07b43 changed hash type to u128 2025-07-13 18:11:07 +00:00
sreedevk
47b2fc4a87 move max path size update to a later stage in the pipeline for accurate results 2025-07-13 14:24:24 +00:00
sreedevk
184efdcbbc simplify scanner 2025-07-13 14:02:19 +00:00
sreedevk
81e13a14a2 reduce memuse: fileinfo uses boxed path instead of pathbuf
reduce memuse: fileinfo does not store full metadata
2025-07-13 13:45:30 +00:00
sreedevk
5daec2f531 multi progress box also hidden if --progress not set 2025-07-13 13:37:03 +00:00
sreedevk
fcf69ae1cc progress bar issues fixed with multiprogress 2025-07-13 13:21:20 +00:00
sreedevk
3a03208f47 parallelization progress representation on readme updated 2025-07-13 13:05:34 +00:00
sreedevk
95ecfa0ccc disable progress sprinners by default 2025-07-13 12:59:22 +00:00
sreedevk
ce115fb8dc better organization of interactive module 2025-07-13 12:47:17 +00:00
sreedevk
3785a87cee add more goals to readme 2025-07-13 12:44:49 +00:00
sreedevk
d42166a4fd added first test 2025-07-12 22:23:37 +00:00
sreedevk
4d85f0bbc9 memory usage improvements 2025-07-12 21:30:05 +00:00
sreedevk
8826a87f32 added strict mode with partial hashing default 2025-07-12 21:17:00 +00:00
sreedevk
4aa536e89b hashing restored 2025-07-12 21:04:43 +00:00
sreedevk
9d67a91423 formatter speed improved (new bug: does not output hash) 2025-07-12 20:50:04 +00:00
sreedevk
4a6aadb78e parallelization improvements 2025-07-12 20:21:44 +00:00
sreedevk
1b06eb8e85 multithreading improvements 2025-07-12 18:26:10 +00:00
20 changed files with 987 additions and 3837 deletions

View File

@@ -1,121 +1,137 @@
name: Deduplicator Release Build CI Pipeline # CI that:
#
# * checks for a Git Tag that looks like a release
# * creates a Github Release™ and fills in its text
# * builds artifacts with cargo-dist (executable-zips, installers)
# * uploads those artifacts to the Github Release™
#
# Note that the Github Release™ will be created before the artifacts,
# so there will be a few minutes where the release has no artifacts
# and then they will slowly trickle in, possibly failing. To make
# this more pleasant we mark the release as a "draft" until all
# artifacts have been successfully uploaded. This allows you to
# choose what to do with partial successes and avoids spamming
# anyone with notifications before the release is actually ready.
name: Release
permissions:
contents: write
# This task will run whenever you push a git tag that looks like a version
# like "v1", "v1.2.0", "v0.1.0-prerelease01", "my-app-v1.0.0", etc.
# The version will be roughly parsed as ({PACKAGE_NAME}-)?v{VERSION}, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version.
#
# If PACKAGE_NAME is specified, then we will create a Github Release™ for that
# package (erroring out if it doesn't have the given version or isn't cargo-dist-able).
#
# If PACKAGE_NAME isn't specified, then we will create a Github Release™ for all
# (cargo-dist-able) packages in the workspace with that version (this is mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent Github Release™ for each one.
#
# If there's a prerelease-style suffix to the version then the Github Release™
# will be marked as a prerelease.
on: on:
push: push:
tags: tags:
- 'v*.*.*' - '*-?v[0-9]+*'
jobs: jobs:
lint_test: # Create the Github Release™ so the packages have something to be uploaded to
create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
has-releases: ${{ steps.create-release.outputs.has-releases }}
env: env:
RUSTFLAGS: "-C target-feature=+aes,+sse2" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
with: - name: Install cargo-dist
components: clippy run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
- id: create-release
run: |
cargo dist plan --tag=${{ github.ref_name }} --output-format=json > dist-manifest.json
echo "dist plan ran successfully"
cat dist-manifest.json
- name: Run cargo check # Create the Github Release™ based on what cargo-dist thinks it should be
run: cargo check ANNOUNCEMENT_TITLE=$(jq --raw-output ".announcement_title" dist-manifest.json)
IS_PRERELEASE=$(jq --raw-output ".announcement_is_prerelease" dist-manifest.json)
jq --raw-output ".announcement_github_body" dist-manifest.json > new_dist_announcement.md
gh release create ${{ github.ref_name }} --draft --prerelease="$IS_PRERELEASE" --title="$ANNOUNCEMENT_TITLE" --notes-file=new_dist_announcement.md
echo "created announcement!"
- name: Run cargo clippy # Upload the manifest to the Github Release™
run: cargo clippy -- -D warnings gh release upload ${{ github.ref_name }} dist-manifest.json
echo "uploaded manifest!"
- name: Run tests # Disable all the upload-artifacts tasks if we have no actual releases
run: cargo test -- --test-threads=1 HAS_RELEASES=$(jq --raw-output ".releases != null" dist-manifest.json)
echo "has-releases=$HAS_RELEASES" >> "$GITHUB_OUTPUT"
build: # Build and packages all the things
needs: lint_test upload-artifacts:
runs-on: ${{ matrix.os }} # Let the initial task tell us to not run (currently very blunt)
needs: create-release
if: ${{ needs.create-release.outputs.has-releases == 'true' }}
strategy: strategy:
matrix: matrix:
# For these target platforms
include: include:
- target: aarch64-unknown-linux-gnu - os: macos-11
os: ubuntu-latest dist-args: --artifacts=local --target=aarch64-apple-darwin --target=x86_64-apple-darwin
artifact_name: linux-aarch64 install-dist: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.sh | sh
rustflags: "-C target-feature=+aes,+neon" - os: ubuntu-20.04
- target: x86_64-unknown-linux-gnu dist-args: --artifacts=local --target=x86_64-unknown-linux-gnu
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-amd64 - os: windows-2019
rustflags: "-C target-feature=+aes,+sse2" dist-args: --artifacts=local --target=x86_64-pc-windows-msvc
- target: x86_64-apple-darwin install-dist: irm https://github.com/axodotdev/cargo-dist/releases/download/v0.0.7/cargo-dist-installer.ps1 | iex
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:
- name: Checkout repository - uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable run: rustup update 1.71.0 --no-self-update && rustup default 1.71.0
- name: Install cargo-dist
- name: Add target run: ${{ matrix.install-dist }}
run: rustup target add ${{ matrix.target }} - name: Run cargo-dist
# This logic is a bit janky because it's trying to be a polyglot between
- name: Install cross-compilation toolchain (Linux ARM only) # powershell and bash since this will run on windows, macos, and linux!
if: matrix.target == 'aarch64-unknown-linux-gnu' # The two platforms don't agree on how to talk about env vars but they
# do agree on 'cat' and '$()' so we use that to marshal values between commands.
run: | run: |
sudo apt-get update # Actually do builds and make zips and whatnot
sudo apt-get install -y gcc-aarch64-linux-gnu cargo dist build --tag=${{ github.ref_name }} --output-format=json ${{ matrix.dist-args }} > dist-manifest.json
echo "dist ran successfully"
cat dist-manifest.json
- name: Build release binary # Parse out what we just built and upload it to the Github Release™
env: jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json > uploads.txt
RUSTFLAGS: ${{ matrix.rustflags }} echo "uploading..."
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} cat uploads.txt
run: | gh release upload ${{ github.ref_name }} $(cat uploads.txt)
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then echo "uploaded!"
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
fi
cargo build --release --target ${{ matrix.target }}
- name: Package binary # Mark the Github Release™ as a non-draft now that everything has succeeded!
run: | publish-release:
if [[ "${{ matrix.os }}" == macos* ]]; then # Only run after all the other tasks, but it's ok if upload-artifacts was skipped
BINARY_NAME=$(find target/${{ matrix.target }}/release -maxdepth 1 -type f -perm +111 -print0 | xargs -0 basename | head -1) needs: [create-release, upload-artifacts]
else if: ${{ always() && needs.create-release.result == 'success' && (needs.upload-artifacts.result == 'skipped' || needs.upload-artifacts.result == 'success') }}
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:
- name: Checkout repository - uses: actions/checkout@v3
uses: actions/checkout@v4 - name: mark release as non-draft
run: |
- name: Download all artifacts gh release edit ${{ github.ref_name }} --draft=false
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 }}

3
.gitignore vendored
View File

@@ -2,6 +2,3 @@
/Cargo.lock /Cargo.lock
/result-bin /result-bin
/.bacon-locations /.bacon-locations
/docs
/.claude
/.superpowers

1852
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "deduplicator" name = "deduplicator"
version = "0.3.2" version = "0.3.0"
edition = "2021" edition = "2021"
description = "find,filter and delete duplicate files" description = "find,filter and delete duplicate files"
repository = "https://github.com/sreedevk/deduplicator" repository = "https://github.com/sreedevk/deduplicator"
@@ -18,20 +18,19 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # 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 = "2.0.1" bytesize = "1.1.0"
chrono = "0.4.23" chrono = "0.4.23"
clap = { version = "4.0.32", features = ["derive"] } clap = { version = "4.0.32", features = ["derive"] }
dirs = "6.0.0" dashmap = { version = "5.4.0", features = ["rayon"] }
globwalk = "0.9.1" globwalk = "0.8.1"
gxhash = { version = "3.4.1", default-features = false } gxhash = { version = "3.4.1", default-features = false }
indicatif = { version = "0.18.0", features = ["rayon"] } indicatif = { version = "0.17.2", features = ["rayon"] }
memmap2 = "0.9.7" memmap2 = "0.5.8"
open = "5.4.0"
pathdiff = "0.2.1" pathdiff = "0.2.1"
prettytable-rs = "0.10.0" prettytable-rs = "0.10.0"
ratatui = "0.30.2" rand = "0.9.1"
rayon = "1.6.1" rayon = "1.6.1"
unicode-segmentation = "1.12.0" threadpool = "1.8.1"
[profile.release] [profile.release]
strip = true strip = true
@@ -57,4 +56,3 @@ cargo-dist-version = "0.0.7"
[dev-dependencies] [dev-dependencies]
tempfile = "3.20.0" tempfile = "3.20.0"
rand = "0.9.1"

173
README.md
View File

@@ -15,17 +15,16 @@ 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, --exclude-types <EXCLUDE_TYPES> Exclude Filetypes [default = none] -t, --types <TYPES> Filetypes to deduplicate [default = all]
-t, --types <TYPES> Filetypes to deduplicate [default = all] -i, --interactive Delete files interactively
-i, --interactive Delete files interactively -m, --min-size <MIN_SIZE> Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T) [default: 1b]
-m, --min-size <MIN_SIZE> Minimum filesize of duplicates to scan (e.g., 100B/1K/2M/3G/4T) [default: 1b] -D, --max-depth <MAX_DEPTH> Max Depth to scan while looking for duplicates
-D, --max-depth <MAX_DEPTH> Max Depth to scan while looking for duplicates -d, --min-depth <MIN_DEPTH> Min Depth to scan while looking for duplicates
-d, --min-depth <MIN_DEPTH> Min Depth to scan while looking for duplicates -f, --follow-links Follow links while scanning directories
-f, --follow-links Follow links while scanning directories -s, --strict Guarantees that two files are duplicate (performs a full hash)
-s, --strict Guarantees that two files are duplicate (performs a full hash) -p, --progress Show Progress spinners & metrics
-p, --progress Show Progress spinners & metrics -h, --help Print help
-h, --help Print help -V, --version Print version
-V, --version Print version
``` ```
### Examples ### Examples
@@ -33,9 +32,6 @@ 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
@@ -50,8 +46,7 @@ deduplicator ~/Media --min-size 100mb
``` ```
## Demo ## Demo
![demo](https://github.com/user-attachments/assets/bdb95831-542d-4902-a458-4e0f5d171a33) ![record](https://github.com/user-attachments/assets/fcfdd9bf-4d05-41b6-a82e-367634eeaa73)
@@ -62,31 +57,16 @@ Currently, you can only install deduplicator using cargo package manager.
> GxHash relies on aes hardware acceleration, so please set `RUSTFLAGS` to `"-C target-feature=+aes"` or `"-C target-cpu=native"` before > GxHash relies on aes hardware acceleration, so please set `RUSTFLAGS` to `"-C target-feature=+aes"` or `"-C target-cpu=native"` before
> installing. > installing.
#### install from crates.io (stable) #### install from crates.io
```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) #### install from git
```bash ```bash
$ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator --git https://github.com/sreedevk/deduplicator $ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator --git https://github.com/sreedevk/deduplicator
# or
$ RUSTFLAGS="-C target-feature=+aes,+sse2" cargo install --git https://github.com/sreedevk/deduplicator
``` ```
### Manual Installation
- Download the right pre-compiled binary archive for your platform from [github release page](https://github.com/sreedevk/deduplicator/releases/tag/latest).
- Decompress it using `tar -zxvf <archive>.tar.gz`
- Move it to a directory included in `$PATH`.
- ideally `/usr/local/bin/`.
## 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 [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.
@@ -97,108 +77,77 @@ I've used hyperfine to run deduplicator on files generated by the rake file at `
``` ```
# hyperfine -N --warmup 80 './target/release/deduplicator bench_artifacts' # hyperfine -N --warmup 80 './target/release/deduplicator bench_artifacts'
Benchmark 1: ./target/release/deduplicator bench_artifacts Benchmark 1: ./target/release/deduplicator bench_artifacts
Time (mean ± σ): 2.2 ms ± 0.4 ms [User: 2.2 ms, System: 4.4 ms] Time (mean ± σ): 2.5 ms ± 0.4 ms [User: 2.2 ms, System: 4.8 ms]
Range (min … max): 1.3 ms … 7.1 ms 1522 runs Range (min … max): 1.9 ms … 6.8 ms 1322 runs
dust 'bench_artifacts' # dust 'bench_artifacts'
37M ┌── file_1_fwds.bin │██ │ 1%
54M ── file_0_fwds.bin │████ │ 2% 201M ── file_0_fwds.bin │██████████ 8%
122M ├── file_1_fwds.bin │████████ 5% 390M ├── file_0_fwdcbss.bin│████████████████████ │ 15%
390M ├── file_0_fwdcbss.bin│██████████████████████████ │ 15% 390M ├── file_0_fwscas.bin │████████████████████ │ 15%
390M ├── file_0_fwscas.bin │██████████████████████████ │ 15% 390M ├── file_0_fwss.bin │████████████████████ │ 15%
390M ├── file_0_fwss.bin │██████████████████████████ │ 15% 390M ├── file_1_fwdcbss.bin│████████████████████ │ 15%
390M ├── file_1_fwdcbss.bin│██████████████████████████ │ 15% 390M ├── file_1_fwscas.bin │████████████████████ │ 15%
390M ├── file_1_fwscas.bin │██████████████████████████ │ 15% 390M ├── file_1_fwss.bin │████████████████████ │ 15%
390M ├── file_1_fwss.bin │██████████████████████████ 15% 2.5G ┌─┴ bench_artifacts │███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ │ 100%
2.5G ┌─┴ bench_artifacts │██████████████████████████████████████████████████████████████████ │ 100%
``` ```
#### Many Small Files #### Many Small Files
``` ```
# hyperfine --warmup 20 './target/release/deduplicator bench_artifacts' # hyperfine --warmup 20 './target/release/deduplicator bench_artifacts'
Benchmark 1: ./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] Time (mean ± σ): 22.3 ms ± 1.7 ms [User: 35.8 ms, System: 46.3 ms]
Range (min … max): 35.0 ms … 45.9 ms 72 runs Range (min … max): 18.9 ms … 27.2 ms 112 runs
dust 'bench_artifacts' # dust 'bench_artifacts'
3.9M ┌── file_992_fwscas.bin │█ │ 0% 3.9M ┌── file_992_fwss.bin │█ │ 0%
3.9M ├── file_992_fwss.bin │█ │ 0% 3.9M ├── file_993_fwdcbss.bin│█ │ 0%
3.9M ├── file_993_fwdcbss.bin│█ │ 0% 3.9M ├── file_993_fwscas.bin │█ │ 0%
3.9M ├── file_993_fwscas.bin │█ │ 0% 3.9M ├── file_993_fwss.bin │█ │ 0%
3.9M ├── file_993_fwss.bin │█ │ 0% 3.9M ├── file_994_fwdcbss.bin│█ │ 0%
3.9M ├── file_994_fwdcbss.bin│█ │ 0% 3.9M ├── file_994_fwscas.bin │█ │ 0%
3.9M ├── file_994_fwscas.bin │█ │ 0% 3.9M ├── file_994_fwss.bin │█ │ 0%
3.9M ├── file_994_fwss.bin │█ │ 0% 3.9M ├── file_995_fwdcbss.bin│█ │ 0%
3.9M ├── file_995_fwdcbss.bin│█ │ 0% 3.9M ├── file_995_fwscas.bin │█ │ 0%
3.9M ├── file_995_fwscas.bin │█ │ 0% 3.9M ├── file_995_fwss.bin │█ │ 0%
3.9M ├── file_995_fwss.bin │█ │ 0% 3.9M ├── file_996_fwdcbss.bin│█ │ 0%
3.9M ├── file_996_fwdcbss.bin│█ │ 0% 3.9M ├── file_996_fwscas.bin │█ │ 0%
3.9M ├── file_996_fwscas.bin │█ │ 0% 3.9M ├── file_996_fwss.bin │█ │ 0%
3.9M ├── file_996_fwss.bin │█ │ 0% 3.9M ├── file_997_fwdcbss.bin│█ │ 0%
3.9M ├── file_997_fwdcbss.bin│█ │ 0% 3.9M ├── file_997_fwscas.bin │█ │ 0%
3.9M ├── file_997_fwscas.bin │█ │ 0% 3.9M ├── file_997_fwss.bin │█ │ 0%
3.9M ├── file_997_fwss.bin │█ │ 0% 3.9M ├── file_998_fwdcbss.bin│█ │ 0%
3.9M ├── file_998_fwdcbss.bin│█ │ 0% 3.9M ├── file_998_fwscas.bin │█ │ 0%
3.9M ├── file_998_fwscas.bin │█ │ 0% 3.9M ├── file_998_fwss.bin │█ │ 0%
3.9M ├── file_998_fwss.bin │█ │ 0% 3.9M ├── file_999_fwdcbss.bin│█ │ 0%
3.9M ├── file_999_fwdcbss.bin│█ │ 0% 3.9M ├── file_999_fwscas.bin │█ │ 0%
3.9M ├── file_999_fwscas.bin │█ │ 0% 3.9M ├── file_999_fwss.bin │█ │ 0%
3.9M ├── file_999_fwss.bin │█ │ 0% 3.9M ├── file_99_fwdcbss.bin │█ │ 0%
3.9M ├── file_99_fwdcbss.bin │█ │ 0% 3.9M ├── file_99_fwscas.bin │█ │ 0%
3.9M ├── file_99_fwscas.bin │█ │ 0% 3.9M ├── file_99_fwss.bin │█ │ 0%
3.9M ├── file_99_fwss.bin │█ │ 0% 3.9M ├── file_9_fwdcbss.bin │█ │ 0%
3.9M ├── file_9_fwdcbss.bin │█ │ 0% 3.9M ├── file_9_fwscas.bin │█ │ 0%
3.9M ├── file_9_fwscas.bin │█ │ 0% 3.9M ├── file_9_fwss.bin │█ │ 0%
3.9M ├── file_9_fwss.bin │█ 0% 11G ┌─┴ bench_artifacts │█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████100%
11G ┌─┴ bench_artifacts │████████████████████████████████████████████████████████████████ │ 100%
``` ```
## proposed ## proposed
- [ ] parallelization - [ ] parallelization
- [ ] (scanning + processing sw + processing hw) & formatting & printing
- [ ] scanning + processing sw + processing hw + formatting + printing - [ ] scanning + processing sw + processing hw + formatting + printing
- [ ] user supplied cache file path for faster re-runs
- [ ] hardlinks / symlinks support
- [ ] max file path size should use the last set of duplicates - [ ] max file path size should use the last set of duplicates
- [ ] add more unit tests - [ ] add more unit tests
- [ ] test against different filesystems - [ ] test against different filesystems
- [ ] test against different file name encodings - [ ] test against different file name encodings
- [ ] restore json output (was removed in 0.3 due to quality issues) - [ ] restore json output (was removed in 0.3)
- [ ] fix memory leak on very large filesystems - [ ] fix memory leak on very large filesystems
- [ ] maybe use a bloom filter - [ ] maybe use a bloom filter
- [ ] reduce FileInfo size - [ ] reduce FileInfo size
- [ ] output in a tree format
- [ ] tui - [ ] tui
- [ ] change the default hashing method to include the first & last page of a file (8K) - [ ] 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. ## v0.3
- [ ] 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] parallelization
- [x] (scanning) + (processing sw & processing hw & formatting & printing) - [x] (scanning) + (processing sw & processing hw & formatting & printing)
- [x] reduce cloning values on the heap - [x] reduce cloning values on the heap

View File

@@ -13,11 +13,9 @@ namespace :benchmark do
# Range (min … max): 1.8 ms … 9.7 ms 1474 runs # Range (min … max): 1.8 ms … 9.7 ms 1474 runs
root = "bench_artifacts" root = "bench_artifacts"
FileUtils.rm_rf(root)
Dir.mkdir(root) Dir.mkdir(root)
# files with same size # files with same size
puts "generating files of same size ..."
2.times.map do |i| 2.times.map do |i|
File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f|
f.write(SecureRandom.bytes(4096 * 100_000)) f.write(SecureRandom.bytes(4096 * 100_000))
@@ -25,7 +23,6 @@ namespace :benchmark do
end end
# files with different sizes # files with different sizes
puts "generating files of different sizes ..."
2.times.map do |i| 2.times.map do |i|
File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f|
f.write(SecureRandom.bytes(4096 * (rand * 100_000).ceil)) f.write(SecureRandom.bytes(4096 * (rand * 100_000).ceil))
@@ -33,7 +30,6 @@ namespace :benchmark do
end end
# files with same content & size # files with same content & size
puts "generating files of same content and sizes ..."
2.times.each do |i| 2.times.each do |i|
File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f|
f.write("\0" * (4096 * 100_000)) f.write("\0" * (4096 * 100_000))
@@ -41,7 +37,6 @@ namespace :benchmark do
end end
# files with different content but same size # files with different content but same size
puts "generating files of different content but same sizes ..."
2.times.each do |i| 2.times.each do |i|
File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f|
f.write(SecureRandom.bytes(4096 * 100_000)) f.write(SecureRandom.bytes(4096 * 100_000))
@@ -63,7 +58,6 @@ namespace :benchmark do
Dir.mkdir(root) Dir.mkdir(root)
# files with same size # files with same size
puts "generating 1000 files of the same size ... "
1000.times.each do |i| 1000.times.each do |i|
File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f|
f.write(SecureRandom.bytes(4096 * 1000)) f.write(SecureRandom.bytes(4096 * 1000))
@@ -71,7 +65,6 @@ namespace :benchmark do
end end
# files with different sizes # files with different sizes
puts "generating 1000 files of different sizes ... "
1000.times.each do |i| 1000.times.each do |i|
File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f|
f.write(SecureRandom.bytes(4096 * (rand * 100).ceil)) f.write(SecureRandom.bytes(4096 * (rand * 100).ceil))
@@ -79,7 +72,6 @@ namespace :benchmark do
end end
# files with same content & size # files with same content & size
puts "generating files of same content and sizes ..."
1000.times.each do |i| 1000.times.each do |i|
File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f|
f.write("\0" * (4096 * 1000)) f.write("\0" * (4096 * 1000))
@@ -87,7 +79,6 @@ namespace :benchmark do
end end
# files with different content but same size # files with different content but same size
puts "generating files of different content but same sizes ..."
1000.times.each do |i| 1000.times.each do |i|
File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f| File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f|
f.write(SecureRandom.bytes(4096 * 1000)) f.write(SecureRandom.bytes(4096 * 1000))

View File

@@ -1,249 +0,0 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const MAGIC: &[u8; 4] = b"DDUP";
const VERSION: u8 = 1;
const RECORD_FIXED_LEN: usize = 45;
pub const CACHE_TTL_DAYS: u64 = 30;
pub struct CacheEntry {
pub size: u64,
pub mtime: u64,
pub strict: bool,
pub hash: u128,
pub last_seen: u64,
}
pub struct Cache {
entries: HashMap<PathBuf, CacheEntry>,
enabled: bool,
}
pub fn mtime_nanos(modified: SystemTime) -> u64 {
modified
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
impl Cache {
pub fn disabled() -> Self {
Self {
entries: HashMap::new(),
enabled: false,
}
}
fn empty_enabled() -> Self {
Self {
entries: HashMap::new(),
enabled: true,
}
}
pub fn load(path: &Path) -> Self {
match fs::read(path) {
Ok(bytes) => Self::parse(&bytes).unwrap_or_else(Self::empty_enabled),
Err(_) => Self::empty_enabled(),
}
}
fn parse(bytes: &[u8]) -> Option<Self> {
if bytes.len() < 5 || &bytes[0..4] != MAGIC || bytes[4] != VERSION {
return None;
}
let mut entries = HashMap::new();
let mut cur = &bytes[5..];
while cur.len() >= RECORD_FIXED_LEN {
let hash = u128::from_le_bytes(cur[0..16].try_into().ok()?);
let size = u64::from_le_bytes(cur[16..24].try_into().ok()?);
let mtime = u64::from_le_bytes(cur[24..32].try_into().ok()?);
let last_seen = u64::from_le_bytes(cur[32..40].try_into().ok()?);
let strict = cur[40] != 0;
let path_len = u32::from_le_bytes(cur[41..45].try_into().ok()?) as usize;
let rest = &cur[RECORD_FIXED_LEN..];
if rest.len() < path_len {
break;
}
match std::str::from_utf8(&rest[..path_len]) {
Ok(s) => {
entries.insert(
PathBuf::from(s),
CacheEntry {
size,
mtime,
strict,
hash,
last_seen,
},
);
}
Err(_) => {}
}
cur = &rest[path_len..];
}
Some(Self {
entries,
enabled: true,
})
}
pub fn lookup(&self, path: &Path, size: u64, mtime: u64, strict: bool) -> Option<u128> {
let entry = self.entries.get(path)?;
match entry.size == size && entry.mtime == mtime && entry.strict == strict {
true => Some(entry.hash),
false => None,
}
}
pub fn record(
&mut self,
path: &Path,
size: u64,
mtime: u64,
strict: bool,
hash: u128,
now_secs: u64,
) {
if path.to_str().is_none() {
return;
}
self.entries.insert(
path.to_path_buf(),
CacheEntry {
size,
mtime,
strict,
hash,
last_seen: now_secs,
},
);
}
pub fn save(&self, path: &Path, ttl_secs: u64, now_secs: u64) {
if !self.enabled {
return;
}
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(MAGIC);
buf.push(VERSION);
for (entry_path, entry) in &self.entries {
if now_secs.saturating_sub(entry.last_seen) > ttl_secs {
continue;
}
let path_str = match entry_path.to_str() {
Some(s) => s,
None => continue,
};
buf.extend_from_slice(&entry.hash.to_le_bytes());
buf.extend_from_slice(&entry.size.to_le_bytes());
buf.extend_from_slice(&entry.mtime.to_le_bytes());
buf.extend_from_slice(&entry.last_seen.to_le_bytes());
buf.push(entry.strict as u8);
buf.extend_from_slice(&(path_str.len() as u32).to_le_bytes());
buf.extend_from_slice(path_str.as_bytes());
}
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(err) = fs::write(path, &buf) {
eprintln!("warning: failed to write cache to {}: {err}", path.display());
}
}
pub fn default_path() -> Option<PathBuf> {
dirs::cache_dir().map(|dir| dir.join("deduplicator").join("cache.bin"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use tempfile::TempDir;
#[test]
fn lookup_hits_on_exact_match() {
let mut c = Cache::disabled();
c.record(Path::new("/a/b"), 100, 200, false, 42, 1000);
assert_eq!(c.lookup(Path::new("/a/b"), 100, 200, false), Some(42));
}
#[test]
fn lookup_misses_on_any_field_change_or_absent() {
let mut c = Cache::disabled();
c.record(Path::new("/a/b"), 100, 200, false, 42, 1000);
assert_eq!(c.lookup(Path::new("/a/b"), 101, 200, false), None);
assert_eq!(c.lookup(Path::new("/a/b"), 100, 201, false), None);
assert_eq!(c.lookup(Path::new("/a/b"), 100, 200, true), None);
assert_eq!(c.lookup(Path::new("/a/x"), 100, 200, false), None);
}
#[test]
fn save_then_load_roundtrips_entries() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("cache.bin");
let mut c = Cache::load(&file);
c.record(Path::new("/a/b"), 100, 200, false, 42, 1000);
c.record(Path::new("/c/d"), 5, 6, true, 99, 1000);
c.save(&file, 86_400, 1000);
let loaded = Cache::load(&file);
assert_eq!(loaded.lookup(Path::new("/a/b"), 100, 200, false), Some(42));
assert_eq!(loaded.lookup(Path::new("/c/d"), 5, 6, true), Some(99));
}
#[test]
fn load_returns_empty_on_corrupt_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("cache.bin");
std::fs::write(&file, b"not a valid cache file at all").unwrap();
let c = Cache::load(&file);
assert_eq!(c.lookup(Path::new("/a/b"), 100, 200, false), None);
}
#[test]
fn load_returns_empty_on_version_mismatch() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("cache.bin");
std::fs::write(&file, b"DDUP\x02").unwrap();
let c = Cache::load(&file);
assert_eq!(c.lookup(Path::new("/a/b"), 100, 200, false), None);
}
#[test]
fn save_prunes_entries_older_than_ttl() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("cache.bin");
let mut c = Cache::load(&file);
c.record(Path::new("/old"), 1, 2, false, 10, 1000);
c.record(Path::new("/new"), 3, 4, false, 20, 5000);
c.save(&file, 1000, 5000);
let loaded = Cache::load(&file);
assert_eq!(loaded.lookup(Path::new("/old"), 1, 2, false), None);
assert_eq!(loaded.lookup(Path::new("/new"), 3, 4, false), Some(20));
}
#[test]
fn mtime_nanos_converts_systemtime() {
let t = UNIX_EPOCH + Duration::from_nanos(1234);
assert_eq!(mtime_nanos(t), 1234);
}
}

View File

@@ -17,23 +17,18 @@ pub struct FileInfo {
impl FileInfo { impl FileInfo {
pub fn hash(&self, seed: i64) -> Result<u128> { pub fn hash(&self, seed: i64) -> Result<u128> {
if self.size == 0 {
return Ok(0u128);
};
let file = fs::File::open(&self.path)?; let file = fs::File::open(&self.path)?;
let mapper = unsafe { Mmap::map(&file)? }; let mapper = unsafe { Mmap::map(&file)? };
let content_hash = mapper let final_hash = mapper.chunks(4096).fold(0u128, |acc, chunk: &[u8]| {
.chunks(4096) acc.wrapping_add(gxhash128(chunk, seed))
.fold(0u128, |acc, chunk: &[u8]| acc ^ gxhash128(chunk, seed)); });
// NOTE: avoids collision bw an empty file & a file full of null bytes. Ok(final_hash)
Ok(content_hash ^ gxhash128(&self.size.to_ne_bytes(), seed))
} }
pub fn initpages_hash(&self, seed: i64) -> Result<u128> { pub fn initial_page_hash(&self, seed: i64) -> Result<u128> {
let mut file = fs::File::open(&self.path)?; let mut file = fs::File::open(&self.path)?;
let mut buffer = [0; 16384]; let mut buffer = [0; 4096];
let bytes_read = file.read(&mut buffer)?; let bytes_read = file.read(&mut buffer)?;
Ok(gxhash128(&buffer[..bytes_read], seed)) Ok(gxhash128(&buffer[..bytes_read], seed))
@@ -48,38 +43,3 @@ impl FileInfo {
}) })
} }
} }
#[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(())
}
}

View File

@@ -1,23 +1,27 @@
use crate::{fileinfo::FileInfo, params::Params, pipeline::DedupReport}; use crate::{fileinfo::FileInfo, params::Params};
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use dashmap::DashMap;
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressFinish, ProgressStyle};
use pathdiff::diff_paths; use pathdiff::diff_paths;
use prettytable::{format, row, Row, Table};
use rayon::prelude::*; use rayon::prelude::*;
use std::path::PathBuf; use std::{borrow::Cow, path::PathBuf, sync::Arc, time::Duration};
const YELLOW: &str = "\x1b[33m";
const RESET: &str = "\x1b[0m";
pub struct Formatter; pub struct Formatter;
impl Formatter { impl Formatter {
pub fn human_path(file: &FileInfo, aargs: &Params, max_path_length: usize) -> Result<String> { pub fn human_path(
let base_directory: PathBuf = aargs.get_directory()?; file: &FileInfo,
app_args: &Params,
min_path_length: usize,
) -> Result<String> {
let base_directory: PathBuf = app_args.get_directory()?;
let relative_path = diff_paths(&file.path, base_directory).unwrap_or_default(); let relative_path = diff_paths(&file.path, 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 = max_path_length width = min_path_length
); );
Ok(formatted_path) Ok(formatted_path)
@@ -32,39 +36,66 @@ impl Formatter {
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 print(report: &DedupReport, aargs: &Params) { pub fn gen_sub_tbl(items: Vec<FileInfo>, app_args: &Params, max_path_len: u64) -> Table {
print!("{}", "\n".repeat(if aargs.progress { 2 } else { 1 })); let mut inner_table = Table::new();
inner_table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
items.iter().for_each(|file| {
inner_table.add_row(row![
Self::human_path(file, app_args, max_path_len as usize).unwrap_or_default(),
Self::human_filesize(file).unwrap_or_default(),
Self::human_mtime(file).unwrap_or_default()
]);
});
inner_table
}
if report.groups.is_empty() { pub fn generate_table(
println!("No duplicates found matching your search criteria."); raw: Arc<DashMap<u128, Vec<FileInfo>>>,
return; mpath_len: u64,
args: &Params,
) -> Result<Table> {
let progress_bar = match args.progress {
true => 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("generating output");
let rows = raw
.par_iter_mut()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from("output generated")))
.filter(|i| i.value().len() > 1)
.map(|i| {
row![
i.key(),
Self::gen_sub_tbl(i.value().to_vec(), args, mpath_len)
]
})
.collect::<Vec<Row>>();
let mut output_table = Table::new();
output_table.set_titles(row!["hash", "duplicates"]);
output_table.extend(rows);
Ok(output_table)
}
pub fn print(
raw: Arc<DashMap<u128, Vec<FileInfo>>>,
max_path_len: u64,
app_args: &Params,
) -> Result<()> {
if raw.is_empty() {
println!("\n\nNo duplicates found matching your search criteria.\n");
return Ok(());
} }
report.groups.par_iter().for_each(|group| { let output_table = Self::generate_table(raw, max_path_len, app_args)?;
let mut ostring = format!("{}{:32x}{}\n", YELLOW, group.hash, RESET); output_table.printstd();
let subfields = group
.files
.par_iter()
.enumerate()
.map(|(i, finfo)| {
let nodechar = if i == group.files.len() - 1 {
"└─"
} else {
"├─"
};
format!(
"{}\t{}\t{}\t{}\n",
nodechar,
Self::human_path(finfo, aargs, report.max_path_len)
.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); Ok(())
println!("{ostring}");
});
} }
} }

View File

@@ -1,42 +1,45 @@
use crate::{fileinfo::FileInfo, formatter::Formatter, params::Params, pipeline::DuplicateGroup}; use crate::{fileinfo::FileInfo, formatter::Formatter, params::Params};
use anyhow::Result; use anyhow::Result;
use dashmap::DashMap;
use prettytable::{format, row, Table}; use prettytable::{format, row, Table};
use std::io::{self, Write}; use std::{
use unicode_segmentation::UnicodeSegmentation; io::{self, Write},
sync::Arc,
};
pub struct Interactive; pub struct Interactive;
impl Interactive { impl Interactive {
pub fn init(groups: &[DuplicateGroup], app_args: &Params) -> Result<()> { pub fn init(result: Arc<DashMap<u128, Vec<FileInfo>>>, app_args: &Params) -> Result<()> {
if groups.is_empty() { result
println!("No duplicates found matching your search criteria."); .clone()
return Ok(()); .iter()
} .filter(|i| i.value().len() > 1)
.enumerate()
.for_each(|(gindex, i)| {
let group = i.value();
let mut itable = Table::new();
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
groups.iter().enumerate().for_each(|(gindex, group)| { let max_path_size = group
let files = &group.files; .iter()
let mut itable = Table::new(); .map(|f| f.path.iter().count())
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR); .max()
itable.set_titles(row!["index", "filename", "size", "updated_at"]); .unwrap_or_default();
let max_path_size = files group.iter().enumerate().for_each(|(index, file)| {
.iter() itable.add_row(row![
.map(|f| f.path.to_string_lossy().graphemes(true).count()) index,
.max() Formatter::human_path(file, app_args, max_path_size).unwrap_or_default(),
.unwrap_or_default(); Formatter::human_filesize(file).unwrap_or_default(),
Formatter::human_mtime(file).unwrap_or_default()
]);
});
files.iter().enumerate().for_each(|(index, file)| { Self::process_group_action(group, gindex, result.len(), itable);
itable.add_row(row![
index,
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()
]);
}); });
Self::process_group_action(files, gindex, groups.len(), itable);
});
Ok(()) Ok(())
} }

View File

@@ -1,29 +1,35 @@
mod cache;
mod fileinfo; mod fileinfo;
mod formatter; mod formatter;
mod interactive; mod interactive;
mod params; mod params;
mod pipeline;
mod processor; mod processor;
mod resolver;
mod scanner; mod scanner;
mod tui; mod server;
use self::{formatter::Formatter, interactive::Interactive}; use self::{formatter::Formatter, interactive::Interactive, server::Server};
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use params::Params; use params::Params;
use std::sync::atomic::Ordering;
fn main() -> Result<()> { fn main() -> Result<()> {
let params = Params::parse(); let app_args = Params::parse();
let report = pipeline::run(&params)?; let server = Server::new(app_args.clone());
match (params.tui, params.keep, params.interactive) { server.start()?;
(true, _, _) => tui::run(report, &params)?,
(false, Some(strategy), _) => resolver::run(&report, strategy, params.force, &params)?, match app_args.interactive {
(false, None, true) => Interactive::init(&report.groups, &params)?, false => {
(false, None, false) => Formatter::print(&report, &params), 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(())
} }

View File

@@ -6,9 +6,6 @@ use clap::{Parser, ValueHint};
#[derive(Parser, Debug, Default, 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>,
@@ -18,12 +15,6 @@ pub struct Params {
/// Delete files interactively /// Delete files interactively
#[arg(long, short)] #[arg(long, short)]
pub interactive: bool, pub interactive: bool,
/// Keep one file per duplicate group by this rule and remove the rest
#[arg(long, conflicts_with = "interactive")]
pub keep: Option<crate::resolver::KeepStrategy>,
/// Actually delete the duplicates (without this, --keep only previews)
#[arg(long, visible_alias = "yes", requires = "keep")]
pub force: 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 = 'm', default_value = "1b")] #[arg(long, short = 'm', default_value = "1b")]
pub min_size: Option<String>, pub min_size: Option<String>,
@@ -42,15 +33,6 @@ pub struct Params {
/// Show Progress spinners & metrics /// Show Progress spinners & metrics
#[arg(long, short = 'p', default_value = "false")] #[arg(long, short = 'p', default_value = "false")]
pub progress: bool, pub progress: bool,
/// Disable the on-disk hash cache
#[arg(long)]
pub no_cache: bool,
/// Use a specific cache file instead of the default location
#[arg(long, value_name = "PATH")]
pub cache_file: Option<PathBuf>,
/// Browse and resolve duplicates in an interactive terminal UI
#[arg(long, conflicts_with_all = ["keep", "interactive"])]
pub tui: bool,
} }
impl Params { impl Params {
@@ -70,4 +52,8 @@ 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()
}
} }

View File

@@ -1,182 +0,0 @@
use anyhow::Result;
use indicatif::{ProgressBar, ProgressStyle};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use unicode_segmentation::UnicodeSegmentation;
use crate::cache::{mtime_nanos, Cache, CACHE_TTL_DAYS};
use crate::fileinfo::FileInfo;
use crate::params::Params;
use crate::processor;
use crate::scanner::Scanner;
const HASH_SEED: i64 = 0x00DE_D0CA_C4E5_EED1;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub struct DuplicateGroup {
pub hash: u128,
pub files: Vec<FileInfo>,
}
pub struct DedupReport {
pub groups: Vec<DuplicateGroup>,
pub max_path_len: usize,
}
pub(crate) fn spinner(enabled: bool, message: &'static str) -> ProgressBar {
let bar = if enabled {
ProgressBar::new_spinner()
} else {
ProgressBar::hidden()
};
let style = ProgressStyle::with_template("[{elapsed_precise}] {pos:>7} {msg}")
.expect("valid progress template");
bar.set_style(style);
bar.enable_steady_tick(Duration::from_millis(50));
bar.set_message(message);
bar
}
pub fn run(params: &Params) -> Result<DedupReport> {
let seed = HASH_SEED;
let cache_path = match params.no_cache {
true => None,
false => params.cache_file.clone().or_else(Cache::default_path),
};
let mut cache = match (params.no_cache, &cache_path) {
(false, Some(path)) => Cache::load(path),
_ => Cache::disabled(),
};
let files = Scanner::new(params)?.scan()?;
let size_groups = processor::group_by_size(files, params.progress);
let candidates: Vec<FileInfo> = size_groups
.into_iter()
.filter(|group| group.len() > 1)
.flatten()
.collect();
let max_path_len = candidates
.iter()
.map(|file| file.path.to_string_lossy().graphemes(true).count())
.max()
.unwrap_or(0);
let hashed = processor::hash_candidates(candidates, params.strict, seed, params.progress, &cache);
let now = now_secs();
for (hash, file) in &hashed {
let mtime = mtime_nanos(file.modified);
cache.record(&file.path, file.size, mtime, params.strict, *hash, now);
}
let groups = processor::group_hashed(hashed);
if let Some(path) = &cache_path {
cache.save(path, CACHE_TTL_DAYS * 86_400, now);
}
Ok(DedupReport {
groups,
max_path_len,
})
}
#[cfg(test)]
mod tests {
use super::run;
use crate::params::Params;
use anyhow::Result;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn run_finds_a_single_group_of_identical_files() -> Result<()> {
let root = TempDir::new()?;
let duplicate = vec![7u8; 200_000];
for name in ["a.bin", "b.bin"] {
let mut file = File::create_new(root.path().join(name))?;
file.write_all(&duplicate)?;
}
let mut unique = File::create_new(root.path().join("c.bin"))?;
unique.write_all(&vec![9u8; 100_000])?;
let params = Params {
dir: Some(root.path().into()),
..Default::default()
};
let report = run(&params)?;
assert_eq!(report.groups.len(), 1);
assert_eq!(report.groups[0].files.len(), 2);
Ok(())
}
}
#[cfg(test)]
mod cache_tests {
use super::run;
use crate::params::Params;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn make_tree(root: &TempDir) {
for name in ["a.bin", "b.bin"] {
let mut f = File::create_new(root.path().join(name)).unwrap();
f.write_all(&vec![7u8; 200_000]).unwrap();
}
let mut u = File::create_new(root.path().join("c.bin")).unwrap();
u.write_all(&vec![9u8; 100_000]).unwrap();
}
#[test]
fn run_twice_with_cache_is_identical_and_writes_the_file() {
let root = TempDir::new().unwrap();
make_tree(&root);
let cache_file = root.path().join("dd.cache");
let params = Params {
dir: Some(root.path().into()),
cache_file: Some(cache_file.clone()),
..Default::default()
};
let first = run(&params).unwrap();
assert!(cache_file.exists());
let second = run(&params).unwrap();
assert_eq!(first.groups.len(), second.groups.len());
assert_eq!(first.groups.len(), 1);
assert_eq!(second.groups[0].files.len(), 2);
}
#[test]
fn no_cache_does_not_write_a_cache_file() {
let root = TempDir::new().unwrap();
make_tree(&root);
let cache_file = root.path().join("dd.cache");
let params = Params {
dir: Some(root.path().into()),
no_cache: true,
cache_file: Some(cache_file.clone()),
..Default::default()
};
run(&params).unwrap();
assert!(!cache_file.exists());
}
}

View File

@@ -1,214 +1,369 @@
use std::collections::HashMap; use anyhow::Result;
use dashmap::DashMap;
use indicatif::{
MultiProgress, ParallelProgressIterator, ProgressBar, ProgressFinish, ProgressStyle,
};
use rayon::prelude::{IntoParallelIterator, ParallelIterator}; use rayon::prelude::{IntoParallelIterator, ParallelIterator};
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, TryLockError, TryLockResult};
use std::time::Duration;
use crate::cache::{mtime_nanos, Cache};
use crate::fileinfo::FileInfo; use crate::fileinfo::FileInfo;
use crate::pipeline::{spinner, DuplicateGroup}; use crate::params::Params;
pub fn group_by_size(files: Vec<FileInfo>, progress: bool) -> Vec<Vec<FileInfo>> { pub struct Processor {}
let bar = spinner(progress, "files grouped by size");
let mut buckets: HashMap<u64, Vec<FileInfo>> = HashMap::new(); impl Processor {
for file in files { pub fn hashwise(
bar.inc(1); app_args: Arc<Params>,
buckets.entry(file.size).or_default().push(file); sw_store: Arc<DashMap<u64, Vec<FileInfo>>>,
hw_store: Arc<DashMap<u128, Vec<FileInfo>>>,
progress_bar_box: Arc<MultiProgress>,
max_file_size: Arc<AtomicU64>,
seed: i64,
) -> Result<()> {
let progress_bar = match app_args.progress {
true => progress_bar_box.add(ProgressBar::new_spinner()),
false => ProgressBar::hidden(),
};
let keys: Vec<u64> = sw_store.clone().iter().map(|i| *i.key()).collect();
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.");
keys.into_par_iter()
.progress_with(progress_bar)
.with_finish(ProgressFinish::WithMessage(Cow::from(
"files grouped by hash.",
)))
.for_each(|key| {
let group: Vec<FileInfo> = sw_store.get(&key).unwrap().to_vec();
if group.len() > 1 {
group.into_par_iter().for_each(|file| {
let fhash = if app_args.strict {
file.hash(seed).expect("hashing file failed.")
} else {
file.initial_page_hash(seed).expect("hashing file failed.")
};
Self::compare_and_update_max_path_len(
max_file_size.clone(),
file.path.to_string_lossy().len() as u64,
)
.unwrap();
hw_store
.entry(fhash)
.and_modify(|fileset| fileset.push(file.clone()))
.or_insert_with(|| vec![file]);
});
}
});
Ok(())
} }
bar.finish_with_message("files grouped by size"); pub fn compare_and_update_max_path_len(current: Arc<AtomicU64>, next: u64) -> Result<()> {
buckets.into_values().collect() if current.load(Ordering::Relaxed) < next {
} current.store(next, Ordering::Release);
}
pub fn hash_candidates( Ok(())
candidates: Vec<FileInfo>, }
strict: bool,
seed: i64,
progress: bool,
cache: &Cache,
) -> Vec<(u128, FileInfo)> {
let bar = spinner(progress, "files grouped by hash");
let hashed: Vec<(u128, FileInfo)> = candidates pub fn sizewise(
.into_par_iter() app_args: Arc<Params>,
.map(|file| { scanner_finished: Arc<AtomicBool>,
bar.inc(1); store: Arc<DashMap<u64, Vec<FileInfo>>>,
let mtime = mtime_nanos(file.modified); files: Arc<Mutex<Vec<FileInfo>>>,
let hash = match cache.lookup(&file.path, file.size, mtime, strict) { progress_bar_box: Arc<MultiProgress>,
Some(cached) => cached, ) -> Result<()> {
None => match strict { let progress_bar = match app_args.progress {
true => file.hash(seed).expect("hashing file failed."), true => progress_bar_box.add(ProgressBar::new_spinner()),
false => file.initpages_hash(seed).expect("hashing file failed."), 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 size");
loop {
let fileopt: Option<FileInfo> = {
match files.try_lock() {
Ok(mut flist) => flist.pop(),
TryLockResult::Err(TryLockError::WouldBlock) => None,
_ => None,
}
}; };
(hash, file)
})
.collect();
bar.finish_with_message("files grouped by hash."); match fileopt {
hashed Some(file) => {
} progress_bar.inc(1);
store
pub fn group_hashed(hashed: Vec<(u128, FileInfo)>) -> Vec<DuplicateGroup> { .entry(file.size)
let mut buckets: HashMap<u128, Vec<FileInfo>> = HashMap::new(); .and_modify(|fileset| fileset.push(file.clone()))
for (hash, file) in hashed { .or_insert_with(|| vec![file]);
buckets.entry(hash).or_default().push(file); continue;
}
None => match scanner_finished.load(std::sync::atomic::Ordering::Relaxed) {
true => {
progress_bar.finish_with_message("files grouped by size");
break Ok(());
}
false => continue,
},
}
}
} }
buckets
.into_iter()
.filter(|(_, files)| files.len() > 1)
.map(|(hash, files)| DuplicateGroup { hash, files })
.collect()
} }
#[cfg(test)] #[cfg(test)]
mod staged_tests { mod tests {
use anyhow::Result; use anyhow::Result;
use dashmap::DashMap;
use indicatif::MultiProgress;
use rand::Rng; use rand::Rng;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Arc, Mutex};
use tempfile::TempDir; use tempfile::TempDir;
use crate::fileinfo::FileInfo; use crate::{fileinfo::FileInfo, params::Params};
use super::Processor;
fn generate_bytes(size: usize) -> Vec<u8> { fn generate_bytes(size: usize) -> Vec<u8> {
let mut rng = rand::rng(); let mut rng = rand::rng();
(0..size).map(|_| rng.random::<u8>()).collect::<Vec<u8>>() (0..size).map(|_| rng.random::<u8>()).collect::<Vec<u8>>()
} }
fn write_files(root: &TempDir, specs: Vec<(&str, Vec<u8>)>) -> Result<Vec<FileInfo>> {
specs
.into_iter()
.map(|(name, content)| {
let path = root.path().join(name);
let mut file = File::create_new(&path)?;
file.write_all(&content)?;
FileInfo::new(path)
})
.collect()
}
#[test] #[test]
fn group_by_size_separates_files_of_different_sizes() -> Result<()> { fn hashwise_sorting_two_files_with_identical_init_page_only_strict_mode() -> Result<()> {
let root = TempDir::new()?; let root = TempDir::new()?;
let files = write_files( let content = generate_bytes(4096);
&root,
vec![
("fileone.bin", generate_bytes(282624)),
("filetwo.bin", generate_bytes(1720320)),
],
)?;
let groups = super::group_by_size(files, false); let mut content_x = content.clone();
assert_eq!(groups.len(), 2); let mut content_y = content.clone();
Ok(())
}
#[test]
fn group_by_size_buckets_same_size_files_together() -> Result<()> {
let root = TempDir::new()?;
let files = write_files(
&root,
vec![
("fileone.bin", generate_bytes(282624)),
("filetwo.bin", generate_bytes(282624)),
],
)?;
let groups = super::group_by_size(files, false);
assert_eq!(groups.len(), 1);
Ok(())
}
#[test]
fn group_by_hash_fast_mode_matches_identical_init_pages() -> Result<()> {
let root = TempDir::new()?;
let shared = generate_bytes(16384);
let mut content_x = shared.clone();
let mut content_y = shared.clone();
content_x.extend(generate_bytes(1720320)); content_x.extend(generate_bytes(1720320));
content_y.extend(generate_bytes(1720320)); content_y.extend(generate_bytes(1720320));
let files = write_files( let files = [
&root, (root.path().join("fileone.bin"), content_x),
vec![("fileone.bin", content_x), ("filetwo.bin", content_y)], (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 groups = super::group_hashed(super::hash_candidates(files, false, 300, false, &crate::cache::Cache::disabled())); let args = Params {
assert_eq!(groups.len(), 1); strict: true,
..Default::default()
};
Processor::hashwise(
Arc::new(args),
dupstore.clone(),
hw_dupstore.clone(),
Arc::new(MultiProgress::new()),
Arc::new(AtomicU64::new(32)),
300,
)?;
assert_eq!(hw_dupstore.len(), 2);
Ok(()) Ok(())
} }
#[test] #[test]
fn group_by_hash_strict_mode_rejects_different_tails() -> Result<()> { fn hashwise_sorting_two_files_with_identical_init_page_only_fast_mode() -> Result<()> {
let root = TempDir::new()?; let root = TempDir::new()?;
let shared = generate_bytes(16384); let content = generate_bytes(4096);
let mut content_x = content.clone();
let mut content_y = content.clone();
let mut content_x = shared.clone();
let mut content_y = shared.clone();
content_x.extend(generate_bytes(1720320)); content_x.extend(generate_bytes(1720320));
content_y.extend(generate_bytes(1720320)); content_y.extend(generate_bytes(1720320));
let files = write_files( let files = [
&root, (root.path().join("fileone.bin"), content_x),
vec![("fileone.bin", content_x), ("filetwo.bin", content_y)], (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 groups = super::group_hashed(super::hash_candidates(files, true, 300, false, &crate::cache::Cache::disabled())); Processor::hashwise(
assert_eq!(groups.len(), 0); Arc::new(Params::default()),
dupstore.clone(),
hw_dupstore.clone(),
Arc::new(MultiProgress::new()),
Arc::new(AtomicU64::new(32)),
300,
)?;
assert_eq!(hw_dupstore.len(), 1);
Ok(()) Ok(())
} }
#[test] #[test]
fn group_by_hash_matches_identical_files() -> Result<()> { fn hashwise_sorting_two_files_with_identical_data() -> Result<()> {
let root = TempDir::new()?; let root = TempDir::new()?;
let content = generate_bytes(282624); let content = generate_bytes(282624);
let files = [
(root.path().join("fileone.bin"), content.clone()),
(root.path().join("filetwo.bin"), content.clone()),
];
let files = write_files( for (fpath, content) in files.iter() {
&root, let mut f = File::create_new(fpath)?;
vec![ f.write_all(content)?;
("fileone.bin", content.clone()), }
("filetwo.bin", content.clone()),
], 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 groups = super::group_hashed(super::hash_candidates(files, false, 300, false, &crate::cache::Cache::disabled())); Processor::hashwise(
assert_eq!(groups.len(), 1); Arc::new(Params::default()),
dupstore.clone(),
hw_dupstore.clone(),
Arc::new(MultiProgress::new()),
Arc::new(AtomicU64::new(32)),
300,
)?;
assert_eq!(hw_dupstore.len(), 1);
Ok(()) Ok(())
} }
#[test] #[test]
fn hash_candidates_uses_cached_hash_when_valid() { fn sizewise_sorting_two_files_of_different_sizes() -> Result<()> {
let root = TempDir::new().unwrap(); let root = TempDir::new()?;
let path = root.path().join("f.bin"); let files = [
let mut f = File::create_new(&path).unwrap(); (root.path().join("fileone.bin"), generate_bytes(282624)),
f.write_all(b"real content for cache hit test").unwrap(); (root.path().join("filetwo.bin"), generate_bytes(1720320)),
let info = FileInfo::new(path.clone()).unwrap(); ];
let mtime = crate::cache::mtime_nanos(info.modified);
let mut cache = crate::cache::Cache::disabled(); for (fpath, content) in files.iter() {
cache.record(&path, info.size, mtime, false, 0xDEAD_BEEF, 0); let mut f = File::create_new(fpath)?;
f.write_all(content)?;
}
let hashed = super::hash_candidates(vec![info], false, 300, false, &cache); let file_queue = Arc::new(Mutex::new(
assert_eq!(hashed[0].0, 0xDEAD_BEEF); 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] #[test]
fn hash_candidates_recomputes_when_mtime_differs() { fn sizewise_sorting_two_files_of_same_size() -> Result<()> {
let root = TempDir::new().unwrap(); let root = TempDir::new()?;
let path = root.path().join("f.bin"); let files = [
let mut f = File::create_new(&path).unwrap(); (root.path().join("fileone.bin"), generate_bytes(282624)),
f.write_all(b"real content for cache miss test").unwrap(); (root.path().join("filetwo.bin"), generate_bytes(282624)),
let info = FileInfo::new(path.clone()).unwrap(); ];
let mtime = crate::cache::mtime_nanos(info.modified);
let real = info.initpages_hash(300).unwrap();
let mut cache = crate::cache::Cache::disabled(); for (fpath, content) in files.iter() {
cache.record(&path, info.size, mtime + 1, false, 0xDEAD_BEEF, 0); let mut f = File::create_new(fpath)?;
f.write_all(content)?;
}
let hashed = super::hash_candidates(vec![info], false, 300, false, &cache); let file_queue = Arc::new(Mutex::new(
assert_eq!(hashed[0].0, real); files
assert_ne!(hashed[0].0, 0xDEAD_BEEF); .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(())
} }
} }

View File

@@ -1,283 +0,0 @@
use std::cmp::Ordering;
use unicode_segmentation::UnicodeSegmentation;
use anyhow::{bail, Result};
use bytesize::ByteSize;
use crate::fileinfo::FileInfo;
use crate::formatter::Formatter;
use crate::params::Params;
use crate::pipeline::DedupReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum KeepStrategy {
Newest,
Oldest,
First,
Last,
Shortest,
Shallowest,
}
fn path_string(file: &FileInfo) -> String {
file.path.to_string_lossy().into_owned()
}
fn path_len(file: &FileInfo) -> usize {
file.path.to_string_lossy().graphemes(true).count()
}
fn depth(file: &FileInfo) -> usize {
file.path.iter().count()
}
fn compare_keep(a: &FileInfo, b: &FileInfo, strategy: KeepStrategy) -> Ordering {
match strategy {
KeepStrategy::Newest => b
.modified
.cmp(&a.modified)
.then_with(|| path_string(a).cmp(&path_string(b))),
KeepStrategy::Oldest => a
.modified
.cmp(&b.modified)
.then_with(|| path_string(a).cmp(&path_string(b))),
KeepStrategy::First => path_string(a).cmp(&path_string(b)),
KeepStrategy::Last => path_string(b).cmp(&path_string(a)),
KeepStrategy::Shortest => path_len(a)
.cmp(&path_len(b))
.then_with(|| path_string(a).cmp(&path_string(b))),
KeepStrategy::Shallowest => depth(a)
.cmp(&depth(b))
.then_with(|| path_len(a).cmp(&path_len(b)))
.then_with(|| path_string(a).cmp(&path_string(b))),
}
}
pub fn select_keeper(files: &[FileInfo], strategy: KeepStrategy) -> usize {
files
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| compare_keep(a, b, strategy))
.map(|(index, _)| index)
.unwrap_or(0)
}
pub fn run(
report: &DedupReport,
strategy: KeepStrategy,
force: bool,
params: &Params,
) -> Result<()> {
if report.groups.is_empty() {
println!("No duplicates found matching your search criteria.");
return Ok(());
}
let group_count = report.groups.len();
let mut victim_count: u64 = 0;
let mut victim_bytes: u64 = 0;
let mut freed_bytes: u64 = 0;
let mut failures: u64 = 0;
for group in &report.groups {
let keeper = select_keeper(&group.files, strategy);
if !force {
println!(
"KEEP {}",
Formatter::human_path(&group.files[keeper], params, report.max_path_len)
.unwrap_or_default()
);
}
for (index, file) in group.files.iter().enumerate() {
if index == keeper {
continue;
}
victim_count += 1;
victim_bytes += file.size;
match force {
false => println!(
"DELETE {} {}",
Formatter::human_path(file, params, report.max_path_len).unwrap_or_default(),
Formatter::human_filesize(file).unwrap_or_default()
),
true => match std::fs::remove_file(&file.path) {
Ok(_) => {
freed_bytes += file.size;
println!("deleted {}", file.path.display());
}
Err(_) => {
failures += 1;
println!("FAILED {}", file.path.display());
}
},
}
}
}
match force {
false => println!(
"\n{group_count} groups, would free {}. Re-run with --force to delete.",
ByteSize::b(victim_bytes)
),
true => println!(
"\ndeleted {} files, freed {} across {group_count} groups.",
victim_count - failures,
ByteSize::b(freed_bytes)
),
}
if failures > 0 {
bail!("{failures} deletion(s) failed");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fileinfo::FileInfo;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use crate::params::Params;
use crate::pipeline::{DedupReport, DuplicateGroup};
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn file(path: &str, mtime_secs: u64) -> FileInfo {
FileInfo {
path: PathBuf::from(path).into_boxed_path(),
size: 0,
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs),
}
}
#[test]
fn newest_keeps_greatest_mtime() {
let files = vec![file("/a/x", 10), file("/a/y", 30), file("/a/z", 20)];
assert_eq!(select_keeper(&files, KeepStrategy::Newest), 1);
}
#[test]
fn oldest_keeps_least_mtime() {
let files = vec![file("/a/x", 10), file("/a/y", 30), file("/a/z", 20)];
assert_eq!(select_keeper(&files, KeepStrategy::Oldest), 0);
}
#[test]
fn first_keeps_lexicographically_smallest_path() {
let files = vec![file("/a/y", 10), file("/a/x", 10), file("/a/z", 10)];
assert_eq!(select_keeper(&files, KeepStrategy::First), 1);
}
#[test]
fn last_keeps_lexicographically_greatest_path() {
let files = vec![file("/a/y", 10), file("/a/x", 10), file("/a/z", 10)];
assert_eq!(select_keeper(&files, KeepStrategy::Last), 2);
}
#[test]
fn shortest_keeps_fewest_chars() {
let files = vec![file("/aaa/bbb", 10), file("/a/b", 10), file("/aa/bb", 10)];
assert_eq!(select_keeper(&files, KeepStrategy::Shortest), 1);
}
#[test]
fn shallowest_keeps_fewest_components() {
let files = vec![file("/a/b/c/d", 10), file("/a/b", 10), file("/a/b/c", 10)];
assert_eq!(select_keeper(&files, KeepStrategy::Shallowest), 1);
}
#[test]
fn newest_tiebreak_is_smallest_path_regardless_of_order() {
let forward = vec![file("/a/y", 30), file("/a/x", 30)];
let reversed = vec![file("/a/x", 30), file("/a/y", 30)];
assert_eq!(
forward[select_keeper(&forward, KeepStrategy::Newest)].path,
reversed[select_keeper(&reversed, KeepStrategy::Newest)].path
);
assert_eq!(
forward[select_keeper(&forward, KeepStrategy::Newest)]
.path
.to_string_lossy(),
"/a/x"
);
}
fn write_dup_report(root: &TempDir, names: &[&str]) -> DedupReport {
let files = names
.iter()
.map(|name| {
let path = root.path().join(name);
let mut f = File::create_new(&path).unwrap();
f.write_all(b"identical duplicate payload").unwrap();
FileInfo::new(path).unwrap()
})
.collect::<Vec<_>>();
DedupReport {
groups: vec![DuplicateGroup { hash: 0, files }],
max_path_len: 0,
}
}
#[test]
fn force_deletes_victims_and_keeps_the_keeper() {
let root = TempDir::new().unwrap();
let report = write_dup_report(&root, &["a.bin", "b.bin"]);
let params = Params {
dir: Some(root.path().into()),
..Default::default()
};
super::run(&report, KeepStrategy::First, true, &params).unwrap();
assert!(root.path().join("a.bin").exists());
assert!(!root.path().join("b.bin").exists());
}
#[test]
fn dry_run_deletes_nothing() {
let root = TempDir::new().unwrap();
let report = write_dup_report(&root, &["a.bin", "b.bin"]);
let params = Params {
dir: Some(root.path().into()),
..Default::default()
};
super::run(&report, KeepStrategy::First, false, &params).unwrap();
assert!(root.path().join("a.bin").exists());
assert!(root.path().join("b.bin").exists());
}
#[test]
fn force_reports_error_when_a_deletion_fails() {
let root = TempDir::new().unwrap();
let report = write_dup_report(&root, &["a.bin", "b.bin"]);
let params = Params {
dir: Some(root.path().into()),
..Default::default()
};
std::fs::remove_file(root.path().join("b.bin")).unwrap();
let result = super::run(&report, KeepStrategy::First, true, &params);
assert!(result.is_err());
}
#[test]
fn empty_report_is_ok() {
let params = Params::default();
let report = DedupReport {
groups: vec![],
max_path_len: 0,
};
assert!(super::run(&report, KeepStrategy::First, false, &params).is_ok());
}
}

View File

@@ -1,25 +1,26 @@
use crate::{fileinfo::FileInfo, params::Params, pipeline::spinner}; use crate::{fileinfo::FileInfo, params::Params};
use anyhow::Result; use anyhow::Result;
use std::path::Path; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::sync::{Arc, Mutex};
use std::{path::Path, time::Duration};
use globwalk::{GlobWalker, GlobWalkerBuilder}; use globwalk::{GlobWalker, GlobWalkerBuilder};
pub struct Scanner { pub struct Scanner {
pub directory: Box<Path>, 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, pub progress: bool,
} }
impl Scanner { impl Scanner {
pub fn new(app_args: &Params) -> Result<Self> { pub fn new(app_args: Arc<Params>) -> Result<Self> {
Ok(Self { Ok(Self {
directory: app_args.get_directory()?.into_boxed_path(), directory: app_args.get_directory()?.into_boxed_path(),
include_types: app_args.types.clone(), filetypes: app_args.get_types(),
exclude_types: app_args.exclude_types.clone(),
min_depth: app_args.min_depth, min_depth: app_args.min_depth,
max_depth: app_args.max_depth, max_depth: app_args.max_depth,
min_size: app_args.get_min_size(), min_size: app_args.get_min_size(),
@@ -28,21 +29,11 @@ impl Scanner {
}) })
} }
fn scan_patterns(&self) -> Result<Vec<String>> { fn scan_patterns(&self) -> Result<String> {
let include_types = match &self.include_types { Ok(match &self.filetypes {
Some(ftypes) => Some(format!("**/*.{{{ftypes}}}")), Some(ftypes) => format!("**/*{{{ftypes}}}"),
None => Some("**/*".to_string()), None => "**/*".to_string(),
}; })
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> {
@@ -62,11 +53,10 @@ impl Scanner {
None => Ok(walker), None => Ok(walker),
} }
} }
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.directory.clone(), 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))
@@ -75,142 +65,36 @@ impl Scanner {
Ok(walker.build()?) Ok(walker.build()?)
} }
pub fn scan(&self) -> Result<Vec<FileInfo>> { pub fn scan(
let bar = spinner(self.progress, "paths mapped"); &self,
let min_size = self.min_size.unwrap_or(0); 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 files: Vec<FileInfo> = self let progress_style = ProgressStyle::with_template("[{elapsed_precise}] {pos:>7} {msg}")?;
.build_walker()? progress_bar.set_style(progress_style);
progress_bar.enable_steady_tick(Duration::from_millis(50));
progress_bar.set_message("paths mapped");
let min_size = self.min_size.unwrap_or_default();
self.build_walker()?
.filter_map(Result::ok) .filter_map(Result::ok)
.map(|entity| entity.into_path()) .map(|entity| entity.into_path())
.inspect(|_path| bar.inc(1)) .inspect(|_path| progress_bar.inc(1))
.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(); .for_each(|file| {
let mut flock = files.lock().unwrap();
flock.push(file);
});
bar.finish_with_message("paths mapped"); progress_bar.finish_with_message("paths mapped");
Ok(files) Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::params::Params;
use std::fs::File;
use tempfile::TempDir;
use super::Scanner;
#[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 scanner = Scanner::new(&params).expect("scanner initialization failed");
let files = scanner.scan().expect("scanning failed.");
assert!(files.iter().any(|f| f.path.to_str().unwrap()
== root.path().join("this-is-a-js-file.js").to_str().unwrap()));
assert!(files.iter().any(|f| f.path.to_str().unwrap()
== root.path().join("this-is-a-csv-file.csv").to_str().unwrap()));
assert!(files.iter().all(|f| f.path.to_str().unwrap()
!= root.path().join("this-is-a-css-file.css").to_str().unwrap()));
assert!(files.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 scanner = Scanner::new(&params).expect("scanner initialization failed");
let files = scanner.scan().expect("scanning failed.");
assert!(files.iter().all(|f| f.path.to_str().unwrap()
!= root.path().join("this-is-a-js-file.js").to_str().unwrap()));
assert!(files.iter().all(|f| f.path.to_str().unwrap()
!= root.path().join("this-is-a-csv-file.csv").to_str().unwrap()));
assert!(files.iter().any(|f| f.path.to_str().unwrap()
== root.path().join("this-is-a-css-file.css").to_str().unwrap()));
assert!(files.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 scanner = Scanner::new(&params).expect("scanner initialization failed");
let files = scanner.scan().expect("scanning failed.");
assert!(files.iter().any(|f| f.path.to_str().unwrap()
== root.path().join("this-is-a-js-file.js").to_str().unwrap()));
assert!(files.iter().all(|f| f.path.to_str().unwrap()
!= root.path().join("this-is-a-csv-file.csv").to_str().unwrap()));
assert!(files.iter().any(|f| f.path.to_str().unwrap()
== root.path().join("this-is-a-rust-file.rs").to_str().unwrap()));
} }
} }

99
src/server.rs Normal file
View File

@@ -0,0 +1,99 @@
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_clone_for_sc = self.app_args.clone();
let app_args_clone_for_pr = self.app_args.clone();
let file_queue_clone_sc = self.filequeue.clone();
let file_queue_clone_pr = self.filequeue.clone();
let scanner_finished = Arc::new(AtomicBool::new(false));
let sfin_sc_tr_cl = scanner_finished.clone();
let sfin_pr_tr_cl = scanner_finished.clone();
let store_dupl_sw_for_sw = self.sw_duplicate_set.clone();
let store_dupl_sw_for_hw = self.sw_duplicate_set.clone();
let store_dupl_hw = self.hw_duplicate_set.clone();
let max_file_path_len_clone = self.max_file_path_len.clone();
let progbarbox_sc_clone = progbarbox.clone();
self.threadpool.execute(move || {
Scanner::new(app_args_clone_for_sc)
.unwrap()
.scan(file_queue_clone_sc, progbarbox_sc_clone)
.unwrap();
sfin_sc_tr_cl.store(true, std::sync::atomic::Ordering::Relaxed);
});
let progbarbox_pr_clone = progbarbox.clone();
self.threadpool.execute(move || {
Processor::sizewise(
app_args_clone_for_pr.clone(),
sfin_pr_tr_cl,
store_dupl_sw_for_sw,
file_queue_clone_pr,
progbarbox_pr_clone.clone(),
)
.unwrap();
Processor::hashwise(
app_args_clone_for_pr,
store_dupl_sw_for_hw,
store_dupl_hw,
progbarbox_pr_clone,
max_file_path_len_clone,
seed,
)
.unwrap();
});
progbarbox.clear()?;
self.threadpool.join();
Ok(())
}
}

View File

@@ -1,499 +0,0 @@
use std::path::PathBuf;
use crate::fileinfo::FileInfo;
use crate::pipeline::DuplicateGroup;
use crate::resolver::{select_keeper, KeepStrategy};
pub const STRATEGIES: [KeepStrategy; 6] = [
KeepStrategy::Newest,
KeepStrategy::Oldest,
KeepStrategy::First,
KeepStrategy::Last,
KeepStrategy::Shortest,
KeepStrategy::Shallowest,
];
pub fn strategy_label(strategy: KeepStrategy) -> &'static str {
match strategy {
KeepStrategy::Newest => "newest",
KeepStrategy::Oldest => "oldest",
KeepStrategy::First => "first",
KeepStrategy::Last => "last",
KeepStrategy::Shortest => "shortest",
KeepStrategy::Shallowest => "shallowest",
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum Focus {
Groups,
Files,
}
#[derive(Clone, Copy, PartialEq)]
pub enum StrategyScope {
CurrentGroup,
AllGroups,
}
#[derive(Clone, Copy, PartialEq)]
pub enum Popup {
None,
Strategy { scope: StrategyScope },
ConfirmDelete,
}
pub enum Key {
Up,
Down,
Tab,
Space,
Enter,
Esc,
Char(char),
}
pub enum Outcome {
Continue,
Quit,
Delete,
Open(PathBuf),
}
pub struct Group {
pub files: Vec<FileInfo>,
pub marked: Vec<bool>,
}
impl Group {
pub fn size(&self) -> u64 {
self.files.first().map(|f| f.size).unwrap_or(0)
}
}
pub struct App {
pub groups: Vec<Group>,
pub group_cursor: usize,
pub file_cursor: usize,
pub focus: Focus,
pub popup: Popup,
pub strategy_cursor: usize,
pub status: Option<String>,
pub should_quit: bool,
}
fn clamp_add(cur: usize, delta: isize, max: usize) -> usize {
(cur as isize + delta).clamp(0, max as isize) as usize
}
impl App {
pub fn from_groups(groups: Vec<DuplicateGroup>) -> Self {
let groups = groups
.into_iter()
.map(|g| Group {
marked: vec![false; g.files.len()],
files: g.files,
})
.collect();
Self {
groups,
group_cursor: 0,
file_cursor: 0,
focus: Focus::Files,
popup: Popup::None,
strategy_cursor: 0,
status: None,
should_quit: false,
}
}
pub fn move_group(&mut self, delta: isize) {
if self.groups.is_empty() {
return;
}
self.group_cursor = clamp_add(self.group_cursor, delta, self.groups.len() - 1);
let len = self.groups[self.group_cursor].files.len();
self.file_cursor = self.file_cursor.min(len.saturating_sub(1));
}
pub fn move_file(&mut self, delta: isize) {
if let Some(g) = self.groups.get(self.group_cursor) {
self.file_cursor = clamp_add(self.file_cursor, delta, g.files.len().saturating_sub(1));
}
}
pub fn toggle_mark(&mut self) {
let fc = self.file_cursor;
if let Some(g) = self.groups.get_mut(self.group_cursor) {
if fc >= g.marked.len() {
return;
}
if !g.marked[fc] && g.marked.iter().filter(|m| !**m).count() <= 1 {
return;
}
g.marked[fc] = !g.marked[fc];
}
}
pub fn apply_strategy(&mut self, strategy: KeepStrategy, scope: StrategyScope) {
let indices: Vec<usize> = match scope {
StrategyScope::CurrentGroup => vec![self.group_cursor],
StrategyScope::AllGroups => (0..self.groups.len()).collect(),
};
for gi in indices {
if let Some(g) = self.groups.get_mut(gi) {
if g.files.len() < 2 {
continue;
}
let keeper = select_keeper(&g.files, strategy);
for i in 0..g.marked.len() {
g.marked[i] = i != keeper;
}
}
}
}
pub fn marked_count(&self) -> usize {
self.groups
.iter()
.flat_map(|g| g.marked.iter())
.filter(|m| **m)
.count()
}
pub fn reclaimable_bytes(&self) -> u64 {
self.groups
.iter()
.flat_map(|g| g.files.iter().zip(&g.marked))
.filter(|(_, m)| **m)
.map(|(f, _)| f.size)
.sum()
}
pub fn marked_paths(&self) -> Vec<PathBuf> {
self.groups
.iter()
.flat_map(|g| g.files.iter().zip(&g.marked))
.filter(|(_, m)| **m)
.map(|(f, _)| f.path.to_path_buf())
.collect()
}
pub fn apply_deletion(&mut self, deleted: &[PathBuf]) {
use std::collections::HashSet;
let removed: HashSet<&std::path::Path> = deleted.iter().map(|p| p.as_path()).collect();
for g in &mut self.groups {
let mut files = Vec::new();
let mut marked = Vec::new();
for (i, f) in g.files.iter().enumerate() {
if !removed.contains(&*f.path) {
files.push(f.clone());
marked.push(g.marked[i]);
}
}
g.files = files;
g.marked = marked;
}
self.groups.retain(|g| g.files.len() >= 2);
if self.groups.is_empty() {
self.should_quit = true;
self.group_cursor = 0;
self.file_cursor = 0;
return;
}
self.group_cursor = self.group_cursor.min(self.groups.len() - 1);
let len = self.groups[self.group_cursor].files.len();
self.file_cursor = self.file_cursor.min(len.saturating_sub(1));
}
pub fn handle_key(&mut self, key: Key) -> Outcome {
match self.popup {
Popup::Strategy { scope } => self.handle_strategy_key(key, scope),
Popup::ConfirmDelete => self.handle_confirm_key(key),
Popup::None => self.handle_main_key(key),
}
}
fn handle_main_key(&mut self, key: Key) -> Outcome {
self.status = None;
match key {
Key::Char('q') | Key::Esc => {
self.should_quit = true;
Outcome::Quit
}
Key::Tab => {
self.focus = match self.focus {
Focus::Groups => Focus::Files,
Focus::Files => Focus::Groups,
};
Outcome::Continue
}
Key::Up | Key::Char('k') => {
self.move_in_focus(-1);
Outcome::Continue
}
Key::Down | Key::Char('j') => {
self.move_in_focus(1);
Outcome::Continue
}
Key::Space => {
if self.focus == Focus::Files {
self.toggle_mark();
}
Outcome::Continue
}
Key::Char('s') => {
self.open_strategy(StrategyScope::CurrentGroup);
Outcome::Continue
}
Key::Char('S') => {
self.open_strategy(StrategyScope::AllGroups);
Outcome::Continue
}
Key::Char('o') => self.open_current(),
Key::Char('d') => {
if self.marked_count() > 0 {
self.popup = Popup::ConfirmDelete;
} else {
self.status = Some("nothing marked".to_string());
}
Outcome::Continue
}
_ => Outcome::Continue,
}
}
fn handle_strategy_key(&mut self, key: Key, scope: StrategyScope) -> Outcome {
match key {
Key::Up | Key::Char('k') => {
self.strategy_cursor = self.strategy_cursor.saturating_sub(1);
}
Key::Down | Key::Char('j') => {
self.strategy_cursor = (self.strategy_cursor + 1).min(STRATEGIES.len() - 1);
}
Key::Enter => {
let strategy = STRATEGIES[self.strategy_cursor];
self.apply_strategy(strategy, scope);
self.popup = Popup::None;
}
Key::Esc => self.popup = Popup::None,
_ => {}
}
Outcome::Continue
}
fn handle_confirm_key(&mut self, key: Key) -> Outcome {
match key {
Key::Char('y') => {
self.popup = Popup::None;
Outcome::Delete
}
Key::Char('n') | Key::Esc => {
self.popup = Popup::None;
Outcome::Continue
}
_ => Outcome::Continue,
}
}
fn move_in_focus(&mut self, delta: isize) {
match self.focus {
Focus::Groups => self.move_group(delta),
Focus::Files => self.move_file(delta),
}
}
fn open_strategy(&mut self, scope: StrategyScope) {
self.strategy_cursor = 0;
self.popup = Popup::Strategy { scope };
}
fn open_current(&self) -> Outcome {
match self.groups.get(self.group_cursor).and_then(|g| g.files.get(self.file_cursor)) {
Some(f) => Outcome::Open(f.path.to_path_buf()),
None => Outcome::Continue,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fileinfo::FileInfo;
use crate::pipeline::DuplicateGroup;
use std::path::PathBuf;
use std::time::{Duration, UNIX_EPOCH};
fn file(path: &str, size: u64, mtime_secs: u64) -> FileInfo {
FileInfo {
path: PathBuf::from(path).into_boxed_path(),
size,
modified: UNIX_EPOCH + Duration::from_secs(mtime_secs),
}
}
fn group(files: Vec<FileInfo>) -> DuplicateGroup {
DuplicateGroup { hash: 0, files }
}
fn sample() -> App {
App::from_groups(vec![
group(vec![file("/a/x", 10, 100), file("/a/y", 10, 200)]),
group(vec![file("/b/p", 5, 50), file("/b/q", 5, 60), file("/b/r", 5, 70)]),
])
}
#[test]
fn move_group_clamps_at_bounds() {
let mut app = sample();
app.move_group(-1);
assert_eq!(app.group_cursor, 0);
app.move_group(1);
assert_eq!(app.group_cursor, 1);
app.move_group(5);
assert_eq!(app.group_cursor, 1);
}
#[test]
fn move_group_reclamps_file_cursor() {
let mut app = sample();
app.group_cursor = 1;
app.file_cursor = 2;
app.move_group(-1);
assert_eq!(app.group_cursor, 0);
assert_eq!(app.file_cursor, 1);
}
#[test]
fn move_file_clamps() {
let mut app = sample();
app.move_file(-1);
assert_eq!(app.file_cursor, 0);
app.move_file(10);
assert_eq!(app.file_cursor, 1);
}
#[test]
fn toggle_mark_blocks_marking_the_last_survivor() {
let mut app = sample();
app.group_cursor = 0;
app.file_cursor = 0;
app.toggle_mark();
assert!(app.groups[0].marked[0]);
app.file_cursor = 1;
app.toggle_mark();
assert!(!app.groups[0].marked[1]);
}
#[test]
fn toggle_mark_allows_unmark() {
let mut app = sample();
app.file_cursor = 0;
app.toggle_mark();
app.toggle_mark();
assert!(!app.groups[0].marked[0]);
}
#[test]
fn apply_strategy_current_group_marks_all_but_keeper() {
let mut app = sample();
app.group_cursor = 0;
app.apply_strategy(KeepStrategy::Newest, StrategyScope::CurrentGroup);
assert_eq!(app.groups[0].marked, vec![true, false]);
assert_eq!(app.groups[1].marked, vec![false, false, false]);
}
#[test]
fn apply_strategy_all_groups() {
let mut app = sample();
app.apply_strategy(KeepStrategy::Oldest, StrategyScope::AllGroups);
assert_eq!(app.groups[0].marked, vec![false, true]);
assert_eq!(app.groups[1].marked, vec![false, true, true]);
}
#[test]
fn totals_sum_marked_only() {
let mut app = sample();
app.apply_strategy(KeepStrategy::Newest, StrategyScope::AllGroups);
assert_eq!(app.marked_count(), 3);
assert_eq!(app.reclaimable_bytes(), 20);
}
#[test]
fn marked_paths_lists_marked_files() {
let mut app = sample();
app.group_cursor = 0;
app.file_cursor = 0;
app.toggle_mark();
assert_eq!(app.marked_paths(), vec![PathBuf::from("/a/x")]);
}
#[test]
fn apply_deletion_removes_files_and_drops_small_groups() {
let mut app = sample();
app.apply_deletion(&[PathBuf::from("/a/x")]);
assert_eq!(app.groups.len(), 1);
assert_eq!(app.groups[0].files.len(), 3);
}
#[test]
fn apply_deletion_emptying_everything_sets_quit() {
let mut app = App::from_groups(vec![group(vec![file("/a/x", 1, 1), file("/a/y", 1, 2)])]);
app.apply_deletion(&[PathBuf::from("/a/x")]);
assert!(app.groups.is_empty());
assert!(app.should_quit);
}
#[test]
fn handle_key_quits_on_q() {
let mut app = sample();
assert!(matches!(app.handle_key(Key::Char('q')), Outcome::Quit));
assert!(app.should_quit);
}
#[test]
fn handle_key_space_marks_in_files_focus() {
let mut app = sample();
app.focus = Focus::Files;
app.handle_key(Key::Space);
assert!(app.groups[0].marked[0]);
}
#[test]
fn handle_key_strategy_popup_apply_flow() {
let mut app = sample();
app.handle_key(Key::Char('S'));
assert!(matches!(app.popup, Popup::Strategy { .. }));
app.handle_key(Key::Enter);
assert!(matches!(app.popup, Popup::None));
assert_eq!(app.groups[0].marked, vec![true, false]);
}
#[test]
fn handle_key_delete_requires_marks_then_confirms() {
let mut app = sample();
app.handle_key(Key::Char('d'));
assert!(matches!(app.popup, Popup::None));
app.focus = Focus::Files;
app.handle_key(Key::Space);
app.handle_key(Key::Char('d'));
assert!(matches!(app.popup, Popup::ConfirmDelete));
assert!(matches!(app.handle_key(Key::Char('y')), Outcome::Delete));
assert!(matches!(app.popup, Popup::None));
}
#[test]
fn handle_key_open_returns_path() {
let mut app = sample();
app.focus = Focus::Files;
match app.handle_key(Key::Char('o')) {
Outcome::Open(p) => assert_eq!(p, PathBuf::from("/a/x")),
_ => panic!("expected Open"),
}
}
}

View File

@@ -1,129 +0,0 @@
pub mod app;
mod ui;
use std::io::{self, Stdout};
use anyhow::Result;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::Terminal;
use crate::params::Params;
use crate::pipeline::DedupReport;
use app::{App, Key, Outcome};
type Tui = Terminal<CrosstermBackend<Stdout>>;
pub fn run(report: DedupReport, params: &Params) -> Result<()> {
if report.groups.is_empty() {
println!("No duplicates found matching your search criteria.");
return Ok(());
}
let mut app = App::from_groups(report.groups);
let mut terminal = setup_terminal()?;
let result = event_loop(&mut terminal, &mut app, params);
restore_terminal(&mut terminal)?;
result
}
fn setup_terminal() -> Result<Tui> {
install_panic_hook();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn restore_terminal(terminal: &mut Tui) -> Result<()> {
let _ = disable_raw_mode();
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
let _ = terminal.show_cursor();
Ok(())
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original(info);
}));
}
fn event_loop(terminal: &mut Tui, app: &mut App, params: &Params) -> Result<()> {
loop {
terminal.draw(|frame| ui::draw(frame, app, params))?;
if app.should_quit {
break;
}
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if let Some(translated) = translate(key) {
match app.handle_key(translated) {
Outcome::Quit => {}
Outcome::Continue => {}
Outcome::Delete => perform_deletion(app),
Outcome::Open(path) => {
if let Err(err) = open::that(&path) {
app.status = Some(format!("open failed: {err}"));
}
}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn translate(key: KeyEvent) -> Option<Key> {
Some(match key.code {
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Tab => Key::Tab,
KeyCode::Enter => Key::Enter,
KeyCode::Esc => Key::Esc,
KeyCode::Char(' ') => Key::Space,
KeyCode::Char(c) => Key::Char(c),
_ => return None,
})
}
fn perform_deletion(app: &mut App) {
let paths = app.marked_paths();
let mut deleted = Vec::new();
let mut freed: u64 = 0;
let mut failures: u64 = 0;
for path in &paths {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
match std::fs::remove_file(path) {
Ok(_) => {
deleted.push(path.clone());
freed += size;
}
Err(_) => failures += 1,
}
}
app.apply_deletion(&deleted);
app.status = Some(match failures {
0 => format!("deleted {}, freed {}", deleted.len(), bytesize::ByteSize::b(freed)),
_ => format!(
"deleted {}, {failures} failed, freed {}",
deleted.len(),
bytesize::ByteSize::b(freed)
),
});
}

View File

@@ -1,211 +0,0 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use crate::params::Params;
use crate::formatter::Formatter;
use super::app::{strategy_label, App, Focus, Popup, StrategyScope, STRATEGIES};
pub fn draw(frame: &mut Frame, app: &App, params: &Params) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(frame.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(rows[0]);
draw_groups(frame, app, panes[0]);
draw_files(frame, app, params, panes[1]);
draw_footer(frame, app, rows[1]);
match app.popup {
Popup::Strategy { scope } => draw_strategy_popup(frame, app, scope),
Popup::ConfirmDelete => draw_confirm_popup(frame, app),
Popup::None => {}
}
}
fn pane_block(title: &str, focused: bool) -> Block<'_> {
let block = Block::default().borders(Borders::ALL).title(title.to_string());
match focused {
true => block.border_style(Style::new().yellow()),
false => block,
}
}
fn draw_groups(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = app
.groups
.iter()
.enumerate()
.map(|(i, g)| {
ListItem::new(format!(
"{:>3} {} files {}",
i + 1,
g.files.len(),
bytesize::ByteSize::b(g.size())
))
})
.collect();
let list = List::new(items)
.block(pane_block("Groups", app.focus == Focus::Groups))
.highlight_style(Style::new().reversed());
let mut state = ListState::default();
state.select(Some(app.group_cursor));
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_files(frame: &mut Frame, app: &App, params: &Params, area: Rect) {
let title = format!(
"Group {}/{}",
(app.group_cursor + 1).min(app.groups.len().max(1)),
app.groups.len()
);
let items: Vec<ListItem> = match app.groups.get(app.group_cursor) {
Some(g) => g
.files
.iter()
.enumerate()
.map(|(i, f)| {
let box_char = if g.marked[i] { "[x]" } else { "[ ]" };
let path = Formatter::human_path(f, params, 0).unwrap_or_default();
let size = Formatter::human_filesize(f).unwrap_or_default();
let mtime = Formatter::human_mtime(f).unwrap_or_default();
let line = format!("{box_char} {} {} {}", path.trim_end(), size.trim_start(), mtime);
match g.marked[i] {
true => ListItem::new(Line::from(line).style(Style::new().red())),
false => ListItem::new(line),
}
})
.collect(),
None => Vec::new(),
};
let list = List::new(items)
.block(pane_block(&title, app.focus == Focus::Files))
.highlight_style(Style::new().reversed());
let mut state = ListState::default();
state.select(Some(app.file_cursor));
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
let hints = "[Tab]pane [Space]mark [s]group [S]all [o]pen [d]elete [q]uit";
let left = match &app.status {
Some(s) => s.clone(),
None => format!(
"marked {} · reclaim {}",
app.marked_count(),
bytesize::ByteSize::b(app.reclaimable_bytes())
),
};
let text = format!("{left} {hints}");
frame.render_widget(Paragraph::new(text), area);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(area.width),
height: height.min(area.height),
}
}
fn draw_strategy_popup(frame: &mut Frame, app: &App, scope: StrategyScope) {
let title = match scope {
StrategyScope::CurrentGroup => "Keep in this group",
StrategyScope::AllGroups => "Keep in ALL groups",
};
let items: Vec<ListItem> = STRATEGIES
.iter()
.map(|s| ListItem::new(strategy_label(*s)))
.collect();
let area = centered_rect(28, (STRATEGIES.len() as u16) + 2, frame.area());
frame.render_widget(Clear, area);
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::new().reversed());
let mut state = ListState::default();
state.select(Some(app.strategy_cursor));
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_confirm_popup(frame: &mut Frame, app: &App) {
let area = centered_rect(48, 3, frame.area());
frame.render_widget(Clear, area);
let text = format!(
"Delete {} files, reclaim {}? [y/N]",
app.marked_count(),
bytesize::ByteSize::b(app.reclaimable_bytes())
);
frame.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Confirm")),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fileinfo::FileInfo;
use crate::pipeline::DuplicateGroup;
use crate::tui::app::App;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::path::PathBuf;
use std::time::{Duration, UNIX_EPOCH};
fn sample_app() -> App {
let files = vec![
FileInfo {
path: PathBuf::from("/tmp/report.pdf").into_boxed_path(),
size: 4_000_000,
modified: UNIX_EPOCH + Duration::from_secs(1_700_000_000),
},
FileInfo {
path: PathBuf::from("/tmp/report-copy.pdf").into_boxed_path(),
size: 4_000_000,
modified: UNIX_EPOCH + Duration::from_secs(1_700_100_000),
},
];
App::from_groups(vec![DuplicateGroup { hash: 0, files }])
}
#[test]
fn draw_renders_without_panic_and_shows_content() {
let app = sample_app();
let params = crate::params::Params::default();
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, &app, &params)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Groups"));
assert!(text.contains("report"));
assert!(text.contains("marked"));
}
}