├── .cargo
└── config.toml
├── .clippy.toml
├── .codecov.yml
├── .config
└── nextest.toml
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── bug-report.yml
│ ├── config.yml
│ ├── documention-issue.yml
│ ├── feature-request.yml
│ ├── performance-issue.yml
│ └── question.yml
├── pull_request_template.md
└── workflows
│ ├── benchmark.yml
│ ├── ci.yml
│ ├── codspeed.yml
│ ├── dependencies.yml
│ └── release.yml
├── .gitignore
├── .markdownlint.yml
├── .rustfmt.toml
├── .typos.toml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── benches
├── duplicate_benchmark.rs
├── organizer_benchmark.rs
└── scanner_benchmark.rs
├── crates
├── app
│ ├── Cargo.toml
│ └── src
│ │ ├── actions.rs
│ │ ├── duplicates.rs
│ │ ├── filters.rs
│ │ ├── handlers.rs
│ │ ├── lib.rs
│ │ ├── mod.rs
│ │ ├── navigation.rs
│ │ └── state.rs
├── config
│ ├── Cargo.toml
│ └── src
│ │ ├── lib.rs
│ │ └── settings.rs
├── core
│ ├── Cargo.toml
│ └── src
│ │ ├── cache.rs
│ │ ├── database_cache.rs
│ │ ├── duplicate_detector.rs
│ │ ├── file_manager.rs
│ │ ├── lib.rs
│ │ ├── organizer.rs
│ │ ├── scanner.rs
│ │ └── undo_manager.rs
├── models
│ ├── Cargo.toml
│ └── src
│ │ ├── duplicate.rs
│ │ ├── filters.rs
│ │ ├── lib.rs
│ │ ├── media_file.rs
│ │ ├── state.rs
│ │ └── statistics.rs
├── ui
│ ├── Cargo.toml
│ └── src
│ │ ├── dashboard.rs
│ │ ├── duplicate_detector.rs
│ │ ├── file_details.rs
│ │ ├── filtering.rs
│ │ ├── lib.rs
│ │ ├── progress.rs
│ │ ├── search.rs
│ │ └── settings.rs
└── utils
│ ├── Cargo.toml
│ └── src
│ ├── bytes.rs
│ ├── datetime.rs
│ ├── folder_stats.rs
│ ├── lib.rs
│ ├── media_types.rs
│ ├── path.rs
│ └── progress.rs
├── images
├── duplicate-detector-page.png
├── filters-page.png
├── main-page.png
├── screenshot.png
└── settings-page.png
├── justfile
├── nextest.toml
├── scripts
└── convert_benchmark_output.py
├── src
├── lib.rs
└── main.rs
└── tests
├── common
├── fixtures.rs
├── helpers.rs
└── mod.rs
├── integration
├── mod.rs
├── organizer.rs
└── scanner.rs
├── integration_tests.rs
├── system
├── mod.rs
└── workflow.rs
└── system_tests.rs
/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [target.'cfg(all())']
2 | rustflags = [
3 | "-Dwarnings",
4 | "-Dclippy::all",
5 | "-Dclippy::pedantic",
6 | "-Aclippy::similar_names",
7 | "-Aclippy::too_many_arguments",
8 | "-Aclippy::type_complexity",
9 | "-Dclippy::needless_pass_by_value",
10 | "-Dclippy::redundant_allocation",
11 | "-Dclippy::unused_async",
12 | "-Dclippy::significant_drop_tightening",
13 | "-Dclippy::expect_used",
14 | "-Dclippy::unwrap_used",
15 | "-Dclippy::await_holding_lock",
16 | "-Dclippy::unnecessary_unwrap",
17 |
18 | # Performance improvements
19 | "-Dclippy::inefficient_to_string",
20 | "-Dclippy::large_stack_arrays",
21 | "-Dclippy::large_types_passed_by_value",
22 | "-Dclippy::manual_memcpy",
23 | "-Dclippy::redundant_clone",
24 | "-Dclippy::trivially_copy_pass_by_ref",
25 |
26 | # Error handling
27 | "-Dclippy::panic",
28 | "-Dclippy::panic_in_result_fn",
29 | "-Dclippy::unwrap_in_result",
30 |
31 | # Code clarity
32 | "-Dclippy::cognitive_complexity",
33 | "-Dclippy::if_not_else",
34 | "-Dclippy::implicit_clone",
35 | "-Dclippy::map_unwrap_or",
36 | "-Dclippy::match_same_arms",
37 | "-Dclippy::semicolon_if_nothing_returned",
38 |
39 | # Documentation
40 | "-Dclippy::missing_errors_doc",
41 | "-Dclippy::missing_panics_doc",
42 |
43 | # Safety and correctness
44 | "-Dclippy::mem_forget",
45 | "-Dclippy::mutex_integer",
46 | "-Dclippy::rc_buffer",
47 | "-Dclippy::rest_pat_in_fully_bound_structs",
48 |
49 | # Style consistency
50 | "-Dclippy::inconsistent_struct_constructor",
51 | "-Dclippy::separated_literal_suffix",
52 |
53 | # Additional allows for practical reasons
54 | "-Aclippy::module_name_repetitions",
55 | "-Dclippy::must_use_candidate",
56 | "-Dclippy::missing_const_for_fn",
57 | ]
58 |
59 | [target.'cfg(test)']
60 | rustflags = [
61 | "-Aclippy::unwrap_used",
62 | "-Aclippy::expect_used",
63 | "-Aclippy::panic",
64 | "-Aclippy::assertions_on_constants",
65 | "-Aclippy::too_many_lines",
66 | "-Aclippy::assertions_on_constants", # OK in tests
67 | "-Aclippy::too_many_lines", # Tests can be longer
68 | "-Aclippy::cognitive_complexity", # Tests can be complex
69 | "-Aclippy::missing_docs_in_private_items",
70 | "-Aclippy::panic_in_result_fn",
71 | "-Aclippy::unwrap_in_result",
72 | ]
73 |
74 |
75 | [target.x86_64-unknown-linux-gnu]
76 | linker = "clang"
77 | rustflags = ["-C", "link-arg=-fuse-ld=lld", "-C", "target-cpu=native"]
78 |
79 | [target.x86_64-pc-windows-msvc]
80 |
81 | [target.x86_64-pc-windows-gnu]
82 | linker = "x86_64-w64-mingw32-gcc"
83 |
84 | [target.x86_64-apple-darwin]
85 |
86 | [profile.release]
87 | opt-level = 3
88 | debug = false
89 | lto = true
90 | codegen-units = 1
91 | panic = "abort"
92 | strip = true
93 |
94 | [alias]
95 | t = "nextest run"
96 |
--------------------------------------------------------------------------------
/.clippy.toml:
--------------------------------------------------------------------------------
1 | # Clippy configuration file
2 |
3 | # Set the maximum cognitive complexity allowed
4 | cognitive-complexity-threshold = 30
5 |
6 | # Set the maximum number of lines for functions
7 | too-many-lines-threshold = 100
8 |
9 | # Set the maximum number of arguments for functions
10 | too-many-arguments-threshold = 7
11 |
12 | # Configure type complexity threshold
13 | type-complexity-threshold = 250
14 |
15 | # Disallow certain macros
16 | disallowed-macros = [
17 | # { path = "std::print", reason = "use tracing macros instead" },
18 | # { path = "std::println", reason = "use tracing macros instead" },
19 | ]
20 |
21 | # Enforce MSRV (Minimum Supported Rust Version)
22 | msrv = "1.85.0"
23 |
24 | # Allow certain names that would normally trigger warnings
25 | allowed-duplicate-crates = []
26 |
27 | # Configure large array/type thresholds
28 | array-size-threshold = 512000 # 500KB
29 |
30 | # Enforce documentation for public items
31 | warn-on-all-wildcard-imports = true
32 |
--------------------------------------------------------------------------------
/.codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | project:
4 | default:
5 | target: 80%
6 | threshold: 5%
7 | patch:
8 | default:
9 | target: 80%
10 | threshold: 5%
11 |
12 | ignore:
13 | - "tests/**/*"
14 | - "benches/**/*"
15 | - "examples/**/*"
16 | - "**/tests.rs"
17 | - "**/test_*.rs"
18 | - "src/main.rs" # Often just bootstrapping code
19 | - "src/lib.rs" # Often just library setup code
20 | - "src/ui/**/*" # UI code might not be fully covered
21 | - "src/app/**/*" # Utility functions might not be fully covered
22 |
23 | comment:
24 | layout: "reach,diff,flags,files,footer"
25 | behavior: default
26 | require_changes: true
--------------------------------------------------------------------------------
/.config/nextest.toml:
--------------------------------------------------------------------------------
1 | # .config/nextest.toml
2 | [profile.default]
3 | # Print out output for failing tests as soon as they fail, and also at the end
4 | # of the run (for easy scrollability).
5 | failure-output = "immediate-final"
6 | # Do not cancel the test run on the first failure.
7 | fail-fast = false
8 |
9 | [profile.ci]
10 | # Nextest started supporting JUnit about a year ago; it's a better format than libtest's.
11 | # In CI, use JUnit instead of the default libtest output.
12 | reporter = "junit"
13 | # In CI, do not retry failing tests. Retrying failing tests can lead to CI passing with flaky tests.
14 | retries = 0
15 | # In CI, fail fast to get feedback on failure as soon as possible.
16 | fail-fast = true
17 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # VisualVault CODEOWNERS
2 | # This file defines the code owners for different parts of the repository.
3 | # Code owners are automatically requested for review when someone opens a pull request
4 | # that modifies code that they own.
5 |
6 | # Default owners for everything in the repo
7 | # These owners will be requested for review when someone opens a pull request
8 | # unless a later match takes precedence
9 | * @mikko
10 |
11 | # Core functionality
12 | /src/core/ @mikko
13 | /src/core/scanner.rs @mikko
14 | /src/core/organizer.rs @mikko
15 | /src/core/duplicate.rs @mikko
16 | /src/core/file_cache.rs @mikko
17 |
18 | # Configuration and settings
19 | /src/config/ @mikko
20 | /src/config/settings.rs @mikko
21 |
22 | # UI components
23 | /src/ui/ @mikko
24 | /src/ui/dashboard.rs @mikko
25 | /src/ui/settings.rs @mikko
26 | /src/ui/help.rs @mikko
27 |
28 | # Models and data structures
29 | /src/models/ @mikko
30 |
31 | # Utilities
32 | /src/utils/ @mikko
33 |
34 | # Application entry and main logic
35 | /src/main.rs @mikko
36 | /src/app.rs @mikko
37 | /src/app/ @mikko
38 |
39 | # Storage providers
40 | /src/storage/ @mikko
41 | /src/storage/google_drive.rs @mikko
42 |
43 | # Tests
44 | /tests/ @mikko
45 | /src/**/tests.rs @mikko
46 | /src/**/*_tests.rs @mikko
47 |
48 | # Documentation
49 | *.md @mikko
50 | /docs/ @mikko
51 | README.md @mikko
52 | CONTRIBUTING.md @mikko
53 |
54 | # CI/CD and GitHub configuration
55 | /.github/ @mikko
56 | /.github/workflows/ @mikko
57 | /.github/ISSUE_TEMPLATE/ @mikko
58 | /.github/pull_request_template.md @mikko
59 |
60 | # Build and configuration files
61 | Cargo.toml @mikko
62 | Cargo.lock @mikko
63 | rust-toolchain.toml @mikko
64 | .rustfmt.toml @mikko
65 | .clippy.toml @mikko
66 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: 🐛 Bug Report
2 | description: Report a bug or unexpected behavior in VisualVault
3 | title: "[Bug]: "
4 | labels: ["bug", "triage"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to report a bug! Please fill out the information below to help us understand and fix the issue.
10 |
11 | - type: textarea
12 | id: description
13 | attributes:
14 | label: Bug Description
15 | description: A clear and concise description of what the bug is
16 | placeholder: |
17 | When I try to organize files by type, the application crashes...
18 | validations:
19 | required: true
20 |
21 | - type: textarea
22 | id: reproduce
23 | attributes:
24 | label: Steps to Reproduce
25 | description: Steps to reproduce the behavior
26 | placeholder: |
27 | 1. Launch VisualVault
28 | 2. Go to Settings (press 's')
29 | 3. Select "By Type" organization mode
30 | 4. Start organizing (press 'o')
31 | 5. See error
32 | validations:
33 | required: true
34 |
35 | - type: textarea
36 | id: expected
37 | attributes:
38 | label: Expected Behavior
39 | description: What you expected to happen
40 | placeholder: Files should be organized into type-based folders (Images/, Videos/, Documents/, etc.)
41 | validations:
42 | required: true
43 |
44 | - type: textarea
45 | id: actual
46 | attributes:
47 | label: Actual Behavior
48 | description: What actually happened
49 | placeholder: The application crashes with a panic error
50 | validations:
51 | required: true
52 |
53 | - type: textarea
54 | id: logs
55 | attributes:
56 | label: Error Messages / Logs
57 | description: Please paste any error messages or relevant logs
58 | render: shell
59 | placeholder: |
60 | thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value...
61 |
62 | - type: dropdown
63 | id: os
64 | attributes:
65 | label: Operating System
66 | options:
67 | - Linux (Ubuntu/Debian)
68 | - Linux (Fedora/RHEL)
69 | - Linux (Arch)
70 | - Linux (Other)
71 | - macOS 14 (Sonoma)
72 | - macOS 13 (Ventura)
73 | - macOS 12 (Monterey)
74 | - macOS (Other)
75 | - Windows 11
76 | - Windows 10
77 | - Other
78 | validations:
79 | required: true
80 |
81 | - type: input
82 | id: rust-version
83 | attributes:
84 | label: Rust Version
85 | description: Output of `rustc --version`
86 | placeholder: rustc 1.85.0 (a4cb52f33 2024-12-05)
87 | validations:
88 | required: true
89 |
90 | - type: input
91 | id: terminal
92 | attributes:
93 | label: Terminal Emulator
94 | description: Which terminal are you using?
95 | placeholder: e.g., Alacritty, iTerm2, Windows Terminal, Gnome Terminal
96 |
97 | - type: input
98 | id: visualvault-version
99 | attributes:
100 | label: VisualVault Version
101 | description: Version or commit hash
102 | placeholder: v0.1.0 or commit abc123
103 | validations:
104 | required: true
105 |
106 | - type: textarea
107 | id: additional
108 | attributes:
109 | label: Additional Context
110 | description: Add any other context about the problem here
111 | placeholder: This started happening after I updated to the latest version...
112 |
113 | - type: checkboxes
114 | id: checklist
115 | attributes:
116 | label: Checklist
117 | options:
118 | - label: I have searched for similar issues and didn't find any duplicates
119 | required: true
120 | - label: I have tested with the latest version of VisualVault
121 | required: true
122 | - label: I can reproduce this issue consistently
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 💬 Discussions
4 | url: https://github.com/mikeleppane/visualvault/discussions
5 | about: Ask questions and discuss ideas with the community
6 | - name: 📚 Documentation
7 | url: https://github.com/mikeleppane/visualvault/wiki
8 | about: Check out our documentation and guides
9 |
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/documention-issue.yml:
--------------------------------------------------------------------------------
1 | name: 📚 Documentation Issue
2 | description: Report issues or suggest improvements for documentation
3 | title: "[Docs]: "
4 | labels: ["documentation"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Help us improve our documentation!
10 |
11 | - type: dropdown
12 | id: doc-type
13 | attributes:
14 | label: Documentation Type
15 | options:
16 | - README
17 | - Code comments
18 | - API documentation
19 | - User guide
20 | - Contributing guide
21 | - Other
22 | validations:
23 | required: true
24 |
25 | - type: textarea
26 | id: issue
27 | attributes:
28 | label: What's Wrong or Missing?
29 | description: Describe the documentation issue
30 | placeholder: |
31 | The README doesn't explain how to configure custom organization patterns...
32 | validations:
33 | required: true
34 |
35 | - type: textarea
36 | id: suggestion
37 | attributes:
38 | label: Suggested Improvement
39 | description: How should we fix or improve this?
40 | placeholder: |
41 | Add a section about custom organization patterns with examples...
42 |
43 | - type: input
44 | id: location
45 | attributes:
46 | label: Location
47 | description: Where in the documentation is this issue?
48 | placeholder: README.md, line 150
49 |
50 | - type: checkboxes
51 | id: contribute
52 | attributes:
53 | label: Contribution
54 | options:
55 | - label: I can submit a PR to fix this documentation issue
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: ✨ Feature Request
2 | description: Suggest a new feature or enhancement for VisualVault
3 | title: "[Feature]: "
4 | labels: ["enhancement", "triage"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for suggesting a feature! Please provide as much detail as possible.
10 |
11 | - type: textarea
12 | id: problem
13 | attributes:
14 | label: Problem Statement
15 | description: Describe the problem or use case this feature would solve
16 | placeholder: |
17 | Currently, there's no way to exclude certain file types from organization...
18 | validations:
19 | required: true
20 |
21 | - type: textarea
22 | id: solution
23 | attributes:
24 | label: Proposed Solution
25 | description: Describe your proposed solution or feature
26 | placeholder: |
27 | Add a file type exclusion list in settings where users can specify extensions to ignore...
28 | validations:
29 | required: true
30 |
31 | - type: textarea
32 | id: alternatives
33 | attributes:
34 | label: Alternative Solutions
35 | description: Have you considered any alternative solutions?
36 | placeholder: |
37 | Another option would be to use a .visualvaultignore file similar to .gitignore...
38 |
39 | - type: dropdown
40 | id: priority
41 | attributes:
42 | label: Priority
43 | description: How important is this feature to you?
44 | options:
45 | - Nice to have
46 | - Would significantly improve my workflow
47 | - Critical for my use case
48 |
49 | - type: dropdown
50 | id: contribution
51 | attributes:
52 | label: Willing to Contribute?
53 | description: Would you be willing to help implement this feature?
54 | options:
55 | - "Yes, I can implement this feature"
56 | - "Yes, I can help test it"
57 | - "No, but I can provide more details if needed"
58 |
59 | - type: textarea
60 | id: mockup
61 | attributes:
62 | label: UI/UX Mockup
63 | description: If applicable, provide a mockup or sketch of how this feature might look
64 | placeholder: |
65 | Settings > Filters Tab:
66 |
67 | [ ] Enable file exclusions
68 |
69 | Excluded extensions:
70 | [.tmp] [x]
71 | [.cache] [x]
72 | [Add extension...]
73 |
74 | - type: textarea
75 | id: use-cases
76 | attributes:
77 | label: Use Cases
78 | description: Provide specific examples of how you would use this feature
79 | placeholder: |
80 | 1. Exclude temporary files (.tmp, .cache) from being organized
81 | 2. Skip system files that shouldn't be moved
82 | 3. Ignore specific project files that need to stay in place
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/performance-issue.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Performance Issue
2 | description: Report performance problems or bottlenecks
3 | title: "[Performance]: "
4 | labels: ["performance", "triage"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Help us identify and fix performance issues in VisualVault.
10 |
11 | - type: textarea
12 | id: description
13 | attributes:
14 | label: Performance Issue Description
15 | description: Describe the performance problem you're experiencing
16 | placeholder: |
17 | Scanning large directories (10,000+ files) takes over 5 minutes...
18 | validations:
19 | required: true
20 |
21 | - type: textarea
22 | id: scenario
23 | attributes:
24 | label: Scenario Details
25 | description: Provide details about your use case
26 | placeholder: |
27 | - Number of files: 50,000
28 | - File types: Mixed (images, videos, documents)
29 | - Directory structure: Deeply nested (5+ levels)
30 | - Storage type: Network drive
31 | validations:
32 | required: true
33 |
34 | - type: textarea
35 | id: metrics
36 | attributes:
37 | label: Performance Metrics
38 | description: Provide any measurements or benchmarks
39 | placeholder: |
40 | - Scan time: 5 minutes 30 seconds
41 | - Memory usage: 2.5 GB
42 | - CPU usage: 100% on single core
43 |
44 | - type: dropdown
45 | id: operation
46 | attributes:
47 | label: Affected Operation
48 | options:
49 | - File scanning
50 | - Duplicate detection
51 | - File organization/moving
52 | - UI responsiveness
53 | - Cache operations
54 | - Other
55 | validations:
56 | required: true
57 |
58 | - type: input
59 | id: file-system
60 | attributes:
61 | label: File System Type
62 | description: What file system are you using?
63 | placeholder: e.g., ext4, NTFS, APFS, SMB/network share
64 |
65 | - type: textarea
66 | id: config
67 | attributes:
68 | label: Configuration
69 | description: Paste relevant parts of your config.toml
70 | render: toml
71 |
72 | - type: textarea
73 | id: profile
74 | attributes:
75 | label: Profiling Data
76 | description: If you have profiling data, please share it
77 | placeholder: Output from performance profiling tools, if available
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question.yml:
--------------------------------------------------------------------------------
1 | name: ❓ Question
2 | description: Ask a question about VisualVault
3 | title: "[Question]: "
4 | labels: ["question"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Before asking a question, please check:
10 | - The README and documentation
11 | - Existing issues and discussions
12 | - The FAQ (if available)
13 |
14 | - type: textarea
15 | id: question
16 | attributes:
17 | label: Your Question
18 | description: What would you like to know?
19 | placeholder: How can I organize files by both type and date?
20 | validations:
21 | required: true
22 |
23 | - type: textarea
24 | id: context
25 | attributes:
26 | label: Context
27 | description: Provide any relevant context or what you've already tried
28 | placeholder: |
29 | I've tried using the "By Type" mode but I also want to keep files organized by year...
30 |
31 | - type: dropdown
32 | id: category
33 | attributes:
34 | label: Question Category
35 | options:
36 | - Installation/Setup
37 | - Configuration
38 | - Usage
39 | - Development/Contributing
40 | - Other
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 |
4 |
5 | Fixes # (issue)
6 |
7 | ## Type of Change
8 |
9 |
10 |
11 | - [ ] Bug fix (non-breaking change which fixes an issue)
12 | - [ ] New feature (non-breaking change which adds functionality)
13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
14 | - [ ] Documentation update
15 | - [ ] Performance improvement
16 | - [ ] Code refactoring
17 | - [ ] Test improvement
18 | - [ ] CI/CD improvement
19 | - [ ] Other (please describe):
20 |
21 | ## How Has This Been Tested?
22 |
23 |
24 |
25 | - [ ] Unit tests pass locally with `cargo test`
26 | - [ ] Integration tests pass
27 | - [ ] Manual testing completed
28 | - [ ] Tested on Linux
29 | - [ ] Tested on macOS
30 | - [ ] Tested on Windows
31 |
32 | **Test Configuration:**
33 | * Rust version:
34 | * OS:
35 | * Terminal:
36 |
37 | ## Checklist
38 |
39 |
40 |
41 | - [ ] My code follows the style guidelines of this project
42 | - [ ] I have performed a self-review of my own code
43 | - [ ] I have commented my code, particularly in hard-to-understand areas
44 | - [ ] I have made corresponding changes to the documentation
45 | - [ ] My changes generate no new warnings
46 | - [ ] I have added tests that prove my fix is effective or that my feature works
47 | - [ ] New and existing unit tests pass locally with my changes
48 | - [ ] Any dependent changes have been merged and published in downstream modules
49 | - [ ] I have run `cargo fmt` to format my code
50 | - [ ] I have run `cargo clippy -- -D warnings` and addressed all issues
51 | - [ ] I have updated the CHANGELOG.md file (if applicable)
52 |
53 | ## Screenshots / Terminal Output
54 |
55 |
56 |
57 |
58 | Screenshots
59 |
60 |
61 |
62 |
63 |
64 |
65 | Terminal Output
66 |
67 | ```
68 |
69 | ```
70 |
71 |
72 |
73 | ## Performance Impact
74 |
75 |
76 |
77 | - [ ] This change has no performance impact
78 | - [ ] This change improves performance
79 | - [ ] This change may degrade performance
80 |
81 |
82 |
83 | ## Breaking Changes
84 |
85 |
86 |
87 | - [ ] This change is backwards compatible
88 | - [ ] This change requires migration steps
89 |
90 |
91 |
92 | ## Additional Context
93 |
94 |
95 |
96 | ## Related Issues / PRs
97 |
98 |
99 |
100 | - Related to #
101 | - Depends on #
102 | - Blocks #
103 |
104 | ## Reviewer Notes
105 |
106 |
107 |
108 | ---
109 |
110 | ### For Maintainers
111 |
112 | - [ ] Changes are covered by tests
113 | - [ ] Documentation has been updated
114 | - [ ] CHANGELOG.md has been updated (if needed)
115 | - [ ] Performance impact has been considered
116 | - [ ] Security implications have been reviewed
117 | - [ ] Cross-platform compatibility verified
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main, develop ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | env:
10 | CARGO_TERM_COLOR: always
11 | RUST_BACKTRACE: 1
12 |
13 | jobs:
14 | typos:
15 | name: Typo Check
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Check for typos
21 | uses: crate-ci/typos@master
22 | with:
23 | config: .typos.toml
24 |
25 | - name: Typo report
26 | if: failure()
27 | run: |
28 | echo "## 📝 Typo Check Failed" >> $GITHUB_STEP_SUMMARY
29 | echo "Found typos in the codebase. Please fix them before merging." >> $GITHUB_STEP_SUMMARY
30 | echo "You can run 'typos' locally to find and fix typos." >> $GITHUB_STEP_SUMMARY
31 |
32 | markdown-lint:
33 | name: Markdown Lint
34 | runs-on: ubuntu-latest
35 | steps:
36 | - uses: actions/checkout@v4
37 | with:
38 | fetch-depth: 0 # Need full history to detect file changes
39 |
40 | - name: Get changed files
41 | id: changed-files
42 | uses: tj-actions/changed-files@v44
43 | with:
44 | files: |
45 | **/*.md
46 | **/*.markdown
47 |
48 | - name: List changed markdown files
49 | if: steps.changed-files.outputs.any_changed == 'true'
50 | run: |
51 | echo "## 📝 Changed Markdown Files" >> $GITHUB_STEP_SUMMARY
52 | echo "The following markdown files were changed:" >> $GITHUB_STEP_SUMMARY
53 | echo '```' >> $GITHUB_STEP_SUMMARY
54 | for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
55 | echo "$file" >> $GITHUB_STEP_SUMMARY
56 | done
57 | echo '```' >> $GITHUB_STEP_SUMMARY
58 |
59 | - name: Run markdownlint
60 | if: steps.changed-files.outputs.any_changed == 'true'
61 | uses: DavidAnson/markdownlint-cli2-action@v16
62 | with:
63 | globs: ${{ steps.changed-files.outputs.all_changed_files }}
64 | config: .markdownlint.yml
65 | fix: false
66 |
67 | - name: Skip message
68 | if: steps.changed-files.outputs.any_changed == 'false'
69 | run: |
70 | echo "## ✅ Markdown Lint Skipped" >> $GITHUB_STEP_SUMMARY
71 | echo "No markdown files were changed in this PR." >> $GITHUB_STEP_SUMMARY
72 |
73 | format:
74 | name: Format Check
75 | runs-on: ubuntu-latest
76 | steps:
77 | - uses: actions/checkout@v4
78 |
79 | - name: Install Rust
80 | uses: dtolnay/rust-toolchain@stable
81 | with:
82 | components: rustfmt
83 |
84 | - name: Check formatting
85 | run: cargo fmt --all -- --check
86 |
87 | lint:
88 | name: Lint
89 | runs-on: ubuntu-latest
90 | steps:
91 | - uses: actions/checkout@v4
92 |
93 | - name: Install Rust
94 | uses: dtolnay/rust-toolchain@stable
95 | with:
96 | components: clippy
97 |
98 | - name: Cache cargo registry
99 | uses: actions/cache@v4
100 | with:
101 | path: ~/.cargo/registry
102 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
103 |
104 | - name: Cache cargo index
105 | uses: actions/cache@v4
106 | with:
107 | path: ~/.cargo/git
108 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
109 |
110 | - name: Cache cargo build
111 | uses: actions/cache@v4
112 | with:
113 | path: target
114 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
115 |
116 | - name: Run clippy
117 | run: cargo clippy --all-targets --all-features -- -D warnings
118 |
119 | test:
120 | name: Test
121 | runs-on: ubuntu-latest
122 | steps:
123 | - uses: actions/checkout@v3
124 | - uses: taiki-e/install-action@nextest
125 | - run: cargo nextest run --workspace --profile ci
126 |
127 | coverage:
128 | name: Code Coverage
129 | runs-on: ubuntu-latest
130 | steps:
131 | - uses: actions/checkout@v4
132 |
133 | - name: Install Rust
134 | uses: dtolnay/rust-toolchain@stable
135 | with:
136 | components: llvm-tools-preview
137 |
138 | - name: Install cargo-llvm-cov
139 | uses: taiki-e/install-action@cargo-llvm-cov
140 |
141 | - name: Install nextest
142 | uses: taiki-e/install-action@nextest
143 |
144 | - name: Cache cargo registry
145 | uses: actions/cache@v4
146 | with:
147 | path: ~/.cargo/registry
148 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
149 |
150 | - name: Cache cargo index
151 | uses: actions/cache@v4
152 | with:
153 | path: ~/.cargo/git
154 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
155 |
156 | - name: Cache cargo build
157 | uses: actions/cache@v4
158 | with:
159 | path: target
160 | key: ${{ runner.os }}-cargo-build-coverage-${{ hashFiles('**/Cargo.lock') }}
161 |
162 | - name: Generate code coverage
163 | run: cargo llvm-cov nextest --all-features --workspace --lcov --output-path lcov.info --verbose
164 |
165 | - name: Upload coverage to Codecov
166 | uses: codecov/codecov-action@v3
167 | with:
168 | token: ${{ secrets.CODECOV_TOKEN }}
169 | files: lcov.info
170 | fail_ci_if_error: false # Don't fail CI if upload fails
171 | verbose: true
172 | continue-on-error: true
173 |
174 | - name: Generate HTML report
175 | run: cargo llvm-cov report --html
176 |
177 | - name: Upload coverage report
178 | uses: actions/upload-artifact@v4
179 | with:
180 | name: coverage-report
181 | path: target/llvm-cov/html/
182 |
183 |
184 | build:
185 | name: Build Check
186 | strategy:
187 | matrix:
188 | include:
189 | - os: ubuntu-latest
190 | target: x86_64-unknown-linux-gnu
191 | - os: windows-latest
192 | target: x86_64-pc-windows-msvc
193 | - os: macos-latest
194 | target: x86_64-apple-darwin
195 | runs-on: ${{ matrix.os }}
196 | steps:
197 | - uses: actions/checkout@v4
198 |
199 | - name: Install Rust
200 | uses: dtolnay/rust-toolchain@stable
201 | with:
202 | targets: ${{ matrix.target }}
203 |
204 | - name: Cache cargo registry
205 | uses: actions/cache@v4
206 | with:
207 | path: ~/.cargo/registry
208 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
209 |
210 | - name: Cache cargo index
211 | uses: actions/cache@v4
212 | with:
213 | path: ~/.cargo/git
214 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
215 |
216 | - name: Cache cargo build
217 | uses: actions/cache@v4
218 | with:
219 | path: target
220 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
221 | - name: Install dependencies (Ubuntu)
222 | if: matrix.os == 'ubuntu-latest'
223 | run: |
224 | sudo apt-get update
225 | sudo apt-get install -y lld clang
226 |
227 | - name: Install dependencies (Windows)
228 | if: matrix.os == 'windows-latest'
229 | run: |
230 | choco install llvm -y
231 | echo "C:\Program Files\LLVM\bin" >> $GITHUB_PATH
232 |
233 | - name: Install dependencies (macOS)
234 | if: matrix.os == 'macos-latest'
235 | run: |
236 | brew install llvm
237 | echo "/usr/local/opt/llvm/bin" >> $GITHUB_PATH
238 | - name: Build
239 | run: cargo build --target ${{ matrix.target }} --release --verbose
240 |
241 | security:
242 | name: Security Audit
243 | runs-on: ubuntu-latest
244 | steps:
245 | - uses: actions/checkout@v4
246 |
247 | - name: Install Rust
248 | uses: dtolnay/rust-toolchain@stable
249 |
250 | - name: Install cargo-audit
251 | run: cargo install cargo-audit
252 |
253 | - name: Run security audit
254 | run: cargo audit
255 | continue-on-error: true
256 |
257 | unused-deps:
258 | name: Unused Dependencies Check
259 | runs-on: ubuntu-latest
260 | steps:
261 | - uses: actions/checkout@v4
262 |
263 | - name: Install Rust
264 | uses: dtolnay/rust-toolchain@stable
265 |
266 | - name: Cache cargo registry
267 | uses: actions/cache@v4
268 | with:
269 | path: ~/.cargo/registry
270 | key: ${{ runner.os }}-cargo-registry-unused-deps-${{ hashFiles('**/Cargo.lock') }}
271 |
272 | - name: Install cargo-machete
273 | run: cargo install cargo-machete
274 |
275 | - name: Check for unused dependencies
276 | run: |
277 | echo "## Unused Dependencies Report" >> $GITHUB_STEP_SUMMARY
278 | cargo machete --with-metadata 2>&1 | tee unused_deps_report.txt
279 |
280 | # Add results to step summary
281 | if grep -q "Found unused dependencies" unused_deps_report.txt; then
282 | echo "⚠️ **Unused dependencies found:**" >> $GITHUB_STEP_SUMMARY
283 | echo '```' >> $GITHUB_STEP_SUMMARY
284 | cat unused_deps_report.txt >> $GITHUB_STEP_SUMMARY
285 | echo '```' >> $GITHUB_STEP_SUMMARY
286 | else
287 | echo "✅ **No unused dependencies found**" >> $GITHUB_STEP_SUMMARY
288 | fi
289 |
290 | - name: Upload unused dependencies report
291 | uses: actions/upload-artifact@v4
292 | with:
293 | name: unused-dependencies-report
294 | path: unused_deps_report.txt
295 | if: always()
296 |
297 | msrv:
298 | name: Minimum Supported Rust Version
299 | runs-on: ubuntu-latest
300 | steps:
301 | - uses: actions/checkout@v4
302 |
303 | - name: Install Rust 1.85
304 | uses: dtolnay/rust-toolchain@1.85
305 |
306 | - name: Check MSRV
307 | run: cargo check --all-features
--------------------------------------------------------------------------------
/.github/workflows/codspeed.yml:
--------------------------------------------------------------------------------
1 | name: CodSpeed
2 |
3 | on:
4 | push:
5 | branches: [ main, develop ]
6 | paths:
7 | - 'src/**'
8 | - 'benches/**'
9 | - 'Cargo.toml'
10 | - 'Cargo.lock'
11 | - '.github/workflows/benchmark.yml'
12 | - '.github/workflows/codspeed.yml'
13 | pull_request:
14 | branches: [ main ]
15 | paths:
16 | - 'src/**'
17 | - 'benches/**'
18 | - 'Cargo.toml'
19 | - 'Cargo.lock'
20 | - '.github/workflows/benchmark.yml'
21 | - '.github/workflows/codspeed.yml'
22 | # `workflow_dispatch` allows CodSpeed to trigger backtest
23 | # performance analysis in order to generate initial data.
24 | workflow_dispatch:
25 |
26 | jobs:
27 | benchmarks:
28 | name: Run benchmarks
29 | runs-on: ubuntu-latest
30 | steps:
31 | - uses: actions/checkout@v4
32 |
33 | - name: Setup rust toolchain, cache and cargo-codspeed binary
34 | uses: moonrepo/setup-rust@v1
35 | with:
36 | channel: stable
37 | cache-target: release
38 | bins: cargo-codspeed@3.0.2
39 |
40 | - name: Build the benchmark target(s)
41 | run: cargo codspeed build
42 |
43 | - name: Run the benchmarks
44 | uses: CodSpeedHQ/action@v3
45 | with:
46 | run: cargo codspeed run
47 | token: ${{ secrets.CODSPEED_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/dependencies.yml:
--------------------------------------------------------------------------------
1 | name: Dependencies
2 |
3 | on:
4 | schedule:
5 | # Run every Monday at 8am UTC
6 | - cron: '0 8 * * 1'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | update:
11 | name: Update Dependencies
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - name: Install Rust
17 | uses: dtolnay/rust-toolchain@stable
18 |
19 | - name: Update dependencies
20 | run: cargo update
21 |
22 | - name: Test updated dependencies
23 | run: cargo test --all-features
24 |
25 | - name: Create Pull Request
26 | uses: peter-evans/create-pull-request@v5
27 | with:
28 | token: ${{ secrets.GITHUB_TOKEN }}
29 | commit-message: "chore: update dependencies"
30 | title: "Weekly dependency updates"
31 | body: |
32 | This PR updates the project dependencies to their latest versions.
33 |
34 | Please review the changes and ensure all tests pass before merging.
35 | branch: deps/update
36 | delete-branch: true
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | build-and-release:
13 | name: Build and Release
14 | strategy:
15 | matrix:
16 | include:
17 | - os: ubuntu-latest
18 | target: x86_64-unknown-linux-gnu
19 | artifact_name: visualvault
20 | asset_name: visualvault-linux-amd64
21 | - os: windows-latest
22 | target: x86_64-pc-windows-msvc
23 | artifact_name: visualvault.exe
24 | asset_name: visualvault-windows-amd64.exe
25 | - os: macos-latest
26 | target: x86_64-apple-darwin
27 | artifact_name: visualvault
28 | asset_name: visualvault-macos-amd64
29 | runs-on: ${{ matrix.os }}
30 | steps:
31 | - uses: actions/checkout@v4
32 |
33 | - name: Install Rust
34 | uses: dtolnay/rust-toolchain@stable
35 | with:
36 | targets: ${{ matrix.target }}
37 |
38 | - name: Install dependencies (Ubuntu)
39 | if: matrix.os == 'ubuntu-latest'
40 | run: |
41 | sudo apt-get update
42 | sudo apt-get install -y lld clang
43 |
44 | - name: Build
45 | run: cargo build --target ${{ matrix.target }} --release
46 |
47 | # Rename the binary to match the asset name to avoid conflicts
48 | - name: Rename binary
49 | run: |
50 | if [ "${{ matrix.os }}" = "windows-latest" ]; then
51 | # Windows binary already has .exe extension
52 | cp ./target/${{ matrix.target }}/release/${{ matrix.artifact_name }} ./${{ matrix.asset_name }}
53 | else
54 | # Linux and macOS binaries
55 | cp ./target/${{ matrix.target }}/release/${{ matrix.artifact_name }} ./${{ matrix.asset_name }}
56 | fi
57 | shell: bash
58 |
59 | - name: Upload artifacts
60 | uses: actions/upload-artifact@v4
61 | with:
62 | name: ${{ matrix.asset_name }}
63 | path: ./${{ matrix.asset_name }}
64 |
65 | create-release:
66 | name: Create Release
67 | needs: build-and-release
68 | runs-on: ubuntu-latest
69 | permissions:
70 | contents: write
71 | steps:
72 | - uses: actions/checkout@v4
73 |
74 | - name: Download all artifacts
75 | uses: actions/download-artifact@v4
76 | with:
77 | path: ./artifacts
78 |
79 | # Debug: List what was downloaded
80 | - name: List artifacts
81 | run: |
82 | echo "Contents of ./artifacts:"
83 | find ./artifacts -type f -ls
84 |
85 | # Move files to have unique names in the root artifacts directory
86 | - name: Prepare release assets
87 | run: |
88 | mkdir -p release-assets
89 |
90 | # Move each file from its subdirectory to the release-assets directory
91 | # The subdirectories are named after the artifact names
92 | if [ -f "./artifacts/visualvault-linux-amd64/visualvault-linux-amd64" ]; then
93 | cp "./artifacts/visualvault-linux-amd64/visualvault-linux-amd64" ./release-assets/
94 | fi
95 |
96 | if [ -f "./artifacts/visualvault-windows-amd64.exe/visualvault-windows-amd64.exe" ]; then
97 | cp "./artifacts/visualvault-windows-amd64.exe/visualvault-windows-amd64.exe" ./release-assets/
98 | fi
99 |
100 | if [ -f "./artifacts/visualvault-macos-amd64/visualvault-macos-amd64" ]; then
101 | cp "./artifacts/visualvault-macos-amd64/visualvault-macos-amd64" ./release-assets/
102 | fi
103 |
104 | echo "Contents of ./release-assets:"
105 | ls -la ./release-assets/
106 |
107 | - name: Create Release
108 | uses: softprops/action-gh-release@v2
109 | with:
110 | name: Release ${{ github.ref_name }}
111 | draft: false
112 | prerelease: false
113 | files: ./release-assets/*
114 | env:
115 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | # Coverage reports
3 | lcov.info
4 | *.profraw
5 | *.profdata
6 | target/llvm-cov/
7 | coverage/
8 | *.snap.new
9 | logs/
--------------------------------------------------------------------------------
/.markdownlint.yml:
--------------------------------------------------------------------------------
1 | # Configuration for markdownlint
2 | # https://github.com/DavidAnson/markdownlint
3 |
4 | # Default state for all rules
5 | default: true
6 |
7 | # Path to configuration file to extend
8 | extends: null
9 |
10 | # MD003/heading-style/header-style - Heading style
11 | MD003:
12 | style: "atx"
13 |
14 | # MD004/ul-style - Unordered list style
15 | MD004:
16 | style: "dash"
17 |
18 | # MD007/ul-indent - Unordered list indentation
19 | MD007:
20 | indent: 2
21 | start_indented: false
22 |
23 | # MD009/no-trailing-spaces - Trailing spaces
24 | MD009:
25 | br_spaces: 2
26 | list_item_empty_lines: false
27 | strict: false
28 |
29 | # MD010/no-hard-tabs - Hard tabs
30 | MD010:
31 | code_blocks: false
32 |
33 | # MD012/no-multiple-blanks - Multiple consecutive blank lines
34 | MD012:
35 | maximum: 2
36 |
37 | # MD013/line-length - Line length
38 | MD013:
39 | line_length: 120
40 | heading_line_length: 120
41 | code_blocks: false
42 | tables: false
43 | headings: true
44 | strict: false
45 | stern: false
46 |
47 | # MD022/blanks-around-headings/blanks-around-headers - Headings should be surrounded by blank lines
48 | MD022:
49 | lines_above: 1
50 | lines_below: 1
51 |
52 | # MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content
53 | MD024:
54 | siblings_only: true
55 |
56 | # MD025/single-title/single-h1 - Multiple top level headings in the same document
57 | MD025:
58 | level: 1
59 | front_matter_title: "^\\s*title\\s*[:=]"
60 |
61 | # MD026/no-trailing-punctuation - Trailing punctuation in heading
62 | MD026:
63 | punctuation: ".,;:!。,;:!"
64 |
65 | # MD029/ol-prefix - Ordered list item prefix
66 | MD029:
67 | style: "ordered"
68 |
69 | # MD030/list-marker-space - Spaces after list markers
70 | MD030:
71 | ul_single: 1
72 | ol_single: 1
73 | ul_multi: 1
74 | ol_multi: 1
75 |
76 | # MD033/no-inline-html - Inline HTML
77 | MD033:
78 | allowed_elements:
79 | - "br"
80 | - "details"
81 | - "summary"
82 | - "img"
83 | - "a"
84 | - "code"
85 | - "pre"
86 | - "kbd"
87 | - "sup"
88 | - "sub"
89 | - "p"
90 | - "i"
91 | - "input"
92 | - "span"
93 |
94 | # MD034/no-bare-urls - Bare URL used
95 | MD034: true
96 |
97 | # MD035/hr-style - Horizontal rule style
98 | MD035:
99 | style: "---"
100 |
101 | # MD036/no-emphasis-as-heading/no-emphasis-as-header - Emphasis used instead of a heading
102 | MD036:
103 | punctuation: ".,;:!?。,;:!?"
104 |
105 | # MD041/first-line-heading/first-line-h1 - First line in file should be a top level heading
106 | MD041:
107 | level: 1
108 | front_matter_title: "^\\s*title\\s*[:=]"
109 |
110 | # MD042/no-empty-links - No empty links
111 | MD042: true
112 |
113 | # MD043/required-headings/required-headers - Required heading structure
114 | MD043: false
115 |
116 | # MD044/proper-names - Proper names should have the correct capitalization
117 | MD044:
118 | names:
119 | - "JavaScript"
120 | - "TypeScript"
121 | - "GitHub"
122 | - "GitLab"
123 | - "Rust"
124 | - "Cargo"
125 | - "visualvault"
126 | - "VisualVault"
127 | - "cargo-nextest"
128 | code_blocks: false
129 |
130 | # MD045/no-alt-text - Images should have alternate text (alt text)
131 | MD045: true
132 |
133 | # MD046/code-block-style - Code block style
134 | MD046:
135 | style: "fenced"
136 |
137 | # MD047/single-trailing-newline - Files should end with a single newline character
138 | MD047: true
139 |
140 | # MD048/code-fence-style - Code fence style
141 | MD048:
142 | style: "backtick"
143 |
144 | # MD049/emphasis-style - Emphasis style should be consistent
145 | MD049:
146 | style: "asterisk"
147 |
148 | # MD050/strong-style - Strong style should be consistent
149 | MD050:
150 | style: "asterisk"
151 |
152 | # MD051/link-fragments - Link fragments should be valid
153 | MD051: true
154 |
155 | # MD052/reference-links-images - Reference links and images should use a label that is defined
156 | MD052: true
157 |
158 | # MD053/link-image-reference-definitions - Link and image reference definitions should be needed
159 | MD053: true
--------------------------------------------------------------------------------
/.rustfmt.toml:
--------------------------------------------------------------------------------
1 | # Rustfmt configuration
2 | edition = "2024"
3 | max_width = 120
4 | tab_spaces = 4
5 | use_small_heuristics = "Default"
6 | reorder_imports = true
7 | reorder_modules = true
8 | remove_nested_parens = true
9 |
--------------------------------------------------------------------------------
/.typos.toml:
--------------------------------------------------------------------------------
1 | # Configuration for typos-cli spell checker
2 | # https://github.com/crate-ci/typos
3 |
4 | [files]
5 | extend-exclude = [
6 | "target/",
7 | "*.lock",
8 | "*.svg",
9 | "*.png",
10 | "*.jpg",
11 | "*.jpeg",
12 | "*.gif",
13 | "*.ico",
14 | "LICENSE*",
15 | ]
16 |
17 | [default]
18 | # Locale to use for spell checking
19 | locale = "en-us"
20 |
21 | [default.extend-words]
22 | # Technical terms and abbreviations
23 | "ratatui" = "ratatui"
24 | "tui" = "tui"
25 | "tokio" = "tokio"
26 | "serde" = "serde"
27 | "clap" = "clap"
28 | "chrono" = "chrono"
29 | "crossterm" = "crossterm"
30 | "thiserror" = "thiserror"
31 | "anyhow" = "anyhow"
32 | "ahash" = "ahash"
33 | "nextest" = "nextest"
34 | "llvm" = "llvm"
35 | "codecov" = "codecov"
36 | "lcov" = "lcov"
37 | "clippy" = "clippy"
38 | "rustfmt" = "rustfmt"
39 | "msrv" = "msrv"
40 | "critcmp" = "critcmp"
41 | "bencher" = "bencher"
42 | "walkdir" = "walkdir"
43 | "blake3" = "blake3"
44 | "sha256" = "sha256"
45 | "md5" = "md5"
46 |
47 | # Project-specific terms
48 | "visualvault" = "visualvault"
49 | "VisualVault" = "VisualVault"
50 | "mikeleppane" = "mikeleppane"
51 | "Leppänen" = "Leppänen"
52 |
53 | # Common technical misspellings that are actually correct
54 | "impl" = "impl"
55 | "struct" = "struct"
56 | "enum" = "enum"
57 | "async" = "async"
58 | "fn" = "fn"
59 | "mut" = "mut"
60 | "Vec" = "Vec"
61 | "HashMap" = "HashMap"
62 | "PathBuf" = "PathBuf"
63 | "DateTime" = "DateTime"
64 | "Arc" = "Arc"
65 | "RwLock" = "RwLock"
66 |
67 | # File extensions and formats
68 | "toml" = "toml"
69 | "yaml" = "yaml"
70 | "yml" = "yml"
71 | "json" = "json"
72 | "md" = "md"
73 | "rs" = "rs"
74 | "EXIF" = "EXIF"
75 | "JPEG" = "JPEG"
76 | "PNG" = "PNG"
77 | "MP4" = "MP4"
78 | "MOV" = "MOV"
79 |
80 | # Common false positives
81 | "ba" = "ba" # often appears in hex strings
82 | "de" = "de" # often appears in hex strings
83 |
84 | [default.extend-identifiers]
85 | # Allow specific identifiers that might be flagged as typos
86 | "ser" = "ser" # Serde serialization
87 | "de" = "de" # Serde deserialization
88 | "datetime" = "datetime"
89 | "filepath" = "filepath"
90 | "filesize" = "filesize"
91 | "filetype" = "filetype"
92 | "metadata" = "metadata"
93 | "organizer" = "organizer"
94 | "subcommand" = "subcommand"
95 |
96 | [type.rust]
97 | extend-glob = ["*.rs"]
98 | check-filename = false # Don't check Rust filenames for typos
99 |
100 | [type.markdown]
101 | extend-glob = ["*.md", "*.markdown"]
102 | check-filename = false
103 |
104 | [type.toml]
105 | extend-glob = ["*.toml"]
106 | check-filename = false
107 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in the VisualVault community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to creating a positive environment include:
12 |
13 | * Using welcoming and inclusive language
14 | * Being respectful of differing viewpoints and experiences
15 | * Gracefully accepting constructive criticism
16 | * Focusing on what is best for the community
17 | * Showing empathy towards other community members
18 | * Being supportive and kind in technical discussions
19 |
20 | Examples of unacceptable behavior include:
21 |
22 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
23 | * Trolling, insulting/derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as physical or email addresses, without explicit permission (doxxing)
26 | * Other conduct which could reasonably be considered inappropriate in a professional setting
27 | * Spamming, excessive self-promotion, or unrelated solicitation
28 |
29 | ## Enforcement Responsibilities
30 |
31 | Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
32 |
33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
34 |
35 | ## Scope
36 |
37 | This Code of Conduct applies within all community spaces, including the GitHub repository, discussions, issue trackers, and any other forums used by our community. It also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
38 |
39 | ## Enforcement
40 |
41 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly.
42 |
43 | All project maintainers are obligated to respect the privacy and security of the reporter of any incident.
44 |
45 | ## Enforcement Guidelines
46 |
47 | Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
48 |
49 | ### 1. Correction
50 |
51 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
52 |
53 | **Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
54 |
55 | ### 2. Warning
56 |
57 | **Community Impact**: A violation through a single incident or series of actions.
58 |
59 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
60 |
61 | ### 3. Temporary Ban
62 |
63 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
64 |
65 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
66 |
67 | ### 4. Permanent Ban
68 |
69 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
70 |
71 | **Consequence**: A permanent ban from any sort of public interaction within the community.
72 |
73 | ## Attribution
74 |
75 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct/](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
76 |
77 | For answers to common questions about this code of conduct, see the [FAQ](https://www.contributor-covenant.org/faq).
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to VisualVault
2 |
3 | First off, thank you for considering contributing to VisualVault! It's people like you that make VisualVault
4 | such a great tool. 🎉
5 |
6 | ## Table of Contents
7 |
8 | - [Code of Conduct](#code-of-conduct)
9 | - [Getting Started](#getting-started)
10 | - [How Can I Contribute?](#how-can-i-contribute)
11 | - [Reporting Bugs](#reporting-bugs)
12 | - [Suggesting Enhancements](#suggesting-enhancements)
13 | - [Your First Code Contribution](#your-first-code-contribution)
14 | - [Pull Requests](#pull-requests)
15 | - [Development Setup](#development-setup)
16 | - [Style Guidelines](#style-guidelines)
17 | - [Git Commit Messages](#git-commit-messages)
18 | - [Rust Style Guide](#rust-style-guide)
19 | - [Documentation Style Guide](#documentation-style-guide)
20 | - [Testing Guidelines](#testing-guidelines)
21 | - [Project Structure](#project-structure)
22 | - [Community](#community)
23 |
24 | ## Code of Conduct
25 |
26 | This project and everyone participating in it is governed by our Code of Conduct.
27 | By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
28 |
29 | ## Getting Started
30 |
31 | 1. Fork the repository on GitHub
32 | 2. Clone your fork locally
33 | 3. Create a new branch for your feature or bugfix
34 | 4. Make your changes
35 | 5. Run tests and ensure they pass
36 | 6. Commit your changes
37 | 7. Push to your fork
38 | 8. Create a Pull Request
39 |
40 | ## How Can I Contribute?
41 |
42 | ### Reporting Bugs
43 |
44 | Before creating bug reports, please check existing issues as you might find out that you don't need to create one.
45 | When you are creating a bug report, please include as many details as possible:
46 |
47 | **Bug Report Template:**
48 |
49 | ```markdown
50 | **Describe the bug**
51 | A clear and concise description of what the bug is.
52 |
53 | **To Reproduce**
54 | Steps to reproduce the behavior:
55 | 1. Go to '...'
56 | 2. Click on '....'
57 | 3. Scroll down to '....'
58 | 4. See error
59 |
60 | **Expected behavior**
61 | A clear and concise description of what you expected to happen.
62 |
63 | **Screenshots**
64 | If applicable, add screenshots to help explain your problem.
65 |
66 | **Environment:**
67 | - OS: [e.g. Ubuntu 22.04, macOS 13, Windows 11]
68 | - Rust version: [e.g. 1.85.0]
69 | - VisualVault version/commit: [e.g. 0.1.0 or commit hash]
70 |
71 | **Additional context**
72 | Add any other context about the problem here.
73 | ```
74 |
75 | ### Suggesting Enhancements
76 |
77 | Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
78 |
79 | - **Use a clear and descriptive title**
80 | - **Provide a step-by-step description** of the suggested enhancement
81 | - **Provide specific examples** to demonstrate the steps
82 | - **Describe the current behavior** and explain which behavior you expected to see instead
83 | - **Explain why this enhancement would be useful** to most VisualVault users
84 |
85 | ### Your First Code Contribution
86 |
87 | Unsure where to begin contributing? You can start by looking through these issues:
88 |
89 | - Issues labeled `good first issue` - issues which should be relatively simple to implement
90 | - Issues labeled `help wanted` - issues which need extra attention
91 | - Issues labeled `documentation` - improvements or additions to documentation
92 |
93 | ### Pull Requests
94 |
95 | 1. **Fork and clone the repository**
96 | 2. **Create a new branch**: `git checkout -b feature/your-feature-name`
97 | 3. **Make your changes** and add tests for them
98 | 4. **Run the test suite**: `cargo test` and `cargo nextest run`
99 | 5. **Run clippy**: `cargo clippy -- -D warnings`
100 | 6. **Format your code**: `cargo fmt`
101 | 7. **Commit your changes**: Use a descriptive commit message
102 | 8. **Push to your fork**: `git push origin feature/your-feature-name`
103 | 9. **Submit a pull request**
104 |
105 | ## Development Setup
106 |
107 | ### Prerequisites
108 |
109 | - Rust 1.85 or higher
110 | - Git
111 | - A terminal emulator with good Unicode support
112 |
113 | ### Building the Project
114 |
115 | ```bash
116 | # Clone the repository
117 | git clone https://github.com/yourusername/visualvault.git
118 | cd visualvault
119 |
120 | # Build in debug mode
121 | cargo build
122 |
123 | # Build in release mode
124 | cargo build --release
125 |
126 | # Run the application
127 | cargo run
128 |
129 | # Run with debug logging
130 | RUST_LOG=debug cargo run
131 | ```
132 |
133 | ### Running Tests
134 |
135 | ```bash
136 | # Run all tests
137 | cargo test
138 |
139 | # Run tests with output
140 | cargo test -- --nocapture
141 |
142 | # Run specific test module
143 | cargo test core::scanner
144 |
145 | # Run with nextest (recommended)
146 | cargo nextest run
147 |
148 | # Run with coverage
149 | cargo tarpaulin --out Html
150 | ```
151 |
152 | ### Development Tools
153 |
154 | We recommend installing these tools for a better development experience:
155 |
156 | ```bash
157 | # Install development tools
158 | cargo install cargo-watch # Auto-rebuild on file changes
159 | cargo install cargo-nextest # Better test runner
160 | cargo install cargo-tarpaulin # Code coverage
161 | cargo install cargo-audit # Security audit
162 |
163 | # Watch for changes and run tests
164 | cargo watch -x test
165 |
166 | # Watch for changes and run the app
167 | cargo watch -x run
168 | ```
169 |
170 | ## Style Guidelines
171 |
172 | ### Git Commit Messages
173 |
174 | - Use the present tense ("Add feature" not "Added feature")
175 | - Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
176 | - Limit the first line to 72 characters or less
177 | - Reference issues and pull requests liberally after the first line
178 | - Use conventional commits format when possible:
179 | - `feat:` for new features
180 | - `fix:` for bug fixes
181 | - `docs:` for documentation changes
182 | - `style:` for formatting changes
183 | - `refactor:` for code refactoring
184 | - `test:` for adding tests
185 | - `chore:` for maintenance tasks
186 |
187 | Examples:
188 |
189 | ```text
190 |
191 | feat: add support for HEIC image format
192 |
193 | - Add HEIC detection in media_types module
194 | - Update scanner to handle HEIC files
195 | - Add tests for HEIC file processing
196 |
197 | Closes #123
198 | ```
199 |
200 | ### Rust Style Guide
201 |
202 | We follow the standard Rust style guidelines:
203 |
204 | - Run `cargo fmt` before committing
205 | - Ensure `cargo clippy -- -D warnings` passes
206 | - Use descriptive variable names
207 | - Add documentation comments for public APIs
208 | - Keep functions focused and small
209 | - Use `Result` for error handling
210 | - Prefer `&str` over `String` for function parameters when possible
211 |
212 | Example:
213 |
214 | ```rust
215 | /// Organizes files based on the specified organization mode.
216 | ///
217 | /// # Arguments
218 | ///
219 | /// * `files` - Vector of files to organize
220 | /// * `settings` - Configuration settings for organization
221 | ///
222 | /// # Returns
223 | ///
224 | /// Returns `Ok(OrganizationResult)` on success, or an error if organization fails.
225 | ///
226 | /// # Example
227 | ///
228 | /// ```
229 | /// let result = organizer.organize_files(files, &settings).await?;
230 | /// println!("Organized {} files", result.files_organized);
231 | /// ```
232 | pub async fn organize_files(
233 | &self,
234 | files: Vec,
235 | settings: &Settings,
236 | ) -> Result {
237 | // Implementation
238 | }
239 | ```
240 |
241 | ### Documentation Style Guide
242 |
243 | - Use triple-slash comments (`///`) for public items
244 | - Include examples in documentation when helpful
245 | - Document panic conditions with `# Panics`
246 | - Document error conditions with `# Errors`
247 | - Keep line length under 100 characters in documentation
248 |
249 | ## Testing Guidelines
250 |
251 | ### Writing Tests
252 |
253 | - Write tests for all new functionality
254 | - Place unit tests in the same file as the code they test
255 | - Place integration tests in the `tests/` directory
256 | - Use descriptive test names that explain what is being tested
257 | - Use test fixtures and helper functions to reduce duplication
258 |
259 | Example test structure:
260 |
261 | ```rust
262 | #[cfg(test)]
263 | mod tests {
264 | use super::*;
265 | use tempfile::TempDir;
266 |
267 | // Helper function for test setup
268 | async fn setup_test_environment() -> Result<(TempDir, Scanner)> {
269 | let temp_dir = TempDir::new()?;
270 | let scanner = Scanner::new();
271 | Ok((temp_dir, scanner))
272 | }
273 |
274 | #[tokio::test]
275 | async fn test_scanner_finds_jpeg_files() -> Result<()> {
276 | let (temp_dir, scanner) = setup_test_environment().await?;
277 |
278 | // Create test file
279 | let test_file = temp_dir.path().join("test.jpg");
280 | fs::write(&test_file, b"fake jpeg data").await?;
281 |
282 | // Run scanner
283 | let files = scanner.scan_directory(temp_dir.path(), false).await?;
284 |
285 | // Assertions
286 | assert_eq!(files.len(), 1);
287 | assert_eq!(files[0].extension, "jpg");
288 |
289 | Ok(())
290 | }
291 | }
292 | ```
293 |
294 | ### Test Categories
295 |
296 | 1. **Unit Tests**: Test individual functions and methods
297 | 2. **Integration Tests**: Test complete workflows
298 | 3. **UI Tests**: Test terminal UI components (when applicable)
299 | 4. **Performance Tests**: Benchmark critical paths
300 |
301 | ## Project Structure
302 |
303 | ```text
304 | visualvault/
305 | ├── src/
306 | │ ├── main.rs # Application entry point
307 | │ ├── app.rs # Main application state and logic
308 | │ ├── config/ # Configuration management
309 | │ │ └── settings.rs # Settings structure and defaults
310 | │ ├── core/ # Core functionality
311 | │ │ ├── scanner.rs # File scanning logic
312 | │ │ ├── organizer.rs # File organization logic
313 | │ │ ├── duplicate.rs # Duplicate detection
314 | │ │ └── file_cache.rs # File metadata caching
315 | │ ├── models/ # Data structures
316 | │ │ ├── file_type.rs # File type definitions
317 | │ │ ├── media_file.rs # Media file representation
318 | │ │ └── filters.rs # Filter definitions
319 | │ ├── ui/ # Terminal UI components
320 | │ │ ├── dashboard.rs # Dashboard view
321 | │ │ ├── settings.rs # Settings view
322 | │ │ └── help.rs # Help overlay
323 | │ └── utils/ # Utility functions
324 | │ ├── datetime.rs # Date/time helpers
325 | │ ├── format.rs # Formatting utilities
326 | │ └── media_types.rs # Media type detection
327 | ├── tests/ # Integration tests
328 | ├── Cargo.toml # Project dependencies
329 | └── README.md # Project documentation
330 | ```
331 |
332 | ## Community
333 |
334 | - **GitHub Issues**: For bug reports and feature requests
335 | - **GitHub Discussions**: For questions and general discussion
336 | - **Pull Requests**: For code contributions
337 |
338 | ### Getting Help
339 |
340 | If you need help, you can:
341 |
342 | 1. Check the [README](README.md) for usage information
343 | 2. Look through existing [issues](https://github.com/yourusername/visualvault/issues)
344 | 3. Create a new issue with the `question` label
345 | 4. Start a discussion in the [Discussions](https://github.com/yourusername/visualvault/discussions) section
346 |
347 | ## Recognition
348 |
349 | Contributors who submit accepted pull requests will be added to the project's AUTHORS file and recognized
350 | in the release notes.
351 |
352 | Thank you for contributing to VisualVault! 🚀
353 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | members = [
3 | "crates/app",
4 | "crates/config",
5 | "crates/core",
6 | "crates/models",
7 | "crates/ui",
8 | "crates/utils",
9 | ]
10 | resolver = "2"
11 |
12 | [workspace.package]
13 | version = "0.8.0"
14 | edition = "2024"
15 | rust-version = "1.85"
16 | authors = ["Mikko Leppänen "]
17 | description = "A modern, terminal-based media file organizer built with Rust"
18 | license = "MIT"
19 | readme = "README.md"
20 | homepage = "https://github.com/mikeleppane/visualvault"
21 | repository = "https://github.com/mikeleppane/visualvault"
22 | keywords = [
23 | "media",
24 | "organizer",
25 | "terminal",
26 | "tui",
27 | "file-management",
28 | "ratatui",
29 | ]
30 | categories = ["command-line-utilities", "filesystem"]
31 |
32 | [workspace.dependencies]
33 | visualvault-app = { path = "crates/app" }
34 | visualvault-config = { path = "crates/config" }
35 | visualvault-core = { path = "crates/core" }
36 | visualvault-models = { path = "crates/models" }
37 | visualvault-ui = { path = "crates/ui" }
38 | visualvault-utils = { path = "crates/utils" }
39 | tokio = { version = "1.47.0", features = ["full"] }
40 | ratatui = "0.29.0"
41 | crossterm = "0.29.0"
42 | color-eyre = "0.6"
43 | eyre = "0.6"
44 | serde = { version = "1.0", features = ["derive", "rc"] }
45 | serde_json = "1.0"
46 | toml = "0.9.4"
47 | chrono = { version = "0.4", features = ["serde"] }
48 | walkdir = "2.5"
49 | sha2 = "0.10"
50 | image = "0.25"
51 | regex = "1.10"
52 | dirs = "6.0.0"
53 | tracing = "0.1"
54 | tracing-subscriber = { version = "0.3", features = ["env-filter"] }
55 | num_cpus = "1.17.0"
56 | rayon = "1.10.0"
57 | ahash = { version = "0.8", features = ["serde"] }
58 | uuid = { version = "1.17.0", features = ["v4", "serde"] }
59 | thiserror = "2.0.12"
60 | smallvec = { version = "1.15.1", features = [
61 | "const_generics",
62 | "const_new",
63 | "serde",
64 | "write",
65 | ] }
66 | sqlx = { version = "0.8.6", features = ["chrono", "runtime-tokio", "sqlite"] }
67 | async-trait = "0.1.88"
68 |
69 | [package]
70 | name = "visualvault"
71 | version.workspace = true
72 | edition.workspace = true
73 | authors.workspace = true
74 | license.workspace = true
75 |
76 | [[bin]]
77 | name = "visualvault"
78 | path = "src/main.rs"
79 | bench = false
80 |
81 | [dependencies]
82 | visualvault-app = { workspace = true }
83 | visualvault-config = { workspace = true }
84 | visualvault-core = { workspace = true }
85 | visualvault-models = { workspace = true }
86 | visualvault-ui = { workspace = true }
87 | visualvault-utils = { workspace = true }
88 | color-eyre = { workspace = true }
89 | crossterm = { workspace = true }
90 | ratatui = { workspace = true }
91 | tokio = { workspace = true }
92 | tracing = { workspace = true }
93 | tracing-subscriber = { workspace = true }
94 |
95 | [dev-dependencies]
96 | dirs = { workspace = true }
97 | tempfile = "3.20"
98 | serde_json = "1.0"
99 | tokio = { version = "1", features = ["full", "test-util"] }
100 | color-eyre = "0.6"
101 | chrono = "0.4"
102 | criterion = { version = "3.0.4", package = "codspeed-criterion-compat" }
103 | proptest = "1.7"
104 |
105 | [target.'cfg(windows)'.dependencies]
106 | mimalloc = "0.1"
107 |
108 | [target.'cfg(not(windows))'.dependencies]
109 | jemallocator = "0.5"
110 |
111 | [profile.release]
112 | lto = true
113 | codegen-units = 1
114 | opt-level = 3
115 | strip = true
116 |
117 | [profile.dev]
118 | opt-level = 0
119 | debug = false
120 | incremental = true
121 | codegen-units = 256
122 |
123 | [profile.dev.package."*"]
124 | opt-level = 3
125 |
126 | [[bench]]
127 | name = "organizer_benchmark"
128 | harness = false
129 |
130 | [[bench]]
131 | name = "scanner_benchmark"
132 | harness = false
133 |
134 | [[bench]]
135 | name = "duplicate_benchmark"
136 | harness = false
137 |
138 | [lib]
139 | bench = false
140 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Mikko Leppänen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/benches/duplicate_benchmark.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::unwrap_used)]
2 | #![allow(clippy::expect_used)]
3 | #![allow(clippy::float_cmp)] // For comparing floats in tests
4 | #![allow(clippy::panic)]
5 | #![allow(clippy::cast_possible_truncation)]
6 | #![allow(clippy::cast_sign_loss)]
7 | #![allow(clippy::cast_precision_loss)]
8 | #![allow(clippy::significant_drop_tightening)]
9 | use chrono::Local;
10 | use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
11 | use std::{hint::black_box, path::PathBuf, sync::Arc};
12 | use visualvault::{
13 | core::DuplicateDetector,
14 | models::{FileType, MediaFile},
15 | };
16 |
17 | fn create_test_files_with_duplicates(total: usize, duplicate_ratio: f32) -> Vec> {
18 | let unique_count = ((total as f32) * (1.0 - duplicate_ratio)) as usize;
19 | let mut files = Vec::with_capacity(total);
20 |
21 | // Create unique files
22 | for i in 0..unique_count {
23 | files.push(Arc::new(MediaFile {
24 | path: PathBuf::from(format!("/tmp/unique_{i:04}.jpg")),
25 | name: Arc::from(format!("unique_{i:04}.jpg")),
26 | extension: Arc::from("jpg"),
27 | file_type: FileType::Image,
28 | size: 1024 * 1024, // 1MB
29 | modified: Local::now(),
30 | created: Local::now(),
31 | metadata: None,
32 | hash: Some(Arc::from(format!("hash_{i:04}"))),
33 | }));
34 | }
35 |
36 | // Create duplicates
37 | let remaining = total - unique_count;
38 | for i in 0..remaining {
39 | let original_idx = i % unique_count;
40 | let original = &files[original_idx];
41 | let duplicate = Arc::new(MediaFile {
42 | path: PathBuf::from(format!("/tmp/duplicate_{i:04}.jpg")),
43 | name: Arc::from(format!("duplicate_{i:04}.jpg")),
44 | extension: original.extension.clone(),
45 | file_type: original.file_type.clone(),
46 | size: original.size,
47 | modified: original.modified,
48 | created: original.created,
49 | metadata: original.metadata.clone(),
50 | hash: original.hash.clone(),
51 | });
52 | files.push(duplicate);
53 | }
54 |
55 | files
56 | }
57 |
58 | fn benchmark_duplicate_detection_without_quick_hash(c: &mut Criterion) {
59 | let mut group = c.benchmark_group("duplicate_detection");
60 | group.sample_size(10);
61 |
62 | for file_count in &[1000, 5000, 10000] {
63 | group.bench_with_input(BenchmarkId::from_parameter(file_count), file_count, |b, &file_count| {
64 | let files = create_test_files_with_duplicates(file_count, 0.3); // 30% duplicates
65 | let detector = DuplicateDetector::new();
66 | b.iter(|| detector.detect_duplicates(black_box(&files), false));
67 | });
68 | }
69 |
70 | group.finish();
71 | }
72 |
73 | fn benchmark_duplicate_detection_with_quick_hash(c: &mut Criterion) {
74 | let mut group = c.benchmark_group("duplicate_detection");
75 | group.sample_size(10);
76 |
77 | for file_count in &[1000, 5000, 10000] {
78 | group.bench_with_input(BenchmarkId::from_parameter(file_count), file_count, |b, &file_count| {
79 | let files = create_test_files_with_duplicates(file_count, 0.3); // 30% duplicates
80 | let detector = DuplicateDetector::new();
81 | b.iter(|| detector.detect_duplicates(black_box(&files), true));
82 | });
83 | }
84 |
85 | group.finish();
86 | }
87 |
88 | fn benchmark_duplicate_ratios(c: &mut Criterion) {
89 | let mut group = c.benchmark_group("duplicate_ratios");
90 | group.sample_size(10);
91 |
92 | let file_count = 10000;
93 | for ratio in &[0.1, 0.3, 0.5, 0.7] {
94 | group.bench_with_input(
95 | BenchmarkId::from_parameter(format!("{}%", (ratio * 100.0) as u32)),
96 | ratio,
97 | |b, &ratio| {
98 | let files = create_test_files_with_duplicates(file_count, ratio);
99 | let detector = DuplicateDetector::new();
100 | b.iter(|| detector.detect_duplicates(black_box(&files), false));
101 | },
102 | );
103 | }
104 |
105 | group.finish();
106 | }
107 |
108 | criterion_group!(
109 | benches,
110 | benchmark_duplicate_detection_without_quick_hash,
111 | benchmark_duplicate_detection_with_quick_hash,
112 | benchmark_duplicate_ratios
113 | );
114 | criterion_main!(benches);
115 |
--------------------------------------------------------------------------------
/benches/organizer_benchmark.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::unwrap_used)]
2 | #![allow(clippy::expect_used)]
3 | #![allow(clippy::float_cmp)] // For comparing floats in tests
4 | #![allow(clippy::panic)]
5 | #![allow(clippy::significant_drop_tightening)]
6 | use chrono::Local;
7 | use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
8 | use std::sync::Arc;
9 | use std::{hint::black_box, path::PathBuf};
10 | use tempfile::TempDir;
11 | use tokio::runtime::Runtime;
12 | use tokio::sync::RwLock;
13 | use visualvault_config::Settings;
14 | use visualvault_core::FileOrganizer;
15 | use visualvault_models::{DuplicateStats, FileType, MediaFile};
16 | use visualvault_utils::Progress;
17 |
18 | fn create_test_media_files(count: usize) -> Vec> {
19 | (0..count)
20 | .map(|i| {
21 | Arc::new(MediaFile {
22 | path: PathBuf::from(format!("/tmp/test_{i:04}.jpg")),
23 | name: Arc::from(format!("test_{i:04}.jpg")),
24 | extension: Arc::from("jpg"),
25 | file_type: FileType::Image,
26 | size: 1024 * 1024, // 1MB
27 | modified: Local::now(),
28 | created: Local::now(),
29 | metadata: None,
30 | hash: None,
31 | })
32 | })
33 | .collect()
34 | }
35 |
36 | fn benchmark_organize_by_type(c: &mut Criterion) {
37 | let rt = Runtime::new().unwrap();
38 |
39 | let mut group = c.benchmark_group("organize_by_type");
40 | group.sample_size(10);
41 |
42 | for file_count in &[100, 500, 1000] {
43 | group.bench_with_input(BenchmarkId::from_parameter(file_count), file_count, |b, &file_count| {
44 | b.iter_batched(
45 | || {
46 | rt.block_on(async {
47 | let temp_dir = TempDir::new().unwrap();
48 | let files = create_test_media_files(file_count);
49 | let settings = Settings {
50 | destination_folder: Some(temp_dir.path().to_path_buf()),
51 | organize_by: "type".to_string(),
52 | ..Default::default()
53 | };
54 | let organizer = FileOrganizer::new(temp_dir.path().to_path_buf()).await.unwrap();
55 | let progress = Arc::new(RwLock::new(Progress::default()));
56 | (files, settings, organizer, progress)
57 | })
58 | },
59 | |(files, settings, organizer, progress)| {
60 | rt.block_on(async {
61 | organizer
62 | .organize_files_with_duplicates(
63 | black_box(files),
64 | DuplicateStats::new(),
65 | &settings,
66 | progress,
67 | )
68 | .await
69 | .unwrap()
70 | })
71 | },
72 | criterion::BatchSize::SmallInput,
73 | );
74 | });
75 | }
76 |
77 | group.finish();
78 | }
79 |
80 | fn benchmark_organize_modes(c: &mut Criterion) {
81 | let rt = Runtime::new().unwrap();
82 |
83 | let mut group = c.benchmark_group("organize_modes");
84 | group.sample_size(10);
85 |
86 | let modes = vec!["yearly", "monthly", "type"];
87 | let files = create_test_media_files(1000);
88 |
89 | for mode in modes {
90 | group.bench_with_input(BenchmarkId::from_parameter(mode), &mode, |b, &mode| {
91 | b.iter_batched(
92 | || {
93 | rt.block_on(async {
94 | let temp_dir = TempDir::new().unwrap();
95 | let settings = Settings {
96 | destination_folder: Some(temp_dir.path().to_path_buf()),
97 | organize_by: mode.to_string(),
98 | ..Default::default()
99 | };
100 | let organizer = FileOrganizer::new(temp_dir.path().to_path_buf()).await.unwrap();
101 | let progress = Arc::new(RwLock::new(Progress::default()));
102 | (files.clone(), settings, organizer, progress)
103 | })
104 | },
105 | |(files, settings, organizer, progress)| {
106 | rt.block_on(async {
107 | organizer
108 | .organize_files_with_duplicates(
109 | black_box(files),
110 | DuplicateStats::new(),
111 | &settings,
112 | progress,
113 | )
114 | .await
115 | .unwrap()
116 | })
117 | },
118 | criterion::BatchSize::SmallInput,
119 | );
120 | });
121 | }
122 |
123 | group.finish();
124 | }
125 |
126 | criterion_group!(benches, benchmark_organize_by_type, benchmark_organize_modes);
127 | criterion_main!(benches);
128 |
--------------------------------------------------------------------------------
/benches/scanner_benchmark.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::unwrap_used)]
2 | #![allow(clippy::expect_used)]
3 | #![allow(clippy::float_cmp)] // For comparing floats in tests
4 | #![allow(clippy::panic)]
5 | #![allow(clippy::significant_drop_tightening)]
6 | use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
7 | use std::fs;
8 | use std::hint::black_box;
9 | use std::path::Path;
10 | use std::sync::Arc;
11 | use tempfile::TempDir;
12 | use tokio::runtime::Runtime;
13 | use tokio::sync::RwLock;
14 | use visualvault_config::Settings;
15 | use visualvault_core::DatabaseCache;
16 | use visualvault_core::Scanner;
17 | use visualvault_utils::Progress;
18 |
19 | fn create_test_files(dir: &Path, count: usize) {
20 | for i in 0..count {
21 | let file_path = dir.join(format!("test_{i:04}.jpg"));
22 | fs::write(&file_path, b"fake image data").unwrap();
23 | }
24 | }
25 |
26 | fn benchmark_scanner(c: &mut Criterion) {
27 | let rt = Runtime::new().unwrap();
28 |
29 | let mut group = c.benchmark_group("scanner");
30 | group.sample_size(10);
31 |
32 | for file_count in &[100, 1000, 5000] {
33 | group.bench_with_input(BenchmarkId::from_parameter(file_count), file_count, |b, &file_count| {
34 | b.iter_batched(
35 | || {
36 | rt.block_on(async {
37 | let temp_dir = TempDir::new().unwrap();
38 | create_test_files(temp_dir.path(), file_count);
39 | let database_cache = DatabaseCache::new(":memory:")
40 | .await
41 | .expect("Failed to initialize database cache");
42 | let scanner = Scanner::new(database_cache);
43 | let progress = Arc::new(RwLock::new(Progress::default()));
44 | let settings = Settings::default();
45 | (scanner, temp_dir, progress, settings)
46 | })
47 | },
48 | |(scanner, temp_dir, progress, settings)| {
49 | rt.block_on(async {
50 | scanner
51 | .scan_directory(black_box(temp_dir.path()), false, progress, &settings, None)
52 | .await
53 | .unwrap()
54 | })
55 | },
56 | criterion::BatchSize::SmallInput,
57 | );
58 | });
59 | }
60 |
61 | group.finish();
62 | }
63 |
64 | fn benchmark_scanner_parallel(c: &mut Criterion) {
65 | let rt = Runtime::new().unwrap();
66 |
67 | let mut group = c.benchmark_group("scanner_parallel");
68 | group.sample_size(10);
69 |
70 | for thread_count in &[1, 2, 4, 8] {
71 | group.bench_with_input(
72 | BenchmarkId::from_parameter(thread_count),
73 | thread_count,
74 | |b, &thread_count| {
75 | b.iter_batched(
76 | || {
77 | rt.block_on(async {
78 | let temp_dir = TempDir::new().unwrap();
79 | create_test_files(temp_dir.path(), 5000);
80 | let database_cache = DatabaseCache::new(":memory:")
81 | .await
82 | .expect("Failed to initialize database cache");
83 | let scanner = Scanner::new(database_cache);
84 | let progress = Arc::new(RwLock::new(Progress::default()));
85 | let settings = Settings {
86 | parallel_processing: true,
87 | worker_threads: thread_count,
88 | ..Default::default()
89 | };
90 | (scanner, temp_dir, progress, settings)
91 | })
92 | },
93 | |(scanner, temp_dir, progress, settings)| {
94 | rt.block_on(async {
95 | scanner
96 | .scan_directory(black_box(temp_dir.path()), false, progress.clone(), &settings, None)
97 | .await
98 | .unwrap()
99 | })
100 | },
101 | criterion::BatchSize::SmallInput,
102 | );
103 | },
104 | );
105 | }
106 |
107 | group.finish();
108 | }
109 |
110 | criterion_group!(benches, benchmark_scanner, benchmark_scanner_parallel);
111 | criterion_main!(benches);
112 |
--------------------------------------------------------------------------------
/crates/app/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "visualvault-app"
3 | version.workspace = true
4 | edition.workspace = true
5 | authors.workspace = true
6 | license.workspace = true
7 |
8 | [dependencies]
9 | visualvault-config = { workspace = true }
10 | visualvault-core = { workspace = true }
11 | visualvault-models = { workspace = true }
12 | visualvault-utils = { workspace = true }
13 | chrono = { workspace = true }
14 | crossterm = { workspace = true }
15 | ahash = { workspace = true }
16 | tokio = { workspace = true }
17 | color-eyre = { workspace = true }
18 | ratatui = { workspace = true }
19 | tracing = { workspace = true }
20 | walkdir = { workspace = true }
21 | image = { workspace = true }
22 | num_cpus = { workspace = true }
23 | dirs = { workspace = true }
24 |
25 | [dev-dependencies]
26 | tempfile = "3.20"
27 | serde_json = "1.0"
28 | tokio = { version = "1", features = ["full", "test-util"] }
29 | color-eyre = "0.6"
30 | chrono = "0.4"
31 | criterion = { version = "3.0.4", package = "codspeed-criterion-compat" }
32 | proptest = "1.7"
33 |
--------------------------------------------------------------------------------
/crates/app/src/duplicates.rs:
--------------------------------------------------------------------------------
1 | use color_eyre::eyre::Result;
2 | use crossterm::event::{KeyCode, KeyEvent};
3 | use visualvault_models::DuplicateFocus;
4 | use visualvault_utils::format_bytes;
5 |
6 | use super::{App, AppState};
7 |
8 | impl App {
9 | /// Starts a duplicate file scan operation.
10 | ///
11 | /// # Errors
12 | /// Returns an error if the duplicate detection process fails.
13 | pub async fn start_duplicate_scan(&mut self) -> Result<()> {
14 | self.error_message = None;
15 | self.success_message = Some("Scanning for duplicates...".to_string());
16 |
17 | // Make sure we have files to scan
18 | if self.cached_files.is_empty() {
19 | self.error_message = Some("No files to scan. Run a file scan first.".to_string());
20 | self.success_message = None;
21 | return Ok(());
22 | }
23 |
24 | // Use cached files for duplicate detection
25 | let stats = self
26 | .duplicate_detector
27 | .detect_duplicates(&self.cached_files, false)
28 | .await?;
29 |
30 | let message = if stats.total_groups > 0 {
31 | format!(
32 | "Found {} duplicate groups with {} files wasting {}",
33 | stats.total_groups,
34 | stats.total_duplicates,
35 | format_bytes(stats.total_wasted_space)
36 | )
37 | } else {
38 | "No duplicates found.".to_string()
39 | };
40 |
41 | let has_groups = stats.total_groups > 0;
42 | self.duplicate_stats = Some(stats);
43 | self.success_message = Some(message);
44 | self.state = AppState::DuplicateReview;
45 |
46 | // Reset selection states
47 | self.selected_duplicate_group = 0;
48 | self.selected_duplicate_items.clear();
49 | self.duplicate_list_state
50 | .select(if has_groups { Some(0) } else { None });
51 |
52 | Ok(())
53 | }
54 |
55 | /// Handles keyboard input in duplicate review mode.
56 | ///
57 | /// # Errors
58 | /// Returns an error if file operations (scanning, deleting) fail.
59 | pub async fn handle_duplicate_keys(&mut self, key: KeyEvent) -> Result<()> {
60 | // Handle bulk delete confirmation first
61 | if self.pending_bulk_delete {
62 | match key.code {
63 | KeyCode::Char('y' | 'Y') => {
64 | self.pending_bulk_delete = false;
65 | self.perform_bulk_delete().await?;
66 | }
67 | KeyCode::Char('n' | 'N') | KeyCode::Esc => {
68 | self.pending_bulk_delete = false;
69 | self.error_message = Some("Bulk delete cancelled".to_string());
70 | }
71 | _ => {}
72 | }
73 | return Ok(());
74 | }
75 |
76 | match key.code {
77 | KeyCode::Esc | KeyCode::Char('q') => {
78 | self.exit_duplicate_review();
79 | }
80 | KeyCode::Char('s') => {
81 | self.start_duplicate_scan().await?;
82 | }
83 | KeyCode::Up => {
84 | self.move_duplicate_selection_up();
85 | }
86 | KeyCode::Down => {
87 | self.move_duplicate_selection_down();
88 | }
89 | KeyCode::Left => {
90 | self.switch_to_group_list();
91 | }
92 | KeyCode::Right => {
93 | self.switch_to_file_list();
94 | }
95 | KeyCode::Char(' ') => {
96 | self.toggle_file_selection();
97 | }
98 | KeyCode::Char('a') => {
99 | self.select_all_except_first();
100 | }
101 | KeyCode::Char('d') => {
102 | self.handle_delete_key().await?;
103 | }
104 | KeyCode::Char('D') => {
105 | self.initiate_bulk_delete();
106 | }
107 | _ => {}
108 | }
109 | Ok(())
110 | }
111 |
112 | fn exit_duplicate_review(&mut self) {
113 | self.state = AppState::Dashboard;
114 | self.selected_duplicate_items.clear();
115 | }
116 |
117 | fn move_duplicate_selection_up(&mut self) {
118 | match self.duplicate_focus {
119 | DuplicateFocus::GroupList => {
120 | if self.selected_duplicate_group > 0 {
121 | self.selected_duplicate_group -= 1;
122 | self.duplicate_list_state.select(Some(self.selected_duplicate_group));
123 | self.selected_duplicate_items.clear();
124 | }
125 | }
126 | DuplicateFocus::FileList => {
127 | if let Some(stats) = &self.duplicate_stats {
128 | if stats.groups.get(self.selected_duplicate_group).is_some() && self.selected_file_in_group > 0 {
129 | self.selected_file_in_group -= 1;
130 | }
131 | }
132 | }
133 | }
134 | }
135 |
136 | fn move_duplicate_selection_down(&mut self) {
137 | match self.duplicate_focus {
138 | DuplicateFocus::GroupList => {
139 | if let Some(stats) = &self.duplicate_stats {
140 | if !stats.groups.is_empty() && self.selected_duplicate_group < stats.groups.len() - 1 {
141 | self.selected_duplicate_group += 1;
142 | self.duplicate_list_state.select(Some(self.selected_duplicate_group));
143 | self.selected_duplicate_items.clear();
144 | }
145 | }
146 | }
147 | DuplicateFocus::FileList => {
148 | if let Some(stats) = &self.duplicate_stats {
149 | if let Some(group) = stats.groups.get(self.selected_duplicate_group) {
150 | if self.selected_file_in_group < group.files.len() - 1 {
151 | self.selected_file_in_group += 1;
152 | }
153 | }
154 | }
155 | }
156 | }
157 | }
158 |
159 | const fn switch_to_group_list(&mut self) {
160 | self.duplicate_focus = DuplicateFocus::GroupList;
161 | self.selected_file_in_group = 0;
162 | }
163 |
164 | fn switch_to_file_list(&mut self) {
165 | if let Some(stats) = &self.duplicate_stats {
166 | if !stats.groups.is_empty() {
167 | self.duplicate_focus = DuplicateFocus::FileList;
168 | self.selected_file_in_group = 0;
169 | }
170 | }
171 | }
172 |
173 | fn toggle_file_selection(&mut self) {
174 | if self.duplicate_focus == DuplicateFocus::FileList {
175 | if self.selected_duplicate_items.contains(&self.selected_file_in_group) {
176 | self.selected_duplicate_items.remove(&self.selected_file_in_group);
177 | } else {
178 | self.selected_duplicate_items.insert(self.selected_file_in_group);
179 | }
180 | }
181 | }
182 |
183 | fn select_all_except_first(&mut self) {
184 | // Select all but the first file in the current group
185 | if let Some(stats) = &self.duplicate_stats {
186 | if let Some(group) = stats.groups.get(self.selected_duplicate_group) {
187 | self.selected_duplicate_items.clear();
188 | for i in 1..group.files.len() {
189 | self.selected_duplicate_items.insert(i);
190 | }
191 | self.success_message = Some(format!(
192 | "Selected {} duplicate files (keeping the first as original)",
193 | self.selected_duplicate_items.len()
194 | ));
195 | }
196 | }
197 | }
198 |
199 | async fn handle_delete_key(&mut self) -> Result<()> {
200 | // Delete selected files in current group
201 | if self.selected_duplicate_items.is_empty() {
202 | self.error_message = Some("No files selected for deletion".to_string());
203 | } else {
204 | self.delete_selected_duplicates().await?;
205 | }
206 | Ok(())
207 | }
208 |
209 | fn initiate_bulk_delete(&mut self) {
210 | // Set pending and show confirmation message
211 | if let Some(stats) = &self.duplicate_stats {
212 | if stats.total_duplicates > 0 {
213 | self.pending_bulk_delete = true;
214 | self.error_message = Some(format!(
215 | "⚠️ Delete {} duplicates from {} groups? This will free {}. Press Y to confirm, N to cancel",
216 | stats.total_duplicates,
217 | stats.total_groups,
218 | format_bytes(stats.total_wasted_space)
219 | ));
220 | } else {
221 | self.error_message = Some("No duplicates to delete".to_string());
222 | }
223 | }
224 | }
225 |
226 | async fn perform_bulk_delete(&mut self) -> Result<()> {
227 | if let Some(stats) = &self.duplicate_stats {
228 | let mut paths_to_delete = Vec::new();
229 |
230 | // Collect all duplicate files (skip first in each group)
231 | for group in &stats.groups {
232 | for (idx, file) in group.files.iter().enumerate() {
233 | if idx > 0 {
234 | // Skip the first file (keep it as original)
235 | paths_to_delete.push(file.path.clone());
236 | }
237 | }
238 | }
239 |
240 | if !paths_to_delete.is_empty() {
241 | let total_to_delete = paths_to_delete.len();
242 | let deleted = self.duplicate_detector.delete_files(&paths_to_delete).await?;
243 |
244 | self.success_message = Some(format!(
245 | "✅ Successfully deleted {} of {} duplicate files, freed {}",
246 | deleted.len(),
247 | total_to_delete,
248 | format_bytes(stats.total_wasted_space)
249 | ));
250 |
251 | // Clear selections and rescan
252 | self.selected_duplicate_items.clear();
253 | self.start_duplicate_scan().await?;
254 | }
255 | }
256 | Ok(())
257 | }
258 |
259 | async fn delete_selected_duplicates(&mut self) -> Result<()> {
260 | if let Some(stats) = &self.duplicate_stats {
261 | if let Some(group) = stats.groups.get(self.selected_duplicate_group) {
262 | let mut paths_to_delete = Vec::new();
263 |
264 | for &idx in &self.selected_duplicate_items {
265 | if let Some(file) = group.files.get(idx) {
266 | paths_to_delete.push(file.path.clone());
267 | }
268 | }
269 |
270 | if !paths_to_delete.is_empty() {
271 | let deleted = self.duplicate_detector.delete_files(&paths_to_delete).await?;
272 | self.success_message = Some(format!("Deleted {} files", deleted.len()));
273 |
274 | // Clear selections and rescan
275 | self.selected_duplicate_items.clear();
276 | self.start_duplicate_scan().await?;
277 | }
278 | }
279 | }
280 | Ok(())
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/crates/app/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod actions;
2 | mod duplicates;
3 | mod filters;
4 | mod handlers;
5 | mod navigation;
6 | pub mod state;
7 |
8 | pub use state::App;
9 |
10 | use color_eyre::eyre::Result;
11 | use crossterm::event::KeyEvent;
12 | use tracing::info;
13 | use visualvault_models::AppState;
14 |
15 | impl App {
16 | /// Creates a new App instance with default settings and components.
17 | ///
18 | /// # Errors
19 | /// Returns an error if:
20 | /// - Settings cannot be loaded from the configuration file
21 | /// - Scanner cache initialization fails
22 | /// - Any other component initialization fails
23 | pub async fn new() -> Result {
24 | // time the initialization process
25 | info!("Starting application initialization...");
26 | let start_time = std::time::Instant::now();
27 | let app = state::App::init().await;
28 |
29 | info!("Application initialized in {} ms", start_time.elapsed().as_millis());
30 | app
31 | }
32 |
33 | /// Handles keyboard input events and updates application state accordingly.
34 | ///
35 | /// # Errors
36 | /// Returns an error if the key handling operation fails, such as when
37 | /// updating settings, performing file operations, or state transitions.
38 | pub async fn on_key(&mut self, key: KeyEvent) -> Result<()> {
39 | self.clear_messages();
40 |
41 | match self.state {
42 | AppState::Search => {
43 | self.handle_search_keys(key);
44 | Ok(())
45 | }
46 | AppState::Filters => {
47 | self.handle_filter_keys(key);
48 | Ok(())
49 | }
50 | AppState::FileDetails(_) => {
51 | self.handle_file_details_keys(key);
52 | Ok(())
53 | }
54 | AppState::DuplicateReview => self.handle_duplicate_keys(key).await,
55 | _ => self.handle_global_keys(key).await,
56 | }
57 | }
58 |
59 | /// Handles periodic updates and state transitions.
60 | ///
61 | /// # Errors
62 | /// Returns an error if statistics update fails.
63 | pub async fn on_tick(&mut self) -> Result<()> {
64 | self.update_progress().await?;
65 | self.update_folder_stats_if_needed();
66 | self.check_scan_completion().await?;
67 | self.check_folder_stats_completion().await;
68 | self.check_operation_completion().await?;
69 | Ok(())
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/crates/app/src/mod.rs:
--------------------------------------------------------------------------------
1 | mod actions;
2 | mod duplicates;
3 | mod filters;
4 | mod handlers;
5 | mod navigation;
6 | pub mod state;
7 |
8 | pub use state::{App, AppState, EditingField, InputMode, OrganizeResult, ScanResult};
9 |
10 | use color_eyre::eyre::Result;
11 | use crossterm::event::KeyEvent;
12 |
13 | impl App {
14 | /// Creates a new App instance with default settings and components.
15 | ///
16 | /// # Errors
17 | /// Returns an error if:
18 | /// - Settings cannot be loaded from the configuration file
19 | /// - Scanner cache initialization fails
20 | /// - Any other component initialization fails
21 | pub async fn new() -> Result {
22 | state::App::init().await
23 | }
24 |
25 | /// Handles keyboard input events and updates application state accordingly.
26 | ///
27 | /// # Errors
28 | /// Returns an error if the key handling operation fails, such as when
29 | /// updating settings, performing file operations, or state transitions.
30 | pub async fn on_key(&mut self, key: KeyEvent) -> Result<()> {
31 | self.clear_messages();
32 |
33 | match self.state {
34 | AppState::Search => {
35 | self.handle_search_keys(key);
36 | Ok(())
37 | }
38 | AppState::Filters => {
39 | self.handle_filter_keys(key);
40 | Ok(())
41 | }
42 | AppState::FileDetails(_) => {
43 | self.handle_file_details_keys(key);
44 | Ok(())
45 | }
46 | AppState::DuplicateReview => self.handle_duplicate_keys(key).await,
47 | _ => self.handle_global_keys(key).await,
48 | }
49 | }
50 |
51 | /// Handles periodic updates and state transitions.
52 | ///
53 | /// # Errors
54 | /// Returns an error if statistics update fails.
55 | pub async fn on_tick(&mut self) -> Result<()> {
56 | self.update_progress().await?;
57 | self.update_folder_stats_if_needed();
58 | self.check_operation_completion().await?;
59 | Ok(())
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/crates/app/src/navigation.rs:
--------------------------------------------------------------------------------
1 | use visualvault_models::InputMode;
2 |
3 | use super::App;
4 |
5 | impl App {
6 | pub const fn next_tab(&mut self) {
7 | let max_tabs = self.get_tab_count();
8 | self.selected_tab = (self.selected_tab + 1) % max_tabs;
9 | self.selected_setting = 0;
10 | }
11 |
12 | pub const fn previous_tab(&mut self) {
13 | let max_tabs = self.get_tab_count();
14 | if self.selected_tab > 0 {
15 | self.selected_tab -= 1;
16 | } else {
17 | self.selected_tab = max_tabs - 1;
18 | }
19 | self.selected_setting = 0;
20 | }
21 |
22 | pub const fn move_selection_up(&mut self) {
23 | if self.selected_file_index > 0 {
24 | self.selected_file_index -= 1;
25 | if self.selected_file_index < self.scroll_offset {
26 | self.scroll_offset = self.selected_file_index;
27 | }
28 | }
29 | }
30 |
31 | pub fn move_selection_down(&mut self) {
32 | let file_count = self.cached_files.len();
33 | if self.selected_file_index < file_count.saturating_sub(1) {
34 | self.selected_file_index += 1;
35 | if self.selected_file_index >= self.scroll_offset + 20 {
36 | self.scroll_offset = self.selected_file_index - 19;
37 | }
38 | }
39 | }
40 |
41 | pub const fn page_up(&mut self) {
42 | if self.selected_file_index >= 10 {
43 | self.selected_file_index -= 10;
44 | } else {
45 | self.selected_file_index = 0;
46 | }
47 | if self.selected_file_index < self.scroll_offset {
48 | self.scroll_offset = self.selected_file_index;
49 | }
50 | }
51 |
52 | pub fn page_down(&mut self) {
53 | let file_count = self.cached_files.len();
54 | self.selected_file_index = std::cmp::min(self.selected_file_index + 10, file_count.saturating_sub(1));
55 | if self.selected_file_index >= self.scroll_offset + 20 {
56 | self.scroll_offset = self.selected_file_index.saturating_sub(19);
57 | }
58 | }
59 |
60 | pub fn handle_search_keys(&mut self, key: crossterm::event::KeyEvent) {
61 | use crossterm::event::KeyCode;
62 |
63 | match self.input_mode {
64 | InputMode::Normal => match key.code {
65 | KeyCode::Enter | KeyCode::Char('/') => {
66 | self.input_mode = InputMode::Insert;
67 | }
68 | KeyCode::Esc => {
69 | self.state = super::AppState::Dashboard;
70 | self.search_input.clear();
71 | self.search_results.clear();
72 | self.selected_file_index = 0;
73 | self.scroll_offset = 0;
74 | }
75 | KeyCode::Up => {
76 | if !self.search_results.is_empty() && self.selected_file_index > 0 {
77 | self.selected_file_index -= 1;
78 | if self.selected_file_index < self.scroll_offset {
79 | self.scroll_offset = self.selected_file_index;
80 | }
81 | }
82 | }
83 | KeyCode::Down => {
84 | if !self.search_results.is_empty()
85 | && self.selected_file_index < self.search_results.len().saturating_sub(1)
86 | {
87 | self.selected_file_index += 1;
88 | if self.selected_file_index >= self.scroll_offset + 20 {
89 | self.scroll_offset = self.selected_file_index - 19;
90 | }
91 | }
92 | }
93 | _ => {}
94 | },
95 | InputMode::Insert => match key.code {
96 | KeyCode::Enter => {
97 | self.perform_search();
98 | self.input_mode = InputMode::Normal;
99 | }
100 | KeyCode::Esc => {
101 | self.input_mode = InputMode::Normal;
102 | }
103 | KeyCode::Char(c) => {
104 | self.search_input.push(c);
105 | self.perform_search();
106 | }
107 | KeyCode::Backspace => {
108 | self.search_input.pop();
109 | self.perform_search();
110 | }
111 | KeyCode::Delete => {
112 | self.search_input.clear();
113 | self.search_results.clear();
114 | self.selected_file_index = 0;
115 | self.scroll_offset = 0;
116 | }
117 | _ => {}
118 | },
119 | InputMode::Editing => {
120 | if key.code == KeyCode::Esc {
121 | self.input_mode = InputMode::Normal;
122 | } else {
123 | self.input_mode = InputMode::Insert;
124 | self.handle_search_keys(key);
125 | }
126 | }
127 | }
128 | }
129 |
130 | pub fn perform_search(&mut self) {
131 | if self.search_input.is_empty() {
132 | self.search_results.clear();
133 | self.selected_file_index = 0;
134 | self.scroll_offset = 0;
135 | return;
136 | }
137 |
138 | let search_term = self.search_input.to_lowercase();
139 | self.search_results = self
140 | .cached_files
141 | .iter()
142 | .filter(|file| {
143 | file.name.to_lowercase().contains(&search_term)
144 | || file.path.to_string_lossy().to_lowercase().contains(&search_term)
145 | })
146 | .map(|file| (**file).clone())
147 | .collect();
148 | self.selected_file_index = 0;
149 | self.scroll_offset = 0;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/crates/app/src/state.rs:
--------------------------------------------------------------------------------
1 | use std::{collections::HashSet, path::PathBuf, sync::Arc};
2 |
3 | use ahash::AHashMap;
4 | use color_eyre::eyre::Result;
5 | use ratatui::widgets::ListState;
6 | use tokio::{sync::RwLock, task::JoinHandle};
7 | use tracing::error;
8 | use tracing::info;
9 | use visualvault_config::Settings;
10 | use visualvault_core::DatabaseCache;
11 | use visualvault_core::{DuplicateDetector, FileManager, FileOrganizer, Scanner};
12 | use visualvault_models::{
13 | AppState, DuplicateFocus, DuplicateStats, EditingField, FilterFocus, FilterSet, InputMode, MediaFile,
14 | OrganizeResult, ScanResult, Statistics,
15 | };
16 | use visualvault_utils::{FolderStats, Progress, create_cache_path};
17 |
18 | pub struct App {
19 | // Core state
20 | pub state: AppState,
21 | pub input_mode: InputMode,
22 | pub should_quit: bool,
23 |
24 | // UI state
25 | pub show_help: bool,
26 | pub error_message: Option,
27 | pub success_message: Option,
28 | pub selected_tab: usize,
29 | pub selected_setting: usize,
30 | pub selected_file_index: usize,
31 | pub scroll_offset: usize,
32 | pub help_scroll: usize,
33 |
34 | // Components
35 | pub settings: Arc>,
36 | pub settings_cache: Settings,
37 | pub scanner: Arc,
38 | pub file_manager: Arc>,
39 | pub organizer: Arc,
40 | pub duplicate_detector: DuplicateDetector,
41 |
42 | // Data
43 | pub statistics: Statistics,
44 | pub progress: Arc>,
45 | pub cached_files: Vec>,
46 | pub search_results: Vec,
47 | pub duplicate_groups: Option>>,
48 | pub duplicate_stats: Option,
49 | pub folder_stats_cache: AHashMap,
50 |
51 | // Search state
52 | pub search_input: String,
53 |
54 | // Input state
55 | pub input_buffer: String,
56 | pub editing_field: Option,
57 |
58 | // Results
59 | pub last_scan_result: Option,
60 | pub last_organize_result: Option,
61 |
62 | // Duplicate state
63 | pub selected_duplicate_group: usize,
64 | pub selected_duplicate_items: HashSet,
65 | pub duplicate_list_state: ListState,
66 | pub duplicate_focus: DuplicateFocus,
67 | pub selected_file_in_group: usize,
68 | pub pending_bulk_delete: bool,
69 |
70 | // Filter state
71 | pub filter_set: FilterSet,
72 | pub filter_tab: usize,
73 | pub filter_focus: FilterFocus,
74 | pub selected_filter_index: usize,
75 | pub filter_input: String,
76 |
77 | // Undo state
78 | pub last_undo_result: Option,
79 |
80 | pub folder_stats_tasks: AHashMap>,
81 | pub folder_stats_in_progress: HashSet,
82 |
83 | pub scan_task: Option>, DuplicateStats)>>>,
84 | pub scan_start_time: Option,
85 | }
86 |
87 | impl App {
88 | /// Initializes a new `App` instance with default settings and components.
89 | ///
90 | /// # Errors
91 | ///
92 | /// Returns an error if:
93 | /// - Settings cannot be loaded from the configuration file
94 | /// - Scanner cache initialization fails
95 | ///
96 | /// # Panics
97 | ///
98 | /// Panics if:
99 | /// - The cache path cannot be converted to a string
100 | /// - The cache path creation fails during background initialization
101 | pub async fn init() -> Result {
102 | let mut duplicate_list_state = ListState::default();
103 | duplicate_list_state.select(Some(0));
104 |
105 | let settings = Settings::load().await?;
106 | let settings_cache = settings.clone();
107 | let settings = Arc::new(RwLock::new(settings));
108 | let file_manager = Arc::new(RwLock::new(FileManager::new()));
109 | let database_cache = DatabaseCache::new_uninit();
110 | let scanner = Arc::new(Scanner::new(database_cache));
111 | let config_dir =
112 | dirs::config_dir().ok_or_else(|| color_eyre::eyre::eyre!("Could not find config directory"))?;
113 | let config_dir_clone = config_dir.clone();
114 | let organizer = Arc::new(FileOrganizer::new(config_dir).await?);
115 | let statistics = Statistics::new();
116 | let progress = Arc::new(RwLock::new(Progress::new()));
117 |
118 | let app = Self {
119 | state: AppState::Dashboard,
120 | input_mode: InputMode::Normal,
121 | should_quit: false,
122 | show_help: false,
123 | error_message: None,
124 | success_message: None,
125 | selected_tab: 0,
126 | selected_setting: 0,
127 | selected_file_index: 0,
128 | scroll_offset: 0,
129 | help_scroll: 0,
130 | settings,
131 | settings_cache,
132 | scanner,
133 | file_manager,
134 | organizer,
135 | duplicate_detector: DuplicateDetector::new(),
136 | statistics,
137 | progress,
138 | cached_files: Vec::new(),
139 | search_results: Vec::new(),
140 | duplicate_groups: None,
141 | duplicate_stats: None,
142 | folder_stats_cache: AHashMap::new(),
143 | search_input: String::new(),
144 | input_buffer: String::new(),
145 | editing_field: None,
146 | last_scan_result: None,
147 | last_organize_result: None,
148 | selected_duplicate_group: 0,
149 | selected_duplicate_items: HashSet::new(),
150 | duplicate_list_state,
151 | duplicate_focus: DuplicateFocus::GroupList,
152 | selected_file_in_group: 0,
153 | pending_bulk_delete: false,
154 | filter_set: FilterSet::new(),
155 | filter_tab: 0,
156 | filter_focus: FilterFocus::DateRange,
157 | selected_filter_index: 0,
158 | filter_input: String::new(),
159 | last_undo_result: None,
160 | folder_stats_tasks: AHashMap::new(),
161 | folder_stats_in_progress: HashSet::new(),
162 | scan_task: None,
163 | scan_start_time: None,
164 | };
165 |
166 | let scanner_clone = Arc::clone(&app.scanner);
167 | #[allow(clippy::expect_used)]
168 | tokio::spawn(async move {
169 | // Load scanner cache in background
170 | let cache_path = create_cache_path("visualvault", "cache.db")
171 | .await
172 | .expect("Failed to create cache path");
173 | //cache_path to &str
174 | let cache_path_str = cache_path.to_str().expect("Failed to convert cache path to string");
175 | let database_cache = DatabaseCache::new(cache_path_str)
176 | .await
177 | .expect("Failed to initialize database cache");
178 | scanner_clone.set_cache(database_cache).await.unwrap_or_else(|e| {
179 | error!("Failed to initialize scanner cache: {}", e);
180 | });
181 |
182 | // Load full organizer state in background
183 | if (FileOrganizer::new(config_dir_clone).await).is_ok() {
184 | // You'd need a way to update this in the app
185 | info!("File organizer fully loaded");
186 | }
187 | });
188 |
189 | Ok(app)
190 | }
191 |
192 | pub fn clear_messages(&mut self) {
193 | self.error_message = None;
194 | self.success_message = None;
195 | }
196 |
197 | #[must_use]
198 | pub const fn get_tab_count(&self) -> usize {
199 | match self.state {
200 | AppState::Dashboard => 4,
201 | AppState::Settings => 3,
202 | _ => 1,
203 | }
204 | }
205 |
206 | /// Updates the cached settings from the shared settings instance.
207 | ///
208 | /// # Errors
209 | ///
210 | /// This function currently does not return any errors, but the `Result` type
211 | /// is used for future compatibility.
212 | pub async fn update_settings_cache(&mut self) -> Result<()> {
213 | let settings = self.settings.read().await;
214 | self.settings_cache = settings.clone();
215 | drop(settings);
216 | Ok(())
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/crates/config/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "visualvault-config"
3 | version.workspace = true
4 | edition.workspace = true
5 | authors.workspace = true
6 | license.workspace = true
7 |
8 | [dependencies]
9 | serde = { workspace = true }
10 | tracing = { workspace = true }
11 | color-eyre = { workspace = true }
12 | toml = { workspace = true }
13 | tokio = { workspace = true }
14 | num_cpus = { workspace = true }
15 | dirs = { workspace = true }
16 |
17 | [dev-dependencies]
18 | tempfile = "3.20"
19 |
--------------------------------------------------------------------------------
/crates/config/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod settings;
2 |
3 | pub use settings::OrganizationMode;
4 | pub use settings::Settings;
5 |
--------------------------------------------------------------------------------
/crates/core/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "visualvault-core"
3 | version.workspace = true
4 | edition.workspace = true
5 | authors.workspace = true
6 | license.workspace = true
7 |
8 | [dependencies]
9 | visualvault-models = { workspace = true }
10 | visualvault-utils = { workspace = true }
11 | visualvault-config = { workspace = true }
12 | ahash = { workspace = true }
13 | color-eyre = { workspace = true }
14 | serde = { workspace = true }
15 | serde_json = { workspace = true }
16 | tokio = { workspace = true }
17 | tracing = { workspace = true }
18 | smallvec = { workspace = true }
19 | sha2 = { workspace = true }
20 | chrono = { workspace = true }
21 | dirs = { workspace = true }
22 | walkdir = { workspace = true }
23 | rayon = { workspace = true }
24 | thiserror = { workspace = true }
25 | uuid = { workspace = true }
26 | sqlx = { workspace = true }
27 | async-trait = { workspace = true }
28 |
29 | [dev-dependencies]
30 | tempfile = "3.20"
31 | serde_json = "1.0"
32 | tokio = { version = "1", features = ["full", "test-util"] }
33 | color-eyre = "0.6"
34 | chrono = "0.4"
35 |
--------------------------------------------------------------------------------
/crates/core/src/cache.rs:
--------------------------------------------------------------------------------
1 | use crate::database_cache::{CacheEntry, CacheStats};
2 | use async_trait::async_trait;
3 | use chrono::{DateTime, Local};
4 | use color_eyre::Result;
5 | use std::path::{Path, PathBuf};
6 |
7 | /// Cache trait for abstracting different cache implementations
8 | #[async_trait]
9 | pub trait Cache: Send + Sync {
10 | async fn get(&self, path: &Path, size: u64, modified: &DateTime) -> Result