├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── args.rs ├── errors.rs ├── file_renamer.rs ├── lib.rs ├── main.rs └── term_utils.rs └── tests ├── increment.rs ├── opts.rs └── patterns.rs /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Credit for this workflow to Burntsushi and the contributors of Ripgrep. 2 | # https://github.com/BurntSushi/ripgrep/blob/16a1221fc70d586a07bd0421722635c61df525be/.github/workflows/release.yml 3 | # Their comments are prefixed with "> ". 4 | 5 | # > The way this works is a little weird. But basically, the create-release job 6 | # > runs purely to initialize the GitHub release itself. Once done, the upload 7 | # > URL of the release is saved as an artifact. 8 | # > 9 | # > The build-release job runs only once create-release is finished. It gets 10 | # > the release upload URL by downloading the corresponding artifact (which was 11 | # > uploaded by create-release). It then builds the release executables for each 12 | # > supported platform and attaches them as release assets to the previously 13 | # > created release. 14 | # > 15 | # > The key here is that we create the release only once. 16 | 17 | name: release 18 | 19 | on: 20 | push: 21 | tags: 22 | - 'v*' 23 | 24 | env: 25 | CARGO_TERM_COLOR: always 26 | 27 | jobs: 28 | create-release: 29 | name: Create Release 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v2 35 | 36 | - name: Get the release version from the tag 37 | run: | 38 | echo "::set-env name=RELEASE_VERSION::${GITHUB_REF#refs/tags/}" 39 | echo "version is: ${{ env.RELEASE_VERSION }}" 40 | 41 | - name: Create Release 42 | id: create_release 43 | uses: actions/create-release@v1 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 46 | with: 47 | tag_name: ${{ env.RELEASE_VERSION }} 48 | release_name: ${{ env.RELEASE_VERSION }} 49 | draft: false 50 | prerelease: false 51 | 52 | - name: Create artifacts dir 53 | run: mkdir artifacts 54 | 55 | - name: Save release upload URL to artifact 56 | run: echo "${{ steps.create_release.outputs.upload_url }}" > artifacts/release-upload-url 57 | 58 | - name: Save version number to artifact 59 | run: echo "${{ env.RELEASE_VERSION }}" > artifacts/release-version 60 | 61 | - name: Upload artifacts 62 | uses: actions/upload-artifact@v2 63 | with: 64 | name: artifacts 65 | path: artifacts 66 | 67 | build-release: 68 | name: build-release 69 | needs: ['create-release'] 70 | runs-on: ${{ matrix.os }} 71 | env: 72 | # > For some builds, use cross 73 | CARGO: cargo 74 | # > When CARGO is set to CROSS, this is set to `--target matrix.target`. 75 | TARGET_FLAGS: "" 76 | # > When CARGO is set to CROSS, TARGET_DIR includes matrix.target. 77 | TARGET_DIR: ./target 78 | # > Emit backtraces on panics. 79 | RUST_BACKTRACE: 1 80 | strategy: 81 | matrix: 82 | include: 83 | - name: linux-x86_64 84 | os: ubuntu-18.04 85 | target: x86_64-unknown-linux-gnu 86 | - name: linux-x86_64-musl 87 | os: ubuntu-18.04 88 | target: x86_64-unknown-linux-musl 89 | - name: linux-arm 90 | os: ubuntu-18.04 91 | target: arm-unknown-linux-gnueabihf 92 | - name: macos-x86_64 93 | os: macos-10.15 94 | target: x86_64-apple-darwin 95 | - name: windows-x86_64-gnu 96 | os: windows-2019 97 | target: x86_64-pc-windows-gnu 98 | - name: windows-x86_64-msvc 99 | os: windows-2019 100 | target: x86_64-pc-windows-msvc 101 | 102 | steps: 103 | - name: Checkout repository 104 | uses: actions/checkout@v2 105 | with: 106 | fetch-depth: 1 107 | 108 | - name: Get release download URL 109 | uses: actions/download-artifact@v2 110 | with: 111 | name: artifacts 112 | path: artifacts 113 | 114 | - name: Set release upload URL and release version 115 | shell: bash 116 | run: | 117 | release_upload_url="$(cat artifacts/release-upload-url)" 118 | echo "::set-env name=RELEASE_UPLOAD_URL::$release_upload_url" 119 | echo "release upload url: $RELEASE_UPLOAD_URL" 120 | release_version="$(cat artifacts/release-version)" 121 | echo "::set-env name=RELEASE_VERSION::$release_version" 122 | echo "release version: $RELEASE_VERSION" 123 | 124 | - name: Install Rust 125 | uses: actions-rs/toolchain@v1 126 | with: 127 | toolchain: stable 128 | profile: minimal 129 | override: true 130 | target: ${{ matrix.target }} 131 | 132 | - name: Use Cross 133 | if: matrix.os == 'ubuntu-18.04' 134 | run: | 135 | cargo install cross 136 | echo "::set-env name=CARGO::cross" 137 | echo "::set-env name=TARGET_FLAGS::--target ${{ matrix.target }}" 138 | echo "::set-env name=TARGET_DIR::./target/${{ matrix.target }}" 139 | 140 | - name: Show command used for Cargo 141 | run: | 142 | echo "cargo is: ${{ env.CARGO }}" 143 | echo "target flag is: ${{ env.TARGET_FLAGS }}" 144 | echo "target dir is: ${{ env.TARGET_DIR }}" 145 | 146 | - name: Build release binary 147 | run: ${{ env.CARGO }} build --verbose --release ${{ env.TARGET_FLAGS }} 148 | 149 | - name: Strip release binary (linux and macos) 150 | if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'x86_64-apple-darwin' 151 | run: strip "${{ env.TARGET_DIR }}/release/renamer" 152 | 153 | - name: Strip release binary (arm) 154 | if: matrix.target == 'arm-unknown-linux-gnueabihf' 155 | run: | 156 | docker run --rm -v \ 157 | "$PWD/target:/target:Z" \ 158 | rustembedded/cross:arm-unknown-linux-gnueabihf \ 159 | arm-linux-gnueabihf-strip \ 160 | /target/arm-unknown-linux-gnueabihf/release/renamer 161 | 162 | - name: Organize output 163 | shell: bash 164 | run: | 165 | executable_name=renamer-${{ env.RELEASE_VERSION }}-${{ matrix.name }} 166 | 167 | if [ "${{ matrix.os }}" = "windows-2019" ]; then 168 | executable_path="${{ env.TARGET_DIR }}/release/renamer.exe" 169 | executable_name="${executable_name}.exe" 170 | else 171 | executable_path="${{ env.TARGET_DIR }}/release/renamer" 172 | fi 173 | 174 | echo "::set-env name=EXECUTABLE_PATH::$executable_path" 175 | echo "::set-env name=EXECUTABLE_NAME::$executable_name" 176 | 177 | - name: Upload release archive 178 | uses: actions/upload-release-asset@v1 179 | env: 180 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 181 | with: 182 | upload_url: ${{ env.RELEASE_UPLOAD_URL }} 183 | asset_path: ${{ env.EXECUTABLE_PATH }} 184 | asset_name: ${{ env.EXECUTABLE_NAME }} 185 | asset_content_type: application/octet-stream 186 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | tests: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | release: ["", "--release"] 18 | 19 | runs-on: ${{ matrix.os }} 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Build 24 | run: cargo build --verbose ${{ matrix.release }} 25 | - name: Run tests 26 | run: cargo test --verbose ${{ matrix.release }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "aho-corasick" 5 | version = "0.7.13" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "043164d8ba5c4c3035fec9bbee8647c0261d788f3474306f93bb65901cae0e86" 8 | dependencies = [ 9 | "memchr", 10 | ] 11 | 12 | [[package]] 13 | name = "ansi_term" 14 | version = "0.11.0" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 17 | dependencies = [ 18 | "winapi", 19 | ] 20 | 21 | [[package]] 22 | name = "atty" 23 | version = "0.2.14" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 26 | dependencies = [ 27 | "hermit-abi", 28 | "libc", 29 | "winapi", 30 | ] 31 | 32 | [[package]] 33 | name = "bitflags" 34 | version = "1.2.1" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 37 | 38 | [[package]] 39 | name = "clap" 40 | version = "2.33.1" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129" 43 | dependencies = [ 44 | "ansi_term", 45 | "atty", 46 | "bitflags", 47 | "strsim", 48 | "textwrap", 49 | "unicode-width", 50 | "vec_map", 51 | ] 52 | 53 | [[package]] 54 | name = "heck" 55 | version = "0.3.1" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" 58 | dependencies = [ 59 | "unicode-segmentation", 60 | ] 61 | 62 | [[package]] 63 | name = "hermit-abi" 64 | version = "0.1.15" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "3deed196b6e7f9e44a2ae8d94225d80302d81208b1bb673fd21fe634645c85a9" 67 | dependencies = [ 68 | "libc", 69 | ] 70 | 71 | [[package]] 72 | name = "lazy_static" 73 | version = "1.4.0" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 76 | 77 | [[package]] 78 | name = "libc" 79 | version = "0.2.72" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "a9f8082297d534141b30c8d39e9b1773713ab50fdbe4ff30f750d063b3bfd701" 82 | 83 | [[package]] 84 | name = "memchr" 85 | version = "2.3.3" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" 88 | 89 | [[package]] 90 | name = "proc-macro-error" 91 | version = "1.0.3" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "fc175e9777c3116627248584e8f8b3e2987405cabe1c0adf7d1dd28f09dc7880" 94 | dependencies = [ 95 | "proc-macro-error-attr", 96 | "proc-macro2", 97 | "quote", 98 | "syn", 99 | "version_check", 100 | ] 101 | 102 | [[package]] 103 | name = "proc-macro-error-attr" 104 | version = "1.0.3" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "3cc9795ca17eb581285ec44936da7fc2335a3f34f2ddd13118b6f4d515435c50" 107 | dependencies = [ 108 | "proc-macro2", 109 | "quote", 110 | "syn", 111 | "syn-mid", 112 | "version_check", 113 | ] 114 | 115 | [[package]] 116 | name = "proc-macro2" 117 | version = "1.0.18" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "beae6331a816b1f65d04c45b078fd8e6c93e8071771f41b8163255bbd8d7c8fa" 120 | dependencies = [ 121 | "unicode-xid", 122 | ] 123 | 124 | [[package]] 125 | name = "quote" 126 | version = "1.0.7" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" 129 | dependencies = [ 130 | "proc-macro2", 131 | ] 132 | 133 | [[package]] 134 | name = "regex" 135 | version = "1.3.9" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "9c3780fcf44b193bc4d09f36d2a3c87b251da4a046c87795a0d35f4f927ad8e6" 138 | dependencies = [ 139 | "aho-corasick", 140 | "memchr", 141 | "regex-syntax", 142 | "thread_local", 143 | ] 144 | 145 | [[package]] 146 | name = "regex-syntax" 147 | version = "0.6.18" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "26412eb97c6b088a6997e05f69403a802a92d520de2f8e63c2b65f9e0f47c4e8" 150 | 151 | [[package]] 152 | name = "renamer" 153 | version = "0.2.0" 154 | dependencies = [ 155 | "regex", 156 | "structopt", 157 | ] 158 | 159 | [[package]] 160 | name = "strsim" 161 | version = "0.8.0" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 164 | 165 | [[package]] 166 | name = "structopt" 167 | version = "0.3.15" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "de2f5e239ee807089b62adce73e48c625e0ed80df02c7ab3f068f5db5281065c" 170 | dependencies = [ 171 | "clap", 172 | "lazy_static", 173 | "structopt-derive", 174 | ] 175 | 176 | [[package]] 177 | name = "structopt-derive" 178 | version = "0.4.8" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "510413f9de616762a4fbeab62509bf15c729603b72d7cd71280fbca431b1c118" 181 | dependencies = [ 182 | "heck", 183 | "proc-macro-error", 184 | "proc-macro2", 185 | "quote", 186 | "syn", 187 | ] 188 | 189 | [[package]] 190 | name = "syn" 191 | version = "1.0.34" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "936cae2873c940d92e697597c5eee105fb570cd5689c695806f672883653349b" 194 | dependencies = [ 195 | "proc-macro2", 196 | "quote", 197 | "unicode-xid", 198 | ] 199 | 200 | [[package]] 201 | name = "syn-mid" 202 | version = "0.5.0" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "7be3539f6c128a931cf19dcee741c1af532c7fd387baa739c03dd2e96479338a" 205 | dependencies = [ 206 | "proc-macro2", 207 | "quote", 208 | "syn", 209 | ] 210 | 211 | [[package]] 212 | name = "textwrap" 213 | version = "0.11.0" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 216 | dependencies = [ 217 | "unicode-width", 218 | ] 219 | 220 | [[package]] 221 | name = "thread_local" 222 | version = "1.0.1" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" 225 | dependencies = [ 226 | "lazy_static", 227 | ] 228 | 229 | [[package]] 230 | name = "unicode-segmentation" 231 | version = "1.6.0" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" 234 | 235 | [[package]] 236 | name = "unicode-width" 237 | version = "0.1.8" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 240 | 241 | [[package]] 242 | name = "unicode-xid" 243 | version = "0.2.1" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 246 | 247 | [[package]] 248 | name = "vec_map" 249 | version = "0.8.2" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 252 | 253 | [[package]] 254 | name = "version_check" 255 | version = "0.9.2" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" 258 | 259 | [[package]] 260 | name = "winapi" 261 | version = "0.3.9" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 264 | dependencies = [ 265 | "winapi-i686-pc-windows-gnu", 266 | "winapi-x86_64-pc-windows-gnu", 267 | ] 268 | 269 | [[package]] 270 | name = "winapi-i686-pc-windows-gnu" 271 | version = "0.4.0" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 274 | 275 | [[package]] 276 | name = "winapi-x86_64-pc-windows-gnu" 277 | version = "0.4.0" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 280 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "renamer" 3 | version = "0.2.0" 4 | authors = ["Adrian Göransson"] 5 | edition = "2018" 6 | description = "A command line tool to rename multiple files at once." 7 | license = "MIT" 8 | repository = "https://github.com/adriangoransson/renamer" 9 | readme = "README.md" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | regex = "1.3.9" 15 | structopt = "0.3.15" 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adrian Göransson 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Renamer 2 | A bulk renaming tool for files. 3 | 4 | ## Features 5 | 6 | * Rename one or several patterns in your files using the powerful [Rust regex engine](https://crates.io/crates/regex). 7 | * Add an increment as a prefix or suffix to files. 8 | 9 | And, uh, it's pretty speedy I guess? I'm hoping it's cross platform too but so far I have only tested it on *nix systems. 10 | 11 | ## Installation 12 | 13 | Have a look at the [releases page](https://github.com/adriangoransson/renamer/releases) for pre-built binaries. 14 | 15 | With [Cargo](https://github.com/rust-lang/cargo/). 16 | 17 | ```console 18 | $ cargo install renamer 19 | ``` 20 | 21 | ## Usage 22 | 23 | USAGE: 24 | renamer [FLAGS] [OPTIONS] ... 25 | 26 | FLAGS: 27 | -d, --dry-run Perform a dry-run. Do everything but the actual renaming. Implies verbose 28 | -f, --force Do not exit or ask for confirmation when overwriting files 29 | -g, --global Test the regular expression against all possible matches instead of only the first 30 | -h, --help Prints help information 31 | --ignore-invalid-files Ignores directories passed to the program as files. Useful for shell globbing 32 | -i, --interactive Ask for confirmation before overwrite. The program will otherwise exit unless --force 33 | is passed 34 | -V, --version Prints version information 35 | -v, --verbose Print operations as they are being performed 36 | 37 | OPTIONS: 38 | -e, --regexp ... 39 | Additional patterns. These can be supplied multiple times. Patterns are executed in the order they are 40 | passed, starting with the mandatory pattern 41 | --prefix-increment 42 | Prefix files with an increasing counter in the specified format. E.g. 0501 => 0501filename, 0502filename. 43 | Applied after pattern replacements 44 | --suffix-increment 45 | See --prefix-increment. Will try to insert suffix before the file extension 46 | 47 | 48 | ARGS: 49 | Regex pattern to match and the string to replace it with. (REGEX=REPLACEMENT) 50 | ... Files to rename 51 | 52 | ## Examples 53 | 54 | Add a prefix or a file extension. 55 | 56 | ```console 57 | # Add a prefix 58 | $ renamer '^=2020-07-18 ' img* 59 | 60 | # Add an extension 61 | $ renamer '$=.bak' file1 file2 62 | 63 | # Change extension 64 | $ renamer 'JPEG$=jpg' *.JPEG 65 | 66 | # Multiple patterns. Change extension and remove a prefix. 67 | $ renamer 'JPEG$=jpg' -e '^some_prefix_=' * 68 | ``` 69 | 70 | Rearrange parts of files. The following describes the various ways to use capture groups, including named groups. 71 | 72 | ```console 73 | $ renamer --verbose '(?P\d{2}\.) (.*)\.(?P)=${index} Lady Gaga - $2.$ext' *.mp3 74 | 01. Chromatica I.mp3 -> 01. Lady Gaga - Chromatica I.mp3 75 | 02. Alice.mp3 -> 02. Lady Gaga - Alice.mp3 76 | 03. Stupid Love.mp3 -> 03. Lady Gaga - Stupid Love.mp3 77 | 04. Rain On Me.mp3 -> 04. Lady Gaga - Rain On Me.mp3 78 | 05. Free Woman.mp3 -> 05. Lady Gaga - Free Woman.mp3 79 | 06. Fun Tonight.mp3 -> 06. Lady Gaga - Fun Tonight.mp3 80 | 07. Chromatica II.mp3 -> 07. Lady Gaga - Chromatica II.mp3 81 | 08. 911.mp3 -> 08. Lady Gaga - 911.mp3 82 | 09. Plastic Doll.mp3 -> 09. Lady Gaga - Plastic Doll.mp3 83 | 10. Sour Candy.mp3 -> 10. Lady Gaga - Sour Candy.mp3 84 | 11. Enigma.mp3 -> 11. Lady Gaga - Enigma.mp3 85 | 12. Replay.mp3 -> 12. Lady Gaga - Replay.mp3 86 | 13. Chromatica III.mp3 -> 13. Lady Gaga - Chromatica III.mp3 87 | 14. Sine From Above.mp3 -> 14. Lady Gaga - Sine From Above.mp3 88 | 15. 1000 Doves.mp3 -> 15. Lady Gaga - 1000 Doves.mp3 89 | ``` 90 | 91 | Add digits to easily sort files. Useful if you were to flatten directory structures but still want your files nicely sorted. 92 | 93 | ```console 94 | $ renamer -v '^=_' --prefix-increment 0201 Westworld01.mkv Westworld.S02E02.mkv Westworld_3.mkv 95 | Westworld01.mkv -> 0201_Westworld01.mkv 96 | Westworld.S02E02.mkv -> 0202_Westworld.S02E02.mkv 97 | Westworld_3.mkv -> 0203_Westworld_3.mkv 98 | ``` 99 | 100 | Also possible to add suffixes with `--prefix-suffix`. 101 | 102 | ## Acknowledgements 103 | Inspired greatly by the original [`rename.pl`](https://metacpan.org/source/PEDERST/rename-1.9/README.md). The aim is to have similar features but with faster execution time and a slightly more intuitive syntax for those not so familiar with regexes. 104 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::{error::Error, path::PathBuf}; 3 | use structopt::StructOpt; 4 | 5 | /// Parse a single key-value pair 6 | // https://github.com/TeXitoi/structopt/blob/master/examples/keyvalue.rs 7 | fn parse_pattern(s: &str) -> Result<(Regex, String), Box> { 8 | let pos = s 9 | .find('=') 10 | .ok_or_else(|| format!("invalid REGEX=REPLACEMENT: no `=` found in `{}`", s))?; 11 | 12 | let pattern = Regex::new(&s[..pos])?; 13 | 14 | Ok((pattern, s[pos + 1..].parse()?)) 15 | } 16 | 17 | fn parse_increment(s: &str) -> Result> { 18 | Ok(Increment { 19 | width: s.len(), 20 | start: s.parse()?, 21 | }) 22 | } 23 | 24 | // TODO: string patterns (like "005-" or "_01") around increment. 25 | #[derive(Debug, Copy, Clone)] 26 | pub struct Increment { 27 | pub width: usize, 28 | pub start: usize, 29 | } 30 | 31 | #[derive(Debug, StructOpt)] 32 | #[structopt(author, about)] 33 | pub struct Options { 34 | /// Test the regular expression against all possible matches instead of only the first. 35 | #[structopt(short, long)] 36 | pub global: bool, 37 | 38 | /// Perform a dry-run. Do everything but the actual renaming. Implies verbose. 39 | #[structopt(short, long)] 40 | pub dry_run: bool, 41 | 42 | /// Print operations as they are being performed. 43 | #[structopt(short, long)] 44 | pub verbose: bool, 45 | 46 | /// Do not exit or ask for confirmation when overwriting files. 47 | #[structopt(short, long)] 48 | pub force: bool, 49 | 50 | /// Ask for confirmation before overwrite. The program will otherwise exit unless --force is passed. 51 | #[structopt(short, long)] 52 | pub interactive: bool, 53 | 54 | /// Ignores directories passed to the program as files. Useful for shell globbing. 55 | #[structopt(long)] 56 | pub ignore_invalid_files: bool, 57 | 58 | /// Prefix files with an increasing counter in the specified format. E.g. 0501 => 0501filename, 0502filename. Applied after pattern replacements. 59 | #[structopt(long, parse(try_from_str = parse_increment))] 60 | pub prefix_increment: Option, 61 | 62 | /// See --prefix-increment. Will try to insert suffix before the file extension. 63 | #[structopt(long, parse(try_from_str = parse_increment))] 64 | pub suffix_increment: Option, 65 | 66 | /// Regex pattern to match and the string to replace it with. (REGEX=REPLACEMENT) 67 | #[structopt(required = true, parse(try_from_str = parse_pattern))] 68 | pub pattern: (Regex, String), 69 | 70 | /// Additional patterns. These can be supplied multiple times. Patterns are executed in the order they are passed, starting with the mandatory pattern. 71 | #[structopt(short = "e", long = "regexp", parse(try_from_str = parse_pattern), number_of_values = 1)] 72 | pub patterns: Vec<(Regex, String)>, 73 | 74 | /// Files to rename. 75 | #[structopt(required = true)] 76 | pub files: Vec, 77 | } 78 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, io, path::PathBuf}; 2 | 3 | #[derive(Debug)] 4 | pub enum RenameError { 5 | /// Errors originated by user input. 6 | InputError(InputError), 7 | 8 | /// General IO errors. 9 | Io(io::Error), 10 | } 11 | 12 | #[derive(Debug)] 13 | pub enum InputError { 14 | /// Received --force and --interactive. Not sure how to continue. 15 | ForceAndInteractive, 16 | 17 | /// Cannot rename `file`. `directory` is already a directory. 18 | CannotRenameFileToDirectory(PathBuf, PathBuf), 19 | 20 | /// `file`. Not overwriting `file` without --interactive or --force. 21 | SkippingOverwrite(PathBuf, PathBuf), 22 | 23 | /// `path` is not a file. If this is intentional, pass --ignore-invalid-files. 24 | InvalidFile(PathBuf), 25 | 26 | /// Invalid rename. `file` can't be renamed to `file`. 27 | InvalidRename(PathBuf, PathBuf), 28 | } 29 | 30 | impl Display for RenameError { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | match self { 33 | RenameError::InputError(err) => { 34 | let out = match err { 35 | InputError::ForceAndInteractive => { 36 | "Received --force and --interactive. Not sure how to continue.".to_string() 37 | } 38 | InputError::CannotRenameFileToDirectory(file, dir) => format!( 39 | "Cannot rename {}. {} is already a directory.", 40 | file.display(), dir.display() 41 | ), 42 | InputError::SkippingOverwrite(file, renamed) => format!( 43 | "{}. Not overwriting {} without --interactive or --force.", 44 | file.display(), renamed.display(), 45 | ), 46 | InputError::InvalidFile(path) => format!( 47 | "{} is not a file. If this is intentional, pass --ignore-invalid-files.", 48 | path.display() 49 | ), 50 | InputError::InvalidRename(path, renamed) => format!( 51 | "Invalid rename. {} can't be renamed to {}.", 52 | path.display(), renamed.display() 53 | ), 54 | }; 55 | 56 | write!(f, "{}", out) 57 | } 58 | RenameError::Io(err) => write!(f, "IO error {}", err), 59 | } 60 | } 61 | } 62 | 63 | impl From for RenameError { 64 | fn from(e: io::Error) -> Self { 65 | RenameError::Io(e) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/file_renamer.rs: -------------------------------------------------------------------------------- 1 | use crate::{args::Increment, errors::RenameError}; 2 | use regex::Regex; 3 | use std::{ 4 | io, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | pub struct FileRenamer { 9 | pub path: PathBuf, 10 | } 11 | 12 | #[derive(Debug)] 13 | pub enum IncrementPosition { 14 | Prefix, 15 | Suffix, 16 | } 17 | 18 | impl FileRenamer { 19 | /// Creates a new builder with the received path. 20 | pub fn new>(path: P) -> Self { 21 | FileRenamer { 22 | path: path.as_ref().to_owned(), 23 | } 24 | } 25 | 26 | fn file_name(&self) -> io::Result { 27 | let name = self 28 | .path 29 | .file_name() 30 | .expect("FileRenamer was created with an invalid file."); 31 | 32 | let s = name.to_str().ok_or_else(|| { 33 | io::Error::new( 34 | io::ErrorKind::Other, 35 | format!("File name {:?} contains invalid UTF-8.", name), 36 | ) 37 | })?; 38 | 39 | Ok(s.to_string()) 40 | } 41 | 42 | /// Apply the `regex=replacement` patterns provided to the file name in sequence. 43 | pub fn apply_patterns( 44 | &mut self, 45 | replace_all: bool, 46 | patterns: &[(Regex, String)], 47 | ) -> Result<&mut Self, RenameError> { 48 | let replace = if replace_all { 49 | Regex::replace_all 50 | } else { 51 | Regex::replace 52 | }; 53 | 54 | let mut file_name = self.file_name()?; 55 | 56 | for (regex, replacement) in patterns { 57 | let rep = replacement.as_str(); 58 | file_name = replace(regex, &file_name, rep).to_string(); 59 | } 60 | 61 | self.path.set_file_name(file_name); 62 | 63 | Ok(self) 64 | } 65 | 66 | /// Takes a position (`{pre,suf}fix`), an Increment struct with the width and starting index. 67 | /// The count specifies the current index or amount to add to the starting index. 68 | /// 69 | /// It will try to respect the naming of hidden files (preceding dot) so that they stay hidden. 70 | /// Extensions should also be preserved. 71 | pub fn increment( 72 | &mut self, 73 | position: IncrementPosition, 74 | increment: Increment, 75 | count: usize, 76 | ) -> Result<&mut Self, RenameError> { 77 | let mut file_name = self.file_name()?; 78 | 79 | let inc = format!( 80 | "{:0width$}", 81 | increment.start + count, 82 | width = increment.width 83 | ); 84 | 85 | file_name = interpolate_increment(file_name, &inc, position); 86 | 87 | self.path.set_file_name(file_name); 88 | 89 | Ok(self) 90 | } 91 | 92 | pub fn finish(self) -> PathBuf { 93 | self.path 94 | } 95 | } 96 | 97 | fn interpolate_increment(mut name: String, inc: &str, position: IncrementPosition) -> String { 98 | // Respect hidden files. 99 | let start_index = if name.starts_with('.') { 1 } else { 0 }; 100 | 101 | match position { 102 | IncrementPosition::Prefix => name.insert_str(start_index, &inc), 103 | IncrementPosition::Suffix => { 104 | let last_dot = name.rfind('.'); 105 | 106 | match last_dot { 107 | Some(i) if i > start_index => name.insert_str(i, &inc), 108 | _ => name.push_str(&inc), 109 | } 110 | } 111 | } 112 | 113 | name 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | use super::{interpolate_increment, IncrementPosition}; 119 | 120 | #[test] 121 | fn interpolate_hidden() { 122 | assert_eq!( 123 | ".vimrc123", 124 | interpolate_increment(".vimrc".to_string(), "123", IncrementPosition::Suffix) 125 | ); 126 | 127 | assert_eq!( 128 | ".003hidden.ext", 129 | interpolate_increment(".hidden.ext".to_string(), "003", IncrementPosition::Prefix) 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod args; 2 | pub mod errors; 3 | pub mod file_renamer; 4 | 5 | mod term_utils; 6 | 7 | use errors::{InputError, RenameError}; 8 | use file_renamer::{FileRenamer, IncrementPosition}; 9 | use std::collections::HashSet; 10 | use term_utils::{ask_for_confirmation, log}; 11 | 12 | pub fn run(opts: args::Options) -> Result<(), RenameError> { 13 | if opts.force && opts.interactive { 14 | return Err(RenameError::InputError(InputError::ForceAndInteractive)); 15 | } 16 | 17 | let verbose = opts.verbose || opts.dry_run; 18 | 19 | // Collect all patterns. The mandatory first and extras in order of input. 20 | let patterns = { 21 | let mut p = Vec::with_capacity(1 + opts.patterns.len()); 22 | 23 | p.push(opts.pattern); 24 | p.extend(opts.patterns); 25 | 26 | p 27 | }; 28 | 29 | // Dry run: keep track of unavailable file names. 30 | let mut paths = HashSet::new(); 31 | 32 | // The counter used for increment operations. Incremented for every iteration where a (dry) rename happened. 33 | let mut count = 0; 34 | 35 | for path in &opts.files { 36 | if path.is_file() { 37 | // Apply all renaming operations using a builder. 38 | let renamed = { 39 | let mut r = FileRenamer::new(path); 40 | 41 | r.apply_patterns(opts.global, &patterns)?; 42 | 43 | if let Some(prefix_increment) = opts.prefix_increment { 44 | r.increment(IncrementPosition::Prefix, prefix_increment, count)?; 45 | } 46 | 47 | if let Some(suffix_increment) = opts.suffix_increment { 48 | r.increment(IncrementPosition::Suffix, suffix_increment, count)?; 49 | } 50 | 51 | r.finish() 52 | }; 53 | 54 | if path == &renamed { 55 | if verbose { 56 | log(opts.dry_run, format!("No patterns match {}", path.display())); 57 | } 58 | 59 | continue; 60 | } 61 | 62 | if let Some(name) = renamed.file_stem() { 63 | let was_hidden = path 64 | .file_stem() 65 | .unwrap_or_else(|| panic!("No file stem for {}?", path.display())) 66 | .to_string_lossy() 67 | .starts_with('.'); 68 | 69 | if !was_hidden && name.to_string_lossy().starts_with('.') { 70 | log( 71 | opts.dry_run, 72 | format!("WARN: {} got prefix '.' and might be hidden.", renamed.display()), 73 | ); 74 | } 75 | } else { 76 | return Err(RenameError::InputError(InputError::InvalidRename( 77 | path.to_owned(), 78 | renamed, 79 | ))); 80 | } 81 | 82 | if renamed.is_dir() { 83 | return Err(RenameError::InputError( 84 | InputError::CannotRenameFileToDirectory(path.to_owned(), renamed), 85 | )); 86 | } 87 | 88 | if renamed.is_file() || paths.contains(&renamed) { 89 | if opts.interactive { 90 | if !ask_for_confirmation(format!("Overwrite {}?", renamed.display()))? { 91 | continue; 92 | } 93 | } else if !opts.force { 94 | return Err(RenameError::InputError(InputError::SkippingOverwrite( 95 | path.to_owned(), 96 | renamed, 97 | ))); 98 | } 99 | } 100 | 101 | if verbose { 102 | log(opts.dry_run, format!("{} -> {}", path.display(), renamed.display())); 103 | } 104 | 105 | if opts.dry_run { 106 | paths.insert(renamed); 107 | } else { 108 | std::fs::rename(path, renamed)?; 109 | } 110 | 111 | count += 1; 112 | } else if opts.ignore_invalid_files { 113 | if verbose { 114 | log(opts.dry_run, format!("Ignoring {}", path.display())); 115 | } 116 | } else { 117 | // path is not a file. It might not be a directory either. 118 | let current = std::env::current_dir()?.join(path); 119 | return Err(RenameError::InputError(InputError::InvalidFile(current))); 120 | } 121 | } 122 | 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use structopt::StructOpt; 2 | 3 | use renamer::{args::Options, run}; 4 | use std::process; 5 | 6 | fn main() { 7 | let options = Options::from_args(); 8 | 9 | if let Err(error) = run(options) { 10 | eprintln!("{} Exiting.", error); 11 | process::exit(1); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/term_utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::stdin; 2 | 3 | //FIXME: remove dependency on stdout 4 | pub(crate) fn log>(dry_run: bool, message: S) { 5 | if dry_run { 6 | print!("DRY "); 7 | } 8 | 9 | println!("{}", message.as_ref()); 10 | } 11 | 12 | pub(crate) fn ask_for_confirmation>( 13 | message: S, 14 | ) -> std::io::Result { 15 | eprint!("{} [y/N] ", message.as_ref()); 16 | let mut input = String::new(); 17 | 18 | stdin().read_line(&mut input)?; 19 | 20 | Ok(input.trim().to_lowercase() == "y") 21 | } 22 | -------------------------------------------------------------------------------- /tests/increment.rs: -------------------------------------------------------------------------------- 1 | use renamer::{args::Increment, file_renamer::*}; 2 | use std::path::PathBuf; 3 | 4 | #[test] 5 | fn increment() { 6 | let mut fr = FileRenamer::new(PathBuf::from("/tmp/renamertest/where_am_i")); 7 | 8 | fr.increment( 9 | IncrementPosition::Prefix, 10 | Increment { width: 4, start: 0 }, 11 | 775, 12 | ) 13 | .unwrap(); 14 | 15 | let result = fr.finish(); 16 | 17 | assert_eq!(PathBuf::from("/tmp/renamertest/0775where_am_i"), result); 18 | } 19 | 20 | #[test] 21 | /// This is intended behavior. The program will not attempt to deduplicate inputs. 22 | fn increment_twice() { 23 | let mut fr = FileRenamer::new(PathBuf::from("/tmp/renamertest/some test file.txt")); 24 | 25 | for count in 0..2 { 26 | fr.increment( 27 | IncrementPosition::Prefix, 28 | Increment { width: 4, start: 0 }, 29 | count, 30 | ) 31 | .unwrap(); 32 | } 33 | 34 | let result = fr.finish(); 35 | 36 | assert_eq!( 37 | PathBuf::from("/tmp/renamertest/00010000some test file.txt"), 38 | result 39 | ); 40 | } 41 | 42 | #[test] 43 | fn increment_multiple() { 44 | let files: Vec = vec!["hello.txt".into(), "goodbye.ini".into()]; 45 | 46 | let expected: Vec = vec!["hello12.txt".into(), "goodbye13.ini".into()]; 47 | 48 | let mut results = Vec::new(); 49 | for (count, path) in files.iter().enumerate() { 50 | let mut fr = FileRenamer::new(path); 51 | 52 | fr.increment( 53 | IncrementPosition::Suffix, 54 | Increment { 55 | start: 12, 56 | width: 2, 57 | }, 58 | count, 59 | ) 60 | .unwrap(); 61 | 62 | results.push(fr.finish()); 63 | } 64 | 65 | assert_eq!(expected, results); 66 | } 67 | 68 | #[test] 69 | fn increment_hidden_with_ext() { 70 | let files: Vec = vec![".xinitrc".into(), ".hidden.config".into()]; 71 | let expected: Vec = vec![".122xinitrc0455".into(), ".123hidden0456.config".into()]; 72 | 73 | let mut results = vec![]; 74 | for (count, path) in files.iter().enumerate() { 75 | let mut fr = FileRenamer::new(path); 76 | 77 | fr.increment( 78 | IncrementPosition::Prefix, 79 | Increment { 80 | start: 122, 81 | width: 3, 82 | }, 83 | count, 84 | ) 85 | .unwrap(); 86 | 87 | fr.increment( 88 | IncrementPosition::Suffix, 89 | Increment { 90 | start: 455, 91 | width: 4, 92 | }, 93 | count, 94 | ) 95 | .unwrap(); 96 | 97 | results.push(fr.finish()); 98 | } 99 | 100 | assert_eq!(expected, results); 101 | } 102 | -------------------------------------------------------------------------------- /tests/opts.rs: -------------------------------------------------------------------------------- 1 | use renamer::{args::Options, run}; 2 | 3 | #[test] 4 | fn no_force_and_interactive() { 5 | #[allow(clippy::trivial_regex)] 6 | let result = run(Options { 7 | global: true, 8 | dry_run: true, 9 | verbose: true, 10 | force: true, 11 | interactive: true, 12 | ignore_invalid_files: true, 13 | prefix_increment: None, 14 | suffix_increment: None, 15 | pattern: (regex::Regex::new("").unwrap(), String::new()), 16 | patterns: vec![], 17 | files: vec![], 18 | }); 19 | 20 | assert!(matches!( 21 | result, 22 | Err(renamer::errors::RenameError::InputError( 23 | renamer::errors::InputError::ForceAndInteractive 24 | )) 25 | )); 26 | } 27 | -------------------------------------------------------------------------------- /tests/patterns.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use renamer::file_renamer::*; 3 | use std::path::PathBuf; 4 | 5 | fn test_helper(initial: &str, replace_all: bool, patterns: Vec<(&str, &str)>) -> PathBuf { 6 | let mut fr = FileRenamer::new(PathBuf::from(initial)); 7 | 8 | let transformed_patterns = patterns 9 | .into_iter() 10 | .map(|(k, v)| (Regex::new(k).unwrap(), v.to_owned())) 11 | .collect::>(); 12 | 13 | fr.apply_patterns(replace_all, &transformed_patterns) 14 | .unwrap(); 15 | 16 | fr.finish() 17 | } 18 | 19 | #[test] 20 | fn single_pattern() { 21 | assert_eq!( 22 | PathBuf::from("/tmp/SOme sort of file"), 23 | test_helper("/tmp/Some sort of file", false, vec![("o", "O")]) 24 | ); 25 | } 26 | 27 | #[test] 28 | fn single_pattern_replace_all() { 29 | assert_eq!( 30 | PathBuf::from("wobdobdoo.yob"), 31 | test_helper("wabbadabbadoo.yabba", true, vec![("abba", "ob")]) 32 | ); 33 | } 34 | 35 | #[test] 36 | fn add_prefix() { 37 | assert_eq!( 38 | PathBuf::from("Beginning of string-some string"), 39 | test_helper("some string", false, vec![("^", "Beginning of string-")]) 40 | ); 41 | } 42 | 43 | #[test] 44 | fn rearrange() { 45 | assert_eq!( 46 | PathBuf::from("Song Artist.mp3"), 47 | test_helper( 48 | "Artist_Song.mp3", 49 | false, 50 | vec![("(Artist)_(?PSong)", "${named} $1")] 51 | ) 52 | ); 53 | } 54 | 55 | #[test] 56 | fn multiple_patterns() { 57 | assert_eq!( 58 | PathBuf::from("Song Artist.aac"), 59 | test_helper( 60 | "Artist_Song.mp3", 61 | false, 62 | vec![("(Artist)_(?PSong)", "${named} $1"), ("mp3", "aac")] 63 | ) 64 | ); 65 | } 66 | --------------------------------------------------------------------------------