├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── codecov.yml ├── renovate.json └── workflows │ ├── ci.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── crates ├── archlinux │ ├── Cargo.toml │ ├── sample │ │ └── archlinux.json │ └── src │ │ ├── errors │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── response │ │ ├── external.rs │ │ ├── internal.rs │ │ └── mod.rs │ │ └── test │ │ └── mod.rs └── mirro-rs │ ├── Cargo.toml │ ├── build.rs │ ├── completions │ ├── _mirro-rs │ ├── mirro-rs.bash │ ├── mirro-rs.elv │ └── mirro-rs.fish │ ├── man │ └── mirro-rs.1 │ └── src │ ├── cli │ └── mod.rs │ ├── config │ ├── file.rs │ ├── mod.rs │ └── watch.rs │ ├── dbg │ └── mod.rs │ ├── direct │ └── mod.rs │ ├── main.rs │ ├── test │ └── mod.rs │ └── tui │ ├── actions.rs │ ├── inputs │ ├── event.rs │ ├── key.rs │ └── mod.rs │ ├── io │ ├── handler.rs │ └── mod.rs │ ├── mod.rs │ ├── state.rs │ ├── ui.rs │ └── view │ ├── filter.rs │ ├── mod.rs │ └── sort.rs ├── examples ├── mirro-rs.json ├── mirro-rs.toml └── mirro-rs.yaml └── systemd ├── mirro-rs.service └── mirro-rs.timer /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | crates/*/Cargo.lock 3 | Dockerfile 4 | .dockerignore 5 | .git 6 | .github 7 | .gitignore 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Toggle '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Ubuntu 23.10] 28 | - Terminal: [e.g Alacritty] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: 'enhancement' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.codecov.com/docs/codecovyml-reference 2 | coverage: 3 | range: 85..100 4 | round: down 5 | precision: 1 6 | status: 7 | project: 8 | default: 9 | threshold: 1% 10 | 11 | comment: 12 | layout: "files" 13 | require_changes: yes 14 | 15 | ignore: 16 | - "**/src/test" 17 | - "**/src/errors" 18 | - "crates/mirro-rs/src/tui" 19 | - "crates/mirro-rs/src/cli" 20 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "semanticCommits": "enabled", 7 | "rebaseWhen": "conflicted", 8 | "packageRules": [ 9 | { 10 | "matchPackagePatterns": [ 11 | "clap" 12 | ], 13 | "groupName": "clap" 14 | }, 15 | { 16 | "matchPackagePatterns": [ 17 | "tracing" 18 | ], 19 | "groupName": "tracing", 20 | "automerge": true 21 | }, 22 | { 23 | "matchPackagePatterns": [ 24 | "anyhow", 25 | "thiserror" 26 | ], 27 | "automerge": true, 28 | "groupName": "error-handling" 29 | }, 30 | { 31 | "groupName": "tokio", 32 | "automerge": true, 33 | "matchPackagePatterns": [ 34 | "tokio" 35 | ], 36 | "matchCurrentVersion": "!/^0/", 37 | "matchUpdateTypes": [ 38 | "patch", 39 | "minor" 40 | ] 41 | }, 42 | { 43 | "groupName": "tui", 44 | "matchPackagePatterns": [ 45 | "ratatui", 46 | "tui-logger" 47 | ] 48 | }, 49 | { 50 | "groupName": "serde", 51 | "automerge": true, 52 | "matchPackagePatterns": [ 53 | "^serde" 54 | ], 55 | "matchCurrentVersion": "!/^0/", 56 | "matchUpdateTypes": [ 57 | "patch", 58 | "minor" 59 | ] 60 | }, 61 | { 62 | "matchUpdateTypes": [ 63 | "minor", 64 | "patch" 65 | ], 66 | "matchCurrentVersion": "!/^0/", 67 | "automerge": true 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | pull_request: 5 | name: rust 6 | 7 | # cancel on going checks when new code is pushed 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | CARGO_INCREMENTAL: 0 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | check: 18 | runs-on: ubuntu-latest 19 | name: ubuntu / stable / check 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Install stable 23 | uses: dtolnay/rust-toolchain@stable 24 | - name: cargo generate-lockfile 25 | if: hashFiles('Cargo.lock') == '' 26 | run: cargo generate-lockfile 27 | - name: cargo check 28 | run: cargo check 29 | 30 | hack: 31 | runs-on: ubuntu-latest 32 | name: ubuntu / stable / features 33 | steps: 34 | - uses: actions/checkout@v4 35 | with: 36 | submodules: true 37 | - name: Install stable 38 | uses: dtolnay/rust-toolchain@stable 39 | - name: cargo install cargo-hack 40 | uses: taiki-e/install-action@cargo-hack 41 | - name: cargo hack 42 | run: cargo hack --feature-powerset check --all 43 | 44 | doc: 45 | runs-on: ubuntu-latest 46 | name: nightly / doc 47 | steps: 48 | - uses: actions/checkout@v4 49 | with: 50 | submodules: true 51 | - name: Install nightly 52 | uses: dtolnay/rust-toolchain@nightly 53 | - name: cargo doc 54 | run: cargo doc --no-deps --all-features 55 | env: 56 | RUSTDOCFLAGS: --cfg docsrs 57 | 58 | msrv: 59 | runs-on: ubuntu-latest 60 | strategy: 61 | matrix: 62 | msrv: ["1.74.0"] # clap_mangen 63 | name: ubuntu / ${{ matrix.msrv }} 64 | steps: 65 | - uses: actions/checkout@v4 66 | with: 67 | submodules: true 68 | - name: Install ${{ matrix.msrv }} 69 | uses: dtolnay/rust-toolchain@stable 70 | with: 71 | toolchain: ${{ matrix.msrv }} 72 | - name: cargo +${{ matrix.msrv }} check 73 | run: cargo check --all-features 74 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | pull_request: 5 | name: lint 6 | 7 | # cancel on going checks when new code is pushed 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | CARGO_INCREMENTAL: 0 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | fmt: 18 | runs-on: ubuntu-latest 19 | name: stable / fmt 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | - name: Install stable 25 | uses: dtolnay/rust-toolchain@stable 26 | with: 27 | components: rustfmt 28 | - name: cargo fmt --check 29 | run: cargo fmt --check 30 | 31 | clippy: 32 | runs-on: ubuntu-latest 33 | name: ${{ matrix.toolchain }} / clippy 34 | permissions: 35 | contents: read 36 | checks: write 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | toolchain: [stable, beta] 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | submodules: true 45 | - name: Install ${{ matrix.toolchain }} 46 | uses: dtolnay/rust-toolchain@master 47 | with: 48 | toolchain: ${{ matrix.toolchain }} 49 | components: clippy 50 | - name: cargo clippy 51 | uses: giraffate/clippy-action@v1 52 | with: 53 | reporter: 'github-pr-check' 54 | fail_on_error: true 55 | clippy_flags: --all-targets --all-features 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/SpectralOps/rust-ci-release-template 2 | name: Release 3 | on: 4 | # schedule: 5 | # - cron: '0 0 * * *' # midnight UTC 6 | 7 | push: 8 | tags: 9 | - 'v[0-9]+.[0-9]+.[0-9]+' 10 | ## - release 11 | 12 | env: 13 | BIN_NAME: mirro-rs 14 | PROJECT_NAME: mirro-rs 15 | REPO_NAME: rtkay123/mirro-rs 16 | 17 | jobs: 18 | dist: 19 | name: Dist 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | fail-fast: false # don't fail other jobs if one fails 23 | matrix: 24 | build: [x86_64-linux, x86_64-macos, x86_64-windows, aarch64-linux] #, x86_64-win-gnu, win32-msvc 25 | include: 26 | - build: x86_64-linux 27 | os: ubuntu-20.04 28 | rust: stable 29 | target: x86_64-unknown-linux-gnu 30 | cross: false 31 | - build: x86_64-macos 32 | os: macos-latest 33 | rust: stable 34 | target: x86_64-apple-darwin 35 | cross: false 36 | - build: x86_64-windows 37 | os: windows-2019 38 | rust: stable 39 | target: x86_64-pc-windows-msvc 40 | cross: false 41 | - build: aarch64-linux 42 | os: ubuntu-20.04 43 | rust: stable 44 | target: aarch64-unknown-linux-gnu 45 | cross: true 46 | 47 | steps: 48 | - name: Checkout sources 49 | uses: actions/checkout@v4 50 | with: 51 | submodules: true 52 | 53 | - name: Install ${{ matrix.rust }} toolchain 54 | uses: actions-rs/toolchain@v1 55 | with: 56 | profile: minimal 57 | toolchain: ${{ matrix.rust }} 58 | target: ${{ matrix.target }} 59 | override: true 60 | 61 | - name: Run cargo test 62 | uses: actions-rs/cargo@v1 63 | with: 64 | use-cross: ${{ matrix.cross }} 65 | command: test 66 | args: --release --all-features --locked --target ${{ matrix.target }} 67 | 68 | - name: Build release binary 69 | uses: actions-rs/cargo@v1 70 | with: 71 | use-cross: ${{ matrix.cross }} 72 | command: build 73 | args: --release --all-features --locked --target ${{ matrix.target }} 74 | 75 | - name: Strip release binary (linux and macos) 76 | if: matrix.build == 'x86_64-linux' || matrix.build == 'x86_64-macos' 77 | run: strip "target/${{ matrix.target }}/release/$BIN_NAME" 78 | 79 | - name: Build archive 80 | shell: bash 81 | run: | 82 | mkdir dist 83 | if [ "${{ matrix.os }}" = "windows-2019" ]; then 84 | cp "target/${{ matrix.target }}/release/$BIN_NAME.exe" "dist/" 85 | else 86 | cp "target/${{ matrix.target }}/release/$BIN_NAME" "dist/" 87 | fi 88 | 89 | - uses: actions/upload-artifact@v4.5.0 90 | with: 91 | name: bins-${{ matrix.build }} 92 | path: dist 93 | 94 | publish: 95 | name: Publish 96 | needs: [dist] 97 | runs-on: ubuntu-latest 98 | steps: 99 | - name: Checkout sources 100 | uses: actions/checkout@v4 101 | with: 102 | submodules: false 103 | 104 | - uses: actions/download-artifact@v4 105 | # with: 106 | # path: dist 107 | # - run: ls -al ./dist 108 | - run: ls -al bins-* 109 | 110 | - name: Calculate tag name 111 | run: | 112 | name=dev 113 | if [[ $GITHUB_REF == refs/tags/v* ]]; then 114 | name=${GITHUB_REF:10} 115 | fi 116 | echo ::set-output name=val::$name 117 | echo TAG=$name >> $GITHUB_ENV 118 | id: tagname 119 | 120 | - name: Build archive 121 | shell: bash 122 | run: | 123 | set -ex 124 | 125 | rm -rf tmp 126 | mkdir tmp 127 | mkdir dist 128 | 129 | for dir in bins-* ; do 130 | platform=${dir#"bins-"} 131 | unset exe 132 | if [[ $platform =~ "windows" ]]; then 133 | exe=".exe" 134 | fi 135 | pkgname=$PROJECT_NAME-$TAG-$platform 136 | mkdir tmp/$pkgname 137 | # cp LICENSE README.md tmp/$pkgname 138 | cp LICENSE-MIT LICENSE-APACHE tmp/$pkgname 139 | cp -r crates/mirro-rs/completions tmp/$pkgname 140 | cp -r crates/mirro-rs/man tmp/$pkgname 141 | cp -r examples tmp/$pkgname 142 | cp -r systemd tmp/$pkgname 143 | mv bins-$platform/$BIN_NAME$exe tmp/$pkgname 144 | chmod +x tmp/$pkgname/$BIN_NAME$exe 145 | 146 | if [ "$exe" = "" ]; then 147 | tar cJf dist/$pkgname.tar.xz -C tmp $pkgname 148 | else 149 | (cd tmp && 7z a -r ../dist/$pkgname.zip $pkgname) 150 | fi 151 | done 152 | 153 | - name: Upload binaries to release 154 | uses: svenstaro/upload-release-action@v2 155 | with: 156 | repo_token: ${{ secrets.GITHUB_TOKEN }} 157 | file: dist/* 158 | file_glob: true 159 | tag: ${{ steps.tagname.outputs.val }} 160 | overwrite: true 161 | 162 | - name: Extract version 163 | id: extract-version 164 | run: | 165 | printf "::set-output name=%s::%s\n" tag-name "${GITHUB_REF#refs/tags/}" 166 | 167 | - name: Install stable 168 | uses: dtolnay/rust-toolchain@stable 169 | - run: cargo publish -p ${BIN_NAME} --token ${CRATES_TOKEN} 170 | env: 171 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 172 | 173 | changelog: 174 | runs-on: ubuntu-latest 175 | needs: [publish] 176 | steps: 177 | - name: Checkout Code 178 | uses: actions/checkout@v4 179 | 180 | - name: Update CHANGELOG 181 | id: changelog 182 | uses: requarks/changelog-action@v1 183 | with: 184 | token: ${{ github.token }} 185 | tag: ${{ github.ref_name }} 186 | useGitmojis: false 187 | 188 | - name: Create Release 189 | uses: ncipollo/release-action@v1.14.0 190 | with: 191 | allowUpdates: true 192 | draft: false 193 | makeLatest: true 194 | name: ${{ github.ref_name }} 195 | body: ${{ steps.changelog.outputs.changes }} 196 | token: ${{ github.token }} 197 | 198 | - name: Commit CHANGELOG.md 199 | uses: stefanzweifel/git-auto-commit-action@v5 200 | with: 201 | branch: master 202 | commit_message: 'docs: update CHANGELOG.md for ${{ github.ref_name }}' 203 | file_pattern: CHANGELOG.md 204 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | pull_request: 5 | name: test 6 | 7 | # cancel on going checks when new code is pushed 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | CARGO_INCREMENTAL: 0 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | required: 18 | runs-on: ubuntu-latest 19 | name: ubuntu / ${{ matrix.toolchain }} 20 | strategy: 21 | matrix: 22 | toolchain: [stable, beta] 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | submodules: true 27 | - name: Install ${{ matrix.toolchain }} 28 | uses: dtolnay/rust-toolchain@master 29 | with: 30 | toolchain: ${{ matrix.toolchain }} 31 | - name: cargo generate-lockfile 32 | if: hashFiles('Cargo.lock') == '' 33 | run: cargo generate-lockfile 34 | - name: cargo test --locked 35 | run: cargo test --locked --all-features --all-targets 36 | - name: cargo test --doc 37 | run: cargo test --locked --all-features --doc 38 | 39 | os-check: 40 | runs-on: ${{ matrix.os }} 41 | name: ${{ matrix.os }} / stable 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | os: [macos-latest, windows-latest] 46 | steps: 47 | - uses: actions/checkout@v4 48 | - name: Install stable 49 | uses: dtolnay/rust-toolchain@stable 50 | - name: cargo generate-lockfile 51 | if: hashFiles('Cargo.lock') == '' 52 | run: cargo generate-lockfile 53 | - name: cargo test --workspace 54 | run: cargo test --no-run --workspace --locked --all-features --all-targets 55 | 56 | coverage: 57 | runs-on: ubuntu-latest 58 | name: ubuntu / stable / coverage 59 | steps: 60 | - uses: actions/checkout@v4 61 | - name: Install stable 62 | uses: dtolnay/rust-toolchain@stable 63 | with: 64 | components: llvm-tools-preview 65 | - name: cargo install cargo-llvm-cov 66 | uses: taiki-e/install-action@cargo-llvm-cov 67 | - name: cargo llvm-cov 68 | run: cargo llvm-cov --workspace --locked --all-features --lcov --output-path lcov.info 69 | - name: Upload to codecov.io 70 | uses: codecov/codecov-action@v4 71 | with: 72 | fail_ci_if_error: true 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | crates/*/Cargo.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v0.2.3] - 2024-03-02 2 | ### Bug Fixes 3 | - [`db93756`](https://github.com/rtkay123/mirro-rs/commit/db93756fc2ed74a944be81dac70d5a72263ec29d) - nightly docs builds 4 | - [`727f570`](https://github.com/rtkay123/mirro-rs/commit/727f5709265d5309d3846bbaeba7f6cf5bd79118) - **deps**: update rust crate chrono to 0.4.34 *(commit by @renovate[bot])* 5 | - [`07e3c72`](https://github.com/rtkay123/mirro-rs/commit/07e3c72f9fc8f9a6bd4b1756c62a594809565e3b) - **deps**: update rust crate unicode-width to 0.1.11 *(commit by @renovate[bot])* 6 | - [`b359df8`](https://github.com/rtkay123/mirro-rs/commit/b359df8205a20b4e86b636e1c4d8a9949746d473) - **deps**: update rust crate log to 0.4.20 *(commit by @renovate[bot])* 7 | - [`3c3f33e`](https://github.com/rtkay123/mirro-rs/commit/3c3f33eb35c39e4e81d41571f337358caf4cd4a9) - **deps**: update tui *(commit by @renovate[bot])* 8 | - [`b73451e`](https://github.com/rtkay123/mirro-rs/commit/b73451ed8d7b4653cba9f541fc6daf55e3b8ca05) - **deps**: update rust crate reqwest to 0.11.24 *(commit by @renovate[bot])* 9 | - [`f544b57`](https://github.com/rtkay123/mirro-rs/commit/f544b57de842da4ee2a75f4a41b570db86e63918) - **deps**: update rust crate ahash to 0.8.8 *(commit by @renovate[bot])* 10 | - [`d24c337`](https://github.com/rtkay123/mirro-rs/commit/d24c33794f2369cfeb1cabbf3380a2203c373e23) - **deps**: update rust crate thiserror to 1.0.57 *(commit by @renovate[bot])* 11 | - [`3221ed7`](https://github.com/rtkay123/mirro-rs/commit/3221ed7967954416140b68b40fd5e30f506d1754) - **deps**: update rust crate ratatui to 0.26.1 *(commit by @renovate[bot])* 12 | - [`958d9e6`](https://github.com/rtkay123/mirro-rs/commit/958d9e6e246e2c6eb042e065f7f5300b63bb765a) - **deps**: update rust crate anyhow to 1.0.80 *(commit by @renovate[bot])* 13 | - [`083b93d`](https://github.com/rtkay123/mirro-rs/commit/083b93dbc6a5065fdd9c668b38845548fec1e898) - **deps**: update rust crate serde_yaml to 0.9.32 *(commit by @renovate[bot])* 14 | - [`a304aa4`](https://github.com/rtkay123/mirro-rs/commit/a304aa456c557bf31cf51da4250942da0cb96454) - **deps**: update rust crate ahash to 0.8.9 *(commit by @renovate[bot])* 15 | - [`a471dca`](https://github.com/rtkay123/mirro-rs/commit/a471dca192db41aa8b4de33308fd480880ae411a) - **deps**: update rust crate ahash to 0.8.10 *(commit by @renovate[bot])* 16 | - [`62895cb`](https://github.com/rtkay123/mirro-rs/commit/62895cb6c7cbe15302bd660e17dec38308b180db) - **deps**: update rust crate log to 0.4.21 *(commit by @renovate[bot])* 17 | 18 | ### Chores 19 | - [`cb51fef`](https://github.com/rtkay123/mirro-rs/commit/cb51fef5d619510914609a9e11023653a6e6cdfb) - update deps 20 | - [`79046b6`](https://github.com/rtkay123/mirro-rs/commit/79046b6b17d5bc6534eb12d4a1848482277e342c) - use workspace deps *(PR #90 by @rtkay123)* 21 | - [`cb9faed`](https://github.com/rtkay123/mirro-rs/commit/cb9faedb4b8ca64c749ed02d8081d6af72c960b9) - update config location 22 | - [`182c4ca`](https://github.com/rtkay123/mirro-rs/commit/182c4ca8fc1a76f3d45e16d4e69a71d31110cdc7) - update config location 23 | - [`d284aad`](https://github.com/rtkay123/mirro-rs/commit/d284aad7b09b753e37a34a4370aec255e3656319) - **deps**: update actions/upload-artifact action to v4.3.1 *(commit by @renovate[bot])* 24 | - [`ea56bc0`](https://github.com/rtkay123/mirro-rs/commit/ea56bc0fe8c9f9e708107745198e71c10218d0d2) - **deps**: update rust crate clap_mangen to 0.2.20 *(commit by @renovate[bot])* 25 | - [`132e725`](https://github.com/rtkay123/mirro-rs/commit/132e725936c5bd39ad8f5c27804ca6b18f60b772) - update msrv badge 26 | - [`0528c35`](https://github.com/rtkay123/mirro-rs/commit/0528c359926f393541f7fb0c67add516e8c8a7a2) - **deps**: update clap to 4.5.0 *(commit by @renovate[bot])* 27 | - [`d86c6da`](https://github.com/rtkay123/mirro-rs/commit/d86c6da49417c2a05e3e2e30ce75b3e6801ee706) - **deps**: update ncipollo/release-action action to v1.14.0 *(commit by @renovate[bot])* 28 | - [`18659e1`](https://github.com/rtkay123/mirro-rs/commit/18659e12223d7f4807b0c966616cc4b90e224052) - **deps**: update rust crate itertools to 0.12.1 *(commit by @renovate[bot])* 29 | - [`8c36740`](https://github.com/rtkay123/mirro-rs/commit/8c367400616a30c953e3cdfa81976ae3adf98851) - **deps**: update alpine docker tag to v3.19.1 *(commit by @renovate[bot])* 30 | - [`b9eef3f`](https://github.com/rtkay123/mirro-rs/commit/b9eef3fc4ec2762443edd0e46a2479f87196929f) - **deps**: update rust crate toml to 0.8.10 *(commit by @renovate[bot])* 31 | - [`81ddbdd`](https://github.com/rtkay123/mirro-rs/commit/81ddbdde61928640423dd5a13d4ba35e75be8eb4) - **deps**: update rust crate tokio to 1.36.0 *(commit by @renovate[bot])* 32 | - [`1ffd1fc`](https://github.com/rtkay123/mirro-rs/commit/1ffd1fcbd34ac859ef29f7fab0102f497c6f5a79) - **deps**: update rust crate clap to 4.5.1 *(commit by @renovate[bot])* 33 | - [`8180f94`](https://github.com/rtkay123/mirro-rs/commit/8180f9419a15ee55b6fb6d99a6dafeff3ad4e5a8) - **deps**: update rust crate clap_complete to 4.5.1 *(commit by @renovate[bot])* 34 | - [`c83e0ba`](https://github.com/rtkay123/mirro-rs/commit/c83e0ba9fae562277d17250fd03eb2b826c3c071) - **deps**: update rust crate serde to 1.0.197 *(commit by @renovate[bot])* 35 | - [`b02a39c`](https://github.com/rtkay123/mirro-rs/commit/b02a39cef3822bbb6b76a5c2c370d34e233e7643) - **deps**: update rust crate serde_json to 1.0.114 *(commit by @renovate[bot])* 36 | - [`26c6966`](https://github.com/rtkay123/mirro-rs/commit/26c6966d44909ed6e8bab79f16689895c9d5c6a3) - bump pkg ver 37 | 38 | ## [v0.2.2] - 2023-11-19 39 | ### New Features 40 | - [`37245a4`](https://github.com/rtkay123/mirro-rs/commit/37245a4139436c694967ca9aa3a1941f025958af) - get mirrors with client 41 | - [`25250b0`](https://github.com/rtkay123/mirro-rs/commit/25250b0bac3746e984cea3c1047e6a24e5c84506) - replace logger with tracing 42 | - [`6506dd1`](https://github.com/rtkay123/mirro-rs/commit/6506dd1ea2f45c66d0029e000d619ea72a522468) - handle cases where journald is absent 43 | 44 | ### Bug Fixes 45 | - [`d829437`](https://github.com/rtkay123/mirro-rs/commit/d829437e966990a10dd86869fd1753f63f232b61) - remove flaky parsing and get lastsync timestamp directly from url *(commit by [@phanen](https://github.com/phanen))* 46 | - [`6a4c8ac`](https://github.com/rtkay123/mirro-rs/commit/6a4c8acd1e2f9a559a28c0325beb66747975cfc8) - skip journal errors outside of unix 47 | 48 | ### Refactors 49 | - [`983bc38`](https://github.com/rtkay123/mirro-rs/commit/983bc38208406eeaf672f1887d1f55143ac684b2) - use non blocking fs module 50 | 51 | ### Chores 52 | - [`8f610b2`](https://github.com/rtkay123/mirro-rs/commit/8f610b2247bbe76191c8199071767de6e2fdb357) - bump lib ver 53 | - [`0306ff3`](https://github.com/rtkay123/mirro-rs/commit/0306ff300b4564d0626103a8aa3454da31021d98) - update changelog 54 | - [`b31531c`](https://github.com/rtkay123/mirro-rs/commit/b31531c2510146ba2f7a92f09ac4edf3c40813e6) - bump pkg ver 55 | - [`9b3b56a`](https://github.com/rtkay123/mirro-rs/commit/9b3b56a9d1ff83da121bec55d4930fcf95a22c15) - clippy fix 56 | - [`bfd2b08`](https://github.com/rtkay123/mirro-rs/commit/bfd2b0825cc0500ccdd67c01bbbb1f5cb629be35) - remove log comments 57 | 58 | 59 | ## [v0.2.1] - 2023-11-14 60 | 61 | ### What's Changed 62 | * tests: add more tests to test suite by @rtkay123 in https://github.com/rtkay123/mirro-rs/pull/22 63 | * Implement From to convert args to config struct by @rtkay123 in https://github.com/rtkay123/mirro-rs/pull/23 64 | * docs: update README.md about official Arch Linux package by @orhun in https://github.com/rtkay123/mirro-rs/pull/24 65 | 66 | ### New Contributors 67 | * @dependabot made their first contribution in https://github.com/rtkay123/mirro-rs/pull/15 68 | * @orhun made their first contribution in https://github.com/rtkay123/mirro-rs/pull/24 69 | 70 | **Full Changelog**: https://github.com/rtkay123/mirro-rs/compare/v0.2.0...v0.2.1 71 | 72 | ## [v0.2.0] - 2023-11-11 73 | 74 | ### What's Changed 75 | * fix: make `ftp` known as protocol type by @rtkay123 in https://github.com/rtkay123/mirro-rs/pull/8 76 | * chore: replace tui with ratatui by @rtkay123 in https://github.com/rtkay123/mirro-rs/pull/9 77 | * refactor: replace hyper with reqwest by @rtkay123 in https://github.com/rtkay123/mirro-rs/pull/10 78 | 79 | ### New Contributors 80 | * @rtkay123 made their first contribution in https://github.com/rtkay123/mirro-rs/pull/8 81 | 82 | [v0.2.2]: https://github.com/rtkay123/mirro-rs/compare/v0.2.1...v0.2.2 83 | [v0.2.3]: https://github.com/rtkay123/mirro-rs/compare/v0.2.2...v0.2.3 84 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ "crates/*" ] 3 | resolver = "2" 4 | 5 | [workspace.dependencies] 6 | itertools = "0.13.0" 7 | serde = "1.0.200" 8 | serde_json = "1.0.116" 9 | tokio = "1.37.0" 10 | 11 | [profile.release] 12 | panic = "abort" 13 | lto = true 14 | strip = true 15 | codegen-units = 1 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21.0 2 | 3 | ENV RUSTFLAGS="-C target-feature=-crt-static" 4 | 5 | RUN apk add --no-cache gcc musl-dev rustup 6 | 7 | RUN rustup-init -t x86_64-unknown-linux-musl --default-toolchain nightly --profile minimal -y 8 | 9 | WORKDIR /usr/src/app 10 | 11 | COPY . . 12 | 13 | RUN /root/.cargo/bin/cargo build --release --all-features 14 | 15 | FROM alpine:3.21.0 16 | 17 | RUN apk add --no-cache libgcc 18 | 19 | COPY --from=0 /usr/src/app/target/release/mirro-rs /bin/ 20 | 21 | ENTRYPOINT ["mirro-rs"] 22 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 kawaki-san 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 |
2 | GitHub Workflow Status 3 | Crates.io 4 | Crates.io 5 | msrv 6 |
7 |

mirro-rs

8 |

9 | A mirrorlist manager for Arch Linux systems 10 |
11 | View usage examples » 12 |
13 |
14 | Report Bug 15 | · 16 | Request Feature 17 |

18 | 19 | ![app](https://github.com/rtkay123/mirro-rs/assets/70331483/03acce81-6dd5-4fc8-b9a1-1709762d53b6) 20 | 21 |

mirro-rs provides a TUI to help you better visualise managing your mirrorlist.

22 | 23 | ## Features 24 | 25 | - Sorting 26 | - Completion - The number of mirror checks (as a percentage) that have successfully connected and disconnected from the given URL. If this is below 100%, the mirror may be unreliable. 27 | - Score - It is currently calculated as (hours delay + average duration + standard deviation) / completion percentage. _Lower is better_. 28 | - Standard deviation - The standard deviation of the connect and retrieval time. A high standard deviation can indicate an unstable or overloaded mirror. 29 | - Delay - The mean value of last check − last sync for each check of this mirror URL. Due to the timing of mirror checks, any value under one hour should be viewed as ideal. 30 | - Rate - sort by download speed 31 | - Filtering 32 | - Age 33 | - Country 34 | - ipv4, ipv6, isos 35 | - Protocol - `http`, `https`, `ftp` or `rsync` 36 | - Completion Percentage 37 | 38 | ## Getting Started 39 | 40 | ### Installation 41 | 42 | Install from the Arch Linux official repository: 43 | 44 | ```sh 45 | pacman -S mirro-rs 46 | ``` 47 | 48 | `mirro-rs` is also available in the AUR. If you're using `paru`: 49 | 50 | ```sh 51 | paru -S mirro-rs-git 52 | ``` 53 | 54 | > **Note** 55 | > By default, this enables [configuration](#configuration) through `toml` files. You should edit the `PKGBUILD` if you prefer another configuration format (or to disable configuration files altogether). 56 | 57 | ### Manual Compilation 58 | 59 | - cargo 60 | 61 | You need to have `cargo` installed to build the application. The easiest way to set this up is installing `rustup`. 62 | 63 | ```sh 64 | pacman -S rustup 65 | ``` 66 | 67 | Install a rust toolchain: 68 | 69 | ```sh 70 | rustup install stable 71 | ``` 72 | 73 | - git 74 | 75 | Clone the repository: 76 | 77 | ```sh 78 | git clone https://github.com/rtkay123/mirro-rs 79 | ``` 80 | 81 | You may then build the release target: 82 | 83 | ```sh 84 | cargo build --release 85 | ``` 86 | 87 | ### Usage 88 | 89 | Pass the `-h` or `--help` flag to mirro-rs to view configuration parameters. 90 | To preview `http` or `https` mirrors that were successfully synchronised in the last 24 hours and use `/home/user/mirrorlist` as an export location for the best (at max) 50: 91 | 92 | ```sh 93 | mirro-rs --export 50 --protocols https --protocols http --age 24 --outfile "/home/user/mirrorlist" 94 | ``` 95 | 96 | To do the same but restrict the sources to be from France and the UK: 97 | 98 | ```sh 99 | mirro-rs --export 50 --protocols https --protocols http --age 24 --outfile "/home/user/mirrorlist" -c France -c "United Kingdom" 100 | ``` 101 | 102 | #### Configuration 103 | 104 | For convenience, mirro-rs optionally supports reading a configuration `[default: $XDG_CONFIG_HOME/mirro-rs/mirro-rs.toml]` for general preferences. If none is available, `[default: $XDG_CONFIG_HOME/mirro-rs.toml]` will be used. If both are available, the former takes priority. 105 | 106 | For `toml` support: 107 | 108 | ```sh 109 | cargo build --release --features toml 110 | ``` 111 | 112 | For `json` support: 113 | 114 | ```sh 115 | cargo build --release --features json 116 | ``` 117 | 118 | Likewise, for `yaml` support: 119 | 120 | ```sh 121 | cargo build --release --features yaml 122 | ``` 123 | 124 | > **Note** 125 | > If you enable all configuration file features, if the configuration directory contains more than one valid file format, the order of priority goes from `toml` -> `json` -> `yaml`. 126 | 127 | Sample configuration files are provided in the [examples](examples) folder. 128 | 129 | A minimal `mirro-rs.toml` config file could look like: 130 | 131 | ```toml 132 | cache-ttl = 24 133 | timeout = 10 134 | ``` 135 | 136 | > **Note** 137 | > Changing the configuration file at runtime will overwrite the parameters that were set as CLI arguments 138 | 139 | ## License 140 | 141 | Licensed under either of 142 | 143 | - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://apache.org/licenses/LICENSE-2.0) 144 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 145 | 146 | ### Contribution 147 | 148 | Unless you explicitly state otherwise, any contribution intentionally submitted 149 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall 150 | be dual licensed as above, without any additional terms or conditions. 151 | -------------------------------------------------------------------------------- /crates/archlinux/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mirrors-arch" 3 | version = "0.1.3" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | authors = ["rtkay123 "] 7 | description = "An ArchLinux mirrorlist retriever used by mirro-rs" 8 | repository = "https://github.com/rtkay123/mirro-rs" 9 | homepage = "https://github.com/rtkay123/mirro-rs" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | chrono = { version = "0.4.38", features = ["serde"], optional = true } 15 | futures = "0.3.30" 16 | itertools.workspace = true 17 | log = "0.4.21" 18 | reqwest = { version = "0.12.4", default-features = false, features = ["json", "rustls-tls"] } 19 | serde = { workspace = true, features = ["derive"] } 20 | serde_json.workspace = true 21 | thiserror = "1.0.59" 22 | 23 | [dev-dependencies] 24 | tokio = { workspace = true, features = ["macros"] } 25 | 26 | [features] 27 | default = [] 28 | time = ["dep:chrono"] 29 | 30 | # docs.rs-specific configuration 31 | [package.metadata.docs.rs] 32 | # document all features 33 | all-features = true 34 | # defines the configuration attribute `docsrs` 35 | rustdoc-args = [ 36 | "--cfg", 37 | "docsrs" 38 | ] 39 | -------------------------------------------------------------------------------- /crates/archlinux/src/errors/mod.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | /// Error type definitions returned by the crate 6 | pub enum Error { 7 | /// The connection could not be made (perhaps a network error is 8 | /// the cause) 9 | #[error("could not establish connection")] 10 | Connection(#[from] reqwest::Error), 11 | /// The response could not be parsed to an internal type 12 | #[error("could not parse response")] 13 | Parse(#[from] serde_json::Error), 14 | /// The mirror could not be rated 15 | #[error("could not find file (expected {qualified_url:?}, from {url:?}), server returned {status_code:?}")] 16 | Rate { 17 | /// The URL including the filepath that was sent in the request 18 | qualified_url: String, 19 | /// The URL of the particular mirror 20 | url: String, 21 | /// The status code returned by the server 22 | status_code: StatusCode, 23 | }, 24 | #[error("could not build request {0}")] 25 | /// There was an error performing the request 26 | Request(String), 27 | /// There was an error performing the request 28 | #[cfg(feature = "time")] 29 | #[error("could not parse time")] 30 | TimeError(#[from] chrono::ParseError), 31 | } 32 | -------------------------------------------------------------------------------- /crates/archlinux/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | #![warn( 3 | missing_docs, 4 | rustdoc::broken_intra_doc_links, 5 | missing_debug_implementations 6 | )] 7 | 8 | //! # mirrors-arch 9 | use std::time::{Duration, Instant}; 10 | 11 | use futures::{future::BoxFuture, FutureExt}; 12 | use log::{info, trace}; 13 | use reqwest::{header::LOCATION, ClientBuilder, Response, StatusCode}; 14 | 15 | use crate::response::external::Root; 16 | 17 | #[cfg(test)] 18 | mod test; 19 | 20 | mod errors; 21 | pub use errors::Error; 22 | 23 | pub use reqwest::Client; 24 | 25 | mod response; 26 | #[cfg(feature = "time")] 27 | #[cfg_attr(docsrs, doc(cfg(feature = "time")))] 28 | #[doc(no_inline)] 29 | pub use chrono; 30 | 31 | pub use response::{external::Protocol, internal::*}; 32 | 33 | type Result = std::result::Result; 34 | 35 | pub(crate) const FILE_PATH: &str = "core/os/x86_64/core.db.tar.gz"; 36 | 37 | /// Get ArchLinux mirrors from an `json` endpoint and return them in a [minified](ArchLinux) format 38 | /// 39 | /// # Parameters 40 | /// 41 | /// - `source` - The URL to query for a mirrorlist 42 | /// - `with_timeout` - Connection timeout (in seconds) to be used in network requests 43 | /// 44 | /// # Example 45 | /// 46 | /// ```rust 47 | /// # use mirrors_arch::get_mirrors; 48 | /// # async fn foo()->Result<(), Box>{ 49 | /// let arch_mirrors = get_mirrors("https://archlinux.org/mirrors/status/json/", None).await?; 50 | /// println!("{arch_mirrors:?}"); 51 | /// # Ok(()) 52 | /// # } 53 | /// ``` 54 | pub async fn get_mirrors(source: &str, with_timeout: Option) -> Result { 55 | let response = get_response(source, with_timeout).await?; 56 | 57 | let root: Root = response.json().await?; 58 | 59 | let body = ArchLinux::from(root); 60 | let count = body.countries.len(); 61 | info!("located mirrors from {count} countries"); 62 | Ok(body) 63 | } 64 | 65 | async fn get_response(source: &str, with_timeout: Option) -> Result { 66 | trace!("creating http client"); 67 | let client = get_client(with_timeout)?; 68 | 69 | trace!("sending request"); 70 | let response = client.get(source).send().await?; 71 | 72 | Ok(response) 73 | } 74 | 75 | /// The same as [get_mirrors] but returns a tuple including the json as a 76 | /// `String` 77 | /// 78 | /// # Example 79 | /// 80 | /// ```rust 81 | /// # use mirrors_arch::get_mirrors_with_raw; 82 | /// # async fn foo()->Result<(), Box>{ 83 | /// let timeout = Some(10); 84 | /// let arch_mirrors = get_mirrors_with_raw("https://my-url.com/json/", timeout).await?; 85 | /// println!("{arch_mirrors:?}"); 86 | /// # Ok(()) 87 | /// # } 88 | /// ``` 89 | pub async fn get_mirrors_with_raw( 90 | source: &str, 91 | with_timeout: Option, 92 | ) -> Result<(ArchLinux, String)> { 93 | let response = get_response(source, with_timeout).await?; 94 | deserialise_mirrors(response).await 95 | } 96 | 97 | async fn deserialise_mirrors(response: Response) -> Result<(ArchLinux, String)> { 98 | let root: Root = response.json().await?; 99 | 100 | let value = serde_json::to_string(&root)?; 101 | Ok((ArchLinux::from(root), value)) 102 | } 103 | 104 | /// The same as [get_mirrors_with_raw] but uses a specified 105 | /// [Client] for requests 106 | pub async fn get_mirrors_with_client(source: &str, client: Client) -> Result<(ArchLinux, String)> { 107 | let response = client.get(source).send().await?; 108 | deserialise_mirrors(response).await 109 | } 110 | 111 | /// Parses a `string slice` to the [ArchLinux] type 112 | /// 113 | /// # Parameters 114 | /// - `contents` - A `json` string slice to be parsed and returned as a [mirrorlist](ArchLinux) 115 | /// 116 | /// # Example 117 | /// 118 | /// ```rust 119 | /// # use mirrors_arch::parse_local; 120 | /// # async fn foo()->Result<(), Box>{ 121 | /// let json = std::fs::read_to_string("archmirrors.json")?; 122 | /// let arch_mirrors = parse_local(&json)?; 123 | /// println!("{arch_mirrors:?}"); 124 | /// # Ok(()) 125 | /// # } 126 | /// ``` 127 | pub fn parse_local(contents: &str) -> Result { 128 | let vals = ArchLinux::from(serde_json::from_str::(contents)?); 129 | Ok(vals) 130 | } 131 | 132 | /// Gets a client that can be used to rate mirrors 133 | /// 134 | /// # Parameters 135 | /// - `with_timeout` - an optional connection timeout to be used when rating the mirrors 136 | /// 137 | /// # Example 138 | /// 139 | /// ```rust 140 | /// # use mirrors_arch::get_client; 141 | /// # async fn foo()->Result<(), Box>{ 142 | /// let timeout = Some(5); 143 | /// let client = get_client(timeout); 144 | /// # Ok(()) 145 | /// # } 146 | /// ``` 147 | pub fn get_client(with_timeout: Option) -> Result { 148 | let timeout = with_timeout.map(Duration::from_secs); 149 | 150 | let mut client_builder = ClientBuilder::new(); 151 | if let Some(timeout) = timeout { 152 | client_builder = client_builder.timeout(timeout).connect_timeout(timeout); 153 | } 154 | 155 | Ok(client_builder.build()?) 156 | } 157 | 158 | /// Queries a mirrorlist and calculates how long it took to get a response 159 | /// 160 | /// # Parameters 161 | /// - `url` - The mirrorlist 162 | /// - `client` - The client returned from [get_client] 163 | /// 164 | /// # Example 165 | /// 166 | /// ```rust 167 | /// # use mirrors_arch::{get_client, rate_mirror}; 168 | /// # async fn foo()->Result<(), Box>{ 169 | /// # let url = String::default(); 170 | /// # let client = get_client(Some(5))?; 171 | /// let (duration, url) = rate_mirror(url, client).await?; 172 | /// # Ok(()) 173 | /// # } 174 | /// ``` 175 | pub fn rate_mirror(url: String, client: Client) -> BoxFuture<'static, Result<(Duration, String)>> { 176 | async move { 177 | let uri = format!("{url}{FILE_PATH}"); 178 | 179 | let now = Instant::now(); 180 | 181 | let response = client.get(&uri).send().await?; 182 | 183 | if response.status() == StatusCode::OK { 184 | Ok((now.elapsed(), url)) 185 | } else if response.status() == StatusCode::MOVED_PERMANENTLY { 186 | if let Some(new_uri) = response.headers().get(LOCATION) { 187 | let new_url = String::from_utf8_lossy(new_uri.as_bytes()).replace(FILE_PATH, ""); 188 | rate_mirror(new_url.to_string(), client.clone()).await 189 | } else { 190 | Err(Error::Rate { 191 | qualified_url: uri, 192 | url, 193 | status_code: response.status(), 194 | }) 195 | } 196 | } else { 197 | Err(Error::Rate { 198 | qualified_url: uri, 199 | url, 200 | status_code: response.status(), 201 | }) 202 | } 203 | } 204 | .boxed() 205 | } 206 | 207 | /// Gets a mirror's last sync time 208 | /// # Parameters 209 | /// - `mirror` - The mirror to get the last sync time for 210 | /// - `client` - A [reqwest::Client] 211 | /// 212 | /// # Example 213 | /// 214 | /// ```rust 215 | /// # use mirrors_arch::{get_client, get_last_sync}; 216 | /// # async fn foo()->Result<(), Box>{ 217 | /// # let mirror = String::default(); 218 | /// # let client = get_client(Some(5))?; 219 | /// let (date_time, mirror) = get_last_sync(mirror, client).await?; 220 | /// # Ok(()) 221 | /// # } 222 | /// ``` 223 | 224 | #[cfg(feature = "time")] 225 | #[cfg_attr(docsrs, doc(cfg(feature = "time")))] 226 | pub async fn get_last_sync( 227 | mirror: impl Into, 228 | client: Client, 229 | ) -> Result<(chrono::DateTime, String)> { 230 | let mirror = mirror.into(); 231 | let lastsync_url = format!("{mirror}lastsync"); 232 | 233 | let timestamp = client 234 | .get(&lastsync_url) 235 | .send() 236 | .await 237 | .map_err(|e| Error::Request(e.to_string()))? 238 | .text() 239 | .await?; 240 | 241 | let result = chrono::NaiveDateTime::parse_from_str(×tamp, "%s") 242 | .map(|res| chrono::DateTime::::from_naive_utc_and_offset(res, chrono::Utc)) 243 | .map_err(Error::TimeError)?; 244 | 245 | Ok((result, mirror)) 246 | } 247 | -------------------------------------------------------------------------------- /crates/archlinux/src/response/external.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | #[cfg(feature = "time")] 4 | use chrono::{DateTime, Utc}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] 8 | pub(crate) struct Root { 9 | pub cutoff: u32, 10 | #[cfg(feature = "time")] 11 | pub last_check: DateTime, 12 | #[cfg(not(feature = "time"))] 13 | pub last_check: String, 14 | pub num_checks: u8, 15 | pub check_frequency: u16, 16 | pub urls: Vec, 17 | pub version: u8, 18 | } 19 | 20 | #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] 21 | pub(crate) struct Url { 22 | pub url: String, 23 | pub protocol: Protocol, 24 | #[cfg(feature = "time")] 25 | pub last_sync: Option>, 26 | #[cfg(not(feature = "time"))] 27 | pub last_sync: Option, 28 | pub completion_pct: f32, 29 | pub delay: Option, 30 | pub duration_avg: Option, 31 | pub duration_stddev: Option, 32 | pub score: Option, 33 | pub active: bool, 34 | pub country: String, 35 | pub country_code: String, 36 | pub isos: bool, 37 | pub ipv4: bool, 38 | pub ipv6: bool, 39 | pub details: String, 40 | } 41 | 42 | #[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] 43 | #[serde(rename_all = "lowercase")] 44 | /// Protocols serving the mirrors 45 | pub enum Protocol { 46 | /// rsync 47 | Rsync, 48 | /// http 49 | Http, 50 | /// https 51 | Https, 52 | /// ftp 53 | Ftp, 54 | } 55 | 56 | impl Display for Protocol { 57 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 58 | write!( 59 | f, 60 | "{}", 61 | match self { 62 | Protocol::Rsync => "rsync", 63 | Protocol::Http => "http", 64 | Protocol::Https => "https", 65 | Protocol::Ftp => "ftp", 66 | } 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /crates/archlinux/src/response/internal.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use log::debug; 3 | use serde::Deserialize; 4 | 5 | #[cfg(feature = "time")] 6 | use chrono::{DateTime, Utc}; 7 | 8 | use super::external::{Protocol, Root}; 9 | 10 | #[derive(Debug, Clone, PartialEq, Deserialize)] 11 | /// The type returned as the mirrorlist 12 | pub struct ArchLinux { 13 | /// Cutoff as returned by the server 14 | pub cutoff: u32, 15 | #[cfg(feature = "time")] 16 | /// Last successful check for mirrorlists 17 | pub last_check: DateTime, 18 | #[cfg(not(feature = "time"))] 19 | /// Last successful check for mirrorlists 20 | pub last_check: String, 21 | /// Number of checks as returned by the server 22 | pub num_checks: u8, 23 | /// Check frequency as returned by the server 24 | pub check_frequency: u16, 25 | /// A list of [countries](Country) that group [mirrors](Mirror) 26 | pub countries: Vec, 27 | /// Version number as returned by the server 28 | pub version: u8, 29 | } 30 | 31 | #[derive(Debug, Clone, PartialEq, Deserialize)] 32 | /// Holds a collection of mirrors 33 | pub struct Country { 34 | /// The string representation of the country name 35 | pub name: String, 36 | /// A short representation the the current country, i.e `ZA` for `South Africa` 37 | pub code: String, 38 | /// A list of [mirrors](Mirror) 39 | pub mirrors: Vec, 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Deserialize)] 43 | /// An ArchLinux mirror 44 | pub struct Mirror { 45 | /// The mirror's URL 46 | pub url: String, 47 | /// Represents a mirror's [protocol](Protocol) 48 | pub protocol: Protocol, 49 | /// The number of mirror checks that have successfully connected and disconnected from the given URL. If it's less than 100, it may be a sign of an unreliable mirror 50 | pub completion_pct: f32, 51 | /// The calculated average mirroring delay; e.g. the mean value of `last_check − last_sync` for each check of this mirror URL. Any value under one hour should be viewed as ideal. 52 | pub delay: Option, 53 | ///A very rough calculation for ranking mirrors. It is currently calculated as `(hours delay + average duration + standard deviation) / completion percentage`. Lower is better. 54 | pub score: Option, 55 | /// The standard deviation of the connect and retrieval time. A high standard deviation can indicate an unstable or overloaded mirror. 56 | pub duration_stddev: Option, 57 | /// Time when the last successful synchronisation occurred 58 | #[cfg(feature = "time")] 59 | pub last_sync: Option>, 60 | #[cfg(not(feature = "time"))] 61 | /// Time when the last successful synchronisation occurred 62 | pub last_sync: Option, 63 | /// ipv4 enabled 64 | pub ipv4: bool, 65 | /// ipv6 enabled 66 | pub ipv6: bool, 67 | /// isos enabled 68 | pub isos: bool, 69 | } 70 | 71 | impl From for ArchLinux { 72 | fn from(mut raw: Root) -> Self { 73 | debug!("minifying mirrors"); 74 | raw.urls.sort_by(|a, b| a.country.cmp(&b.country)); 75 | let countries = raw 76 | .urls 77 | .iter() 78 | .dedup_by(|a, b| a.country == b.country) 79 | .map(|f| f.country.to_string()) 80 | .collect_vec(); 81 | 82 | let mut output = Vec::with_capacity(countries.len()); 83 | let urls = &raw.urls; 84 | 85 | for i in countries.iter() { 86 | let mut code = String::default(); 87 | let mirrors = urls 88 | .iter() 89 | .filter_map(|f| { 90 | if f.country.eq_ignore_ascii_case(i) { 91 | code = f.country_code.clone(); 92 | Some(Mirror { 93 | url: f.url.clone(), 94 | protocol: f.protocol, 95 | completion_pct: f.completion_pct, 96 | delay: f.delay, 97 | score: f.score, 98 | duration_stddev: f.duration_stddev, 99 | #[cfg(feature = "time")] 100 | last_sync: f.last_sync, 101 | #[cfg(not(feature = "time"))] 102 | last_sync: f.last_sync.clone(), 103 | ipv4: f.ipv4, 104 | ipv6: f.ipv6, 105 | isos: f.isos, 106 | }) 107 | } else { 108 | None 109 | } 110 | }) 111 | .collect_vec(); 112 | let country = Country { 113 | name: i.to_string(), 114 | code, 115 | mirrors, 116 | }; 117 | output.push(country); 118 | } 119 | Self { 120 | cutoff: raw.cutoff, 121 | last_check: raw.last_check, 122 | num_checks: raw.num_checks, 123 | check_frequency: raw.check_frequency, 124 | countries: output, 125 | version: raw.version, 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /crates/archlinux/src/response/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod external; 2 | pub mod internal; 3 | -------------------------------------------------------------------------------- /crates/archlinux/src/test/mod.rs: -------------------------------------------------------------------------------- 1 | use reqwest::{Response, StatusCode}; 2 | 3 | use super::Result; 4 | 5 | use crate::{get_client, response::external::Root}; 6 | 7 | const ARCHLINUX_MIRRORS: &str = "https://archlinux.org/mirrors/status/json/"; 8 | const LOCAL_SOURCE: &str = include_str!("../../sample/archlinux.json"); 9 | 10 | async fn response() -> Result { 11 | let client = get_client(None)?; 12 | 13 | let response = client.get(ARCHLINUX_MIRRORS).send().await; 14 | 15 | Ok(response?) 16 | } 17 | 18 | #[tokio::test] 19 | async fn arch_mirrors_ok() -> Result<()> { 20 | assert!(response().await.is_ok()); 21 | assert_eq!(response().await?.status(), StatusCode::OK); 22 | Ok(()) 23 | } 24 | 25 | #[tokio::test] 26 | async fn archlinux_parse_body_remote() -> Result<()> { 27 | assert!(response().await.is_ok()); 28 | 29 | let root = response().await?.json::().await; 30 | 31 | assert!(root.is_ok()); 32 | 33 | Ok(()) 34 | } 35 | 36 | #[tokio::test] 37 | async fn archlinux_parse_body_local() -> Result<()> { 38 | assert!(serde_json::from_str::(LOCAL_SOURCE).is_ok()); 39 | Ok(()) 40 | } 41 | 42 | #[tokio::test] 43 | async fn check_mirrors() -> Result<()> { 44 | let mirrors = crate::get_mirrors(ARCHLINUX_MIRRORS, None); 45 | let response = crate::get_response(ARCHLINUX_MIRRORS, None); 46 | let (mirrors, response) = tokio::join!(mirrors, response); 47 | assert!(mirrors.is_ok()); 48 | assert!(response.is_ok()); 49 | Ok(()) 50 | } 51 | 52 | #[tokio::test] 53 | async fn check_mirrors_raw() -> Result<()> { 54 | let mirrors = crate::get_mirrors_with_raw(ARCHLINUX_MIRRORS, None).await; 55 | assert!(mirrors.is_ok()); 56 | Ok(()) 57 | } 58 | 59 | #[tokio::test] 60 | async fn check_local_parse() -> Result<()> { 61 | let json = include_str!("../../sample/archlinux.json"); 62 | 63 | let mirrors = crate::parse_local(json); 64 | assert!(mirrors.is_ok()); 65 | Ok(()) 66 | } 67 | 68 | #[tokio::test] 69 | #[cfg(feature = "time")] 70 | async fn check_last_sync() -> Result<()> { 71 | let client = get_client(None)?; 72 | let urls = [ 73 | "https://mirror.ufs.ac.za/archlinux/", 74 | "https://cloudflaremirrors.com/archlinux/", 75 | "https://mirror.lesviallon.fr/archlinux/", 76 | ]; 77 | 78 | let mut futures = Vec::with_capacity(urls.len()); 79 | 80 | for i in urls.iter() { 81 | let handle = tokio::spawn({ 82 | let client = client.clone(); 83 | let mirror = String::from(*i); 84 | async move { crate::get_last_sync(mirror, client.clone()).await } 85 | }); 86 | 87 | futures.push(handle); 88 | } 89 | 90 | let result = futures::future::try_join_all(futures).await; 91 | 92 | assert!(result.is_ok()); 93 | 94 | Ok(()) 95 | } 96 | 97 | #[tokio::test] 98 | #[cfg(feature = "time")] 99 | async fn rate_mirror() -> Result<()> { 100 | let client = get_client(None)?; 101 | let url = "https://mirror.ufs.ac.za/archlinux/"; 102 | 103 | let res = crate::rate_mirror(url.into(), client).await; 104 | assert!(res.is_ok()); 105 | Ok(()) 106 | } 107 | -------------------------------------------------------------------------------- /crates/mirro-rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mirro-rs" 3 | version = "0.2.3" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | description = "An ArchLinux mirrorlist manager with a TUI" 7 | authors = ["rtkay123 "] 8 | keywords = ["http", "tui", "linux"] 9 | categories = ["command-line-interface", "command-line-utilities"] 10 | repository = "https://github.com/rtkay123/mirro-rs" 11 | homepage = "https://github.com/rtkay123/mirro-rs" 12 | documentation = "https://github.com/rtkay123/mirro-rs" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | ahash = "0.8.11" # https://github.com/tkaitchuck/aHash/issues/200 18 | anyhow = "1.0.82" 19 | cfg-if = { version = "1.0.0", optional = true } 20 | clap = { version = "4.5.4", features = ["derive"] } 21 | crossterm = "0.28.0" 22 | dirs = "5.0.1" 23 | itertools.workspace = true 24 | archlinux = { package = "mirrors-arch", version = "0.1.3", path = "../archlinux", features = ["time"] } 25 | notify = { version = "7.0.0", optional = true } 26 | serde = { workspace = true, features = ["derive"] } 27 | serde_json = { workspace = true, optional = true } 28 | serde_yaml = { version = "0.9.34", optional = true } 29 | tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] } 30 | toml = { version = "0.8.12", optional = true } 31 | tui-logger = { version = "0.13.0", features = ["crossterm", "tracing-support"], default-features = false } 32 | unicode-width = "0.1.12" 33 | ratatui = { version = "0.28.0", features = ["crossterm"], default-features = false } 34 | tracing = "0.1.40" 35 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 36 | 37 | [target.'cfg(unix)'.dependencies] 38 | tracing-journald = "0.3.0" 39 | 40 | [features] 41 | default = [] 42 | json = ["dep:serde_json", "dep:notify", "dep:cfg-if"] 43 | yaml = ["dep:serde_yaml", "dep:notify", "dep:cfg-if"] 44 | toml = ["dep:toml", "dep:notify", "dep:cfg-if"] 45 | 46 | [dev-dependencies] 47 | toml = "0.8.12" 48 | 49 | [build-dependencies] 50 | clap = { version = "4.5.4", features = ["derive"] } 51 | clap_complete = "4.5.2" 52 | clap_mangen = "0.2.20" 53 | serde = { workspace = true, features = ["derive"] } 54 | -------------------------------------------------------------------------------- /crates/mirro-rs/build.rs: -------------------------------------------------------------------------------- 1 | use clap::CommandFactory; 2 | use clap_complete::{generate_to, Shell}; 3 | 4 | #[path = "src/cli/mod.rs"] 5 | mod cli; 6 | 7 | fn main() -> std::io::Result<()> { 8 | println!("cargo:rerun-if-changed=src/cli/mod.rs"); 9 | if let Some(outdir) = std::env::var_os("OUT_DIR") { 10 | let outdir = std::path::PathBuf::from(outdir); 11 | let man_dir = outdir.join("man"); 12 | std::fs::create_dir_all(&man_dir)?; 13 | 14 | let mut command = cli::ArgConfig::command(); 15 | 16 | let man = clap_mangen::Man::new(command.clone()); 17 | let mut buffer: Vec = Default::default(); 18 | man.render(&mut buffer)?; 19 | 20 | std::fs::write(man_dir.join("mirro-rs.1"), buffer)?; 21 | 22 | let completions_dir = outdir.join("completions"); 23 | std::fs::create_dir_all(&completions_dir)?; 24 | 25 | let crate_name = env!("CARGO_PKG_NAME"); 26 | 27 | generate_to(Shell::Zsh, &mut command, crate_name, &completions_dir)?; 28 | generate_to(Shell::Bash, &mut command, crate_name, &completions_dir)?; 29 | generate_to(Shell::Fish, &mut command, crate_name, &completions_dir)?; 30 | generate_to(Shell::Elvish, &mut command, crate_name, &completions_dir)?; 31 | } 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /crates/mirro-rs/completions/_mirro-rs: -------------------------------------------------------------------------------- 1 | #compdef mirro-rs 2 | 3 | autoload -U is-at-least 4 | 5 | _mirro-rs() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" \ 18 | '-o+[File to write mirrors to]:OUTFILE:_files' \ 19 | '--outfile=[File to write mirrors to]:OUTFILE:_files' \ 20 | '-e+[Number of mirrors to export \[default: 50\]]:EXPORT: ' \ 21 | '--export=[Number of mirrors to export \[default: 50\]]:EXPORT: ' \ 22 | '-v+[An order to view all countries]:VIEW:(alphabetical mirror-count)' \ 23 | '--view=[An order to view all countries]:VIEW:(alphabetical mirror-count)' \ 24 | '-s+[Default sort for exported mirrors]:SORT:(percentage delay duration score)' \ 25 | '--sort=[Default sort for exported mirrors]:SORT:(percentage delay duration score)' \ 26 | '-t+[Number of hours to cache mirrorlist for]:TTL: ' \ 27 | '--ttl=[Number of hours to cache mirrorlist for]:TTL: ' \ 28 | '-u+[URL to check for mirrors]:URL: ' \ 29 | '--url=[URL to check for mirrors]:URL: ' \ 30 | '--timeout=[Connection timeout in seconds]:TIMEOUT: ' \ 31 | '*-i+[Extra CDNs to check for mirrors]:INCLUDE: ' \ 32 | '*--include=[Extra CDNs to check for mirrors]:INCLUDE: ' \ 33 | '-a+[How old (in hours) should the mirrors be since last synchronisation]:AGE: ' \ 34 | '--age=[How old (in hours) should the mirrors be since last synchronisation]:AGE: ' \ 35 | '*-c+[Countries to search for mirrorlists]:COUNTRY: ' \ 36 | '*-p+[Filters to use on mirrorlists]:PROTOCOLS:(https http rsync)' \ 37 | '*--protocols=[Filters to use on mirrorlists]:PROTOCOLS:(https http rsync)' \ 38 | '--completion-percent=[Set the minimum completion percent for the returned mirrors]:COMPLETION_PERCENT: ' \ 39 | '-r[Sort mirrorlists by download speed when exporting]' \ 40 | '--rate[Sort mirrorlists by download speed when exporting]' \ 41 | '-d[Skip TUI session and directly export the mirrorlist]' \ 42 | '--direct[Skip TUI session and directly export the mirrorlist]' \ 43 | '--ipv4[Only return mirrors that support IPv4]' \ 44 | '--ipv6[Only return mirrors that support IPv6]' \ 45 | '--isos[Only return mirrors that host ISOs]' \ 46 | '-h[Print help information]' \ 47 | '--help[Print help information]' \ 48 | '-V[Print version information]' \ 49 | '--version[Print version information]' \ 50 | && ret=0 51 | } 52 | 53 | (( $+functions[_mirro-rs_commands] )) || 54 | _mirro-rs_commands() { 55 | local commands; commands=() 56 | _describe -t commands 'mirro-rs commands' commands "$@" 57 | } 58 | 59 | _mirro-rs "$@" 60 | -------------------------------------------------------------------------------- /crates/mirro-rs/completions/mirro-rs.bash: -------------------------------------------------------------------------------- 1 | _mirro-rs() { 2 | local i cur prev opts cmds 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | cmd="" 7 | opts="" 8 | 9 | for i in ${COMP_WORDS[@]} 10 | do 11 | case "${cmd},${i}" in 12 | ",$1") 13 | cmd="mirro__rs" 14 | ;; 15 | *) 16 | ;; 17 | esac 18 | done 19 | 20 | case "${cmd}" in 21 | mirro__rs) 22 | opts="-o -e -v -s -t -u -r -i -d -a -c -p -h -V --outfile --export --view --sort --ttl --url --rate --timeout --include --direct --age --protocols --ipv4 --ipv6 --isos --completion-percent --help --version" 23 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 24 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 25 | return 0 26 | fi 27 | case "${prev}" in 28 | --outfile) 29 | COMPREPLY=($(compgen -f "${cur}")) 30 | return 0 31 | ;; 32 | -o) 33 | COMPREPLY=($(compgen -f "${cur}")) 34 | return 0 35 | ;; 36 | --export) 37 | COMPREPLY=($(compgen -f "${cur}")) 38 | return 0 39 | ;; 40 | -e) 41 | COMPREPLY=($(compgen -f "${cur}")) 42 | return 0 43 | ;; 44 | --view) 45 | COMPREPLY=($(compgen -W "alphabetical mirror-count" -- "${cur}")) 46 | return 0 47 | ;; 48 | -v) 49 | COMPREPLY=($(compgen -W "alphabetical mirror-count" -- "${cur}")) 50 | return 0 51 | ;; 52 | --sort) 53 | COMPREPLY=($(compgen -W "percentage delay duration score" -- "${cur}")) 54 | return 0 55 | ;; 56 | -s) 57 | COMPREPLY=($(compgen -W "percentage delay duration score" -- "${cur}")) 58 | return 0 59 | ;; 60 | --ttl) 61 | COMPREPLY=($(compgen -f "${cur}")) 62 | return 0 63 | ;; 64 | -t) 65 | COMPREPLY=($(compgen -f "${cur}")) 66 | return 0 67 | ;; 68 | --url) 69 | COMPREPLY=($(compgen -f "${cur}")) 70 | return 0 71 | ;; 72 | -u) 73 | COMPREPLY=($(compgen -f "${cur}")) 74 | return 0 75 | ;; 76 | --timeout) 77 | COMPREPLY=($(compgen -f "${cur}")) 78 | return 0 79 | ;; 80 | --include) 81 | COMPREPLY=($(compgen -f "${cur}")) 82 | return 0 83 | ;; 84 | -i) 85 | COMPREPLY=($(compgen -f "${cur}")) 86 | return 0 87 | ;; 88 | --age) 89 | COMPREPLY=($(compgen -f "${cur}")) 90 | return 0 91 | ;; 92 | -a) 93 | COMPREPLY=($(compgen -f "${cur}")) 94 | return 0 95 | ;; 96 | -c) 97 | COMPREPLY=($(compgen -f "${cur}")) 98 | return 0 99 | ;; 100 | --protocols) 101 | COMPREPLY=($(compgen -W "https http rsync" -- "${cur}")) 102 | return 0 103 | ;; 104 | -p) 105 | COMPREPLY=($(compgen -W "https http rsync" -- "${cur}")) 106 | return 0 107 | ;; 108 | --completion-percent) 109 | COMPREPLY=($(compgen -f "${cur}")) 110 | return 0 111 | ;; 112 | *) 113 | COMPREPLY=() 114 | ;; 115 | esac 116 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 117 | return 0 118 | ;; 119 | esac 120 | } 121 | 122 | complete -F _mirro-rs -o bashdefault -o default mirro-rs 123 | -------------------------------------------------------------------------------- /crates/mirro-rs/completions/mirro-rs.elv: -------------------------------------------------------------------------------- 1 | 2 | use builtin; 3 | use str; 4 | 5 | set edit:completion:arg-completer[mirro-rs] = {|@words| 6 | fn spaces {|n| 7 | builtin:repeat $n ' ' | str:join '' 8 | } 9 | fn cand {|text desc| 10 | edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc 11 | } 12 | var command = 'mirro-rs' 13 | for word $words[1..-1] { 14 | if (str:has-prefix $word '-') { 15 | break 16 | } 17 | set command = $command';'$word 18 | } 19 | var completions = [ 20 | &'mirro-rs'= { 21 | cand -o 'File to write mirrors to' 22 | cand --outfile 'File to write mirrors to' 23 | cand -e 'Number of mirrors to export [default: 50]' 24 | cand --export 'Number of mirrors to export [default: 50]' 25 | cand -v 'An order to view all countries' 26 | cand --view 'An order to view all countries' 27 | cand -s 'Default sort for exported mirrors' 28 | cand --sort 'Default sort for exported mirrors' 29 | cand -t 'Number of hours to cache mirrorlist for' 30 | cand --ttl 'Number of hours to cache mirrorlist for' 31 | cand -u 'URL to check for mirrors' 32 | cand --url 'URL to check for mirrors' 33 | cand --timeout 'Connection timeout in seconds' 34 | cand -i 'Extra CDNs to check for mirrors' 35 | cand --include 'Extra CDNs to check for mirrors' 36 | cand -a 'How old (in hours) should the mirrors be since last synchronisation' 37 | cand --age 'How old (in hours) should the mirrors be since last synchronisation' 38 | cand -c 'Countries to search for mirrorlists' 39 | cand -p 'Filters to use on mirrorlists' 40 | cand --protocols 'Filters to use on mirrorlists' 41 | cand --completion-percent 'Set the minimum completion percent for the returned mirrors' 42 | cand -r 'Sort mirrorlists by download speed when exporting' 43 | cand --rate 'Sort mirrorlists by download speed when exporting' 44 | cand -d 'Skip TUI session and directly export the mirrorlist' 45 | cand --direct 'Skip TUI session and directly export the mirrorlist' 46 | cand --ipv4 'Only return mirrors that support IPv4' 47 | cand --ipv6 'Only return mirrors that support IPv6' 48 | cand --isos 'Only return mirrors that host ISOs' 49 | cand -h 'Print help information' 50 | cand --help 'Print help information' 51 | cand -V 'Print version information' 52 | cand --version 'Print version information' 53 | } 54 | ] 55 | $completions[$command] 56 | } 57 | -------------------------------------------------------------------------------- /crates/mirro-rs/completions/mirro-rs.fish: -------------------------------------------------------------------------------- 1 | complete -c mirro-rs -s o -l outfile -d 'File to write mirrors to' -r -F 2 | complete -c mirro-rs -s e -l export -d 'Number of mirrors to export [default: 50]' -r 3 | complete -c mirro-rs -s v -l view -d 'An order to view all countries' -r -f -a "{alphabetical ,mirror-count }" 4 | complete -c mirro-rs -s s -l sort -d 'Default sort for exported mirrors' -r -f -a "{percentage ,delay ,duration ,score }" 5 | complete -c mirro-rs -s t -l ttl -d 'Number of hours to cache mirrorlist for' -r 6 | complete -c mirro-rs -s u -l url -d 'URL to check for mirrors' -r 7 | complete -c mirro-rs -l timeout -d 'Connection timeout in seconds' -r 8 | complete -c mirro-rs -s i -l include -d 'Extra CDNs to check for mirrors' -r 9 | complete -c mirro-rs -s a -l age -d 'How old (in hours) should the mirrors be since last synchronisation' -r 10 | complete -c mirro-rs -s c -d 'Countries to search for mirrorlists' -r 11 | complete -c mirro-rs -s p -l protocols -d 'Filters to use on mirrorlists' -r -f -a "{https ,http ,rsync }" 12 | complete -c mirro-rs -l completion-percent -d 'Set the minimum completion percent for the returned mirrors' -r 13 | complete -c mirro-rs -s r -l rate -d 'Sort mirrorlists by download speed when exporting' 14 | complete -c mirro-rs -s d -l direct -d 'Skip TUI session and directly export the mirrorlist' 15 | complete -c mirro-rs -l ipv4 -d 'Only return mirrors that support IPv4' 16 | complete -c mirro-rs -l ipv6 -d 'Only return mirrors that support IPv6' 17 | complete -c mirro-rs -l isos -d 'Only return mirrors that host ISOs' 18 | complete -c mirro-rs -s h -l help -d 'Print help information' 19 | complete -c mirro-rs -s V -l version -d 'Print version information' 20 | -------------------------------------------------------------------------------- /crates/mirro-rs/man/mirro-rs.1: -------------------------------------------------------------------------------- 1 | .ie \n(.g .ds Aq \(aq 2 | .el .ds Aq ' 3 | .TH mirro-rs 1 "mirro-rs 0.1.0-alpha.1" 4 | .SH NAME 5 | mirro\-rs \- An ArchLinux mirrorlist manager with a TUI 6 | .SH SYNOPSIS 7 | \fBmirro\-rs\fR [\fB\-o\fR|\fB\-\-outfile\fR] [\fB\-e\fR|\fB\-\-export\fR] [\fB\-v\fR|\fB\-\-view\fR] [\fB\-s\fR|\fB\-\-sort\fR] [\fB\-t\fR|\fB\-\-ttl\fR] [\fB\-u\fR|\fB\-\-url\fR] [\fB\-r\fR|\fB\-\-rate\fR] [\fB\-\-timeout\fR] [\fB\-i\fR|\fB\-\-include\fR] [\fB\-d\fR|\fB\-\-direct\fR] [\fB\-a\fR|\fB\-\-age\fR] [\fB\-c \fR] [\fB\-p\fR|\fB\-\-protocols\fR] [\fB\-\-ipv4\fR] [\fB\-\-ipv6\fR] [\fB\-\-isos\fR] [\fB\-\-completion\-percent\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] 8 | .SH DESCRIPTION 9 | An ArchLinux mirrorlist manager with a TUI 10 | .SH OPTIONS 11 | .TP 12 | \fB\-o\fR, \fB\-\-outfile\fR=\fIOUTFILE\fR 13 | File to write mirrors to 14 | .TP 15 | \fB\-e\fR, \fB\-\-export\fR=\fIEXPORT\fR 16 | Number of mirrors to export [default: 50] 17 | .TP 18 | \fB\-v\fR, \fB\-\-view\fR=\fIVIEW\fR 19 | An order to view all countries 20 | .br 21 | 22 | .br 23 | [\fIpossible values: \fRalphabetical, mirror\-count] 24 | .TP 25 | \fB\-s\fR, \fB\-\-sort\fR=\fISORT\fR 26 | Default sort for exported mirrors 27 | .br 28 | 29 | .br 30 | [\fIpossible values: \fRpercentage, delay, duration, score] 31 | .TP 32 | \fB\-t\fR, \fB\-\-ttl\fR=\fITTL\fR 33 | Number of hours to cache mirrorlist for 34 | .TP 35 | \fB\-u\fR, \fB\-\-url\fR=\fIURL\fR 36 | URL to check for mirrors 37 | .TP 38 | \fB\-r\fR, \fB\-\-rate\fR=\fIRATE\fR 39 | Sort mirrorlists by download speed when exporting 40 | .TP 41 | \fB\-\-timeout\fR=\fITIMEOUT\fR 42 | Connection timeout in seconds 43 | .TP 44 | \fB\-i\fR, \fB\-\-include\fR=\fIINCLUDE\fR 45 | Extra CDNs to check for mirrors 46 | .TP 47 | \fB\-d\fR, \fB\-\-direct\fR=\fIDIRECT\fR 48 | Skip TUI session and directly export the mirrorlist 49 | .TP 50 | \fB\-a\fR, \fB\-\-age\fR=\fIAGE\fR 51 | How old (in hours) should the mirrors be since last synchronisation 52 | .TP 53 | \fB\-c\fR=\fICOUNTRY\fR 54 | Countries to search for mirrorlists 55 | .TP 56 | \fB\-p\fR, \fB\-\-protocols\fR=\fIPROTOCOLS\fR 57 | Filters to use on mirrorlists 58 | .br 59 | 60 | .br 61 | [\fIpossible values: \fRhttps, http, rsync] 62 | .TP 63 | \fB\-\-ipv4\fR=\fIIPV4\fR 64 | Only return mirrors that support IPv4 65 | .TP 66 | \fB\-\-ipv6\fR=\fIIPV6\fR 67 | Only return mirrors that support IPv6 68 | .TP 69 | \fB\-\-isos\fR=\fIISOS\fR 70 | Only return mirrors that host ISOs 71 | .TP 72 | \fB\-\-completion\-percent\fR=\fICOMPLETION_PERCENT\fR 73 | Set the minimum completion percent for the returned mirrors 74 | .TP 75 | \fB\-h\fR, \fB\-\-help\fR 76 | Print help information 77 | .TP 78 | \fB\-V\fR, \fB\-\-version\fR 79 | Print version information 80 | .SH VERSION 81 | v0.1.0\-alpha.1 82 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{Parser, ValueEnum}; 4 | use serde::Deserialize; 5 | 6 | pub const DEFAULT_MIRROR_COUNT: u16 = 50; 7 | pub const DEFAULT_CACHE_TTL: u16 = 24; 8 | pub const ARCH_URL: &str = "https://archlinux.org/mirrors/status/json/"; 9 | 10 | #[cfg_attr(test, derive(Default))] 11 | #[derive(Parser, Debug, Deserialize)] 12 | #[command(author, version, about, long_about = None)] 13 | pub struct ArgConfig { 14 | #[command(flatten)] 15 | pub general: Args, 16 | #[command(flatten)] 17 | pub filters: Filters, 18 | } 19 | 20 | #[cfg_attr(test, derive(Default))] 21 | #[derive(clap::Args, Debug, Deserialize)] 22 | #[command(author, version, about, long_about = None)] 23 | pub struct Args { 24 | /// File to write mirrors to 25 | #[arg(short, long)] 26 | pub outfile: Option, 27 | 28 | /// Number of mirrors to export [default: 50] 29 | #[arg(short, long)] 30 | #[serde(default = "default_export")] 31 | pub export: Option, 32 | 33 | /// An order to view all countries 34 | #[arg(short, long, value_enum)] 35 | #[serde(default = "view")] 36 | pub view: Option, 37 | 38 | /// Default sort for exported mirrors 39 | #[arg(short, long, value_enum)] 40 | #[serde(default = "sort")] 41 | pub sort: Option, 42 | 43 | /// Number of hours to cache mirrorlist for 44 | #[arg(short, long)] 45 | #[serde(rename = "cache-ttl")] 46 | #[serde(default = "default_ttl")] 47 | pub ttl: Option, 48 | 49 | /// URL to check for mirrors 50 | #[arg(short, long)] 51 | #[serde(default = "url")] 52 | pub url: Option, 53 | 54 | /// Specify alternate configuration file 55 | #[arg(long)] 56 | #[serde(skip)] 57 | #[cfg(any(feature = "toml", feature = "yaml", feature = "json"))] 58 | pub config: Option, 59 | 60 | /// Sort mirrorlists by download speed when exporting 61 | #[arg(short, long)] 62 | #[serde(default, rename = "rate-speed")] 63 | pub rate: bool, 64 | 65 | /// Connection timeout in seconds 66 | #[arg(long = "timeout")] 67 | pub timeout: Option, 68 | 69 | /// Extra CDNs to check for mirrors 70 | #[arg(short, long)] 71 | pub include: Option>, 72 | 73 | /// Skip TUI session and directly export the mirrorlist 74 | #[arg(short, long)] 75 | #[serde(default)] 76 | pub direct: bool, 77 | } 78 | 79 | #[cfg_attr(test, derive(Default))] 80 | #[derive(clap::Args, Debug, Clone, Eq, PartialEq, Deserialize)] 81 | pub struct Filters { 82 | /// How old (in hours) should the mirrors be since last synchronisation 83 | #[arg(long, short)] 84 | pub age: Option, 85 | 86 | /// Countries to search for mirrorlists 87 | #[arg(short)] 88 | #[serde(rename = "countries")] 89 | #[serde(default)] 90 | pub country: Option>, 91 | 92 | /// Filters to use on mirrorlists 93 | #[arg(short, long, value_enum)] 94 | #[serde(default = "filters")] 95 | pub protocols: Option>, 96 | 97 | ///Only return mirrors that support IPv4. 98 | #[arg(long)] 99 | #[serde(default = "enable")] 100 | pub ipv4: bool, 101 | ///Only return mirrors that support IPv6. 102 | #[arg(long)] 103 | #[serde(default = "enable")] 104 | pub ipv6: bool, 105 | /// Only return mirrors that host ISOs. 106 | #[arg(long)] 107 | #[serde(default = "enable")] 108 | pub isos: bool, 109 | 110 | /// Set the minimum completion percent for the returned mirrors. 111 | #[arg(long)] 112 | #[serde(default = "completion", rename = "completion-percent")] 113 | pub completion_percent: Option, 114 | } 115 | 116 | #[derive(Default, Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, ValueEnum, Deserialize)] 117 | #[serde(rename_all = "lowercase")] 118 | pub enum SelectionSort { 119 | Percentage, 120 | Delay, 121 | Duration, 122 | #[default] 123 | Score, 124 | } 125 | 126 | fn enable() -> bool { 127 | true 128 | } 129 | 130 | fn completion() -> Option { 131 | Some(100) 132 | } 133 | 134 | fn url() -> Option { 135 | Some(ARCH_URL.to_string()) 136 | } 137 | 138 | fn default_ttl() -> Option { 139 | Some(DEFAULT_CACHE_TTL) 140 | } 141 | 142 | fn default_export() -> Option { 143 | Some(DEFAULT_MIRROR_COUNT) 144 | } 145 | 146 | fn sort() -> Option { 147 | Some(SelectionSort::Score) 148 | } 149 | 150 | fn view() -> Option { 151 | Some(ViewSort::Alphabetical) 152 | } 153 | 154 | fn filters() -> Option> { 155 | Some(vec![Protocol::Http, Protocol::Https]) 156 | } 157 | 158 | #[cfg_attr(test, derive(Default))] 159 | #[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, ValueEnum, Deserialize)] 160 | #[serde(rename_all = "lowercase")] 161 | pub enum Protocol { 162 | #[cfg_attr(test, default)] 163 | Https, 164 | Http, 165 | Rsync, 166 | Ftp, 167 | #[value(skip)] 168 | InSync, 169 | #[value(skip)] 170 | Ipv4, 171 | #[value(skip)] 172 | Ipv6, 173 | #[value(skip)] 174 | Isos, 175 | } 176 | 177 | #[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, ValueEnum, Deserialize, Default)] 178 | #[serde(rename_all = "lowercase")] 179 | pub enum ViewSort { 180 | #[default] 181 | Alphabetical, 182 | MirrorCount, 183 | } 184 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/config/file.rs: -------------------------------------------------------------------------------- 1 | use std::{io::ErrorKind, path::PathBuf}; 2 | 3 | use std::path::Path; 4 | 5 | use itertools::Itertools; 6 | use tracing::error; 7 | 8 | use crate::cli::ArgConfig; 9 | 10 | cfg_if::cfg_if! { 11 | if #[cfg(feature = "toml")] { 12 | fn default_args()->ArgConfig { 13 | let config_str = include_str!("../../../../examples/mirro-rs.toml"); 14 | toml::from_str(config_str).unwrap() 15 | } 16 | } else if #[cfg(feature = "yaml")] { 17 | fn default_args()->ArgConfig { 18 | let config_str = include_str!("../../../../examples/mirro-rs.yaml"); 19 | serde_yaml::from_str(config_str).unwrap() 20 | } 21 | } else { 22 | fn default_args()->ArgConfig { 23 | let config_str = include_str!("../../../../examples/mirro-rs.json"); 24 | serde_json::from_str(config_str).unwrap() 25 | } 26 | } 27 | } 28 | 29 | pub fn read_config_file(file: Option>) -> (ArgConfig, Option) { 30 | let config_file = if let Some(ref file) = file { 31 | let buf = file.as_ref().to_path_buf(); 32 | Some(check_file(&buf, None)) 33 | } else { 34 | dirs::config_dir().map(|dir| get_config(dir, &extensions())) 35 | }; 36 | match config_file { 37 | Some(Some(opts)) => opts, 38 | _ => (default_args(), None), 39 | } 40 | } 41 | 42 | fn check_file(file: &PathBuf, backup: Option<&PathBuf>) -> Option<(ArgConfig, Option)> { 43 | let err = |e| { 44 | error!("{e}"); 45 | }; 46 | 47 | let call_backup = |backup: Option<&PathBuf>| { 48 | if let Some(backup) = backup { 49 | check_file(backup, None) 50 | } else { 51 | None 52 | } 53 | }; 54 | 55 | let f = std::fs::read_to_string(file); 56 | 57 | let err_type = || -> Result { 58 | let ext = String::from_iter(extensions()); 59 | Err(format!("unsupported file extension: file must be: {ext}")) 60 | }; 61 | 62 | match f { 63 | #[allow(unused_variables)] 64 | Ok(contents) => { 65 | let result = if let Some(ext) = file.extension() { 66 | match ext.to_string_lossy().to_string().as_str() { 67 | #[cfg(feature = "toml")] 68 | "toml" => toml::from_str::(&contents).map_err(|e| e.to_string()), 69 | #[cfg(feature = "json")] 70 | "json" => { 71 | serde_json::from_str::(&contents).map_err(|e| e.to_string()) 72 | } 73 | #[cfg(feature = "yaml")] 74 | "yaml" | "yml" => { 75 | serde_yaml::from_str::(&contents).map_err(|e| e.to_string()) 76 | } 77 | _ => err_type(), 78 | } 79 | } else { 80 | err_type() 81 | }; 82 | 83 | match result { 84 | Ok(e) => Some((e, Some(file.to_owned()))), 85 | Err(e) => { 86 | err(format!("config: {} -> {}", file.display(), e)); 87 | call_backup(backup) 88 | } 89 | } 90 | } 91 | Err(e) => { 92 | if e.kind() != ErrorKind::NotFound { 93 | err(format!("config: {} -> {}", file.display(), e)); 94 | } 95 | call_backup(backup) 96 | } 97 | } 98 | } 99 | 100 | fn extensions() -> Vec { 101 | let valid_extensions = vec![ 102 | #[cfg(feature = "toml")] 103 | "toml", 104 | #[cfg(feature = "json")] 105 | "json", 106 | #[cfg(feature = "yaml")] 107 | "yaml", 108 | #[cfg(feature = "yaml")] 109 | "yml", 110 | ]; 111 | 112 | valid_extensions.into_iter().map(String::from).collect_vec() 113 | } 114 | 115 | fn get_config(mut dir: PathBuf, extension: &[String]) -> Option<(ArgConfig, Option)> { 116 | let crate_name = env!("CARGO_PKG_NAME"); 117 | let location = PathBuf::from(crate_name); 118 | let mut file = PathBuf::from(crate_name); 119 | let mut result: Option<(ArgConfig, Option)> = None; 120 | for i in extension.iter() { 121 | let mut inner_location = location.clone(); 122 | file.set_extension(i); 123 | inner_location.push(file.clone()); 124 | 125 | let mut alt = dir.clone(); 126 | dir.push(inner_location); 127 | alt.push(file.clone()); 128 | let interim = check_file(&dir, Some(&alt)); 129 | if interim.is_some() { 130 | result = interim; 131 | break; 132 | } 133 | } 134 | result 135 | } 136 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 2 | mod file; 3 | 4 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 5 | mod watch; 6 | 7 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 8 | pub use watch::watch_config; 9 | 10 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 11 | pub use file::read_config_file; 12 | 13 | use std::path::PathBuf; 14 | 15 | use crate::{ 16 | cli::{self, ArgConfig, Protocol, SelectionSort, ViewSort}, 17 | tui::view::sort::ExportSort, 18 | }; 19 | 20 | #[cfg_attr(test, derive(Default))] 21 | #[derive(Debug)] 22 | pub struct Configuration { 23 | pub outfile: PathBuf, 24 | pub export: u16, 25 | pub filters: Vec, 26 | pub view: ViewSort, 27 | pub sort: ExportSort, 28 | pub country: Vec, 29 | pub ttl: u16, 30 | pub url: String, 31 | pub completion_percent: u8, 32 | pub age: u16, 33 | pub rate: bool, 34 | pub connection_timeout: Option, 35 | pub include: Option>, 36 | pub direct: bool, 37 | } 38 | 39 | impl Configuration { 40 | #[allow(clippy::too_many_arguments)] 41 | pub fn new( 42 | outfile: PathBuf, 43 | export: u16, 44 | mut filters: Vec, 45 | view: ViewSort, 46 | sort: SelectionSort, 47 | country: Vec, 48 | ttl: u16, 49 | url: String, 50 | ipv4: bool, 51 | isos: bool, 52 | ipv6: bool, 53 | completion_percent: u8, 54 | age: u16, 55 | rate: bool, 56 | connection_timeout: Option, 57 | include: Option>, 58 | direct: bool, 59 | ) -> Self { 60 | if ipv4 { 61 | filters.push(Protocol::Ipv4) 62 | } 63 | if ipv6 { 64 | filters.push(Protocol::Ipv6) 65 | } 66 | if isos { 67 | filters.push(Protocol::Isos) 68 | } 69 | Self { 70 | outfile, 71 | export, 72 | filters, 73 | view, 74 | sort: match sort { 75 | SelectionSort::Percentage => ExportSort::Completion, 76 | SelectionSort::Delay => ExportSort::MirroringDelay, 77 | SelectionSort::Duration => ExportSort::Duration, 78 | SelectionSort::Score => ExportSort::Score, 79 | }, 80 | country, 81 | ttl, 82 | url, 83 | completion_percent, 84 | age, 85 | rate, 86 | connection_timeout, 87 | include, 88 | direct, 89 | } 90 | } 91 | } 92 | 93 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 94 | fn get_bools(args: &cli::Filters, config: &cli::Filters) -> (bool, bool, bool) { 95 | let ipv4 = if !args.ipv4 && config.ipv4 { 96 | true 97 | } else { 98 | args.ipv4 99 | }; 100 | 101 | let ipv6 = if !args.ipv6 && config.ipv6 { 102 | true 103 | } else { 104 | args.ipv6 105 | }; 106 | 107 | let isos = if !args.isos && config.isos { 108 | true 109 | } else { 110 | args.isos 111 | }; 112 | 113 | (ipv4, ipv6, isos) 114 | } 115 | 116 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 117 | impl From<(ArgConfig, ArgConfig)> for Configuration { 118 | fn from((mut args, mut config): (ArgConfig, ArgConfig)) -> Self { 119 | let (ipv4, isos, ipv6) = get_bools(&args.filters, &config.filters); 120 | let outfile = args 121 | .general 122 | .outfile 123 | .unwrap_or_else(|| config.general.outfile.unwrap()); 124 | let export = args 125 | .general 126 | .export 127 | .unwrap_or_else(|| config.general.export.unwrap()); 128 | let filters = args 129 | .filters 130 | .protocols 131 | .unwrap_or_else(|| config.filters.protocols.unwrap()); 132 | let view = args 133 | .general 134 | .view 135 | .unwrap_or_else(|| config.general.view.unwrap()); 136 | let sort = args 137 | .general 138 | .sort 139 | .unwrap_or_else(|| config.general.sort.unwrap()); 140 | let countries = args 141 | .filters 142 | .country 143 | .unwrap_or_else(|| config.filters.country.unwrap()); 144 | let ttl = args 145 | .general 146 | .ttl 147 | .unwrap_or_else(|| config.general.ttl.unwrap()); 148 | let url = args 149 | .general 150 | .url 151 | .unwrap_or_else(|| config.general.url.unwrap()); 152 | 153 | let completion = args 154 | .filters 155 | .completion_percent 156 | .unwrap_or_else(|| config.filters.completion_percent.unwrap()); 157 | 158 | let age = args 159 | .filters 160 | .age 161 | .unwrap_or_else(|| config.filters.age.unwrap_or_default()); 162 | 163 | let rate = if !args.general.rate && config.general.rate { 164 | true 165 | } else { 166 | args.general.rate 167 | }; 168 | 169 | let timoeut = if args.general.timeout.is_none() && config.general.timeout.is_some() { 170 | config.general.timeout 171 | } else { 172 | args.general.timeout 173 | }; 174 | 175 | let include = if args.general.include.is_none() && config.general.include.is_some() { 176 | std::mem::take(&mut config.general.include) 177 | } else { 178 | std::mem::take(&mut args.general.include) 179 | }; 180 | let direct = if !args.general.direct && config.general.direct { 181 | true 182 | } else { 183 | args.general.direct 184 | }; 185 | 186 | Self::new( 187 | outfile, export, filters, view, sort, countries, ttl, url, ipv4, isos, ipv6, 188 | completion, age, rate, timoeut, include, direct, 189 | ) 190 | } 191 | } 192 | 193 | #[cfg(any(test, not(any(feature = "json", feature = "toml", feature = "yaml"))))] 194 | impl From for Configuration { 195 | fn from(args: ArgConfig) -> Self { 196 | let outfile = args 197 | .general 198 | .outfile 199 | .or_else(|| crate::exit("outfile")) 200 | .unwrap(); 201 | let export = args.general.export.unwrap_or(cli::DEFAULT_MIRROR_COUNT); 202 | let filters = args 203 | .filters 204 | .protocols 205 | .unwrap_or_else(|| vec![Protocol::Http, Protocol::Https]); 206 | let view = args.general.view.unwrap_or_default(); 207 | let sort = args.general.sort.unwrap_or_default(); 208 | let countries = args.filters.country.unwrap_or_default(); 209 | let ttl = args.general.ttl.unwrap_or(cli::DEFAULT_CACHE_TTL); 210 | let url = args 211 | .general 212 | .url 213 | .unwrap_or_else(|| cli::ARCH_URL.to_string()); 214 | 215 | let completion = args.filters.completion_percent.unwrap_or(100); 216 | 217 | let age = args.filters.age.unwrap_or(0); 218 | let rate = args.general.rate; 219 | let timeout = args.general.timeout; 220 | let include = args.general.include; 221 | 222 | Self::new( 223 | outfile, 224 | export, 225 | filters, 226 | view, 227 | sort, 228 | countries, 229 | ttl, 230 | url, 231 | args.filters.ipv4, 232 | args.filters.isos, 233 | args.filters.ipv6, 234 | completion, 235 | age, 236 | rate, 237 | timeout, 238 | include, 239 | args.general.direct, 240 | ) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/config/watch.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Debug, 3 | path::Path, 4 | path::PathBuf, 5 | sync::{mpsc, Arc, Mutex}, 6 | }; 7 | 8 | use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; 9 | use tracing::error; 10 | 11 | use crate::config::read_config_file; 12 | 13 | use super::Configuration; 14 | 15 | pub fn watch_config(path: Option, configuration: Arc>) { 16 | if let Some(dir) = dirs::config_dir() { 17 | if let Some(path) = path { 18 | tokio::task::spawn_blocking(move || { 19 | if let Err(e) = async_watch(path, dir, configuration) { 20 | error!("error: {:?}", e) 21 | } 22 | }); 23 | } 24 | } 25 | } 26 | 27 | fn async_watcher() -> notify::Result<(RecommendedWatcher, mpsc::Receiver>)> { 28 | let (tx, rx) = mpsc::channel(); 29 | 30 | // Automatically select the best implementation for your platform. 31 | // You can also access each implementation directly e.g. INotifyWatcher. 32 | let watcher = RecommendedWatcher::new( 33 | move |res| { 34 | let _ = tx.send(res); 35 | }, 36 | Config::default(), 37 | )?; 38 | 39 | Ok((watcher, rx)) 40 | } 41 | 42 | fn async_watch( 43 | path: impl AsRef + Debug, 44 | dir: impl AsRef + Debug, 45 | config: Arc>, 46 | ) -> notify::Result<()> { 47 | let (mut watcher, rx) = async_watcher()?; 48 | 49 | // Add a path to be watched. All files and directories at that path and 50 | // below will be monitored for changes. 51 | 52 | watcher.watch(dir.as_ref(), RecursiveMode::Recursive)?; 53 | 54 | while let Ok(res) = rx.recv() { 55 | match res { 56 | Ok(event) => { 57 | if event 58 | .paths 59 | .iter() 60 | .any(|f| f.file_name() == path.as_ref().file_name()) 61 | { 62 | let (config_file, _) = read_config_file(Some(path.as_ref().to_path_buf())); 63 | let parsed_config = Configuration::new( 64 | config_file.general.outfile.unwrap(), 65 | config_file.general.export.unwrap(), 66 | config_file.filters.protocols.unwrap(), 67 | config_file.general.view.unwrap(), 68 | config_file.general.sort.unwrap(), 69 | config_file.filters.country.unwrap(), 70 | config_file.general.ttl.unwrap(), 71 | config_file.general.url.unwrap(), 72 | config_file.filters.ipv4, 73 | config_file.filters.isos, 74 | config_file.filters.ipv6, 75 | config_file.filters.completion_percent.unwrap(), 76 | config_file.filters.age.unwrap_or_default(), 77 | config_file.general.rate, 78 | config_file.general.timeout, 79 | config_file.general.include, 80 | config_file.general.direct, 81 | ); 82 | 83 | let mut new_config = config.lock().unwrap(); 84 | *new_config = parsed_config; 85 | } 86 | } 87 | Err(e) => error!("watch error: {:?}", e), 88 | } 89 | } 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/dbg/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::Error; 2 | 3 | use tracing::info; 4 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 5 | 6 | pub fn log(skip_tui: bool) { 7 | let registry = tracing_subscriber::registry().with( 8 | tracing_subscriber::EnvFilter::try_from_default_env() 9 | .unwrap_or_else(|_| "mirro_rs=debug".into()), 10 | ); 11 | 12 | let err_fn = |e: Error| { 13 | #[cfg(unix)] 14 | tracing::error!("couldn't connect to journald: {}", e); 15 | }; 16 | 17 | #[cfg(unix)] 18 | match (tracing_journald::layer(), skip_tui) { 19 | (Ok(layer), true) => { 20 | registry 21 | .with(layer) 22 | .with(tracing_subscriber::fmt::layer()) 23 | .init(); 24 | } 25 | // journald is typically available on Linux systems, but nowhere else. Portable software 26 | // should handle its absence gracefully. 27 | (Err(e), true) => { 28 | registry.with(tracing_subscriber::fmt::layer()).init(); 29 | err_fn(e); 30 | } 31 | (Ok(layer), false) => { 32 | registry 33 | .with(layer) 34 | .with(tui_logger::tracing_subscriber_layer()) 35 | .init(); 36 | } 37 | (Err(e), false) => { 38 | registry.with(tui_logger::tracing_subscriber_layer()).init(); 39 | err_fn(e); 40 | } 41 | } 42 | 43 | #[cfg(not(unix))] 44 | if skip_tui { 45 | registry.with(tracing_subscriber::fmt::layer()).init(); 46 | } else { 47 | registry.with(tui_logger::tracing_subscriber_layer()).init(); 48 | } 49 | 50 | let pkg_ver = env!("CARGO_PKG_VERSION"); 51 | info!(version = pkg_ver, "mirro-rs has started"); 52 | } 53 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/direct/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | use anyhow::{bail, Context, Result}; 4 | use archlinux::{ 5 | chrono::{DateTime, Local}, 6 | get_client, ArchLinux, Mirror, 7 | }; 8 | use itertools::Itertools; 9 | use tracing::error; 10 | 11 | use crate::{ 12 | cli::Protocol, 13 | config::Configuration, 14 | tui::io::{self, handler::IoAsyncHandler}, 15 | }; 16 | 17 | pub async fn begin(configuration: Configuration) -> Result<()> { 18 | let included = configuration.include.clone(); 19 | let connection_timeout = configuration.connection_timeout; 20 | let rate = configuration.rate; 21 | let outfile = configuration.outfile.clone(); 22 | let export_count = configuration.export; 23 | 24 | let config = Arc::new(Mutex::new(configuration)); 25 | let (is_fresh, cache_file) = io::handler::is_fresh(Arc::clone(&config)).await; 26 | let mirrorlist = if is_fresh { 27 | match tokio::fs::read_to_string(cache_file.as_ref().unwrap()).await { 28 | Ok(contents) => { 29 | let result = archlinux::parse_local(&contents); 30 | match result { 31 | Ok(mirrors) => mirrors, 32 | Err(e) => { 33 | error!("{e}"); 34 | get_new_mirrors(Arc::clone(&config), cache_file.as_ref()).await? 35 | } 36 | } 37 | } 38 | Err(e) => { 39 | error!("{e}"); 40 | get_new_mirrors(Arc::clone(&config), cache_file.as_ref()).await? 41 | } 42 | } 43 | } else { 44 | get_new_mirrors(Arc::clone(&config), cache_file.as_ref()).await? 45 | }; 46 | 47 | let mut results = mirrorlist 48 | .countries 49 | .iter() 50 | .filter_map(|f| { 51 | let results = f 52 | .mirrors 53 | .iter() 54 | .filter(|f| filter_result(f, Arc::clone(&config))) 55 | .filter(|_| { 56 | let conf = config.lock().unwrap(); 57 | 58 | if conf.country.is_empty() { 59 | true 60 | } else { 61 | conf.country.iter().any(|b| b.eq_ignore_ascii_case(&f.name)) 62 | } 63 | }) 64 | .collect_vec(); 65 | if results.is_empty() { 66 | None 67 | } else { 68 | Some(results) 69 | } 70 | }) 71 | .flatten() 72 | .map(|f| f.url.clone()) 73 | .collect_vec(); 74 | 75 | if let Some(mut included) = included { 76 | results.append(&mut included); 77 | } 78 | 79 | let client = get_client(connection_timeout)?; 80 | 81 | if rate { 82 | if let Err(e) = IoAsyncHandler::rate_mirrors( 83 | results, 84 | None, 85 | None, 86 | outfile, 87 | export_count.into(), 88 | None, 89 | client, 90 | ) 91 | .await 92 | .await 93 | { 94 | error!("{e}"); 95 | } 96 | } else { 97 | IoAsyncHandler::write_to_file(outfile, &results, export_count as usize, None, None).await; 98 | } 99 | 100 | Ok(()) 101 | } 102 | 103 | async fn get_new_mirrors( 104 | config: Arc>, 105 | cache_file: Option<&std::path::PathBuf>, 106 | ) -> Result { 107 | let (url, timeout) = { 108 | let config = config.lock().unwrap(); 109 | (config.url.clone(), config.connection_timeout) 110 | }; 111 | 112 | match archlinux::get_mirrors_with_raw(&url, timeout).await { 113 | Ok((resp, str_value)) => { 114 | if let Some(cache) = cache_file { 115 | if let Err(e) = tokio::fs::write(cache, str_value).await { 116 | error!("{e}"); 117 | } 118 | } 119 | Ok(resp) 120 | } 121 | Err(e) => { 122 | error!("{e}"); 123 | if let Some(f) = cache_file { 124 | tokio::fs::read_to_string(f) 125 | .await 126 | .ok() 127 | .and_then(|contents| archlinux::parse_local(&contents).ok()) 128 | .context("could not read cache file") 129 | } else { 130 | bail!("No cache file was configured") 131 | } 132 | } 133 | } 134 | } 135 | 136 | pub fn filter_result(f: &Mirror, configuration: Arc>) -> bool { 137 | let mut config = configuration.lock().unwrap(); 138 | 139 | let res = |config: &Configuration, f: &Mirror| { 140 | let mut completion_ok = config.completion_percent as f32 <= f.completion_pct * 100.0; 141 | let v4_on = config.filters.contains(&Protocol::Ipv4); 142 | let isos_on = config.filters.contains(&Protocol::Isos); 143 | let v6_on = config.filters.contains(&Protocol::Ipv6); 144 | if v4_on { 145 | completion_ok = completion_ok && f.ipv4; 146 | } 147 | 148 | if isos_on { 149 | completion_ok = completion_ok && f.isos; 150 | } 151 | 152 | if v6_on { 153 | completion_ok = completion_ok && f.ipv6; 154 | } 155 | completion_ok 156 | }; 157 | 158 | if config.age != 0 { 159 | if let Some(mirror_sync) = f.last_sync { 160 | let now = Local::now(); 161 | let mirror_sync: DateTime = DateTime::from(mirror_sync); 162 | let duration = now - mirror_sync; 163 | if !config.filters.contains(&Protocol::InSync) { 164 | config.filters.push(Protocol::InSync); 165 | } 166 | duration.num_hours() <= config.age.into() 167 | && config.filters.contains(&Protocol::from(f.protocol)) 168 | && res(&config, f) 169 | } else { 170 | false 171 | } 172 | } else { 173 | config.filters.contains(&Protocol::from(f.protocol)) && res(&config, f) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod config; 3 | mod dbg; 4 | mod direct; 5 | #[cfg(test)] 6 | mod test; 7 | mod tui; 8 | 9 | use std::sync::{Arc, Mutex}; 10 | 11 | use tracing::error; 12 | 13 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 14 | use self::config::watch_config; 15 | 16 | #[tokio::main] 17 | async fn main() { 18 | let args = ::parse(); 19 | 20 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 21 | let (config, file) = config::read_config_file(args.general.config.as_ref()); 22 | 23 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 24 | if !check_outfile(&args.general) && !check_outfile(&config.general) { 25 | exit("outfile"); 26 | } 27 | 28 | #[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))] 29 | if !check_outfile(&args.general) { 30 | exit("outfile"); 31 | } 32 | 33 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 34 | let config = config::Configuration::from((args, config)); 35 | 36 | #[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))] 37 | let config = config::Configuration::from(args); 38 | 39 | dbg::log(config.direct); 40 | 41 | if config.direct { 42 | if let Err(e) = direct::begin(config).await { 43 | error!("{e}") 44 | } 45 | } else { 46 | let config = Arc::new(Mutex::new(config)); 47 | 48 | #[cfg(any(feature = "json", feature = "toml", feature = "yaml"))] 49 | watch_config(file, Arc::clone(&config)); 50 | 51 | let _ = tui::start(config).await; 52 | } 53 | std::process::exit(0); 54 | } 55 | 56 | pub fn exit(value: &str) -> ! { 57 | let cmd = clap::Command::new("mirro-rs"); 58 | let mut err = clap::Error::new(clap::error::ErrorKind::ValueValidation).with_cmd(&cmd); 59 | err.insert( 60 | clap::error::ContextKind::InvalidArg, 61 | clap::error::ContextValue::String(format!("--{value}")), 62 | ); 63 | 64 | err.insert( 65 | clap::error::ContextKind::InvalidValue, 66 | clap::error::ContextValue::String(String::default()), 67 | ); 68 | err.exit(); 69 | } 70 | 71 | fn check_outfile(config: &cli::Args) -> bool { 72 | if let Some(ref outfile) = config.outfile { 73 | if outfile.to_string_lossy().ends_with('/') || outfile.to_string_lossy().is_empty() { 74 | exit("outfile"); 75 | } 76 | true 77 | } else { 78 | false 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/test/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{cli::ArgConfig, config::Configuration, direct::begin}; 2 | 3 | #[tokio::test] 4 | async fn sample_bin() { 5 | let config_str = include_str!("../../../../examples/mirro-rs.toml"); 6 | let configuration: ArgConfig = toml::from_str(config_str).unwrap(); 7 | let config = Configuration::from(configuration); 8 | let result = begin(config).await; 9 | dbg!(&result); 10 | assert!(result.is_ok()); 11 | } 12 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/actions.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Display, slice::Iter}; 2 | 3 | use super::inputs::key::Key; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 6 | pub enum Action { 7 | ClosePopUp, 8 | Quit, 9 | ShowInput, 10 | NavigateUp, 11 | NavigateDown, 12 | FilterHttps, 13 | FilterHttp, 14 | FilterFtp, 15 | FilterRsync, 16 | FilterSyncing, 17 | FilterIpv4, 18 | FilterIpv6, 19 | FilterIsos, 20 | ViewSortAlphabetically, 21 | ViewSortMirrorCount, 22 | ToggleSelect, 23 | SelectionSortCompletionPct, 24 | SelectionSortDelay, 25 | SelectionSortDuration, 26 | SelectionSortScore, 27 | Export, 28 | } 29 | 30 | impl Action { 31 | pub fn iterator() -> Iter<'static, Action> { 32 | static ACTIONS: [Action; 21] = [ 33 | Action::Quit, 34 | Action::ClosePopUp, 35 | Action::ShowInput, 36 | Action::NavigateUp, 37 | Action::NavigateDown, 38 | Action::FilterHttp, 39 | Action::FilterHttps, 40 | Action::FilterRsync, 41 | Action::FilterFtp, 42 | Action::FilterIpv4, 43 | Action::FilterIpv6, 44 | Action::FilterIsos, 45 | Action::FilterSyncing, 46 | Action::ViewSortMirrorCount, 47 | Action::ViewSortAlphabetically, 48 | Action::ToggleSelect, 49 | Action::SelectionSortCompletionPct, 50 | Action::SelectionSortDelay, 51 | Action::SelectionSortDuration, 52 | Action::SelectionSortScore, 53 | Action::Export, 54 | ]; 55 | ACTIONS.iter() 56 | } 57 | 58 | pub fn keys(&self) -> &[Key] { 59 | match self { 60 | Action::Quit => &[Key::Ctrl('c'), Key::Char('q')], 61 | Action::ClosePopUp => &[Key::Ctrl('p')], 62 | Action::ShowInput => &[Key::Ctrl('i'), Key::Char('/')], 63 | Action::NavigateUp => &[Key::Char('k'), Key::Up], 64 | Action::NavigateDown => &[Key::Char('j'), Key::Down], 65 | Action::FilterHttps => &[Key::Ctrl('s')], 66 | Action::FilterHttp => &[Key::Ctrl('h')], 67 | Action::FilterRsync => &[Key::Ctrl('r')], 68 | Action::FilterFtp => &[Key::Ctrl('f')], 69 | Action::FilterSyncing => &[Key::Ctrl('o')], 70 | Action::ViewSortAlphabetically => &[Key::Char('1')], 71 | Action::ViewSortMirrorCount => &[Key::Char('2')], 72 | Action::ToggleSelect => &[Key::Char(' ')], 73 | Action::SelectionSortCompletionPct => &[Key::Char('5')], 74 | Action::SelectionSortDelay => &[Key::Char('6')], 75 | Action::SelectionSortDuration => &[Key::Char('7')], 76 | Action::SelectionSortScore => &[Key::Char('8')], 77 | Action::Export => &[Key::Ctrl('e')], 78 | Action::FilterIpv4 => &[Key::Ctrl('4')], 79 | Action::FilterIpv6 => &[Key::Ctrl('6')], 80 | Action::FilterIsos => &[Key::Ctrl('5')], 81 | } 82 | } 83 | } 84 | 85 | impl Display for Action { 86 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 87 | let str = match self { 88 | Action::ClosePopUp => "close popup", 89 | Action::Quit => "quit", 90 | Action::ShowInput => "toggle filter", 91 | Action::NavigateUp => "up", 92 | Action::NavigateDown => "down", 93 | Action::FilterHttps => "toggle https", 94 | Action::FilterHttp => "toggle http", 95 | Action::FilterRsync => "toggle rsync", 96 | Action::FilterFtp => "toggle ftp", 97 | Action::FilterSyncing => "toggle in-sync", 98 | Action::ViewSortAlphabetically => "sort [country] A-Z", 99 | Action::ViewSortMirrorCount => "sort [country] mirrors", 100 | Action::ToggleSelect => "[de]select mirror", 101 | Action::SelectionSortCompletionPct => "sort [selection] completion", 102 | Action::SelectionSortDelay => "sort [selection] delay", 103 | Action::SelectionSortDuration => "sort [selection] duration", 104 | Action::SelectionSortScore => "sort [selection] score", 105 | Action::Export => "export mirrors", 106 | Action::FilterIpv4 => "toggle ipv4", 107 | Action::FilterIpv6 => "toggle ipv6", 108 | Action::FilterIsos => "toggle isos", 109 | }; 110 | write!(f, "{str}") 111 | } 112 | } 113 | 114 | /// The application should have some contextual actions. 115 | #[derive(Default, Debug, Clone)] 116 | pub struct Actions(Vec); 117 | 118 | impl Actions { 119 | /// Given a key, find the corresponding action 120 | pub fn find(&self, key: Key) -> Option<&Action> { 121 | Action::iterator() 122 | .filter(|action| self.0.contains(action)) 123 | .find(|action| action.keys().contains(&key)) 124 | } 125 | 126 | /// Get contextual actions. 127 | /// (just for building a help view) 128 | pub fn actions(&self) -> &[Action] { 129 | self.0.as_slice() 130 | } 131 | } 132 | 133 | impl From> for Actions { 134 | /// Build contextual action 135 | /// 136 | /// # Panics 137 | /// 138 | /// If two actions have same key 139 | fn from(actions: Vec) -> Self { 140 | // Check key unicity 141 | let mut map: HashMap> = HashMap::new(); 142 | for action in actions.iter() { 143 | for key in action.keys().iter() { 144 | match map.get_mut(key) { 145 | Some(vec) => vec.push(*action), 146 | None => { 147 | map.insert(*key, vec![*action]); 148 | } 149 | } 150 | } 151 | } 152 | let errors = map 153 | .iter() 154 | .filter(|(_, actions)| actions.len() > 1) // at least two actions share same shortcut 155 | .map(|(key, actions)| { 156 | let actions = actions 157 | .iter() 158 | .map(Action::to_string) 159 | .collect::>() 160 | .join(", "); 161 | format!("Conflict key {key} with actions {actions}") 162 | }) 163 | .collect::>(); 164 | if !errors.is_empty() { 165 | let err = errors.join("; "); 166 | panic!("{err}") 167 | } 168 | 169 | // Ok, we can create contextual actions 170 | Self(actions) 171 | } 172 | } 173 | 174 | #[cfg(test)] 175 | mod tests { 176 | use super::*; 177 | 178 | #[test] 179 | fn should_find_action_by_key() { 180 | let actions: Actions = vec![Action::Quit, Action::ClosePopUp].into(); 181 | let result = actions.find(Key::Ctrl('c')); 182 | assert_eq!(result, Some(&Action::Quit)); 183 | } 184 | 185 | #[test] 186 | fn should_find_action_by_key_not_found() { 187 | let actions: Actions = vec![Action::Quit, Action::ClosePopUp].into(); 188 | let result = actions.find(Key::Alt('w')); 189 | assert_eq!(result, None); 190 | } 191 | 192 | #[test] 193 | fn should_create_actions_from_vec() { 194 | let _actions: Actions = vec![Action::Quit, Action::ClosePopUp].into(); 195 | } 196 | 197 | #[test] 198 | #[should_panic] 199 | fn should_panic_when_create_actions_conflict_key() { 200 | let _actions: Actions = vec![Action::Quit, Action::ClosePopUp, Action::Quit].into(); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/inputs/event.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{ 3 | atomic::{AtomicBool, Ordering}, 4 | Arc, 5 | }, 6 | time::Duration, 7 | }; 8 | 9 | use tracing::error; 10 | 11 | use super::key::Key; 12 | use super::InputEvent; 13 | 14 | /// A small event handler that wrap crossterm input and tick event. Each event 15 | /// type is handled in its own thread and returned to a common `Receiver` 16 | pub struct Events { 17 | rx: tokio::sync::mpsc::Receiver, 18 | // Need to be kept around to prevent disposing the sender side. 19 | _tx: tokio::sync::mpsc::Sender, 20 | // To stop the loop 21 | stop_capture: Arc, 22 | } 23 | 24 | impl Events { 25 | /// Constructs an new instance of `Events` with the default config. 26 | pub fn new(tick_rate: Duration) -> Events { 27 | let (tx, rx) = tokio::sync::mpsc::channel(100); 28 | let stop_capture = Arc::new(AtomicBool::new(false)); 29 | 30 | let event_tx = tx.clone(); 31 | let event_stop_capture = stop_capture.clone(); 32 | tokio::spawn(async move { 33 | loop { 34 | // poll for tick rate duration, if no event, sent tick event. 35 | if crossterm::event::poll(tick_rate).unwrap() { 36 | if let crossterm::event::Event::Key(key) = crossterm::event::read().unwrap() { 37 | let key = Key::from(key); 38 | if let Err(err) = event_tx.send(InputEvent::Input(key)).await { 39 | error!("{err}"); 40 | } 41 | } 42 | } 43 | if let Err(err) = event_tx.send(InputEvent::Tick).await { 44 | error!("{err}"); 45 | } 46 | if event_stop_capture.load(Ordering::Relaxed) { 47 | break; 48 | } 49 | } 50 | }); 51 | 52 | Events { 53 | rx, 54 | _tx: tx, 55 | stop_capture, 56 | } 57 | } 58 | 59 | /// Attempts to read an event. 60 | pub async fn next(&mut self) -> InputEvent { 61 | self.rx.recv().await.unwrap_or(InputEvent::Tick) 62 | } 63 | 64 | /// Close 65 | pub fn close(&mut self) { 66 | self.stop_capture.store(true, Ordering::Relaxed) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/inputs/key.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use crossterm::event; 4 | 5 | /// Represents an key. 6 | #[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)] 7 | pub enum Key { 8 | /// Both Enter (or Return) and numpad Enter 9 | Enter, 10 | /// Tabulation key 11 | Tab, 12 | /// Backspace key 13 | Backspace, 14 | /// Escape key 15 | Esc, 16 | 17 | /// Left arrow 18 | Left, 19 | /// Right arrow 20 | Right, 21 | /// Up arrow 22 | Up, 23 | /// Down arrow 24 | Down, 25 | 26 | /// Insert key 27 | Ins, 28 | /// Delete key 29 | Delete, 30 | /// Home key 31 | Home, 32 | /// End key 33 | End, 34 | /// Page Up key 35 | PageUp, 36 | /// Page Down key 37 | PageDown, 38 | 39 | /// F0 key 40 | F0, 41 | /// F1 key 42 | F1, 43 | /// F2 key 44 | F2, 45 | /// F3 key 46 | F3, 47 | /// F4 key 48 | F4, 49 | /// F5 key 50 | F5, 51 | /// F6 key 52 | F6, 53 | /// F7 key 54 | F7, 55 | /// F8 key 56 | F8, 57 | /// F9 key 58 | F9, 59 | /// F10 key 60 | F10, 61 | /// F11 key 62 | F11, 63 | /// F12 key 64 | F12, 65 | Char(char), 66 | Ctrl(char), 67 | Alt(char), 68 | Unknown, 69 | } 70 | 71 | impl Key { 72 | /// If exit 73 | pub fn is_exit(&self) -> bool { 74 | matches!(self, Key::Ctrl('c') | Key::Char('q') | Key::Esc) 75 | } 76 | 77 | /// Returns the function key corresponding to the given number 78 | /// 79 | /// 1 -> F1, etc... 80 | /// 81 | /// # Panics 82 | /// 83 | /// If `n == 0 || n > 12` 84 | pub fn from_f(n: u8) -> Key { 85 | match n { 86 | 0 => Key::F0, 87 | 1 => Key::F1, 88 | 2 => Key::F2, 89 | 3 => Key::F3, 90 | 4 => Key::F4, 91 | 5 => Key::F5, 92 | 6 => Key::F6, 93 | 7 => Key::F7, 94 | 8 => Key::F8, 95 | 9 => Key::F9, 96 | 10 => Key::F10, 97 | 11 => Key::F11, 98 | 12 => Key::F12, 99 | _ => panic!("unknown function key: F{n}"), 100 | } 101 | } 102 | } 103 | 104 | impl Display for Key { 105 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 106 | match *self { 107 | Key::Alt(' ') => write!(f, "alt+Space"), 108 | Key::Ctrl(' ') => write!(f, "ctrl+Space"), 109 | Key::Char(' ') => write!(f, "space"), 110 | Key::Alt(c) => write!(f, "alt+{c}"), 111 | Key::Ctrl(c) => write!(f, "ctrl+{c}"), 112 | Key::Char(c) => write!(f, "{c}"), 113 | _ => write!(f, "{self:?}"), 114 | } 115 | } 116 | } 117 | 118 | impl From for Key { 119 | fn from(key_event: event::KeyEvent) -> Self { 120 | match key_event { 121 | event::KeyEvent { 122 | code: event::KeyCode::Esc, 123 | .. 124 | } => Key::Esc, 125 | event::KeyEvent { 126 | code: event::KeyCode::Backspace, 127 | .. 128 | } => Key::Backspace, 129 | event::KeyEvent { 130 | code: event::KeyCode::Left, 131 | .. 132 | } => Key::Left, 133 | event::KeyEvent { 134 | code: event::KeyCode::Right, 135 | .. 136 | } => Key::Right, 137 | event::KeyEvent { 138 | code: event::KeyCode::Up, 139 | .. 140 | } => Key::Up, 141 | event::KeyEvent { 142 | code: event::KeyCode::Down, 143 | .. 144 | } => Key::Down, 145 | event::KeyEvent { 146 | code: event::KeyCode::Home, 147 | .. 148 | } => Key::Home, 149 | event::KeyEvent { 150 | code: event::KeyCode::End, 151 | .. 152 | } => Key::End, 153 | event::KeyEvent { 154 | code: event::KeyCode::PageUp, 155 | .. 156 | } => Key::PageUp, 157 | event::KeyEvent { 158 | code: event::KeyCode::PageDown, 159 | .. 160 | } => Key::PageDown, 161 | event::KeyEvent { 162 | code: event::KeyCode::Delete, 163 | .. 164 | } => Key::Delete, 165 | event::KeyEvent { 166 | code: event::KeyCode::Insert, 167 | .. 168 | } => Key::Ins, 169 | event::KeyEvent { 170 | code: event::KeyCode::F(n), 171 | .. 172 | } => Key::from_f(n), 173 | event::KeyEvent { 174 | code: event::KeyCode::Enter, 175 | .. 176 | } => Key::Enter, 177 | event::KeyEvent { 178 | code: event::KeyCode::Tab, 179 | .. 180 | } => Key::Tab, 181 | 182 | // First check for char + modifier 183 | event::KeyEvent { 184 | code: event::KeyCode::Char(c), 185 | modifiers: event::KeyModifiers::ALT, 186 | .. 187 | } => Key::Alt(c), 188 | event::KeyEvent { 189 | code: event::KeyCode::Char(c), 190 | modifiers: event::KeyModifiers::CONTROL, 191 | .. 192 | } => Key::Ctrl(c), 193 | 194 | event::KeyEvent { 195 | code: event::KeyCode::Char(c), 196 | .. 197 | } => Key::Char(c), 198 | 199 | _ => Key::Unknown, 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/inputs/mod.rs: -------------------------------------------------------------------------------- 1 | use self::key::Key; 2 | 3 | pub mod event; 4 | pub mod key; 5 | 6 | pub enum InputEvent { 7 | Input(Key), 8 | Tick, 9 | } 10 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/io/handler.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | 3 | use archlinux::{ 4 | chrono::{DateTime, Utc}, 5 | ArchLinux, Client, Country, 6 | }; 7 | 8 | use std::{ 9 | path::PathBuf, 10 | sync::{atomic::AtomicBool, Arc}, 11 | time::SystemTime, 12 | }; 13 | 14 | use itertools::Itertools; 15 | use tokio::sync::Mutex; 16 | use tracing::{error, info, warn}; 17 | 18 | use crate::{ 19 | config::Configuration, 20 | tui::state::{App, PopUpState}, 21 | }; 22 | 23 | use super::IoEvent; 24 | 25 | const CACHE_FILE: &str = "cache"; 26 | 27 | pub struct IoAsyncHandler { 28 | app: Arc>, 29 | popup: Arc>, 30 | client: Client, 31 | } 32 | 33 | impl IoAsyncHandler { 34 | pub fn new(app: Arc>, popup: Arc>, client: Client) -> Self { 35 | Self { app, popup, client } 36 | } 37 | 38 | pub async fn initialise(&mut self, config: Arc>) -> Result<()> { 39 | let (is_fresh, cache_file) = is_fresh(Arc::clone(&config)).await; 40 | if is_fresh { 41 | match tokio::fs::read_to_string(cache_file.as_ref().unwrap()).await { 42 | Ok(contents) => { 43 | let result = archlinux::parse_local(&contents); 44 | match result { 45 | Ok(mirrors) => { 46 | show_stats(&mirrors.countries, is_fresh); 47 | 48 | update_state(Arc::clone(&self.app), Arc::clone(&config), mirrors).await; 49 | } 50 | Err(e) => { 51 | if let Err(f) = get_new_mirrors( 52 | cache_file, 53 | Arc::clone(&self.app), 54 | Arc::clone(&config), 55 | self.client.clone(), 56 | ) 57 | .await 58 | { 59 | error!("{e}, {f}"); 60 | } 61 | } 62 | } 63 | } 64 | Err(e) => { 65 | error!("{e}"); 66 | if let Err(e) = get_new_mirrors( 67 | cache_file, 68 | Arc::clone(&self.app), 69 | Arc::clone(&config), 70 | self.client.clone(), 71 | ) 72 | .await 73 | { 74 | error!("{e}"); 75 | } 76 | } 77 | } 78 | // read cached 79 | } else if let Err(e) = get_new_mirrors( 80 | cache_file, 81 | Arc::clone(&self.app), 82 | Arc::clone(&config), 83 | self.client.clone(), 84 | ) 85 | .await 86 | { 87 | error!("{e}"); 88 | } 89 | Ok(()) 90 | } 91 | 92 | pub async fn close_popup(&self) -> Result<()> { 93 | let mut state = self.popup.lock().await; 94 | state.visible = false; 95 | Ok(()) 96 | } 97 | 98 | pub async fn export( 99 | &self, 100 | in_progress: Arc, 101 | progress_transmitter: std::sync::mpsc::Sender, 102 | ) -> Result<()> { 103 | in_progress.store(true, std::sync::atomic::Ordering::Relaxed); 104 | 105 | let mut popup_state = self.popup.lock().await; 106 | popup_state.popup_text = String::from("Exporting your mirrors, please wait..."); 107 | popup_state.visible = true; 108 | std::mem::drop(popup_state); 109 | 110 | let (check_dl_speed, outfile, export_count, mut selected_mirrors, extra_urls, age) = { 111 | let app_state = self.app.lock().await; 112 | let configuration = app_state.configuration.lock().unwrap(); 113 | let check_dl_speed = configuration.rate; 114 | let outfile = configuration.outfile.clone(); 115 | let export_count = configuration.export as usize; 116 | let include = configuration.include.clone(); 117 | let age = configuration.age; 118 | 119 | let selected_mirrors = app_state 120 | .selected_mirrors 121 | .iter() 122 | .map(|f| f.url.to_owned()) 123 | .collect_vec(); 124 | ( 125 | check_dl_speed, 126 | outfile, 127 | export_count, 128 | selected_mirrors, 129 | include, 130 | age, 131 | ) 132 | }; 133 | 134 | let client = self.client.clone(); 135 | let included_urls = tokio::spawn(async move { 136 | if let Some(extra_urls) = extra_urls { 137 | let results = check_extra_urls(extra_urls, age, client).await; 138 | Some(results) 139 | } else { 140 | None 141 | } 142 | }); 143 | 144 | if let Ok(Some(Ok(mut item))) = included_urls.await { 145 | selected_mirrors.append(&mut item) 146 | } 147 | 148 | if !check_dl_speed { 149 | Self::write_to_file( 150 | outfile, 151 | &selected_mirrors, 152 | export_count, 153 | Some(in_progress), 154 | Some(Arc::clone(&self.popup)), 155 | ) 156 | .await; 157 | } else { 158 | Self::rate_mirrors( 159 | selected_mirrors, 160 | Some(Arc::clone(&self.popup)), 161 | Some(progress_transmitter), 162 | outfile, 163 | export_count, 164 | Some(in_progress), 165 | self.client.clone(), 166 | ) 167 | .await; 168 | } 169 | 170 | Ok(()) 171 | } 172 | 173 | pub async fn rate_mirrors( 174 | selected_mirrors: Vec, 175 | popup: Option>>, 176 | progress_transmitter: Option>, 177 | outfile: PathBuf, 178 | export_count: usize, 179 | in_progress: Option>, 180 | client: Client, 181 | ) -> tokio::task::JoinHandle<()> { 182 | let mut mirrors = Vec::with_capacity(selected_mirrors.len()); 183 | 184 | let mut set = tokio::task::JoinSet::new(); 185 | 186 | for i in selected_mirrors.iter() { 187 | set.spawn(archlinux::rate_mirror(i.clone(), client.clone())); 188 | } 189 | 190 | let popup_state = popup.clone(); 191 | 192 | tokio::spawn(async move { 193 | let mut current = 0; 194 | let len = set.len(); 195 | 196 | while let Some(res) = set.join_next().await { 197 | match res { 198 | Ok(Ok((duration, url))) => { 199 | mirrors.push((duration, url)); 200 | } 201 | Ok(Err(cause)) => match cause { 202 | archlinux::Error::Connection(e) => { 203 | error!("{e}"); 204 | } 205 | archlinux::Error::Parse(e) => { 206 | error!("{e}"); 207 | } 208 | archlinux::Error::Rate { 209 | qualified_url, 210 | url, 211 | status_code, 212 | } => { 213 | error!( 214 | "could not locate {qualified_url} from {url}, reason=> {status_code}", 215 | ); 216 | } 217 | archlinux::Error::Request(e) => { 218 | error!("{e}"); 219 | } 220 | archlinux::Error::TimeError(e) => { 221 | error!("{e}") 222 | } 223 | }, 224 | Err(e) => error!("{e}"), 225 | } 226 | if let Some(progress_transmitter) = progress_transmitter.as_ref() { 227 | current += 1; 228 | let value = (current as f32) / (len as f32) * 100.0; 229 | let _ = progress_transmitter.send(value); 230 | } 231 | } 232 | 233 | let results = { 234 | if !mirrors.is_empty() { 235 | mirrors.sort_by(|(duration_a, _), (duration_b, _)| duration_a.cmp(duration_b)); 236 | 237 | mirrors.iter().map(|(_, url)| url.to_owned()).collect_vec() 238 | } else { 239 | warn!("Exporting mirrors without rating..."); 240 | selected_mirrors.to_vec() 241 | } 242 | }; 243 | 244 | Self::write_to_file(outfile, &results, export_count, in_progress, popup_state).await; 245 | 246 | if let Some(progress) = progress_transmitter { 247 | let _ = progress.send(0.0); // reset progress 248 | } 249 | }) 250 | } 251 | 252 | pub async fn write_to_file( 253 | outfile: PathBuf, 254 | selected_mirrors: &[String], 255 | export_count: usize, 256 | in_progress: Option>, 257 | popup: Option>>, 258 | ) { 259 | if let Some(dir) = outfile.parent() { 260 | info!(count = %export_count, "making export of mirrors"); 261 | if tokio::fs::create_dir_all(dir).await.is_ok() { 262 | let output = &selected_mirrors[if selected_mirrors.len() >= export_count { 263 | ..export_count 264 | } else { 265 | ..selected_mirrors.len() 266 | }]; 267 | let output: Vec<_> = output 268 | .iter() 269 | .map(|f| format!("Server = {f}$repo/os/$arch")) 270 | .collect(); 271 | 272 | if let Err(e) = tokio::fs::write(&outfile, output.join("\n")).await { 273 | error!("{e}"); 274 | } else { 275 | info!("Your mirrorlist has been exported"); 276 | } 277 | if let Some(popup) = popup { 278 | let mut state = popup.lock().await; 279 | state.popup_text = format!( 280 | "Your mirrorlist has been successfully exported to: {}", 281 | outfile.display() 282 | ); 283 | } 284 | } 285 | } 286 | if let Some(in_progress) = in_progress { 287 | in_progress.store(false, std::sync::atomic::Ordering::Relaxed); 288 | } 289 | } 290 | 291 | pub async fn handle_io_event( 292 | &mut self, 293 | io_event: IoEvent, 294 | config: Arc>, 295 | ) { 296 | if let Err(e) = match io_event { 297 | IoEvent::Initialise => { 298 | if let Err(e) = self.initialise(config).await { 299 | error!("{e}") 300 | }; 301 | let mut popup = self.popup.lock().await; 302 | popup.visible = false; 303 | Ok(()) 304 | } 305 | IoEvent::ClosePopUp => self.close_popup().await, 306 | IoEvent::Export { 307 | in_progress, 308 | progress_transmitter, 309 | } => self.export(in_progress, progress_transmitter).await, 310 | } { 311 | error!("{e}"); 312 | } 313 | let mut app = self.app.lock().await; 314 | app.ready(); 315 | } 316 | } 317 | 318 | async fn check_extra_urls( 319 | extra_urls: Vec, 320 | age: u16, 321 | client: Client, 322 | ) -> Result> { 323 | info!("parsing included URLs"); 324 | let mut results = Vec::with_capacity(extra_urls.len()); 325 | 326 | let mut set = tokio::task::JoinSet::new(); 327 | 328 | for i in extra_urls.into_iter() { 329 | set.spawn(archlinux::get_last_sync(i, client.clone())); 330 | } 331 | 332 | while let Some(res) = set.join_next().await { 333 | match res { 334 | Ok(Ok((dt, url))) => { 335 | let utc: DateTime = Utc::now(); 336 | let diff = utc - dt; 337 | if i64::from(age) >= diff.num_hours() { 338 | results.push(url); 339 | } 340 | } 341 | Ok(Err(e)) => { 342 | error!("{e}") 343 | } 344 | Err(e) => { 345 | error!("{e}") 346 | } 347 | } 348 | } 349 | 350 | Ok(results) 351 | } 352 | 353 | // Do we get a new mirrorlist or nah 354 | pub async fn is_fresh( 355 | app: Arc>, 356 | ) -> (bool, Option) { 357 | if let Some(mut cache) = dirs::cache_dir() { 358 | let crate_name = env!("CARGO_PKG_NAME"); 359 | cache.push(crate_name); 360 | if let Err(e) = tokio::fs::create_dir_all(&cache).await { 361 | error!("could not create cache directory, {e}"); 362 | } 363 | cache.push(CACHE_FILE); 364 | if cache.exists() { 365 | let config = app.lock().unwrap(); 366 | let expires = config.ttl; 367 | drop(config); 368 | 369 | let duration = cache.metadata().map(|f| { 370 | f.modified().map(|f| { 371 | let now = SystemTime::now(); 372 | now.duration_since(f) 373 | }) 374 | }); 375 | match duration { 376 | Ok(Ok(Ok(duration))) => { 377 | let hours = duration.as_secs() / 3600; 378 | if hours < expires as u64 { 379 | (true, Some(cache)) 380 | } else { 381 | (false, Some(cache)) 382 | } 383 | } 384 | _ => (false, Some(cache)), 385 | } 386 | } else { 387 | (false, Some(cache)) 388 | } 389 | } else { 390 | (false, None) 391 | } 392 | } 393 | 394 | async fn get_new_mirrors( 395 | cache_file: Option, 396 | app: Arc>, 397 | config: Arc>, 398 | client: Client, 399 | ) -> Result<()> { 400 | let url = Arc::new(Mutex::new(String::default())); 401 | let inner = Arc::clone(&url); 402 | { 403 | let mut val = inner.lock().await; 404 | let source = config.lock().unwrap(); 405 | *val = source.url.clone(); 406 | }; 407 | let strs = url.lock().await; 408 | 409 | match archlinux::get_mirrors_with_client(&strs, client).await { 410 | Ok((mirrors, str_value)) => { 411 | if let Some(cache) = cache_file { 412 | if let Err(e) = tokio::fs::write(cache, str_value).await { 413 | error!("{e}"); 414 | } 415 | } 416 | 417 | show_stats(&mirrors.countries, false); 418 | 419 | let mut app = app.lock().await; 420 | app.mirrors = Some(mirrors); 421 | } 422 | Err(e) => { 423 | warn!("{e}, using old cached file fallback"); 424 | if let Some(file) = cache_file { 425 | let slice = tokio::fs::read_to_string(file).await; 426 | 427 | match slice.ok().and_then(|f| archlinux::parse_local(&f).ok()) { 428 | Some(mirrors) => { 429 | update_state(app, Arc::clone(&config), mirrors).await; 430 | } 431 | _ => { 432 | bail!("{e}"); 433 | } 434 | } 435 | } 436 | } 437 | } 438 | Ok(()) 439 | } 440 | 441 | async fn update_state( 442 | app: Arc>, 443 | config: Arc>, 444 | mut mirrors: ArchLinux, 445 | ) { 446 | let mut app = app.lock().await; 447 | let config = config.lock().unwrap(); 448 | if !config.country.is_empty() { 449 | let items = mirrors 450 | .countries 451 | .into_iter() 452 | .filter(|f| { 453 | config 454 | .country 455 | .iter() 456 | .any(|a| a.eq_ignore_ascii_case(&f.name)) 457 | }) 458 | .collect_vec(); 459 | mirrors.countries = items; 460 | } 461 | app.mirrors = Some(mirrors); 462 | } 463 | 464 | fn show_stats(slice: &[Country], is_cache: bool) { 465 | let mut count = 0; 466 | for i in slice.iter() { 467 | count += i.mirrors.len(); 468 | } 469 | info!( 470 | "Found {count} mirrors from {} countries{}.", 471 | slice.len(), 472 | if is_cache { " cached" } else { "" } 473 | ); 474 | } 475 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/io/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{atomic::AtomicBool, mpsc::Sender, Arc}; 2 | 3 | pub mod handler; 4 | 5 | pub enum IoEvent { 6 | Initialise, 7 | ClosePopUp, 8 | Export { 9 | in_progress: Arc, 10 | progress_transmitter: Sender, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | mod actions; 2 | mod inputs; 3 | pub mod io; 4 | mod state; 5 | mod ui; 6 | pub mod view; 7 | 8 | use anyhow::Result; 9 | 10 | use archlinux::get_client; 11 | use crossterm::{ 12 | event::{DisableMouseCapture, EnableMouseCapture}, 13 | execute, 14 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 15 | }; 16 | use std::{ 17 | sync::{atomic::AtomicBool, Arc}, 18 | time::Duration, 19 | }; 20 | use tokio::sync::Mutex; 21 | use tracing::debug; 22 | 23 | use ratatui::{backend::CrosstermBackend, Terminal}; 24 | 25 | use crate::config::Configuration; 26 | 27 | use self::{ 28 | inputs::{event::Events, InputEvent}, 29 | io::{handler::IoAsyncHandler, IoEvent}, 30 | state::{App, AppReturn, PopUpState}, 31 | ui::ui, 32 | }; 33 | 34 | pub async fn start(configuration: Arc>) -> Result<()> { 35 | enable_raw_mode()?; 36 | let mut stdout = std::io::stdout(); 37 | 38 | let client = { 39 | let timeout = configuration.lock().unwrap(); 40 | let timeout = timeout.connection_timeout; 41 | get_client(timeout)? 42 | }; 43 | 44 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 45 | 46 | let backend = CrosstermBackend::new(stdout); 47 | let mut terminal = Terminal::new(backend)?; 48 | let (sync_io_tx, mut sync_io_rx) = tokio::sync::mpsc::channel::(100); 49 | let app = Arc::new(Mutex::new(App::new(sync_io_tx, Arc::clone(&configuration)))); 50 | let inner = Arc::clone(&app); 51 | 52 | let popup_state = Arc::new(Mutex::new(PopUpState::new())); 53 | { 54 | let popup_state = Arc::clone(&popup_state); 55 | tokio::spawn(async move { 56 | let mut handler = IoAsyncHandler::new(inner, popup_state, client); 57 | debug!("Getting Arch Linux mirrors. Please wait"); 58 | while let Some(io_event) = sync_io_rx.recv().await { 59 | handler 60 | .handle_io_event(io_event, Arc::clone(&configuration)) 61 | .await; 62 | } 63 | }); 64 | } 65 | let res = run_app(&mut terminal, Arc::clone(&app), popup_state).await; 66 | 67 | disable_raw_mode()?; 68 | execute!( 69 | terminal.backend_mut(), 70 | LeaveAlternateScreen, 71 | DisableMouseCapture 72 | )?; 73 | terminal.show_cursor()?; 74 | 75 | if let Err(err) = res { 76 | eprintln!("{err:?}") 77 | } 78 | 79 | Ok(()) 80 | } 81 | 82 | async fn run_app( 83 | terminal: &mut Terminal>, 84 | app: Arc>, 85 | popup_state: Arc>, 86 | ) -> std::io::Result<()> { 87 | let tick_rate = Duration::from_millis(100); 88 | let mut events = Events::new(tick_rate); 89 | 90 | // Trigger state change from Init to Initialised 91 | { 92 | let mut app = app.lock().await; 93 | // Here we assume the the first load is a long task 94 | app.dispatch(IoEvent::Initialise).await; 95 | } 96 | 97 | let exporting = Arc::new(AtomicBool::new(false)); 98 | let (pos_tx, pos_rx) = std::sync::mpsc::channel(); 99 | 100 | loop { 101 | let mut app = app.lock().await; 102 | let popup = popup_state.lock().await; 103 | 104 | terminal.draw(|f| ui(f, &mut app, &popup, Arc::clone(&exporting), &pos_rx))?; 105 | 106 | let result = match events.next().await { 107 | InputEvent::Input(key) => { 108 | app.dispatch_action(key, Arc::clone(&exporting), pos_tx.clone()) 109 | .await 110 | } 111 | InputEvent::Tick => app.update_on_tick().await, 112 | }; 113 | 114 | if result == AppReturn::Exit { 115 | events.close(); 116 | break; 117 | } 118 | } 119 | Ok(()) 120 | } 121 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/state.rs: -------------------------------------------------------------------------------- 1 | use archlinux::{ 2 | chrono::{DateTime, Utc}, 3 | ArchLinux, Country, 4 | }; 5 | use std::sync::{atomic::AtomicBool, mpsc::Sender, Arc, Mutex}; 6 | 7 | use crate::{ 8 | cli::{Protocol, ViewSort}, 9 | config::Configuration, 10 | }; 11 | 12 | use itertools::Itertools; 13 | use ratatui::{ 14 | style::{Color, Modifier, Style}, 15 | widgets::{Cell, Row}, 16 | }; 17 | use tracing::{error, info, warn}; 18 | use unicode_width::UnicodeWidthStr; 19 | 20 | use crate::tui::actions::Action; 21 | 22 | use super::{actions::Actions, inputs::key::Key, io::IoEvent, ui::filter_result}; 23 | 24 | #[derive(Debug, PartialEq, Eq)] 25 | pub enum AppReturn { 26 | Exit, 27 | Continue, 28 | } 29 | 30 | pub struct App { 31 | pub actions: Actions, 32 | pub mirrors: Option, 33 | pub io_tx: tokio::sync::mpsc::Sender, 34 | pub input: String, 35 | pub input_cursor_position: usize, 36 | pub show_input: bool, 37 | pub scroll_pos: isize, 38 | pub filtered_countries: Vec<(Country, usize)>, 39 | pub selected_mirrors: Vec, 40 | pub table_viewport_height: u16, 41 | pub configuration: Arc>, 42 | pub show_insync: bool, 43 | } 44 | 45 | pub struct PopUpState { 46 | pub popup_text: String, 47 | pub visible: bool, 48 | } 49 | 50 | impl PopUpState { 51 | pub fn new() -> Self { 52 | Self { 53 | popup_text: String::from("Getting mirrors... please wait..."), 54 | visible: true, 55 | } 56 | } 57 | } 58 | 59 | #[derive(Debug, Clone)] 60 | pub struct SelectedMirror { 61 | pub country_code: String, 62 | pub protocol: Protocol, 63 | pub completion_pct: f32, 64 | pub delay: Option, 65 | pub score: Option, 66 | pub duration_stddev: Option, 67 | pub last_sync: Option>, 68 | pub url: String, 69 | } 70 | 71 | impl App { 72 | pub fn new( 73 | io_tx: tokio::sync::mpsc::Sender, 74 | configuration: Arc>, 75 | ) -> Self { 76 | let show_sync = configuration.lock().unwrap(); 77 | let sync = show_sync.age != 0; 78 | drop(show_sync); 79 | Self { 80 | actions: vec![Action::Quit].into(), 81 | show_input: false, 82 | mirrors: None, 83 | io_tx, 84 | input: String::default(), 85 | input_cursor_position: 0, 86 | configuration, 87 | scroll_pos: 0, 88 | table_viewport_height: 0, 89 | selected_mirrors: vec![], 90 | filtered_countries: vec![], 91 | show_insync: sync, 92 | } 93 | } 94 | 95 | pub async fn dispatch_action( 96 | &mut self, 97 | key: Key, 98 | exporting: Arc, 99 | progress_transmitter: Sender, 100 | ) -> AppReturn { 101 | if let Some(action) = self.actions.find(key) { 102 | if key.is_exit() && !self.show_input { 103 | AppReturn::Exit 104 | } else if self.show_input { 105 | match action { 106 | Action::Quit => { 107 | if key == Key::Char('q') { 108 | insert_character(self, 'q'); 109 | } 110 | } 111 | Action::NavigateUp => { 112 | if key == Key::Char('k') { 113 | insert_character(self, 'k'); 114 | } 115 | } 116 | Action::NavigateDown => { 117 | if key == Key::Char('j') { 118 | insert_character(self, 'j'); 119 | } 120 | } 121 | Action::ViewSortAlphabetically => insert_character(self, '1'), 122 | Action::ViewSortMirrorCount => insert_character(self, '2'), 123 | _ => {} 124 | } 125 | AppReturn::Continue 126 | } else { 127 | match action { 128 | Action::ClosePopUp => { 129 | let _ = self.io_tx.send(IoEvent::ClosePopUp).await; 130 | AppReturn::Continue 131 | } 132 | Action::Quit => AppReturn::Continue, 133 | Action::ShowInput => { 134 | self.show_input = !self.show_input; 135 | AppReturn::Continue 136 | } 137 | Action::NavigateUp => { 138 | self.previous(); 139 | AppReturn::Continue 140 | } 141 | Action::NavigateDown => { 142 | self.next(); 143 | AppReturn::Continue 144 | } 145 | Action::FilterHttps => insert_filter(self, Protocol::Https), 146 | Action::FilterHttp => insert_filter(self, Protocol::Http), 147 | Action::FilterRsync => insert_filter(self, Protocol::Rsync), 148 | Action::FilterFtp => insert_filter(self, Protocol::Ftp), 149 | Action::FilterSyncing => insert_filter(self, Protocol::InSync), 150 | Action::ViewSortAlphabetically => insert_sort(self, ViewSort::Alphabetical), 151 | Action::ViewSortMirrorCount => insert_sort(self, ViewSort::MirrorCount), 152 | Action::ToggleSelect => { 153 | self.focused_country(); 154 | AppReturn::Continue 155 | } 156 | Action::SelectionSortCompletionPct => { 157 | self.selected_mirrors 158 | .sort_by(|a, b| b.completion_pct.total_cmp(&a.completion_pct)); 159 | AppReturn::Continue 160 | } 161 | Action::SelectionSortDelay => { 162 | self.selected_mirrors.sort_by(|a, b| { 163 | let a = a.delay.unwrap_or(i64::MAX); 164 | let b = b.delay.unwrap_or(i64::MAX); 165 | a.partial_cmp(&b).unwrap() 166 | }); 167 | AppReturn::Continue 168 | } 169 | Action::SelectionSortScore => { 170 | self.selected_mirrors.sort_by(|a, b| { 171 | let a = a.score.unwrap_or(f64::MAX); 172 | let b = b.score.unwrap_or(f64::MAX); 173 | a.partial_cmp(&b).unwrap() 174 | }); 175 | AppReturn::Continue 176 | } 177 | Action::SelectionSortDuration => { 178 | self.selected_mirrors.sort_by(|a, b| { 179 | let a = a.duration_stddev.unwrap_or(f64::MAX); 180 | let b = b.duration_stddev.unwrap_or(f64::MAX); 181 | a.partial_cmp(&b).unwrap() 182 | }); 183 | AppReturn::Continue 184 | } 185 | Action::Export => { 186 | if !exporting.load(std::sync::atomic::Ordering::Relaxed) { 187 | if self.selected_mirrors.is_empty() { 188 | warn!("You haven't selected any mirrors yet"); 189 | } else { 190 | let _ = self 191 | .io_tx 192 | .send(IoEvent::Export { 193 | in_progress: Arc::clone(&exporting), 194 | progress_transmitter, 195 | }) 196 | .await; 197 | } 198 | } 199 | AppReturn::Continue 200 | } 201 | Action::FilterIpv4 => insert_filter(self, Protocol::Ipv4), 202 | Action::FilterIpv6 => insert_filter(self, Protocol::Ipv6), 203 | Action::FilterIsos => insert_filter(self, Protocol::Isos), 204 | } 205 | } 206 | } else { 207 | if self.show_input { 208 | match key { 209 | Key::Backspace => { 210 | if !self.input.is_empty() { 211 | self.input = format!( 212 | "{}{}", 213 | &self.input[..self.input_cursor_position - 1], 214 | &self.input[self.input_cursor_position..] 215 | ); 216 | self.input_cursor_position -= 1; 217 | } 218 | } 219 | Key::Left => { 220 | if self.input_cursor_position > 0 { 221 | self.input_cursor_position -= 1; 222 | } 223 | } 224 | Key::Right => { 225 | if self.input_cursor_position < self.input.width() { 226 | self.input_cursor_position += 1; 227 | } else { 228 | self.input_cursor_position = self.input.width(); 229 | }; 230 | } 231 | Key::Delete => { 232 | if self.input_cursor_position < self.input.width() { 233 | self.input.remove(self.input_cursor_position); 234 | } 235 | } 236 | Key::Home => { 237 | self.input_cursor_position = 0; 238 | } 239 | Key::End => { 240 | self.input_cursor_position = self.input.width(); 241 | } 242 | Key::Char(c) => { 243 | insert_character(self, c); 244 | self.scroll_pos = 0; 245 | } 246 | Key::Esc => { 247 | self.show_input = false; 248 | } 249 | _ => { 250 | warn!("No action associated to {key}"); 251 | } 252 | } 253 | } else { 254 | warn!("No action associated to {key}"); 255 | } 256 | AppReturn::Continue 257 | } 258 | } 259 | 260 | pub async fn dispatch(&mut self, action: IoEvent) { 261 | if let Err(e) = self.io_tx.send(action).await { 262 | error!("Error from dispatch {e}"); 263 | }; 264 | } 265 | 266 | pub async fn update_on_tick(&mut self) -> AppReturn { 267 | AppReturn::Continue 268 | } 269 | 270 | pub fn ready(&mut self) { 271 | self.actions = vec![ 272 | Action::ShowInput, 273 | Action::ClosePopUp, 274 | Action::Quit, 275 | Action::NavigateDown, 276 | Action::NavigateUp, 277 | Action::FilterHttp, 278 | Action::FilterHttps, 279 | Action::FilterFtp, 280 | Action::FilterRsync, 281 | Action::FilterSyncing, 282 | Action::FilterIpv4, 283 | Action::FilterIpv6, 284 | Action::FilterIsos, 285 | Action::ToggleSelect, 286 | Action::ViewSortAlphabetically, 287 | Action::ViewSortMirrorCount, 288 | Action::SelectionSortCompletionPct, 289 | Action::SelectionSortDelay, 290 | Action::SelectionSortDuration, 291 | Action::SelectionSortScore, 292 | Action::Export, 293 | ] 294 | .into(); 295 | } 296 | 297 | pub fn next(&mut self) { 298 | if self.scroll_pos + 1 == self.filtered_countries.len() as isize { 299 | self.scroll_pos = 0; 300 | } else { 301 | self.scroll_pos += 1; 302 | } 303 | } 304 | 305 | pub fn previous(&mut self) { 306 | if self.scroll_pos - 1 < 0 { 307 | self.scroll_pos = (self.filtered_countries.len() - 1) as isize; 308 | } else { 309 | self.scroll_pos -= 1; 310 | } 311 | } 312 | 313 | pub fn view_fragments<'a, T>(&'a self, iter: &'a [T]) -> Vec<&'a [T]> { 314 | iter.chunks(self.table_viewport_height.into()).collect_vec() 315 | } 316 | 317 | pub fn rows(&self) -> Vec { 318 | self.filtered_countries 319 | .iter() 320 | .enumerate() 321 | .map(|(idx, (f, count))| { 322 | let c = if idx == self.filtered_countries.len() - 1 { 323 | '╰' 324 | } else { 325 | '├' 326 | }; 327 | let mut selected = false; 328 | let default = format!("{c}─ [{}] {}", f.code, f.name); 329 | let item_name = match self.scroll_pos as usize == idx { 330 | true => { 331 | if idx == self.scroll_pos as usize { 332 | selected = true; 333 | format!("{c}─»[{}] {}«", f.code, f.name) 334 | } else { 335 | default 336 | } 337 | } 338 | false => default, 339 | }; 340 | 341 | let index = format!(" {idx}│"); 342 | 343 | return Row::new([index, item_name, count.to_string()].iter().map(|c| { 344 | Cell::from(c.clone()).style(if selected { 345 | Style::default() 346 | .add_modifier(Modifier::BOLD) 347 | .fg(Color::Green) 348 | } else { 349 | Style::default().fg(Color::Gray) 350 | }) 351 | })); 352 | }) 353 | .collect_vec() 354 | } 355 | 356 | pub fn view(&self, fragment: &[T]) -> T { 357 | fragment[self.fragment_number()] 358 | } 359 | 360 | pub fn focused_country(&mut self) { 361 | if self.mirrors.is_some() { 362 | let country = if self.scroll_pos < self.table_viewport_height as isize { 363 | let (country, _) = &self.filtered_countries[self.scroll_pos as usize]; 364 | // we can directly index 365 | info!("selected: {}", country.name); 366 | country 367 | } else { 368 | let page = self.fragment_number(); 369 | let index = (self.scroll_pos 370 | - (page * self.table_viewport_height as usize) as isize) 371 | as usize; 372 | let fragments = self.view_fragments(&self.filtered_countries); 373 | let frag = fragments[page]; 374 | let (country, _) = &frag[index]; 375 | info!("selected: {}", country.name); 376 | country 377 | }; 378 | 379 | let mut mirrors = country 380 | .mirrors 381 | .iter() 382 | .filter(|f| filter_result(self, f)) 383 | .map(|f| SelectedMirror { 384 | country_code: country.code.to_string(), 385 | protocol: Protocol::from(f.protocol), 386 | completion_pct: f.completion_pct, 387 | delay: f.delay, 388 | score: f.score, 389 | duration_stddev: f.duration_stddev, 390 | last_sync: f.last_sync, 391 | url: f.url.to_string(), 392 | }) 393 | .collect_vec(); 394 | 395 | let pos = self 396 | .selected_mirrors 397 | .iter() 398 | .positions(|f| f.country_code == country.code) 399 | .collect_vec(); 400 | 401 | if pos.is_empty() { 402 | self.selected_mirrors.append(&mut mirrors) 403 | } else { 404 | let new_items = self 405 | .selected_mirrors 406 | .iter() 407 | .filter_map(|f| { 408 | if f.country_code != country.code { 409 | Some(f.clone()) 410 | } else { 411 | None 412 | } 413 | }) 414 | .collect_vec(); 415 | 416 | self.selected_mirrors = new_items; 417 | } 418 | } 419 | } 420 | 421 | fn fragment_number(&self) -> usize { 422 | (self.scroll_pos / self.table_viewport_height as isize) as usize 423 | } 424 | } 425 | 426 | fn insert_character(app: &mut App, key: char) { 427 | app.input.insert(app.input_cursor_position, key); 428 | app.input_cursor_position += 1; 429 | app.scroll_pos = 0; 430 | } 431 | 432 | fn insert_filter(app: &mut App, filter: Protocol) -> AppReturn { 433 | let mut config = app.configuration.lock().unwrap(); 434 | if let Some(idx) = config.filters.iter().position(|f| *f == filter) { 435 | info!("protocol filter: removed {filter}"); 436 | config.filters.remove(idx); 437 | app.show_insync = false; 438 | } else { 439 | info!("protocol filter: added {filter}"); 440 | config.filters.push(filter); 441 | app.show_insync = false; 442 | } 443 | app.scroll_pos = 0; 444 | AppReturn::Continue 445 | } 446 | 447 | fn insert_sort(app: &mut App, view: ViewSort) -> AppReturn { 448 | let mut config = app.configuration.lock().unwrap(); 449 | config.view = view; 450 | AppReturn::Continue 451 | } 452 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/ui.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{atomic::AtomicBool, mpsc::Receiver, Arc}, 3 | time::Duration, 4 | }; 5 | 6 | use archlinux::{ 7 | chrono::{DateTime, Local}, 8 | Mirror, 9 | }; 10 | 11 | use itertools::Itertools; 12 | use ratatui::{ 13 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 14 | style::{Color, Modifier, Style}, 15 | text::{Line, Span}, 16 | widgets::{Block, BorderType, Borders, Cell, Clear, Gauge, Paragraph, Row, Table}, 17 | Frame, 18 | }; 19 | use tracing::debug; 20 | use tui_logger::TuiLoggerWidget; 21 | 22 | use crate::cli::{Protocol, ViewSort}; 23 | 24 | use super::{ 25 | actions::{Action, Actions}, 26 | state::{App, PopUpState}, 27 | }; 28 | 29 | pub fn ui( 30 | f: &mut Frame, 31 | app: &mut App, 32 | popup: &PopUpState, 33 | exporting: Arc, 34 | percentage: &Receiver, 35 | ) { 36 | const MIN_WIDTH: u16 = 80; 37 | const MIN_HEIGHT: u16 = 27; 38 | let area = f.size(); 39 | if check_size(&area, MIN_WIDTH, MIN_HEIGHT) { 40 | let region = Layout::default() 41 | .direction(Direction::Vertical) 42 | .constraints( 43 | [ 44 | Constraint::Percentage(25), 45 | Constraint::Percentage(25), 46 | Constraint::Percentage(25), 47 | Constraint::Percentage(25), 48 | ] 49 | .as_ref(), 50 | ) 51 | .split(centered_rect(50, 50, area)); 52 | 53 | let current_size_label = Line::from(vec![Span::styled( 54 | "Terminal size is too small", 55 | Style::default().add_modifier(Modifier::BOLD), 56 | )]); 57 | let current_size = Line::from(vec![ 58 | Span::styled("width = ", Style::default()), 59 | Span::styled( 60 | area.width.to_string(), 61 | if area.width < MIN_WIDTH { 62 | Style::default().fg(Color::Red) 63 | } else { 64 | Style::default().fg(Color::Green) 65 | }, 66 | ), 67 | Span::styled(" height = ", Style::default()), 68 | Span::styled( 69 | area.height.to_string(), 70 | if area.height < MIN_HEIGHT { 71 | Style::default().fg(Color::Red) 72 | } else { 73 | Style::default().fg(Color::Green) 74 | }, 75 | ), 76 | ]); 77 | let expected_size_label = Line::from(vec![Span::styled( 78 | "Expected size", 79 | Style::default().add_modifier(Modifier::BOLD), 80 | )]); 81 | let expected_size = Line::from(vec![Span::styled( 82 | format!("width = {MIN_WIDTH} height = {MIN_HEIGHT}"), 83 | Style::default(), 84 | )]); 85 | let text = Paragraph::new(current_size_label).alignment(Alignment::Center); 86 | let current_size = Paragraph::new(current_size).alignment(Alignment::Center); 87 | let expected_size_label = Paragraph::new(expected_size_label).alignment(Alignment::Center); 88 | let expected_size = Paragraph::new(expected_size).alignment(Alignment::Center); 89 | f.render_widget(text, region[0]); 90 | f.render_widget(current_size, region[1]); 91 | f.render_widget(expected_size_label, region[2]); 92 | f.render_widget(expected_size, region[3]); 93 | } else { 94 | let chunks = Layout::default() 95 | .constraints([Constraint::Min(20), Constraint::Length(3)].as_ref()) 96 | .split(area); 97 | 98 | let body_chunks = Layout::default() 99 | .direction(Direction::Horizontal) 100 | .constraints([Constraint::Min(20), Constraint::Length(60)].as_ref()) 101 | .split(chunks[0]); 102 | 103 | { 104 | // Body & Help 105 | let sidebar = Layout::default() 106 | .direction(Direction::Vertical) 107 | .constraints([Constraint::Percentage(40), Constraint::Percentage(60)].as_ref()) 108 | .split(body_chunks[1]); 109 | 110 | let help = draw_help(&app.actions); 111 | f.render_widget(help, sidebar[1]); 112 | 113 | f.render_widget(draw_selection(app), sidebar[0]); 114 | 115 | match app.show_input { 116 | true => { 117 | f.render_widget(draw_filter(app), chunks[1]); 118 | f.set_cursor( 119 | // Put cursor past the end of the input text 120 | chunks[1].x + app.input_cursor_position as u16 + 1, 121 | // Move one line down, from the border to the input line 122 | chunks[1].y + 1, 123 | ) 124 | } 125 | false => f.render_widget(draw_logs(), chunks[1]), 126 | }; 127 | } 128 | 129 | { 130 | let content_bar = Layout::default() 131 | .direction(Direction::Vertical) 132 | .constraints([Constraint::Length(3), Constraint::Min(20)].as_ref()) 133 | .split(body_chunks[0]); 134 | 135 | f.render_widget(draw_sort(app), content_bar[0]); 136 | 137 | draw_table(app, f, content_bar[1]); 138 | } 139 | 140 | let p = { Paragraph::new(popup.popup_text.clone()) }; 141 | 142 | if popup.visible { 143 | let rate_enabled = { 144 | let state = app.configuration.lock().unwrap(); 145 | state.rate 146 | }; 147 | let block = Block::default() 148 | .borders(Borders::ALL) 149 | .style(Style::default().bg(Color::Black)); 150 | let p = p.block(block).alignment(Alignment::Center); 151 | let area = centered_rect(60, 20, area); 152 | f.render_widget(Clear, area); 153 | if exporting.load(std::sync::atomic::Ordering::Relaxed) && rate_enabled { 154 | while let Ok(pos) = percentage.try_recv() { 155 | debug!("exporting mirrors: progress {pos:.2}%"); 156 | let gauge = Gauge::default() 157 | .gauge_style(Style::default().fg(Color::Blue).bg(Color::Black)) 158 | .block(create_block("Exporting mirrors")) 159 | .percent(pos as u16); 160 | f.render_widget(gauge, area); 161 | } 162 | } else { 163 | f.render_widget(p, area); 164 | } 165 | } 166 | } 167 | } 168 | 169 | fn draw_table(app: &mut App, f: &mut Frame, region: Rect) { 170 | let header_cells = [" index", "╭─── country", "mirrors"] 171 | .iter() 172 | .map(|h| Cell::from(*h).style(Style::default())); 173 | 174 | if let Some(items) = app.mirrors.as_ref() { 175 | app.filtered_countries = items 176 | .countries 177 | .iter() 178 | .filter_map(|f| { 179 | let count = f.mirrors.iter().filter(|m| filter_result(app, m)).count(); 180 | if count == 0 { 181 | None 182 | } else if f 183 | .name 184 | .to_ascii_lowercase() 185 | .contains(&app.input.to_ascii_lowercase()) 186 | { 187 | Some((f.clone(), count)) 188 | } else { 189 | None 190 | } 191 | }) 192 | .sorted_by(|(f, count), (b, second_count)| { 193 | let config = app.configuration.lock().unwrap(); 194 | match config.view { 195 | ViewSort::Alphabetical => Ord::cmp(&f.name, &b.name), 196 | ViewSort::MirrorCount => Ord::cmp(&second_count, &count), 197 | } 198 | }) 199 | .collect_vec(); 200 | }; 201 | 202 | // 3 is the height offset 203 | app.table_viewport_height = region.height - 3; 204 | 205 | let rows = app.rows(); 206 | 207 | let pagination_fragments = app.view_fragments(&rows); 208 | 209 | let header = Row::new(header_cells).height(1); 210 | 211 | let t = Table::new( 212 | if pagination_fragments.is_empty() { 213 | rows 214 | } else { 215 | app.view(&pagination_fragments).to_vec() 216 | }, 217 | [ 218 | Constraint::Percentage(6), 219 | Constraint::Length(33), 220 | Constraint::Min(10), 221 | ], 222 | ) 223 | .header(header) 224 | .block(create_block(format!( 225 | "Results from ({}) countries", 226 | app.filtered_countries.len() 227 | ))); 228 | 229 | f.render_widget(t, region); 230 | } 231 | 232 | fn draw_help(actions: &Actions) -> Table { 233 | let key_style = Style::default().fg(Color::LightCyan); 234 | let help_style = Style::default().fg(Color::Gray); 235 | 236 | let rows = actions.actions().iter().filter_map(|action| match action { 237 | Action::NavigateUp | Action::NavigateDown => None, 238 | _ => { 239 | let mut actions: Vec<_> = action 240 | .keys() 241 | .iter() 242 | .map(|k| Span::styled(k.to_string(), key_style)) 243 | .collect(); 244 | 245 | if actions.len() == 1 { 246 | actions.push(Span::raw("")); 247 | } 248 | 249 | let text = Span::styled(action.to_string(), help_style); 250 | actions.push(text); 251 | Some(Row::new(actions)) 252 | } 253 | }); 254 | 255 | Table::new( 256 | rows, 257 | [ 258 | Constraint::Percentage(20), 259 | Constraint::Percentage(20), 260 | Constraint::Percentage(60), 261 | ], 262 | ) 263 | .block(create_block("Help")) 264 | .column_spacing(1) 265 | } 266 | 267 | fn check_size(area: &Rect, width: u16, height: u16) -> bool { 268 | area.width < width || area.height < height 269 | } 270 | 271 | fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { 272 | let popup_layout = Layout::default() 273 | .direction(Direction::Vertical) 274 | .constraints( 275 | [ 276 | Constraint::Percentage((100 - percent_y) / 2), 277 | Constraint::Percentage(percent_y), 278 | Constraint::Percentage((100 - percent_y) / 2), 279 | ] 280 | .as_ref(), 281 | ) 282 | .split(area); 283 | 284 | Layout::default() 285 | .direction(Direction::Horizontal) 286 | .constraints( 287 | [ 288 | Constraint::Percentage((100 - percent_x) / 2), 289 | Constraint::Percentage(percent_x), 290 | Constraint::Percentage((100 - percent_x) / 2), 291 | ] 292 | .as_ref(), 293 | ) 294 | .split(popup_layout[1])[1] 295 | } 296 | 297 | fn draw_logs<'a>() -> TuiLoggerWidget<'a> { 298 | TuiLoggerWidget::default() 299 | .style_error(Style::default().fg(Color::Red)) 300 | .style_debug(Style::default().fg(Color::Blue)) 301 | .style_warn(Style::default().fg(Color::Yellow)) 302 | .style_trace(Style::default().fg(Color::Magenta)) 303 | .style_info(Style::default().fg(Color::Green)) 304 | .output_file(false) 305 | .output_timestamp(None) 306 | .output_line(false) 307 | .output_target(false) 308 | .block(create_block("Logs")) 309 | } 310 | 311 | fn draw_filter(app: &App) -> Paragraph { 312 | Paragraph::new(app.input.as_str()).block(create_block("Filter")) 313 | } 314 | 315 | fn draw_selection<'a>(app: &App) -> Table<'a> { 316 | let header_cells = ["code", "proto", "comp %", "delay", "dur", "score"] 317 | .iter() 318 | .map(|h| Cell::from(*h).style(Style::default())); 319 | let headers = Row::new(header_cells); 320 | 321 | let items = app.selected_mirrors.iter().map(|f| { 322 | let delay = f.delay.map(|f| { 323 | let duration = Duration::from_secs(f as u64); 324 | let minutes = (duration.as_secs() / 60) % 60; 325 | let hours = (duration.as_secs() / 60) / 60; 326 | (hours, minutes) 327 | }); 328 | 329 | let score = f.score.map(format_float); 330 | 331 | let dur = f.duration_stddev.map(format_float); 332 | 333 | let completion = f.completion_pct; 334 | 335 | Row::new(vec![ 336 | Cell::from(f.country_code.to_string()), 337 | Cell::from(f.protocol.to_string()), 338 | Cell::from(format!("{:.2}", (completion * 100.0))).style(if completion == 1.0 { 339 | Style::default().fg(Color::Green) 340 | } else if completion > 0.90 { 341 | Style::default().fg(Color::LightCyan) 342 | } else if completion > 0.80 { 343 | Style::default().fg(Color::Cyan) 344 | } else if completion > 0.70 { 345 | Style::default() 346 | .fg(Color::LightYellow) 347 | .add_modifier(Modifier::SLOW_BLINK) 348 | } else if completion > 0.60 { 349 | Style::default() 350 | .fg(Color::Yellow) 351 | .add_modifier(Modifier::SLOW_BLINK) 352 | } else if completion > 0.50 { 353 | Style::default() 354 | .fg(Color::LightRed) 355 | .add_modifier(Modifier::SLOW_BLINK) 356 | } else { 357 | Style::default() 358 | .fg(Color::Red) 359 | .add_modifier(Modifier::SLOW_BLINK) 360 | }), 361 | Cell::from(match delay { 362 | Some((hours, minutes)) => { 363 | format!("{hours}:{minutes}") 364 | } 365 | None => "-".to_string(), 366 | }) 367 | .style(match delay { 368 | Some((hours, _)) => { 369 | if hours < 1 { 370 | Style::default().fg(Color::Green) 371 | } else { 372 | Style::default() 373 | } 374 | } 375 | None => Style::default(), 376 | }), 377 | Cell::from( 378 | dur.map(|f| f.to_string()) 379 | .unwrap_or_else(|| "-".to_string()), 380 | ), 381 | Cell::from( 382 | score 383 | .map(|f| f.to_string()) 384 | .unwrap_or_else(|| "-".to_string()), 385 | ), 386 | ]) 387 | }); 388 | 389 | let mirror_count = app.selected_mirrors.len(); 390 | let config = app.configuration.lock().unwrap(); 391 | 392 | let t = Table::new( 393 | items, 394 | [ 395 | Constraint::Percentage(16), 396 | Constraint::Percentage(16), 397 | Constraint::Percentage(16), 398 | Constraint::Percentage(16), 399 | Constraint::Percentage(16), 400 | Constraint::Percentage(20), 401 | ], 402 | ) 403 | // You can set the style of the entire Table. 404 | .style(Style::default().fg(Color::White)) 405 | // It has an optional header, which is simply a Row always visible at the top. 406 | .header(headers) 407 | // As any other widget, a Table can be wrapped in a Block. 408 | .block(create_block(if mirror_count < 1 { 409 | format!("Selection({mirror_count})") 410 | } else { 411 | format!( 412 | "Selection({})▶ ({}) to {}", 413 | mirror_count, 414 | if config.export as usize <= mirror_count { 415 | config.export.to_string() 416 | } else { 417 | "ALL".to_string() 418 | }, 419 | config.outfile.display() 420 | ) 421 | })); 422 | 423 | t 424 | } 425 | 426 | fn draw_sort<'a>(app: &App) -> Paragraph<'a> { 427 | let config = app.configuration.lock().unwrap(); 428 | let count: isize = config.filters.len() as isize; 429 | let active_sort = [config.view]; 430 | let mut sorts: Vec<_> = active_sort 431 | .iter() 432 | .enumerate() 433 | .flat_map(|(idx, f)| { 434 | let mut ret = vec![ 435 | Span::raw(format!(" [{f}]")), 436 | Span::styled(" ⇣", Style::default()), 437 | ]; 438 | if (idx as isize) < count - 1 { 439 | ret.push(Span::styled(" 🢒", Style::default().fg(Color::Black))) 440 | } 441 | ret 442 | }) 443 | .collect(); 444 | 445 | let mut filters: Vec<_> = config 446 | .filters 447 | .iter() 448 | .enumerate() 449 | .flat_map(|(idx, f)| { 450 | let mut ret = vec![Span::styled( 451 | format!(" {f}"), 452 | Style::default() 453 | .fg(match f { 454 | Protocol::InSync => Color::Cyan, 455 | _ => Color::Blue, 456 | }) 457 | .add_modifier(Modifier::BOLD), 458 | )]; 459 | if (idx as isize) < count - 1 { 460 | ret.push(Span::styled(" 🢒", Style::default().fg(Color::Black))) 461 | } 462 | ret 463 | }) 464 | .collect(); 465 | 466 | sorts.append(&mut filters); 467 | 468 | let widget = Line::from(sorts); 469 | 470 | let bt = format!("Sort & Filter ({count})"); 471 | 472 | Paragraph::new(widget).block(create_block(bt)) 473 | } 474 | 475 | fn create_block<'a>(title: impl Into) -> Block<'a> { 476 | let title = title.into(); 477 | Block::default() 478 | .borders(Borders::ALL) 479 | .border_type(BorderType::Rounded) 480 | .border_style(Style::default().fg(Color::Black)) 481 | .title(Span::styled( 482 | format!(" {title} "), 483 | Style::default() 484 | .add_modifier(Modifier::BOLD) 485 | .fg(Color::White), 486 | )) 487 | } 488 | 489 | fn format_float(str: impl ToString) -> f32 { 490 | match str.to_string().parse::() { 491 | Ok(res) => (res * 100.0).round() / 100.0, 492 | Err(_) => -999.0, 493 | } 494 | } 495 | 496 | pub fn filter_result(app: &App, f: &Mirror) -> bool { 497 | use crate::config::Configuration; 498 | let mut config = app.configuration.lock().unwrap(); 499 | 500 | let res = |config: &Configuration, f: &Mirror| { 501 | let mut completion_ok = config.completion_percent as f32 <= f.completion_pct * 100.0; 502 | let v4_on = config.filters.contains(&Protocol::Ipv4); 503 | let isos_on = config.filters.contains(&Protocol::Isos); 504 | let v6_on = config.filters.contains(&Protocol::Ipv6); 505 | if v4_on { 506 | completion_ok = completion_ok && f.ipv4; 507 | } 508 | 509 | if isos_on { 510 | completion_ok = completion_ok && f.isos; 511 | } 512 | 513 | if v6_on { 514 | completion_ok = completion_ok && f.ipv6; 515 | } 516 | completion_ok 517 | }; 518 | 519 | if config.age != 0 { 520 | if let Some(mirror_sync) = f.last_sync { 521 | let now = Local::now(); 522 | let mirror_sync: DateTime = DateTime::from(mirror_sync); 523 | let duration = now - mirror_sync; 524 | if !config.filters.contains(&Protocol::InSync) && app.show_insync { 525 | config.filters.push(Protocol::InSync); 526 | } 527 | duration.num_hours() <= config.age.into() 528 | && config.filters.contains(&Protocol::from(f.protocol)) 529 | && res(&config, f) 530 | } else { 531 | false 532 | } 533 | } else { 534 | config.filters.contains(&Protocol::from(f.protocol)) && res(&config, f) 535 | } 536 | } 537 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/view/filter.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::cli::Protocol; 4 | 5 | //#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, ValueEnum, Deserialize)] 6 | //#[serde(rename_all = "lowercase")] 7 | //pub enum Protocol { 8 | // Https, 9 | // Http, 10 | // Rsync, 11 | // #[value(skip)] 12 | // InSync, 13 | // #[value(skip)] 14 | // Ipv4, 15 | // #[value(skip)] 16 | // Ipv6, 17 | // #[value(skip)] 18 | // Isos, 19 | //} 20 | 21 | impl From for Protocol { 22 | fn from(value: archlinux::Protocol) -> Self { 23 | match value { 24 | archlinux::Protocol::Rsync => Self::Rsync, 25 | archlinux::Protocol::Http => Self::Http, 26 | archlinux::Protocol::Https => Self::Https, 27 | archlinux::Protocol::Ftp => Self::Ftp, 28 | } 29 | } 30 | } 31 | 32 | impl Display for Protocol { 33 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 34 | write!( 35 | f, 36 | "{}", 37 | match self { 38 | Protocol::Https => "https", 39 | Protocol::Http => "http", 40 | Protocol::Rsync => "rsync", 41 | Protocol::Ftp => "ftp", 42 | Protocol::InSync => "in-sync", 43 | Protocol::Ipv4 => "ipv4", 44 | Protocol::Ipv6 => "ipv6", 45 | Protocol::Isos => "isos", 46 | } 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/view/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod filter; 2 | pub mod sort; 3 | -------------------------------------------------------------------------------- /crates/mirro-rs/src/tui/view/sort.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::cli::ViewSort; 4 | 5 | impl Display for ViewSort { 6 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 7 | let str = match self { 8 | ViewSort::Alphabetical => "A", 9 | ViewSort::MirrorCount => "1", 10 | }; 11 | write!(f, "{str}") 12 | } 13 | } 14 | 15 | #[allow(dead_code)] 16 | #[cfg_attr(test, derive(Default))] 17 | #[derive(PartialEq, Eq, Debug, Clone, Copy)] 18 | pub enum ExportSort { 19 | Completion, 20 | MirroringDelay, 21 | #[cfg_attr(test, default)] 22 | Duration, 23 | Score, 24 | } 25 | 26 | impl Display for ExportSort { 27 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 | let str = match self { 29 | ExportSort::Completion => "%", 30 | ExportSort::MirroringDelay => "μ", 31 | ExportSort::Duration => "σ", 32 | ExportSort::Score => "~", 33 | }; 34 | write!(f, "{str}") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/mirro-rs.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "outfile": "/home/user/example/generated-mirrors", 4 | "export": 50, 5 | "view": "alphabetical", 6 | "sort": "score", 7 | "cache-ttl": 24, 8 | "url": "https://archlinux.org/mirrors/status/json/", 9 | "rate-speed": true, 10 | "timeout": 5 11 | }, 12 | "filters": { 13 | "countries": [], 14 | "age": 24, 15 | "ipv6": true, 16 | "ipv4": true, 17 | "isos": true, 18 | "protocols": [ 19 | "https", 20 | "http", 21 | "rsync" 22 | ], 23 | "completion-percent": 100 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/mirro-rs.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | outfile = "/home/user/example/generated-mirrors" # must not end with trailing slash 3 | # Max number of mirrors to export 4 | export = 50 5 | view = "alphabetical" # alphabetical mirror-count 6 | sort = "score" # percentage, duration, delay, score 7 | cache-ttl = 24 8 | url = "https://archlinux.org/mirrors/status/json/" 9 | rate-speed = true 10 | timeout = 5 11 | #include = [ 12 | # "https://cloudflaremirrors.com/archlinux/" 13 | #] 14 | 15 | [filters] 16 | countries = [ ] 17 | age = 0 18 | ipv6 = true 19 | ipv4 = true 20 | isos = true 21 | protocols = [ "https", "http", "rsync" ] 22 | completion-percent = 100 23 | -------------------------------------------------------------------------------- /examples/mirro-rs.yaml: -------------------------------------------------------------------------------- 1 | general: 2 | outfile: /home/user/example/generated-mirrors 3 | export: 50 4 | view: alphabetical 5 | sort: score 6 | cache-ttl: 24 7 | url: https://archlinux.org/mirrors/status/json/ 8 | rate-speed: true 9 | timeout: 5 10 | # include: 11 | # - https://cloudflaremirrors.com/archlinux/ 12 | filters: 13 | countries: [] 14 | age: 24 15 | ipv6: true 16 | ipv4: true 17 | isos: true 18 | protocols: 19 | - https 20 | - http 21 | - rsync 22 | completion-percent: 100 23 | -------------------------------------------------------------------------------- /systemd/mirro-rs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Update Pacman mirrorlists with mirro-rs 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=/usr/bin/mirro-rs -d -o /etc/pacman.d/mirrorlist --rate --protocols http --protocols https 9 | 10 | [Install] 11 | RequiredBy=multi-user.target 12 | -------------------------------------------------------------------------------- /systemd/mirro-rs.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Refresh Pacman mirrorlist weekly with mirro-rs. 3 | 4 | [Timer] 5 | OnCalendar=weekly 6 | Persistent=true 7 | AccuracySec=1us 8 | RandomizedDelaySec=12h 9 | 10 | [Install] 11 | WantedBy=timers.target 12 | --------------------------------------------------------------------------------