mirror of
https://github.com/sreedevk/deduplicator.git
synced 2026-08-26 18:15:33 +00:00
Compare commits
138 Commits
0.0.7
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5324b48c | ||
|
|
1442cd0aaf | ||
|
|
cc7144afbc | ||
|
|
3ca931fa8e | ||
|
|
d102d92bfa | ||
|
|
68c2491fd0 | ||
|
|
da24efbb36 | ||
|
|
2191763b4d | ||
|
|
b84510f18e | ||
|
|
bfb89edd16 | ||
|
|
21f0eb3cb0 | ||
|
|
f5d2d4e22c | ||
|
|
360af25318 | ||
|
|
d06fe93171 | ||
|
|
6a24ca5ecf | ||
|
|
6f23a51a53 | ||
|
|
4894d1b4db | ||
|
|
c0286b17cc | ||
|
|
e78dfc4211 | ||
|
|
66f49a9aee | ||
|
|
711eb36fb9 | ||
|
|
130a8f99ba | ||
|
|
d97af6b8f6 | ||
|
|
90198502a3 | ||
|
|
e1b81b9981 | ||
|
|
48e7c62036 | ||
|
|
58e33cc8da | ||
|
|
f9e86f8522 | ||
|
|
b4bf5d4fb3 | ||
|
|
028b868ea9 | ||
|
|
a56d194ee3 | ||
|
|
080cd791dc | ||
|
|
b16236763c | ||
|
|
3e407f69c8 | ||
|
|
7d386c9420 | ||
|
|
0dc681d4e7 | ||
|
|
442cb4b519 | ||
|
|
8463e72f2d | ||
|
|
05c95bb67a | ||
|
|
062d44acd9 | ||
|
|
ee6655de9b | ||
|
|
ffa5295598 | ||
|
|
6be8596992 | ||
|
|
a40f251e30 | ||
|
|
02d05172da | ||
|
|
dcc709a666 | ||
|
|
e3d48ec505 | ||
|
|
7bd88f1642 | ||
|
|
a65e22269c | ||
|
|
6e3fe4a37d | ||
|
|
eadffb5fea | ||
|
|
5532ded331 | ||
|
|
7119f019d3 | ||
|
|
493cac1762 | ||
|
|
f0dbf05705 | ||
|
|
ef1e9a1fce | ||
|
|
d06ca7897d | ||
|
|
b49940998b | ||
|
|
ab44d1ed04 | ||
|
|
6b06798e8e | ||
|
|
4d27da99f3 | ||
|
|
91dce29bb4 | ||
|
|
f9b6d57968 | ||
|
|
5c86a080c4 | ||
|
|
9f4d9139b6 | ||
|
|
153109ef57 | ||
|
|
fa12f85b6a | ||
|
|
7d66aeef6e | ||
|
|
163e8d85c0 | ||
|
|
b1c508e9e7 | ||
|
|
4125b18d67 | ||
|
|
10329015f7 | ||
|
|
de6d2e325e | ||
|
|
850c274adc | ||
|
|
31d35aae51 | ||
|
|
087f36ac52 | ||
|
|
6f8b1d55df | ||
|
|
c6318a310b | ||
|
|
76c69ef8a0 | ||
|
|
fd4e882aa8 | ||
|
|
fd1d214d62 | ||
|
|
1a573fbf3f | ||
|
|
b12b662ba1 | ||
|
|
96337b6b48 | ||
|
|
02f70aa55f | ||
|
|
4a18b3fd91 | ||
|
|
876fdc69ac | ||
|
|
8acc3f8c8f | ||
|
|
38f6c48452 | ||
|
|
3e1e9c0e2e | ||
|
|
0674ab55bf | ||
|
|
ccff95dfc5 | ||
|
|
53d28abb62 | ||
|
|
ea6cf94952 | ||
|
|
7b275d40d0 | ||
|
|
30398c3ca9 | ||
|
|
c0042fc9f7 | ||
|
|
5aea0eb6f4 | ||
|
|
f0ff1ec325 | ||
|
|
b267fcdedf | ||
|
|
84b194efca | ||
|
|
d162ca98ef | ||
|
|
dfb73ceb5c | ||
|
|
72ca7c7a44 | ||
|
|
2f3cfd3162 | ||
|
|
9b1f591c20 | ||
|
|
e24603c645 | ||
|
|
0e624b042d | ||
|
|
e61d1f1475 | ||
|
|
8c76c6a3c8 | ||
|
|
df3bea3d4f | ||
|
|
7ace0bde63 | ||
|
|
70de402eab | ||
|
|
01dd93a0ea | ||
|
|
8ac78fb856 | ||
|
|
37a4c4d52d | ||
|
|
1ea5705474 | ||
|
|
ea748c60d8 | ||
|
|
51f5f8e61a | ||
|
|
175c7579c4 | ||
|
|
81c96ce3b8 | ||
|
|
0031891b8b | ||
|
|
ae87e4e830 | ||
|
|
284e168453 | ||
|
|
a6511f2cf3 | ||
|
|
64d0106765 | ||
|
|
c75b2eb1c9 | ||
|
|
b736853dfe | ||
|
|
92290480a8 | ||
|
|
b7f775e04c | ||
|
|
12295d7847 | ||
|
|
d81f499db3 | ||
|
|
9e1360aeb4 | ||
|
|
38ea37711f | ||
|
|
2294471b50 | ||
|
|
dbf504fc17 | ||
|
|
06efb2ed6f | ||
|
|
95af6c4a70 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustflags = ["-C", "target-feature=+aes,+sse2"]
|
||||
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[Bug] Title"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
** Runtime Info **
|
||||
App Arguments: [e.g. `-i --nocache`]
|
||||
Install Type: [e.g. `cargo install`]
|
||||
App Version: [e.g. v0.0.7]
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Platform Details (please complete the following information):**
|
||||
- OS: [e.g. Arch Linux]
|
||||
- Terminal Emulator: [e.g Alacritty]
|
||||
- Shell [e.g. Zshell]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature] Title"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
121
.github/workflows/release.yml
vendored
Normal file
121
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Deduplicator Release Build CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
lint_test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "-C target-feature=+aes,+sse2"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Run cargo check
|
||||
run: cargo check
|
||||
|
||||
- name: Run cargo clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test -- --test-threads=1
|
||||
|
||||
build:
|
||||
needs: lint_test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
artifact_name: linux-aarch64
|
||||
rustflags: "-C target-feature=+aes,+neon"
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
artifact_name: linux-amd64
|
||||
rustflags: "-C target-feature=+aes,+sse2"
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-14
|
||||
artifact_name: macos-amd64
|
||||
rustflags: "-C target-feature=+aes,+sse2"
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-14
|
||||
artifact_name: macos-arm64
|
||||
rustflags: "-C target-feature=+aes,+neon"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation toolchain (Linux ARM only)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Build release binary
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }}
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
fi
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Package binary
|
||||
run: |
|
||||
if [[ "${{ matrix.os }}" == macos* ]]; then
|
||||
BINARY_NAME=$(find target/${{ matrix.target }}/release -maxdepth 1 -type f -perm +111 -print0 | xargs -0 basename | head -1)
|
||||
else
|
||||
BINARY_NAME=$(find target/${{ matrix.target }}/release -maxdepth 1 -type f -executable -print0 | xargs -0 basename | head -1)
|
||||
fi
|
||||
|
||||
echo "Packaging binary: $BINARY_NAME"
|
||||
mkdir -p release
|
||||
cp target/${{ matrix.target }}/release/$BINARY_NAME release/$BINARY_NAME
|
||||
tar -C release -czf ${{ matrix.artifact_name }}.tar.gz $BINARY_NAME
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}-binary
|
||||
path: ${{ matrix.artifact_name }}.tar.gz
|
||||
|
||||
create_release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: "Automated release for version ${{ github.ref_name }}"
|
||||
files: |
|
||||
artifacts/*/*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
/target
|
||||
/test_data
|
||||
/Cargo.lock
|
||||
/result-bin
|
||||
/.bacon-locations
|
||||
/docs
|
||||
/.claude
|
||||
/.superpowers
|
||||
|
||||
28
CONTRIBUTING.md
Normal file
28
CONTRIBUTING.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## How to contribute to Deduplicator
|
||||
|
||||
#### **Did you find a bug?**
|
||||
|
||||
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/sreedevk/deduplicator/issues).
|
||||
|
||||
* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/sreedevk/deduplicator/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring.
|
||||
|
||||
* If possible, use the [bug report template](https://github.com/sreedevk/deduplicator/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) to create the issue.
|
||||
|
||||
#### **Would you like to write a fix for the bug?**
|
||||
* Assign the Issue to yourself (if unassigned) before you start working in order to avoid any conficts.
|
||||
* Open a new GitHub pull request with the patch.
|
||||
* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number.
|
||||
* Make sure that the PR points to the development branch.
|
||||
|
||||
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
|
||||
|
||||
Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of Deduplicator will generally not be accepted/
|
||||
|
||||
#### **Do you intend to add a new feature or change an existing one?**
|
||||
|
||||
* First open an issue with the sugggestion using the [feature request template](https://github.com/sreedevk/deduplicator/blob/main/.github/ISSUE_TEMPLATE/feature-request.md)
|
||||
* Do not create a PR before one of the core contributors has conveyed acceptance for a feature request.
|
||||
|
||||
#### **Do you have questions about the source code?**
|
||||
|
||||
* If you have a question, raise an issue in the repository with a "question" label.
|
||||
2285
Cargo.lock
generated
2285
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
62
Cargo.toml
@@ -1,26 +1,60 @@
|
||||
[package]
|
||||
name = "deduplicator"
|
||||
version = "0.0.7"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
description = "find,filter,delete Duplicates"
|
||||
description = "find,filter and delete duplicate files"
|
||||
repository = "https://github.com/sreedevk/deduplicator"
|
||||
license = "MIT"
|
||||
authors = ["Sreedev Kodichath <sreedevpadmakumar@gmail.com>", "Valentin Bersier <vbersier@gmail.com>", "Dhruva Sagar <dhruva.sagar@gmail.com>"]
|
||||
authors = [
|
||||
"Sreedev Kodichath <sreedevpadmakumar@gmail.com>",
|
||||
"Valentin Bersier <vbersier@gmail.com>",
|
||||
"Dhruva Sagar <dhruva.sagar@gmail.com>",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
name = "deduplicator"
|
||||
path = "src/main.rs"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.68"
|
||||
bytesize = "2.0.1"
|
||||
chrono = "0.4.23"
|
||||
clap = { version = "4.0.32", features = ["derive"] }
|
||||
colored = "2.0.0"
|
||||
crossterm = "0.25.0"
|
||||
fxhash = "0.2.1"
|
||||
glob = "0.3.0"
|
||||
humansize = "2.1.2"
|
||||
itertools = "0.10.5"
|
||||
dirs = "6.0.0"
|
||||
globwalk = "0.9.1"
|
||||
gxhash = { version = "3.4.1", default-features = false }
|
||||
indicatif = { version = "0.18.0", features = ["rayon"] }
|
||||
memmap2 = "0.9.7"
|
||||
open = "5.4.0"
|
||||
pathdiff = "0.2.1"
|
||||
prettytable-rs = "0.10.0"
|
||||
ratatui = "0.30.2"
|
||||
rayon = "1.6.1"
|
||||
sqlite = "0.30.3"
|
||||
thiserror = "1.0.38"
|
||||
tokio = { version = "1.23.0", features = ["full"] }
|
||||
tui = "0.19.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
debug = false
|
||||
codegen-units = 1
|
||||
|
||||
# generated by 'cargo dist init'
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
|
||||
[workspace.metadata.dist]
|
||||
rust-toolchain-version = "1.87.0"
|
||||
ci = ["github"]
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
cargo-dist-version = "0.0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.20.0"
|
||||
rand = "0.9.1"
|
||||
|
||||
219
README.md
219
README.md
@@ -4,45 +4,210 @@
|
||||
Find, Sort, Filter & Delete duplicate files
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
NOTE: This project is still being developed. At the moment, as shown in the screenshot below, deduplicator is able to scan through and list duplicates with and without caching. Contributions are welcome.
|
||||
</p>
|
||||
|
||||
<h2 align="center">Usage</h2>
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
Usage: deduplicator [OPTIONS]
|
||||
find,filter and delete duplicate files
|
||||
|
||||
Usage: deduplicator [OPTIONS] [scan_dir_path]
|
||||
|
||||
Arguments:
|
||||
[scan_dir_path] Run Deduplicator on dir different from pwd (e.g., ~/Pictures )
|
||||
|
||||
Options:
|
||||
-t, --types <TYPES> Filetypes to deduplicate (default = all)
|
||||
--dir <DIR> Run Deduplicator on dir different from pwd
|
||||
-n, --nocache Don't use cache for indexing files (default = true)
|
||||
-h, --help Print help information
|
||||
-V, --version Print version information
|
||||
-T, --exclude-types <EXCLUDE_TYPES> Exclude Filetypes [default = none]
|
||||
-t, --types <TYPES> Filetypes to deduplicate [default = all]
|
||||
-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]
|
||||
-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
|
||||
-f, --follow-links Follow links while scanning directories
|
||||
-s, --strict Guarantees that two files are duplicate (performs a full hash)
|
||||
-p, --progress Show Progress spinners & metrics
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Scan for duplicates recursively from the current dir, only look for png, jpg & pdf file types & interactively delete files
|
||||
deduplicator -t pdf,jpg,png -i
|
||||
|
||||
# Scan for duplicates recursively from 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
|
||||
deduplicator ~/Pictures/ -t png,jpeg,jpg,pdf -i
|
||||
|
||||
# Scan for duplicates in the ~/Pictures without recursing into subdirectories
|
||||
deduplicator ~/Pictures --max-depth 0
|
||||
|
||||
# look for duplicates in the ~/.config directory while also recursing into symbolic link paths
|
||||
deduplicator ~/.config --follow-links
|
||||
|
||||
# scan for duplicates that are greater than 100mb in the ~/Media directory
|
||||
deduplicator ~/Media --min-size 100mb
|
||||
```
|
||||
|
||||
<h2 align="center">Installation</h2>
|
||||
## Demo
|
||||

|
||||
|
||||
<p align="center">Currently, deduplicator is only installable via rust's cargo package manager</p>
|
||||
|
||||
|
||||
|
||||
## Installation
|
||||
Currently, you can only install deduplicator using cargo package manager.
|
||||
|
||||
### Cargo
|
||||
> GxHash relies on aes hardware acceleration, so please set `RUSTFLAGS` to `"-C target-feature=+aes"` or `"-C target-cpu=native"` before
|
||||
> installing.
|
||||
|
||||
#### install from crates.io (stable)
|
||||
|
||||
```bash
|
||||
$ RUSTFLAGS="-C target-cpu=native" cargo install deduplicator
|
||||
|
||||
# or
|
||||
|
||||
$ RUSTFLAGS="-C target-feature=+aes,+sse2" cargo install deduplicator
|
||||
```
|
||||
cargo install deduplicator
|
||||
|
||||
#### install from git (nightly)
|
||||
```bash
|
||||
$ 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
|
||||
```
|
||||
<p align="center">
|
||||
note that if you use a version manager to install rust (like asdf), you need to reshim (`asdf reshim rust`).
|
||||
</p>
|
||||
|
||||
<h2 align="center">Performance</h2>
|
||||
### 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/`.
|
||||
|
||||
<p align="center">
|
||||
Deduplicator uses fxhash (a non-cryptographic hashing algorithm) which is extremely fast. As a result, deduplicator is able to process huge amounts of data in a couple of seconds.</p>
|
||||
## 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.
|
||||
|
||||
<p align="center">
|
||||
While testing, Deduplicator was able to go through 8.6GB of pdf files and detect duplicates in 2.9 seconds
|
||||
</p>
|
||||
<h2 align="center">Screenshots</h2>
|
||||
### Benchmarks
|
||||
I've used hyperfine to run deduplicator on files generated by the rake file at `rakelib/benchmark.rake`. The Benchmarking accuracy can further be improved by isolating runs inside restricted docker containers. I'll include that in the future. For now, here's the hyperfine output on my i7-12800H laptop with 32G of RAM.
|
||||
|
||||
<p align="center">
|
||||
<img align="center" src="https://user-images.githubusercontent.com/36154121/211458077-90092aa3-496c-492f-a061-618059890d5f.png" width="500" height="400" />
|
||||
</p>
|
||||
#### Fewer Large Files
|
||||
```
|
||||
# hyperfine -N --warmup 80 './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]
|
||||
Range (min … max): 1.3 ms … 7.1 ms 1522 runs
|
||||
|
||||
dust 'bench_artifacts'
|
||||
|
||||
54M ┌── file_0_fwds.bin │████ │ 2%
|
||||
122M ├── file_1_fwds.bin │████████ │ 5%
|
||||
390M ├── file_0_fwdcbss.bin│██████████████████████████ │ 15%
|
||||
390M ├── file_0_fwscas.bin │██████████████████████████ │ 15%
|
||||
390M ├── file_0_fwss.bin │██████████████████████████ │ 15%
|
||||
390M ├── file_1_fwdcbss.bin│██████████████████████████ │ 15%
|
||||
390M ├── file_1_fwscas.bin │██████████████████████████ │ 15%
|
||||
390M ├── file_1_fwss.bin │██████████████████████████ │ 15%
|
||||
2.5G ┌─┴ bench_artifacts │██████████████████████████████████████████████████████████████████ │ 100%
|
||||
```
|
||||
|
||||
#### Many Small Files
|
||||
```
|
||||
# hyperfine --warmup 20 './target/release/deduplicator bench_artifacts'
|
||||
Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||
Time (mean ± σ): 40.1 ms ± 2.3 ms [User: 251.0 ms, System: 277.3 ms]
|
||||
Range (min … max): 35.0 ms … 45.9 ms 72 runs
|
||||
|
||||
dust 'bench_artifacts'
|
||||
3.9M ┌── file_992_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_992_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_993_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_993_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_993_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_994_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_994_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_994_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_995_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_995_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_995_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_996_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_996_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_996_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_997_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_997_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_997_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_998_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_998_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_998_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_999_fwdcbss.bin│█ │ 0%
|
||||
3.9M ├── file_999_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_999_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_99_fwdcbss.bin │█ │ 0%
|
||||
3.9M ├── file_99_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_99_fwss.bin │█ │ 0%
|
||||
3.9M ├── file_9_fwdcbss.bin │█ │ 0%
|
||||
3.9M ├── file_9_fwscas.bin │█ │ 0%
|
||||
3.9M ├── file_9_fwss.bin │█ │ 0%
|
||||
11G ┌─┴ bench_artifacts │████████████████████████████████████████████████████████████████ │ 100%
|
||||
```
|
||||
|
||||
## proposed
|
||||
- [ ] parallelization
|
||||
- [ ] 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
|
||||
- [ ] add more unit tests
|
||||
- [ ] test against different filesystems
|
||||
- [ ] test against different file name encodings
|
||||
- [ ] restore json output (was removed in 0.3 due to quality issues)
|
||||
- [ ] fix memory leak on very large filesystems
|
||||
- [ ] maybe use a bloom filter
|
||||
- [ ] reduce FileInfo size
|
||||
- [ ] tui
|
||||
- [ ] change the default hashing method to include the first & last page of a file (8K)
|
||||
- [ ] provide option to localize duplicate detection to arbitrary levels relative to current directory
|
||||
- [ ] localize file meta store locks to sub path levels to avoid global lock contention from multiple threads.
|
||||
- [ ] bulk operations
|
||||
- [ ] --keep-latest
|
||||
- [ ] --keep-oldest
|
||||
- [ ] --keep-last-modified
|
||||
- [ ] --keep-first-modified
|
||||
|
||||
- [ ] fix: partial hash collision - a file full of null bytes ("\0") and an empty file. This is a known trade off in gxhash.
|
||||
- [ ] include initial pages and final pages of the file
|
||||
- [ ] append the offset between the last initial page hashed and the first final page hashed in the content passed to the hasher.
|
||||
- [ ] potential optimizations
|
||||
- [ ] lookup the memory efficiency gains if instead of directly inserting into a hashmap, deduplicator looksup the file in a bloom filter. this way, the duplicate store hashmap does
|
||||
not require to be locked for every single file. The bloom filter can be stored in an atomically updateable type to improve performance as well.
|
||||
|
||||
## v0.3.2
|
||||
- [x] fix: single file groups are printed to screen
|
||||
- [x] fix: --exclude-types and --types flag behave identically.
|
||||
|
||||
## v0.3.1
|
||||
- [x] parallelization
|
||||
- [x] (scanning + processing sw + processing hw) & formatting & printing
|
||||
- [x] remove formatting step and write directly to stdout
|
||||
- [x] simplify output to improve performance
|
||||
- [x] increase the number of pages hashed in partial hashing
|
||||
- [x] updated dependencies
|
||||
- [x] fix: full file hash collision between a file full of null bytes ("\0") and an empty file. This is a known trade off in gxhash.
|
||||
- [x] appending the file size at the end of content before hashing.
|
||||
- [x] created an automated release pipeline
|
||||
|
||||
## v0.3.0
|
||||
- [x] parallelization
|
||||
- [x] (scanning) + (processing sw & processing hw & formatting & printing)
|
||||
- [x] reduce cloning values on the heap
|
||||
- [x] add a partial hashing mode (--strict)
|
||||
- [x] add unit tests
|
||||
- [x] add silent mode
|
||||
- [x] update documentation
|
||||
- [x] remove color output
|
||||
- [x] progress bar improvements
|
||||
- [x] use progress bar groups
|
||||
- [x] remove broken json rendering
|
||||
- [x] add benchmarks
|
||||
|
||||
102
rakelib/benchmark.rake
Normal file
102
rakelib/benchmark.rake
Normal file
@@ -0,0 +1,102 @@
|
||||
require 'tempfile'
|
||||
require 'fileutils'
|
||||
require 'securerandom'
|
||||
|
||||
namespace :benchmark do
|
||||
file 'target/release/deduplicator' do
|
||||
sh "cargo build --release"
|
||||
end
|
||||
|
||||
task :few_large_files => 'target/release/deduplicator' do
|
||||
# Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||
# Time (mean ± σ): 2.5 ms ± 0.6 ms [User: 2.4 ms, System: 4.8 ms]
|
||||
# Range (min … max): 1.8 ms … 9.7 ms 1474 runs
|
||||
|
||||
root = "bench_artifacts"
|
||||
FileUtils.rm_rf(root)
|
||||
Dir.mkdir(root)
|
||||
|
||||
# files with same size
|
||||
puts "generating files of same size ..."
|
||||
2.times.map do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f|
|
||||
f.write(SecureRandom.bytes(4096 * 100_000))
|
||||
end
|
||||
end
|
||||
|
||||
# files with different sizes
|
||||
puts "generating files of different sizes ..."
|
||||
2.times.map do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f|
|
||||
f.write(SecureRandom.bytes(4096 * (rand * 100_000).ceil))
|
||||
end
|
||||
end
|
||||
|
||||
# files with same content & size
|
||||
puts "generating files of same content and sizes ..."
|
||||
2.times.each do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f|
|
||||
f.write("\0" * (4096 * 100_000))
|
||||
end
|
||||
end
|
||||
|
||||
# files with different content but same size
|
||||
puts "generating files of different content but same sizes ..."
|
||||
2.times.each do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f|
|
||||
f.write(SecureRandom.bytes(4096 * 100_000))
|
||||
end
|
||||
end
|
||||
|
||||
sh("hyperfine -N --warmup 80 './target/release/deduplicator #{root}'")
|
||||
sh("dust '#{root}'")
|
||||
|
||||
FileUtils.rm_rf(root)
|
||||
end
|
||||
|
||||
task :many_small_files => 'target/release/deduplicator' do
|
||||
# Benchmark 1: ./target/release/deduplicator bench_artifacts
|
||||
# Time (mean ± σ): 10.6 ms ± 1.0 ms [User: 20.0 ms, System: 22.5 ms]
|
||||
# Range (min … max): 8.4 ms … 14.2 ms 235 runs
|
||||
|
||||
root = "bench_artifacts"
|
||||
Dir.mkdir(root)
|
||||
|
||||
# files with same size
|
||||
puts "generating 1000 files of the same size ... "
|
||||
1000.times.each do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwss.bin"), 'wb') do |f|
|
||||
f.write(SecureRandom.bytes(4096 * 1000))
|
||||
end
|
||||
end
|
||||
|
||||
# files with different sizes
|
||||
puts "generating 1000 files of different sizes ... "
|
||||
1000.times.each do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwds.bin"), 'wb') do |f|
|
||||
f.write(SecureRandom.bytes(4096 * (rand * 100).ceil))
|
||||
end
|
||||
end
|
||||
|
||||
# files with same content & size
|
||||
puts "generating files of same content and sizes ..."
|
||||
1000.times.each do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwscas.bin"), 'wb') do |f|
|
||||
f.write("\0" * (4096 * 1000))
|
||||
end
|
||||
end
|
||||
|
||||
# files with different content but same size
|
||||
puts "generating files of different content but same sizes ..."
|
||||
1000.times.each do |i|
|
||||
File.open(File.join(root, "file_#{i}_fwdcbss.bin"), 'wb') do |f|
|
||||
f.write(SecureRandom.bytes(4096 * 1000))
|
||||
end
|
||||
end
|
||||
|
||||
sh("hyperfine --warmup 20 './target/release/deduplicator #{root}'")
|
||||
sh("dust '#{root}'")
|
||||
|
||||
FileUtils.rm_rf(root)
|
||||
end
|
||||
end
|
||||
@@ -1,28 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, KeyCode, KeyEvent};
|
||||
|
||||
use super::events;
|
||||
|
||||
pub struct EventHandler;
|
||||
|
||||
impl EventHandler {
|
||||
pub fn init() -> Result<events::Event> {
|
||||
if crossterm::event::poll(Duration::from_millis(10))? {
|
||||
match event::read()? {
|
||||
event::Event::Key(keycode) => Self::handle_keypress(keycode),
|
||||
_ => Ok(events::Event::Noop),
|
||||
}
|
||||
} else {
|
||||
Ok(events::Event::Noop)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_keypress(keyevent: KeyEvent) -> Result<events::Event> {
|
||||
match keyevent.code {
|
||||
KeyCode::Char('q') => Ok(events::Event::Exit),
|
||||
_ => Ok(events::Event::Noop),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub enum Event {
|
||||
Exit,
|
||||
Noop,
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use crate::database::File;
|
||||
use anyhow::Result;
|
||||
use colored::Colorize;
|
||||
|
||||
pub fn delete_files(files: Vec<File>) -> Result<()> {
|
||||
files.into_iter().for_each(|file| {
|
||||
match std::fs::remove_file(file.path.clone()) {
|
||||
Ok(_) => println!("{}: {}", "DELETED".green(), file.path),
|
||||
Err(e) => println!("{}: {}", "FAILED".red(), file.path)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
#![allow(unused)]
|
||||
|
||||
mod event_handler;
|
||||
mod events;
|
||||
mod formatter;
|
||||
mod ui;
|
||||
pub mod file_manager;
|
||||
|
||||
use std::{io, thread, time::Duration};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use crossterm::{event, execute, terminal};
|
||||
use event_handler::EventHandler;
|
||||
use tui::{
|
||||
backend::CrosstermBackend,
|
||||
widgets::{Block, Borders, Widget},
|
||||
Terminal,
|
||||
};
|
||||
use ui::Ui;
|
||||
|
||||
use crate::database;
|
||||
use crate::output;
|
||||
use crate::params::Params;
|
||||
use crate::scanner;
|
||||
|
||||
pub struct App;
|
||||
|
||||
impl App {
|
||||
pub fn init(app_args: &Params) -> Result<()> {
|
||||
// let mut term = Self::init_terminal()?;
|
||||
|
||||
let connection = database::get_connection(app_args)?;
|
||||
let duplicates = scanner::duplicates(app_args, &connection)?;
|
||||
|
||||
// Self::init_render_loop(&mut term)?;
|
||||
// Self::cleanup(&mut term)?;
|
||||
|
||||
match app_args.interactive {
|
||||
true => output::interactive(duplicates, app_args),
|
||||
false => output::print(duplicates, app_args) /* TODO: APP TUI INIT FUNCTION */
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup(term: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
terminal::disable_raw_mode()?;
|
||||
execute!(
|
||||
term.backend_mut(),
|
||||
terminal::LeaveAlternateScreen,
|
||||
event::DisableMouseCapture
|
||||
)?;
|
||||
|
||||
term.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_cycle(term: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
match EventHandler::init()? {
|
||||
events::Event::Noop => Ui::render_frame(term),
|
||||
events::Event::Exit => Err(anyhow!("Exit")),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_render_loop(term: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
// this could be simplified with a `while Self::render_cycle(term).is_ok() {}` in the current state, but maybe
|
||||
// it's good to keep it to handle errors in the future
|
||||
loop {
|
||||
match Self::render_cycle(term) {
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(
|
||||
stdout,
|
||||
terminal::EnterAlternateScreen,
|
||||
event::EnableMouseCapture
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
Ok(Terminal::new(backend)?)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use std::io;
|
||||
|
||||
use anyhow::Result;
|
||||
use tui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, Borders, List, ListItem, Widget},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
pub struct Ui;
|
||||
|
||||
impl Ui {
|
||||
fn generate_file_list() -> impl Widget {
|
||||
let tasks: Vec<ListItem> = vec!["Sreedev"; 100]
|
||||
.into_iter()
|
||||
.map(|item| ListItem::new(vec![Spans::from(Span::raw(item))]))
|
||||
.collect();
|
||||
|
||||
List::new(tasks)
|
||||
.block(Block::default().borders(Borders::ALL).title("List"))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("> ")
|
||||
}
|
||||
|
||||
fn generate_info_bar() -> impl Widget {
|
||||
Block::default().title("Description").borders(Borders::ALL)
|
||||
}
|
||||
|
||||
fn generate_file_desc() -> impl Widget {
|
||||
Block::default().title("Description").borders(Borders::ALL)
|
||||
}
|
||||
|
||||
pub fn render_frame(term: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
term.draw(|f| {
|
||||
let windows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Ratio(2, 16), Constraint::Ratio(14, 16)].as_ref())
|
||||
.split(f.size());
|
||||
|
||||
let subwindows = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Ratio(1, 4), Constraint::Ratio(3, 4)].as_ref())
|
||||
.split(windows[1]);
|
||||
|
||||
f.render_widget(Self::generate_info_bar(), windows[0]);
|
||||
f.render_widget(Self::generate_file_list(), subwindows[0]);
|
||||
f.render_widget(Self::generate_file_desc(), subwindows[1]);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
249
src/cache.rs
Normal file
249
src/cache.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
use std::env::temp_dir;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::params::Params;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct File {
|
||||
pub path: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
fn db_connection_url(args: &Params) -> String {
|
||||
match args.nocache {
|
||||
true => String::from(":memory:"),
|
||||
false => {
|
||||
let temp_dir_path = temp_dir();
|
||||
format!("{}/deduplicator.db", temp_dir_path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_connection(args: &Params) -> Result<sqlite::Connection, sqlite::Error> {
|
||||
sqlite::open(db_connection_url(args)).and_then(|conn| {
|
||||
setup(&conn).ok();
|
||||
Ok(conn)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setup(connection: &sqlite::Connection) -> Result<()> {
|
||||
let query = "CREATE TABLE files (file_identifier STRING, hash STRING)";
|
||||
connection.execute(query).ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn put(file: &File, connection: &sqlite::Connection) -> Result<()> {
|
||||
let query = format!(
|
||||
"INSERT INTO files (file_identifier, hash) VALUES (\"{}\", \"{}\")",
|
||||
file.path, file.hash
|
||||
);
|
||||
connection.execute(query)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn indexed_paths(connection: &sqlite::Connection) -> Result<Vec<File>> {
|
||||
let query = "SELECT * FROM files";
|
||||
|
||||
let result: Vec<File> = connection
|
||||
.prepare(query)?
|
||||
.into_iter()
|
||||
.filter_map(|row_result| row_result.ok())
|
||||
.map(|row| {
|
||||
let path = row.read::<&str, _>("file_identifier").to_string();
|
||||
let hash = row.read::<i64, _>("hash").to_string();
|
||||
File { path, hash }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn duplicate_hashes(connection: &sqlite::Connection, path: &str) -> Result<Vec<File>> {
|
||||
let query = format!(
|
||||
"
|
||||
SELECT a.* FROM files a
|
||||
JOIN (SELECT file_identifier, hash, COUNT(*)
|
||||
FROM files
|
||||
GROUP BY hash
|
||||
HAVING count(*) > 1 ) b
|
||||
ON a.hash = b.hash
|
||||
WHERE a.file_identifier LIKE \"{}%\"
|
||||
ORDER BY a.file_identifier
|
||||
",
|
||||
path
|
||||
);
|
||||
|
||||
let result: Vec<File> = connection
|
||||
.prepare(query)?
|
||||
.into_iter()
|
||||
.filter_map(|row_result| row_result.ok())
|
||||
.map(|row| {
|
||||
let path = row.read::<&str, _>("file_identifier").to_string();
|
||||
let hash = row.read::<i64, _>("hash").to_string();
|
||||
File { path, hash }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
85
src/fileinfo.rs
Normal file
85
src/fileinfo.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use anyhow::Result;
|
||||
use gxhash::gxhash128;
|
||||
use memmap2::Mmap;
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileInfo {
|
||||
pub path: Box<Path>,
|
||||
pub size: u64,
|
||||
pub modified: SystemTime,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
pub fn hash(&self, seed: i64) -> Result<u128> {
|
||||
if self.size == 0 {
|
||||
return Ok(0u128);
|
||||
};
|
||||
|
||||
let file = fs::File::open(&self.path)?;
|
||||
let mapper = unsafe { Mmap::map(&file)? };
|
||||
let content_hash = mapper
|
||||
.chunks(4096)
|
||||
.fold(0u128, |acc, chunk: &[u8]| acc ^ gxhash128(chunk, seed));
|
||||
|
||||
// NOTE: avoids collision bw an empty file & a file full of null bytes.
|
||||
Ok(content_hash ^ gxhash128(&self.size.to_ne_bytes(), seed))
|
||||
}
|
||||
|
||||
pub fn initpages_hash(&self, seed: i64) -> Result<u128> {
|
||||
let mut file = fs::File::open(&self.path)?;
|
||||
let mut buffer = [0; 16384];
|
||||
let bytes_read = file.read(&mut buffer)?;
|
||||
|
||||
Ok(gxhash128(&buffer[..bytes_read], seed))
|
||||
}
|
||||
|
||||
pub fn new(path: PathBuf) -> Result<Self> {
|
||||
let filemeta = std::fs::metadata(&path)?;
|
||||
Ok(Self {
|
||||
path: path.into_boxed_path(),
|
||||
size: filemeta.len(),
|
||||
modified: filemeta.modified()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
70
src/formatter.rs
Normal file
70
src/formatter.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use crate::{fileinfo::FileInfo, params::Params, pipeline::DedupReport};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use pathdiff::diff_paths;
|
||||
use rayon::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const YELLOW: &str = "\x1b[33m";
|
||||
const RESET: &str = "\x1b[0m";
|
||||
|
||||
pub struct Formatter;
|
||||
impl Formatter {
|
||||
pub fn human_path(file: &FileInfo, aargs: &Params, max_path_length: usize) -> Result<String> {
|
||||
let base_directory: PathBuf = aargs.get_directory()?;
|
||||
let relative_path = diff_paths(&file.path, base_directory).unwrap_or_default();
|
||||
|
||||
let formatted_path = format!(
|
||||
"{:<0width$}",
|
||||
relative_path.to_str().unwrap_or_default().to_string(),
|
||||
width = max_path_length
|
||||
);
|
||||
|
||||
Ok(formatted_path)
|
||||
}
|
||||
|
||||
pub fn human_filesize(file: &FileInfo) -> Result<String> {
|
||||
Ok(format!("{:>12}", bytesize::ByteSize::b(file.size)))
|
||||
}
|
||||
|
||||
pub fn human_mtime(file: &FileInfo) -> Result<String> {
|
||||
let modified_time: DateTime<Utc> = file.modified.into();
|
||||
Ok(modified_time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
}
|
||||
|
||||
pub fn print(report: &DedupReport, aargs: &Params) {
|
||||
print!("{}", "\n".repeat(if aargs.progress { 2 } else { 1 }));
|
||||
|
||||
if report.groups.is_empty() {
|
||||
println!("No duplicates found matching your search criteria.");
|
||||
return;
|
||||
}
|
||||
|
||||
report.groups.par_iter().for_each(|group| {
|
||||
let mut ostring = format!("{}{:32x}{}\n", YELLOW, group.hash, RESET);
|
||||
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);
|
||||
println!("{ostring}");
|
||||
});
|
||||
}
|
||||
}
|
||||
122
src/interactive.rs
Normal file
122
src/interactive.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use crate::{fileinfo::FileInfo, formatter::Formatter, params::Params, pipeline::DuplicateGroup};
|
||||
use anyhow::Result;
|
||||
use prettytable::{format, row, Table};
|
||||
use std::io::{self, Write};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub struct Interactive;
|
||||
|
||||
impl Interactive {
|
||||
pub fn init(groups: &[DuplicateGroup], app_args: &Params) -> Result<()> {
|
||||
if groups.is_empty() {
|
||||
println!("No duplicates found matching your search criteria.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
groups.iter().enumerate().for_each(|(gindex, group)| {
|
||||
let files = &group.files;
|
||||
let mut itable = Table::new();
|
||||
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
|
||||
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
|
||||
|
||||
let max_path_size = files
|
||||
.iter()
|
||||
.map(|f| f.path.to_string_lossy().graphemes(true).count())
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
|
||||
files.iter().enumerate().for_each(|(index, file)| {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn scan_group_confirmation() -> Result<bool> {
|
||||
print!("\nconfirm? [y/N]: ");
|
||||
std::io::stdout().flush()?;
|
||||
let mut user_input = String::new();
|
||||
io::stdin().read_line(&mut user_input)?;
|
||||
|
||||
match user_input.trim() {
|
||||
"Y" | "y" => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_group_instruction() -> Result<String> {
|
||||
println!("\nEnter the indices of the files you want to delete.");
|
||||
println!("You can enter multiple files using commas to seperate file indices.");
|
||||
println!("example: 1,2");
|
||||
print!("\n> ");
|
||||
std::io::stdout().flush()?;
|
||||
let mut user_input = String::new();
|
||||
io::stdin().read_line(&mut user_input)?;
|
||||
|
||||
Ok(user_input)
|
||||
}
|
||||
|
||||
pub fn process_group_action(
|
||||
duplicates: &Vec<FileInfo>,
|
||||
dup_index: usize,
|
||||
dup_size: usize,
|
||||
table: Table,
|
||||
) {
|
||||
println!("\nDuplicate Set {} of {}\n", dup_index + 1, dup_size);
|
||||
table.printstd();
|
||||
let files_to_delete = Self::scan_group_instruction().unwrap_or_default();
|
||||
let parsed_file_indices = files_to_delete
|
||||
.trim()
|
||||
.split(',')
|
||||
.filter(|element| !element.is_empty())
|
||||
.map(|index| index.parse::<usize>().unwrap_or_default())
|
||||
.collect::<Vec<usize>>();
|
||||
|
||||
if parsed_file_indices
|
||||
.clone()
|
||||
.into_iter()
|
||||
.any(|index| index > (duplicates.len() - 1))
|
||||
{
|
||||
println!("Err: File Index Out of Bounds!");
|
||||
return Self::process_group_action(duplicates, dup_index, dup_size, table);
|
||||
}
|
||||
|
||||
print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
|
||||
|
||||
if parsed_file_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let files_to_delete = parsed_file_indices
|
||||
.into_iter()
|
||||
.map(|index| duplicates[index].clone());
|
||||
|
||||
println!("\nThe following files will be deleted:");
|
||||
files_to_delete
|
||||
.clone()
|
||||
.enumerate()
|
||||
.for_each(|(index, file)| {
|
||||
println!("{}: {}", index, file.path.display());
|
||||
});
|
||||
|
||||
match Self::scan_group_confirmation().unwrap() {
|
||||
true => {
|
||||
files_to_delete.into_iter().for_each(|file| {
|
||||
match std::fs::remove_file(file.path.clone()) {
|
||||
Ok(_) => println!("DELETED: {}", file.path.display()),
|
||||
Err(_) => println!("FAILED: {}", file.path.display()),
|
||||
}
|
||||
});
|
||||
}
|
||||
false => println!("\nCancelled Delete Operation."),
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/main.rs
30
src/main.rs
@@ -1,15 +1,29 @@
|
||||
#![allow(unused)] // TODO: remove this once TUI is implemented
|
||||
mod app;
|
||||
mod database;
|
||||
mod output;
|
||||
mod cache;
|
||||
mod fileinfo;
|
||||
mod formatter;
|
||||
mod interactive;
|
||||
mod params;
|
||||
mod pipeline;
|
||||
mod processor;
|
||||
mod resolver;
|
||||
mod scanner;
|
||||
mod tui;
|
||||
|
||||
use self::{formatter::Formatter, interactive::Interactive};
|
||||
use anyhow::Result;
|
||||
use app::App;
|
||||
use clap::Parser;
|
||||
use params::Params;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
App::init(¶ms::Params::parse())
|
||||
fn main() -> Result<()> {
|
||||
let params = Params::parse();
|
||||
let report = pipeline::run(¶ms)?;
|
||||
|
||||
match (params.tui, params.keep, params.interactive) {
|
||||
(true, _, _) => tui::run(report, ¶ms)?,
|
||||
(false, Some(strategy), _) => resolver::run(&report, strategy, params.force, ¶ms)?,
|
||||
(false, None, true) => Interactive::init(&report.groups, ¶ms)?,
|
||||
(false, None, false) => Formatter::print(&report, ¶ms),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
165
src/output.rs
165
src/output.rs
@@ -1,165 +0,0 @@
|
||||
use std::{collections::HashMap, fs, io};
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::offset::Utc;
|
||||
use chrono::DateTime;
|
||||
use colored::Colorize;
|
||||
use humansize::{format_size, DECIMAL};
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::app::file_manager;
|
||||
use crate::database::File;
|
||||
use crate::params::Params;
|
||||
use prettytable::{format, row, Cell, Row, Table};
|
||||
|
||||
fn format_path(path: &str, opts: &Params) -> Result<String> {
|
||||
let display_path = path.replace(&opts.get_directory()?, "");
|
||||
let text_vec = display_path.chars().collect::<Vec<_>>();
|
||||
|
||||
let display_range = if text_vec.len() > 32 {
|
||||
text_vec[(display_path.len() - 32)..]
|
||||
.iter()
|
||||
.collect::<String>()
|
||||
} else {
|
||||
display_path
|
||||
};
|
||||
|
||||
Ok(format!("...{:<32}", display_range))
|
||||
}
|
||||
|
||||
fn file_size(path: &String) -> Result<String> {
|
||||
let mdata = fs::metadata(path)?;
|
||||
let formatted_size = format!("{:>12}", format_size(mdata.len(), DECIMAL));
|
||||
Ok(formatted_size)
|
||||
}
|
||||
|
||||
fn modified_time(path: &String) -> Result<String> {
|
||||
let mdata = fs::metadata(path)?;
|
||||
let modified_time: DateTime<Utc> = mdata.modified()?.into();
|
||||
|
||||
Ok(modified_time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
}
|
||||
|
||||
fn group_duplicates(duplicates: Vec<File>) -> HashMap<String, Vec<File>> {
|
||||
let mut duplicate_mapper: HashMap<String, Vec<File>> = HashMap::new();
|
||||
duplicates.into_iter().for_each(|file| {
|
||||
duplicate_mapper
|
||||
.entry(file.hash.clone())
|
||||
.and_modify(|value| value.push(file.clone()))
|
||||
.or_insert_with(|| vec![file]);
|
||||
});
|
||||
|
||||
duplicate_mapper
|
||||
}
|
||||
|
||||
fn print_meta_info(duplicates: &Vec<File>, opts: &Params) {
|
||||
println!("Deduplicator v{}", std::env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
fn scan_group_instruction() -> Result<String> {
|
||||
println!("\nEnter the indices of the files you want to delete.");
|
||||
println!("You can enter multiple files using commas to seperate file indices.");
|
||||
println!("example: 1,2");
|
||||
print!("\n> ");
|
||||
std::io::stdout().flush()?;
|
||||
let mut user_input = String::new();
|
||||
io::stdin().read_line(&mut user_input)?;
|
||||
|
||||
Ok(user_input)
|
||||
}
|
||||
|
||||
fn scan_group_confirmation() -> Result<bool> {
|
||||
print!("\nconfirm? [Y/n]: ");
|
||||
std::io::stdout().flush()?;
|
||||
let mut user_input = String::new();
|
||||
io::stdin().read_line(&mut user_input)?;
|
||||
|
||||
match user_input.trim() {
|
||||
"Y" | "y" => Ok(true),
|
||||
_ => Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_group_action(duplicates: &Vec<File>, dup_index: usize, dup_size: usize, table: Table) {
|
||||
println!("\nDuplicate Set {} of {}\n", dup_index + 1, dup_size);
|
||||
table.printstd();
|
||||
let files_to_delete = scan_group_instruction().unwrap_or_default();
|
||||
let parsed_file_indices = files_to_delete
|
||||
.trim()
|
||||
.split(',')
|
||||
.filter(|element| !element.is_empty())
|
||||
.map(|index| index.parse::<usize>().unwrap_or_default())
|
||||
.collect_vec();
|
||||
|
||||
if parsed_file_indices
|
||||
.clone()
|
||||
.into_iter()
|
||||
.any(|index| index > (duplicates.len() - 1))
|
||||
{
|
||||
println!("{}", "Err: File Index Out of Bounds!".red());
|
||||
return process_group_action(duplicates, dup_index, dup_size, table);
|
||||
}
|
||||
|
||||
print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
|
||||
|
||||
if parsed_file_indices.is_empty() { return }
|
||||
|
||||
let files_to_delete = parsed_file_indices
|
||||
.into_iter()
|
||||
.map(|index| duplicates[index].clone());
|
||||
|
||||
println!("\n{}", "The following files will be deleted:".red());
|
||||
files_to_delete.clone().enumerate().for_each(|(index, file)| {
|
||||
println!("{}: {}", index.to_string().blue(), file.path);
|
||||
});
|
||||
|
||||
match scan_group_confirmation().unwrap() {
|
||||
true => { file_manager::delete_files(files_to_delete.collect_vec()); },
|
||||
false => println!("{}", "\nCancelled Delete Operation.".red())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interactive(duplicates: Vec<File>, opts: &Params) {
|
||||
print_meta_info(&duplicates, opts);
|
||||
let grouped_duplicates = group_duplicates(duplicates);
|
||||
|
||||
grouped_duplicates.iter().enumerate().for_each(|(gindex, (hash, group))| {
|
||||
let mut itable = Table::new();
|
||||
itable.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
|
||||
itable.set_titles(row!["index", "filename", "size", "updated_at"]);
|
||||
group.iter().enumerate().for_each(|(index, file)| {
|
||||
itable.add_row(row![
|
||||
index,
|
||||
format_path(&file.path, opts).unwrap_or_default().blue(),
|
||||
file_size(&file.path).unwrap_or_default().red(),
|
||||
modified_time(&file.path).unwrap_or_default().yellow()
|
||||
]);
|
||||
});
|
||||
|
||||
process_group_action(group, gindex, grouped_duplicates.len(), itable);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn print(duplicates: Vec<File>, opts: &Params) {
|
||||
print_meta_info(&duplicates, opts);
|
||||
|
||||
let mut output_table = Table::new();
|
||||
let grouped_duplicates: HashMap<String, Vec<File>> = group_duplicates(duplicates);
|
||||
|
||||
output_table.set_titles(row!["hash", "duplicates"]);
|
||||
grouped_duplicates.iter().for_each(|(hash, group)| {
|
||||
let mut inner_table = Table::new();
|
||||
inner_table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
|
||||
group.iter().for_each(|file| {
|
||||
inner_table.add_row(row![
|
||||
format_path(&file.path, opts).unwrap_or_default().blue(),
|
||||
file_size(&file.path).unwrap_or_default().red(),
|
||||
modified_time(&file.path).unwrap_or_default().yellow()
|
||||
]);
|
||||
});
|
||||
output_table.add_row(row![hash.green(), inner_table]);
|
||||
});
|
||||
|
||||
output_table.printstd();
|
||||
}
|
||||
@@ -1,40 +1,73 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, ValueHint};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[derive(Parser, Debug, Default, Clone)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Params {
|
||||
/// Filetypes to deduplicate (default = all)
|
||||
/// Exclude Filetypes [default = none]
|
||||
#[arg(short = 'T', long)]
|
||||
pub exclude_types: Option<String>,
|
||||
/// Filetypes to deduplicate [default = all]
|
||||
#[arg(short, long)]
|
||||
pub types: Option<String>,
|
||||
/// Run Deduplicator on dir different from pwd
|
||||
#[arg(long)]
|
||||
/// Run Deduplicator on dir different from pwd (e.g., ~/Pictures )
|
||||
#[arg(value_hint = ValueHint::DirPath, value_name = "scan_dir_path")]
|
||||
pub dir: Option<PathBuf>,
|
||||
/// Don't use cache for indexing files (default = false)
|
||||
#[arg(long, short)]
|
||||
pub nocache: bool,
|
||||
/// Delete files interactively
|
||||
#[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).
|
||||
#[arg(long, short = 'm', default_value = "1b")]
|
||||
pub min_size: Option<String>,
|
||||
/// Max Depth to scan while looking for duplicates
|
||||
#[arg(long, short = 'D')]
|
||||
pub max_depth: Option<usize>,
|
||||
/// Min Depth to scan while looking for duplicates
|
||||
#[arg(long, short = 'd')]
|
||||
pub min_depth: Option<usize>,
|
||||
/// Follow links while scanning directories
|
||||
#[arg(long, short)]
|
||||
pub follow_links: bool,
|
||||
/// Guarantees that two files are duplicate (performs a full hash)
|
||||
#[arg(long, short = 's', default_value = "false")]
|
||||
pub strict: bool,
|
||||
/// Show Progress spinners & metrics
|
||||
#[arg(long, short = 'p', default_value = "false")]
|
||||
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 {
|
||||
pub fn get_directory(&self) -> Result<String> {
|
||||
let dir_pathbuf: PathBuf = self
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or(std::env::current_dir()?)
|
||||
.as_os_str()
|
||||
.into();
|
||||
|
||||
let dir = fs::canonicalize(dir_pathbuf)?
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Invalid directory"))?
|
||||
.to_string();
|
||||
pub fn get_min_size(&self) -> Option<u64> {
|
||||
match &self.min_size {
|
||||
Some(msize) => match msize.parse::<bytesize::ByteSize>() {
|
||||
Ok(units) => Some(units.0),
|
||||
Err(_) => None,
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_directory(&self) -> Result<PathBuf> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
let dir_path = self.dir.as_ref().unwrap_or(¤t_dir).as_path();
|
||||
let dir = fs::canonicalize(dir_path)?;
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
182
src/pipeline.rs
Normal file
182
src/pipeline.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
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(¶ms)?;
|
||||
|
||||
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(¶ms).unwrap();
|
||||
assert!(cache_file.exists());
|
||||
|
||||
let second = run(¶ms).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(¶ms).unwrap();
|
||||
assert!(!cache_file.exists());
|
||||
}
|
||||
}
|
||||
214
src/processor.rs
Normal file
214
src/processor.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
|
||||
|
||||
use crate::cache::{mtime_nanos, Cache};
|
||||
use crate::fileinfo::FileInfo;
|
||||
use crate::pipeline::{spinner, DuplicateGroup};
|
||||
|
||||
pub fn group_by_size(files: Vec<FileInfo>, progress: bool) -> Vec<Vec<FileInfo>> {
|
||||
let bar = spinner(progress, "files grouped by size");
|
||||
|
||||
let mut buckets: HashMap<u64, Vec<FileInfo>> = HashMap::new();
|
||||
for file in files {
|
||||
bar.inc(1);
|
||||
buckets.entry(file.size).or_default().push(file);
|
||||
}
|
||||
|
||||
bar.finish_with_message("files grouped by size");
|
||||
buckets.into_values().collect()
|
||||
}
|
||||
|
||||
pub fn hash_candidates(
|
||||
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
|
||||
.into_par_iter()
|
||||
.map(|file| {
|
||||
bar.inc(1);
|
||||
let mtime = mtime_nanos(file.modified);
|
||||
let hash = match cache.lookup(&file.path, file.size, mtime, strict) {
|
||||
Some(cached) => cached,
|
||||
None => match strict {
|
||||
true => file.hash(seed).expect("hashing file failed."),
|
||||
false => file.initpages_hash(seed).expect("hashing file failed."),
|
||||
},
|
||||
};
|
||||
(hash, file)
|
||||
})
|
||||
.collect();
|
||||
|
||||
bar.finish_with_message("files grouped by hash.");
|
||||
hashed
|
||||
}
|
||||
|
||||
pub fn group_hashed(hashed: Vec<(u128, FileInfo)>) -> Vec<DuplicateGroup> {
|
||||
let mut buckets: HashMap<u128, Vec<FileInfo>> = HashMap::new();
|
||||
for (hash, file) in hashed {
|
||||
buckets.entry(hash).or_default().push(file);
|
||||
}
|
||||
|
||||
buckets
|
||||
.into_iter()
|
||||
.filter(|(_, files)| files.len() > 1)
|
||||
.map(|(hash, files)| DuplicateGroup { hash, files })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod staged_tests {
|
||||
use anyhow::Result;
|
||||
use rand::Rng;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::fileinfo::FileInfo;
|
||||
|
||||
fn generate_bytes(size: usize) -> Vec<u8> {
|
||||
let mut rng = rand::rng();
|
||||
(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]
|
||||
fn group_by_size_separates_files_of_different_sizes() -> Result<()> {
|
||||
let root = TempDir::new()?;
|
||||
let files = write_files(
|
||||
&root,
|
||||
vec![
|
||||
("fileone.bin", generate_bytes(282624)),
|
||||
("filetwo.bin", generate_bytes(1720320)),
|
||||
],
|
||||
)?;
|
||||
|
||||
let groups = super::group_by_size(files, false);
|
||||
assert_eq!(groups.len(), 2);
|
||||
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_y.extend(generate_bytes(1720320));
|
||||
|
||||
let files = write_files(
|
||||
&root,
|
||||
vec![("fileone.bin", content_x), ("filetwo.bin", content_y)],
|
||||
)?;
|
||||
|
||||
let groups = super::group_hashed(super::hash_candidates(files, false, 300, false, &crate::cache::Cache::disabled()));
|
||||
assert_eq!(groups.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_hash_strict_mode_rejects_different_tails() -> 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_y.extend(generate_bytes(1720320));
|
||||
|
||||
let files = write_files(
|
||||
&root,
|
||||
vec![("fileone.bin", content_x), ("filetwo.bin", content_y)],
|
||||
)?;
|
||||
|
||||
let groups = super::group_hashed(super::hash_candidates(files, true, 300, false, &crate::cache::Cache::disabled()));
|
||||
assert_eq!(groups.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_hash_matches_identical_files() -> Result<()> {
|
||||
let root = TempDir::new()?;
|
||||
let content = generate_bytes(282624);
|
||||
|
||||
let files = write_files(
|
||||
&root,
|
||||
vec![
|
||||
("fileone.bin", content.clone()),
|
||||
("filetwo.bin", content.clone()),
|
||||
],
|
||||
)?;
|
||||
|
||||
let groups = super::group_hashed(super::hash_candidates(files, false, 300, false, &crate::cache::Cache::disabled()));
|
||||
assert_eq!(groups.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_candidates_uses_cached_hash_when_valid() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let path = root.path().join("f.bin");
|
||||
let mut f = File::create_new(&path).unwrap();
|
||||
f.write_all(b"real content for cache hit test").unwrap();
|
||||
let info = FileInfo::new(path.clone()).unwrap();
|
||||
let mtime = crate::cache::mtime_nanos(info.modified);
|
||||
|
||||
let mut cache = crate::cache::Cache::disabled();
|
||||
cache.record(&path, info.size, mtime, false, 0xDEAD_BEEF, 0);
|
||||
|
||||
let hashed = super::hash_candidates(vec![info], false, 300, false, &cache);
|
||||
assert_eq!(hashed[0].0, 0xDEAD_BEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_candidates_recomputes_when_mtime_differs() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let path = root.path().join("f.bin");
|
||||
let mut f = File::create_new(&path).unwrap();
|
||||
f.write_all(b"real content for cache miss test").unwrap();
|
||||
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();
|
||||
cache.record(&path, info.size, mtime + 1, false, 0xDEAD_BEEF, 0);
|
||||
|
||||
let hashed = super::hash_candidates(vec![info], false, 300, false, &cache);
|
||||
assert_eq!(hashed[0].0, real);
|
||||
assert_ne!(hashed[0].0, 0xDEAD_BEEF);
|
||||
}
|
||||
}
|
||||
283
src/resolver.rs
Normal file
283
src/resolver.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
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, ¶ms).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, ¶ms).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, ¶ms);
|
||||
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, ¶ms).is_ok());
|
||||
}
|
||||
}
|
||||
269
src/scanner.rs
269
src/scanner.rs
@@ -1,85 +1,216 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use crate::{fileinfo::FileInfo, params::Params, pipeline::spinner};
|
||||
use anyhow::Result;
|
||||
use fxhash::hash32 as hasher;
|
||||
use glob::glob;
|
||||
use itertools::Itertools;
|
||||
use rayon::prelude::*;
|
||||
use std::path::Path;
|
||||
use globwalk::{GlobWalker, GlobWalkerBuilder};
|
||||
|
||||
use crate::{
|
||||
database::{self, File},
|
||||
params::Params,
|
||||
};
|
||||
|
||||
pub fn duplicates(app_opts: &Params, connection: &sqlite::Connection) -> Result<Vec<File>> {
|
||||
let scan_results = scan(app_opts, connection)?;
|
||||
let base_path = app_opts.get_directory()?;
|
||||
|
||||
index_files(scan_results, connection)?;
|
||||
database::duplicate_hashes(connection, &base_path)
|
||||
pub struct Scanner {
|
||||
pub directory: Box<Path>,
|
||||
pub min_depth: Option<usize>,
|
||||
pub max_depth: Option<usize>,
|
||||
pub include_types: Option<String>,
|
||||
pub exclude_types: Option<String>,
|
||||
pub min_size: Option<u64>,
|
||||
pub follow_links: bool,
|
||||
pub progress: bool,
|
||||
}
|
||||
|
||||
fn get_glob_patterns(opts: &Params, directory: &str) -> Vec<PathBuf> {
|
||||
opts.types
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from("*"))
|
||||
.split(',')
|
||||
.map(|filetype| format!("*.{}", filetype))
|
||||
.map(|filetype| {
|
||||
vec![directory.to_owned(), String::from("**"), filetype]
|
||||
.iter()
|
||||
.collect()
|
||||
impl Scanner {
|
||||
pub fn new(app_args: &Params) -> Result<Self> {
|
||||
Ok(Self {
|
||||
directory: app_args.get_directory()?.into_boxed_path(),
|
||||
include_types: app_args.types.clone(),
|
||||
exclude_types: app_args.exclude_types.clone(),
|
||||
min_depth: app_args.min_depth,
|
||||
max_depth: app_args.max_depth,
|
||||
min_size: app_args.get_min_size(),
|
||||
follow_links: app_args.follow_links,
|
||||
progress: app_args.progress,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scan_patterns(&self) -> Result<Vec<String>> {
|
||||
let include_types = match &self.include_types {
|
||||
Some(ftypes) => Some(format!("**/*.{{{ftypes}}}")),
|
||||
None => Some("**/*".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> {
|
||||
Ok(walker.follow_links(self.follow_links))
|
||||
}
|
||||
|
||||
fn attach_walker_min_depth(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
|
||||
match self.min_depth {
|
||||
Some(min_depth) => Ok(walker.min_depth(min_depth)),
|
||||
None => Ok(walker),
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_walker_max_depth(&self, walker: GlobWalkerBuilder) -> Result<GlobWalkerBuilder> {
|
||||
match self.max_depth {
|
||||
Some(max_depth) => Ok(walker.max_depth(max_depth)),
|
||||
None => Ok(walker),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_walker(&self) -> Result<GlobWalker> {
|
||||
let walker = Ok(GlobWalkerBuilder::from_patterns(
|
||||
self.directory.clone(),
|
||||
&self.scan_patterns()?,
|
||||
))
|
||||
.and_then(|walker| self.attach_walker_min_depth(walker))
|
||||
.and_then(|walker| self.attach_walker_max_depth(walker))
|
||||
.and_then(|walker| self.attach_link_opts(walker))?;
|
||||
|
||||
Ok(walker.build()?)
|
||||
}
|
||||
|
||||
pub fn scan(&self) -> Result<Vec<FileInfo>> {
|
||||
let bar = spinner(self.progress, "paths mapped");
|
||||
let min_size = self.min_size.unwrap_or(0);
|
||||
|
||||
let files: Vec<FileInfo> = self
|
||||
.build_walker()?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entity| entity.into_path())
|
||||
.inspect(|_path| bar.inc(1))
|
||||
.filter(|path| path.is_file())
|
||||
.map(FileInfo::new)
|
||||
.filter_map(Result::ok)
|
||||
.filter(|file| file.size >= min_size)
|
||||
.collect();
|
||||
|
||||
bar.finish_with_message("paths mapped");
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_indexed_file(path: impl Into<String>, indexed: &[File]) -> bool {
|
||||
indexed
|
||||
#[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()
|
||||
.map(|file| file.path.clone())
|
||||
.contains(&path.into())
|
||||
}
|
||||
.for_each(|path| {
|
||||
File::create_new(root.path().join(path)).unwrap_or_else(|_| {
|
||||
panic!("unable to create file {path}");
|
||||
});
|
||||
});
|
||||
|
||||
fn scan(app_opts: &Params, connection: &sqlite::Connection) -> Result<Vec<String>> {
|
||||
let directory = app_opts.get_directory()?;
|
||||
let glob_patterns: Vec<PathBuf> = get_glob_patterns(app_opts, &directory);
|
||||
let indexed_paths = database::indexed_paths(connection)?;
|
||||
let files: Vec<String> = glob_patterns
|
||||
.par_iter()
|
||||
.filter_map(|glob_pattern| glob(glob_pattern.as_os_str().to_str()?).ok())
|
||||
.flat_map(|file_vec| {
|
||||
file_vec
|
||||
.filter_map(|x| Some(x.ok()?.as_os_str().to_str()?.to_string()))
|
||||
.filter(|fpath| !is_indexed_file(fpath, &indexed_paths))
|
||||
.filter(|glob_result| {
|
||||
fs::metadata(glob_result)
|
||||
.map(|f| f.is_file())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.collect();
|
||||
let params = Params {
|
||||
types: Some(String::from("js,csv")),
|
||||
dir: Some(root.path().into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
let scanner = Scanner::new(¶ms).expect("scanner initialization failed");
|
||||
let files = scanner.scan().expect("scanning failed.");
|
||||
|
||||
fn index_files(files: Vec<String>, connection: &sqlite::Connection) -> Result<()> {
|
||||
let hashed: Vec<File> = files
|
||||
.into_par_iter()
|
||||
.filter_map(|file| {
|
||||
let hash = hash_file(&file).ok()?;
|
||||
Some(database::File { path: file, hash })
|
||||
})
|
||||
.collect();
|
||||
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()));
|
||||
}
|
||||
|
||||
hashed
|
||||
#[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()
|
||||
.try_for_each(|file| database::put(file, connection))
|
||||
}
|
||||
.for_each(|path| {
|
||||
File::create_new(root.path().join(path)).unwrap_or_else(|_| {
|
||||
panic!("unable to create file {path}");
|
||||
});
|
||||
});
|
||||
|
||||
pub fn hash_file(filepath: &str) -> Result<String> {
|
||||
let file = fs::read(filepath)?;
|
||||
let hash = hasher(&*file).to_string();
|
||||
let params = Params {
|
||||
exclude_types: Some(String::from("js,csv")),
|
||||
dir: Some(root.path().into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(hash)
|
||||
let scanner = Scanner::new(¶ms).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(¶ms).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()));
|
||||
}
|
||||
}
|
||||
|
||||
499
src/tui/app.rs
Normal file
499
src/tui/app.rs
Normal file
@@ -0,0 +1,499 @@
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/tui/mod.rs
Normal file
129
src/tui/mod.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
),
|
||||
});
|
||||
}
|
||||
211
src/tui/ui.rs
Normal file
211
src/tui/ui.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
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, ¶ms)).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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user