├── .github └── workflows │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── benches └── cache.rs ├── rust-toolchain.toml ├── rustfmt.toml ├── screenshot_details.png ├── screenshot_marks.png ├── screenshot_start.png └── src ├── args.rs ├── cache.rs ├── cache ├── filetree.rs ├── sql │ ├── none_to_v0.sql │ ├── none_to_v1.sql │ └── v0_to_v1.sql └── tests.rs ├── lib.rs ├── main.rs ├── restic.rs ├── ui.rs └── util.rs /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build-linux-x86_64: 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Rustfmt Check 18 | run: cargo fmt --check 19 | - name: Add x86_64-unknown-linux-musl target 20 | run: | 21 | rustup target add x86_64-unknown-linux-musl 22 | sudo apt-get -y update 23 | sudo apt-get -y install musl-dev musl-tools 24 | - name: Build 25 | run: cargo build --target=x86_64-unknown-linux-musl --verbose 26 | - name: Run tests 27 | run: cargo test --verbose 28 | - name: Build benches 29 | run: cargo bench --features bench --no-run 30 | build-linux-arm64: 31 | runs-on: ubuntu-24.04 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Install cross 35 | run: cargo install cross 36 | - name: Build 37 | run: cross build --target aarch64-unknown-linux-musl --verbose 38 | build-darwin-x86_64: 39 | runs-on: macos-13 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Build 43 | run: cargo build --verbose 44 | build-darwin-arm64: 45 | runs-on: macos-14 46 | steps: 47 | - uses: actions/checkout@v4 48 | - name: Build 49 | run: cargo build --verbose 50 | build-windows-x86_64: 51 | runs-on: windows-2022 52 | steps: 53 | - uses: actions/checkout@v4 54 | - name: Build 55 | run: cargo build --target=x86_64-pc-windows-msvc --verbose 56 | - name: Run tests 57 | run: cargo test --verbose 58 | build-windows-arm64: 59 | runs-on: windows-2022 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Add aarch64-pc-windows-msvc target 63 | run: rustup target add aarch64-pc-windows-msvc 64 | - name: Build 65 | run: cargo build --target=aarch64-pc-windows-msvc --verbose 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | set-env: 12 | runs-on: ubuntu-24.04 13 | outputs: 14 | name: ${{steps.vars.outputs.name}} 15 | version: ${{steps.vars.outputs.version}} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - id: vars 19 | run: | 20 | set -e -o pipefail 21 | echo "NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name')" >> "$GITHUB_OUTPUT" 22 | echo "VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].version')" >> "$GITHUB_OUTPUT" 23 | build-linux-x86_64: 24 | needs: set-env 25 | runs-on: ubuntu-24.04 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Rustfmt Check 29 | run: cargo fmt --check 30 | - name: Add x86_64-unknown-linux-musl target 31 | run: | 32 | rustup target add x86_64-unknown-linux-musl 33 | sudo apt-get -y update 34 | sudo apt-get -y install musl-dev musl-tools 35 | - name: Build 36 | run: cargo build --target=x86_64-unknown-linux-musl --release --verbose 37 | - name: Run tests 38 | run: cargo test --verbose 39 | - name: Compress 40 | run: > 41 | cat "target/x86_64-unknown-linux-musl/release/${{needs.set-env.outputs.name}}" 42 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-x86_64.bz2 43 | - name: Upload 44 | uses: diamondburned/action-upload-release@v0.0.1 45 | with: 46 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-x86_64.bz2 47 | build-linux-arm64: 48 | needs: set-env 49 | runs-on: ubuntu-24.04 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Install cross 53 | run: cargo install cross 54 | - name: Build 55 | run: cross build --target aarch64-unknown-linux-musl --release --verbose 56 | - name: Compress 57 | run: > 58 | cat "target/aarch64-unknown-linux-musl/release/${{needs.set-env.outputs.name}}" 59 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-arm64.bz2 60 | - name: Upload 61 | uses: diamondburned/action-upload-release@v0.0.1 62 | with: 63 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-arm64.bz2 64 | build-darwin-x86_64: 65 | needs: set-env 66 | runs-on: macos-13 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Build 70 | run: cargo build --release --verbose 71 | - name: Compress 72 | run: > 73 | cat "target/release/${{needs.set-env.outputs.name}}" 74 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-x86_64.bz2 75 | - name: Upload 76 | uses: diamondburned/action-upload-release@v0.0.1 77 | with: 78 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-x86_64.bz2 79 | build-darwin-arm64: 80 | needs: set-env 81 | runs-on: macos-14 82 | steps: 83 | - uses: actions/checkout@v4 84 | - name: Build 85 | run: cargo build --release --verbose 86 | - name: Compress 87 | run: > 88 | cat "target/release/${{needs.set-env.outputs.name}}" 89 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-arm64.bz2 90 | - name: Upload 91 | uses: diamondburned/action-upload-release@v0.0.1 92 | with: 93 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-arm64.bz2 94 | build-windows-x86_64: 95 | needs: set-env 96 | runs-on: windows-2022 97 | steps: 98 | - uses: actions/checkout@v4 99 | - name: Build 100 | run: cargo build --release --verbose 101 | - name: Run tests 102 | run: cargo test --verbose 103 | - name: Compress 104 | run: > 105 | Compress-Archive 106 | target/release/${{needs.set-env.outputs.name}}.exe 107 | ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-x86_64.zip 108 | - name: Upload artifact 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: windows-x86_64-release 112 | path: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-x86_64.zip 113 | upload-windows-x86_64: 114 | needs: [set-env, build-windows-x86_64] 115 | runs-on: ubuntu-24.04 116 | steps: 117 | - name: Download artifact 118 | uses: actions/download-artifact@v4 119 | with: 120 | name: windows-x86_64-release 121 | - name: Upload 122 | uses: diamondburned/action-upload-release@v0.0.1 123 | with: 124 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-x86_64.zip 125 | build-windows-arm64: 126 | needs: set-env 127 | runs-on: windows-2022 128 | steps: 129 | - uses: actions/checkout@v4 130 | - name: Add aarch64-pc-windows-msvc target 131 | run: | 132 | rustup target add aarch64-pc-windows-msvc 133 | - name: Build 134 | run: cargo build --release --target=aarch64-pc-windows-msvc --verbose 135 | - name: Compress 136 | run: > 137 | Compress-Archive 138 | target/aarch64-pc-windows-msvc/release/${{needs.set-env.outputs.name}}.exe 139 | ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-arm64.zip 140 | - name: Upload artifact 141 | uses: actions/upload-artifact@v4 142 | with: 143 | name: windows-arm64-release 144 | path: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-arm64.zip 145 | upload-windows-arm64: 146 | needs: [set-env, build-windows-arm64] 147 | runs-on: ubuntu-24.04 148 | steps: 149 | - name: Download artifact 150 | uses: actions/download-artifact@v4 151 | with: 152 | name: windows-arm64-release 153 | - name: Upload 154 | uses: diamondburned/action-upload-release@v0.0.1 155 | with: 156 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-arm64.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .envrc 3 | .idea 4 | /scripts/target 5 | /target 6 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "allocator-api2" 16 | version = "0.2.21" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 | 20 | [[package]] 21 | name = "android-tzdata" 22 | version = "0.1.1" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 25 | 26 | [[package]] 27 | name = "android_system_properties" 28 | version = "0.1.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 31 | dependencies = [ 32 | "libc", 33 | ] 34 | 35 | [[package]] 36 | name = "anes" 37 | version = "0.1.6" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" 40 | 41 | [[package]] 42 | name = "anstream" 43 | version = "0.6.18" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 46 | dependencies = [ 47 | "anstyle", 48 | "anstyle-parse", 49 | "anstyle-query", 50 | "anstyle-wincon", 51 | "colorchoice", 52 | "is_terminal_polyfill", 53 | "utf8parse", 54 | ] 55 | 56 | [[package]] 57 | name = "anstyle" 58 | version = "1.0.10" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 61 | 62 | [[package]] 63 | name = "anstyle-parse" 64 | version = "0.2.6" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 67 | dependencies = [ 68 | "utf8parse", 69 | ] 70 | 71 | [[package]] 72 | name = "anstyle-query" 73 | version = "1.1.2" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 76 | dependencies = [ 77 | "windows-sys 0.59.0", 78 | ] 79 | 80 | [[package]] 81 | name = "anstyle-wincon" 82 | version = "3.0.7" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 85 | dependencies = [ 86 | "anstyle", 87 | "once_cell", 88 | "windows-sys 0.59.0", 89 | ] 90 | 91 | [[package]] 92 | name = "anyhow" 93 | version = "1.0.98" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 96 | 97 | [[package]] 98 | name = "autocfg" 99 | version = "1.4.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 102 | 103 | [[package]] 104 | name = "bitflags" 105 | version = "2.9.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 108 | 109 | [[package]] 110 | name = "bumpalo" 111 | version = "3.17.0" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 114 | 115 | [[package]] 116 | name = "camino" 117 | version = "1.1.9" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" 120 | 121 | [[package]] 122 | name = "cassowary" 123 | version = "0.3.0" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 126 | 127 | [[package]] 128 | name = "cast" 129 | version = "0.3.0" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" 132 | 133 | [[package]] 134 | name = "castaway" 135 | version = "0.2.3" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" 138 | dependencies = [ 139 | "rustversion", 140 | ] 141 | 142 | [[package]] 143 | name = "cc" 144 | version = "1.2.19" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" 147 | dependencies = [ 148 | "shlex", 149 | ] 150 | 151 | [[package]] 152 | name = "cfg-if" 153 | version = "1.0.0" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 156 | 157 | [[package]] 158 | name = "cfg_aliases" 159 | version = "0.2.1" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 162 | 163 | [[package]] 164 | name = "chrono" 165 | version = "0.4.40" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" 168 | dependencies = [ 169 | "android-tzdata", 170 | "iana-time-zone", 171 | "js-sys", 172 | "num-traits", 173 | "serde", 174 | "wasm-bindgen", 175 | "windows-link", 176 | ] 177 | 178 | [[package]] 179 | name = "ciborium" 180 | version = "0.2.2" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" 183 | dependencies = [ 184 | "ciborium-io", 185 | "ciborium-ll", 186 | "serde", 187 | ] 188 | 189 | [[package]] 190 | name = "ciborium-io" 191 | version = "0.2.2" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" 194 | 195 | [[package]] 196 | name = "ciborium-ll" 197 | version = "0.2.2" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" 200 | dependencies = [ 201 | "ciborium-io", 202 | "half", 203 | ] 204 | 205 | [[package]] 206 | name = "clap" 207 | version = "4.5.37" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" 210 | dependencies = [ 211 | "clap_builder", 212 | "clap_derive", 213 | ] 214 | 215 | [[package]] 216 | name = "clap_builder" 217 | version = "4.5.37" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" 220 | dependencies = [ 221 | "anstream", 222 | "anstyle", 223 | "clap_lex", 224 | "strsim", 225 | ] 226 | 227 | [[package]] 228 | name = "clap_derive" 229 | version = "4.5.32" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 232 | dependencies = [ 233 | "heck", 234 | "proc-macro2", 235 | "quote", 236 | "syn", 237 | ] 238 | 239 | [[package]] 240 | name = "clap_lex" 241 | version = "0.7.4" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 244 | 245 | [[package]] 246 | name = "colorchoice" 247 | version = "1.0.3" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 250 | 251 | [[package]] 252 | name = "compact_str" 253 | version = "0.8.1" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 256 | dependencies = [ 257 | "castaway", 258 | "cfg-if", 259 | "itoa", 260 | "rustversion", 261 | "ryu", 262 | "static_assertions", 263 | ] 264 | 265 | [[package]] 266 | name = "console" 267 | version = "0.15.11" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" 270 | dependencies = [ 271 | "encode_unicode", 272 | "libc", 273 | "once_cell", 274 | "unicode-width 0.2.0", 275 | "windows-sys 0.59.0", 276 | ] 277 | 278 | [[package]] 279 | name = "convert_case" 280 | version = "0.7.1" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" 283 | dependencies = [ 284 | "unicode-segmentation", 285 | ] 286 | 287 | [[package]] 288 | name = "core-foundation-sys" 289 | version = "0.8.7" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 292 | 293 | [[package]] 294 | name = "criterion" 295 | version = "0.5.1" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" 298 | dependencies = [ 299 | "anes", 300 | "cast", 301 | "ciborium", 302 | "clap", 303 | "criterion-plot", 304 | "is-terminal", 305 | "itertools 0.10.5", 306 | "num-traits", 307 | "once_cell", 308 | "oorandom", 309 | "plotters", 310 | "rayon", 311 | "regex", 312 | "serde", 313 | "serde_derive", 314 | "serde_json", 315 | "tinytemplate", 316 | "walkdir", 317 | ] 318 | 319 | [[package]] 320 | name = "criterion-plot" 321 | version = "0.5.0" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" 324 | dependencies = [ 325 | "cast", 326 | "itertools 0.10.5", 327 | ] 328 | 329 | [[package]] 330 | name = "crossbeam-deque" 331 | version = "0.8.6" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 334 | dependencies = [ 335 | "crossbeam-epoch", 336 | "crossbeam-utils", 337 | ] 338 | 339 | [[package]] 340 | name = "crossbeam-epoch" 341 | version = "0.9.18" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 344 | dependencies = [ 345 | "crossbeam-utils", 346 | ] 347 | 348 | [[package]] 349 | name = "crossbeam-utils" 350 | version = "0.8.21" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 353 | 354 | [[package]] 355 | name = "crossterm" 356 | version = "0.28.1" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 359 | dependencies = [ 360 | "bitflags", 361 | "crossterm_winapi", 362 | "mio", 363 | "parking_lot", 364 | "rustix 0.38.44", 365 | "signal-hook", 366 | "signal-hook-mio", 367 | "winapi", 368 | ] 369 | 370 | [[package]] 371 | name = "crossterm" 372 | version = "0.29.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" 375 | dependencies = [ 376 | "bitflags", 377 | "crossterm_winapi", 378 | "derive_more", 379 | "document-features", 380 | "mio", 381 | "parking_lot", 382 | "rustix 1.0.5", 383 | "signal-hook", 384 | "signal-hook-mio", 385 | "winapi", 386 | ] 387 | 388 | [[package]] 389 | name = "crossterm_winapi" 390 | version = "0.9.1" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 393 | dependencies = [ 394 | "winapi", 395 | ] 396 | 397 | [[package]] 398 | name = "crunchy" 399 | version = "0.2.3" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" 402 | 403 | [[package]] 404 | name = "darling" 405 | version = "0.20.11" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 408 | dependencies = [ 409 | "darling_core", 410 | "darling_macro", 411 | ] 412 | 413 | [[package]] 414 | name = "darling_core" 415 | version = "0.20.11" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 418 | dependencies = [ 419 | "fnv", 420 | "ident_case", 421 | "proc-macro2", 422 | "quote", 423 | "strsim", 424 | "syn", 425 | ] 426 | 427 | [[package]] 428 | name = "darling_macro" 429 | version = "0.20.11" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 432 | dependencies = [ 433 | "darling_core", 434 | "quote", 435 | "syn", 436 | ] 437 | 438 | [[package]] 439 | name = "deranged" 440 | version = "0.4.0" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 443 | dependencies = [ 444 | "powerfmt", 445 | ] 446 | 447 | [[package]] 448 | name = "derive_more" 449 | version = "2.0.1" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 452 | dependencies = [ 453 | "derive_more-impl", 454 | ] 455 | 456 | [[package]] 457 | name = "derive_more-impl" 458 | version = "2.0.1" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 461 | dependencies = [ 462 | "convert_case", 463 | "proc-macro2", 464 | "quote", 465 | "syn", 466 | ] 467 | 468 | [[package]] 469 | name = "directories" 470 | version = "6.0.0" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" 473 | dependencies = [ 474 | "dirs-sys", 475 | ] 476 | 477 | [[package]] 478 | name = "dirs-sys" 479 | version = "0.5.0" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 482 | dependencies = [ 483 | "libc", 484 | "option-ext", 485 | "redox_users", 486 | "windows-sys 0.59.0", 487 | ] 488 | 489 | [[package]] 490 | name = "document-features" 491 | version = "0.2.11" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" 494 | dependencies = [ 495 | "litrs", 496 | ] 497 | 498 | [[package]] 499 | name = "either" 500 | version = "1.15.0" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 503 | 504 | [[package]] 505 | name = "encode_unicode" 506 | version = "1.0.0" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 509 | 510 | [[package]] 511 | name = "equivalent" 512 | version = "1.0.2" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 515 | 516 | [[package]] 517 | name = "errno" 518 | version = "0.3.11" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" 521 | dependencies = [ 522 | "libc", 523 | "windows-sys 0.59.0", 524 | ] 525 | 526 | [[package]] 527 | name = "fallible-iterator" 528 | version = "0.3.0" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 531 | 532 | [[package]] 533 | name = "fallible-streaming-iterator" 534 | version = "0.1.9" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 537 | 538 | [[package]] 539 | name = "fnv" 540 | version = "1.0.7" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 543 | 544 | [[package]] 545 | name = "foldhash" 546 | version = "0.1.5" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 549 | 550 | [[package]] 551 | name = "getrandom" 552 | version = "0.2.15" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 555 | dependencies = [ 556 | "cfg-if", 557 | "libc", 558 | "wasi 0.11.0+wasi-snapshot-preview1", 559 | ] 560 | 561 | [[package]] 562 | name = "getrandom" 563 | version = "0.3.2" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" 566 | dependencies = [ 567 | "cfg-if", 568 | "libc", 569 | "r-efi", 570 | "wasi 0.14.2+wasi-0.2.4", 571 | ] 572 | 573 | [[package]] 574 | name = "half" 575 | version = "2.6.0" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" 578 | dependencies = [ 579 | "cfg-if", 580 | "crunchy", 581 | ] 582 | 583 | [[package]] 584 | name = "hashbrown" 585 | version = "0.15.2" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 588 | dependencies = [ 589 | "allocator-api2", 590 | "equivalent", 591 | "foldhash", 592 | ] 593 | 594 | [[package]] 595 | name = "hashlink" 596 | version = "0.10.0" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 599 | dependencies = [ 600 | "hashbrown", 601 | ] 602 | 603 | [[package]] 604 | name = "heck" 605 | version = "0.5.0" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 608 | 609 | [[package]] 610 | name = "hermit-abi" 611 | version = "0.5.0" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" 614 | 615 | [[package]] 616 | name = "humansize" 617 | version = "2.1.3" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" 620 | dependencies = [ 621 | "libm", 622 | ] 623 | 624 | [[package]] 625 | name = "iana-time-zone" 626 | version = "0.1.63" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 629 | dependencies = [ 630 | "android_system_properties", 631 | "core-foundation-sys", 632 | "iana-time-zone-haiku", 633 | "js-sys", 634 | "log", 635 | "wasm-bindgen", 636 | "windows-core", 637 | ] 638 | 639 | [[package]] 640 | name = "iana-time-zone-haiku" 641 | version = "0.1.2" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 644 | dependencies = [ 645 | "cc", 646 | ] 647 | 648 | [[package]] 649 | name = "ident_case" 650 | version = "1.0.1" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 653 | 654 | [[package]] 655 | name = "indicatif" 656 | version = "0.17.11" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" 659 | dependencies = [ 660 | "console", 661 | "number_prefix", 662 | "portable-atomic", 663 | "unicode-width 0.2.0", 664 | "web-time", 665 | ] 666 | 667 | [[package]] 668 | name = "indoc" 669 | version = "2.0.6" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 672 | 673 | [[package]] 674 | name = "instability" 675 | version = "0.3.7" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" 678 | dependencies = [ 679 | "darling", 680 | "indoc", 681 | "proc-macro2", 682 | "quote", 683 | "syn", 684 | ] 685 | 686 | [[package]] 687 | name = "is-terminal" 688 | version = "0.4.16" 689 | source = "registry+https://github.com/rust-lang/crates.io-index" 690 | checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" 691 | dependencies = [ 692 | "hermit-abi", 693 | "libc", 694 | "windows-sys 0.59.0", 695 | ] 696 | 697 | [[package]] 698 | name = "is_terminal_polyfill" 699 | version = "1.70.1" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 702 | 703 | [[package]] 704 | name = "itertools" 705 | version = "0.10.5" 706 | source = "registry+https://github.com/rust-lang/crates.io-index" 707 | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" 708 | dependencies = [ 709 | "either", 710 | ] 711 | 712 | [[package]] 713 | name = "itertools" 714 | version = "0.13.0" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 717 | dependencies = [ 718 | "either", 719 | ] 720 | 721 | [[package]] 722 | name = "itoa" 723 | version = "1.0.15" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 726 | 727 | [[package]] 728 | name = "js-sys" 729 | version = "0.3.77" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 732 | dependencies = [ 733 | "once_cell", 734 | "wasm-bindgen", 735 | ] 736 | 737 | [[package]] 738 | name = "libc" 739 | version = "0.2.172" 740 | source = "registry+https://github.com/rust-lang/crates.io-index" 741 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 742 | 743 | [[package]] 744 | name = "libm" 745 | version = "0.2.13" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72" 748 | 749 | [[package]] 750 | name = "libredox" 751 | version = "0.1.3" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 754 | dependencies = [ 755 | "bitflags", 756 | "libc", 757 | ] 758 | 759 | [[package]] 760 | name = "libsqlite3-sys" 761 | version = "0.33.0" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" 764 | dependencies = [ 765 | "cc", 766 | "pkg-config", 767 | "vcpkg", 768 | ] 769 | 770 | [[package]] 771 | name = "linux-raw-sys" 772 | version = "0.4.15" 773 | source = "registry+https://github.com/rust-lang/crates.io-index" 774 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 775 | 776 | [[package]] 777 | name = "linux-raw-sys" 778 | version = "0.9.4" 779 | source = "registry+https://github.com/rust-lang/crates.io-index" 780 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 781 | 782 | [[package]] 783 | name = "litrs" 784 | version = "0.4.1" 785 | source = "registry+https://github.com/rust-lang/crates.io-index" 786 | checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" 787 | 788 | [[package]] 789 | name = "lock_api" 790 | version = "0.4.12" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 793 | dependencies = [ 794 | "autocfg", 795 | "scopeguard", 796 | ] 797 | 798 | [[package]] 799 | name = "log" 800 | version = "0.4.27" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 803 | 804 | [[package]] 805 | name = "lru" 806 | version = "0.12.5" 807 | source = "registry+https://github.com/rust-lang/crates.io-index" 808 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 809 | dependencies = [ 810 | "hashbrown", 811 | ] 812 | 813 | [[package]] 814 | name = "memchr" 815 | version = "2.7.4" 816 | source = "registry+https://github.com/rust-lang/crates.io-index" 817 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 818 | 819 | [[package]] 820 | name = "mio" 821 | version = "1.0.3" 822 | source = "registry+https://github.com/rust-lang/crates.io-index" 823 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 824 | dependencies = [ 825 | "libc", 826 | "log", 827 | "wasi 0.11.0+wasi-snapshot-preview1", 828 | "windows-sys 0.52.0", 829 | ] 830 | 831 | [[package]] 832 | name = "nix" 833 | version = "0.29.0" 834 | source = "registry+https://github.com/rust-lang/crates.io-index" 835 | checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" 836 | dependencies = [ 837 | "bitflags", 838 | "cfg-if", 839 | "cfg_aliases", 840 | "libc", 841 | ] 842 | 843 | [[package]] 844 | name = "num-conv" 845 | version = "0.1.0" 846 | source = "registry+https://github.com/rust-lang/crates.io-index" 847 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 848 | 849 | [[package]] 850 | name = "num-traits" 851 | version = "0.2.19" 852 | source = "registry+https://github.com/rust-lang/crates.io-index" 853 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 854 | dependencies = [ 855 | "autocfg", 856 | ] 857 | 858 | [[package]] 859 | name = "num_threads" 860 | version = "0.1.7" 861 | source = "registry+https://github.com/rust-lang/crates.io-index" 862 | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 863 | dependencies = [ 864 | "libc", 865 | ] 866 | 867 | [[package]] 868 | name = "number_prefix" 869 | version = "0.4.0" 870 | source = "registry+https://github.com/rust-lang/crates.io-index" 871 | checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" 872 | 873 | [[package]] 874 | name = "once_cell" 875 | version = "1.21.3" 876 | source = "registry+https://github.com/rust-lang/crates.io-index" 877 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 878 | 879 | [[package]] 880 | name = "oorandom" 881 | version = "11.1.5" 882 | source = "registry+https://github.com/rust-lang/crates.io-index" 883 | checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" 884 | 885 | [[package]] 886 | name = "option-ext" 887 | version = "0.2.0" 888 | source = "registry+https://github.com/rust-lang/crates.io-index" 889 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 890 | 891 | [[package]] 892 | name = "parking_lot" 893 | version = "0.12.3" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 896 | dependencies = [ 897 | "lock_api", 898 | "parking_lot_core", 899 | ] 900 | 901 | [[package]] 902 | name = "parking_lot_core" 903 | version = "0.9.10" 904 | source = "registry+https://github.com/rust-lang/crates.io-index" 905 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 906 | dependencies = [ 907 | "cfg-if", 908 | "libc", 909 | "redox_syscall", 910 | "smallvec", 911 | "windows-targets", 912 | ] 913 | 914 | [[package]] 915 | name = "paste" 916 | version = "1.0.15" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 919 | 920 | [[package]] 921 | name = "pkg-config" 922 | version = "0.3.32" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 925 | 926 | [[package]] 927 | name = "plotters" 928 | version = "0.3.7" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" 931 | dependencies = [ 932 | "num-traits", 933 | "plotters-backend", 934 | "plotters-svg", 935 | "wasm-bindgen", 936 | "web-sys", 937 | ] 938 | 939 | [[package]] 940 | name = "plotters-backend" 941 | version = "0.3.7" 942 | source = "registry+https://github.com/rust-lang/crates.io-index" 943 | checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" 944 | 945 | [[package]] 946 | name = "plotters-svg" 947 | version = "0.3.7" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" 950 | dependencies = [ 951 | "plotters-backend", 952 | ] 953 | 954 | [[package]] 955 | name = "portable-atomic" 956 | version = "1.11.0" 957 | source = "registry+https://github.com/rust-lang/crates.io-index" 958 | checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 959 | 960 | [[package]] 961 | name = "powerfmt" 962 | version = "0.2.0" 963 | source = "registry+https://github.com/rust-lang/crates.io-index" 964 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 965 | 966 | [[package]] 967 | name = "ppv-lite86" 968 | version = "0.2.21" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 971 | dependencies = [ 972 | "zerocopy", 973 | ] 974 | 975 | [[package]] 976 | name = "proc-macro2" 977 | version = "1.0.95" 978 | source = "registry+https://github.com/rust-lang/crates.io-index" 979 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 980 | dependencies = [ 981 | "unicode-ident", 982 | ] 983 | 984 | [[package]] 985 | name = "quote" 986 | version = "1.0.40" 987 | source = "registry+https://github.com/rust-lang/crates.io-index" 988 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 989 | dependencies = [ 990 | "proc-macro2", 991 | ] 992 | 993 | [[package]] 994 | name = "r-efi" 995 | version = "5.2.0" 996 | source = "registry+https://github.com/rust-lang/crates.io-index" 997 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 998 | 999 | [[package]] 1000 | name = "rand" 1001 | version = "0.9.1" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 1004 | dependencies = [ 1005 | "rand_chacha", 1006 | "rand_core", 1007 | ] 1008 | 1009 | [[package]] 1010 | name = "rand_chacha" 1011 | version = "0.9.0" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1014 | dependencies = [ 1015 | "ppv-lite86", 1016 | "rand_core", 1017 | ] 1018 | 1019 | [[package]] 1020 | name = "rand_core" 1021 | version = "0.9.3" 1022 | source = "registry+https://github.com/rust-lang/crates.io-index" 1023 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1024 | dependencies = [ 1025 | "getrandom 0.3.2", 1026 | ] 1027 | 1028 | [[package]] 1029 | name = "ratatui" 1030 | version = "0.29.0" 1031 | source = "registry+https://github.com/rust-lang/crates.io-index" 1032 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 1033 | dependencies = [ 1034 | "bitflags", 1035 | "cassowary", 1036 | "compact_str", 1037 | "crossterm 0.28.1", 1038 | "indoc", 1039 | "instability", 1040 | "itertools 0.13.0", 1041 | "lru", 1042 | "paste", 1043 | "strum", 1044 | "unicode-segmentation", 1045 | "unicode-truncate", 1046 | "unicode-width 0.2.0", 1047 | ] 1048 | 1049 | [[package]] 1050 | name = "rayon" 1051 | version = "1.10.0" 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" 1053 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 1054 | dependencies = [ 1055 | "either", 1056 | "rayon-core", 1057 | ] 1058 | 1059 | [[package]] 1060 | name = "rayon-core" 1061 | version = "1.12.1" 1062 | source = "registry+https://github.com/rust-lang/crates.io-index" 1063 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 1064 | dependencies = [ 1065 | "crossbeam-deque", 1066 | "crossbeam-utils", 1067 | ] 1068 | 1069 | [[package]] 1070 | name = "redox_syscall" 1071 | version = "0.5.11" 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" 1073 | checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" 1074 | dependencies = [ 1075 | "bitflags", 1076 | ] 1077 | 1078 | [[package]] 1079 | name = "redox_users" 1080 | version = "0.5.0" 1081 | source = "registry+https://github.com/rust-lang/crates.io-index" 1082 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 1083 | dependencies = [ 1084 | "getrandom 0.2.15", 1085 | "libredox", 1086 | "thiserror", 1087 | ] 1088 | 1089 | [[package]] 1090 | name = "redu" 1091 | version = "0.2.13" 1092 | dependencies = [ 1093 | "anyhow", 1094 | "camino", 1095 | "chrono", 1096 | "clap", 1097 | "criterion", 1098 | "crossterm 0.29.0", 1099 | "directories", 1100 | "humansize", 1101 | "indicatif", 1102 | "log", 1103 | "nix", 1104 | "rand", 1105 | "ratatui", 1106 | "rpassword", 1107 | "rusqlite", 1108 | "scopeguard", 1109 | "serde", 1110 | "serde_json", 1111 | "simplelog", 1112 | "thiserror", 1113 | "unicode-segmentation", 1114 | "uuid", 1115 | ] 1116 | 1117 | [[package]] 1118 | name = "regex" 1119 | version = "1.11.1" 1120 | source = "registry+https://github.com/rust-lang/crates.io-index" 1121 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1122 | dependencies = [ 1123 | "aho-corasick", 1124 | "memchr", 1125 | "regex-automata", 1126 | "regex-syntax", 1127 | ] 1128 | 1129 | [[package]] 1130 | name = "regex-automata" 1131 | version = "0.4.9" 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" 1133 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1134 | dependencies = [ 1135 | "aho-corasick", 1136 | "memchr", 1137 | "regex-syntax", 1138 | ] 1139 | 1140 | [[package]] 1141 | name = "regex-syntax" 1142 | version = "0.8.5" 1143 | source = "registry+https://github.com/rust-lang/crates.io-index" 1144 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1145 | 1146 | [[package]] 1147 | name = "rpassword" 1148 | version = "7.4.0" 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" 1150 | checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" 1151 | dependencies = [ 1152 | "libc", 1153 | "rtoolbox", 1154 | "windows-sys 0.59.0", 1155 | ] 1156 | 1157 | [[package]] 1158 | name = "rtoolbox" 1159 | version = "0.0.3" 1160 | source = "registry+https://github.com/rust-lang/crates.io-index" 1161 | checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" 1162 | dependencies = [ 1163 | "libc", 1164 | "windows-sys 0.52.0", 1165 | ] 1166 | 1167 | [[package]] 1168 | name = "rusqlite" 1169 | version = "0.35.0" 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" 1171 | checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" 1172 | dependencies = [ 1173 | "bitflags", 1174 | "fallible-iterator", 1175 | "fallible-streaming-iterator", 1176 | "hashlink", 1177 | "libsqlite3-sys", 1178 | "smallvec", 1179 | ] 1180 | 1181 | [[package]] 1182 | name = "rustix" 1183 | version = "0.38.44" 1184 | source = "registry+https://github.com/rust-lang/crates.io-index" 1185 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1186 | dependencies = [ 1187 | "bitflags", 1188 | "errno", 1189 | "libc", 1190 | "linux-raw-sys 0.4.15", 1191 | "windows-sys 0.59.0", 1192 | ] 1193 | 1194 | [[package]] 1195 | name = "rustix" 1196 | version = "1.0.5" 1197 | source = "registry+https://github.com/rust-lang/crates.io-index" 1198 | checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" 1199 | dependencies = [ 1200 | "bitflags", 1201 | "errno", 1202 | "libc", 1203 | "linux-raw-sys 0.9.4", 1204 | "windows-sys 0.59.0", 1205 | ] 1206 | 1207 | [[package]] 1208 | name = "rustversion" 1209 | version = "1.0.20" 1210 | source = "registry+https://github.com/rust-lang/crates.io-index" 1211 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 1212 | 1213 | [[package]] 1214 | name = "ryu" 1215 | version = "1.0.20" 1216 | source = "registry+https://github.com/rust-lang/crates.io-index" 1217 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1218 | 1219 | [[package]] 1220 | name = "same-file" 1221 | version = "1.0.6" 1222 | source = "registry+https://github.com/rust-lang/crates.io-index" 1223 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1224 | dependencies = [ 1225 | "winapi-util", 1226 | ] 1227 | 1228 | [[package]] 1229 | name = "scopeguard" 1230 | version = "1.2.0" 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" 1232 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1233 | 1234 | [[package]] 1235 | name = "serde" 1236 | version = "1.0.219" 1237 | source = "registry+https://github.com/rust-lang/crates.io-index" 1238 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1239 | dependencies = [ 1240 | "serde_derive", 1241 | ] 1242 | 1243 | [[package]] 1244 | name = "serde_derive" 1245 | version = "1.0.219" 1246 | source = "registry+https://github.com/rust-lang/crates.io-index" 1247 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1248 | dependencies = [ 1249 | "proc-macro2", 1250 | "quote", 1251 | "syn", 1252 | ] 1253 | 1254 | [[package]] 1255 | name = "serde_json" 1256 | version = "1.0.140" 1257 | source = "registry+https://github.com/rust-lang/crates.io-index" 1258 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1259 | dependencies = [ 1260 | "itoa", 1261 | "memchr", 1262 | "ryu", 1263 | "serde", 1264 | ] 1265 | 1266 | [[package]] 1267 | name = "shlex" 1268 | version = "1.3.0" 1269 | source = "registry+https://github.com/rust-lang/crates.io-index" 1270 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1271 | 1272 | [[package]] 1273 | name = "signal-hook" 1274 | version = "0.3.17" 1275 | source = "registry+https://github.com/rust-lang/crates.io-index" 1276 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 1277 | dependencies = [ 1278 | "libc", 1279 | "signal-hook-registry", 1280 | ] 1281 | 1282 | [[package]] 1283 | name = "signal-hook-mio" 1284 | version = "0.2.4" 1285 | source = "registry+https://github.com/rust-lang/crates.io-index" 1286 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 1287 | dependencies = [ 1288 | "libc", 1289 | "mio", 1290 | "signal-hook", 1291 | ] 1292 | 1293 | [[package]] 1294 | name = "signal-hook-registry" 1295 | version = "1.4.5" 1296 | source = "registry+https://github.com/rust-lang/crates.io-index" 1297 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 1298 | dependencies = [ 1299 | "libc", 1300 | ] 1301 | 1302 | [[package]] 1303 | name = "simplelog" 1304 | version = "0.12.2" 1305 | source = "registry+https://github.com/rust-lang/crates.io-index" 1306 | checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" 1307 | dependencies = [ 1308 | "log", 1309 | "termcolor", 1310 | "time", 1311 | ] 1312 | 1313 | [[package]] 1314 | name = "smallvec" 1315 | version = "1.15.0" 1316 | source = "registry+https://github.com/rust-lang/crates.io-index" 1317 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 1318 | 1319 | [[package]] 1320 | name = "static_assertions" 1321 | version = "1.1.0" 1322 | source = "registry+https://github.com/rust-lang/crates.io-index" 1323 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1324 | 1325 | [[package]] 1326 | name = "strsim" 1327 | version = "0.11.1" 1328 | source = "registry+https://github.com/rust-lang/crates.io-index" 1329 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1330 | 1331 | [[package]] 1332 | name = "strum" 1333 | version = "0.26.3" 1334 | source = "registry+https://github.com/rust-lang/crates.io-index" 1335 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1336 | dependencies = [ 1337 | "strum_macros", 1338 | ] 1339 | 1340 | [[package]] 1341 | name = "strum_macros" 1342 | version = "0.26.4" 1343 | source = "registry+https://github.com/rust-lang/crates.io-index" 1344 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1345 | dependencies = [ 1346 | "heck", 1347 | "proc-macro2", 1348 | "quote", 1349 | "rustversion", 1350 | "syn", 1351 | ] 1352 | 1353 | [[package]] 1354 | name = "syn" 1355 | version = "2.0.100" 1356 | source = "registry+https://github.com/rust-lang/crates.io-index" 1357 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 1358 | dependencies = [ 1359 | "proc-macro2", 1360 | "quote", 1361 | "unicode-ident", 1362 | ] 1363 | 1364 | [[package]] 1365 | name = "termcolor" 1366 | version = "1.4.1" 1367 | source = "registry+https://github.com/rust-lang/crates.io-index" 1368 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 1369 | dependencies = [ 1370 | "winapi-util", 1371 | ] 1372 | 1373 | [[package]] 1374 | name = "thiserror" 1375 | version = "2.0.12" 1376 | source = "registry+https://github.com/rust-lang/crates.io-index" 1377 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 1378 | dependencies = [ 1379 | "thiserror-impl", 1380 | ] 1381 | 1382 | [[package]] 1383 | name = "thiserror-impl" 1384 | version = "2.0.12" 1385 | source = "registry+https://github.com/rust-lang/crates.io-index" 1386 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 1387 | dependencies = [ 1388 | "proc-macro2", 1389 | "quote", 1390 | "syn", 1391 | ] 1392 | 1393 | [[package]] 1394 | name = "time" 1395 | version = "0.3.41" 1396 | source = "registry+https://github.com/rust-lang/crates.io-index" 1397 | checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 1398 | dependencies = [ 1399 | "deranged", 1400 | "itoa", 1401 | "libc", 1402 | "num-conv", 1403 | "num_threads", 1404 | "powerfmt", 1405 | "serde", 1406 | "time-core", 1407 | "time-macros", 1408 | ] 1409 | 1410 | [[package]] 1411 | name = "time-core" 1412 | version = "0.1.4" 1413 | source = "registry+https://github.com/rust-lang/crates.io-index" 1414 | checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 1415 | 1416 | [[package]] 1417 | name = "time-macros" 1418 | version = "0.2.22" 1419 | source = "registry+https://github.com/rust-lang/crates.io-index" 1420 | checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" 1421 | dependencies = [ 1422 | "num-conv", 1423 | "time-core", 1424 | ] 1425 | 1426 | [[package]] 1427 | name = "tinytemplate" 1428 | version = "1.2.1" 1429 | source = "registry+https://github.com/rust-lang/crates.io-index" 1430 | checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" 1431 | dependencies = [ 1432 | "serde", 1433 | "serde_json", 1434 | ] 1435 | 1436 | [[package]] 1437 | name = "unicode-ident" 1438 | version = "1.0.18" 1439 | source = "registry+https://github.com/rust-lang/crates.io-index" 1440 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 1441 | 1442 | [[package]] 1443 | name = "unicode-segmentation" 1444 | version = "1.12.0" 1445 | source = "registry+https://github.com/rust-lang/crates.io-index" 1446 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1447 | 1448 | [[package]] 1449 | name = "unicode-truncate" 1450 | version = "1.1.0" 1451 | source = "registry+https://github.com/rust-lang/crates.io-index" 1452 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 1453 | dependencies = [ 1454 | "itertools 0.13.0", 1455 | "unicode-segmentation", 1456 | "unicode-width 0.1.14", 1457 | ] 1458 | 1459 | [[package]] 1460 | name = "unicode-width" 1461 | version = "0.1.14" 1462 | source = "registry+https://github.com/rust-lang/crates.io-index" 1463 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1464 | 1465 | [[package]] 1466 | name = "unicode-width" 1467 | version = "0.2.0" 1468 | source = "registry+https://github.com/rust-lang/crates.io-index" 1469 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 1470 | 1471 | [[package]] 1472 | name = "utf8parse" 1473 | version = "0.2.2" 1474 | source = "registry+https://github.com/rust-lang/crates.io-index" 1475 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1476 | 1477 | [[package]] 1478 | name = "uuid" 1479 | version = "1.16.0" 1480 | source = "registry+https://github.com/rust-lang/crates.io-index" 1481 | checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" 1482 | dependencies = [ 1483 | "getrandom 0.3.2", 1484 | ] 1485 | 1486 | [[package]] 1487 | name = "vcpkg" 1488 | version = "0.2.15" 1489 | source = "registry+https://github.com/rust-lang/crates.io-index" 1490 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1491 | 1492 | [[package]] 1493 | name = "walkdir" 1494 | version = "2.5.0" 1495 | source = "registry+https://github.com/rust-lang/crates.io-index" 1496 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1497 | dependencies = [ 1498 | "same-file", 1499 | "winapi-util", 1500 | ] 1501 | 1502 | [[package]] 1503 | name = "wasi" 1504 | version = "0.11.0+wasi-snapshot-preview1" 1505 | source = "registry+https://github.com/rust-lang/crates.io-index" 1506 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1507 | 1508 | [[package]] 1509 | name = "wasi" 1510 | version = "0.14.2+wasi-0.2.4" 1511 | source = "registry+https://github.com/rust-lang/crates.io-index" 1512 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 1513 | dependencies = [ 1514 | "wit-bindgen-rt", 1515 | ] 1516 | 1517 | [[package]] 1518 | name = "wasm-bindgen" 1519 | version = "0.2.100" 1520 | source = "registry+https://github.com/rust-lang/crates.io-index" 1521 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1522 | dependencies = [ 1523 | "cfg-if", 1524 | "once_cell", 1525 | "rustversion", 1526 | "wasm-bindgen-macro", 1527 | ] 1528 | 1529 | [[package]] 1530 | name = "wasm-bindgen-backend" 1531 | version = "0.2.100" 1532 | source = "registry+https://github.com/rust-lang/crates.io-index" 1533 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1534 | dependencies = [ 1535 | "bumpalo", 1536 | "log", 1537 | "proc-macro2", 1538 | "quote", 1539 | "syn", 1540 | "wasm-bindgen-shared", 1541 | ] 1542 | 1543 | [[package]] 1544 | name = "wasm-bindgen-macro" 1545 | version = "0.2.100" 1546 | source = "registry+https://github.com/rust-lang/crates.io-index" 1547 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1548 | dependencies = [ 1549 | "quote", 1550 | "wasm-bindgen-macro-support", 1551 | ] 1552 | 1553 | [[package]] 1554 | name = "wasm-bindgen-macro-support" 1555 | version = "0.2.100" 1556 | source = "registry+https://github.com/rust-lang/crates.io-index" 1557 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1558 | dependencies = [ 1559 | "proc-macro2", 1560 | "quote", 1561 | "syn", 1562 | "wasm-bindgen-backend", 1563 | "wasm-bindgen-shared", 1564 | ] 1565 | 1566 | [[package]] 1567 | name = "wasm-bindgen-shared" 1568 | version = "0.2.100" 1569 | source = "registry+https://github.com/rust-lang/crates.io-index" 1570 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1571 | dependencies = [ 1572 | "unicode-ident", 1573 | ] 1574 | 1575 | [[package]] 1576 | name = "web-sys" 1577 | version = "0.3.77" 1578 | source = "registry+https://github.com/rust-lang/crates.io-index" 1579 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 1580 | dependencies = [ 1581 | "js-sys", 1582 | "wasm-bindgen", 1583 | ] 1584 | 1585 | [[package]] 1586 | name = "web-time" 1587 | version = "1.1.0" 1588 | source = "registry+https://github.com/rust-lang/crates.io-index" 1589 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 1590 | dependencies = [ 1591 | "js-sys", 1592 | "wasm-bindgen", 1593 | ] 1594 | 1595 | [[package]] 1596 | name = "winapi" 1597 | version = "0.3.9" 1598 | source = "registry+https://github.com/rust-lang/crates.io-index" 1599 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1600 | dependencies = [ 1601 | "winapi-i686-pc-windows-gnu", 1602 | "winapi-x86_64-pc-windows-gnu", 1603 | ] 1604 | 1605 | [[package]] 1606 | name = "winapi-i686-pc-windows-gnu" 1607 | version = "0.4.0" 1608 | source = "registry+https://github.com/rust-lang/crates.io-index" 1609 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1610 | 1611 | [[package]] 1612 | name = "winapi-util" 1613 | version = "0.1.9" 1614 | source = "registry+https://github.com/rust-lang/crates.io-index" 1615 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 1616 | dependencies = [ 1617 | "windows-sys 0.59.0", 1618 | ] 1619 | 1620 | [[package]] 1621 | name = "winapi-x86_64-pc-windows-gnu" 1622 | version = "0.4.0" 1623 | source = "registry+https://github.com/rust-lang/crates.io-index" 1624 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1625 | 1626 | [[package]] 1627 | name = "windows-core" 1628 | version = "0.61.0" 1629 | source = "registry+https://github.com/rust-lang/crates.io-index" 1630 | checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" 1631 | dependencies = [ 1632 | "windows-implement", 1633 | "windows-interface", 1634 | "windows-link", 1635 | "windows-result", 1636 | "windows-strings", 1637 | ] 1638 | 1639 | [[package]] 1640 | name = "windows-implement" 1641 | version = "0.60.0" 1642 | source = "registry+https://github.com/rust-lang/crates.io-index" 1643 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 1644 | dependencies = [ 1645 | "proc-macro2", 1646 | "quote", 1647 | "syn", 1648 | ] 1649 | 1650 | [[package]] 1651 | name = "windows-interface" 1652 | version = "0.59.1" 1653 | source = "registry+https://github.com/rust-lang/crates.io-index" 1654 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 1655 | dependencies = [ 1656 | "proc-macro2", 1657 | "quote", 1658 | "syn", 1659 | ] 1660 | 1661 | [[package]] 1662 | name = "windows-link" 1663 | version = "0.1.1" 1664 | source = "registry+https://github.com/rust-lang/crates.io-index" 1665 | checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 1666 | 1667 | [[package]] 1668 | name = "windows-result" 1669 | version = "0.3.2" 1670 | source = "registry+https://github.com/rust-lang/crates.io-index" 1671 | checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" 1672 | dependencies = [ 1673 | "windows-link", 1674 | ] 1675 | 1676 | [[package]] 1677 | name = "windows-strings" 1678 | version = "0.4.0" 1679 | source = "registry+https://github.com/rust-lang/crates.io-index" 1680 | checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" 1681 | dependencies = [ 1682 | "windows-link", 1683 | ] 1684 | 1685 | [[package]] 1686 | name = "windows-sys" 1687 | version = "0.52.0" 1688 | source = "registry+https://github.com/rust-lang/crates.io-index" 1689 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1690 | dependencies = [ 1691 | "windows-targets", 1692 | ] 1693 | 1694 | [[package]] 1695 | name = "windows-sys" 1696 | version = "0.59.0" 1697 | source = "registry+https://github.com/rust-lang/crates.io-index" 1698 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1699 | dependencies = [ 1700 | "windows-targets", 1701 | ] 1702 | 1703 | [[package]] 1704 | name = "windows-targets" 1705 | version = "0.52.6" 1706 | source = "registry+https://github.com/rust-lang/crates.io-index" 1707 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1708 | dependencies = [ 1709 | "windows_aarch64_gnullvm", 1710 | "windows_aarch64_msvc", 1711 | "windows_i686_gnu", 1712 | "windows_i686_gnullvm", 1713 | "windows_i686_msvc", 1714 | "windows_x86_64_gnu", 1715 | "windows_x86_64_gnullvm", 1716 | "windows_x86_64_msvc", 1717 | ] 1718 | 1719 | [[package]] 1720 | name = "windows_aarch64_gnullvm" 1721 | version = "0.52.6" 1722 | source = "registry+https://github.com/rust-lang/crates.io-index" 1723 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1724 | 1725 | [[package]] 1726 | name = "windows_aarch64_msvc" 1727 | version = "0.52.6" 1728 | source = "registry+https://github.com/rust-lang/crates.io-index" 1729 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1730 | 1731 | [[package]] 1732 | name = "windows_i686_gnu" 1733 | version = "0.52.6" 1734 | source = "registry+https://github.com/rust-lang/crates.io-index" 1735 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1736 | 1737 | [[package]] 1738 | name = "windows_i686_gnullvm" 1739 | version = "0.52.6" 1740 | source = "registry+https://github.com/rust-lang/crates.io-index" 1741 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1742 | 1743 | [[package]] 1744 | name = "windows_i686_msvc" 1745 | version = "0.52.6" 1746 | source = "registry+https://github.com/rust-lang/crates.io-index" 1747 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1748 | 1749 | [[package]] 1750 | name = "windows_x86_64_gnu" 1751 | version = "0.52.6" 1752 | source = "registry+https://github.com/rust-lang/crates.io-index" 1753 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1754 | 1755 | [[package]] 1756 | name = "windows_x86_64_gnullvm" 1757 | version = "0.52.6" 1758 | source = "registry+https://github.com/rust-lang/crates.io-index" 1759 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1760 | 1761 | [[package]] 1762 | name = "windows_x86_64_msvc" 1763 | version = "0.52.6" 1764 | source = "registry+https://github.com/rust-lang/crates.io-index" 1765 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1766 | 1767 | [[package]] 1768 | name = "wit-bindgen-rt" 1769 | version = "0.39.0" 1770 | source = "registry+https://github.com/rust-lang/crates.io-index" 1771 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 1772 | dependencies = [ 1773 | "bitflags", 1774 | ] 1775 | 1776 | [[package]] 1777 | name = "zerocopy" 1778 | version = "0.8.24" 1779 | source = "registry+https://github.com/rust-lang/crates.io-index" 1780 | checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" 1781 | dependencies = [ 1782 | "zerocopy-derive", 1783 | ] 1784 | 1785 | [[package]] 1786 | name = "zerocopy-derive" 1787 | version = "0.8.24" 1788 | source = "registry+https://github.com/rust-lang/crates.io-index" 1789 | checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" 1790 | dependencies = [ 1791 | "proc-macro2", 1792 | "quote", 1793 | "syn", 1794 | ] 1795 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "redu" 3 | version = "0.2.13" 4 | authors = ["Daniel Rebelo de Oliveira "] 5 | license = "MIT" 6 | homepage = "https://github.com/drdo/redu" 7 | repository = "https://github.com/drdo/redu" 8 | keywords = ["restic", "ncdu", "disk", "usage", "analyzer"] 9 | categories = ["command-line-utilities"] 10 | edition = "2021" 11 | description = "This is like ncdu for a restic repository." 12 | 13 | [dependencies] 14 | anyhow = "1" 15 | camino = "1" 16 | chrono = { version = "0.4", features = ["serde"] } 17 | clap = { version = "4", features = ["derive", "env"] } 18 | crossterm = "0.29" 19 | directories = "6" 20 | simplelog = "0.12" 21 | humansize = "2" 22 | indicatif = "0.17" 23 | log = "0.4" 24 | rand = "0.9" 25 | ratatui = { version = "0.29", features = [ 26 | "unstable-rendered-line-info", 27 | "unstable-widget-ref", 28 | ] } 29 | rpassword = "7.3.1" 30 | rusqlite = { version = "0.35", features = ["bundled", "functions", "trace"] } 31 | scopeguard = "1" 32 | serde = { version = "1", features = ["derive"] } 33 | serde_json = "1" 34 | thiserror = "2" 35 | unicode-segmentation = "1" 36 | uuid = { version = "1", features = ["v4"], optional = true } 37 | 38 | [target.'cfg(unix)'.dependencies] 39 | nix = { version = "0.29", features = ["process"] } 40 | 41 | [lib] 42 | path = "src/lib.rs" 43 | 44 | [[bin]] 45 | name = "redu" 46 | path = "src/main.rs" 47 | 48 | [features] 49 | bench = ["uuid"] 50 | 51 | [profile.release] 52 | codegen-units = 1 53 | lto = "fat" 54 | 55 | [dev-dependencies] 56 | criterion = { version = "0.5", features = ["html_reports"] } 57 | uuid = { version = "1", features = ["v4"] } 58 | 59 | [[bench]] 60 | name = "cache" 61 | harness = false 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Daniel Oliveira 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 | # Introduction 2 | 3 | redu in a nutshell: it's ncdu for your restic repo. 4 | 5 | If you ever wanted to know what's taking so much space in your restic 6 | repo so that you can find all the caches and useless things you might be backing 7 | up and delete them from the snapshots, then this is exactly for you. 8 | 9 | redu aggregates data from **all** snapshots into one view so you can easily find 10 | the culprits! 11 | 12 | # Installing 13 | 14 | You can either grab a pre-built binary from Github, currently available for: 15 | - Darwin (MacOS) arm64 16 | - Darwin (MacOS) x86-64 17 | - Linux arm64 18 | - Linux x86-64 19 | - Windows arm64 20 | - Windows x86-64 21 | 22 | Note: On MacOS if you download via browser you might need to remove quarantine with: 23 | `xattr -d com.apple.quarantine ` 24 | 25 | or you can install with cargo: 26 | ``` 27 | cargo install redu --locked 28 | ``` 29 | 30 | # Running 31 | 32 | You can specify the repository and the password command in exactly the same ways 33 | that restic supports. 34 | 35 | For example using environment variables: 36 | ``` 37 | $ export RESTIC_REPOSITORY='sftp://my-backup-server.my-domain.net' 38 | $ export RESTIC_PASSWORD_COMMAND='security find-generic-password -s restic -a personal -w' 39 | $ redu 40 | ``` 41 | 42 | Or via command line arguments: 43 | ``` 44 | redu -r 'sftp://my-backup-server.my-domain.net' --password-command 'security find-generic-password -s restic -a personal -w' 45 | ``` 46 | 47 | Note: `--repository-file` (env: `RESTIC_REPOSITORY_FILE`) and `--password-file` (env: `RESTIC_PASSWORD_FILE`), 48 | as well as plain text passwords set via the `RESTIC_PASSWORD` environment variable, 49 | are supported as well and work just like in restic. 50 | 51 | Similar to restic, redu will prompt you to enter the password, if it isn't 52 | given any other way. 53 | 54 | # Usage 55 | Redu keeps a cache with your file/directory sizes (per repo). 56 | On each run it will sync the cache with the snapshots in your repo, 57 | deleting old snapshots and integrating new ones into the cache. 58 | 59 | If you have a lot of large snapshots the first sync might take some minutes 60 | depending on your connection speed and computer. 61 | It will be much faster the next time as it no longer needs to fetch the entire repo. 62 | 63 | After some time you will see something like this: 64 | 65 | ![Screenshot of redu showing the contents of a repo](screenshot_start.png) 66 | 67 | You can navigate using the **arrow keys** or **hjkl**. 68 | Going right enters a directory and going left leaves back to the parent. 69 | 70 | **PgUp**/**PgDown** or **C-b**/**C-b** scroll up or down a full page. 71 | 72 | The size that redu shows for each item is the maximum size of the item 73 | across all snapshots. That is, it's the size of that item for the snapshot 74 | where it is the biggest. 75 | 76 | The bars indicate the relative size of the item compared to everything else 77 | in the current location. 78 | 79 | By pressing **Enter** you can make a small window visible that shows some details 80 | about the currently highlighted item: 81 | - The latest snapshot where it has maximum size 82 | - The earliest date and snapshot where this item appears 83 | - The latest date and snapshot where this item appears 84 | 85 | ![Screenshot of redu showing the contents of a repo with details open](screenshot_details.png) 86 | 87 | You can keep navigating with the details window open and it will update as you 88 | browse around. 89 | 90 | Hint: you can press **Escape** to close the details window (as well as other dialogs). 91 | 92 | ### Marking files 93 | You can mark files and directories to build up your list of things to exclude. 94 | Keybinds 95 | - **m**: mark selected file/directory 96 | - **u**: unmark selected file/directory 97 | - **c**: clear all marks (this will prompt you for confirmation) 98 | 99 | The marks are persistent across runs of redu (they are saved in the cache file), 100 | so feel free to mark a few files and just quit and come back later. 101 | 102 | The marks are shown with an asterik at the beginning of the line 103 | and you can see how many total marks you have on the bar at the bottom. 104 | 105 | ![Screenshot of redu showing the contents of a repo with some marks](screenshot_marks.png) 106 | 107 | ### Generating the excludes 108 | Press **g** to exit redu and generate a list with all of your marks in alphabetic order to stdout. 109 | 110 | Everything else that redu prints (including the UI itself) goes to stderr, 111 | so this allows you to redirect redu's output to a file to get an exclude-file 112 | that you can directly use with restic. 113 | 114 | For example: 115 | ``` 116 | $ redu > exclude.txt 117 | $ restic rewrite --exclude-file=exclude.txt --forget 118 | ``` 119 | 120 | Note: redu is strictly **read-only** and will never modify your repository itself. 121 | 122 | ### Quit 123 | You can also just quit without generating the list by pressing **q**. 124 | 125 | # Contributing 126 | Bug reports, feature requests and PRs are all welcome! 127 | Just go ahead! 128 | 129 | You can also shoot me an email or talk to me on the rust Discord or Freenode 130 | if you want to contribute and want to discuss some point. 131 | 132 | ### Tests and Benchmarks 133 | You can run the tests with 134 | ``` 135 | cargo test 136 | ``` 137 | 138 | There are also a couple of benchmarks based on criterion that can be run with 139 | ``` 140 | cargo bench --features bench 141 | ``` 142 | -------------------------------------------------------------------------------- /benches/cache.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | 3 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 4 | use redu::{ 5 | cache::{tests::*, Migrator}, 6 | restic::Snapshot, 7 | }; 8 | 9 | pub fn criterion_benchmark(c: &mut Criterion) { 10 | c.bench_function("merge sizetree", |b| { 11 | let sizetree0 = 12 | Cell::new(generate_sizetree(black_box(6), black_box(12))); 13 | let sizetree1 = 14 | Cell::new(generate_sizetree(black_box(5), black_box(14))); 15 | b.iter(move || sizetree0.take().merge(black_box(sizetree1.take()))); 16 | }); 17 | 18 | c.bench_function("save snapshot", |b| { 19 | let foo = Snapshot { 20 | id: "foo".to_string(), 21 | time: mk_datetime(2024, 4, 12, 12, 00, 00), 22 | parent: Some("bar".to_string()), 23 | tree: "sometree".to_string(), 24 | paths: vec![ 25 | "/home/user".to_string(), 26 | "/etc".to_string(), 27 | "/var".to_string(), 28 | ] 29 | .into_iter() 30 | .collect(), 31 | hostname: Some("foo.com".to_string()), 32 | username: Some("user".to_string()), 33 | uid: Some(123), 34 | gid: Some(456), 35 | excludes: vec![ 36 | ".cache".to_string(), 37 | "Cache".to_string(), 38 | "/home/user/Downloads".to_string(), 39 | ] 40 | .into_iter() 41 | .collect(), 42 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 43 | .into_iter() 44 | .collect(), 45 | original_id: Some("fefwfwew".to_string()), 46 | program_version: Some("restic 0.16.0".to_string()), 47 | }; 48 | b.iter_with_setup( 49 | || { 50 | let tempfile = Tempfile::new(); 51 | let cache = 52 | Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 53 | (tempfile, cache, generate_sizetree(6, 12)) 54 | }, 55 | |(_tempfile, mut cache, tree)| { 56 | cache.save_snapshot(&foo, tree).unwrap() 57 | }, 58 | ); 59 | }); 60 | 61 | c.bench_function("save lots of small snapshots", |b| { 62 | fn mk_snapshot(id: String) -> Snapshot { 63 | Snapshot { 64 | id, 65 | time: mk_datetime(2024, 4, 12, 12, 00, 00), 66 | parent: Some("bar".to_string()), 67 | tree: "sometree".to_string(), 68 | paths: vec![ 69 | "/home/user".to_string(), 70 | "/etc".to_string(), 71 | "/var".to_string(), 72 | ] 73 | .into_iter() 74 | .collect(), 75 | hostname: Some("foo.com".to_string()), 76 | username: Some("user".to_string()), 77 | uid: Some(123), 78 | gid: Some(456), 79 | excludes: vec![ 80 | ".cache".to_string(), 81 | "Cache".to_string(), 82 | "/home/user/Downloads".to_string(), 83 | ] 84 | .into_iter() 85 | .collect(), 86 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 87 | .into_iter() 88 | .collect(), 89 | original_id: Some("fefwfwew".to_string()), 90 | program_version: Some("restic 0.16.0".to_string()), 91 | } 92 | } 93 | 94 | b.iter_with_setup( 95 | || { 96 | let tempfile = Tempfile::new(); 97 | let cache = 98 | Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 99 | (tempfile, cache, generate_sizetree(1, 0)) 100 | }, 101 | |(_tempfile, mut cache, tree)| { 102 | for i in 0..10_000 { 103 | cache 104 | .save_snapshot( 105 | &mk_snapshot(i.to_string()), 106 | tree.clone(), 107 | ) 108 | .unwrap(); 109 | } 110 | }, 111 | ); 112 | }); 113 | } 114 | 115 | criterion_group! { 116 | name = benches; 117 | config = Criterion::default().sample_size(10); 118 | targets = criterion_benchmark 119 | } 120 | criterion_main!(benches); 121 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | use_small_heuristics = "Max" 3 | use_field_init_shorthand = true 4 | -------------------------------------------------------------------------------- /screenshot_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdo/redu/1cc0b9e7d2c18af18320c3515b7fd5018680cbe2/screenshot_details.png -------------------------------------------------------------------------------- /screenshot_marks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdo/redu/1cc0b9e7d2c18af18320c3515b7fd5018680cbe2/screenshot_marks.png -------------------------------------------------------------------------------- /screenshot_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdo/redu/1cc0b9e7d2c18af18320c3515b7fd5018680cbe2/screenshot_start.png -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgGroup, Parser}; 2 | use log::LevelFilter; 3 | use redu::restic::Repository; 4 | use rpassword::read_password; 5 | 6 | use crate::restic::Password; 7 | 8 | #[derive(Debug)] 9 | pub struct Args { 10 | pub repository: Repository, 11 | pub password: Password, 12 | pub parallelism: usize, 13 | pub log_level: LevelFilter, 14 | pub no_cache: bool, 15 | } 16 | 17 | impl Args { 18 | /// Parse arguments from env::args_os(), exit on error. 19 | pub fn parse() -> Self { 20 | let cli = Cli::parse(); 21 | 22 | Args { 23 | repository: if let Some(repo) = cli.repo { 24 | Repository::Repo(repo) 25 | } else if let Some(file) = cli.repository_file { 26 | Repository::File(file) 27 | } else { 28 | unreachable!("Error in Config: neither repo nor repository_file found. Please open an issue if you see this.") 29 | }, 30 | password: if let Some(command) = cli.password_command { 31 | Password::Command(command) 32 | } else if let Some(file) = cli.password_file { 33 | Password::File(file) 34 | } else if let Some(str) = cli.restic_password { 35 | Password::Plain(str) 36 | } else { 37 | Password::Plain(Self::read_password_from_stdin()) 38 | }, 39 | parallelism: cli.parallelism, 40 | log_level: match cli.verbose { 41 | 0 => LevelFilter::Info, 42 | 1 => LevelFilter::Debug, 43 | _ => LevelFilter::Trace, 44 | }, 45 | no_cache: cli.no_cache, 46 | } 47 | } 48 | 49 | fn read_password_from_stdin() -> String { 50 | eprint!("enter password for repository: "); 51 | read_password().unwrap() 52 | } 53 | } 54 | 55 | /// This is like ncdu for a restic respository. 56 | /// 57 | /// It computes the size for each directory/file by 58 | /// taking the largest over all snapshots in the repository. 59 | /// 60 | /// You can browse your repository and mark directories/files. 61 | /// These marks are persisted across runs of redu. 62 | /// 63 | /// When you're happy with the marks you can generate 64 | /// a list to stdout with everything that you marked. 65 | /// This list can be used directly as an exclude-file for restic. 66 | /// 67 | /// Redu keeps all messages and UI in stderr, 68 | /// only the marks list is generated to stdout. 69 | /// This means that you can pipe redu directly to a file 70 | /// to get the exclude-file. 71 | /// 72 | /// NOTE: redu will never do any kind of modification to your repo. 73 | /// It's strictly read-only. 74 | /// 75 | /// Keybinds: 76 | /// Arrows or hjkl: Movement 77 | /// PgUp/PgDown or C-b/C-f: Page up / Page down 78 | /// Enter: Details 79 | /// Escape: Close dialog 80 | /// m: Mark 81 | /// u: Unmark 82 | /// c: Clear all marks 83 | /// g: Generate 84 | /// q: Quit 85 | #[derive(Parser)] 86 | #[command(version, long_about, verbatim_doc_comment)] 87 | #[command(group( 88 | ArgGroup::new("repository") 89 | .required(true) 90 | .args(["repo", "repository_file"]), 91 | ))] 92 | struct Cli { 93 | #[arg(short = 'r', long, env = "RESTIC_REPOSITORY")] 94 | repo: Option, 95 | 96 | #[arg(long, env = "RESTIC_REPOSITORY_FILE")] 97 | repository_file: Option, 98 | 99 | #[arg(long, value_name = "COMMAND", env = "RESTIC_PASSWORD_COMMAND")] 100 | password_command: Option, 101 | 102 | #[arg(long, value_name = "FILE", env = "RESTIC_PASSWORD_FILE")] 103 | password_file: Option, 104 | 105 | #[arg(value_name = "RESTIC_PASSWORD", env = "RESTIC_PASSWORD")] 106 | restic_password: Option, 107 | 108 | /// How many restic subprocesses to spawn concurrently. 109 | /// 110 | /// If you get ssh-related errors or too much memory use try lowering this. 111 | #[arg(short = 'j', value_name = "NUMBER", default_value_t = 4)] 112 | parallelism: usize, 113 | 114 | /// Log verbosity level. You can pass it multiple times (maxes out at two). 115 | #[arg( 116 | short = 'v', 117 | action = clap::ArgAction::Count, 118 | )] 119 | verbose: u8, 120 | 121 | /// Pass the --no-cache option to restic subprocesses. 122 | #[arg(long)] 123 | no_cache: bool, 124 | } 125 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::{max, Reverse}, 3 | collections::{HashMap, HashSet}, 4 | path::Path, 5 | }; 6 | 7 | use camino::{Utf8Path, Utf8PathBuf}; 8 | use chrono::{DateTime, Utc}; 9 | use log::trace; 10 | use rusqlite::{ 11 | functions::FunctionFlags, params, types::FromSqlError, Connection, 12 | OptionalExtension, 13 | }; 14 | use thiserror::Error; 15 | 16 | use crate::{cache::filetree::SizeTree, restic::Snapshot}; 17 | 18 | pub mod filetree; 19 | #[cfg(any(test, feature = "bench"))] 20 | pub mod tests; 21 | 22 | #[derive(Debug)] 23 | pub struct Cache { 24 | conn: Connection, 25 | } 26 | 27 | #[derive(Error, Debug)] 28 | pub enum OpenError { 29 | #[error("Sqlite error")] 30 | Sqlite(#[from] rusqlite::Error), 31 | #[error("Error running migrations")] 32 | Migration(#[from] MigrationError), 33 | } 34 | 35 | #[derive(Error, Debug)] 36 | pub enum Error { 37 | #[error("SQL error")] 38 | Sql(#[from] rusqlite::Error), 39 | #[error("Unexpected SQL datatype")] 40 | FromSqlError(#[from] FromSqlError), 41 | #[error("Error parsing JSON")] 42 | Json(#[from] serde_json::Error), 43 | #[error("Exhausted timestamp precision (a couple hundred thousand years after the epoch).")] 44 | ExhaustedTimestampPrecision, 45 | } 46 | 47 | impl Cache { 48 | pub fn get_snapshots(&self) -> Result, Error> { 49 | self.conn 50 | .prepare( 51 | "SELECT \ 52 | hash, \ 53 | time, \ 54 | parent, \ 55 | tree, \ 56 | hostname, \ 57 | username, \ 58 | uid, \ 59 | gid, \ 60 | original_id, \ 61 | program_version, \ 62 | coalesce((SELECT json_group_array(path) FROM snapshot_paths WHERE hash = snapshots.hash), json_array()) as paths, \ 63 | coalesce((SELECT json_group_array(path) FROM snapshot_excludes WHERE hash = snapshots.hash), json_array()) as excludes, \ 64 | coalesce((SELECT json_group_array(tag) FROM snapshot_tags WHERE hash = snapshots.hash), json_array()) as tags \ 65 | FROM snapshots")? 66 | .query_and_then([], |row| 67 | Ok(Snapshot { 68 | id: row.get("hash")?, 69 | time: timestamp_to_datetime(row.get("time")?)?, 70 | parent: row.get("parent")?, 71 | tree: row.get("tree")?, 72 | paths: serde_json::from_str(row.get_ref("paths")?.as_str()?)?, 73 | hostname: row.get("hostname")?, 74 | username: row.get("username")?, 75 | uid: row.get("uid")?, 76 | gid: row.get("gid")?, 77 | excludes: serde_json::from_str(row.get_ref("excludes")?.as_str()?)?, 78 | tags: serde_json::from_str(row.get_ref("tags")?.as_str()?)?, 79 | original_id: row.get("original_id")?, 80 | program_version: row.get("program_version")?, 81 | }) 82 | )? 83 | .collect() 84 | } 85 | 86 | pub fn get_parent_id( 87 | &self, 88 | path_id: PathId, 89 | ) -> Result>, rusqlite::Error> { 90 | self.conn 91 | .query_row( 92 | "SELECT parent_id FROM paths WHERE id = ?", 93 | [path_id.0], 94 | |row| row.get("parent_id").map(raw_u64_to_o_path_id), 95 | ) 96 | .optional() 97 | } 98 | 99 | /// This is not very efficient, it does one query per path component. 100 | /// Mainly used for testing convenience. 101 | #[cfg(any(test, feature = "bench"))] 102 | pub fn get_path_id_by_path( 103 | &self, 104 | path: &Utf8Path, 105 | ) -> Result, rusqlite::Error> { 106 | let mut path_id = None; 107 | for component in path { 108 | path_id = self 109 | .conn 110 | .query_row( 111 | "SELECT id FROM paths \ 112 | WHERE parent_id = ? AND component = ?", 113 | params![o_path_id_to_raw_u64(path_id), component], 114 | |row| row.get(0).map(PathId), 115 | ) 116 | .optional()?; 117 | if path_id.is_none() { 118 | return Ok(None); 119 | } 120 | } 121 | Ok(path_id) 122 | } 123 | 124 | fn entries_tables( 125 | &self, 126 | ) -> Result, rusqlite::Error> { 127 | Ok(get_tables(&self.conn)? 128 | .into_iter() 129 | .filter(|name| name.starts_with("entries_"))) 130 | } 131 | 132 | /// This returns the children files/directories of the given path. 133 | /// Each entry's size is the largest size of that file/directory across 134 | /// all snapshots. 135 | pub fn get_entries( 136 | &self, 137 | path_id: Option, 138 | ) -> Result, rusqlite::Error> { 139 | let raw_path_id = o_path_id_to_raw_u64(path_id); 140 | let mut entries: Vec = Vec::new(); 141 | let mut index: HashMap = HashMap::new(); 142 | for table in self.entries_tables()? { 143 | let stmt_str = format!( 144 | "SELECT \ 145 | path_id, \ 146 | component, \ 147 | size, \ 148 | is_dir \ 149 | FROM \"{table}\" JOIN paths ON path_id = paths.id \ 150 | WHERE parent_id = {raw_path_id}\n", 151 | ); 152 | let mut stmt = self.conn.prepare(&stmt_str)?; 153 | let rows = stmt.query_map([], |row| { 154 | Ok(Entry { 155 | path_id: PathId(row.get("path_id")?), 156 | component: row.get("component")?, 157 | size: row.get("size")?, 158 | is_dir: row.get("is_dir")?, 159 | }) 160 | })?; 161 | for row in rows { 162 | let row = row?; 163 | let path_id = row.path_id; 164 | match index.get(&path_id) { 165 | None => { 166 | entries.push(row); 167 | index.insert(path_id, entries.len() - 1); 168 | } 169 | Some(i) => { 170 | let entry = &mut entries[*i]; 171 | entry.size = max(entry.size, row.size); 172 | entry.is_dir = entry.is_dir || row.is_dir; 173 | } 174 | } 175 | } 176 | } 177 | entries.sort_by_key(|e| Reverse(e.size)); 178 | Ok(entries) 179 | } 180 | 181 | pub fn get_entry_details( 182 | &self, 183 | path_id: PathId, 184 | ) -> Result, Error> { 185 | let raw_path_id = path_id.0; 186 | let run_query = |table: &str| -> Result< 187 | Option<(String, usize, DateTime)>, 188 | Error, 189 | > { 190 | let snapshot_hash = table.strip_prefix("entries_").unwrap(); 191 | let stmt_str = format!( 192 | "SELECT \ 193 | hash, \ 194 | size, \ 195 | time \ 196 | FROM \"{table}\" \ 197 | JOIN paths ON path_id = paths.id \ 198 | JOIN snapshots ON hash = '{snapshot_hash}' \ 199 | WHERE path_id = {raw_path_id}\n" 200 | ); 201 | let mut stmt = self.conn.prepare(&stmt_str)?; 202 | stmt.query_row([], |row| { 203 | Ok((row.get("hash")?, row.get("size")?, row.get("time")?)) 204 | }) 205 | .optional()? 206 | .map(|(hash, size, timestamp)| { 207 | Ok((hash, size, timestamp_to_datetime(timestamp)?)) 208 | }) 209 | .transpose() 210 | }; 211 | 212 | let mut entries_tables = self.entries_tables()?; 213 | let mut details = loop { 214 | match entries_tables.next() { 215 | None => return Ok(None), 216 | Some(table) => { 217 | if let Some((hash, size, time)) = run_query(&table)? { 218 | break EntryDetails { 219 | max_size: size, 220 | max_size_snapshot_hash: hash.clone(), 221 | first_seen: time, 222 | first_seen_snapshot_hash: hash.clone(), 223 | last_seen: time, 224 | last_seen_snapshot_hash: hash, 225 | }; 226 | } 227 | } 228 | } 229 | }; 230 | let mut max_size_time = details.first_seen; // Time of the max_size snapshot 231 | for table in entries_tables { 232 | if let Some((hash, size, time)) = run_query(&table)? { 233 | if size > details.max_size 234 | || (size == details.max_size && time > max_size_time) 235 | { 236 | details.max_size = size; 237 | details.max_size_snapshot_hash = hash.clone(); 238 | max_size_time = time; 239 | } 240 | if time < details.first_seen { 241 | details.first_seen = time; 242 | details.first_seen_snapshot_hash = hash.clone(); 243 | } 244 | if time > details.last_seen { 245 | details.last_seen = time; 246 | details.last_seen_snapshot_hash = hash; 247 | } 248 | } 249 | } 250 | Ok(Some(details)) 251 | } 252 | 253 | pub fn save_snapshot( 254 | &mut self, 255 | snapshot: &Snapshot, 256 | tree: SizeTree, 257 | ) -> Result { 258 | let mut file_count = 0; 259 | let tx = self.conn.transaction()?; 260 | { 261 | tx.execute( 262 | "INSERT INTO snapshots ( \ 263 | hash, \ 264 | time, \ 265 | parent, \ 266 | tree, \ 267 | hostname, \ 268 | username, \ 269 | uid, \ 270 | gid, \ 271 | original_id, \ 272 | program_version \ 273 | ) \ 274 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 275 | params![ 276 | snapshot.id, 277 | datetime_to_timestamp(snapshot.time), 278 | snapshot.parent, 279 | snapshot.tree, 280 | snapshot.hostname, 281 | snapshot.username, 282 | snapshot.uid, 283 | snapshot.gid, 284 | snapshot.original_id, 285 | snapshot.program_version 286 | ], 287 | )?; 288 | let mut snapshot_paths_stmt = tx.prepare( 289 | "INSERT INTO snapshot_paths (hash, path) VALUES (?, ?)", 290 | )?; 291 | for path in snapshot.paths.iter() { 292 | snapshot_paths_stmt.execute([&snapshot.id, path])?; 293 | } 294 | let mut snapshot_excludes_stmt = tx.prepare( 295 | "INSERT INTO snapshot_excludes (hash, path) VALUES (?, ?)", 296 | )?; 297 | for path in snapshot.excludes.iter() { 298 | snapshot_excludes_stmt.execute([&snapshot.id, path])?; 299 | } 300 | let mut snapshot_tags_stmt = tx.prepare( 301 | "INSERT INTO snapshot_tags (hash, tag) VALUES (?, ?)", 302 | )?; 303 | for path in snapshot.tags.iter() { 304 | snapshot_tags_stmt.execute([&snapshot.id, path])?; 305 | } 306 | } 307 | { 308 | let entries_table = format!("entries_{}", &snapshot.id); 309 | tx.execute( 310 | &format!( 311 | "CREATE TABLE \"{entries_table}\" ( 312 | path_id INTEGER PRIMARY KEY, 313 | size INTEGER NOT NULL, 314 | is_dir INTEGER NOT NULL, 315 | FOREIGN KEY (path_id) REFERENCES paths (id) 316 | )" 317 | ), 318 | [], 319 | )?; 320 | let mut entries_stmt = tx.prepare(&format!( 321 | "INSERT INTO \"{entries_table}\" (path_id, size, is_dir) \ 322 | VALUES (?, ?, ?)", 323 | ))?; 324 | 325 | let mut paths_stmt = tx.prepare( 326 | "INSERT INTO paths (parent_id, component) 327 | VALUES (?, ?) 328 | ON CONFLICT (parent_id, component) DO NOTHING", 329 | )?; 330 | let mut paths_query = tx.prepare( 331 | "SELECT id FROM paths WHERE parent_id = ? AND component = ?", 332 | )?; 333 | 334 | tree.0.traverse_with_context( 335 | |id_stack, component, size, is_dir| { 336 | let parent_id = id_stack.last().copied(); 337 | paths_stmt.execute(params![ 338 | o_path_id_to_raw_u64(parent_id), 339 | component, 340 | ])?; 341 | let path_id = paths_query.query_row( 342 | params![o_path_id_to_raw_u64(parent_id), component], 343 | |row| row.get(0).map(PathId), 344 | )?; 345 | entries_stmt.execute(params![path_id.0, size, is_dir])?; 346 | file_count += 1; 347 | Ok::(path_id) 348 | }, 349 | )?; 350 | } 351 | tx.commit()?; 352 | Ok(file_count) 353 | } 354 | 355 | pub fn delete_snapshot( 356 | &mut self, 357 | hash: impl AsRef, 358 | ) -> Result<(), rusqlite::Error> { 359 | let hash = hash.as_ref(); 360 | let tx = self.conn.transaction()?; 361 | tx.execute("DELETE FROM snapshots WHERE hash = ?", [hash])?; 362 | tx.execute("DELETE FROM snapshot_paths WHERE hash = ?", [hash])?; 363 | tx.execute("DELETE FROM snapshot_excludes WHERE hash = ?", [hash])?; 364 | tx.execute("DELETE FROM snapshot_tags WHERE hash = ?", [hash])?; 365 | tx.execute(&format!("DROP TABLE IF EXISTS \"entries_{}\"", hash), [])?; 366 | tx.commit() 367 | } 368 | 369 | // Marks //////////////////////////////////////////////// 370 | pub fn get_marks(&self) -> Result, rusqlite::Error> { 371 | let mut stmt = self.conn.prepare("SELECT path FROM marks")?; 372 | #[allow(clippy::let_and_return)] 373 | let result = stmt 374 | .query_map([], |row| Ok(row.get::<&str, String>("path")?.into()))? 375 | .collect(); 376 | result 377 | } 378 | 379 | pub fn upsert_mark( 380 | &mut self, 381 | path: &Utf8Path, 382 | ) -> Result { 383 | self.conn.execute( 384 | "INSERT INTO marks (path) VALUES (?) \ 385 | ON CONFLICT (path) DO NOTHING", 386 | [path.as_str()], 387 | ) 388 | } 389 | 390 | pub fn delete_mark( 391 | &mut self, 392 | path: &Utf8Path, 393 | ) -> Result { 394 | self.conn.execute("DELETE FROM marks WHERE path = ?", [path.as_str()]) 395 | } 396 | 397 | pub fn delete_all_marks(&mut self) -> Result { 398 | self.conn.execute("DELETE FROM marks", []) 399 | } 400 | } 401 | 402 | // A PathId should never be 0. 403 | // This is reserved for the absolute root and should match None 404 | #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] 405 | #[repr(transparent)] 406 | pub struct PathId(u64); 407 | 408 | fn raw_u64_to_o_path_id(id: u64) -> Option { 409 | if id == 0 { 410 | None 411 | } else { 412 | Some(PathId(id)) 413 | } 414 | } 415 | 416 | fn o_path_id_to_raw_u64(path_id: Option) -> u64 { 417 | path_id.map(|path_id| path_id.0).unwrap_or(0) 418 | } 419 | 420 | #[derive(Clone, Debug, Eq, PartialEq)] 421 | pub struct Entry { 422 | pub path_id: PathId, 423 | pub component: String, 424 | pub size: usize, 425 | pub is_dir: bool, 426 | } 427 | 428 | #[derive(Clone, Debug, Eq, PartialEq)] 429 | pub struct EntryDetails { 430 | pub max_size: usize, 431 | pub max_size_snapshot_hash: String, 432 | pub first_seen: DateTime, 433 | pub first_seen_snapshot_hash: String, 434 | pub last_seen: DateTime, 435 | pub last_seen_snapshot_hash: String, 436 | } 437 | 438 | ////////// Migrations ////////////////////////////////////////////////////////// 439 | type VersionId = u64; 440 | 441 | struct Migration { 442 | old: Option, 443 | new: VersionId, 444 | resync_necessary: bool, 445 | migration_fun: fn(&mut Connection) -> Result<(), rusqlite::Error>, 446 | } 447 | 448 | const INTEGER_METADATA_TABLE: &str = "metadata_integer"; 449 | 450 | pub const LATEST_VERSION: VersionId = 1; 451 | 452 | const MIGRATIONS: [Migration; 3] = [ 453 | Migration { 454 | old: None, 455 | new: 0, 456 | resync_necessary: false, 457 | migration_fun: migrate_none_to_v0, 458 | }, 459 | Migration { 460 | old: None, 461 | new: 1, 462 | resync_necessary: false, 463 | migration_fun: migrate_none_to_v1, 464 | }, 465 | Migration { 466 | old: Some(0), 467 | new: 1, 468 | resync_necessary: true, 469 | migration_fun: migrate_v0_to_v1, 470 | }, 471 | ]; 472 | 473 | #[derive(Debug, Error)] 474 | pub enum MigrationError { 475 | #[error("Invalid state, unable to determine version")] 476 | UnableToDetermineVersion, 477 | #[error("Do not know how to migrate from the current version")] 478 | NoMigrationPath { old: Option, new: VersionId }, 479 | #[error("Sqlite error")] 480 | Sql(#[from] rusqlite::Error), 481 | } 482 | 483 | pub struct Migrator<'a> { 484 | conn: Connection, 485 | migration: Option<&'a Migration>, 486 | } 487 | 488 | impl<'a> Migrator<'a> { 489 | pub fn open(file: &Path) -> Result { 490 | Self::open_(file, LATEST_VERSION) 491 | } 492 | 493 | #[cfg(any(test, feature = "bench"))] 494 | pub fn open_with_target( 495 | file: &Path, 496 | target: VersionId, 497 | ) -> Result { 498 | Self::open_(file, target) 499 | } 500 | 501 | // We don't try to find multi step migrations. 502 | fn open_(file: &Path, target: VersionId) -> Result { 503 | let mut conn = Connection::open(file)?; 504 | conn.pragma_update(None, "journal_mode", "WAL")?; 505 | conn.pragma_update(None, "synchronous", "NORMAL")?; 506 | // This is only used in V0 507 | conn.create_scalar_function( 508 | "path_parent", 509 | 1, 510 | FunctionFlags::SQLITE_UTF8 511 | | FunctionFlags::SQLITE_DETERMINISTIC 512 | | FunctionFlags::SQLITE_INNOCUOUS, 513 | |ctx| { 514 | let path = Utf8Path::new(ctx.get_raw(0).as_str()?); 515 | let parent = path.parent().map(ToOwned::to_owned); 516 | Ok(parent.and_then(|p| { 517 | let s = p.to_string(); 518 | if s.is_empty() { 519 | None 520 | } else { 521 | Some(s) 522 | } 523 | })) 524 | }, 525 | )?; 526 | conn.profile(Some(|stmt, duration| { 527 | trace!("SQL {stmt} (took {duration:#?})") 528 | })); 529 | let current = determine_version(&conn)?; 530 | if current == Some(target) { 531 | return Ok(Migrator { conn, migration: None }); 532 | } 533 | if let Some(migration) = 534 | MIGRATIONS.iter().find(|m| m.old == current && m.new == target) 535 | { 536 | Ok(Migrator { conn, migration: Some(migration) }) 537 | } else { 538 | Err(MigrationError::NoMigrationPath { old: current, new: target }) 539 | } 540 | } 541 | 542 | pub fn migrate(mut self) -> Result { 543 | if let Some(migration) = self.migration { 544 | (migration.migration_fun)(&mut self.conn)?; 545 | } 546 | Ok(Cache { conn: self.conn }) 547 | } 548 | 549 | pub fn need_to_migrate(&self) -> Option<(Option, VersionId)> { 550 | self.migration.map(|m| (m.old, m.new)) 551 | } 552 | 553 | pub fn resync_necessary(&self) -> bool { 554 | self.migration.map(|m| m.resync_necessary).unwrap_or(false) 555 | } 556 | } 557 | 558 | fn migrate_none_to_v0(conn: &mut Connection) -> Result<(), rusqlite::Error> { 559 | let tx = conn.transaction()?; 560 | tx.execute_batch(include_str!("cache/sql/none_to_v0.sql"))?; 561 | tx.commit() 562 | } 563 | 564 | fn migrate_none_to_v1(conn: &mut Connection) -> Result<(), rusqlite::Error> { 565 | let tx = conn.transaction()?; 566 | tx.execute_batch(include_str!("cache/sql/none_to_v1.sql"))?; 567 | tx.commit() 568 | } 569 | 570 | fn migrate_v0_to_v1(conn: &mut Connection) -> Result<(), rusqlite::Error> { 571 | let tx = conn.transaction()?; 572 | tx.execute_batch(include_str!("cache/sql/v0_to_v1.sql"))?; 573 | tx.commit() 574 | } 575 | 576 | fn determine_version( 577 | conn: &Connection, 578 | ) -> Result, MigrationError> { 579 | const V0_TABLES: [&str; 4] = ["snapshots", "files", "directories", "marks"]; 580 | 581 | let tables = get_tables(conn)?; 582 | if tables.contains(INTEGER_METADATA_TABLE) { 583 | conn.query_row( 584 | &format!( 585 | "SELECT value FROM {INTEGER_METADATA_TABLE} 586 | WHERE key = 'version'" 587 | ), 588 | [], 589 | |row| row.get::(0), 590 | ) 591 | .optional()? 592 | .map(|v| Ok(Some(v))) 593 | .unwrap_or(Err(MigrationError::UnableToDetermineVersion)) 594 | } else if V0_TABLES.iter().all(|t| tables.contains(*t)) { 595 | // The V0 tables are present but without a metadata table 596 | // Assume V0 (pre-versioning schema). 597 | Ok(Some(0)) 598 | } else { 599 | // No metadata table and no V0 tables, assume a fresh db. 600 | Ok(None) 601 | } 602 | } 603 | 604 | fn get_tables(conn: &Connection) -> Result, rusqlite::Error> { 605 | let mut stmt = 606 | conn.prepare("SELECT name FROM sqlite_master WHERE type='table'")?; 607 | let names = stmt.query_map([], |row| row.get(0))?; 608 | names.collect() 609 | } 610 | 611 | ////////// Misc //////////////////////////////////////////////////////////////// 612 | fn timestamp_to_datetime(timestamp: i64) -> Result, Error> { 613 | DateTime::from_timestamp_micros(timestamp) 614 | .map(Ok) 615 | .unwrap_or(Err(Error::ExhaustedTimestampPrecision)) 616 | } 617 | 618 | fn datetime_to_timestamp(datetime: DateTime) -> i64 { 619 | datetime.timestamp_micros() 620 | } 621 | -------------------------------------------------------------------------------- /src/cache/filetree.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::max, 3 | collections::{hash_map, HashMap}, 4 | iter::Peekable, 5 | }; 6 | 7 | use thiserror::Error; 8 | 9 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 10 | pub struct SizeTree(pub FileTree); 11 | 12 | #[derive(Debug, Eq, Error, PartialEq)] 13 | pub enum InsertError { 14 | #[error("Tried to insert into empty path")] 15 | EmptyPath, 16 | #[error("Tried to insert into existing path")] 17 | EntryExists, 18 | } 19 | 20 | impl SizeTree { 21 | pub fn new() -> Self { 22 | SizeTree(FileTree::new()) 23 | } 24 | 25 | pub fn merge(self, other: SizeTree) -> Self { 26 | SizeTree(self.0.merge(other.0, max)) 27 | } 28 | 29 | pub fn iter( 30 | &self, 31 | ) -> impl Iterator + '_ { 32 | self.0 33 | .iter() 34 | .map(|(level, cs, size, is_dir)| (level, cs, *size, is_dir)) 35 | } 36 | 37 | // `update` is used to update the sizes for all ancestors 38 | pub fn insert( 39 | &mut self, 40 | path: P, 41 | size: usize, 42 | ) -> Result<(), InsertError> 43 | where 44 | C: AsRef, 45 | P: IntoIterator, 46 | { 47 | let (mut breadcrumbs, mut remaining) = { 48 | let (breadcrumbs, remaining) = self.0.find(path); 49 | (breadcrumbs, remaining.peekable()) 50 | }; 51 | if remaining.peek().is_none() { 52 | return Err(InsertError::EntryExists); 53 | } 54 | 55 | // Update existing ancestors 56 | for node in breadcrumbs.iter_mut() { 57 | unsafe { (**node).data += size }; 58 | } 59 | 60 | // Create the rest 61 | let mut current_node: &mut Node = { 62 | if let Some(last) = breadcrumbs.pop() { 63 | unsafe { &mut *last } 64 | } else if let Some(component) = remaining.next() { 65 | self.0 66 | .children 67 | .entry(Box::from(component.as_ref())) 68 | .or_insert(Node::new(size)) 69 | } else { 70 | return Err(InsertError::EmptyPath); 71 | } 72 | }; 73 | for component in remaining { 74 | current_node = current_node 75 | .children 76 | .entry(Box::from(component.as_ref())) 77 | .or_insert(Node::new(0)); 78 | current_node.data = size; 79 | } 80 | 81 | Ok(()) 82 | } 83 | } 84 | 85 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 86 | pub struct FileTree { 87 | children: HashMap, Node>, 88 | } 89 | 90 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 91 | struct Node { 92 | data: T, 93 | children: HashMap, Node>, 94 | } 95 | 96 | impl FileTree { 97 | pub fn new() -> Self { 98 | FileTree { children: HashMap::new() } 99 | } 100 | 101 | pub fn merge(self, other: Self, mut combine: F) -> Self 102 | where 103 | F: FnMut(T, T) -> T, 104 | { 105 | fn merge_children T>( 106 | a: HashMap, Node>, 107 | b: HashMap, Node>, 108 | f: &mut F, 109 | ) -> HashMap, Node> { 110 | let mut sorted_a = sorted_hashmap(a).into_iter(); 111 | let mut sorted_b = sorted_hashmap(b).into_iter(); 112 | let mut children = HashMap::new(); 113 | loop { 114 | match (sorted_a.next(), sorted_b.next()) { 115 | (Some((name0, tree0)), Some((name1, tree1))) => { 116 | if name0 == name1 { 117 | children.insert(name0, merge_node(tree0, tree1, f)); 118 | } else { 119 | children.insert(name0, tree0); 120 | children.insert(name1, tree1); 121 | } 122 | } 123 | (None, Some((name, tree))) => { 124 | children.insert(name, tree); 125 | } 126 | (Some((name, tree)), None) => { 127 | children.insert(name, tree); 128 | } 129 | (None, None) => { 130 | break; 131 | } 132 | } 133 | } 134 | children 135 | } 136 | 137 | // This exists to be able to reuse `combine` multiple times in the loop 138 | // without being consumed by the recursive calls 139 | fn merge_node T>( 140 | a: Node, 141 | b: Node, 142 | f: &mut F, 143 | ) -> Node { 144 | Node { 145 | data: f(a.data, b.data), 146 | children: merge_children(a.children, b.children, f), 147 | } 148 | } 149 | 150 | FileTree { 151 | children: merge_children( 152 | self.children, 153 | other.children, 154 | &mut combine, 155 | ), 156 | } 157 | } 158 | 159 | /// Depth first, parent before children 160 | pub fn iter(&self) -> Iter<'_, T> { 161 | let breadcrumb = 162 | Breadcrumb { level: 1, children: self.children.iter() }; 163 | Iter { stack: vec![breadcrumb] } 164 | } 165 | 166 | /// Traverse the tree while keeping a context. 167 | /// The context is morally `[f(node_0), f(node_1), ..., f(node_2)]` for 168 | /// all ancestors nodes `node_i` of the visited node. 169 | /// 170 | /// Depth first, parent before children 171 | pub fn traverse_with_context<'a, C, E, F>( 172 | &'a self, 173 | mut f: F, 174 | ) -> Result<(), E> 175 | where 176 | F: for<'b> FnMut(&'b [C], &'a str, &'a T, bool) -> Result, 177 | { 178 | let mut iter = self.iter(); 179 | // First iteration just to initialized id_stack and previous_level 180 | let (mut context, mut previous_level): (Vec, usize) = { 181 | if let Some((level, component, data, is_dir)) = iter.next() { 182 | let context_component = f(&[], component, data, is_dir)?; 183 | (vec![context_component], level) 184 | } else { 185 | return Ok(()); 186 | } 187 | }; 188 | 189 | for (level, component, size, is_dir) in iter { 190 | if level <= previous_level { 191 | // We went up the tree or moved to a sibling 192 | for _ in 0..previous_level - level + 1 { 193 | context.pop(); 194 | } 195 | } 196 | context.push(f(&context, component, size, is_dir)?); 197 | previous_level = level; 198 | } 199 | Ok(()) 200 | } 201 | 202 | /// Returns the breadcrumbs of the largest prefix of the path. 203 | /// If the file is in the tree the last breadcrumb will be the file itself. 204 | /// Does not modify self at all. 205 | /// The cdr is the remaining path that did not match, if any. 206 | fn find( 207 | &mut self, 208 | path: P, 209 | ) -> (Vec<*mut Node>, impl Iterator) 210 | where 211 | C: AsRef, 212 | P: IntoIterator, 213 | { 214 | let mut iter = path.into_iter().peekable(); 215 | if let Some(component) = iter.peek() { 216 | let component = component.as_ref(); 217 | if let Some(node) = self.children.get_mut(component) { 218 | iter.next(); 219 | return node.find(iter); 220 | } 221 | } 222 | (vec![], iter) 223 | } 224 | } 225 | 226 | impl Node { 227 | fn new(data: T) -> Self { 228 | Node { data, children: HashMap::new() } 229 | } 230 | 231 | fn find( 232 | &mut self, 233 | mut path: Peekable

, 234 | ) -> (Vec<*mut Node>, Peekable

) 235 | where 236 | C: AsRef, 237 | P: Iterator, 238 | { 239 | let mut breadcrumbs: Vec<*mut Node> = vec![self]; 240 | while let Some(c) = path.peek() { 241 | let c = c.as_ref(); 242 | let current = unsafe { &mut **breadcrumbs.last().unwrap() }; 243 | match current.children.get_mut(c) { 244 | Some(next) => { 245 | breadcrumbs.push(next); 246 | path.next(); 247 | } 248 | None => break, 249 | } 250 | } 251 | (breadcrumbs, path) 252 | } 253 | } 254 | 255 | pub struct Iter<'a, T> { 256 | stack: Vec>, 257 | } 258 | 259 | struct Breadcrumb<'a, T> { 260 | level: usize, 261 | children: hash_map::Iter<'a, Box, Node>, 262 | } 263 | 264 | impl<'a, T> Iterator for Iter<'a, T> { 265 | /// (level, component, data, is_directory) 266 | type Item = (usize, &'a str, &'a T, bool); 267 | 268 | /// Depth first, parent before children 269 | fn next(&mut self) -> Option { 270 | loop { 271 | if let Some(mut breadcrumb) = self.stack.pop() { 272 | if let Some((component, child)) = breadcrumb.children.next() { 273 | let level = breadcrumb.level + 1; 274 | let item = ( 275 | level, 276 | component as &str, 277 | &child.data, 278 | !child.children.is_empty(), 279 | ); 280 | self.stack.push(breadcrumb); 281 | self.stack.push(Breadcrumb { 282 | level, 283 | children: child.children.iter(), 284 | }); 285 | break Some(item); 286 | } 287 | } else { 288 | break None; 289 | } 290 | } 291 | } 292 | } 293 | 294 | fn sorted_hashmap(m: HashMap) -> Vec<(K, V)> { 295 | let mut vec = m.into_iter().collect::>(); 296 | vec.sort_unstable_by(|(k0, _), (k1, _)| k0.cmp(k1)); 297 | vec 298 | } 299 | -------------------------------------------------------------------------------- /src/cache/sql/none_to_v0.sql: -------------------------------------------------------------------------------- 1 | -- snapshots 2 | CREATE TABLE IF NOT EXISTS snapshots ( 3 | id TEXT PRIMARY KEY, 4 | "group" INTEGER NOT NULL 5 | ); 6 | 7 | -- files 8 | CREATE TABLE IF NOT EXISTS files ( 9 | snapshot_group INTEGER, 10 | path TEXT, 11 | size INTEGER, 12 | parent TEXT GENERATED ALWAYS AS (path_parent(path)), 13 | PRIMARY KEY (snapshot_group, path) 14 | ); 15 | 16 | CREATE INDEX IF NOT EXISTS files_path_parent 17 | ON files (parent); 18 | 19 | -- directories 20 | CREATE TABLE IF NOT EXISTS directories ( 21 | snapshot_group INTEGER, 22 | path TEXT, 23 | size INTEGER, 24 | parent TEXT GENERATED ALWAYS AS (path_parent(path)), 25 | PRIMARY KEY (snapshot_group, path) 26 | ); 27 | 28 | CREATE INDEX IF NOT EXISTS directories_path_parent 29 | ON directories (parent); 30 | 31 | -- marks 32 | CREATE TABLE IF NOT EXISTS marks ( 33 | path TEXT PRIMARY KEY 34 | ); 35 | -------------------------------------------------------------------------------- /src/cache/sql/none_to_v1.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE metadata_integer ( 2 | key TEXT PRIMARY KEY, 3 | value INTEGER NOT NULL 4 | ) WITHOUT ROWID; 5 | INSERT INTO metadata_integer (key, value) VALUES ('version', 1); 6 | 7 | CREATE TABLE paths ( 8 | id INTEGER PRIMARY KEY, 9 | parent_id INTEGER NOT NULL, 10 | component TEXT NOT NULL 11 | ); 12 | CREATE UNIQUE INDEX paths_parent_component ON paths (parent_id, component); 13 | 14 | CREATE TABLE snapshots ( 15 | hash TEXT PRIMARY KEY, 16 | time INTEGER, 17 | parent TEXT, 18 | tree TEXT NOT NULL, 19 | hostname TEXT, 20 | username TEXT, 21 | uid INTEGER, 22 | gid INTEGER, 23 | original_id TEXT, 24 | program_version TEXT 25 | ) WITHOUT ROWID; 26 | CREATE TABLE snapshot_paths ( 27 | hash TEXT, 28 | path TEXT, 29 | PRIMARY KEY (hash, path) 30 | ) WITHOUT ROWID; 31 | CREATE TABLE snapshot_excludes ( 32 | hash TEXT, 33 | path TEXT, 34 | PRIMARY KEY (hash, path) 35 | ) WITHOUT ROWID; 36 | CREATE TABLE snapshot_tags ( 37 | hash TEXT, 38 | tag TEXT, 39 | PRIMARY KEY (hash, tag) 40 | ) WITHOUT ROWID; 41 | 42 | -- The entries tables are sharded per snapshot and created dynamically 43 | 44 | CREATE TABLE marks (path TEXT PRIMARY KEY) WITHOUT ROWID; 45 | -------------------------------------------------------------------------------- /src/cache/sql/v0_to_v1.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX files_path_parent; 2 | DROP INDEX directories_path_parent; 3 | DROP TABLE snapshots; 4 | DROP TABLE files; 5 | DROP TABLE directories; 6 | 7 | CREATE TABLE metadata_integer ( 8 | key TEXT PRIMARY KEY, 9 | value INTEGER NOT NULL 10 | ) WITHOUT ROWID; 11 | INSERT INTO metadata_integer (key, value) VALUES ('version', 1); 12 | 13 | CREATE TABLE paths ( 14 | id INTEGER PRIMARY KEY, 15 | parent_id INTEGER NOT NULL, 16 | component TEXT NOT NULL 17 | ); 18 | CREATE UNIQUE INDEX paths_parent_component ON paths (parent_id, component); 19 | 20 | CREATE TABLE snapshots ( 21 | hash TEXT PRIMARY KEY, 22 | time INTEGER, 23 | parent TEXT, 24 | tree TEXT NOT NULL, 25 | hostname TEXT, 26 | username TEXT, 27 | uid INTEGER, 28 | gid INTEGER, 29 | original_id TEXT, 30 | program_version TEXT 31 | ) WITHOUT ROWID; 32 | CREATE TABLE snapshot_paths ( 33 | hash TEXT, 34 | path TEXT, 35 | PRIMARY KEY (hash, path) 36 | ) WITHOUT ROWID; 37 | CREATE TABLE snapshot_excludes ( 38 | hash TEXT, 39 | path TEXT, 40 | PRIMARY KEY (hash, path) 41 | ) WITHOUT ROWID; 42 | CREATE TABLE snapshot_tags ( 43 | hash TEXT, 44 | tag TEXT, 45 | PRIMARY KEY (hash, tag) 46 | ) WITHOUT ROWID; 47 | 48 | -- The entries tables are sharded per snapshot and created dynamically 49 | 50 | CREATE TABLE new_marks (path TEXT PRIMARY KEY) WITHOUT ROWID; 51 | INSERT INTO new_marks (path) SELECT path FROM marks; 52 | DROP TABLE marks; 53 | ALTER TABLE new_marks RENAME TO marks; 54 | -------------------------------------------------------------------------------- /src/cache/tests.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Reverse, collections::HashSet, convert::Infallible, env, fs, iter, 3 | mem, path::PathBuf, 4 | }; 5 | 6 | use camino::{Utf8Path, Utf8PathBuf}; 7 | use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; 8 | use rusqlite::Connection; 9 | use uuid::Uuid; 10 | 11 | use crate::{ 12 | cache::{ 13 | determine_version, 14 | filetree::{InsertError, SizeTree}, 15 | get_tables, timestamp_to_datetime, Cache, EntryDetails, Migrator, 16 | }, 17 | restic::Snapshot, 18 | }; 19 | 20 | pub fn mk_datetime( 21 | year: i32, 22 | month: u32, 23 | day: u32, 24 | hour: u32, 25 | minute: u32, 26 | second: u32, 27 | ) -> DateTime { 28 | NaiveDateTime::new( 29 | NaiveDate::from_ymd_opt(year, month, day).unwrap(), 30 | NaiveTime::from_hms_opt(hour, minute, second).unwrap(), 31 | ) 32 | .and_utc() 33 | } 34 | 35 | pub struct Tempfile(pub PathBuf); 36 | 37 | impl Drop for Tempfile { 38 | fn drop(&mut self) { 39 | fs::remove_file(mem::take(&mut self.0)).unwrap(); 40 | } 41 | } 42 | 43 | impl Tempfile { 44 | pub fn new() -> Self { 45 | let mut path = env::temp_dir(); 46 | path.push(Uuid::new_v4().to_string()); 47 | Tempfile(path) 48 | } 49 | } 50 | 51 | pub fn path_parent(path: &Utf8Path) -> Option { 52 | let parent = path.parent().map(ToOwned::to_owned); 53 | parent.and_then(|p| if p.as_str().is_empty() { None } else { Some(p) }) 54 | } 55 | 56 | pub struct PathGenerator { 57 | branching_factor: usize, 58 | state: Vec<(usize, Utf8PathBuf, usize)>, 59 | } 60 | 61 | impl PathGenerator { 62 | pub fn new(depth: usize, branching_factor: usize) -> Self { 63 | let mut state = Vec::with_capacity(depth); 64 | state.push((depth, Utf8PathBuf::new(), 0)); 65 | PathGenerator { branching_factor, state } 66 | } 67 | } 68 | 69 | impl Iterator for PathGenerator { 70 | type Item = Utf8PathBuf; 71 | 72 | fn next(&mut self) -> Option { 73 | loop { 74 | let (depth, prefix, child) = self.state.pop()?; 75 | if child < self.branching_factor { 76 | let mut new_prefix = prefix.clone(); 77 | new_prefix.push(Utf8PathBuf::from(child.to_string())); 78 | self.state.push((depth, prefix, child + 1)); 79 | if depth == 1 { 80 | break Some(new_prefix); 81 | } else { 82 | self.state.push((depth - 1, new_prefix, 0)); 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | pub fn generate_sizetree(depth: usize, branching_factor: usize) -> SizeTree { 90 | let mut sizetree = SizeTree::new(); 91 | for path in PathGenerator::new(depth, branching_factor) { 92 | sizetree.insert(path.components(), 1).unwrap(); 93 | } 94 | sizetree 95 | } 96 | 97 | fn sort_entries(entries: &mut [(Vec<&str>, usize, bool)]) { 98 | entries.sort_unstable_by(|e0, e1| e0.0.cmp(&e1.0)); 99 | } 100 | 101 | fn to_sorted_entries(tree: &SizeTree) -> Vec<(Vec<&str>, usize, bool)> { 102 | let mut entries = Vec::new(); 103 | tree.0 104 | .traverse_with_context(|context, component, size, is_dir| { 105 | let mut path = Vec::from(context); 106 | path.push(component); 107 | entries.push((path, *size, is_dir)); 108 | Ok::<&str, Infallible>(component) 109 | }) 110 | .unwrap(); 111 | sort_entries(&mut entries); 112 | entries 113 | } 114 | 115 | fn assert_get_entries_correct_at_path>( 116 | cache: &Cache, 117 | tree: &SizeTree, 118 | path: P, 119 | ) { 120 | let mut db_entries = { 121 | let path_id = if path.as_ref().as_str().is_empty() { 122 | None 123 | } else { 124 | cache.get_path_id_by_path(path.as_ref()).unwrap() 125 | }; 126 | if path_id.is_none() && !path.as_ref().as_str().is_empty() { 127 | // path was not found 128 | vec![] 129 | } else { 130 | cache 131 | .get_entries(path_id) 132 | .unwrap() 133 | .into_iter() 134 | .map(|e| (e.component, e.size, e.is_dir)) 135 | .collect::>() 136 | } 137 | }; 138 | db_entries.sort_by_key(|(component, _, _)| component.clone()); 139 | let mut entries = to_sorted_entries(&tree) 140 | .iter() 141 | .filter_map(|(components, size, is_dir)| { 142 | // keep only the ones with parent == loc 143 | let (last, parent_cs) = components.split_last()?; 144 | let parent = parent_cs.iter().collect::(); 145 | if parent == path.as_ref() { 146 | Some((last.to_string(), *size, *is_dir)) 147 | } else { 148 | None 149 | } 150 | }) 151 | .collect::>(); 152 | entries.sort_by_key(|(_, size, _)| Reverse(*size)); 153 | entries.sort_by_key(|(component, _, _)| component.clone()); 154 | assert_eq!(db_entries, entries); 155 | } 156 | 157 | fn example_tree_0() -> SizeTree { 158 | let mut sizetree = SizeTree::new(); 159 | assert_eq!(sizetree.insert(["a", "0", "x"], 1), Ok(())); 160 | assert_eq!(sizetree.insert(["a", "0", "y"], 2), Ok(())); 161 | assert_eq!(sizetree.insert(["a", "1", "x", "0"], 7), Ok(())); 162 | assert_eq!(sizetree.insert(["a", "0", "z", "0"], 1), Ok(())); 163 | assert_eq!(sizetree.insert(["a", "1", "x", "1"], 2), Ok(())); 164 | sizetree 165 | } 166 | 167 | fn example_tree_1() -> SizeTree { 168 | let mut sizetree = SizeTree::new(); 169 | assert_eq!(sizetree.insert(["a", "0", "x"], 3), Ok(())); 170 | assert_eq!(sizetree.insert(["a", "0", "y"], 2), Ok(())); 171 | assert_eq!(sizetree.insert(["a", "2", "x", "0"], 7), Ok(())); 172 | assert_eq!(sizetree.insert(["a", "0", "z", "0"], 9), Ok(())); 173 | assert_eq!(sizetree.insert(["a", "1", "x", "1"], 1), Ok(())); 174 | sizetree 175 | } 176 | 177 | fn example_tree_2() -> SizeTree { 178 | let mut sizetree = SizeTree::new(); 179 | assert_eq!(sizetree.insert(["b", "0", "x"], 3), Ok(())); 180 | assert_eq!(sizetree.insert(["b", "0", "y"], 2), Ok(())); 181 | assert_eq!(sizetree.insert(["a", "2", "x", "0"], 7), Ok(())); 182 | assert_eq!(sizetree.insert(["b", "0", "z", "0"], 9), Ok(())); 183 | assert_eq!(sizetree.insert(["a", "1", "x", "1"], 1), Ok(())); 184 | sizetree 185 | } 186 | 187 | #[test] 188 | fn sizetree_iter_empty() { 189 | let sizetree = SizeTree::new(); 190 | assert_eq!(sizetree.iter().next(), None); 191 | } 192 | 193 | #[test] 194 | fn insert_uniques_0() { 195 | let tree = example_tree_0(); 196 | let entries = to_sorted_entries(&tree); 197 | assert_eq!( 198 | entries, 199 | vec![ 200 | (vec!["a"], 13, true), 201 | (vec!["a", "0"], 4, true), 202 | (vec!["a", "0", "x"], 1, false), 203 | (vec!["a", "0", "y"], 2, false), 204 | (vec!["a", "0", "z"], 1, true), 205 | (vec!["a", "0", "z", "0"], 1, false), 206 | (vec!["a", "1"], 9, true), 207 | (vec!["a", "1", "x"], 9, true), 208 | (vec!["a", "1", "x", "0"], 7, false), 209 | (vec!["a", "1", "x", "1"], 2, false), 210 | ] 211 | ); 212 | } 213 | 214 | #[test] 215 | fn insert_uniques_1() { 216 | let tree = example_tree_1(); 217 | let entries = to_sorted_entries(&tree); 218 | assert_eq!( 219 | entries, 220 | vec![ 221 | (vec!["a"], 22, true), 222 | (vec!["a", "0"], 14, true), 223 | (vec!["a", "0", "x"], 3, false), 224 | (vec!["a", "0", "y"], 2, false), 225 | (vec!["a", "0", "z"], 9, true), 226 | (vec!["a", "0", "z", "0"], 9, false), 227 | (vec!["a", "1"], 1, true), 228 | (vec!["a", "1", "x"], 1, true), 229 | (vec!["a", "1", "x", "1"], 1, false), 230 | (vec!["a", "2"], 7, true), 231 | (vec!["a", "2", "x"], 7, true), 232 | (vec!["a", "2", "x", "0"], 7, false), 233 | ] 234 | ); 235 | } 236 | 237 | #[test] 238 | fn insert_uniques_2() { 239 | let tree = example_tree_2(); 240 | let entries = to_sorted_entries(&tree); 241 | assert_eq!( 242 | entries, 243 | vec![ 244 | (vec!["a"], 8, true), 245 | (vec!["a", "1"], 1, true), 246 | (vec!["a", "1", "x"], 1, true), 247 | (vec!["a", "1", "x", "1"], 1, false), 248 | (vec!["a", "2"], 7, true), 249 | (vec!["a", "2", "x"], 7, true), 250 | (vec!["a", "2", "x", "0"], 7, false), 251 | (vec!["b"], 14, true), 252 | (vec!["b", "0"], 14, true), 253 | (vec!["b", "0", "x"], 3, false), 254 | (vec!["b", "0", "y"], 2, false), 255 | (vec!["b", "0", "z"], 9, true), 256 | (vec!["b", "0", "z", "0"], 9, false), 257 | ] 258 | ); 259 | } 260 | 261 | #[test] 262 | fn insert_existing() { 263 | let mut sizetree = example_tree_0(); 264 | assert_eq!( 265 | sizetree.insert(Vec::<&str>::new(), 1), 266 | Err(InsertError::EntryExists) 267 | ); 268 | assert_eq!(sizetree.insert(["a", "0"], 1), Err(InsertError::EntryExists)); 269 | assert_eq!( 270 | sizetree.insert(["a", "0", "z", "0"], 1), 271 | Err(InsertError::EntryExists) 272 | ); 273 | } 274 | 275 | #[test] 276 | fn merge_test() { 277 | let tree = example_tree_0().merge(example_tree_1()); 278 | let entries = to_sorted_entries(&tree); 279 | assert_eq!( 280 | entries, 281 | vec![ 282 | (vec!["a"], 22, true), 283 | (vec!["a", "0"], 14, true), 284 | (vec!["a", "0", "x"], 3, false), 285 | (vec!["a", "0", "y"], 2, false), 286 | (vec!["a", "0", "z"], 9, true), 287 | (vec!["a", "0", "z", "0"], 9, false), 288 | (vec!["a", "1"], 9, true), 289 | (vec!["a", "1", "x"], 9, true), 290 | (vec!["a", "1", "x", "0"], 7, false), 291 | (vec!["a", "1", "x", "1"], 2, false), 292 | (vec!["a", "2"], 7, true), 293 | (vec!["a", "2", "x"], 7, true), 294 | (vec!["a", "2", "x", "0"], 7, false), 295 | ] 296 | ); 297 | } 298 | 299 | #[test] 300 | fn merge_reflexivity() { 301 | assert_eq!(example_tree_0().merge(example_tree_0()), example_tree_0()); 302 | assert_eq!(example_tree_1().merge(example_tree_1()), example_tree_1()); 303 | } 304 | 305 | #[test] 306 | fn merge_associativity() { 307 | assert_eq!( 308 | example_tree_0().merge(example_tree_1()).merge(example_tree_2()), 309 | example_tree_0().merge(example_tree_1().merge(example_tree_2())) 310 | ); 311 | } 312 | 313 | #[test] 314 | fn merge_commutativity() { 315 | assert_eq!( 316 | example_tree_0().merge(example_tree_1()), 317 | example_tree_1().merge(example_tree_0()) 318 | ); 319 | } 320 | 321 | #[test] 322 | fn cache_snapshots_entries() { 323 | fn test_snapshots(cache: &Cache, mut snapshots: Vec<&Snapshot>) { 324 | let mut db_snapshots = cache.get_snapshots().unwrap(); 325 | db_snapshots.sort_unstable_by(|s0, s1| s0.id.cmp(&s1.id)); 326 | snapshots.sort_unstable_by(|s0, s1| s0.id.cmp(&s1.id)); 327 | for (s0, s1) in iter::zip(db_snapshots.iter(), snapshots.iter()) { 328 | assert_eq!(s0.id, s1.id); 329 | assert_eq!(s0.time, s1.time); 330 | assert_eq!(s0.parent, s1.parent); 331 | assert_eq!(s0.tree, s1.tree); 332 | assert_eq!(s0.hostname, s1.hostname); 333 | assert_eq!(s0.username, s1.username); 334 | assert_eq!(s0.uid, s1.uid); 335 | assert_eq!(s0.gid, s1.gid); 336 | assert_eq!(s0.original_id, s1.original_id); 337 | assert_eq!(s0.program_version, s1.program_version); 338 | 339 | let mut s0_paths: Vec = s0.paths.iter().cloned().collect(); 340 | s0_paths.sort(); 341 | let mut s1_paths: Vec = s1.paths.iter().cloned().collect(); 342 | s1_paths.sort(); 343 | assert_eq!(s0_paths, s1_paths); 344 | 345 | let mut s0_excludes: Vec = 346 | s0.excludes.iter().cloned().collect(); 347 | s0_excludes.sort(); 348 | let mut s1_excludes: Vec = 349 | s1.excludes.iter().cloned().collect(); 350 | s1_excludes.sort(); 351 | assert_eq!(s0_excludes, s1_excludes); 352 | 353 | let mut s0_tags: Vec = s0.tags.iter().cloned().collect(); 354 | s0_tags.sort(); 355 | let mut s1_tags: Vec = s1.tags.iter().cloned().collect(); 356 | s1_tags.sort(); 357 | assert_eq!(s0_tags, s1_tags); 358 | } 359 | } 360 | 361 | let tempfile = Tempfile::new(); 362 | let mut cache = Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 363 | 364 | let foo = Snapshot { 365 | id: "foo".to_string(), 366 | time: mk_datetime(2024, 4, 12, 12, 00, 00), 367 | parent: Some("bar".to_string()), 368 | tree: "sometree".to_string(), 369 | paths: vec![ 370 | "/home/user".to_string(), 371 | "/etc".to_string(), 372 | "/var".to_string(), 373 | ] 374 | .into_iter() 375 | .collect(), 376 | hostname: Some("foo.com".to_string()), 377 | username: Some("user".to_string()), 378 | uid: Some(123), 379 | gid: Some(456), 380 | excludes: vec![ 381 | ".cache".to_string(), 382 | "Cache".to_string(), 383 | "/home/user/Downloads".to_string(), 384 | ] 385 | .into_iter() 386 | .collect(), 387 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 388 | .into_iter() 389 | .collect(), 390 | original_id: Some("fefwfwew".to_string()), 391 | program_version: Some("restic 0.16.0".to_string()), 392 | }; 393 | 394 | let bar = Snapshot { 395 | id: "bar".to_string(), 396 | time: mk_datetime(2025, 5, 12, 17, 00, 00), 397 | parent: Some("wat".to_string()), 398 | tree: "anothertree".to_string(), 399 | paths: vec!["/home/user".to_string()].into_iter().collect(), 400 | hostname: Some("foo.com".to_string()), 401 | username: Some("user".to_string()), 402 | uid: Some(123), 403 | gid: Some(456), 404 | excludes: vec![ 405 | ".cache".to_string(), 406 | "Cache".to_string(), 407 | "/home/user/Downloads".to_string(), 408 | ] 409 | .into_iter() 410 | .collect(), 411 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 412 | .into_iter() 413 | .collect(), 414 | original_id: Some("fefwfwew".to_string()), 415 | program_version: Some("restic 0.16.0".to_string()), 416 | }; 417 | 418 | let wat = Snapshot { 419 | id: "wat".to_string(), 420 | time: mk_datetime(2023, 5, 12, 17, 00, 00), 421 | parent: None, 422 | tree: "fwefwfwwefwefwe".to_string(), 423 | paths: HashSet::new(), 424 | hostname: None, 425 | username: None, 426 | uid: None, 427 | gid: None, 428 | excludes: HashSet::new(), 429 | tags: HashSet::new(), 430 | original_id: None, 431 | program_version: None, 432 | }; 433 | 434 | cache.save_snapshot(&foo, example_tree_0()).unwrap(); 435 | cache.save_snapshot(&bar, example_tree_1()).unwrap(); 436 | cache.save_snapshot(&wat, example_tree_2()).unwrap(); 437 | 438 | test_snapshots(&cache, vec![&foo, &bar, &wat]); 439 | 440 | fn test_entries(cache: &Cache, sizetree: SizeTree) { 441 | assert_get_entries_correct_at_path(cache, &sizetree, ""); 442 | assert_get_entries_correct_at_path(cache, &sizetree, "a"); 443 | assert_get_entries_correct_at_path(cache, &sizetree, "b"); 444 | assert_get_entries_correct_at_path(cache, &sizetree, "a/0"); 445 | assert_get_entries_correct_at_path(cache, &sizetree, "a/1"); 446 | assert_get_entries_correct_at_path(cache, &sizetree, "a/2"); 447 | assert_get_entries_correct_at_path(cache, &sizetree, "b/0"); 448 | assert_get_entries_correct_at_path(cache, &sizetree, "b/1"); 449 | assert_get_entries_correct_at_path(cache, &sizetree, "b/2"); 450 | assert_get_entries_correct_at_path(cache, &sizetree, "something"); 451 | assert_get_entries_correct_at_path(cache, &sizetree, "a/something"); 452 | } 453 | 454 | test_entries( 455 | &cache, 456 | example_tree_0().merge(example_tree_1()).merge(example_tree_2()), 457 | ); 458 | 459 | // Deleting a non-existent snapshot does nothing 460 | cache.delete_snapshot("non-existent").unwrap(); 461 | test_snapshots(&cache, vec![&foo, &bar, &wat]); 462 | test_entries( 463 | &cache, 464 | example_tree_0().merge(example_tree_1()).merge(example_tree_2()), 465 | ); 466 | 467 | // Remove bar 468 | cache.delete_snapshot("bar").unwrap(); 469 | test_snapshots(&cache, vec![&foo, &wat]); 470 | test_entries(&cache, example_tree_0().merge(example_tree_2())); 471 | } 472 | 473 | // TODO: Ideally we would run more than 10_000 but at the moment this is too slow. 474 | #[test] 475 | fn lots_of_snapshots() { 476 | let tempfile = Tempfile::new(); 477 | let mut cache = Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 478 | 479 | const NUM_SNAPSHOTS: usize = 10_000; 480 | 481 | // Insert lots of snapshots 482 | for i in 0..NUM_SNAPSHOTS { 483 | let snapshot = Snapshot { 484 | id: i.to_string(), 485 | time: timestamp_to_datetime(i as i64).unwrap(), 486 | parent: None, 487 | tree: i.to_string(), 488 | paths: HashSet::new(), 489 | hostname: None, 490 | username: None, 491 | uid: None, 492 | gid: None, 493 | excludes: HashSet::new(), 494 | tags: HashSet::new(), 495 | original_id: None, 496 | program_version: None, 497 | }; 498 | cache.save_snapshot(&snapshot, example_tree_0()).unwrap(); 499 | } 500 | 501 | // get_entries 502 | let tree = example_tree_0(); 503 | for path in ["", "a", "a/0", "a/1", "a/1/x", "a/something"] { 504 | assert_get_entries_correct_at_path(&cache, &tree, path); 505 | } 506 | 507 | // get_entry_details 508 | let path_id = cache.get_path_id_by_path("a/0".into()).unwrap().unwrap(); 509 | let details = cache.get_entry_details(path_id).unwrap().unwrap(); 510 | assert_eq!( 511 | details, 512 | EntryDetails { 513 | max_size: 4, 514 | max_size_snapshot_hash: (NUM_SNAPSHOTS - 1).to_string(), 515 | first_seen: timestamp_to_datetime(0).unwrap(), 516 | first_seen_snapshot_hash: 0.to_string(), 517 | last_seen: timestamp_to_datetime((NUM_SNAPSHOTS - 1) as i64) 518 | .unwrap(), 519 | last_seen_snapshot_hash: (NUM_SNAPSHOTS - 1).to_string(), 520 | } 521 | ); 522 | } 523 | 524 | ////////// Migrations ////////////////////////////////////////////////////////// 525 | fn assert_tables(conn: &Connection, tables: &[&str]) { 526 | let mut actual_tables: Vec = 527 | get_tables(conn).unwrap().into_iter().collect(); 528 | actual_tables.sort(); 529 | let mut expected_tables: Vec = 530 | tables.iter().map(ToString::to_string).collect(); 531 | expected_tables.sort(); 532 | assert_eq!(actual_tables, expected_tables); 533 | } 534 | 535 | fn assert_marks(cache: &Cache, marks: &[&str]) { 536 | let mut actual_marks = cache.get_marks().unwrap(); 537 | actual_marks.sort(); 538 | let mut expected_marks: Vec = 539 | marks.iter().map(Utf8PathBuf::from).collect(); 540 | expected_marks.sort(); 541 | assert_eq!(actual_marks, expected_marks); 542 | } 543 | 544 | fn populate_v0<'a>( 545 | marks: impl IntoIterator, 546 | ) -> Result { 547 | let file = Tempfile::new(); 548 | let mut cache = Migrator::open_with_target(&file.0, 0)?.migrate()?; 549 | let tx = cache.conn.transaction()?; 550 | { 551 | let mut marks_stmt = 552 | tx.prepare("INSERT INTO marks (path) VALUES (?)")?; 553 | for mark in marks { 554 | marks_stmt.execute([mark])?; 555 | } 556 | } 557 | tx.commit()?; 558 | Ok(file) 559 | } 560 | 561 | #[test] 562 | fn test_migrate_v0_to_v1() { 563 | let marks = ["/foo", "/bar/wat", "foo/a/b/c", "something"]; 564 | let file = populate_v0(marks).unwrap(); 565 | 566 | let cache = 567 | Migrator::open_with_target(&file.0, 1).unwrap().migrate().unwrap(); 568 | 569 | assert_tables( 570 | &cache.conn, 571 | &[ 572 | "metadata_integer", 573 | "paths", 574 | "snapshots", 575 | "snapshot_paths", 576 | "snapshot_excludes", 577 | "snapshot_tags", 578 | "marks", 579 | ], 580 | ); 581 | 582 | assert_marks(&cache, &marks); 583 | 584 | assert_eq!(determine_version(&cache.conn).unwrap(), Some(1)); 585 | 586 | cache_snapshots_entries(); 587 | } 588 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod restic; 3 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | io::{self, stderr}, 4 | sync::{ 5 | atomic::{AtomicBool, Ordering}, 6 | mpsc::{self, RecvTimeoutError}, 7 | Arc, Mutex, 8 | }, 9 | thread::{self, ScopedJoinHandle}, 10 | time::{Duration, Instant}, 11 | }; 12 | 13 | use anyhow::Context; 14 | use args::Args; 15 | use camino::{Utf8Path, Utf8PathBuf}; 16 | use chrono::Local; 17 | use crossterm::{ 18 | event::{KeyCode, KeyModifiers}, 19 | terminal::{ 20 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, 21 | LeaveAlternateScreen, 22 | }, 23 | ExecutableCommand, 24 | }; 25 | use directories::ProjectDirs; 26 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 27 | use log::{debug, error, info, trace, LevelFilter}; 28 | use rand::{seq::SliceRandom, thread_rng}; 29 | use ratatui::{ 30 | backend::{Backend, CrosstermBackend}, 31 | layout::Size, 32 | style::Stylize, 33 | widgets::WidgetRef, 34 | CompletedFrame, Terminal, 35 | }; 36 | use redu::{ 37 | cache::{self, filetree::SizeTree, Cache, Migrator}, 38 | restic::{self, escape_for_exclude, Restic, Snapshot}, 39 | }; 40 | use scopeguard::defer; 41 | use simplelog::{ThreadLogMode, WriteLogger}; 42 | use thiserror::Error; 43 | use util::snapshot_short_id; 44 | 45 | use crate::ui::{Action, App, Event}; 46 | 47 | mod args; 48 | mod ui; 49 | mod util; 50 | 51 | fn main() -> anyhow::Result<()> { 52 | let args = Args::parse(); 53 | let restic = Restic::new(args.repository, args.password, args.no_cache); 54 | 55 | let dirs = ProjectDirs::from("eu", "drdo", "redu") 56 | .expect("unable to determine project directory"); 57 | 58 | // Initialize the logger 59 | { 60 | fn generate_filename() -> String { 61 | format!("{}.log", Local::now().format("%Y-%m-%dT%H-%M-%S%.f%z")) 62 | } 63 | 64 | let mut path = dirs.data_local_dir().to_path_buf(); 65 | path.push(Utf8Path::new("logs")); 66 | fs::create_dir_all(&path)?; 67 | path.push(generate_filename()); 68 | let file = loop { 69 | // Spin until we hit a timestamp that isn't taken yet. 70 | // With the level of precision that we are using this should virtually 71 | // never run more than once. 72 | match fs::OpenOptions::new() 73 | .write(true) 74 | .create_new(true) 75 | .open(&path) 76 | { 77 | Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { 78 | path.set_file_name(generate_filename()) 79 | } 80 | x => break x, 81 | } 82 | }?; 83 | 84 | eprintln!("Logging to {:#?}", path); 85 | 86 | let config = simplelog::ConfigBuilder::new() 87 | .set_target_level(LevelFilter::Error) 88 | .set_thread_mode(ThreadLogMode::Names) 89 | .build(); 90 | WriteLogger::init(args.log_level, config, file)?; 91 | } 92 | 93 | unsafe { 94 | rusqlite::trace::config_log(Some(|code, msg| { 95 | error!(target: "sqlite", "({code}) {msg}"); 96 | }))?; 97 | } 98 | 99 | let mut cache = { 100 | // Get config to determine repo id and open cache 101 | let pb = new_pb(" {spinner} Getting restic config"); 102 | let repo_id = restic.config()?.id; 103 | pb.finish(); 104 | 105 | let cache_file = { 106 | let mut path = dirs.cache_dir().to_path_buf(); 107 | path.push(format!("{repo_id}.db")); 108 | path 109 | }; 110 | 111 | let err_msg = format!( 112 | "unable to create cache directory at {}", 113 | dirs.cache_dir().to_string_lossy(), 114 | ); 115 | fs::create_dir_all(dirs.cache_dir()).expect(&err_msg); 116 | 117 | eprintln!("Using cache file {cache_file:#?}"); 118 | let migrator = 119 | Migrator::open(&cache_file).context("unable to open cache file")?; 120 | if let Some((old, new)) = migrator.need_to_migrate() { 121 | eprintln!("Need to upgrade cache version from {old:?} to {new:?}"); 122 | let mut template = 123 | String::from(" {spinner} Upgrading cache version"); 124 | if migrator.resync_necessary() { 125 | template.push_str(" (a resync will be necessary)"); 126 | } 127 | let pb = new_pb(&template); 128 | let cache = migrator.migrate().context("cache migration failed")?; 129 | pb.finish(); 130 | cache 131 | } else { 132 | migrator.migrate().context("there is a problem with the cache")? 133 | } 134 | }; 135 | 136 | sync_snapshots(&restic, &mut cache, args.parallelism)?; 137 | 138 | let paths = ui(cache)?; 139 | for line in paths { 140 | println!("{}", escape_for_exclude(line.as_str())); 141 | } 142 | 143 | Ok(()) 144 | } 145 | 146 | fn sync_snapshots( 147 | restic: &Restic, 148 | cache: &mut Cache, 149 | fetching_thread_count: usize, 150 | ) -> anyhow::Result<()> { 151 | let pb = new_pb(" {spinner} Fetching repository snapshot list"); 152 | let repo_snapshots = restic.snapshots()?; 153 | pb.finish(); 154 | 155 | let cache_snapshots = cache.get_snapshots()?; 156 | 157 | // Delete snapshots from the DB that were deleted on the repo 158 | let snapshots_to_delete: Vec<&Snapshot> = cache_snapshots 159 | .iter() 160 | .filter(|cache_snapshot| { 161 | !repo_snapshots 162 | .iter() 163 | .any(|repo_snapshot| cache_snapshot.id == repo_snapshot.id) 164 | }) 165 | .collect(); 166 | if !snapshots_to_delete.is_empty() { 167 | eprintln!("Need to delete {} snapshot(s)", snapshots_to_delete.len()); 168 | let pb = new_pb(" {spinner} {wide_bar} [{pos}/{len}]"); 169 | pb.set_length(snapshots_to_delete.len() as u64); 170 | for snapshot in snapshots_to_delete { 171 | cache.delete_snapshot(&snapshot.id)?; 172 | pb.inc(1); 173 | } 174 | pb.finish(); 175 | } 176 | 177 | let mut missing_snapshots: Vec = repo_snapshots 178 | .into_iter() 179 | .filter(|repo_snapshot| { 180 | !cache_snapshots 181 | .iter() 182 | .any(|cache_snapshot| cache_snapshot.id == repo_snapshot.id) 183 | }) 184 | .collect(); 185 | missing_snapshots.shuffle(&mut thread_rng()); 186 | let total_missing_snapshots = match missing_snapshots.len() { 187 | 0 => { 188 | eprintln!("Snapshots up to date"); 189 | return Ok(()); 190 | } 191 | n => n, 192 | }; 193 | let missing_queue = FixedSizeQueue::new(missing_snapshots); 194 | 195 | // Create progress indicators 196 | let mpb = MultiProgress::new(); 197 | let pb = 198 | mpb_insert_end(&mpb, " {spinner} {prefix} {wide_bar} [{pos}/{len}] "); 199 | pb.set_prefix("Fetching snapshots"); 200 | pb.set_length(total_missing_snapshots as u64); 201 | 202 | const SHOULD_QUIT_POLL_PERIOD: Duration = Duration::from_millis(500); 203 | 204 | thread::scope(|scope| { 205 | macro_rules! spawn { 206 | ($name_fmt:literal, $scope:expr, $thunk:expr) => { 207 | thread::Builder::new() 208 | .name(format!($name_fmt)) 209 | .spawn_scoped($scope, $thunk)? 210 | }; 211 | } 212 | let mut handles: Vec>> = Vec::new(); 213 | 214 | // The threads periodically poll this to see if they should 215 | // prematurely terminate (when other threads get unrecoverable errors). 216 | let should_quit: Arc = Arc::new(AtomicBool::new(false)); 217 | 218 | // Channel to funnel snapshots from the fetching threads to the db thread 219 | let (snapshot_sender, snapshot_receiver) = 220 | mpsc::sync_channel::<(Snapshot, SizeTree)>(fetching_thread_count); 221 | 222 | // Start fetching threads 223 | for i in 0..fetching_thread_count { 224 | let missing_queue = missing_queue.clone(); 225 | let snapshot_sender = snapshot_sender.clone(); 226 | let mpb = mpb.clone(); 227 | let should_quit = should_quit.clone(); 228 | handles.push(spawn!("fetching-{i}", &scope, move || { 229 | fetching_thread_body( 230 | restic, 231 | missing_queue, 232 | mpb, 233 | snapshot_sender, 234 | should_quit.clone(), 235 | ) 236 | .inspect_err(|_| should_quit.store(true, Ordering::SeqCst)) 237 | .map_err(anyhow::Error::from) 238 | })); 239 | } 240 | // Drop the leftover channel so that the db thread 241 | // can properly terminate when all snapshot senders are closed 242 | drop(snapshot_sender); 243 | 244 | // Start DB thread 245 | handles.push({ 246 | let should_quit = should_quit.clone(); 247 | spawn!("db", &scope, move || { 248 | db_thread_body( 249 | cache, 250 | mpb, 251 | pb, 252 | snapshot_receiver, 253 | should_quit.clone(), 254 | SHOULD_QUIT_POLL_PERIOD, 255 | ) 256 | .inspect_err(|_| should_quit.store(true, Ordering::SeqCst)) 257 | .map_err(anyhow::Error::from) 258 | }) 259 | }); 260 | 261 | for handle in handles { 262 | handle.join().unwrap()? 263 | } 264 | Ok(()) 265 | }) 266 | } 267 | 268 | #[derive(Debug, Error)] 269 | #[error("error in fetching thread")] 270 | enum FetchingThreadError { 271 | ResticLaunch(#[from] restic::LaunchError), 272 | Restic(#[from] restic::Error), 273 | Cache(#[from] rusqlite::Error), 274 | } 275 | 276 | fn fetching_thread_body( 277 | restic: &Restic, 278 | missing_queue: FixedSizeQueue, 279 | mpb: MultiProgress, 280 | snapshot_sender: mpsc::SyncSender<(Snapshot, SizeTree)>, 281 | should_quit: Arc, 282 | ) -> Result<(), FetchingThreadError> { 283 | defer! { trace!("terminated") } 284 | trace!("started"); 285 | while let Some(snapshot) = missing_queue.pop() { 286 | let short_id = snapshot_short_id(&snapshot.id); 287 | let pb = 288 | mpb_insert_end(&mpb, " {spinner} fetching {prefix}: starting up") 289 | .with_prefix(short_id.clone()); 290 | let mut sizetree = SizeTree::new(); 291 | let files = restic.ls(&snapshot.id)?; 292 | trace!("started fetching snapshot ({short_id})"); 293 | let start = Instant::now(); 294 | for r in files { 295 | if should_quit.load(Ordering::SeqCst) { 296 | return Ok(()); 297 | } 298 | let file = r?; 299 | sizetree 300 | .insert(file.path.components(), file.size) 301 | .expect("repeated entry in restic snapshot ls"); 302 | if pb.position() == 0 { 303 | pb.set_style(new_style( 304 | " {spinner} fetching {prefix}: {pos} file(s)", 305 | )); 306 | } 307 | pb.inc(1); 308 | } 309 | pb.finish_and_clear(); 310 | mpb.remove(&pb); 311 | info!( 312 | "snapshot fetched in {}s ({short_id})", 313 | start.elapsed().as_secs_f64() 314 | ); 315 | if should_quit.load(Ordering::SeqCst) { 316 | return Ok(()); 317 | } 318 | let start = Instant::now(); 319 | snapshot_sender.send((snapshot.clone(), sizetree)).unwrap(); 320 | debug!( 321 | "waited {}s to send snapshot ({short_id})", 322 | start.elapsed().as_secs_f64() 323 | ); 324 | } 325 | Ok(()) 326 | } 327 | 328 | #[derive(Debug, Error)] 329 | #[error("error in db thread")] 330 | enum DBThreadError { 331 | CacheError(#[from] rusqlite::Error), 332 | } 333 | 334 | fn db_thread_body( 335 | cache: &mut Cache, 336 | mpb: MultiProgress, 337 | main_pb: ProgressBar, 338 | snapshot_receiver: mpsc::Receiver<(Snapshot, SizeTree)>, 339 | should_quit: Arc, 340 | should_quit_poll_period: Duration, 341 | ) -> Result<(), DBThreadError> { 342 | defer! { trace!("terminated") } 343 | trace!("started"); 344 | loop { 345 | trace!("waiting for snapshot"); 346 | if should_quit.load(Ordering::SeqCst) { 347 | return Ok(()); 348 | } 349 | let start = Instant::now(); 350 | // We wait with timeout to poll the should_quit periodically 351 | match snapshot_receiver.recv_timeout(should_quit_poll_period) { 352 | Ok((snapshot, sizetree)) => { 353 | debug!( 354 | "waited {}s to get snapshot", 355 | start.elapsed().as_secs_f64() 356 | ); 357 | trace!("got snapshot, saving"); 358 | if should_quit.load(Ordering::SeqCst) { 359 | return Ok(()); 360 | } 361 | let short_id = snapshot_short_id(&snapshot.id); 362 | let pb = mpb_insert_after( 363 | &mpb, 364 | &main_pb, 365 | " {spinner} saving {prefix}", 366 | ) 367 | .with_prefix(short_id.clone()); 368 | let start = Instant::now(); 369 | let file_count = cache.save_snapshot(&snapshot, sizetree)?; 370 | pb.finish_and_clear(); 371 | mpb.remove(&pb); 372 | main_pb.inc(1); 373 | info!( 374 | "waited {}s to save snapshot ({} files)", 375 | start.elapsed().as_secs_f64(), 376 | file_count 377 | ); 378 | trace!("snapshot saved"); 379 | } 380 | Err(RecvTimeoutError::Timeout) => continue, 381 | Err(RecvTimeoutError::Disconnected) => { 382 | trace!("loop done"); 383 | break Ok(()); 384 | } 385 | } 386 | } 387 | } 388 | 389 | fn convert_event(event: crossterm::event::Event) -> Option { 390 | use crossterm::event::{Event as TermEvent, KeyEventKind}; 391 | use ui::Event::*; 392 | 393 | const KEYBINDINGS: &[((KeyModifiers, KeyCode), Event)] = &[ 394 | ((KeyModifiers::empty(), KeyCode::Left), Left), 395 | ((KeyModifiers::empty(), KeyCode::Char('h')), Left), 396 | ((KeyModifiers::empty(), KeyCode::Right), Right), 397 | ((KeyModifiers::empty(), KeyCode::Char('l')), Right), 398 | ((KeyModifiers::empty(), KeyCode::Up), Up), 399 | ((KeyModifiers::empty(), KeyCode::Char('k')), Up), 400 | ((KeyModifiers::empty(), KeyCode::Down), Down), 401 | ((KeyModifiers::empty(), KeyCode::Char('j')), Down), 402 | ((KeyModifiers::empty(), KeyCode::PageUp), PageUp), 403 | ((KeyModifiers::CONTROL, KeyCode::Char('b')), PageUp), 404 | ((KeyModifiers::empty(), KeyCode::PageDown), PageDown), 405 | ((KeyModifiers::CONTROL, KeyCode::Char('f')), PageDown), 406 | ((KeyModifiers::empty(), KeyCode::Enter), Enter), 407 | ((KeyModifiers::empty(), KeyCode::Esc), Exit), 408 | ((KeyModifiers::empty(), KeyCode::Char('m')), Mark), 409 | ((KeyModifiers::empty(), KeyCode::Char('u')), Unmark), 410 | ((KeyModifiers::empty(), KeyCode::Char('c')), UnmarkAll), 411 | ((KeyModifiers::empty(), KeyCode::Char('q')), Quit), 412 | ((KeyModifiers::empty(), KeyCode::Char('g')), Generate), 413 | ]; 414 | match event { 415 | TermEvent::Resize(w, h) => Some(Resize(Size::new(w, h))), 416 | TermEvent::Key(event) if event.kind == KeyEventKind::Press => { 417 | KEYBINDINGS.iter().find_map(|((mods, code), ui_event)| { 418 | if event.modifiers == *mods && event.code == *code { 419 | Some(ui_event.clone()) 420 | } else { 421 | None 422 | } 423 | }) 424 | } 425 | _ => None, 426 | } 427 | } 428 | 429 | fn ui(mut cache: Cache) -> anyhow::Result> { 430 | let entries = cache.get_entries(None)?; 431 | if entries.is_empty() { 432 | eprintln!("The repository is empty!"); 433 | return Ok(vec![]); 434 | } 435 | 436 | stderr().execute(EnterAlternateScreen)?; 437 | defer! { 438 | stderr().execute(LeaveAlternateScreen).unwrap(); 439 | } 440 | enable_raw_mode()?; 441 | defer! { 442 | disable_raw_mode().unwrap(); 443 | } 444 | let mut terminal = Terminal::new(CrosstermBackend::new(stderr()))?; 445 | terminal.clear()?; 446 | 447 | let mut app = { 448 | let rect = terminal.size()?; 449 | App::new( 450 | rect, 451 | None, 452 | Utf8PathBuf::new(), 453 | entries, 454 | cache.get_marks()?, 455 | vec![ 456 | "Enter".bold(), 457 | ":Details ".into(), 458 | "m".bold(), 459 | ":Mark ".into(), 460 | "u".bold(), 461 | ":Unmark ".into(), 462 | "c".bold(), 463 | ":ClearAllMarks ".into(), 464 | "g".bold(), 465 | ":Generate ".into(), 466 | "q".bold(), 467 | ":Quit".into(), 468 | ], 469 | ) 470 | }; 471 | 472 | render(&mut terminal, &app)?; 473 | loop { 474 | let mut o_event = convert_event(crossterm::event::read()?); 475 | while let Some(event) = o_event { 476 | o_event = match app.update(event) { 477 | Action::Nothing => None, 478 | Action::Render => { 479 | render(&mut terminal, &app)?; 480 | None 481 | } 482 | Action::Quit => return Ok(vec![]), 483 | Action::Generate(paths) => return Ok(paths), 484 | Action::GetParentEntries(path_id) => { 485 | let parent_id = cache.get_parent_id(path_id)? 486 | .expect("The UI requested a GetParentEntries with a path_id that does not exist"); 487 | let entries = cache.get_entries(parent_id)?; 488 | Some(Event::Entries { path_id: parent_id, entries }) 489 | } 490 | Action::GetEntries(path_id) => { 491 | let entries = cache.get_entries(path_id)?; 492 | Some(Event::Entries { path_id, entries }) 493 | } 494 | Action::GetEntryDetails(path_id) => 495 | Some(Event::EntryDetails(cache.get_entry_details(path_id)? 496 | .expect("The UI requested a GetEntryDetails with a path_id that does not exist"))), 497 | Action::UpsertMark(path) => { 498 | cache.upsert_mark(&path)?; 499 | Some(Event::Marks(cache.get_marks()?)) 500 | } 501 | Action::DeleteMark(loc) => { 502 | cache.delete_mark(&loc).unwrap(); 503 | Some(Event::Marks(cache.get_marks()?)) 504 | } 505 | Action::DeleteAllMarks => { 506 | cache.delete_all_marks()?; 507 | Some(Event::Marks(Vec::new())) 508 | } 509 | } 510 | } 511 | } 512 | } 513 | 514 | fn render<'a>( 515 | terminal: &'a mut Terminal, 516 | app: &App, 517 | ) -> io::Result> { 518 | terminal.draw(|frame| { 519 | let area = frame.area(); 520 | let buf = frame.buffer_mut(); 521 | app.render_ref(area, buf) 522 | }) 523 | } 524 | 525 | /// Util /////////////////////////////////////////////////////////////////////// 526 | fn new_style(template: &str) -> ProgressStyle { 527 | let frames = &[ 528 | "(● )", 529 | "( ● )", 530 | "( ● )", 531 | "( ● )", 532 | "( ● )", 533 | "( ● )", 534 | "( ● )", 535 | "(● )", 536 | "(● )", 537 | ]; 538 | ProgressStyle::with_template(template).unwrap().tick_strings(frames) 539 | } 540 | 541 | const PB_TICK_INTERVAL: Duration = Duration::from_millis(100); 542 | 543 | fn new_pb(template: &str) -> ProgressBar { 544 | let pb = ProgressBar::new_spinner().with_style(new_style(template)); 545 | pb.enable_steady_tick(PB_TICK_INTERVAL); 546 | pb 547 | } 548 | 549 | // This is necessary to avoid some weird redraws that happen 550 | // when enabling the tick thread before adding to the MultiProgress. 551 | fn mpb_insert_after( 552 | mpb: &MultiProgress, 553 | other_pb: &ProgressBar, 554 | template: &str, 555 | ) -> ProgressBar { 556 | let pb = ProgressBar::new_spinner().with_style(new_style(template)); 557 | let pb = mpb.insert_after(other_pb, pb); 558 | pb.enable_steady_tick(PB_TICK_INTERVAL); 559 | pb 560 | } 561 | 562 | fn mpb_insert_end(mpb: &MultiProgress, template: &str) -> ProgressBar { 563 | let pb = ProgressBar::new_spinner().with_style(new_style(template)); 564 | let pb = mpb.add(pb); 565 | pb.enable_steady_tick(PB_TICK_INTERVAL); 566 | pb 567 | } 568 | 569 | #[derive(Clone)] 570 | struct FixedSizeQueue(Arc>>); 571 | 572 | impl FixedSizeQueue { 573 | fn new(data: Vec) -> Self { 574 | FixedSizeQueue(Arc::new(Mutex::new(data))) 575 | } 576 | 577 | fn pop(&self) -> Option { 578 | self.0.lock().unwrap().pop() 579 | } 580 | } 581 | -------------------------------------------------------------------------------- /src/restic.rs: -------------------------------------------------------------------------------- 1 | use core::str; 2 | #[cfg(not(target_os = "windows"))] 3 | use std::os::unix::process::CommandExt; 4 | use std::{ 5 | borrow::Cow, 6 | collections::HashSet, 7 | ffi::OsStr, 8 | fmt::{self, Display, Formatter}, 9 | io::{self, BufRead, BufReader, Lines, Read, Write}, 10 | marker::PhantomData, 11 | mem, 12 | process::{Child, ChildStdout, Command, Stdio}, 13 | str::Utf8Error, 14 | }; 15 | 16 | use camino::Utf8PathBuf; 17 | use chrono::{DateTime, Utc}; 18 | use log::info; 19 | use scopeguard::defer; 20 | use serde::{de::DeserializeOwned, Deserialize}; 21 | use serde_json::Value; 22 | use thiserror::Error; 23 | 24 | #[derive(Debug, Error)] 25 | #[error("error launching restic process")] 26 | pub struct LaunchError(#[source] pub io::Error); 27 | 28 | #[derive(Debug, Error)] 29 | pub enum RunError { 30 | #[error("error doing IO")] 31 | Io(#[from] io::Error), 32 | #[error("error reading input as UTF-8")] 33 | Utf8(#[from] Utf8Error), 34 | #[error("error parsing JSON")] 35 | Parse(#[from] serde_json::Error), 36 | #[error("the restic process exited with error code {}", if let Some(code) = .0 { code.to_string() } else { "None".to_string() } )] 37 | Exit(Option), 38 | } 39 | 40 | #[derive(Debug, Error)] 41 | pub enum ErrorKind { 42 | #[error("error launching restic process")] 43 | Launch(#[from] LaunchError), 44 | #[error("error while running restic process")] 45 | Run(#[from] RunError), 46 | } 47 | 48 | impl From for ErrorKind { 49 | fn from(value: io::Error) -> Self { 50 | ErrorKind::Run(RunError::Io(value)) 51 | } 52 | } 53 | 54 | impl From for ErrorKind { 55 | fn from(value: Utf8Error) -> Self { 56 | ErrorKind::Run(RunError::Utf8(value)) 57 | } 58 | } 59 | 60 | impl From for ErrorKind { 61 | fn from(value: serde_json::Error) -> Self { 62 | ErrorKind::Run(RunError::Parse(value)) 63 | } 64 | } 65 | 66 | #[derive(Debug, Error)] 67 | pub struct Error { 68 | #[source] 69 | pub kind: ErrorKind, 70 | pub stderr: Option, 71 | } 72 | 73 | impl Display for Error { 74 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 75 | match &self.stderr { 76 | Some(s) => write!(f, "restic error, stderr dump:\n{}", s), 77 | None => write!(f, "restic error"), 78 | } 79 | } 80 | } 81 | 82 | impl From for Error { 83 | fn from(value: LaunchError) -> Self { 84 | Error { kind: ErrorKind::Launch(value), stderr: None } 85 | } 86 | } 87 | 88 | #[derive(Debug, Deserialize)] 89 | pub struct Config { 90 | pub id: String, 91 | } 92 | 93 | pub struct Restic { 94 | repository: Repository, 95 | password: Password, 96 | no_cache: bool, 97 | } 98 | 99 | #[derive(Debug)] 100 | pub enum Repository { 101 | /// A repository string (restic: --repo) 102 | Repo(String), 103 | /// A repository file (restic: --repository-file) 104 | File(String), 105 | } 106 | 107 | #[derive(Debug)] 108 | pub enum Password { 109 | /// A plain string (restic: RESTIC_PASSWORD env variable) 110 | Plain(String), 111 | /// A password command (restic: --password-command) 112 | Command(String), 113 | /// A password file (restic: --password-file) 114 | File(String), 115 | } 116 | 117 | impl Restic { 118 | pub fn new( 119 | repository: Repository, 120 | password: Password, 121 | no_cache: bool, 122 | ) -> Self { 123 | Restic { repository, password, no_cache } 124 | } 125 | 126 | pub fn config(&self) -> Result { 127 | self.run_greedy_command(["cat", "config"]) 128 | } 129 | 130 | pub fn snapshots(&self) -> Result, Error> { 131 | self.run_greedy_command(["snapshots"]) 132 | } 133 | 134 | pub fn ls( 135 | &self, 136 | snapshot: &str, 137 | ) -> Result> + 'static, LaunchError> 138 | { 139 | fn parse_file(mut v: Value) -> Option { 140 | let mut m = mem::take(v.as_object_mut()?); 141 | Some(File { 142 | path: Utf8PathBuf::from(m.remove("path")?.as_str()?), 143 | size: m.remove("size")?.as_u64()? as usize, 144 | }) 145 | } 146 | 147 | Ok(self 148 | .run_lazy_command(["ls", snapshot])? 149 | .filter_map(|r| r.map(parse_file).transpose())) 150 | } 151 | 152 | // This is a trait object because of 153 | // https://github.com/rust-lang/rust/issues/125075 154 | fn run_lazy_command( 155 | &self, 156 | args: impl IntoIterator, 157 | ) -> Result> + 'static>, LaunchError> 158 | where 159 | T: DeserializeOwned + 'static, 160 | A: AsRef, 161 | { 162 | let child = self.run_command(args)?; 163 | Ok(Box::new(Iter::new(child))) 164 | } 165 | 166 | fn run_greedy_command( 167 | &self, 168 | args: impl IntoIterator, 169 | ) -> Result 170 | where 171 | T: DeserializeOwned, 172 | A: AsRef, 173 | { 174 | let child = self.run_command(args)?; 175 | let id = child.id(); 176 | defer! { info!("finished pid {}", id); } 177 | let output = child.wait_with_output().map_err(|e| Error { 178 | kind: ErrorKind::Run(RunError::Io(e)), 179 | stderr: None, 180 | })?; 181 | let r_value: Result = if output.status.success() { 182 | match str::from_utf8(&output.stdout) { 183 | Ok(s) => serde_json::from_str(s).map_err(|e| e.into()), 184 | Err(e) => Err(e.into()), 185 | } 186 | } else { 187 | Err(ErrorKind::Run(RunError::Exit(output.status.code()))) 188 | }; 189 | match r_value { 190 | Err(kind) => Err(Error { 191 | kind, 192 | stderr: Some( 193 | String::from_utf8_lossy(&output.stderr).into_owned(), 194 | ), 195 | }), 196 | Ok(value) => Ok(value), 197 | } 198 | } 199 | 200 | fn run_command>( 201 | &self, 202 | args: impl IntoIterator, 203 | ) -> Result { 204 | let mut cmd = Command::new("restic"); 205 | // Need to detach process from terminal 206 | #[cfg(not(target_os = "windows"))] 207 | unsafe { 208 | cmd.pre_exec(|| { 209 | nix::unistd::setsid()?; 210 | Ok(()) 211 | }); 212 | } 213 | match &self.repository { 214 | Repository::Repo(repo) => cmd.arg("--repo").arg(repo), 215 | Repository::File(file) => cmd.arg("--repository-file").arg(file), 216 | }; 217 | match &self.password { 218 | Password::Command(command) => { 219 | cmd.arg("--password-command").arg(command); 220 | cmd.stdin(Stdio::null()); 221 | } 222 | Password::File(file) => { 223 | cmd.arg("--password-file").arg(file); 224 | cmd.stdin(Stdio::null()); 225 | } 226 | Password::Plain(_) => { 227 | // passed via stdin after the process is started 228 | cmd.stdin(Stdio::piped()); 229 | } 230 | }; 231 | if self.no_cache { 232 | cmd.arg("--no-cache"); 233 | } 234 | cmd.arg("--json"); 235 | // pass --quiet to remove informational messages in stdout mixed up with the JSON we want 236 | // (https://github.com/restic/restic/issues/5236) 237 | cmd.arg("--quiet"); 238 | cmd.args(args); 239 | let mut child = cmd 240 | .stdout(Stdio::piped()) 241 | .stderr(Stdio::piped()) 242 | .spawn() 243 | .map_err(LaunchError)?; 244 | info!("running \"{cmd:?}\" (pid {})", child.id()); 245 | if let Password::Plain(ref password) = self.password { 246 | let mut stdin = child 247 | .stdin 248 | .take() 249 | .expect("child has no stdin when it should have"); 250 | stdin.write_all(password.as_bytes()).map_err(LaunchError)?; 251 | stdin.write_all(b"\n").map_err(LaunchError)?; 252 | } 253 | Ok(child) 254 | } 255 | } 256 | 257 | struct Iter { 258 | child: Child, 259 | lines: Lines>, 260 | finished: bool, 261 | _phantom_data: PhantomData, 262 | } 263 | 264 | impl Iter { 265 | fn new(mut child: Child) -> Self { 266 | let stdout = child.stdout.take().unwrap(); 267 | Iter { 268 | child, 269 | lines: BufReader::new(stdout).lines(), 270 | finished: false, 271 | _phantom_data: PhantomData, 272 | } 273 | } 274 | 275 | fn read_stderr(&mut self, kind: ErrorKind) -> Result { 276 | let mut buf = String::new(); 277 | // read_to_string would block forever if the child was still running. 278 | let _ = self.child.kill(); 279 | match self.child.stderr.take().unwrap().read_to_string(&mut buf) { 280 | Err(e) => Err(Error { 281 | kind: ErrorKind::Run(RunError::Io(e)), 282 | stderr: None, 283 | }), 284 | Ok(_) => Err(Error { kind, stderr: Some(buf) }), 285 | } 286 | } 287 | 288 | fn finish(&mut self) { 289 | if !self.finished { 290 | info!("finished pid {}", self.child.id()); 291 | } 292 | } 293 | } 294 | 295 | impl Iterator for Iter { 296 | type Item = Result; 297 | 298 | fn next(&mut self) -> Option { 299 | if let Some(r_line) = self.lines.next() { 300 | let r_value: Result = 301 | r_line.map_err(|e| e.into()).and_then(|line| { 302 | serde_json::from_str(&line).map_err(|e| e.into()) 303 | }); 304 | Some(match r_value { 305 | Err(kind) => { 306 | self.finish(); 307 | self.read_stderr(kind) 308 | } 309 | Ok(value) => Ok(value), 310 | }) 311 | } else { 312 | self.finish(); 313 | match self.child.wait() { 314 | Err(e) => { 315 | Some(self.read_stderr(ErrorKind::Run(RunError::Io(e)))) 316 | } 317 | Ok(status) => { 318 | if status.success() { 319 | None 320 | } else { 321 | Some(self.read_stderr(ErrorKind::Run(RunError::Exit( 322 | status.code(), 323 | )))) 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | 331 | #[derive(Clone, Debug, Deserialize)] 332 | pub struct Snapshot { 333 | pub id: String, 334 | pub time: DateTime, 335 | #[serde(default)] 336 | pub parent: Option, 337 | pub tree: String, 338 | pub paths: HashSet, 339 | #[serde(default)] 340 | pub hostname: Option, 341 | #[serde(default)] 342 | pub username: Option, 343 | #[serde(default)] 344 | pub uid: Option, 345 | #[serde(default)] 346 | pub gid: Option, 347 | #[serde(default)] 348 | pub excludes: HashSet, 349 | #[serde(default)] 350 | pub tags: HashSet, 351 | #[serde(default)] 352 | pub original_id: Option, 353 | #[serde(default)] 354 | pub program_version: Option, 355 | } 356 | 357 | #[derive(Clone, Debug, Eq, PartialEq)] 358 | pub struct File { 359 | pub path: Utf8PathBuf, 360 | pub size: usize, 361 | } 362 | 363 | pub fn escape_for_exclude(path: &str) -> Cow { 364 | fn is_special(c: char) -> bool { 365 | ['*', '?', '[', '\\', '\r', '\n'].contains(&c) 366 | } 367 | 368 | fn char_backward(c: char) -> char { 369 | char::from_u32( 370 | (c as u32).checked_sub(1).expect( 371 | "char_backward: underflow when computing previous char", 372 | ), 373 | ) 374 | .expect("char_backward: invalid resulting character") 375 | } 376 | 377 | fn char_forward(c: char) -> char { 378 | char::from_u32( 379 | (c as u32) 380 | .checked_add(1) 381 | .expect("char_backward: overflow when computing next char"), 382 | ) 383 | .expect("char_forward: invalid resulting character") 384 | } 385 | 386 | fn push_as_inverse_range(buf: &mut String, c: char) { 387 | #[rustfmt::skip] 388 | let cs = [ 389 | '[', '^', 390 | char::MIN, '-', char_backward(c), 391 | char_forward(c), '-', char::MAX, 392 | ']', 393 | ]; 394 | for d in cs { 395 | buf.push(d); 396 | } 397 | } 398 | 399 | match path.find(is_special) { 400 | None => Cow::Borrowed(path), 401 | Some(index) => { 402 | let (left, right) = path.split_at(index); 403 | let mut escaped = String::with_capacity(path.len() + 1); // the +1 is for the extra \ 404 | escaped.push_str(left); 405 | for c in right.chars() { 406 | match c { 407 | '*' | '?' | '[' => { 408 | escaped.push('['); 409 | escaped.push(c); 410 | escaped.push(']'); 411 | } 412 | '\\' => { 413 | #[cfg(target_os = "windows")] 414 | escaped.push('\\'); 415 | #[cfg(not(target_os = "windows"))] 416 | escaped.push_str("\\\\"); 417 | } 418 | '\r' | '\n' => push_as_inverse_range(&mut escaped, c), 419 | c => escaped.push(c), 420 | } 421 | } 422 | Cow::Owned(escaped) 423 | } 424 | } 425 | } 426 | 427 | #[cfg(test)] 428 | mod test { 429 | use super::escape_for_exclude; 430 | 431 | #[cfg(not(target_os = "windows"))] 432 | #[test] 433 | fn escape_for_exclude_test() { 434 | assert_eq!( 435 | escape_for_exclude("foo* bar?[somethin\\g]]]\r\n"), 436 | "foo[*] bar[?][[]somethin\\\\g]]][^\0-\u{000C}\u{000E}-\u{10FFFF}][^\0-\u{0009}\u{000B}-\u{10FFFF}]" 437 | ); 438 | } 439 | 440 | #[cfg(target_os = "windows")] 441 | #[test] 442 | fn escape_for_exclude_test() { 443 | assert_eq!( 444 | escape_for_exclude("foo* bar?[somethin\\g]]]\r\n"), 445 | "foo[*] bar[?][[]somethin\\g]]][^\0-\u{000C}\u{000E}-\u{10FFFF}][^\0-\u{0009}\u{000B}-\u{10FFFF}]" 446 | ); 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | cmp::{max, min}, 4 | collections::HashSet, 5 | iter, 6 | }; 7 | 8 | use camino::Utf8PathBuf; 9 | use ratatui::{ 10 | buffer::Buffer, 11 | layout::{Constraint, Direction, Layout, Position, Rect, Size}, 12 | prelude::Line, 13 | style::{Style, Stylize}, 14 | text::Span, 15 | widgets::{ 16 | Block, BorderType, Clear, Padding, Paragraph, Row, Table, Widget, 17 | WidgetRef, Wrap, 18 | }, 19 | }; 20 | use redu::cache::EntryDetails; 21 | use unicode_segmentation::UnicodeSegmentation; 22 | 23 | use crate::{ 24 | cache::{Entry, PathId}, 25 | util::snapshot_short_id, 26 | }; 27 | 28 | #[derive(Clone, Debug)] 29 | pub enum Event { 30 | Resize(Size), 31 | Left, 32 | Right, 33 | Up, 34 | Down, 35 | PageUp, 36 | PageDown, 37 | Enter, 38 | Exit, 39 | Mark, 40 | Unmark, 41 | UnmarkAll, 42 | Quit, 43 | Generate, 44 | Entries { 45 | /// `entries` is expected to be sorted by size, largest first. 46 | path_id: Option, 47 | entries: Vec, 48 | }, 49 | EntryDetails(EntryDetails), 50 | Marks(Vec), 51 | } 52 | 53 | #[derive(Debug)] 54 | pub enum Action { 55 | Nothing, 56 | Render, 57 | Quit, 58 | Generate(Vec), 59 | GetParentEntries(PathId), 60 | GetEntries(Option), 61 | GetEntryDetails(PathId), 62 | UpsertMark(Utf8PathBuf), 63 | DeleteMark(Utf8PathBuf), 64 | DeleteAllMarks, 65 | } 66 | 67 | pub struct App { 68 | path_id: Option, 69 | path: Utf8PathBuf, 70 | entries: Vec, 71 | marks: HashSet, 72 | list_size: Size, 73 | selected: usize, 74 | offset: usize, 75 | footer_extra: Vec>, 76 | details_drawer: Option, 77 | confirm_dialog: Option, 78 | } 79 | 80 | impl App { 81 | /// `entries` is expected to be sorted by size, largest first. 82 | pub fn new( 83 | screen: Size, 84 | path_id: Option, 85 | path: Utf8PathBuf, 86 | entries: Vec, 87 | marks: Vec, 88 | footer_extra: Vec>, 89 | ) -> Self { 90 | let list_size = compute_list_size(screen); 91 | App { 92 | path_id, 93 | path, 94 | entries, 95 | marks: HashSet::from_iter(marks), 96 | list_size, 97 | selected: 0, 98 | offset: 0, 99 | footer_extra, 100 | details_drawer: None, 101 | confirm_dialog: None, 102 | } 103 | } 104 | 105 | pub fn update(&mut self, event: Event) -> Action { 106 | log::debug!("received {:?}", event); 107 | use Event::*; 108 | match event { 109 | Resize(new_size) => self.resize(new_size), 110 | Left => { 111 | if let Some(ref mut confirm_dialog) = self.confirm_dialog { 112 | confirm_dialog.yes_selected = false; 113 | Action::Render 114 | } else { 115 | self.left() 116 | } 117 | } 118 | Right => { 119 | if let Some(ref mut confirm_dialog) = self.confirm_dialog { 120 | confirm_dialog.yes_selected = true; 121 | Action::Render 122 | } else { 123 | self.right() 124 | } 125 | } 126 | Up => self.move_selection(-1, true), 127 | Down => self.move_selection(1, true), 128 | PageUp => { 129 | self.move_selection(-(self.list_size.height as isize), false) 130 | } 131 | PageDown => { 132 | self.move_selection(self.list_size.height as isize, false) 133 | } 134 | Enter => { 135 | if let Some(confirm_dialog) = self.confirm_dialog.take() { 136 | if confirm_dialog.yes_selected { 137 | confirm_dialog.action 138 | } else { 139 | Action::Render 140 | } 141 | } else if self.confirm_dialog.is_none() { 142 | Action::GetEntryDetails(self.entries[self.selected].path_id) 143 | } else { 144 | Action::Nothing 145 | } 146 | } 147 | Exit => { 148 | if self.confirm_dialog.take().is_some() 149 | || self.details_drawer.take().is_some() 150 | { 151 | Action::Render 152 | } else { 153 | Action::Nothing 154 | } 155 | } 156 | Mark => self.mark_selection(), 157 | Unmark => self.unmark_selection(), 158 | UnmarkAll => { 159 | if self.confirm_dialog.is_none() { 160 | self.confirm_dialog = Some(ConfirmDialog { 161 | text: "Are you sure you want to delete all marks?" 162 | .into(), 163 | yes: "Yes".into(), 164 | no: "No".into(), 165 | yes_selected: false, 166 | action: Action::DeleteAllMarks, 167 | }); 168 | Action::Render 169 | } else { 170 | Action::Nothing 171 | } 172 | } 173 | Quit => Action::Quit, 174 | Generate => self.generate(), 175 | Entries { path_id, entries } => self.set_entries(path_id, entries), 176 | EntryDetails(details) => { 177 | self.details_drawer = Some(DetailsDrawer { details }); 178 | Action::Render 179 | } 180 | Marks(new_marks) => self.set_marks(new_marks), 181 | } 182 | } 183 | 184 | fn resize(&mut self, new_size: Size) -> Action { 185 | self.list_size = compute_list_size(new_size); 186 | self.fix_offset(); 187 | Action::Render 188 | } 189 | 190 | fn left(&mut self) -> Action { 191 | if let Some(path_id) = self.path_id { 192 | Action::GetParentEntries(path_id) 193 | } else { 194 | Action::Nothing 195 | } 196 | } 197 | 198 | fn right(&mut self) -> Action { 199 | if !self.entries.is_empty() { 200 | let entry = &self.entries[self.selected]; 201 | if entry.is_dir { 202 | return Action::GetEntries(Some(entry.path_id)); 203 | } 204 | } 205 | Action::Nothing 206 | } 207 | 208 | fn move_selection(&mut self, delta: isize, wrap: bool) -> Action { 209 | if self.entries.is_empty() { 210 | return Action::Nothing; 211 | } 212 | 213 | let selected = self.selected as isize; 214 | let len = self.entries.len() as isize; 215 | self.selected = if wrap { 216 | (selected + delta).rem_euclid(len) 217 | } else { 218 | max(0, min(len - 1, selected + delta)) 219 | } as usize; 220 | self.fix_offset(); 221 | 222 | if self.details_drawer.is_some() { 223 | Action::GetEntryDetails(self.entries[self.selected].path_id) 224 | } else { 225 | Action::Render 226 | } 227 | } 228 | 229 | fn mark_selection(&mut self) -> Action { 230 | self.selected_entry().map(Action::UpsertMark).unwrap_or(Action::Nothing) 231 | } 232 | 233 | fn unmark_selection(&mut self) -> Action { 234 | self.selected_entry().map(Action::DeleteMark).unwrap_or(Action::Nothing) 235 | } 236 | 237 | fn generate(&self) -> Action { 238 | let mut lines = self.marks.iter().map(Clone::clone).collect::>(); 239 | lines.sort_unstable(); 240 | Action::Generate(lines) 241 | } 242 | 243 | fn set_entries( 244 | &mut self, 245 | path_id: Option, 246 | entries: Vec, 247 | ) -> Action { 248 | // See if any of the new entries matches the current directory 249 | // and pre-select it. This means that we went up to the parent dir. 250 | self.selected = entries 251 | .iter() 252 | .enumerate() 253 | .find(|(_, e)| Some(e.path_id) == self.path_id) 254 | .map(|(i, _)| i) 255 | .unwrap_or(0); 256 | self.offset = 0; 257 | self.path_id = path_id; 258 | { 259 | // Check if the new path_id matches any of the old entries. 260 | // If we find one this means that we are going down into that entry. 261 | if let Some(e) = 262 | self.entries.iter().find(|e| Some(e.path_id) == path_id) 263 | { 264 | self.path.push(&e.component); 265 | } else { 266 | self.path.pop(); 267 | } 268 | } 269 | self.entries = entries; 270 | self.fix_offset(); 271 | 272 | if self.details_drawer.is_some() { 273 | Action::GetEntryDetails(self.entries[self.selected].path_id) 274 | } else { 275 | Action::Render 276 | } 277 | } 278 | 279 | fn set_marks(&mut self, new_marks: Vec) -> Action { 280 | self.marks = HashSet::from_iter(new_marks); 281 | Action::Render 282 | } 283 | 284 | /// Adjust offset to make sure the selected item is visible. 285 | fn fix_offset(&mut self) { 286 | let offset = self.offset as isize; 287 | let selected = self.selected as isize; 288 | let h = self.list_size.height as isize; 289 | let first_visible = offset; 290 | let last_visible = offset + h - 1; 291 | let new_offset = if selected < first_visible { 292 | selected 293 | } else if last_visible < selected { 294 | selected - h + 1 295 | } else { 296 | offset 297 | }; 298 | self.offset = new_offset as usize; 299 | } 300 | 301 | fn selected_entry(&self) -> Option { 302 | if self.entries.is_empty() { 303 | return None; 304 | } 305 | Some(self.full_path(&self.entries[self.selected])) 306 | } 307 | 308 | fn full_path(&self, entry: &Entry) -> Utf8PathBuf { 309 | let mut full_loc = self.path.clone(); 310 | full_loc.push(&entry.component); 311 | full_loc 312 | } 313 | } 314 | 315 | fn compute_list_size(area: Size) -> Size { 316 | let (_, list, _) = compute_layout((Position::new(0, 0), area).into()); 317 | list.as_size() 318 | } 319 | 320 | fn compute_layout(area: Rect) -> (Rect, Rect, Rect) { 321 | let layout = Layout::default() 322 | .direction(Direction::Vertical) 323 | .constraints([ 324 | Constraint::Length(1), 325 | Constraint::Fill(100), 326 | Constraint::Length(1), 327 | ]) 328 | .split(area); 329 | (layout[0], layout[1], layout[2]) 330 | } 331 | 332 | impl WidgetRef for App { 333 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 334 | let (header_area, table_area, footer_area) = compute_layout(area); 335 | { 336 | // Header 337 | let mut string = "--- ".to_string(); 338 | string.push_str( 339 | shorten_to( 340 | if self.path.as_str().is_empty() { 341 | "#" 342 | } else { 343 | self.path.as_str() 344 | }, 345 | max(0, header_area.width as isize - string.len() as isize) 346 | as usize, 347 | ) 348 | .as_ref(), 349 | ); 350 | let mut remaining_width = max( 351 | 0, 352 | header_area.width as isize 353 | - string.graphemes(true).count() as isize, 354 | ) as usize; 355 | if remaining_width > 0 { 356 | string.push(' '); 357 | remaining_width -= 1; 358 | } 359 | string.push_str(&"-".repeat(remaining_width)); 360 | Paragraph::new(string).on_light_blue().render_ref(header_area, buf); 361 | } 362 | 363 | { 364 | // Table 365 | const MIN_WIDTH_SHOW_SIZEBAR: u16 = 50; 366 | let show_sizebar = table_area.width >= MIN_WIDTH_SHOW_SIZEBAR; 367 | let mut rows: Vec = Vec::with_capacity(self.entries.len()); 368 | let mut entries = self.entries.iter(); 369 | if let Some(first) = entries.next() { 370 | let largest_size = first.size as f64; 371 | for (index, entry) in iter::once(first) 372 | .chain(entries) 373 | .enumerate() 374 | .skip(self.offset) 375 | { 376 | let selected = index == self.selected; 377 | let mut spans = Vec::with_capacity(4); 378 | spans.push(render_mark( 379 | self.marks.contains(&self.full_path(entry)), 380 | )); 381 | spans.push(render_size(entry.size)); 382 | if show_sizebar { 383 | spans.push(render_sizebar( 384 | entry.size as f64 / largest_size, 385 | )); 386 | } 387 | let used_width: usize = spans 388 | .iter() 389 | .map(|s| grapheme_len(&s.content)) 390 | .sum::() 391 | + spans.len(); // separators 392 | let available_width = 393 | max(0, table_area.width as isize - used_width as isize) 394 | as usize; 395 | spans.push(render_name( 396 | &entry.component, 397 | entry.is_dir, 398 | selected, 399 | available_width, 400 | )); 401 | rows.push(Row::new(spans).style(if selected { 402 | Style::new().black().on_white() 403 | } else { 404 | Style::new() 405 | })); 406 | } 407 | } 408 | let mut constraints = Vec::with_capacity(4); 409 | constraints.push(Constraint::Min(MARK_LEN)); 410 | constraints.push(Constraint::Min(SIZE_LEN)); 411 | if show_sizebar { 412 | constraints.push(Constraint::Min(SIZEBAR_LEN)); 413 | } 414 | constraints.push(Constraint::Percentage(100)); 415 | Table::new(rows, constraints).render_ref(table_area, buf) 416 | } 417 | 418 | { 419 | // Footer 420 | let spans = vec![ 421 | Span::from(format!(" Marks: {}", self.marks.len())), 422 | Span::from(" | "), 423 | ] 424 | .into_iter() 425 | .chain(self.footer_extra.clone()) 426 | .collect::>(); 427 | Paragraph::new(Line::from(spans)) 428 | .on_light_blue() 429 | .render_ref(footer_area, buf); 430 | } 431 | 432 | if let Some(details_dialog) = &self.details_drawer { 433 | details_dialog.render_ref(table_area, buf); 434 | } 435 | 436 | if let Some(confirm_dialog) = &self.confirm_dialog { 437 | confirm_dialog.render_ref(area, buf); 438 | } 439 | } 440 | } 441 | 442 | const MARK_LEN: u16 = 1; 443 | 444 | fn render_mark(is_marked: bool) -> Span<'static> { 445 | Span::raw(if is_marked { "*" } else { " " }) 446 | } 447 | 448 | const SIZE_LEN: u16 = 11; 449 | 450 | fn render_size(size: usize) -> Span<'static> { 451 | Span::raw(format!( 452 | "{:>11}", 453 | humansize::format_size(size, humansize::BINARY) 454 | )) 455 | } 456 | 457 | const SIZEBAR_LEN: u16 = 16; 458 | 459 | fn render_sizebar(relative_size: f64) -> Span<'static> { 460 | Span::raw({ 461 | let bar_frac_width = 462 | (relative_size * (SIZEBAR_LEN * 8) as f64) as usize; 463 | let full_blocks = bar_frac_width / 8; 464 | let last_block = match (bar_frac_width % 8) as u32 { 465 | 0 => String::new(), 466 | x => String::from(unsafe { char::from_u32_unchecked(0x2590 - x) }), 467 | }; 468 | let empty_width = 469 | SIZEBAR_LEN as usize - full_blocks - grapheme_len(&last_block); 470 | let mut bar = String::with_capacity(1 + SIZEBAR_LEN as usize + 1); 471 | for _ in 0..full_blocks { 472 | bar.push('\u{2588}'); 473 | } 474 | bar.push_str(&last_block); 475 | for _ in 0..empty_width { 476 | bar.push(' '); 477 | } 478 | bar 479 | }) 480 | .green() 481 | } 482 | 483 | fn render_name( 484 | name: &str, 485 | is_dir: bool, 486 | selected: bool, 487 | available_width: usize, 488 | ) -> Span { 489 | let mut escaped = escape_name(name); 490 | if is_dir { 491 | if !escaped.ends_with('/') { 492 | escaped.to_mut().push('/'); 493 | } 494 | let span = 495 | Span::raw(shorten_to(&escaped, available_width).into_owned()) 496 | .bold(); 497 | if selected { 498 | span.dark_gray() 499 | } else { 500 | span.blue() 501 | } 502 | } else { 503 | Span::raw(shorten_to(&escaped, available_width).into_owned()) 504 | } 505 | } 506 | 507 | fn escape_name(name: &str) -> Cow { 508 | match name.find(char::is_control) { 509 | None => Cow::Borrowed(name), 510 | Some(index) => { 511 | let (left, right) = name.split_at(index); 512 | let mut escaped = String::with_capacity(name.len() + 1); // the +1 is for the extra \ 513 | escaped.push_str(left); 514 | for c in right.chars() { 515 | if c.is_control() { 516 | for d in c.escape_default() { 517 | escaped.push(d); 518 | } 519 | } else { 520 | escaped.push(c) 521 | } 522 | } 523 | Cow::Owned(escaped) 524 | } 525 | } 526 | } 527 | 528 | fn shorten_to(s: &str, width: usize) -> Cow { 529 | let len = s.graphemes(true).count(); 530 | let res = if len <= width { 531 | Cow::Borrowed(s) 532 | } else if width <= 3 { 533 | Cow::Owned(".".repeat(width)) 534 | } else { 535 | let front_width = (width - 3).div_euclid(2); 536 | let back_width = width - front_width - 3; 537 | let graphemes = s.graphemes(true); 538 | let mut name = graphemes.clone().take(front_width).collect::(); 539 | name.push_str("..."); 540 | for g in graphemes.skip(len - back_width) { 541 | name.push_str(g); 542 | } 543 | Cow::Owned(name) 544 | }; 545 | res 546 | } 547 | 548 | /// DetailsDialog ////////////////////////////////////////////////////////////// 549 | struct DetailsDrawer { 550 | details: EntryDetails, 551 | } 552 | 553 | impl WidgetRef for DetailsDrawer { 554 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 555 | let details = &self.details; 556 | let text = format!( 557 | "max size: {} ({})\n\ 558 | first seen: {} ({})\n\ 559 | last seen: {} ({})\n", 560 | humansize::format_size(details.max_size, humansize::BINARY), 561 | snapshot_short_id(&details.max_size_snapshot_hash), 562 | details.first_seen.date_naive(), 563 | snapshot_short_id(&details.first_seen_snapshot_hash), 564 | details.last_seen.date_naive(), 565 | snapshot_short_id(&details.last_seen_snapshot_hash), 566 | ); 567 | let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); 568 | let padding = Padding { left: 2, right: 2, top: 0, bottom: 0 }; 569 | let horiz_padding = padding.left + padding.right; 570 | let inner_width = { 571 | let desired_inner_width = paragraph.line_width() as u16; 572 | let max_inner_width = area.width.saturating_sub(2 + horiz_padding); 573 | min(max_inner_width, desired_inner_width) 574 | }; 575 | let outer_width = inner_width + 2 + horiz_padding; 576 | let outer_height = { 577 | let vert_padding = padding.top + padding.bottom; 578 | let inner_height = paragraph.line_count(inner_width) as u16; 579 | inner_height + 2 + vert_padding 580 | }; 581 | let block_area = Rect { 582 | x: area.x + area.width - outer_width, 583 | y: area.y + area.height - outer_height, 584 | width: outer_width, 585 | height: outer_height, 586 | }; 587 | let block = Block::bordered().title("Details").padding(padding); 588 | let paragraph_area = block.inner(block_area); 589 | Clear.render(block_area, buf); 590 | block.render(block_area, buf); 591 | paragraph.render(paragraph_area, buf); 592 | } 593 | } 594 | 595 | /// ConfirmDialog ////////////////////////////////////////////////////////////// 596 | struct ConfirmDialog { 597 | text: String, 598 | yes: String, 599 | no: String, 600 | yes_selected: bool, 601 | action: Action, 602 | } 603 | 604 | impl WidgetRef for ConfirmDialog { 605 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 606 | let main_text = Paragraph::new(self.text.clone()) 607 | .centered() 608 | .wrap(Wrap { trim: false }); 609 | 610 | let padding = Padding { left: 2, right: 2, top: 1, bottom: 0 }; 611 | let width = min(80, grapheme_len(&self.text) as u16); 612 | let height = main_text.line_count(width) as u16 + 1 + 3; // text + empty line + buttons 613 | let dialog_area = dialog(padding, width, height, area); 614 | 615 | let block = Block::bordered().title("Confirm").padding(padding); 616 | 617 | let (main_text_area, buttons_area) = { 618 | let layout = Layout::default() 619 | .direction(Direction::Vertical) 620 | .constraints([Constraint::Fill(100), Constraint::Length(3)]) 621 | .split(block.inner(dialog_area)); 622 | (layout[0], layout[1]) 623 | }; 624 | let (no_button_area, yes_button_area) = { 625 | let layout = Layout::default() 626 | .direction(Direction::Horizontal) 627 | .constraints([ 628 | Constraint::Fill(1), 629 | Constraint::Min(self.no.graphemes(true).count() as u16), 630 | Constraint::Fill(1), 631 | Constraint::Min(self.yes.graphemes(true).count() as u16), 632 | Constraint::Fill(1), 633 | ]) 634 | .split(buttons_area); 635 | (layout[1], layout[3]) 636 | }; 637 | 638 | fn render_button( 639 | label: &str, 640 | selected: bool, 641 | area: Rect, 642 | buf: &mut Buffer, 643 | ) { 644 | let mut block = Block::bordered().border_type(BorderType::Plain); 645 | let mut button = 646 | Paragraph::new(label).centered().wrap(Wrap { trim: false }); 647 | if selected { 648 | block = block.border_type(BorderType::QuadrantInside); 649 | button = button.black().on_white(); 650 | } 651 | button.render(block.inner(area), buf); 652 | block.render(area, buf); 653 | } 654 | 655 | Clear.render(dialog_area, buf); 656 | block.render(dialog_area, buf); 657 | main_text.render(main_text_area, buf); 658 | render_button(&self.no, !self.yes_selected, no_button_area, buf); 659 | render_button(&self.yes, self.yes_selected, yes_button_area, buf); 660 | } 661 | } 662 | 663 | /// Misc ////////////////////////////////////////////////////////////////////// 664 | fn dialog( 665 | padding: Padding, 666 | max_inner_width: u16, 667 | max_inner_height: u16, 668 | area: Rect, 669 | ) -> Rect { 670 | let horiz_padding = padding.left + padding.right; 671 | let vert_padding = padding.top + padding.bottom; 672 | let max_width = max_inner_width + 2 + horiz_padding; // The extra 2 is the border 673 | let max_height = max_inner_height + 2 + vert_padding; 674 | centered(max_width, max_height, area) 675 | } 676 | 677 | /// Returns a `Rect` centered in `area` with a maximum width and height. 678 | fn centered(max_width: u16, max_height: u16, area: Rect) -> Rect { 679 | let width = min(max_width, area.width); 680 | let height = min(max_height, area.height); 681 | Rect { 682 | x: area.width / 2 - width / 2, 683 | y: area.height / 2 - height / 2, 684 | width, 685 | height, 686 | } 687 | } 688 | 689 | fn grapheme_len(s: &str) -> usize { 690 | s.graphemes(true).count() 691 | } 692 | 693 | /// Tests ////////////////////////////////////////////////////////////////////// 694 | #[cfg(test)] 695 | mod tests { 696 | use super::{shorten_to, *}; 697 | 698 | #[test] 699 | fn render_sizebar_test() { 700 | fn aux(size: f64, content: &str) { 701 | assert_eq!(render_sizebar(size).content, content); 702 | } 703 | 704 | aux(0.00, " "); 705 | aux(0.25, "████ "); 706 | aux(0.50, "████████ "); 707 | aux(0.75, "████████████ "); 708 | aux(0.90, "██████████████▍ "); 709 | aux(1.00, "████████████████"); 710 | aux(0.5 + (1.0 / (8.0 * 16.0)), "████████▏ "); 711 | aux(0.5 + (2.0 / (8.0 * 16.0)), "████████▎ "); 712 | aux(0.5 + (3.0 / (8.0 * 16.0)), "████████▍ "); 713 | aux(0.5 + (4.0 / (8.0 * 16.0)), "████████▌ "); 714 | aux(0.5 + (5.0 / (8.0 * 16.0)), "████████▋ "); 715 | aux(0.5 + (6.0 / (8.0 * 16.0)), "████████▊ "); 716 | aux(0.5 + (7.0 / (8.0 * 16.0)), "████████▉ "); 717 | } 718 | 719 | #[test] 720 | fn escape_name_test() { 721 | assert_eq!( 722 | escape_name("f\no\\tóà 学校\r"), 723 | Cow::Borrowed("f\\no\\tóà 学校\\r") 724 | ); 725 | } 726 | 727 | #[test] 728 | fn shorten_to_test() { 729 | let s = "123456789"; 730 | assert_eq!(shorten_to(s, 0), Cow::Owned::("".to_owned())); 731 | assert_eq!(shorten_to(s, 1), Cow::Owned::(".".to_owned())); 732 | assert_eq!(shorten_to(s, 2), Cow::Owned::("..".to_owned())); 733 | assert_eq!(shorten_to(s, 3), Cow::Owned::("...".to_owned())); 734 | assert_eq!(shorten_to(s, 4), Cow::Owned::("...9".to_owned())); 735 | assert_eq!(shorten_to(s, 5), Cow::Owned::("1...9".to_owned())); 736 | assert_eq!(shorten_to(s, 8), Cow::Owned::("12...789".to_owned())); 737 | assert_eq!(shorten_to(s, 9), Cow::Borrowed(s)); 738 | } 739 | } 740 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | pub fn snapshot_short_id(id: &str) -> String { 2 | id.chars().take(7).collect::() 3 | } 4 | --------------------------------------------------------------------------------