├── .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>; 11 | async fn insert(&self, path: PathBuf, entry: CacheEntry) -> Result<()>; 12 | async fn update_hash(&self, path: &Path, hash: &str) -> Result<()>; 13 | async fn get_stats(&self) -> Result; 14 | async fn remove_stale_entries(&self) -> Result; 15 | async fn len(&self) -> Result; 16 | async fn is_empty(&self) -> Result; 17 | } 18 | 19 | /// Implement the Cache trait for DatabaseCache 20 | #[async_trait] 21 | impl Cache for crate::DatabaseCache { 22 | async fn get(&self, path: &Path, size: u64, modified: &DateTime) -> Result> { 23 | self.get(path, size, modified).await 24 | } 25 | 26 | async fn insert(&self, path: PathBuf, entry: CacheEntry) -> Result<()> { 27 | self.insert(path, entry).await 28 | } 29 | 30 | async fn update_hash(&self, path: &Path, hash: &str) -> Result<()> { 31 | self.update_hash(path, hash).await 32 | } 33 | 34 | async fn get_stats(&self) -> Result { 35 | self.get_stats().await 36 | } 37 | 38 | async fn remove_stale_entries(&self) -> Result { 39 | self.remove_stale_entries().await 40 | } 41 | async fn len(&self) -> Result { 42 | self.len().await 43 | } 44 | 45 | async fn is_empty(&self) -> Result { 46 | Ok(self.len().await? == 0) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/core/src/file_manager.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use visualvault_models::MediaFile; 4 | 5 | #[derive(Default)] 6 | pub struct FileManager { 7 | files: Arc<[Arc]>, 8 | // Remove filtered_files and filter_active for now 9 | } 10 | 11 | impl FileManager { 12 | #[must_use] 13 | pub fn new() -> Self { 14 | Self { 15 | files: Arc::<[Arc]>::from([]), 16 | } 17 | } 18 | 19 | pub fn set_files(&mut self, files: Vec>) { 20 | self.files = Arc::from(files); 21 | } 22 | 23 | #[must_use] 24 | pub fn get_files(&self) -> Arc<[Arc]> { 25 | Arc::clone(&self.files) 26 | } 27 | 28 | #[must_use] 29 | pub fn get_file_count(&self) -> usize { 30 | self.files.len() 31 | } 32 | } 33 | 34 | // ...existing code... 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | #![allow(clippy::unwrap_used)] 39 | #![allow(clippy::expect_used)] 40 | #![allow(clippy::float_cmp)] // For comparing floats in tests 41 | #![allow(clippy::panic)] 42 | use super::*; 43 | use chrono::Local; 44 | use std::path::PathBuf; 45 | use visualvault_models::FileType; 46 | 47 | fn create_test_media_file(name: &str, size: u64) -> Arc { 48 | Arc::new(MediaFile { 49 | path: PathBuf::from(format!("/test/{name}")), 50 | name: name.to_string().into(), 51 | size, 52 | modified: Local::now(), 53 | created: Local::now(), 54 | file_type: FileType::Image, 55 | extension: "jpg".to_string().into(), 56 | hash: Some(format!("hash_{name}").into()), 57 | metadata: None, 58 | }) 59 | } 60 | 61 | #[test] 62 | fn test_new_file_manager() { 63 | let manager = FileManager::new(); 64 | assert_eq!(manager.get_file_count(), 0); 65 | assert!(manager.files.is_empty()); 66 | } 67 | 68 | #[test] 69 | fn test_default_file_manager() { 70 | let manager = FileManager::default(); 71 | assert_eq!(manager.get_file_count(), 0); 72 | 73 | assert!(manager.files.is_empty()); 74 | } 75 | 76 | #[test] 77 | fn test_set_files() { 78 | let mut manager = FileManager::new(); 79 | let files = vec![ 80 | create_test_media_file("file1.jpg", 1000), 81 | create_test_media_file("file2.jpg", 2000), 82 | create_test_media_file("file3.jpg", 3000), 83 | ]; 84 | 85 | manager.set_files(files); 86 | 87 | assert_eq!(manager.get_file_count(), 3); 88 | assert_eq!(manager.files.len(), 3); 89 | 90 | // Verify files are the same 91 | let retrieved_files = manager.get_files(); 92 | assert_eq!(retrieved_files.len(), 3); 93 | assert_eq!(retrieved_files[0].name, "file1.jpg".into()); 94 | assert_eq!(retrieved_files[1].name, "file2.jpg".into()); 95 | assert_eq!(retrieved_files[2].name, "file3.jpg".into()); 96 | } 97 | 98 | #[test] 99 | fn test_set_files_overwrites_existing() { 100 | let mut manager = FileManager::new(); 101 | 102 | // Set initial files 103 | let initial_files = vec![ 104 | create_test_media_file("initial1.jpg", 1000), 105 | create_test_media_file("initial2.jpg", 2000), 106 | ]; 107 | manager.set_files(initial_files); 108 | assert_eq!(manager.get_file_count(), 2); 109 | 110 | // Set new files 111 | let new_files = vec![ 112 | create_test_media_file("new1.jpg", 3000), 113 | create_test_media_file("new2.jpg", 4000), 114 | create_test_media_file("new3.jpg", 5000), 115 | ]; 116 | manager.set_files(new_files); 117 | 118 | assert_eq!(manager.get_file_count(), 3); 119 | 120 | let retrieved_files = manager.get_files(); 121 | assert_eq!(retrieved_files[0].name, "new1.jpg".into()); 122 | assert_eq!(retrieved_files[1].name, "new2.jpg".into()); 123 | assert_eq!(retrieved_files[2].name, "new3.jpg".into()); 124 | } 125 | 126 | #[test] 127 | fn test_get_files_returns_arc() { 128 | let mut manager = FileManager::new(); 129 | let files = vec![ 130 | create_test_media_file("file1.jpg", 1000), 131 | create_test_media_file("file2.jpg", 2000), 132 | ]; 133 | 134 | manager.set_files(files); 135 | 136 | // Get files multiple times to ensure Arc is working 137 | let files_ref1 = manager.get_files(); 138 | let files_ref2 = manager.get_files(); 139 | 140 | // Both references should point to the same data 141 | assert_eq!(files_ref1.len(), files_ref2.len()); 142 | assert_eq!(files_ref1[0].name, files_ref2[0].name); 143 | 144 | // Verify Arc is being used (same pointer) 145 | assert!(Arc::ptr_eq(&files_ref1, &files_ref2)); 146 | } 147 | 148 | #[test] 149 | fn test_get_file_count_with_empty_manager() { 150 | let manager = FileManager::new(); 151 | assert_eq!(manager.get_file_count(), 0); 152 | } 153 | 154 | #[test] 155 | fn test_get_files_with_empty_manager() { 156 | let manager = FileManager::new(); 157 | let files = manager.get_files(); 158 | assert!(files.is_empty()); 159 | } 160 | 161 | #[test] 162 | fn test_filter_active_behavior() { 163 | let mut manager = FileManager::new(); 164 | let files = vec![ 165 | create_test_media_file("file1.jpg", 1000), 166 | create_test_media_file("file2.jpg", 2000), 167 | create_test_media_file("file3.jpg", 3000), 168 | ]; 169 | 170 | manager.set_files(files); 171 | 172 | // When filter is not active, should return all files 173 | assert_eq!(manager.get_file_count(), 3); 174 | assert_eq!(manager.get_files().len(), 3); 175 | 176 | // When filter is active, should return filtered files 177 | // Since set_files makes filtered_files = files, should still be 3 178 | assert_eq!(manager.get_file_count(), 3); 179 | assert_eq!(manager.get_files().len(), 3); 180 | } 181 | 182 | #[test] 183 | fn test_set_files_resets_filter() { 184 | let mut manager = FileManager::new(); 185 | 186 | // Set initial files and activate filter 187 | let files = vec![create_test_media_file("file1.jpg", 1000)]; 188 | manager.set_files(files); 189 | 190 | // Set new files 191 | let new_files = vec![ 192 | create_test_media_file("new1.jpg", 2000), 193 | create_test_media_file("new2.jpg", 3000), 194 | ]; 195 | manager.set_files(new_files); 196 | } 197 | 198 | #[test] 199 | #[allow(clippy::cast_sign_loss)] 200 | fn test_arc_cloning_efficiency() { 201 | let mut manager = FileManager::new(); 202 | let large_file_list: Vec> = (0..1000) 203 | .map(|i| create_test_media_file(&format!("file{i}.jpg"), i as u64 * 1000)) 204 | .collect(); 205 | 206 | manager.set_files(large_file_list); 207 | 208 | // Getting files multiple times should be efficient due to Arc 209 | let start = std::time::Instant::now(); 210 | for _ in 0..1000 { 211 | let _ = manager.get_files(); 212 | } 213 | let duration = start.elapsed(); 214 | 215 | // This should be very fast due to Arc cloning 216 | assert!(duration.as_millis() < 100, "Arc cloning should be fast"); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod cache; 2 | mod database_cache; 3 | mod duplicate_detector; 4 | mod file_manager; 5 | mod organizer; 6 | mod scanner; 7 | mod undo_manager; 8 | 9 | pub use cache::Cache; 10 | pub use database_cache::DatabaseCache; 11 | pub use duplicate_detector::DuplicateDetector; 12 | pub use file_manager::FileManager; 13 | pub use organizer::FileOrganizer; 14 | pub use scanner::Scanner; 15 | pub use undo_manager::UndoManager; 16 | -------------------------------------------------------------------------------- /crates/models/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "visualvault-models" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | 8 | [dependencies] 9 | chrono = { workspace = true } 10 | serde = { workspace = true } 11 | smallvec = { workspace = true } 12 | ahash = { workspace = true } 13 | regex = { workspace = true } 14 | serde_json = { workspace = true } 15 | -------------------------------------------------------------------------------- /crates/models/src/duplicate.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use smallvec::SmallVec; 4 | 5 | use crate::media_file::MediaFile; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct DuplicateGroup { 9 | pub files: SmallVec<[Arc; 4]>, 10 | pub wasted_space: u64, // Size that could be saved by keeping only one copy 11 | } 12 | 13 | impl DuplicateGroup { 14 | #[allow(dead_code)] 15 | #[must_use] 16 | pub fn new(files: impl Into; 4]>>, wasted_space: u64) -> Self { 17 | Self { 18 | files: files.into(), 19 | wasted_space, 20 | } 21 | } 22 | } 23 | 24 | #[derive(Debug, Clone, Default)] 25 | pub struct DuplicateStats { 26 | pub total_groups: usize, 27 | pub total_duplicates: usize, 28 | pub total_wasted_space: u64, 29 | pub groups: Vec, 30 | } 31 | 32 | impl DuplicateStats { 33 | #[must_use] 34 | pub fn new() -> Self { 35 | Self::default() 36 | } 37 | 38 | #[allow(dead_code)] 39 | #[must_use] 40 | pub fn get_by_hash(&self, hash: &str) -> Option<&DuplicateGroup> { 41 | self.groups.iter().find(|g| g.files[0].hash.as_deref() == Some(hash)) 42 | } 43 | 44 | #[must_use] 45 | pub fn len(&self) -> usize { 46 | self.groups.len() 47 | } 48 | 49 | #[must_use] 50 | pub fn is_empty(&self) -> bool { 51 | self.groups.is_empty() 52 | } 53 | 54 | #[must_use] 55 | pub fn total_size(&self) -> u64 { 56 | self.groups.iter().map(|g| g.wasted_space).sum() 57 | } 58 | 59 | #[must_use] 60 | pub fn total_files(&self) -> usize { 61 | self.groups.iter().map(|g| g.files.len()).sum() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/models/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod duplicate; 2 | pub mod filters; 3 | mod media_file; 4 | mod state; 5 | mod statistics; 6 | 7 | pub use duplicate::{DuplicateGroup, DuplicateStats}; 8 | pub use filters::FilterSet; 9 | pub use media_file::{FileType, ImageMetadata, MediaFile, MediaMetadata}; 10 | pub use state::{AppState, DuplicateFocus, EditingField, FilterFocus, InputMode, OrganizeResult, ScanResult}; 11 | pub use statistics::Statistics; 12 | -------------------------------------------------------------------------------- /crates/models/src/state.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use chrono::{DateTime, Local}; 4 | 5 | #[derive(Debug, Clone, PartialEq)] 6 | pub enum AppState { 7 | Dashboard, 8 | Settings, 9 | Scanning, 10 | Organizing, 11 | Search, 12 | FileDetails(usize), 13 | DuplicateReview, 14 | Filters, 15 | } 16 | 17 | #[derive(Debug, Clone, PartialEq)] 18 | pub enum InputMode { 19 | Normal, 20 | Insert, 21 | Editing, 22 | } 23 | 24 | #[derive(Debug, Clone, PartialEq)] 25 | pub enum EditingField { 26 | SourceFolder, 27 | DestinationFolder, 28 | WorkerThreads, 29 | BufferSize, 30 | } 31 | 32 | #[derive(Debug, Clone, Copy, PartialEq)] 33 | pub enum DuplicateFocus { 34 | GroupList, 35 | FileList, 36 | } 37 | 38 | #[derive(Debug, Clone)] 39 | pub struct ScanResult { 40 | pub files_found: usize, 41 | pub duration: std::time::Duration, 42 | pub timestamp: DateTime, 43 | } 44 | 45 | #[derive(Debug, Clone)] 46 | pub struct OrganizeResult { 47 | pub files_organized: usize, 48 | pub files_total: usize, 49 | pub destination: PathBuf, 50 | pub success: bool, 51 | pub timestamp: DateTime, 52 | pub skipped_duplicates: usize, 53 | pub errors: Vec, 54 | } 55 | 56 | #[derive(Debug, Clone, Copy, PartialEq)] 57 | pub enum FilterFocus { 58 | DateRange, 59 | SizeRange, 60 | MediaType, 61 | RegexPattern, 62 | } 63 | -------------------------------------------------------------------------------- /crates/ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "visualvault-ui" 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-app = { workspace = true } 12 | visualvault-config = { workspace = true } 13 | ahash = { workspace = true } 14 | color-eyre = { workspace = true } 15 | chrono = { workspace = true } 16 | num_cpus = { workspace = true } 17 | dirs = { workspace = true } 18 | crossterm = { workspace = true } 19 | ratatui = { workspace = true } 20 | tracing = { workspace = true } 21 | -------------------------------------------------------------------------------- /crates/ui/src/duplicate_detector.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::{Line, Span}, 6 | widgets::{Block, Borders, List, ListItem, Paragraph, Row, Table}, 7 | }; 8 | use visualvault_app::App; 9 | use visualvault_models::{DuplicateFocus, DuplicateGroup, DuplicateStats}; 10 | use visualvault_utils::format_bytes; 11 | 12 | pub fn draw(f: &mut Frame, area: Rect, app: &App) { 13 | // Remove the header since it's now handled by the main UI 14 | let chunks = Layout::default() 15 | .direction(Direction::Vertical) 16 | .constraints([ 17 | Constraint::Length(5), // Stats 18 | Constraint::Min(10), // Duplicate groups 19 | Constraint::Length(3), // Help 20 | ]) 21 | .split(area); 22 | 23 | // Stats section 24 | if let Some(stats) = &app.duplicate_stats { 25 | draw_stats(f, chunks[0], stats); 26 | draw_duplicate_groups(f, chunks[1], stats, app); 27 | } else { 28 | draw_no_scan(f, chunks[0]); 29 | } 30 | 31 | // Help section 32 | draw_help(f, chunks[2]); 33 | } 34 | 35 | fn draw_stats(f: &mut Frame, area: Rect, stats: &DuplicateStats) { 36 | let stats_chunks = Layout::default() 37 | .direction(Direction::Horizontal) 38 | .constraints([ 39 | Constraint::Percentage(33), 40 | Constraint::Percentage(33), 41 | Constraint::Percentage(34), 42 | ]) 43 | .split(area); 44 | 45 | // Total groups 46 | let groups = Paragraph::new(vec![ 47 | Line::from("Duplicate Groups"), 48 | Line::from(vec![Span::styled( 49 | stats.total_groups.to_string(), 50 | Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 51 | )]), 52 | ]) 53 | .alignment(Alignment::Center) 54 | .block( 55 | Block::default() 56 | .borders(Borders::ALL) 57 | .border_style(Style::default().fg(Color::Gray)), 58 | ); 59 | f.render_widget(groups, stats_chunks[0]); 60 | 61 | // Total duplicates 62 | let duplicates = Paragraph::new(vec![ 63 | Line::from("Total Duplicates"), 64 | Line::from(vec![Span::styled( 65 | stats.total_duplicates.to_string(), 66 | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), 67 | )]), 68 | ]) 69 | .alignment(Alignment::Center) 70 | .block( 71 | Block::default() 72 | .borders(Borders::ALL) 73 | .border_style(Style::default().fg(Color::Gray)), 74 | ); 75 | f.render_widget(duplicates, stats_chunks[1]); 76 | 77 | // Wasted space 78 | let wasted = Paragraph::new(vec![ 79 | Line::from("Wasted Space"), 80 | Line::from(vec![Span::styled( 81 | format_bytes(stats.total_wasted_space), 82 | Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), 83 | )]), 84 | ]) 85 | .alignment(Alignment::Center) 86 | .block( 87 | Block::default() 88 | .borders(Borders::ALL) 89 | .border_style(Style::default().fg(Color::Gray)), 90 | ); 91 | f.render_widget(wasted, stats_chunks[2]); 92 | } 93 | 94 | fn draw_duplicate_groups(f: &mut Frame, area: Rect, stats: &DuplicateStats, app: &App) { 95 | let chunks = Layout::default() 96 | .direction(Direction::Horizontal) 97 | .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) 98 | .split(area); 99 | 100 | // Left: Group list 101 | let items: Vec = stats 102 | .groups 103 | .iter() 104 | .enumerate() 105 | .map(|(idx, group)| { 106 | let selected = app.selected_duplicate_group == idx; 107 | let style = if selected { 108 | Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) 109 | } else { 110 | Style::default() 111 | }; 112 | 113 | ListItem::new(vec![ 114 | Line::from(vec![ 115 | Span::raw(format!("{} files, ", group.files.len())), 116 | Span::styled(format_bytes(group.wasted_space), Style::default().fg(Color::Red)), 117 | Span::raw(" wasted"), 118 | ]), 119 | Line::from(vec![Span::styled( 120 | &*group.files[0].name, 121 | Style::default().fg(Color::Gray), 122 | )]), 123 | ]) 124 | .style(style) 125 | }) 126 | .collect(); 127 | 128 | let list = List::new(items) 129 | .block( 130 | Block::default() 131 | .title(if app.duplicate_focus == DuplicateFocus::GroupList { 132 | " Duplicate Groups [ACTIVE] " 133 | } else { 134 | " Duplicate Groups " 135 | }) 136 | .borders(Borders::ALL) 137 | .border_style(if app.duplicate_focus == DuplicateFocus::GroupList { 138 | Style::default().fg(Color::Yellow) 139 | } else { 140 | Style::default().fg(Color::Gray) 141 | }), 142 | ) 143 | .highlight_style(Style::default().bg(Color::DarkGray)); 144 | 145 | f.render_stateful_widget(list, chunks[0], &mut app.duplicate_list_state.clone()); 146 | 147 | // Right: Selected group details 148 | if let Some(group) = stats.groups.get(app.selected_duplicate_group) { 149 | draw_group_details(f, chunks[1], group, app); 150 | } 151 | } 152 | 153 | fn draw_group_details(f: &mut Frame, area: Rect, group: &DuplicateGroup, app: &App) { 154 | let rows: Vec = group 155 | .files 156 | .iter() 157 | .enumerate() 158 | .map(|(idx, file)| { 159 | let selected = app.selected_duplicate_items.contains(&idx); 160 | let checkbox = if selected { "☑" } else { "☐" }; 161 | let path = truncate_path(&file.path.display().to_string(), 40); 162 | 163 | // Highlight the currently focused file when in FileList focus 164 | let is_focused = app.duplicate_focus == DuplicateFocus::FileList && idx == app.selected_file_in_group; 165 | 166 | Row::new(vec![ 167 | checkbox.to_string(), 168 | file.name.to_string(), 169 | format_bytes(file.size), 170 | path, 171 | ]) 172 | .style(if selected { 173 | Style::default().fg(Color::Red) 174 | } else if is_focused { 175 | Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) 176 | } else { 177 | Style::default() 178 | }) 179 | }) 180 | .collect(); 181 | 182 | let table = Table::new( 183 | rows, 184 | [ 185 | Constraint::Length(3), 186 | Constraint::Percentage(30), 187 | Constraint::Length(10), 188 | Constraint::Percentage(50), 189 | ], 190 | ) 191 | .header(Row::new(vec!["", "Name", "Size", "Path"]).style(Style::default().add_modifier(Modifier::BOLD))) 192 | .block( 193 | Block::default() 194 | .title(if app.duplicate_focus == DuplicateFocus::FileList { 195 | " Files in Group (Space to select) [ACTIVE] " 196 | } else { 197 | " Files in Group (Space to select) " 198 | }) 199 | .borders(Borders::ALL) 200 | .border_style(if app.duplicate_focus == DuplicateFocus::FileList { 201 | Style::default().fg(Color::Yellow) 202 | } else { 203 | Style::default().fg(Color::Gray) 204 | }), 205 | ) 206 | .row_highlight_style(Style::default().bg(Color::DarkGray)); 207 | 208 | f.render_widget(table, area); 209 | } 210 | 211 | fn truncate_path(path: &str, max_width: usize) -> String { 212 | if path.len() <= max_width { 213 | path.to_string() 214 | } else if max_width > 3 { 215 | format!("...{}", &path[path.len() - (max_width - 3)..]) 216 | } else { 217 | "...".to_string() 218 | } 219 | } 220 | 221 | fn draw_no_scan(f: &mut Frame, area: Rect) { 222 | let message = Paragraph::new(vec![ 223 | Line::from(""), 224 | Line::from("No duplicate scan performed yet."), 225 | Line::from(""), 226 | Line::from("Press 's' to start scanning for duplicates."), 227 | ]) 228 | .style(Style::default().fg(Color::Gray)) 229 | .alignment(Alignment::Center) 230 | .block( 231 | Block::default() 232 | .borders(Borders::ALL) 233 | .border_style(Style::default().fg(Color::Gray)), 234 | ); 235 | 236 | f.render_widget(message, area); 237 | } 238 | 239 | fn draw_help(f: &mut Frame, area: Rect) { 240 | let help_text = vec![Line::from(vec![ 241 | Span::styled("s", Style::default().fg(Color::Yellow)), 242 | Span::raw(" - Scan | "), 243 | Span::styled("↑↓", Style::default().fg(Color::Yellow)), 244 | Span::raw(" - Navigate | "), 245 | Span::styled("←→", Style::default().fg(Color::Yellow)), 246 | Span::raw(" - Switch panes | "), 247 | Span::styled("Space", Style::default().fg(Color::Yellow)), 248 | Span::raw(" - Select | "), 249 | Span::styled("a", Style::default().fg(Color::Yellow)), 250 | Span::raw(" - Select all but first | "), 251 | Span::styled("d", Style::default().fg(Color::Red)), 252 | Span::raw(" - Delete selected | "), 253 | Span::styled("D", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), 254 | Span::raw(" - DELETE ALL DUPLICATES | "), 255 | Span::styled("Esc", Style::default().fg(Color::Yellow)), 256 | Span::raw(" - Back"), 257 | ])]; 258 | 259 | let help = Paragraph::new(help_text).alignment(Alignment::Center).block( 260 | Block::default() 261 | .borders(Borders::ALL) 262 | .border_style(Style::default().fg(Color::DarkGray)), 263 | ); 264 | 265 | f.render_widget(help, area); 266 | } 267 | -------------------------------------------------------------------------------- /crates/ui/src/file_details.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::{Line, Span}, 6 | widgets::{Block, Borders, Clear, Paragraph, Row, Table}, 7 | }; 8 | use tracing::info; 9 | use visualvault_models::{FileType, MediaFile, MediaMetadata}; 10 | use visualvault_utils::format_bytes; 11 | 12 | #[allow(clippy::too_many_lines)] 13 | pub fn draw_modal(f: &mut Frame, file: &MediaFile) { 14 | let area = centered_rect(70, 80, f.area()); 15 | 16 | // Clear the area first 17 | f.render_widget(Clear, area); 18 | 19 | // Create the main layout 20 | let chunks = Layout::default() 21 | .direction(Direction::Vertical) 22 | .margin(1) 23 | .constraints([ 24 | Constraint::Length(3), // Title 25 | Constraint::Length(10), // Basic info 26 | Constraint::Length(8), // File system info 27 | Constraint::Min(5), // Metadata (if available) 28 | Constraint::Length(3), // Help text 29 | ]) 30 | .split(area); 31 | 32 | // Main block 33 | let block = Block::default() 34 | .title(" File Details ") 35 | .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) 36 | .borders(Borders::ALL) 37 | .border_style(Style::default().fg(Color::Cyan)) 38 | .style(Style::default().bg(Color::Rgb(20, 20, 30))); 39 | 40 | f.render_widget(block, area); 41 | 42 | // Title with file icon 43 | let icon = match file.file_type { 44 | FileType::Image => "🖼️", 45 | FileType::Video => "🎬", 46 | FileType::Document => "📄", 47 | FileType::Other => "📎", 48 | }; 49 | 50 | let title = Paragraph::new(vec![Line::from(vec![ 51 | Span::raw(format!("{icon} ")), 52 | Span::styled( 53 | &*file.name, 54 | Style::default().fg(Color::White).add_modifier(Modifier::BOLD), 55 | ), 56 | ])]) 57 | .alignment(Alignment::Center); 58 | 59 | f.render_widget(title, chunks[0]); 60 | 61 | // Basic information table 62 | let file_type = file.file_type.to_string(); 63 | let size = format_bytes(file.size); 64 | let created = file.created.format("%Y-%m-%d %H:%M:%S").to_string(); 65 | let modified = file.modified.format("%Y-%m-%d %H:%M:%S").to_string(); 66 | let basic_info = vec![ 67 | Row::new(vec!["Type", &file_type]), 68 | Row::new(vec!["Size", &size]), 69 | Row::new(vec!["Extension", &file.extension]), 70 | Row::new(vec!["Created", &created]), 71 | Row::new(vec!["Modified", &modified]), 72 | ]; 73 | 74 | let basic_table = Table::new(basic_info, [Constraint::Percentage(30), Constraint::Percentage(70)]) 75 | .block( 76 | Block::default() 77 | .title(" Basic Information ") 78 | .borders(Borders::ALL) 79 | .border_style(Style::default().fg(Color::Gray)), 80 | ) 81 | .row_highlight_style(Style::default().fg(Color::Yellow)) 82 | .column_spacing(2); 83 | 84 | f.render_widget(basic_table, chunks[1]); 85 | 86 | // File system information 87 | let full_path = file.path.display().to_string(); 88 | let parent = file 89 | .path 90 | .parent() 91 | .map_or_else(|| "N/A".to_string(), |p| p.display().to_string()); 92 | #[cfg(unix)] 93 | let permissions = { 94 | use std::os::unix::fs::PermissionsExt; 95 | std::fs::metadata(&file.path).ok().map_or_else( 96 | || "Unknown".to_string(), 97 | |m| format!("{:o}", m.permissions().mode() & 0o777), 98 | ) 99 | }; 100 | 101 | #[cfg(not(unix))] 102 | let permissions = { 103 | // On Windows, check if file is read-only 104 | std::fs::metadata(&file.path) 105 | .ok() 106 | .map(|m| { 107 | if m.permissions().readonly() { 108 | "Read-only".to_string() 109 | } else { 110 | "Read/Write".to_string() 111 | } 112 | }) 113 | .unwrap_or_else(|| "Unknown".to_string()) 114 | }; 115 | 116 | let fs_info = vec![ 117 | Row::new(vec!["Full Path", &full_path]), 118 | Row::new(vec!["Directory", &parent]), 119 | Row::new(vec!["Permissions", &permissions]), 120 | ]; 121 | 122 | let fs_table = Table::new(fs_info, [Constraint::Percentage(30), Constraint::Percentage(70)]) 123 | .block( 124 | Block::default() 125 | .title(" File System ") 126 | .borders(Borders::ALL) 127 | .border_style(Style::default().fg(Color::Gray)), 128 | ) 129 | .column_spacing(2); 130 | 131 | f.render_widget(fs_table, chunks[2]); 132 | 133 | info!("Metadata section (for images): {}", &file.metadata.is_some()); 134 | 135 | // Metadata section (for images) 136 | if file.file_type == FileType::Image { 137 | if let Some(MediaMetadata::Image(metadata)) = &file.metadata { 138 | let metadata_text = vec![ 139 | Line::from(format!("Width: {} px", metadata.width)), 140 | Line::from(format!("Height: {} px", metadata.height)), 141 | Line::from(format!("Format: {}", metadata.format)), 142 | Line::from(format!("Color Type: {}", metadata.color_type)), 143 | ]; 144 | 145 | let metadata_paragraph = Paragraph::new(metadata_text) 146 | .block( 147 | Block::default() 148 | .title(" Image Metadata ") 149 | .borders(Borders::ALL) 150 | .border_style(Style::default().fg(Color::Gray)), 151 | ) 152 | .alignment(Alignment::Left); 153 | 154 | f.render_widget(metadata_paragraph, chunks[3]); 155 | } else { 156 | let no_metadata = Paragraph::new("No image metadata available") 157 | .block( 158 | Block::default() 159 | .title(" Image Metadata ") 160 | .borders(Borders::ALL) 161 | .border_style(Style::default().fg(Color::Gray)), 162 | ) 163 | .alignment(Alignment::Center); 164 | 165 | f.render_widget(no_metadata, chunks[3]); 166 | } 167 | } else { 168 | // For non-images, show file content preview or other relevant info 169 | let preview = Paragraph::new("No additional metadata available for this file type") 170 | .block( 171 | Block::default() 172 | .title(" Additional Information ") 173 | .borders(Borders::ALL) 174 | .border_style(Style::default().fg(Color::Gray)), 175 | ) 176 | .alignment(Alignment::Center); 177 | 178 | f.render_widget(preview, chunks[3]); 179 | } 180 | 181 | // Help text 182 | let help = Paragraph::new(vec![Line::from(vec![ 183 | Span::styled("ESC", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 184 | Span::raw(" or "), 185 | Span::styled("q", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 186 | Span::raw(" to close"), 187 | ])]) 188 | .alignment(Alignment::Center) 189 | .style(Style::default().fg(Color::Rgb(150, 150, 150))); 190 | 191 | f.render_widget(help, chunks[4]); 192 | } 193 | 194 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 195 | let popup_layout = Layout::default() 196 | .direction(Direction::Vertical) 197 | .constraints([ 198 | Constraint::Percentage((100 - percent_y) / 2), 199 | Constraint::Percentage(percent_y), 200 | Constraint::Percentage((100 - percent_y) / 2), 201 | ]) 202 | .split(r); 203 | 204 | Layout::default() 205 | .direction(Direction::Horizontal) 206 | .constraints([ 207 | Constraint::Percentage((100 - percent_x) / 2), 208 | Constraint::Percentage(percent_x), 209 | Constraint::Percentage((100 - percent_x) / 2), 210 | ]) 211 | .split(popup_layout[1])[1] 212 | } 213 | -------------------------------------------------------------------------------- /crates/ui/src/progress.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::{Line, Span}, 6 | widgets::{Block, Borders, Clear, Gauge, Paragraph}, 7 | }; 8 | 9 | use visualvault_app::App; 10 | use visualvault_models::AppState; 11 | 12 | #[allow(clippy::significant_drop_tightening)] 13 | pub fn draw_progress_overlay(f: &mut Frame, app: &App) { 14 | // Get progress data 15 | let Ok(progress) = app.progress.try_read() else { return }; 16 | 17 | // Create centered overlay area 18 | let area = centered_rect(60, 30, f.area()); 19 | 20 | // Clear the area for the overlay 21 | f.render_widget(Clear, area); 22 | 23 | // Create layout for progress components 24 | let chunks = Layout::default() 25 | .direction(Direction::Vertical) 26 | .margin(2) 27 | .constraints([ 28 | Constraint::Length(3), // Title 29 | Constraint::Length(3), // Progress bar 30 | Constraint::Length(2), // Stats 31 | Constraint::Length(2), // Message 32 | Constraint::Length(2), // Time info 33 | ]) 34 | .split(area); 35 | 36 | // Main block with border 37 | let block = Block::default() 38 | .title(" Operation Progress ") 39 | .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) 40 | .borders(Borders::ALL) 41 | .border_style(Style::default().fg(Color::Cyan)) 42 | .style(Style::default().bg(Color::Rgb(20, 20, 30))); 43 | 44 | f.render_widget(block, area); 45 | 46 | // Title with operation icon 47 | let (icon, operation) = match app.state { 48 | AppState::Scanning => ("🔍", "Scanning Files"), 49 | AppState::Organizing => ("📁", "Organizing Files"), 50 | _ => ("⏳", "Processing"), 51 | }; 52 | 53 | let title = Paragraph::new(vec![Line::from(vec![Span::styled( 54 | format!("{icon} {operation}"), 55 | Style::default().fg(Color::White).add_modifier(Modifier::BOLD), 56 | )])]) 57 | .alignment(Alignment::Center); 58 | 59 | f.render_widget(title, chunks[0]); 60 | 61 | // Progress bar 62 | let percentage = progress.percentage(); 63 | let label = if progress.total > 0 { 64 | format!("{percentage:.0}%") 65 | } else { 66 | "Calculating...".to_string() 67 | }; 68 | 69 | #[allow(clippy::cast_precision_loss)] 70 | #[allow(clippy::cast_possible_truncation)] 71 | #[allow(clippy::cast_sign_loss)] 72 | let gauge = Gauge::default() 73 | .block(Block::default().borders(Borders::NONE)) 74 | .gauge_style(Style::default().fg(Color::Cyan).bg(Color::Rgb(40, 40, 40))) 75 | .percent(percentage as u16) 76 | .label(label) 77 | .use_unicode(true); 78 | 79 | f.render_widget(gauge, chunks[1]); 80 | 81 | // Statistics 82 | let stats_text = if progress.total > 0 { 83 | format!("{} / {} items", progress.current, progress.total) 84 | } else { 85 | format!("{} items processed", progress.current) 86 | }; 87 | 88 | let stats = Paragraph::new(vec![Line::from(vec![Span::styled( 89 | stats_text, 90 | Style::default().fg(Color::Yellow), 91 | )])]) 92 | .alignment(Alignment::Center); 93 | 94 | f.render_widget(stats, chunks[2]); 95 | 96 | // Current message 97 | if !progress.message.is_empty() { 98 | let message = Paragraph::new(vec![Line::from(vec![Span::styled( 99 | &progress.message, 100 | Style::default() 101 | .fg(Color::Rgb(150, 150, 150)) 102 | .add_modifier(Modifier::ITALIC), 103 | )])]) 104 | .alignment(Alignment::Center); 105 | 106 | f.render_widget(message, chunks[3]); 107 | } 108 | 109 | // Time information 110 | let elapsed = progress.elapsed(); 111 | let time_info = if let Some(eta) = progress.eta() { 112 | format!("Elapsed: {} | ETA: {}", format_duration(elapsed), format_duration(eta)) 113 | } else { 114 | format!("Elapsed: {}", format_duration(elapsed)) 115 | }; 116 | 117 | let time_paragraph = Paragraph::new(vec![Line::from(vec![Span::styled( 118 | time_info, 119 | Style::default().fg(Color::Green), 120 | )])]) 121 | .alignment(Alignment::Center); 122 | 123 | f.render_widget(time_paragraph, chunks[4]); 124 | } 125 | 126 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 127 | let popup_layout = Layout::default() 128 | .direction(Direction::Vertical) 129 | .constraints([ 130 | Constraint::Percentage((100 - percent_y) / 2), 131 | Constraint::Percentage(percent_y), 132 | Constraint::Percentage((100 - percent_y) / 2), 133 | ]) 134 | .split(r); 135 | 136 | Layout::default() 137 | .direction(Direction::Horizontal) 138 | .constraints([ 139 | Constraint::Percentage((100 - percent_x) / 2), 140 | Constraint::Percentage(percent_x), 141 | Constraint::Percentage((100 - percent_x) / 2), 142 | ]) 143 | .split(popup_layout[1])[1] 144 | } 145 | 146 | fn format_duration(duration: std::time::Duration) -> String { 147 | let secs = duration.as_secs(); 148 | if secs < 60 { 149 | format!("{secs}s") 150 | } else if secs < 3600 { 151 | format!("{}m {}s", secs / 60, secs % 60) 152 | } else { 153 | format!("{}h {}m", secs / 3600, (secs % 3600) / 60) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /crates/ui/src/search.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::{Line, Span}, 6 | widgets::{Block, Borders, Cell, Paragraph, Row, Table}, 7 | }; 8 | 9 | use visualvault_app::App; 10 | use visualvault_models::FileType; 11 | use visualvault_models::InputMode; 12 | use visualvault_utils::format_bytes; 13 | 14 | pub fn draw(f: &mut Frame, area: Rect, app: &App) { 15 | // Create main layout 16 | let chunks = Layout::default() 17 | .direction(Direction::Vertical) 18 | .constraints([ 19 | Constraint::Length(3), // Search bar 20 | Constraint::Min(10), // Results 21 | Constraint::Length(3), // Status bar 22 | ]) 23 | .split(area); 24 | 25 | // Draw search input 26 | draw_search_bar(f, chunks[0], app); 27 | 28 | // Draw search results 29 | draw_search_results(f, chunks[1], app); 30 | 31 | // Draw search status 32 | draw_search_status(f, chunks[2], app); 33 | } 34 | 35 | fn draw_search_bar(f: &mut Frame, area: Rect, app: &App) { 36 | let input_style = if app.input_mode == InputMode::Insert { 37 | Style::default().fg(Color::Yellow) 38 | } else { 39 | Style::default().fg(Color::White) 40 | }; 41 | 42 | let input = Paragraph::new(app.search_input.as_str()).style(input_style).block( 43 | Block::default() 44 | .borders(Borders::ALL) 45 | .border_style(if app.input_mode == InputMode::Insert { 46 | Style::default().fg(Color::Yellow) 47 | } else { 48 | Style::default().fg(Color::Gray) 49 | }) 50 | .title(" Search Files ") 51 | .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), 52 | ); 53 | 54 | f.render_widget(input, area); 55 | 56 | // Show cursor when in insert mode 57 | if app.input_mode == InputMode::Insert { 58 | f.set_cursor_position(( 59 | area.x + u16::try_from(app.search_input.len()).unwrap_or_default() + 1, 60 | area.y + 1, 61 | )); 62 | } 63 | } 64 | 65 | fn draw_search_results(f: &mut Frame, area: Rect, app: &App) { 66 | if app.search_results.is_empty() && !app.search_input.is_empty() { 67 | // No results found 68 | let no_results = Paragraph::new(vec![ 69 | Line::from(""), 70 | Line::from(vec![Span::styled( 71 | "No files found matching your search criteria", 72 | Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC), 73 | )]), 74 | Line::from(""), 75 | Line::from(vec![Span::raw("Try adjusting your search terms")]), 76 | ]) 77 | .block( 78 | Block::default() 79 | .title(" Search Results ") 80 | .borders(Borders::ALL) 81 | .border_style(Style::default().fg(Color::Gray)), 82 | ) 83 | .alignment(Alignment::Center); 84 | 85 | f.render_widget(no_results, area); 86 | return; 87 | } 88 | 89 | if app.search_results.is_empty() { 90 | // Initial state - no search performed 91 | let help_text = vec![ 92 | Line::from(""), 93 | Line::from(vec![Span::styled( 94 | "🔍 Search for files", 95 | Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), 96 | )]), 97 | Line::from(""), 98 | Line::from("• Press Enter to start typing"), 99 | Line::from("• Type to search file names"), 100 | Line::from("• Search is case-insensitive"), 101 | Line::from(""), 102 | Line::from(vec![Span::styled( 103 | "Tips:", 104 | Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 105 | )]), 106 | Line::from(" - Search updates as you type"), 107 | Line::from(" - Use partial names to find files"), 108 | Line::from(" - Press ESC to clear search"), 109 | ]; 110 | 111 | let help = Paragraph::new(help_text) 112 | .block( 113 | Block::default() 114 | .title(" Search Results ") 115 | .borders(Borders::ALL) 116 | .border_style(Style::default().fg(Color::Gray)), 117 | ) 118 | .alignment(Alignment::Center); 119 | 120 | f.render_widget(help, area); 121 | return; 122 | } 123 | 124 | // Display search results as a table 125 | let header = Row::new(vec!["Name", "Type", "Size", "Modified", "Path"]) 126 | .style(Style::default().add_modifier(Modifier::BOLD)) 127 | .height(1) 128 | .bottom_margin(1); 129 | 130 | let rows: Vec = app 131 | .search_results 132 | .iter() 133 | .skip(app.scroll_offset) 134 | .take(area.height.saturating_sub(4) as usize) 135 | .map(|file| { 136 | Row::new(vec![ 137 | Cell::from(&*file.name), 138 | Cell::from(file.file_type.to_string()).style(Style::default().fg(get_type_color(&file.file_type))), 139 | Cell::from(format_bytes(file.size)), 140 | Cell::from(file.modified.format("%Y-%m-%d").to_string()), 141 | Cell::from(file.path.parent().map(|p| p.display().to_string()).unwrap_or_default()), 142 | ]) 143 | }) 144 | .collect(); 145 | 146 | let table = Table::new( 147 | rows, 148 | [ 149 | Constraint::Percentage(25), 150 | Constraint::Percentage(10), 151 | Constraint::Percentage(15), 152 | Constraint::Percentage(15), 153 | Constraint::Percentage(35), 154 | ], 155 | ) 156 | .header(header) 157 | .block( 158 | Block::default() 159 | .title(format!(" Search Results ({}) ", app.search_results.len())) 160 | .borders(Borders::ALL) 161 | .border_style(Style::default().fg(Color::Gray)), 162 | ); 163 | 164 | f.render_widget(table, area); 165 | } 166 | 167 | fn draw_search_status(f: &mut Frame, area: Rect, app: &App) { 168 | let status_text = if app.input_mode == InputMode::Insert { 169 | "Press ESC to stop editing | Enter to search" 170 | } else if !app.search_results.is_empty() { 171 | "Enter: View details | ↑↓: Navigate | /: New search | ESC: Back" 172 | } else { 173 | "Press Enter to start searching | ESC to go back" 174 | }; 175 | 176 | let status = Paragraph::new(status_text) 177 | .style(Style::default().fg(Color::Rgb(150, 150, 150))) 178 | .block( 179 | Block::default() 180 | .borders(Borders::ALL) 181 | .border_style(Style::default().fg(Color::Rgb(60, 60, 60))), 182 | ) 183 | .alignment(Alignment::Center); 184 | 185 | f.render_widget(status, area); 186 | } 187 | 188 | const fn get_type_color(file_type: &FileType) -> Color { 189 | match file_type { 190 | FileType::Image => Color::Green, 191 | FileType::Video => Color::Blue, 192 | FileType::Document => Color::Yellow, 193 | FileType::Other => Color::Gray, 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /crates/utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "visualvault-utils" 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 | chrono = { workspace = true } 11 | regex = { workspace = true } 12 | tracing = { workspace = true } 13 | color-eyre = { workspace = true } 14 | dirs = { workspace = true } 15 | tokio = { workspace = true } 16 | -------------------------------------------------------------------------------- /crates/utils/src/bytes.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::cast_possible_truncation)] 2 | #[allow(clippy::cast_precision_loss)] 3 | #[allow(clippy::cast_sign_loss)] 4 | #[must_use] 5 | pub fn format_bytes(bytes: u64) -> String { 6 | const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; 7 | let mut size = bytes as f64; 8 | let mut unit_index = 0; 9 | 10 | while size >= 1024.0 && unit_index < UNITS.len() - 1 { 11 | size /= 1024.0; 12 | unit_index += 1; 13 | } 14 | 15 | if unit_index == 0 { 16 | format!("{} {}", size as u64, UNITS[unit_index]) 17 | } else { 18 | format!("{:.2} {}", size, UNITS[unit_index]) 19 | } 20 | } 21 | 22 | #[cfg(test)] 23 | mod tests { 24 | use super::*; 25 | 26 | #[test] 27 | fn test_format_bytes_zero() { 28 | assert_eq!(format_bytes(0), "0 B"); 29 | } 30 | 31 | #[test] 32 | fn test_format_bytes_bytes() { 33 | assert_eq!(format_bytes(1), "1 B"); 34 | assert_eq!(format_bytes(100), "100 B"); 35 | assert_eq!(format_bytes(1023), "1023 B"); 36 | } 37 | 38 | #[test] 39 | fn test_format_bytes_kilobytes() { 40 | assert_eq!(format_bytes(1024), "1.00 KB"); 41 | assert_eq!(format_bytes(1536), "1.50 KB"); 42 | assert_eq!(format_bytes(2048), "2.00 KB"); 43 | assert_eq!(format_bytes(1024 * 1023), "1023.00 KB"); 44 | } 45 | 46 | #[test] 47 | fn test_format_bytes_megabytes() { 48 | assert_eq!(format_bytes(1024 * 1024), "1.00 MB"); 49 | assert_eq!(format_bytes(1024 * 1024 * 2), "2.00 MB"); 50 | assert_eq!(format_bytes(1024 * 1024 * 10 + 1024 * 512), "10.50 MB"); 51 | assert_eq!(format_bytes(1024 * 1024 * 1023), "1023.00 MB"); 52 | } 53 | 54 | #[test] 55 | fn test_format_bytes_gigabytes() { 56 | assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB"); 57 | assert_eq!(format_bytes(1024 * 1024 * 1024 * 2), "2.00 GB"); 58 | assert_eq!(format_bytes(1024 * 1024 * 1024 * 5 + 1024 * 1024 * 512), "5.50 GB"); 59 | assert_eq!(format_bytes(1024 * 1024 * 1024 * 1023), "1023.00 GB"); 60 | } 61 | 62 | #[test] 63 | fn test_format_bytes_terabytes() { 64 | assert_eq!(format_bytes(1024u64.pow(4)), "1.00 TB"); 65 | assert_eq!(format_bytes(1024u64.pow(4) * 2), "2.00 TB"); 66 | assert_eq!(format_bytes(1024u64.pow(4) * 10), "10.00 TB"); 67 | assert_eq!(format_bytes(1024u64.pow(4) * 100), "100.00 TB"); 68 | } 69 | 70 | #[test] 71 | fn test_format_bytes_large_terabytes() { 72 | // Test values larger than 1024 TB (should still show as TB) 73 | assert_eq!(format_bytes(1024u64.pow(4) * 2048), "2048.00 TB"); 74 | assert_eq!(format_bytes(1024u64.pow(4) * 10000), "10000.00 TB"); 75 | } 76 | 77 | #[test] 78 | fn test_format_bytes_edge_cases() { 79 | // Just below threshold 80 | assert_eq!(format_bytes(1023), "1023 B"); 81 | assert_eq!(format_bytes(1024 * 1024 - 1), "1024.00 KB"); 82 | assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.00 MB"); 83 | 84 | // Exactly at threshold 85 | assert_eq!(format_bytes(1024), "1.00 KB"); 86 | assert_eq!(format_bytes(1024 * 1024), "1.00 MB"); 87 | assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB"); 88 | } 89 | 90 | #[test] 91 | fn test_format_bytes_precision() { 92 | // Test decimal precision 93 | assert_eq!(format_bytes(1024 + 51), "1.05 KB"); // 1.0498... rounds to 1.05 94 | assert_eq!(format_bytes(1024 + 102), "1.10 KB"); // 1.0996... rounds to 1.10 95 | assert_eq!(format_bytes(1024 * 1024 + 1024 * 256), "1.25 MB"); 96 | assert_eq!(format_bytes(1024 * 1024 + 1024 * 768), "1.75 MB"); 97 | } 98 | 99 | #[test] 100 | fn test_format_bytes_maximum_u64() { 101 | // Test with maximum u64 value 102 | assert_eq!(format_bytes(u64::MAX), "16777216.00 TB"); 103 | } 104 | 105 | #[test] 106 | fn test_format_bytes_common_file_sizes() { 107 | // Common file sizes 108 | assert_eq!(format_bytes(1024 * 100), "100.00 KB"); // Small document 109 | assert_eq!(format_bytes(1024 * 1024 * 5), "5.00 MB"); // Photo 110 | assert_eq!(format_bytes(1024 * 1024 * 700), "700.00 MB"); // CD size 111 | assert_eq!(format_bytes(1024 * 1024 * 4700), "4.59 GB"); // DVD size 112 | assert_eq!(format_bytes(1024u64.pow(3) * 25), "25.00 GB"); // Blu-ray 113 | } 114 | 115 | #[test] 116 | fn test_format_bytes_rounding() { 117 | // Test rounding behavior 118 | assert_eq!(format_bytes(1024 + 5), "1.00 KB"); // 1.0048... rounds to 1.00 119 | assert_eq!(format_bytes(1024 + 6), "1.01 KB"); // 1.0058... rounds to 1.01 120 | assert_eq!(format_bytes(1024 * 1024 * 1024 + 1024 * 1024 * 5), "1.00 GB"); // 1.0048... rounds to 1.00 121 | } 122 | 123 | #[test] 124 | fn test_format_bytes_incremental() { 125 | // Test smooth transitions between units 126 | let test_values = vec![ 127 | (1023, "1023 B"), 128 | (1024, "1.00 KB"), 129 | (1025, "1.00 KB"), 130 | (1024 * 1023, "1023.00 KB"), 131 | (1024 * 1024 - 1, "1024.00 KB"), 132 | (1024 * 1024, "1.00 MB"), 133 | (1024 * 1024 + 1, "1.00 MB"), 134 | ]; 135 | 136 | for (bytes, expected) in test_values { 137 | assert_eq!(format_bytes(bytes), expected); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /crates/utils/src/datetime.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use chrono::{DateTime, Utc}; 4 | 5 | #[allow(clippy::cast_possible_wrap)] 6 | #[must_use] 7 | pub fn system_time_to_datetime(time: std::io::Result) -> Option> { 8 | time.ok().and_then(|t| { 9 | t.duration_since(SystemTime::UNIX_EPOCH) 10 | .ok() 11 | .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())) 12 | }) 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | #![allow(clippy::unwrap_used)] 18 | #![allow(clippy::expect_used)] 19 | #![allow(clippy::float_cmp)] // For comparing floats in tests 20 | #![allow(clippy::panic)] 21 | use super::*; 22 | use std::io; 23 | use std::time::Duration; 24 | 25 | #[test] 26 | fn test_system_time_to_datetime_valid_time() { 27 | // Test with a valid system time 28 | let system_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000); 29 | let result = system_time_to_datetime(Ok(system_time)); 30 | 31 | assert!(result.is_some()); 32 | let datetime = result.unwrap(); 33 | 34 | // 1_000_000_000 seconds after UNIX_EPOCH is September 9, 2001 35 | assert_eq!(datetime.timestamp(), 1_000_000_000); 36 | assert_eq!(datetime.format("%Y-%m-%d").to_string(), "2001-09-09"); 37 | } 38 | 39 | #[test] 40 | fn test_system_time_to_datetime_unix_epoch() { 41 | // Test with UNIX_EPOCH itself 42 | let result = system_time_to_datetime(Ok(SystemTime::UNIX_EPOCH)); 43 | 44 | assert!(result.is_some()); 45 | let datetime = result.unwrap(); 46 | 47 | assert_eq!(datetime.timestamp(), 0); 48 | assert_eq!(datetime.format("%Y-%m-%d %H:%M:%S").to_string(), "1970-01-01 00:00:00"); 49 | } 50 | 51 | #[test] 52 | fn test_system_time_to_datetime_with_nanoseconds() { 53 | // Test with nanosecond precision 54 | let nanos = 123_456_789u32; 55 | let system_time = SystemTime::UNIX_EPOCH + Duration::new(1_500_000_000, nanos); 56 | let result = system_time_to_datetime(Ok(system_time)); 57 | 58 | assert!(result.is_some()); 59 | let datetime = result.unwrap(); 60 | 61 | assert_eq!(datetime.timestamp(), 1_500_000_000); 62 | assert_eq!(datetime.timestamp_subsec_nanos(), nanos); 63 | } 64 | 65 | #[test] 66 | fn test_system_time_to_datetime_io_error() { 67 | // Test with IO error 68 | let io_error = io::Error::other("test error"); 69 | let result = system_time_to_datetime(Err(io_error)); 70 | 71 | assert!(result.is_none()); 72 | } 73 | 74 | #[test] 75 | fn test_system_time_to_datetime_before_unix_epoch() { 76 | // Test with time before UNIX_EPOCH (should return None) 77 | let system_time = SystemTime::UNIX_EPOCH - Duration::from_secs(1); 78 | let result = system_time_to_datetime(Ok(system_time)); 79 | 80 | // This should return None because duration_since will fail 81 | assert!(result.is_none()); 82 | } 83 | 84 | #[test] 85 | fn test_system_time_to_datetime_current_time() { 86 | // Test with current time 87 | let now = SystemTime::now(); 88 | let result = system_time_to_datetime(Ok(now)); 89 | 90 | assert!(result.is_some()); 91 | let datetime = result.unwrap(); 92 | 93 | // Verify the time is recent (within last minute) 94 | let current_timestamp = Utc::now().timestamp(); 95 | assert!((datetime.timestamp() - current_timestamp).abs() < 60); 96 | } 97 | 98 | #[test] 99 | fn test_system_time_to_datetime_max_duration() { 100 | // Test with maximum safe duration 101 | // i64::MAX seconds is about 292 billion years 102 | let max_safe_seconds = i64::MAX as u64; 103 | let system_time = SystemTime::UNIX_EPOCH + Duration::from_secs(max_safe_seconds); 104 | let result = system_time_to_datetime(Ok(system_time)); 105 | 106 | // This might fail due to overflow in from_timestamp 107 | // The function should handle this gracefully 108 | if let Some(datetime) = result { 109 | assert!(datetime.timestamp() > 0); 110 | } 111 | } 112 | 113 | #[test] 114 | #[allow(clippy::cast_possible_wrap)] 115 | fn test_system_time_to_datetime_specific_dates() { 116 | // Test with specific known dates 117 | let test_cases = vec![ 118 | (946_684_800, "2000-01-01"), // Y2K 119 | (1_234_567_890, "2009-02-13"), // Unix time 1234567890 120 | (1_609_459_200, "2021-01-01"), // 2021 New Year 121 | (2_147_483_647, "2038-01-19"), // 32-bit signed int max (Y2038 problem) 122 | ]; 123 | 124 | for (timestamp, expected_date) in test_cases { 125 | let system_time = SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp); 126 | let result = system_time_to_datetime(Ok(system_time)); 127 | 128 | assert!(result.is_some()); 129 | let datetime = result.unwrap(); 130 | assert_eq!(datetime.timestamp(), timestamp as i64); 131 | assert_eq!(datetime.format("%Y-%m-%d").to_string(), expected_date); 132 | } 133 | } 134 | 135 | #[test] 136 | fn test_system_time_to_datetime_leap_second() { 137 | // Test around a leap second (though Rust/chrono might not handle actual leap seconds) 138 | let leap_second_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_483_228_799); 139 | let result = system_time_to_datetime(Ok(leap_second_time)); 140 | 141 | assert!(result.is_some()); 142 | let datetime = result.unwrap(); 143 | assert_eq!(datetime.format("%Y-%m-%d %H:%M:%S").to_string(), "2016-12-31 23:59:59"); 144 | } 145 | 146 | #[test] 147 | fn test_system_time_to_datetime_subsec_precision() { 148 | // Test various subsecond precisions 149 | let test_cases = vec![ 150 | (0, 0), // No subseconds 151 | (1, 1), // 1 nanosecond 152 | (1_000, 1_000), // 1 microsecond 153 | (1_000_000, 1_000_000), // 1 millisecond 154 | (999_999_999, 999_999_999), // Maximum nanoseconds 155 | ]; 156 | 157 | for (input_nanos, expected_nanos) in test_cases { 158 | let system_time = SystemTime::UNIX_EPOCH + Duration::new(1_000_000, input_nanos); 159 | let result = system_time_to_datetime(Ok(system_time)); 160 | 161 | assert!(result.is_some()); 162 | let datetime = result.unwrap(); 163 | assert_eq!(datetime.timestamp_subsec_nanos(), expected_nanos); 164 | } 165 | } 166 | 167 | #[test] 168 | fn test_system_time_to_datetime_io_error_kinds() { 169 | // Test various IO error kinds 170 | let error_kinds = vec![ 171 | io::ErrorKind::NotFound, 172 | io::ErrorKind::PermissionDenied, 173 | io::ErrorKind::ConnectionRefused, 174 | io::ErrorKind::TimedOut, 175 | io::ErrorKind::UnexpectedEof, 176 | ]; 177 | 178 | for kind in error_kinds { 179 | let io_error = io::Error::new(kind, "test error"); 180 | let result = system_time_to_datetime(Err(io_error)); 181 | assert!(result.is_none()); 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /crates/utils/src/folder_stats.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Default)] 2 | pub struct FolderStats { 3 | pub total_files: usize, 4 | pub total_dirs: usize, 5 | pub media_files: usize, 6 | pub total_size: u64, 7 | } 8 | -------------------------------------------------------------------------------- /crates/utils/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod bytes; 2 | pub mod datetime; 3 | mod folder_stats; 4 | pub mod media_types; 5 | mod path; 6 | mod progress; 7 | 8 | // 9 | pub use bytes::format_bytes; 10 | pub use folder_stats::FolderStats; 11 | pub use path::create_cache_path; 12 | pub use progress::Progress; 13 | -------------------------------------------------------------------------------- /crates/utils/src/media_types.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use visualvault_models::FileType; 3 | 4 | #[allow(clippy::expect_used)] 5 | // Original media extensions for backward compatibility 6 | pub static MEDIA_EXTENSIONS: std::sync::LazyLock = std::sync::LazyLock::new(|| { 7 | Regex::new(r"(?i)\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?|raw|cr2|nef|arw|dng|orf|rw2|pef|sr2|mp4|avi|mkv|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|3g2|mts|m2ts|vob|ogv|heic|heif)$").expect("Failed to compile MEDIA_EXTENSIONS regex") 8 | }); 9 | 10 | #[must_use] 11 | pub fn determine_file_type(extension: &str) -> FileType { 12 | match extension.to_lowercase().as_str() { 13 | // Images 14 | "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "svg" | "ico" | "tiff" | "tif" | "raw" | "cr2" | "nef" 15 | | "arw" | "dng" | "orf" | "rw2" | "pef" | "sr2" | "heic" | "heif" => FileType::Image, 16 | 17 | // Videos 18 | "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" | "mpg" | "mpeg" | "3gp" | "3g2" | "mts" 19 | | "m2ts" | "vob" | "ogv" => FileType::Video, 20 | 21 | // Documents 22 | "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "txt" | "odt" | "ods" | "odp" | "rtf" | "tex" 23 | | "md" | "csv" | "html" | "htm" | "xml" | "json" => FileType::Document, 24 | 25 | // Everything else 26 | _ => FileType::Other, 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | #![allow(clippy::cognitive_complexity)] 33 | use super::*; 34 | 35 | #[test] 36 | fn test_determine_file_type_images() { 37 | // Test all image extensions 38 | let image_extensions = vec![ 39 | "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "raw", "heic", "heif", 40 | ]; 41 | 42 | for ext in image_extensions { 43 | assert_eq!( 44 | determine_file_type(ext), 45 | FileType::Image, 46 | "Extension '{ext}' should be identified as Image" 47 | ); 48 | } 49 | } 50 | 51 | #[test] 52 | fn test_determine_file_type_videos() { 53 | // Test all video extensions 54 | let video_extensions = vec!["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg"]; 55 | 56 | for ext in video_extensions { 57 | assert_eq!( 58 | determine_file_type(ext), 59 | FileType::Video, 60 | "Extension '{ext}' should be identified as Video" 61 | ); 62 | } 63 | } 64 | 65 | #[test] 66 | fn test_determine_file_type_documents() { 67 | // Test all document extensions 68 | let document_extensions = vec!["pdf", "doc", "docx", "txt", "odt", "rtf"]; 69 | 70 | for ext in document_extensions { 71 | assert_eq!( 72 | determine_file_type(ext), 73 | FileType::Document, 74 | "Extension '{ext}' should be identified as Document" 75 | ); 76 | } 77 | } 78 | 79 | #[test] 80 | fn test_determine_file_type_other() { 81 | // Test extensions that should be classified as Other 82 | let other_extensions = vec![ 83 | "exe", "zip", "rar", "7z", "tar", "gz", "iso", "dmg", "pkg", "deb", "rpm", "msi", "app", "js", "py", "rs", 84 | "go", "java", "cpp", "c", "css", "wma", // Audio files (not in our media types) 85 | "", // Empty string 86 | "unknown", "xyz", "abc", "123", 87 | ]; 88 | 89 | for ext in other_extensions { 90 | assert_eq!( 91 | determine_file_type(ext), 92 | FileType::Other, 93 | "Extension '{ext}' should be identified as Other" 94 | ); 95 | } 96 | } 97 | 98 | #[test] 99 | fn test_determine_file_type_case_sensitivity() { 100 | // The function appears to be case-sensitive based on the implementation 101 | // Testing uppercase versions which should be treated as Other 102 | assert_eq!(determine_file_type("JPG"), FileType::Image); 103 | assert_eq!(determine_file_type("PNG"), FileType::Image); 104 | assert_eq!(determine_file_type("MP4"), FileType::Video); 105 | assert_eq!(determine_file_type("PDF"), FileType::Document); 106 | assert_eq!(determine_file_type("DOC"), FileType::Document); 107 | assert_eq!(determine_file_type("TXT"), FileType::Document); 108 | assert_eq!(determine_file_type("EXE"), FileType::Other); 109 | 110 | // Mixed case 111 | assert_eq!(determine_file_type("Jpg"), FileType::Image); 112 | assert_eq!(determine_file_type("Mp4"), FileType::Video); 113 | assert_eq!(determine_file_type("Pdf"), FileType::Document); 114 | assert_eq!(determine_file_type("Doc"), FileType::Document); 115 | assert_eq!(determine_file_type("Txt"), FileType::Document); 116 | assert_eq!(determine_file_type("ExE"), FileType::Other); 117 | } 118 | 119 | #[test] 120 | fn test_determine_file_type_edge_cases() { 121 | // Test edge cases 122 | assert_eq!(determine_file_type(""), FileType::Other); 123 | assert_eq!(determine_file_type(" "), FileType::Other); 124 | assert_eq!(determine_file_type("jpg "), FileType::Other); // With trailing space 125 | assert_eq!(determine_file_type(" jpg"), FileType::Other); // With leading space 126 | assert_eq!(determine_file_type(".jpg"), FileType::Other); // With dot 127 | assert_eq!(determine_file_type("file.jpg"), FileType::Other); // Full filename 128 | assert_eq!(determine_file_type("jpg.png"), FileType::Other); // Multiple extensions 129 | } 130 | 131 | #[test] 132 | fn test_determine_file_type_similar_extensions() { 133 | // Test extensions that are similar but not exact matches 134 | assert_eq!(determine_file_type("jp"), FileType::Other); 135 | assert_eq!(determine_file_type("jpe"), FileType::Other); 136 | assert_eq!(determine_file_type("jpgg"), FileType::Other); 137 | assert_eq!(determine_file_type("pngg"), FileType::Other); 138 | assert_eq!(determine_file_type("mp"), FileType::Other); 139 | assert_eq!(determine_file_type("mp44"), FileType::Other); 140 | assert_eq!(determine_file_type("pdff"), FileType::Other); 141 | } 142 | 143 | #[test] 144 | fn test_determine_file_type_unicode() { 145 | // Test with unicode characters 146 | assert_eq!(determine_file_type("jpg📷"), FileType::Other); 147 | assert_eq!(determine_file_type("файл"), FileType::Other); 148 | assert_eq!(determine_file_type("图片"), FileType::Other); 149 | assert_eq!(determine_file_type("🎬mp4"), FileType::Other); 150 | } 151 | 152 | #[test] 153 | fn test_media_extensions_regex() { 154 | // Test that MEDIA_EXTENSIONS regex works correctly 155 | let test_cases = vec![ 156 | ("image.jpg", true), 157 | ("IMAGE.JPG", true), // Case insensitive 158 | ("photo.jpeg", true), 159 | ("pic.png", true), 160 | ("animation.gif", true), 161 | ("video.mp4", true), 162 | ("MOVIE.AVI", true), 163 | ("song.mp3", false), 164 | ("audio.wav", false), 165 | ("document.pdf", false), // PDF not in MEDIA_EXTENSIONS 166 | ("file.txt", false), 167 | ("archive.zip", false), 168 | ("noextension", false), 169 | ("", false), 170 | ]; 171 | 172 | for (filename, should_match) in test_cases { 173 | assert_eq!( 174 | MEDIA_EXTENSIONS.is_match(filename), 175 | should_match, 176 | "Filename '{filename}' match expectation failed" 177 | ); 178 | } 179 | } 180 | 181 | #[test] 182 | fn test_all_determine_file_type_extensions_consistency() { 183 | // Ensure all extensions in determine_file_type for Image and Video 184 | // are covered by MEDIA_EXTENSIONS regex (except for audio which seems intentional) 185 | 186 | let image_extensions = vec![ 187 | "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "heic", "raw", "heif", 188 | ]; 189 | let video_extensions = vec!["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg"]; 190 | 191 | for ext in image_extensions { 192 | let filename = format!("test.{ext}"); 193 | assert!( 194 | MEDIA_EXTENSIONS.is_match(&filename), 195 | "Image extension '{ext}' should be in MEDIA_EXTENSIONS" 196 | ); 197 | } 198 | 199 | for ext in video_extensions { 200 | let filename = format!("test.{ext}"); 201 | assert!( 202 | MEDIA_EXTENSIONS.is_match(&filename), 203 | "Video extension '{ext}' should be in MEDIA_EXTENSIONS" 204 | ); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /crates/utils/src/path.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use color_eyre::Result; 4 | 5 | /// Creates a cache path for the database. 6 | /// 7 | /// If `use_in_memory` is true, returns ":memory:" for in-memory database. 8 | /// Otherwise, creates the cache directory if it doesn't exist and returns the path to cache.db. 9 | /// 10 | /// # Errors 11 | /// 12 | /// Returns an error if: 13 | /// - The cache directory cannot be determined 14 | /// - The cache directory cannot be created 15 | /// - The cache path cannot be converted to a string 16 | pub async fn create_cache_path(app_name: &str, filename: &str) -> Result { 17 | let cache_dir = dirs::cache_dir() 18 | .ok_or_else(|| color_eyre::eyre::eyre!("Failed to get cache directory"))? 19 | .join(app_name) 20 | .join(filename); 21 | let cache_path = cache_dir 22 | .parent() 23 | .ok_or_else(|| color_eyre::eyre::eyre!("Invalid cache path"))?; 24 | 25 | tokio::fs::create_dir_all(cache_path).await?; 26 | Ok(cache_dir) 27 | } 28 | -------------------------------------------------------------------------------- /images/duplicate-detector-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/images/duplicate-detector-page.png -------------------------------------------------------------------------------- /images/filters-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/images/filters-page.png -------------------------------------------------------------------------------- /images/main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/images/main-page.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/images/screenshot.png -------------------------------------------------------------------------------- /images/settings-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/images/settings-page.png -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # List available commands 2 | default: 3 | @just --list 4 | 5 | all: format lint 6 | 7 | # Format code using rustfmt 8 | format: 9 | @echo "\nFormatting all code..." 10 | cargo fmt --all 11 | @echo "Done formatting!\n" 12 | 13 | # Run clippy to lint the code 14 | lint: 15 | @echo "\nLinting with clippy..." 16 | cargo fmt -- --check 17 | cargo clippy --all-features --all-targets -- -D warnings 18 | @echo "Done linting!\n" 19 | 20 | # Fix linting issues where possible 21 | lint-fix: 22 | @echo "Fixing linting issues..." 23 | cargo clippy --fix -- -D warnings 24 | 25 | # Run tests 26 | test: 27 | @echo "\nRunning tests..." 28 | cargo nextest run --workspace 29 | @echo "Done running tests!\n" -------------------------------------------------------------------------------- /nextest.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | # Number of threads to use for running tests 3 | test-threads = "num-cpus" 4 | 5 | # How long to wait before timing out tests 6 | slow-timeout = { period = "30s", terminate-after = 2 } 7 | 8 | # Show output for failing tests 9 | failure-output = "immediate-final" 10 | 11 | # Retry flaky tests 12 | retries = 1 13 | 14 | [profile.ci] 15 | # CI profile with stricter settings 16 | test-threads = 2 17 | slow-timeout = { period = "10s", terminate-after = 1 } 18 | failure-output = "final" 19 | retries = 3 20 | -------------------------------------------------------------------------------- /scripts/convert_benchmark_output.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Convert Criterion benchmark output to bencher format for GitHub Actions benchmark tracking. 4 | 5 | This script parses Criterion's output format and converts it to the bencher format 6 | expected by benchmark-action/github-action-benchmark. 7 | 8 | Usage: 9 | python3 convert_benchmark_output.py 10 | python3 convert_benchmark_output.py benchmark-output.txt output.txt 11 | """ 12 | 13 | import argparse 14 | import json 15 | import re 16 | import sys 17 | 18 | 19 | def convert_unit_to_nanoseconds(value: float, unit: str) -> int: 20 | """Convert time value from various units to nanoseconds.""" 21 | unit_multipliers = { 22 | "ms": 1_000_000, 23 | "us": 1_000, 24 | "µs": 1_000, # Alternative microsecond symbol 25 | "ns": 1, 26 | "s": 1_000_000_000, 27 | } 28 | 29 | multiplier = unit_multipliers.get(unit.lower()) 30 | if multiplier is None: 31 | raise ValueError(f"Unknown time unit: {unit}") 32 | 33 | return int(value * multiplier) 34 | 35 | 36 | def parse_existing_bencher_format(content: str, debug: bool = False) -> list: 37 | """Parse input that's already in bencher format.""" 38 | # Pattern to match existing bencher format: 39 | # test benchmark_name ... bench: 123 ns/iter (+/- 45) 40 | pattern = r"test\s+([^\s].+?)\s+\.\.\.\s+bench:\s+(\d+(?:,\d+)*)\s+ns/iter\s+\(\+/-\s+(\d+(?:,\d+)*)\)" 41 | 42 | results = [] 43 | matches = list(re.finditer(pattern, content)) 44 | 45 | if debug: 46 | print(f"Bencher format pattern found {len(matches)} matches", file=sys.stderr) 47 | 48 | for match in matches: 49 | test_name = match.group(1).strip() 50 | value_str = match.group(2).replace(",", "") # Remove commas 51 | variance_str = match.group(3).replace(",", "") # Remove commas 52 | 53 | try: 54 | value = int(value_str) 55 | variance = int(variance_str) 56 | 57 | if debug: 58 | print( 59 | f"Parsed: {test_name} -> {value} ns/iter (+/- {variance})", 60 | file=sys.stderr, 61 | ) 62 | 63 | results.append({"name": test_name, "value": value, "variance": variance}) 64 | 65 | except ValueError as e: 66 | if debug: 67 | print( 68 | f"Warning: Skipping benchmark '{test_name}': {e}", file=sys.stderr 69 | ) 70 | continue 71 | 72 | return results 73 | 74 | 75 | def parse_bencher_json_output(content: str, debug: bool = False) -> list: 76 | """Parse bencher format output with JSON timing data.""" 77 | # Multiple patterns to try 78 | patterns = [ 79 | # Pattern 1: Standard format with optional #N suffix 80 | r"([^:\n]+?)(?:\s+#\d+)?\s*:\s*(\{[^}]+\})", 81 | # Pattern 2: More flexible whitespace handling 82 | r"(.+?)\s*:\s*(\{[^}]+\})", 83 | # Pattern 3: Line-by-line approach 84 | r"^([^:\n]+?)\s*:\s*(\{.+?\})", 85 | ] 86 | 87 | results = [] 88 | 89 | for i, pattern in enumerate(patterns): 90 | if debug: 91 | print(f"Trying JSON pattern {i+1}: {pattern}", file=sys.stderr) 92 | 93 | matches = list(re.finditer(pattern, content, re.MULTILINE)) 94 | if debug: 95 | print(f"JSON pattern {i+1} found {len(matches)} matches", file=sys.stderr) 96 | 97 | if matches: 98 | for match in matches: 99 | test_name = match.group(1).strip() 100 | json_data = match.group(2) 101 | 102 | if debug: 103 | print( 104 | f"Match: '{test_name}' -> '{json_data[:50]}...'", 105 | file=sys.stderr, 106 | ) 107 | 108 | try: 109 | # Parse JSON data 110 | data = json.loads(json_data) 111 | 112 | # Extract values 113 | estimate = data.get("estimate", 0) 114 | lower_bound = data.get("lower_bound", estimate) 115 | upper_bound = data.get("upper_bound", estimate) 116 | unit = data.get("unit", "ns") 117 | 118 | # Convert to nanoseconds 119 | value_ns = convert_unit_to_nanoseconds(estimate, unit) 120 | low_ns = convert_unit_to_nanoseconds(lower_bound, unit) 121 | high_ns = convert_unit_to_nanoseconds(upper_bound, unit) 122 | 123 | # Calculate variance 124 | variance = max(abs(value_ns - low_ns), abs(high_ns - value_ns)) 125 | 126 | results.append( 127 | {"name": test_name, "value": value_ns, "variance": variance} 128 | ) 129 | 130 | except (json.JSONDecodeError, ValueError) as e: 131 | if debug: 132 | print( 133 | f"Warning: Skipping benchmark '{test_name}': {e}", 134 | file=sys.stderr, 135 | ) 136 | continue 137 | 138 | # If we found results with this pattern, stop trying others 139 | if results: 140 | if debug: 141 | print( 142 | f"Successfully parsed {len(results)} results with JSON pattern {i+1}", 143 | file=sys.stderr, 144 | ) 145 | break 146 | 147 | return results 148 | 149 | 150 | def parse_criterion_output(content: str, debug: bool = False) -> list: 151 | """Parse Criterion benchmark output and extract timing information.""" 152 | # Pattern to match Criterion output 153 | # Example: "benchmark_name time: [1.2345 ms 1.2567 ms 1.2789 ms]" 154 | pattern = r"(\S+)\s+time:\s+\[(\d+\.?\d*)\s+(\w+)\s+(\d+\.?\d*)\s+(\w+)\s+(\d+\.?\d*)\s+(\w+)\]" 155 | 156 | results = [] 157 | matches = list(re.finditer(pattern, content)) 158 | 159 | if debug: 160 | print(f"Criterion pattern found {len(matches)} matches", file=sys.stderr) 161 | 162 | for match in matches: 163 | test_name = match.group(1) 164 | 165 | # Extract the three timing values (low, median, high estimates) 166 | low_value = float(match.group(2)) 167 | low_unit = match.group(3) 168 | median_value = float(match.group(4)) 169 | median_unit = match.group(5) 170 | high_value = float(match.group(6)) 171 | high_unit = match.group(7) 172 | 173 | # Use the median value as the primary benchmark result 174 | try: 175 | value_ns = convert_unit_to_nanoseconds(median_value, median_unit) 176 | 177 | # Calculate variance estimate from low and high values 178 | low_ns = convert_unit_to_nanoseconds(low_value, low_unit) 179 | high_ns = convert_unit_to_nanoseconds(high_value, high_unit) 180 | variance = max(abs(value_ns - low_ns), abs(high_ns - value_ns)) 181 | 182 | results.append({"name": test_name, "value": value_ns, "variance": variance}) 183 | 184 | except ValueError as e: 185 | if debug: 186 | print( 187 | f"Warning: Skipping benchmark '{test_name}': {e}", file=sys.stderr 188 | ) 189 | continue 190 | 191 | return results 192 | 193 | 194 | def parse_benchmark_output(content: str, debug: bool = False) -> list: 195 | """Parse benchmark output, trying different formats.""" 196 | if debug: 197 | print("Analyzing input content...", file=sys.stderr) 198 | lines = content.split("\n") 199 | print(f"Total lines: {len(lines)}", file=sys.stderr) 200 | print("First 5 non-empty lines:", file=sys.stderr) 201 | for i, line in enumerate(lines[:10]): 202 | if line.strip(): 203 | print(f" {i+1}: {line[:100]}", file=sys.stderr) 204 | 205 | # First check if input is already in bencher format 206 | results = parse_existing_bencher_format(content, debug) 207 | 208 | # If no results, try bencher JSON format 209 | if not results: 210 | results = parse_bencher_json_output(content, debug) 211 | 212 | # If still no results, try Criterion format 213 | if not results: 214 | if debug: 215 | print( 216 | "No results from JSON format, trying Criterion format...", 217 | file=sys.stderr, 218 | ) 219 | results = parse_criterion_output(content, debug) 220 | 221 | return results 222 | 223 | 224 | def write_bencher_format(results: list, output_file: str): 225 | """Write benchmark results in bencher format.""" 226 | with open(output_file, "w") as f: 227 | for result in results: 228 | # Format: test ... bench: ns/iter (+/- ) 229 | f.write( 230 | f"test {result['name']} ... bench: {result['value']} ns/iter " 231 | f"(+/- {result['variance']})\n" 232 | ) 233 | 234 | 235 | def main(): 236 | parser = argparse.ArgumentParser( 237 | description="Convert Criterion benchmark output to bencher format" 238 | ) 239 | parser.add_argument( 240 | "input_file", help="Input file containing Criterion benchmark output" 241 | ) 242 | parser.add_argument("output_file", help="Output file for bencher format results") 243 | parser.add_argument( 244 | "--verbose", "-v", action="store_true", help="Enable verbose output" 245 | ) 246 | parser.add_argument( 247 | "--debug", "-d", action="store_true", help="Enable debug output" 248 | ) 249 | 250 | args = parser.parse_args() 251 | 252 | try: 253 | # Read input file 254 | with open(args.input_file, "r", encoding="utf-8") as f: 255 | content = f.read() 256 | 257 | if args.verbose: 258 | print(f"Reading benchmark output from: {args.input_file}") 259 | 260 | if args.debug: 261 | print(f"Input file size: {len(content)} characters", file=sys.stderr) 262 | print( 263 | f"Input content (first 200 chars):\n{content[:200]}\n", file=sys.stderr 264 | ) 265 | 266 | # Parse benchmark output 267 | results = parse_benchmark_output(content, args.debug) 268 | 269 | if not results: 270 | print("Warning: No benchmark results found in input file", file=sys.stderr) 271 | 272 | if args.debug: 273 | print("Debug: Manual pattern search...", file=sys.stderr) 274 | 275 | # Look for test lines 276 | test_lines = [ 277 | line 278 | for line in content.split("\n") 279 | if line.strip().startswith("test ") 280 | ] 281 | print( 282 | f"Found {len(test_lines)} lines starting with 'test'", 283 | file=sys.stderr, 284 | ) 285 | for i, line in enumerate(test_lines[:3]): 286 | print(f" {i+1}: {line}", file=sys.stderr) 287 | 288 | # Create a dummy result to avoid empty output 289 | results = [{"name": "dummy_benchmark", "value": 1000, "variance": 0}] 290 | 291 | if args.verbose: 292 | print(f"Found {len(results)} benchmark results") 293 | for result in results: 294 | print(f" {result['name']}: {result['value']} ns/iter") 295 | 296 | # Write bencher format output 297 | write_bencher_format(results, args.output_file) 298 | 299 | if args.verbose: 300 | print(f"Results written to: {args.output_file}") 301 | 302 | return 0 303 | 304 | except FileNotFoundError: 305 | print(f"Error: Input file '{args.input_file}' not found", file=sys.stderr) 306 | return 1 307 | except Exception as e: 308 | print(f"Error: {e}", file=sys.stderr) 309 | if args.debug: 310 | import traceback 311 | 312 | traceback.print_exc() 313 | return 1 314 | 315 | 316 | if __name__ == "__main__": 317 | sys.exit(main()) 318 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use visualvault_config as config; 2 | pub use visualvault_core as core; 3 | pub use visualvault_models as models; 4 | pub use visualvault_utils as utils; 5 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::Result; 2 | use crossterm::{ 3 | event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, 4 | execute, 5 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 6 | }; 7 | use ratatui::{ 8 | Terminal, 9 | backend::{Backend, CrosstermBackend}, 10 | }; 11 | use std::{ 12 | io::{self, IsTerminal}, 13 | sync::Arc, 14 | time::{Duration, Instant}, 15 | }; 16 | use tokio::sync::RwLock; 17 | use tracing::{error, info}; 18 | 19 | use visualvault_app::App; 20 | use visualvault_ui::draw; 21 | 22 | #[cfg(windows)] 23 | use mimalloc::MiMalloc; 24 | 25 | #[cfg(not(windows))] 26 | use jemallocator::Jemalloc; 27 | 28 | #[cfg(windows)] 29 | #[global_allocator] 30 | static GLOBAL: MiMalloc = MiMalloc; 31 | 32 | #[cfg(not(windows))] 33 | #[global_allocator] 34 | static GLOBAL: Jemalloc = Jemalloc; 35 | 36 | #[tokio::main] 37 | async fn main() -> Result<()> { 38 | // Install error hooks 39 | color_eyre::install()?; 40 | 41 | // Setup logging 42 | setup_logging()?; 43 | 44 | // Run the application 45 | if let Err(e) = run().await { 46 | error!("Application error: {}", e); 47 | return Err(e); 48 | } 49 | 50 | Ok(()) 51 | } 52 | 53 | fn setup_logging() -> Result<()> { 54 | use std::env; 55 | 56 | // create log file if not already exists to the project root 57 | let log_dir = env::current_dir()?.join("logs"); 58 | std::fs::create_dir_all(&log_dir)?; 59 | let log_path = log_dir.join("visualvault.log"); 60 | 61 | // Print where we're logging to 62 | eprintln!("Logging to: {}", log_path.display()); 63 | 64 | // Create or truncate log file 65 | let log_file = std::fs::OpenOptions::new() 66 | .create(true) 67 | .write(true) 68 | .truncate(true) 69 | .open(&log_path)?; 70 | 71 | // Configure tracing to write to file 72 | tracing_subscriber::fmt() 73 | .with_writer(log_file) 74 | .with_ansi(false) 75 | .with_env_filter("visualvault=debug,info") 76 | .with_target(true) 77 | .with_line_number(true) 78 | .with_thread_ids(false) 79 | .init(); 80 | 81 | tracing::info!("Starting VisualVault..."); 82 | tracing::info!("Log file: {}", log_path.display()); 83 | tracing::info!("Working directory: {}", env::current_dir()?.display()); 84 | 85 | Ok(()) 86 | } 87 | 88 | async fn run() -> Result<()> { 89 | // Setup terminal 90 | 91 | if !std::io::stdout().is_terminal() { 92 | eprintln!("Error: This application must be run in a terminal"); 93 | std::process::exit(1); 94 | } 95 | 96 | enable_raw_mode()?; 97 | let mut stdout = io::stdout(); 98 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 99 | let backend = CrosstermBackend::new(stdout); 100 | let mut terminal = Terminal::new(backend)?; 101 | 102 | // Create app 103 | let app = Arc::new(RwLock::new(App::new().await?)); 104 | 105 | // Run the app 106 | let res = run_app(&mut terminal, app).await; 107 | 108 | // Restore terminal 109 | disable_raw_mode()?; 110 | execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; 111 | terminal.show_cursor()?; 112 | 113 | if let Err(err) = res { 114 | error!("Runtime error: {:?}", err); 115 | return Err(err); 116 | } 117 | 118 | Ok(()) 119 | } 120 | 121 | async fn run_app(terminal: &mut Terminal, app: Arc>) -> Result<()> { 122 | let tick_rate = Duration::from_millis(100); 123 | let mut last_tick = Instant::now(); 124 | 125 | loop { 126 | // Draw UI 127 | { 128 | let mut app = app.write().await; 129 | terminal.draw(|f| draw(f, &mut app))?; 130 | } 131 | 132 | // Handle events with timeout 133 | let timeout = tick_rate 134 | .checked_sub(last_tick.elapsed()) 135 | .unwrap_or_else(|| Duration::from_secs(0)); 136 | 137 | if event::poll(timeout)? { 138 | if let Event::Key(key) = event::read()? { 139 | if key.kind == KeyEventKind::Press { 140 | let mut app = app.write().await; 141 | 142 | match key.code { 143 | KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { 144 | info!("User forced quit"); 145 | return Ok(()); 146 | } 147 | _ => { 148 | app.on_key(key).await?; 149 | if app.should_quit { 150 | info!("User requested quit"); 151 | return Ok(()); 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | // Update app state on tick 160 | if last_tick.elapsed() >= tick_rate { 161 | app.write().await.on_tick().await?; 162 | last_tick = Instant::now(); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /tests/common/fixtures.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/tests/common/fixtures.rs -------------------------------------------------------------------------------- /tests/common/helpers.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeleppane/visualvault/6dab7e85633ccb3d5a43b25e2fd1b00300758b83/tests/common/helpers.rs -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/integration/mod.rs: -------------------------------------------------------------------------------- 1 | mod organizer; 2 | mod scanner; 3 | -------------------------------------------------------------------------------- /tests/integration/organizer.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::panic)] 2 | #![allow(clippy::unwrap_used)] 3 | #![allow(clippy::expect_used)] 4 | #![allow(clippy::panic_in_result_fn)] 5 | 6 | use color_eyre::Result; 7 | use std::path::Path; 8 | use std::sync::Arc; 9 | use tempfile::TempDir; 10 | use tokio::fs; 11 | use tokio::sync::RwLock; 12 | 13 | use visualvault_config::Settings; 14 | use visualvault_core::DatabaseCache; 15 | use visualvault_core::{FileOrganizer, Scanner}; 16 | use visualvault_utils::Progress; 17 | 18 | /// Create a test file with specific content and size 19 | async fn create_test_file(path: &Path, content: &[u8], size: usize) -> Result<()> { 20 | if let Some(parent) = path.parent() { 21 | fs::create_dir_all(parent).await?; 22 | } 23 | 24 | let mut data = content.to_vec(); 25 | data.resize(size, 0); 26 | fs::write(path, &data).await?; 27 | Ok(()) 28 | } 29 | 30 | async fn create_test_scanner() -> Result { 31 | let database_cache = DatabaseCache::new(":memory:") 32 | .await 33 | .expect("Failed to initialize database cache"); 34 | let scanner = Scanner::new(database_cache); 35 | Ok(scanner) 36 | } 37 | 38 | #[tokio::test] 39 | async fn test_organizer_source_equals_destination() -> Result<()> { 40 | let temp_dir = TempDir::new()?; 41 | let root = temp_dir.path(); 42 | 43 | // Create test files 44 | create_test_file(&root.join("photo1.jpg"), b"PHOTO1", 1024 * 1024).await?; 45 | create_test_file(&root.join("photo2.jpg"), b"PHOTO2", 1024 * 1024).await?; 46 | create_test_file(&root.join("video.mp4"), b"VIDEO", 5 * 1024 * 1024).await?; 47 | 48 | let settings = Settings { 49 | source_folder: Some(root.to_path_buf()), 50 | destination_folder: Some(root.to_path_buf()), // Same as source! 51 | recurse_subfolders: true, 52 | organize_by: "Monthly".to_string(), 53 | ..Default::default() 54 | }; 55 | 56 | let scanner = create_test_scanner().await?; 57 | let progress = Arc::new(RwLock::new(Progress::default())); 58 | 59 | // Scanning should work fine even when source equals destination 60 | let (files, dups) = scanner 61 | .scan_directory_with_duplicates(root, true, progress.clone(), &settings, None) 62 | .await?; 63 | 64 | assert_eq!(files.len(), 3, "Should find all 3 files"); 65 | 66 | // Verify scanner doesn't create any issues 67 | // Files should still exist and be readable 68 | for file in &files { 69 | assert!(file.path.exists(), "File should still exist after scan"); 70 | let content = fs::read(&file.path).await?; 71 | assert!(!content.is_empty(), "File should have content"); 72 | } 73 | 74 | let organizer = FileOrganizer::new(temp_dir.path().to_path_buf()).await?; 75 | let result = organizer 76 | .organize_files_with_duplicates(files, dups, &settings, progress) 77 | .await 78 | .unwrap(); 79 | 80 | // Ensure no files were moved since source equals destination 81 | assert!( 82 | result.success, 83 | "Organizing should succeed even with same source/destination" 84 | ); 85 | assert!( 86 | result.errors.is_empty(), 87 | "No errors should occur when source equals destination" 88 | ); 89 | 90 | // Verify that files are organized correctly 91 | assert_eq!(result.files_organized, 3, "Should organize all 3 files"); 92 | assert_eq!(result.files_total, 3, "Total files should still be 3"); 93 | 94 | let current_month = chrono::Local::now().format("%m-%B").to_string(); 95 | let current_year = chrono::Local::now().format("%Y").to_string(); 96 | let expected_destination = root.join(current_year).join(current_month); 97 | assert!(expected_destination.exists(), "Destination folder should exist"); 98 | 99 | Ok(()) 100 | } 101 | 102 | #[tokio::test] 103 | async fn test_organize_by_type_with_separate_videos() -> Result<()> { 104 | let temp_dir = TempDir::new()?; 105 | let source = temp_dir.path().join("source"); 106 | let dest = temp_dir.path().join("organized"); 107 | 108 | // Create mixed media files 109 | create_test_file(&source.join("vacation.jpg"), b"VACATION_JPG", 2 * 1024 * 1024).await?; 110 | create_test_file(&source.join("portrait.png"), b"PORTRAIT_PNG", 1024 * 1024).await?; 111 | create_test_file(&source.join("screenshot.bmp"), b"SCREENSHOT", 512 * 1024).await?; 112 | create_test_file(&source.join("movie.mp4"), b"MOVIE_MP4", 50 * 1024 * 1024).await?; 113 | create_test_file(&source.join("clip.avi"), b"CLIP_AVI", 20 * 1024 * 1024).await?; 114 | create_test_file(&source.join("raw_photo.raw"), b"RAW_DATA", 10 * 1024 * 1024).await?; 115 | 116 | let settings = Settings { 117 | source_folder: Some(source.clone()), 118 | destination_folder: Some(dest.clone()), 119 | organize_by: "type".to_string(), 120 | separate_videos: true, 121 | recurse_subfolders: true, 122 | ..Default::default() 123 | }; 124 | 125 | // First scan the files 126 | let scanner = create_test_scanner().await?; 127 | let progress = Arc::new(RwLock::new(Progress::default())); 128 | let (files, dups) = scanner 129 | .scan_directory_with_duplicates(&source, true, progress.clone(), &settings, None) 130 | .await?; 131 | 132 | assert_eq!(files.len(), 6, "Should find all 6 media files"); 133 | 134 | // Now organize them using FileOrganizer 135 | let organizer = FileOrganizer::new(temp_dir.path().to_path_buf()).await?; 136 | let result = organizer 137 | .organize_files_with_duplicates(files, dups, &settings, progress) 138 | .await?; 139 | 140 | assert!(result.success, "Organization should succeed"); 141 | assert_eq!(result.files_organized, 6, "Should organize all 6 files"); 142 | 143 | // Verify the directory structure 144 | // When organize_by = "type" and separate_videos = true: 145 | // - Images go to: organized/Photos/ 146 | // - Videos go to: organized/Videos/ 147 | // - Screenshots go to: organized/Screenshots/ (if separate_screenshots = true) 148 | // - Raw files go to: organized/Photos/RAW/ 149 | 150 | // Check Photos directory 151 | let photos_dir = dest.join("Images"); 152 | assert!(photos_dir.exists(), "Images directory should exist"); 153 | assert!( 154 | photos_dir.join("vacation.jpg").exists(), 155 | "vacation.jpg should be in Images" 156 | ); 157 | assert!( 158 | photos_dir.join("portrait.png").exists(), 159 | "portrait.png should be in Images" 160 | ); 161 | assert!( 162 | photos_dir.join("screenshot.bmp").exists(), 163 | "screenshot.bmp should be in Images" 164 | ); 165 | assert!( 166 | photos_dir.join("raw_photo.raw").exists(), 167 | "RAW subdirectory should exist under Images" 168 | ); 169 | 170 | // Check Videos directory (separate from Photos due to separate_videos = true) 171 | let videos_dir = dest.join("Videos"); 172 | assert!(videos_dir.exists(), "Videos directory should exist"); 173 | assert!(videos_dir.join("movie.mp4").exists(), "movie.mp4 should be in Videos"); 174 | assert!(videos_dir.join("clip.avi").exists(), "clip.avi should be in Videos"); 175 | 176 | Ok(()) 177 | } 178 | 179 | #[tokio::test] 180 | async fn test_organize_by_type_without_separate_videos() -> Result<()> { 181 | let temp_dir = TempDir::new()?; 182 | let source = temp_dir.path().join("source"); 183 | let dest = temp_dir.path().join("organized"); 184 | 185 | // Create mixed media files 186 | create_test_file(&source.join("vacation.jpg"), b"VACATION_JPG", 2 * 1024 * 1024).await?; 187 | create_test_file(&source.join("portrait.png"), b"PORTRAIT_PNG", 1024 * 1024).await?; 188 | create_test_file(&source.join("screenshot.bmp"), b"SCREENSHOT", 512 * 1024).await?; 189 | create_test_file(&source.join("movie.mp4"), b"MOVIE_MP4", 50 * 1024 * 1024).await?; 190 | create_test_file(&source.join("clip.avi"), b"CLIP_AVI", 20 * 1024 * 1024).await?; 191 | create_test_file(&source.join("raw_photo.raw"), b"RAW_DATA", 10 * 1024 * 1024).await?; 192 | 193 | let settings = Settings { 194 | source_folder: Some(source.clone()), 195 | destination_folder: Some(dest.clone()), 196 | organize_by: "type".to_string(), 197 | separate_videos: false, 198 | recurse_subfolders: true, 199 | ..Default::default() 200 | }; 201 | 202 | // First scan the files 203 | let scanner = create_test_scanner().await?; 204 | let progress = Arc::new(RwLock::new(Progress::default())); 205 | let (files, dups) = scanner 206 | .scan_directory_with_duplicates(&source, true, progress.clone(), &settings, None) 207 | .await?; 208 | 209 | assert_eq!(files.len(), 6, "Should find all 6 media files"); 210 | 211 | // Now organize them using FileOrganizer 212 | let organizer = FileOrganizer::new(temp_dir.path().to_path_buf()).await?; 213 | let result = organizer 214 | .organize_files_with_duplicates(files, dups, &settings, progress) 215 | .await?; 216 | 217 | assert!(result.success, "Organization should succeed"); 218 | assert_eq!(result.files_organized, 6, "Should organize all 6 files"); 219 | 220 | // Verify the directory structure 221 | // When organize_by = "type" and separate_videos = true: 222 | // - Images go to: organized/Photos/ 223 | // - Videos go to: organized/Videos/ 224 | // - Screenshots go to: organized/Screenshots/ (if separate_screenshots = true) 225 | // - Raw files go to: organized/Photos/RAW/ 226 | 227 | // Check Photos directory 228 | let photos_dir = dest.join("Images"); 229 | assert!(photos_dir.exists(), "Images directory should exist"); 230 | assert!( 231 | photos_dir.join("vacation.jpg").exists(), 232 | "vacation.jpg should be in Images" 233 | ); 234 | assert!( 235 | photos_dir.join("portrait.png").exists(), 236 | "portrait.png should be in Images" 237 | ); 238 | assert!( 239 | photos_dir.join("screenshot.bmp").exists(), 240 | "screenshot.bmp should be in Images" 241 | ); 242 | assert!( 243 | photos_dir.join("raw_photo.raw").exists(), 244 | "RAW subdirectory should exist under Images" 245 | ); 246 | 247 | // Check Videos directory (separate from Photos due to separate_videos = true) 248 | let videos_dir = dest.join("Videos"); 249 | assert!(videos_dir.exists(), "Videos directory should exist"); 250 | assert!(videos_dir.join("movie.mp4").exists(), "movie.mp4 should be in Videos"); 251 | assert!(videos_dir.join("clip.avi").exists(), "clip.avi should be in Videos"); 252 | 253 | Ok(()) 254 | } 255 | -------------------------------------------------------------------------------- /tests/integration/scanner.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::panic)] 2 | #![allow(clippy::unwrap_used)] 3 | #![allow(clippy::expect_used)] 4 | #![allow(clippy::panic_in_result_fn)] 5 | 6 | use color_eyre::Result; 7 | use std::path::Path; 8 | use std::sync::Arc; 9 | use tempfile::TempDir; 10 | use tokio::fs; 11 | use tokio::sync::RwLock; 12 | 13 | use visualvault_config::Settings; 14 | use visualvault_core::DatabaseCache; 15 | use visualvault_core::{DuplicateDetector, Scanner}; 16 | use visualvault_models::FileType; 17 | use visualvault_utils::Progress; 18 | 19 | /// Create a test file with specific content and size 20 | async fn create_test_file(path: &Path, content: &[u8], size: usize) -> Result<()> { 21 | if let Some(parent) = path.parent() { 22 | fs::create_dir_all(parent).await?; 23 | } 24 | 25 | let mut data = content.to_vec(); 26 | data.resize(size, 0); 27 | fs::write(path, &data).await?; 28 | Ok(()) 29 | } 30 | 31 | /// Create a test media file structure 32 | async fn setup_test_files(root: &Path) -> Result<()> { 33 | // Create various media files 34 | create_test_file(&root.join("photos/vacation/beach.jpg"), b"JPG_DATA", 1024 * 1024).await?; 35 | create_test_file(&root.join("photos/vacation/sunset.jpg"), b"JPG_DATA_2", 1024 * 1024).await?; 36 | create_test_file(&root.join("photos/family/birthday.png"), b"PNG_DATA", 2 * 1024 * 1024).await?; 37 | create_test_file(&root.join("videos/holiday.mp4"), b"MP4_DATA", 10 * 1024 * 1024).await?; 38 | create_test_file(&root.join("videos/wedding.avi"), b"AVI_DATA", 20 * 1024 * 1024).await?; 39 | //create_test_file(&root.join("documents/report.pdf"), b"PDF_DATA", 512 * 1024).await?; 40 | 41 | // Create some duplicate files 42 | create_test_file(&root.join("duplicates/beach_copy.jpg"), b"JPG_DATA", 1024 * 1024).await?; 43 | create_test_file(&root.join("duplicates/beach_backup.jpg"), b"JPG_DATA", 1024 * 1024).await?; 44 | 45 | // Create hidden files 46 | create_test_file(&root.join(".hidden/secret.jpg"), b"SECRET", 1024).await?; 47 | 48 | Ok(()) 49 | } 50 | 51 | async fn create_test_scanner() -> Result { 52 | let database_cache = DatabaseCache::new(":memory:") 53 | .await 54 | .expect("Failed to initialize database cache"); 55 | let scanner = Scanner::new(database_cache); 56 | Ok(scanner) 57 | } 58 | 59 | #[tokio::test] 60 | async fn test_scanner_finds_all_media_files() -> Result<()> { 61 | let temp_dir = TempDir::new()?; 62 | let root = temp_dir.path(); 63 | setup_test_files(root).await?; 64 | 65 | let settings = Settings { 66 | recurse_subfolders: true, 67 | skip_hidden_files: false, 68 | ..Default::default() 69 | }; 70 | 71 | let scanner = create_test_scanner().await?; 72 | let progress = Arc::new(RwLock::new(Progress::default())); 73 | 74 | let files = scanner.scan_directory(root, true, progress, &settings, None).await?; 75 | 76 | // Should find all files including hidden 77 | assert_eq!(files.len(), 8, "Should find 8 files total"); 78 | 79 | // Verify file types 80 | let images: Vec<_> = files 81 | .iter() 82 | .filter(|f| matches!(f.file_type, FileType::Image)) 83 | .collect(); 84 | let videos: Vec<_> = files 85 | .iter() 86 | .filter(|f| matches!(f.file_type, FileType::Video)) 87 | .collect(); 88 | 89 | assert_eq!( 90 | images.len(), 91 | 6, 92 | "Should find 6 images (including duplicates and hidden)" 93 | ); 94 | assert_eq!(videos.len(), 2, "Should find 2 videos"); 95 | 96 | Ok(()) 97 | } 98 | 99 | #[tokio::test] 100 | async fn test_scanner_respects_hidden_files_setting() -> Result<()> { 101 | let temp_dir = TempDir::new()?; 102 | let root = temp_dir.path(); 103 | setup_test_files(root).await?; 104 | 105 | let settings = Settings { 106 | recurse_subfolders: true, 107 | skip_hidden_files: true, 108 | ..Default::default() 109 | }; 110 | 111 | let scanner = create_test_scanner().await?; 112 | let progress = Arc::new(RwLock::new(Progress::default())); 113 | 114 | let files = scanner.scan_directory(root, true, progress, &settings, None).await?; 115 | 116 | // Should not find hidden files 117 | assert_eq!(files.len(), 0, "Should find 0 files (excluding hidden)"); 118 | assert!(!files.iter().any(|f| f.path.to_string_lossy().contains(".hidden"))); 119 | 120 | Ok(()) 121 | } 122 | 123 | #[tokio::test] 124 | async fn test_duplicate_detection() -> Result<()> { 125 | let temp_dir = TempDir::new()?; 126 | let root = temp_dir.path(); 127 | setup_test_files(root).await?; 128 | 129 | let settings = Settings::default(); 130 | let scanner = create_test_scanner().await?; 131 | let progress = Arc::new(RwLock::new(Progress::default())); 132 | 133 | // Scan for files and duplicates 134 | let (files, duplicates) = scanner 135 | .scan_directory_with_duplicates(root, true, progress.clone(), &settings, None) 136 | .await?; 137 | 138 | // Should find duplicate groups 139 | assert!(!duplicates.is_empty(), "Should find duplicates"); 140 | 141 | // Verify duplicate group 142 | let duplicate_count: usize = duplicates.total_files(); 143 | assert_eq!(duplicate_count, 3, "Should find 3 files in duplicate groups"); 144 | 145 | // Use DuplicateDetector directly 146 | let detector = DuplicateDetector::new(); 147 | let stats = detector.detect_duplicates(&files, false).await?; 148 | 149 | assert_eq!(stats.groups.len(), 1, "Should find 1 duplicate group"); 150 | assert_eq!(stats.total_duplicates, 2, "Should find 2 duplicate files"); 151 | assert_eq!(stats.total_wasted_space, 2 * 1024 * 1024, "Should waste 2MB"); 152 | 153 | Ok(()) 154 | } 155 | 156 | #[tokio::test] 157 | async fn test_scanner_non_recursive_scan() -> Result<()> { 158 | let temp_dir = TempDir::new()?; 159 | let root = temp_dir.path(); 160 | 161 | // Create files in root and subdirectories 162 | create_test_file(&root.join("root_photo.jpg"), b"ROOT_JPG", 1024 * 1024).await?; 163 | create_test_file(&root.join("root_video.mp4"), b"ROOT_MP4", 2 * 1024 * 1024).await?; 164 | create_test_file(&root.join("subdir/nested_photo.jpg"), b"NESTED_JPG", 1024 * 1024).await?; 165 | create_test_file(&root.join("subdir/deep/very_nested.png"), b"DEEP_PNG", 512 * 1024).await?; 166 | 167 | let settings = Settings { 168 | recurse_subfolders: false, // This is the key setting 169 | skip_hidden_files: false, 170 | ..Default::default() 171 | }; 172 | 173 | let scanner = create_test_scanner().await?; 174 | let progress = Arc::new(RwLock::new(Progress::default())); 175 | 176 | // Use recursive=false in scan_directory 177 | let files = scanner.scan_directory(root, false, progress, &settings, None).await?; 178 | 179 | // Should only find files in root directory 180 | assert_eq!(files.len(), 2, "Should only find 2 files in root directory"); 181 | 182 | // Verify all files are in root (no subdirectory files) 183 | for file in &files { 184 | assert_eq!( 185 | file.path.parent().unwrap(), 186 | root, 187 | "All files should be directly in root directory" 188 | ); 189 | } 190 | 191 | // Verify we got the expected files 192 | let file_names: Vec = files.iter().map(|f| f.name.to_string()).collect(); 193 | assert!(file_names.contains(&"root_photo.jpg".to_string())); 194 | assert!(file_names.contains(&"root_video.mp4".to_string())); 195 | assert!(!file_names.contains(&"nested_photo.jpg".to_string())); 196 | assert!(!file_names.contains(&"very_nested.png".to_string())); 197 | 198 | Ok(()) 199 | } 200 | 201 | #[tokio::test] 202 | async fn test_scanner_handles_empty_directories() -> Result<()> { 203 | let temp_dir = TempDir::new()?; 204 | let root = temp_dir.path(); 205 | 206 | // Create empty directories 207 | fs::create_dir_all(root.join("empty1")).await?; 208 | fs::create_dir_all(root.join("empty2/nested/deep")).await?; 209 | 210 | let settings = Settings { 211 | recurse_subfolders: true, 212 | ..Default::default() 213 | }; 214 | 215 | let scanner = create_test_scanner().await?; 216 | let progress = Arc::new(RwLock::new(Progress::default())); 217 | 218 | let (files, dups) = scanner 219 | .scan_directory_with_duplicates(root, true, progress, &settings, None) 220 | .await?; 221 | 222 | // Should find no files in empty directories 223 | assert_eq!(files.len(), 0, "Should find no files in empty directories"); 224 | assert!(dups.is_empty(), "Should find no duplicates in empty directories"); 225 | 226 | Ok(()) 227 | } 228 | -------------------------------------------------------------------------------- /tests/integration_tests.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | mod integration; 3 | -------------------------------------------------------------------------------- /tests/system/mod.rs: -------------------------------------------------------------------------------- 1 | mod workflow; 2 | -------------------------------------------------------------------------------- /tests/system_tests.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | mod system; 3 | --------------------------------------------------------------------------------