├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── justfile ├── libium ├── CHANGELOG.md ├── Cargo.toml ├── README.md └── src │ ├── add.rs │ ├── config │ ├── filters.rs │ ├── mod.rs │ └── structs.rs │ ├── iter_ext.rs │ ├── lib.rs │ ├── modpack │ ├── add.rs │ ├── curseforge │ │ ├── mod.rs │ │ └── structs.rs │ ├── mod.rs │ └── modrinth │ │ ├── mod.rs │ │ └── structs.rs │ ├── scan.rs │ ├── upgrade │ ├── check.rs │ ├── mod.rs │ ├── mod_downloadable.rs │ └── modpack_downloadable.rs │ └── version_ext.rs ├── media ├── list_verbose.png ├── profile_info_and_list.png └── upgrade.png ├── src ├── add.rs ├── cli.rs ├── download.rs ├── file_picker.rs ├── main.rs ├── subcommands │ ├── list.rs │ ├── mod.rs │ ├── modpack │ │ ├── add.rs │ │ ├── configure.rs │ │ ├── delete.rs │ │ ├── info.rs │ │ ├── mod.rs │ │ ├── switch.rs │ │ └── upgrade.rs │ ├── profile │ │ ├── configure.rs │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── info.rs │ │ ├── mod.rs │ │ └── switch.rs │ ├── remove.rs │ └── upgrade.rs └── tests.rs └── tests ├── configs ├── empty.json ├── empty_profile.json ├── one_profile_full.json ├── two_modpacks_cfactive.json ├── two_modpacks_mdactive.json └── two_profiles_one_empty.json └── test_mods ├── Incendium.jar ├── Sodium.jar ├── Starlight Duplicate.jar └── Starlight.jar /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report behaviour that should not occur 3 | type: Bug 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Description 8 | description: Describe the bug 9 | validations: 10 | required: true 11 | 12 | - type: textarea 13 | attributes: 14 | label: Steps to Reproduce 15 | description: How do you reproduce this bug? You can also describe how you encountered the bug if you are unsure. 16 | placeholder: | 17 | For example: 18 | 1. Run `ferium ...` 19 | 2. ... doesn't properly change 20 | 3. Run command again 21 | 4. ... changes properly 22 | ... 23 | 24 | - type: dropdown 25 | attributes: 26 | label: Operating System 27 | description: The operating system you encountered the bug on 28 | multiple: true 29 | options: 30 | - Windows 31 | - Linux 32 | - macOS 33 | - other (Specify in Additional Information) 34 | validations: 35 | required: true 36 | 37 | - type: dropdown 38 | attributes: 39 | label: Installation Method 40 | description: How did you install ferium? 41 | options: 42 | - AUR (pacman) 43 | - Homebrew 44 | - winget 45 | - Scoop 46 | - Pacstall 47 | - Nixpkgs 48 | - LoaTcHi's overlay (Gentoo Portage) 49 | - XBPS 50 | - crates.io (`cargo install`) 51 | - GitHub Releases (manually installed) 52 | - Locally compiled 53 | - GitHub Actions 54 | validations: 55 | required: true 56 | 57 | - type: input 58 | attributes: 59 | label: Ferium version 60 | description: What is the output of `ferium --version`? 61 | validations: 62 | required: true 63 | 64 | - type: textarea 65 | attributes: 66 | label: Additional Information 67 | description: Any additional information you would like to provide. You can even drop images or videos here. 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Discussions 3 | url: https://github.com/gorilla-devs/ferium/discussions 4 | about: | 5 | If you are not reporting a bug or requesting a feature, 6 | e.g. you want to get help on how to use ferium, 7 | use GitHub Discussions instead of creating an issue. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for the project 3 | type: Feature 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: The Problem 8 | description: What problem do you have, that this feature could resolve? 9 | placeholder: | 10 | For example: 11 | I cannot do ... 12 | I have to do ... every time. 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | attributes: 18 | label: Your Solution(s) 19 | description: How could ferium solve your problem? 20 | placeholder: | 21 | For example: 22 | Ferium should do ... 23 | Instead of doing ..., ferium should ... instead. 24 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | # Do not run on tag pushes 6 | branches: '**' 7 | # Only run when; 8 | paths: 9 | # any Rust code has changed, 10 | - "**.rs" 11 | # this workflow has changed, 12 | - ".github/workflows/build.yml" 13 | # dependencies have changed, 14 | - "Cargo.lock" 15 | # or a rebuild is manually called. 16 | workflow_dispatch: 17 | 18 | jobs: 19 | build: 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest, macos-latest, windows-latest] 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | CARGO_TERM_COLOR: always 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Create `out/` 30 | run: mkdir out 31 | 32 | # Install Rust on the various platforms 33 | 34 | - name: Install Rust for macOS 35 | if: matrix.os == 'macos-latest' 36 | uses: dtolnay/rust-toolchain@nightly 37 | with: 38 | targets: aarch64-apple-darwin, x86_64-apple-darwin 39 | components: clippy 40 | 41 | - name: Install Rust for Windows 42 | if: matrix.os == 'windows-latest' 43 | uses: dtolnay/rust-toolchain@nightly 44 | with: 45 | components: clippy 46 | 47 | - name: Install Rust for Linux 48 | if: matrix.os == 'ubuntu-latest' 49 | uses: dtolnay/rust-toolchain@nightly 50 | with: 51 | targets: x86_64-pc-windows-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-musl 52 | components: clippy 53 | 54 | - name: Install Linux dependencies 55 | if: matrix.os == 'ubuntu-latest' 56 | run: | 57 | sudo apt update 58 | sudo apt install gcc-mingw-w64-x86-64 musl musl-tools gcc-aarch64-linux-gnu libwayland-dev 59 | curl https://apt.llvm.org/llvm.sh | sudo bash -s 17 60 | 61 | - name: Install Rust cache 62 | uses: Swatinem/rust-cache@v2 63 | 64 | - name: Install nextest 65 | uses: taiki-e/install-action@nextest 66 | 67 | - name: Lint code 68 | run: cargo clippy 69 | 70 | - name: Run tests 71 | run: cargo nextest run --no-default-features --no-fail-fast 72 | 73 | - name: Build macOS Intel 74 | if: matrix.os == 'macos-latest' 75 | env: 76 | MACOSX_DEPLOYMENT_TARGET: 10.12 # Sierra 77 | run: | 78 | cargo build --target=x86_64-apple-darwin --release 79 | zip -r out/ferium-macos-x64.zip -j target/x86_64-apple-darwin/release/ferium 80 | 81 | - name: Build macOS ARM 82 | if: matrix.os == 'macos-latest' 83 | env: 84 | MACOSX_DEPLOYMENT_TARGET: 11.0 # Big Sur 85 | run: | 86 | cargo build --target=aarch64-apple-darwin --release 87 | zip -r out/ferium-macos-arm.zip -j target/aarch64-apple-darwin/release/ferium 88 | 89 | - name: Build Windows MSVC 90 | if: matrix.os == 'windows-latest' 91 | run: | 92 | cargo build --target=x86_64-pc-windows-msvc --release 93 | Compress-Archive -Path "target\x86_64-pc-windows-msvc\release\ferium.exe" -DestinationPath "out\ferium-windows-msvc.zip" 94 | 95 | - name: Build Linux 96 | if: matrix.os == 'ubuntu-latest' 97 | run: | 98 | cargo build --target=x86_64-unknown-linux-musl --release 99 | zip -r out/ferium-linux.zip -j target/x86_64-unknown-linux-musl/release/ferium 100 | 101 | - name: Build ARM Linux 102 | if: matrix.os == 'ubuntu-latest' 103 | run: | 104 | cargo rustc --target=aarch64-unknown-linux-musl --release -- -Clink-self-contained=yes -Clinker=rust-lld 105 | zip -r out/ferium-linux-arm64.zip -j target/aarch64-unknown-linux-musl/release/ferium 106 | env: 107 | CC_aarch64_unknown_linux_musl: clang-17 108 | 109 | - name: Build Linux nogui 110 | if: matrix.os == 'ubuntu-latest' 111 | run: | 112 | cargo build --target=x86_64-unknown-linux-musl --release --no-default-features 113 | zip -r out/ferium-linux-nogui.zip -j target/x86_64-unknown-linux-musl/release/ferium 114 | 115 | - name: Build ARM Linux nogui 116 | if: matrix.os == 'ubuntu-latest' 117 | run: | 118 | cargo rustc --target=aarch64-unknown-linux-musl --release --no-default-features -- -Clink-self-contained=yes -Clinker=rust-lld 119 | zip -r out/ferium-linux-arm64-nogui.zip -j target/aarch64-unknown-linux-musl/release/ferium 120 | env: 121 | CC_aarch64_unknown_linux_musl: clang-17 122 | 123 | - name: Build Windows GNU 124 | if: matrix.os == 'ubuntu-latest' 125 | run: | 126 | cargo build --target=x86_64-pc-windows-gnu --release 127 | zip -r out/ferium-windows-gnu.zip -j target/x86_64-pc-windows-gnu/release/ferium.exe 128 | 129 | - name: Upload build artefacts 130 | uses: actions/upload-artifact@v4 131 | with: 132 | name: binaries-${{ matrix.os }} 133 | path: out/ferium*.zip 134 | if-no-files-found: error 135 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish and Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: "*" 7 | 8 | jobs: 9 | crates-publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install Rust 15 | uses: dtolnay/rust-toolchain@stable 16 | 17 | - name: Upload to crates.io 18 | run: cargo publish 19 | env: 20 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 21 | 22 | gh-release: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Download artifacts 30 | uses: dawidd6/action-download-artifact@v3 31 | with: 32 | workflow_conclusion: success 33 | workflow: build.yml 34 | path: ./out 35 | 36 | - name: Create sha256sum for Scoop 37 | run: sha256sum ./out/**/ferium-windows-msvc.zip | cut -d ' ' -f 1 > ./out/ferium-windows-msvc.zip.sha256 38 | 39 | # Remove the dots for the markdown header 40 | - name: MD Header 41 | run: echo "MD_HEADER=$(git describe --tags --abbrev=0 | sed 's/\.//g')" >> $GITHUB_ENV 42 | 43 | - name: Get the latest tag 44 | run: echo "TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV 45 | 46 | - name: Create release 47 | uses: softprops/action-gh-release@v1 48 | with: 49 | files: ./out/** 50 | name: Ferium ${{ env.TAG }} 51 | tag_name: ${{ env.TAG }} 52 | body: | 53 | Compiled binaries for Ferium version `${{ env.TAG }}` ([changelog](${{ github.server_url }}/${{ github.repository }}/blob/main/CHANGELOG.md#${{ env.MD_HEADER }})) 54 | 55 | The provided binaries are for: 56 | - Linux ARM without a GUI file dialogue 57 | - Linux ARM (aarch64 linux musl) 58 | - Linux without a GUI file dialogue 59 | - Linux (x64 linux musl) 60 | - macOS Apple Silicon (aarch64 darwin) 61 | - macOS Intel (x64 darwin) 62 | - GNU Windows (x64 windows gnu) (i.e. Cygwin/MinGW) 63 | - Windows (x64 windows msvc) 64 | 65 | You can install ferium by downloading and unzipping the appropriate asset, and moving the executable to ~/bin or any other folder in your path. 66 | 67 | aur-update: 68 | runs-on: ubuntu-latest 69 | needs: gh-release 70 | steps: 71 | - uses: actions/checkout@v4 72 | with: 73 | fetch-depth: 0 74 | 75 | - name: Get the latest tag 76 | run: echo "TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV 77 | 78 | - name: Update no-gui AUR package 79 | uses: aksh1618/update-aur-package@v1 80 | with: 81 | package_name: ferium-bin 82 | commit_username: "Ilesh Thiada" 83 | commit_email: ileshkt@gmail.com 84 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 85 | - name: Update gui AUR package 86 | uses: aksh1618/update-aur-package@v1 87 | with: 88 | package_name: ferium-gui-bin 89 | commit_username: "Ilesh Thiada" 90 | commit_email: ileshkt@gmail.com 91 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 92 | 93 | homebrew-update: 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: actions/checkout@v4 97 | with: 98 | fetch-depth: 0 99 | 100 | - name: Get the latest tag 101 | run: echo "TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV 102 | 103 | - name: Update Homebrew formula 104 | uses: mislav/bump-homebrew-formula-action@v3 105 | with: 106 | tag-name: ${{ env.TAG }} 107 | env: 108 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} 109 | 110 | winget-update: 111 | runs-on: ubuntu-latest 112 | steps: 113 | - uses: actions/checkout@v4 114 | with: 115 | fetch-depth: 0 116 | 117 | - uses: vedantmgoyal9/winget-releaser@v2 118 | with: 119 | identifier: GorillaDevs.Ferium 120 | fork-user: theRookieCoder 121 | installers-regex: ferium-windows-msvc\.zip 122 | token: ${{ secrets.COMMITTER_TOKEN }} 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.idea 3 | /.vscode 4 | /out 5 | /perf 6 | /tests/configs/running 7 | /tests/mods 8 | /tests/md_modpack 9 | /tests/cf_modpack 10 | **/.DS_Store 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | [workspace.dependencies] 4 | reqwest = { version = "0.12", default-features = false, features = [ 5 | "rustls-tls", 6 | ] } 7 | clap = { version = "4.5", features = ["derive", "env"] } 8 | serde_json = "1.0" 9 | octocrab = "0.44" 10 | ferinth = "2.12" 11 | furse = "1.6" 12 | 13 | [package] 14 | 15 | name = "ferium" 16 | version = "5.0.0" 17 | repository = "https://github.com/gorilla-devs/ferium" 18 | description = "Fast CLI program for managing Minecraft mods and modpacks from Modrinth, CurseForge, and Github Releases" 19 | authors = [ 20 | ## Code 21 | "Ilesh Thiada (theRookieCoder) ", # AUR, Scoop, Homebrew, winget 22 | "atamakahere (atamakahere-git)", 23 | "Tuxinal", 24 | 25 | ## Package Management 26 | "KyleUltimateS", # AUR 27 | "ImperatorStorm", # AUR 28 | "leo60228", # Nixpkgs 29 | "Sofi (soupglasses)", # Nixpkgs 30 | "Elsie19", # Pacstall 31 | "Julianne (KokaKiwi)", # AUR 32 | "Loatchi", # Portage 33 | "ST-DDT", # winget 34 | ] 35 | 36 | license = "MPL-2.0" 37 | readme = "README.md" 38 | categories = ["command-line-utilities", "games"] 39 | keywords = ["minecraft", "mod-manager", "modrinth", "curseforge", "github"] 40 | 41 | edition = "2021" 42 | rust-version = "1.80" # Bound by `std::sync::LazyLock` 43 | exclude = [".github", "tests", "media"] 44 | 45 | 46 | [features] 47 | default = ["gui"] 48 | 49 | # Replaces the CLI text input with a GUI file dialogue for picking folders 50 | gui = ["rfd"] 51 | 52 | 53 | [dependencies] 54 | serde_json.workspace = true 55 | octocrab.workspace = true 56 | ferinth.workspace = true 57 | reqwest.workspace = true 58 | furse.workspace = true 59 | clap.workspace = true 60 | 61 | rfd = { version = "0.15", optional = true, default-features = false, features = [ 62 | "xdg-portal", 63 | "tokio", 64 | ] } 65 | tokio = { version = "1.44", default-features = false, features = [ 66 | "rt-multi-thread", 67 | "macros", 68 | ] } 69 | clap_complete = "4.5" 70 | parking_lot = "0.12" 71 | indicatif = "0.17" 72 | fs_extra = "1.3" 73 | colored = "3.0" 74 | inquire = "0.7" 75 | libium = { path = "./libium" } 76 | anyhow = "1.0" 77 | size = "0.5" 78 | 79 | [dev-dependencies] 80 | rand = "0.8" 81 | 82 | 83 | [profile.release] 84 | codegen-units = 1 85 | strip = true 86 | lto = "fat" 87 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: install-dev 2 | set windows-powershell := true 3 | 4 | # Install ferium to cargo's binary folder 5 | install: 6 | cargo install --force --path . 7 | 8 | # Install ferium to cargo's binary folder, but with faster compilation (offline & debug) 9 | install-dev: 10 | cargo install --offline --debug --force --path . 11 | 12 | # Delete test artefacts 13 | clean-test: 14 | rm -rf tests/mods \ 15 | tests/md_modpack \ 16 | tests/cf_modpack \ 17 | tests/configs/running 18 | -------------------------------------------------------------------------------- /libium/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for Libium 2 | 3 | ## `1.32.0` 4 | ### 5 | 6 | Added filters 7 | 8 | - Structs are defined in `config::filter` 9 | - 6 types: mod loader (prefer and any), game version, minor game version, release channel, and filename regex 10 | - Removed game version and mod loader from `Profile` 11 | - Removed `check_mod_loader` and `check_game_version` from `Mod` 12 | - Added `filters` to `Profile` and `Mod`, added `override_filters` option to `Mod` 13 | - Added `pin` to `Mod` 14 | - Rewrote `upgrade::check` to use filters instead 15 | - The method `Filter::filter` will return indices of the matching files 16 | - Mod resolution will now fail with detailed error messages, including if any of the filters produced an empty set, or if intersecting the filtered sets failed 17 | - Added `release_channel` to `DownloadFile` 18 | 19 | ## `1.31.0` 20 | ### Unreleased 21 | 22 | - Switched to `std::sync:LazyLock` and removed the `once_cell` dependency 23 | - Added `MODRINTH_API`, `GITHUB_API`, and `CURSEFORGE_API` as lazily initialised global variables so that they don't need to be passed around everywhere 24 | - Removed `APIs` 25 | - Changed `Downloadable` into `DownloadFile` and added game version and loader data so that it can be used to perform platform agnostic filtering 26 | - Made functions in `upgrade::check` platform agnostic 27 | - Added `ModIdentifier::fetch_version_likes` method to fetch platform agnostic `DownloadFiles` from its platform dependant enum variants 28 | - Replaces the `get_compatible_downloadable` function in `upgrade::mod_downloadable` 29 | 30 | ## `1.30.0` 31 | ### 09.08.2024 32 | 33 | - [`gorilla-devs/ferium#422`](https://github.com/gorilla-devs/ferium/issues/422): Fix a crash when identical files are scanned 34 | - Replace `tokio` IO with `std::io`, and `async_zip` with `zip` 35 | - Use `zip-extensions` for compressing and extracting to directories 36 | - Replace `modpack::extract_zip` `modpack::compress_dir` with re-exports of `zip_extract` and `zip_create_from_directory` from `zip_extensions` 37 | - Make many functions not `async` 38 | - Downloading modpacks from `upgrade::modpack_downloadable` no longer returns the file, it returns the path instead 39 | 40 | ## `1.29.0` 41 | ### 11.06.2024 42 | 43 | - Made argument and return type in `add::parse_id()` owned 44 | - Made `add()` take `ModIdentifier`s instead of `String`s, so the function itself doesn't parse IDs 45 | - Remove duplicate curseforge and modrinth IDs in `add()` 46 | - Added `scan` module 47 | - `scan()` reads the files in the provided directory, sends their hashes to modrinth and curseforge, and return the project/mod IDs provided 48 | 49 | ## `1.28.0` 50 | ### 10.06.2024 51 | 52 | - Add `APIs` struct to store and pass ferinth, furse, and octocrab clients together 53 | - Make `ModIdentifierRef::GitHubRepository` have the references inside the tuple 54 | - `ModIdentifierRef` is now `Copy` 55 | - Replace many instances of `&str` with `impl AsRef` 56 | - Change `upgrade::check::github()` to accept a list of asset names instead 57 | - This is so that both REST and GraphQL responses can be used 58 | - Improved error messages for custom error types 59 | 60 | #### Completely reworked project adding 61 | 62 | - Simplify error handling since custom catching of "not found" errors is no longer needed 63 | - Added a function to parse a string into either a curseforge, github, or modrinth identifier 64 | - Required information about projects is now sent batched to the relevant APIs 65 | - GitHub batched queries use GraphQL 66 | - `github()`, `curseforge()`, and `modrinth()` do not perform any network requests, they solely use the data provided in their arguments 67 | - All of these functions now perform compatibility checks by themselves, again without any additional network requests 68 | 69 | ## `1.27.0` 70 | ### 21.05.24 71 | 72 | - Update dependencies 73 | - Replace references with `AsRef` in as many places as possible 74 | - Replace functions generics with direct `impl`s as much as possible 75 | - Added `add_multiple` and `add_single` functions to `add` from ferium to facilitate adding of multiple mods 76 | 77 | ## `1.26.2` 78 | ### 23.02.2024 79 | 80 | Add Fabric backwards compatibility for Quilt when adding Modrinth mods. 81 | 82 | ## `1.26.1` 83 | ### 22.02.2024 84 | 85 | Fix a bug where the directory to which a file was being downloaded would not be created. 86 | 87 | ## `1.26.0` 88 | ### 22.02.2024 89 | 90 | - Replace `Option` with `bool` for whether or not to check game version or mod loader 91 | - Added `ModIdentifierRef` and `ModIdentifier.as_ref()` for comparing mod identifiers without cloning 92 | - Replaced many procedural loops with functional alternatives 93 | - Added `Profile.get_version(check_game_version)` and `Profile.get_loader(check_mod_loader)` to replace this common pattern: 94 | ```rs 95 | if check_(game_version | mod_loader) { 96 | Some(&profile.(game_version | mod_loader)) 97 | } else { 98 | None 99 | } 100 | ``` 101 | - Use the `game_versions` and `loaders` specified in the Modrinth `Project` struct instead of using version resolution 102 | - Only use `.to_string()` instead of `.into()` when converting a value to a `String` 103 | - Replace `config::file_path()` with `DEFAULT_CONFIG_PATH` which uses `Lazy` 104 | - Extract the file opening to `open_config_file()` 105 | - Move `config::read_file()` to `read_wrapper()` since it is not specific to the `config` module 106 | - Derive and use `Default` for the `config::Config` when creating an empty config 107 | - Skip serialising the active index and profiles/modpacks vectors if they're zero or empty 108 | - Remove the `Option` in `check_game_version` and `check_mod_loader` fields for `Mod` 109 | - Replace `TryFrom<&str>` with `FromStr` for `ModLoader` 110 | - Derive `Copy` for `ModLoader` 111 | - Determine `get_minecraft_dir()` at compile-time 112 | - Set the UNIX permissions when compressing a directory 113 | - Replace `curseforge::read_manifest_file()` and `modrinth::read_metadata_file()` with `read_file_from_zip()` 114 | - Refactor `upgrade::check` to make it more readable 115 | - Remove the subdirectory classification done when converting to a `Downloadable`, modpack installers can do this manually 116 | 117 | ## `1.25.0` 118 | ### 07.02.2024 119 | 120 | Support for [NeoForge](https://neoforged.net) 121 | 122 | ## `1.24.2` 123 | ### 05.02.2024 124 | 125 | - Fix [#343](https://github.com/gorilla-devs/ferium/issues/343); When checking github assets, check that the name _ends with_ `.jar`, and strip it before splitting the string 126 | - Tweak the distribution denied error message 127 | 128 | ## `1.24.1` 129 | ### 30.01.2024 130 | 131 | Fix compilation on linux 132 | 133 | ## `1.24.0` 134 | ### 28.01.2024 135 | 136 | - In `add.rs`, add mods to the profile and return only the mod name 137 | - Add option to override compatibility checks when adding 138 | - Update `async_zip` to the latest version `0.0.16` 139 | 140 | ## `1.23.0` 141 | ### 23.03.2023 142 | 143 | - Switch to `async_zip` 144 | - Add `name` argument to `pick_folder()` 145 | - Move `get_minecraft_dir()` to root folder, remove `misc` module and `get_major_mc_versions()` 146 | - Reading manifest or metadata files now returns an optional result 147 | - Removed the rather redundant `deser_manifest()` and `deser_metadata()` functions 148 | - Add a recursive `compress_dir()` function 149 | - Tweak Modrinth modpack structs to use ferinth's types 150 | - Tweak `Downloadable`'s file length field's type 151 | - Wrap `Downloadable::download()`'s opened file in a `BufWriter` 152 | - Only update the progress bar after the write is finished 153 | - Remove `mutex_ext` and `force_lock()` 154 | 155 | ## `1.22.1` 156 | ### 01.01.2023 157 | 158 | - Only use required features for `zip` 159 | - Switch to `once_cell` and remove `lazy_static` 160 | 161 | ## `1.22.0` 162 | ### 23.12.2022 163 | 164 | Loosen dependency specification and remove unnecessary `bytes` dependency 165 | 166 | ## `1.21.1` 167 | ### 13.11.2022 168 | 169 | Fixed a bug where the file returned from `config::get_file()` is not readable if it's newly created 170 | 171 | ## `1.21.0` 172 | ### 13.11.2022 173 | 174 | - Update dependencies, remove `urlencoding` and `size` 175 | - Remove unnecessary `Arc`s 176 | - Use the website URL to determine that a project is a a mod/modpack on CF 177 | - Simplify `config` module methods 178 | - Remove redundant doc-comments 179 | - File picker now uses sync dialogue on all platforms 180 | - Edit `file_picker.rs` to use the updated feature flags, fixes [gorilla-devs/ferium#228](https://github.com/gorilla-devs/ferium/issues/228) 181 | - The file picker function will now resolve `~` and `.` to the home and cwd respectively 182 | - Added the android PojavLauncher to the default minecraft directory function 183 | - Change the function signature of `check` functions 184 | - Change `Downloadable`'s `size` field into `length`, remove the `Option`, and make it a number 185 | - Remove the `total` closure in `Downloadable::download()` 186 | - Remove `Downloadable::from_file_id()` 187 | - Edit functions in `mod_downloadable.rs` to match those of `check.rs` 188 | 189 | ## `1.20.0` 190 | ### 03.09.2022 191 | 192 | - Update dependencies 193 | - Clean up imports in `add.rs` 194 | - Switch to only XDG backend for `rfd` 195 | - `add::modrinth()` and `add::curseforge()` now directly accept the project struct 196 | 197 | ## `1.19.2` 198 | ### 18.07.2022 199 | 200 | Fix a bug where the file is not rewound after being written to 201 | 202 | Fixes [gorilla-devs/ferium#87](https://github.com/gorilla-devs/ferium/issues/87) 203 | 204 | ## `1.19.1` 205 | ### 17.07.2022 206 | 207 | Update dependencies 208 | 209 | ## `1.19.0` 210 | ### 24.06.2022 211 | 212 | - Update dependencies 213 | - Make `Downloadable` use `url::Url` 214 | 215 | ## `1.18.2` 216 | ### 12.06.2022 217 | 218 | Update ferinth minor version 219 | 220 | ## `1.18.1` 221 | ### 07.06.2022 222 | 223 | - Update dependencies 224 | - [gorilla-devs/ferium#113](https://github.com/gorilla-devs/ferium/issues/113) Make dependencies use `~` so that only minor versions are auto updated 225 | - Many small clippy lint fixes 226 | 227 | ## `1.18.0` 228 | ### 30.05.2022 229 | 230 | - Improve error messages 231 | - Add functions no longer add the mod to the config 232 | - Modpack manifests will now accept unknown fields 233 | - `DistributionDeniedError` now has mod_id and file_id fields 234 | 235 | ## `1.16.0` 236 | ### 18.05.2022 237 | 238 | Implemented CurseForge's third party distribution restrictions 239 | 240 | ## `1.15.5` 241 | ### 18.05.2022 242 | 243 | `modpack::add` no longer adds the project to the config 244 | 245 | ## `1.15.4` 246 | ### 17.05.2022 247 | 248 | - Add `install_overrides` field to `Modpack` in config 249 | - Change `get_curseforge_manifest` and `get_modrinth_manifest` to `download_curseforge_modpack` and `download_modrinth_modpack` respectively 250 | 251 | ## `1.15.3` 252 | ### 16.05.2022 253 | 254 | - Added Modrinth modpacks 255 | - Modpack add commands only return the project struct now 256 | - Change `Downloadable::filename` to `output` which will include the path from the instance directory 257 | - Added `Downloadable::size` for the file size 258 | 259 | ## `1.15.2` 260 | ### 16.05.2022 261 | 262 | - `Downloadable::download()` now directly downloads to the output file as a `.part`, it will rename it back to the actual filename after it finishes downloading 263 | - The `progress` closure is now a `total` and `update` closure 264 | - `Downloadable::from_ids()` now properly decodes percent characters (e.g. `%20` -> ` `) 265 | 266 | ## `1.15.1` 267 | ### 15.05.2022 268 | 269 | - Update to Furse `1.1.2` 270 | - Add `from_ids` to create a downloadable from a curseforge project and file id 271 | 272 | ## `1.15.0` 273 | ### 14.05.2022 274 | 275 | - Added minor versions to all dependencies 276 | - Moved `check` and `upgrade` to `upgrade::check` and `upgrade::mod_downloadable` 277 | - Moved the `Downloadable` to `upgrade`, it also has a new `download()` function 278 | - Added modpacks to the config 279 | - Added `modpack` with a curseforge modpack and a function to add that to the config 280 | 281 | ## `1.14.1` 282 | ### 12.05.2022 283 | 284 | - Changed `misc::get_mods_dir()` to `misc::get_minecraft_dir()`, the new function only returns the default Minecraft instance directory 285 | - Added `config::read_file()` and `config::deserialise()` 286 | - The add commands now return the latest compatible _ of the mod 287 | - Added `Error::Incompatible` to go along with this 288 | - The curseforge add command checks if the project is a mod using the same method as the github add command 289 | 290 | ## `1.14.0` 291 | ### 11.05.2022 292 | 293 | Revert back to octocrab 294 | 295 | ## `1.13.0` 296 | ### 10.05.2022 297 | 298 | - Move from octocrab to [octorust](https://crates.io/crates/octorust) 299 | - This fixes [#52](https://github.com/theRookieCoder/ferium/issues/52) 300 | - (I later realise that even though it does, octocrab was fine) 301 | - Many GitHub related functions have had their signatures changed 302 | - The `upgrade` functions have been slightly updated 303 | - Removed unnecessary `async`s 304 | - Replaced many `Vec<_>`s with `&[_]` 305 | - The add functions now check if mods have the same name too 306 | - This fixes [#53](https://github.com/theRookieCoder/ferium/issues/53) 307 | 308 | ## `1.12.0` 309 | ### 09.05.2022 310 | 311 | - Rename the `upgrade` module to `check` 312 | - Changes in `check` 313 | - Removed error 314 | - `write_mod_file()` now takes an output directory rather than a whole file 315 | - The functions now take a vector of items to search and return a reference to the latest compatible one using an `Option` 316 | - The modrinth function now return the primary version file along side the version 317 | - Create a new upgrade module which actually does upgrading stuff 318 | - Functions to get the latest compatible 'item' for each mod source. These functions also implement the Quilt->Fabric backwards compatibility 319 | - A function to use the previously mentioned functions from a mod identifier to return a downloadable 320 | 321 | ## `1.11.4` 322 | ### 08.05.2022 323 | 324 | - Do not check the release name when checking the game version for github releases 325 | - This fixes Ferium [#47](https://github.com/theRookieCoder/ferium/issues/47) 326 | 327 | ## `1.11.3` 328 | ### 05.05.2022 329 | 330 | - Added `prompt` to file pickers 331 | - Used the `default` provided to the no-gui pick folder 332 | 333 | ## `1.11.2` 334 | ### 05.05.2022 335 | 336 | Change macOS default mods directory from using the `ApplicationSupport` shortcut to the actual `Application Support` directory 337 | 338 | ## `1.11.1` 339 | ### 04.05.2022 340 | 341 | - Updated to Ferinth `2.2` 342 | - Add commands now accept `should_check_game_version` and `should_check_mod_loader` 343 | - They also use this when adding the mod to the config 344 | 345 | ## `1.11.0` 346 | ### 03.05.2022 347 | 348 | - Replace the `for` loop in `check_mod_loader()` with an iterator call 349 | - The upgrade functions no longer deal with Quilt -> Fabric backwards compatibility 350 | - Upgrade functions (again) return only the compatible asset they found 351 | - Upgrade functions no longer take a `profile`, they check for compatibility with the `game_version_to_check` and `mod_loader_to_check` provided 352 | 353 | ## `1.10.0` 354 | ### 01.05.2022 355 | 356 | - Added minor versions to `Cargo.toml` 357 | - Update to Furse `1.1` 358 | - Implemented new error type 359 | - Simplified checking if a project had already been added 360 | - `upgrade::github()` now checks that the asset isn't a sources jar 361 | 362 | ## [1.9.0] - 24.04.2022 363 | 364 | - Added Quilt to `ModLoader` 365 | - Added `check_mod_loader()` to check mod loader compatibility 366 | - The upgrade functions now return additional info, whether the mod was deemed compatible through backwards compatibility (e.g. Fabric mod on Quilt) 367 | - Generally improved code in `upgrade` 368 | 369 | ## [1.8.0] - 20.04.2022 370 | 371 | - Added a `check_mod_loader` and `check_game_version` flag to each mod 372 | - They are `None` by default 373 | - If they are `Some(false)` then the corresponding checks are skipped in `upgrade.rs` 374 | - Removed `no_patch_check`, `remove_semver_patch()`, `SemVerError`, and the `semver` dependency 375 | 376 | ## [1.7.0] - 15.04.2022 377 | 378 | - Remove `config` from function names in config module 379 | - Upgrade functions no longer download and write the mod file 380 | - `write_mod_file()` is now public 381 | 382 | ## [1.6.0] - 02.04.2022 383 | 384 | Update the `config` struct format 385 | 386 | ## [1.5.0] - 29.03.2022 387 | 388 | - Moved `upgrade.rs` from ferium to libium 389 | - Added improved custom error handling 390 | - Improved doc comments 391 | - Made functions return the file/version/asset downloaded (similar to `add.rs`) 392 | - Changed some variable names 393 | 394 | ## [1.4.0] - 28.03.2022 395 | 396 | - Moved `add.rs` from ferium to libium 397 | - Added improved custom error handling 398 | - Extracted file dialogues to `file_picker.rs` 399 | -------------------------------------------------------------------------------- /libium/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "libium" 3 | version = "1.32.0" 4 | edition = "2021" 5 | authors = [ 6 | "Ilesh Thiada (theRookieCoder) ", 7 | "Daniel Hauck (SolidTux)", 8 | ] 9 | description = "Multi-source backend for managing Minecraft mods and modpacks from Modrinth, CurseForge, and Github Releases" 10 | repository = "https://github.com/gorilla-devs/ferium/tree/main/libium" 11 | readme = "README.md" 12 | license = "MPL-2.0" 13 | categories = ["games"] 14 | keywords = [ 15 | "minecraft", 16 | "mod-manager", 17 | "modrinth", 18 | "curseforge", 19 | "github-releases", 20 | ] 21 | 22 | [dependencies] 23 | serde_json.workspace = true 24 | octocrab.workspace = true 25 | ferinth.workspace = true 26 | reqwest.workspace = true 27 | furse.workspace = true 28 | clap.workspace = true 29 | 30 | derive_more = { version = "2.0", features = ["display"] } 31 | serde = { version = "1.0", features = ["derive"] } 32 | url = { version = "2.5", features = ["serde"] } 33 | zip-extensions = "0.8" 34 | futures-util = "0.3" 35 | directories = "6.0" 36 | thiserror = "2.0" 37 | regex = "1.11" 38 | sha1 = "0.10" 39 | zip = "2.5" 40 | -------------------------------------------------------------------------------- /libium/README.md: -------------------------------------------------------------------------------- 1 | # Libium 2 | 3 | > [!IMPORTANT] 4 | > This project used to be in its [own repository](https://github.com/gorilla-devs/libium), but it has now been moved into ferium's repository to make pull requests and dependency syncing easier. 5 | > You will need to go to the old repository to see the commit history. 6 | 7 | Libium is the backend of [ferium](https://github.com/gorilla-devs/ferium). It helps manage Minecraft mods from Modrinth, CurseForge, and Github Releases. 8 | 9 | Here's a brief description of the main components of libium; 10 | 11 | - `config` defines the config structure and methods to get the config file, deserialise it, upgrade it to a new version, etc. 12 | - `modpack` contains manifest/metadata structs for Modrinth and CurseForge modpack formats, reads these from a zip file, and adds modpacks to configs. 13 | - `upgrade` defines and implements filters, and fetches the latest compatible mod/modpack file, and downloads it. 14 | - `add` verifies and adds a mod to a profile. 15 | - `scan` hashes mod files in a directory and sends them to the Modrinth and CurseForge APIs to retrieve mod information and add them to a profile. 16 | -------------------------------------------------------------------------------- /libium/src/config/filters.rs: -------------------------------------------------------------------------------- 1 | use super::structs::ModLoader; 2 | use crate::iter_ext::IterExt as _; 3 | use derive_more::derive::Display; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Display, Clone)] 7 | pub enum Filter { 8 | /// Prefers files in the order of the given loaders 9 | /// 10 | /// Implementation detail: This filter only works as intended if it is run last on an already filtered list. 11 | #[display("Mod Loader ({})", _0.iter().display(", "))] 12 | ModLoaderPrefer(Vec), 13 | 14 | /// Selects files that are compatible with any of the given loaders 15 | #[display("Mod Loader Either ({})", _0.iter().display(", "))] 16 | ModLoaderAny(Vec), 17 | 18 | /// Selects files strictly compatible with the versions specified 19 | #[display("Game Version ({})", _0.iter().display(", "))] 20 | GameVersionStrict(Vec), 21 | 22 | /// Selects files compatible with the versions specified and related versions that are 23 | /// considered to not have breaking changes (determined using Modrinth's game version tag list) 24 | #[display("Game Version Minor ({})", _0.iter().display(", "))] 25 | GameVersionMinor(Vec), 26 | 27 | /// Selects files matching the channel provided or more stable channels 28 | #[display("Release Channel ({_0})")] 29 | ReleaseChannel(ReleaseChannel), 30 | 31 | /// Selects the files with filenames matching the provided regex 32 | #[display("Filename ({_0})")] 33 | Filename(String), 34 | 35 | /// Selects files with titles matching the provided regex 36 | #[display("Title ({_0})")] 37 | Title(String), 38 | 39 | /// Selects files with descriptions matching the provided regex 40 | #[display("Description ({_0})")] 41 | Description(String), 42 | } 43 | 44 | pub trait ProfileParameters { 45 | /// Get the game versions present, if self has `GameVersionStrict` or `GameVersionMinor` 46 | fn game_versions(&self) -> Option<&Vec>; 47 | /// Get the first mod loader present, if self has `ModLoaderPrefer` or `ModLoaderAny` 48 | fn mod_loader(&self) -> Option<&ModLoader>; 49 | /// Get the game versions present, if self has `GameVersionStrict` or `GameVersionMinor` 50 | fn game_versions_mut(&mut self) -> Option<&mut Vec>; 51 | /// Get the mod loaders present, if self has `ModLoaderPrefer` or `ModLoaderAny` 52 | fn mod_loaders_mut(&mut self) -> Option<&mut Vec>; 53 | } 54 | 55 | impl ProfileParameters for Vec { 56 | fn game_versions(&self) -> Option<&Vec> { 57 | self.iter().find_map(|filter| match filter { 58 | Filter::GameVersionStrict(v) => Some(v), 59 | Filter::GameVersionMinor(v) => Some(v), 60 | _ => None, 61 | }) 62 | } 63 | 64 | fn mod_loader(&self) -> Option<&ModLoader> { 65 | self.iter().find_map(|filter| match filter { 66 | Filter::ModLoaderPrefer(v) => v.first(), 67 | Filter::ModLoaderAny(v) => v.first(), 68 | _ => None, 69 | }) 70 | } 71 | 72 | fn game_versions_mut(&mut self) -> Option<&mut Vec> { 73 | self.iter_mut().find_map(|filter| match filter { 74 | Filter::GameVersionStrict(v) => Some(v), 75 | Filter::GameVersionMinor(v) => Some(v), 76 | _ => None, 77 | }) 78 | } 79 | 80 | fn mod_loaders_mut(&mut self) -> Option<&mut Vec> { 81 | self.iter_mut().find_map(|filter| match filter { 82 | Filter::ModLoaderPrefer(v) => Some(v), 83 | Filter::ModLoaderAny(v) => Some(v), 84 | _ => None, 85 | }) 86 | } 87 | } 88 | 89 | // impl PartialEq for Filter { 90 | // fn eq(&self, other: &Self) -> bool { 91 | // discriminant(self) == discriminant(other) 92 | // } 93 | // } 94 | 95 | #[derive(Deserialize, Serialize, Debug, Display, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] 96 | pub enum ReleaseChannel { 97 | Release, 98 | Beta, 99 | Alpha, 100 | } 101 | -------------------------------------------------------------------------------- /libium/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod filters; 2 | pub mod structs; 3 | use std::{ 4 | fs::{create_dir_all, File}, 5 | io::{BufReader, Result}, 6 | path::Path, 7 | }; 8 | 9 | /// Open the config file at `path` and deserialise it into a config struct 10 | pub fn read_config(path: impl AsRef) -> Result { 11 | if !path.as_ref().exists() { 12 | create_dir_all(path.as_ref().parent().expect("Invalid config directory"))?; 13 | write_config(&path, &structs::Config::default())?; 14 | } 15 | 16 | let config_file = BufReader::new(File::open(&path)?); 17 | let mut config: structs::Config = serde_json::from_reader(config_file)?; 18 | 19 | config 20 | .profiles 21 | .iter_mut() 22 | .for_each(structs::Profile::backwards_compat); 23 | 24 | Ok(config) 25 | } 26 | 27 | /// Serialise `config` and write it to the config file at `path` 28 | pub fn write_config(path: impl AsRef, config: &structs::Config) -> Result<()> { 29 | let config_file = File::create(path)?; 30 | serde_json::to_writer_pretty(config_file, config)?; 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /libium/src/config/structs.rs: -------------------------------------------------------------------------------- 1 | use super::filters::Filter; 2 | use derive_more::derive::Display; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{path::PathBuf, str::FromStr}; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Default, Clone)] 7 | pub struct Config { 8 | #[serde(skip_serializing_if = "is_zero")] 9 | #[serde(default)] 10 | pub active_profile: usize, 11 | 12 | #[serde(skip_serializing_if = "Vec::is_empty")] 13 | #[serde(default)] 14 | pub profiles: Vec, 15 | 16 | #[serde(skip_serializing_if = "is_zero")] 17 | #[serde(default)] 18 | pub active_modpack: usize, 19 | 20 | #[serde(skip_serializing_if = "Vec::is_empty")] 21 | #[serde(default)] 22 | pub modpacks: Vec, 23 | } 24 | 25 | const fn is_zero(n: &usize) -> bool { 26 | *n == 0 27 | } 28 | 29 | #[derive(Deserialize, Serialize, Debug, Clone)] 30 | pub struct Modpack { 31 | pub name: String, 32 | pub output_dir: PathBuf, 33 | pub install_overrides: bool, 34 | pub identifier: ModpackIdentifier, 35 | } 36 | 37 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 38 | pub enum ModpackIdentifier { 39 | CurseForgeModpack(i32), 40 | ModrinthModpack(String), 41 | } 42 | 43 | #[derive(Deserialize, Serialize, Debug, Clone)] 44 | pub struct Profile { 45 | pub name: String, 46 | 47 | /// The directory to download mod files to 48 | pub output_dir: PathBuf, 49 | 50 | // There will be no filters when reading a v4 config 51 | #[serde(default)] 52 | pub filters: Vec, 53 | 54 | pub mods: Vec, 55 | 56 | // Kept for backwards compatibility reasons (i.e. migrating from a v4 config) 57 | #[serde(skip_serializing)] 58 | game_version: Option, 59 | #[serde(skip_serializing)] 60 | mod_loader: Option, 61 | } 62 | 63 | impl Profile { 64 | /// A simple constructor that automatically deals with converting to filters 65 | pub fn new( 66 | name: String, 67 | output_dir: PathBuf, 68 | game_versions: Vec, 69 | mod_loader: ModLoader, 70 | ) -> Self { 71 | Self { 72 | name, 73 | output_dir, 74 | filters: vec![ 75 | Filter::ModLoaderPrefer(match mod_loader { 76 | ModLoader::Quilt => vec![ModLoader::Quilt, ModLoader::Fabric], 77 | _ => vec![mod_loader], 78 | }), 79 | Filter::GameVersionStrict(game_versions), 80 | ], 81 | mods: vec![], 82 | game_version: None, 83 | mod_loader: None, 84 | } 85 | } 86 | 87 | /// Convert the v4 profile's `game_version` and `mod_loader` fields into filters 88 | pub(crate) fn backwards_compat(&mut self) { 89 | if let (Some(version), Some(loader)) = (self.game_version.take(), self.mod_loader.take()) { 90 | self.filters = vec![ 91 | Filter::ModLoaderPrefer(match loader { 92 | ModLoader::Quilt => vec![ModLoader::Quilt, ModLoader::Fabric], 93 | _ => vec![loader], 94 | }), 95 | Filter::GameVersionStrict(vec![version]), 96 | ]; 97 | } 98 | 99 | for mod_ in &self.mods { 100 | if mod_.check_game_version.is_some() || mod_.check_mod_loader.is_some() { 101 | eprintln!("WARNING: Check overrides found for {}", mod_.name); 102 | eprintln!("Migrate to the new filter system if necessary!"); 103 | } 104 | } 105 | } 106 | 107 | pub fn push_mod( 108 | &mut self, 109 | name: String, 110 | identifier: ModIdentifier, 111 | slug: String, 112 | override_filters: bool, 113 | filters: Vec, 114 | ) { 115 | self.mods.push(Mod { 116 | name, 117 | slug: Some(slug), 118 | identifier, 119 | filters, 120 | override_filters, 121 | check_game_version: None, 122 | check_mod_loader: None, 123 | }) 124 | } 125 | } 126 | 127 | #[derive(Deserialize, Serialize, Debug, Clone)] 128 | pub struct Mod { 129 | pub name: String, 130 | pub identifier: ModIdentifier, 131 | 132 | // Is an `Option` for backwards compatibility reasons, 133 | // since the slug field didn't exist in older ferium versions 134 | #[serde(skip_serializing_if = "Option::is_none")] 135 | pub slug: Option, 136 | 137 | /// Custom filters that apply only for this mod 138 | #[serde(skip_serializing_if = "Vec::is_empty")] 139 | #[serde(default)] 140 | pub filters: Vec, 141 | 142 | /// Whether the filters specified above replace or apply with the profile's filters 143 | #[serde(skip_serializing_if = "is_false")] 144 | #[serde(default)] 145 | pub override_filters: bool, 146 | 147 | // Kept for backwards compatibility reasons 148 | #[serde(skip_serializing)] 149 | check_game_version: Option, 150 | #[serde(skip_serializing)] 151 | check_mod_loader: Option, 152 | } 153 | 154 | impl Mod { 155 | pub fn new( 156 | name: String, 157 | identifier: ModIdentifier, 158 | filters: Vec, 159 | override_filters: bool, 160 | ) -> Self { 161 | Self { 162 | name, 163 | slug: None, 164 | identifier, 165 | filters, 166 | override_filters, 167 | check_game_version: None, 168 | check_mod_loader: None, 169 | } 170 | } 171 | } 172 | 173 | const fn is_false(b: &bool) -> bool { 174 | !*b 175 | } 176 | 177 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 178 | pub enum ModIdentifier { 179 | CurseForgeProject(i32), 180 | ModrinthProject(String), 181 | GitHubRepository(String, String), 182 | 183 | PinnedCurseForgeProject(i32, i32), 184 | PinnedModrinthProject(String, String), 185 | PinnedGitHubRepository((String, String), i32), 186 | } 187 | 188 | #[derive(Deserialize, Serialize, Debug, Display, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] 189 | pub enum ModLoader { 190 | Quilt, 191 | Fabric, 192 | Forge, 193 | #[clap(name = "neoforge")] 194 | NeoForge, 195 | } 196 | 197 | #[derive(thiserror::Error, Debug, PartialEq, Eq)] 198 | #[error("The given string is not a recognised mod loader")] 199 | pub struct ModLoaderParseError; 200 | 201 | impl FromStr for ModLoader { 202 | type Err = ModLoaderParseError; 203 | 204 | // This implementation is case-insensitive 205 | fn from_str(from: &str) -> Result { 206 | match from.trim().to_lowercase().as_str() { 207 | "quilt" => Ok(Self::Quilt), 208 | "fabric" => Ok(Self::Fabric), 209 | "forge" => Ok(Self::Forge), 210 | "neoforge" => Ok(Self::NeoForge), 211 | _ => Err(Self::Err {}), 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /libium/src/iter_ext.rs: -------------------------------------------------------------------------------- 1 | pub trait IterExt: Iterator 2 | where 3 | Self: Sized, 4 | { 5 | fn collect_vec(self) -> Vec { 6 | self.collect::>() 7 | } 8 | 9 | fn collect_hashset(self) -> std::collections::HashSet 10 | where 11 | T: Eq + std::hash::Hash, 12 | { 13 | self.collect::>() 14 | } 15 | 16 | /// Delimits elements of `self` with a comma and returns a single string 17 | fn display(self, sep: impl AsRef) -> String 18 | where 19 | T: ToString, 20 | { 21 | self.map(|s| ToString::to_string(&s)) 22 | .collect_vec() 23 | .join(sep.as_ref()) 24 | } 25 | } 26 | impl> IterExt for U {} 27 | 28 | pub trait IterExtPositions: Iterator 29 | where 30 | Self: Sized, 31 | { 32 | /// Returns the indices of elements for which `predicate` returns true 33 | fn positions(self, predicate: impl Fn(T) -> bool) -> impl Iterator { 34 | self.filter_map(move |(i, e)| if predicate(e) { Some(i) } else { None }) 35 | } 36 | } 37 | impl> IterExtPositions for U {} 38 | -------------------------------------------------------------------------------- /libium/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | pub mod config; 3 | pub mod iter_ext; 4 | pub mod modpack; 5 | pub mod scan; 6 | pub mod upgrade; 7 | pub mod version_ext; 8 | 9 | pub use add::add; 10 | pub use scan::scan; 11 | 12 | use directories::{BaseDirs, ProjectDirs}; 13 | use std::{path::PathBuf, sync::LazyLock}; 14 | 15 | pub static GITHUB_API: LazyLock = LazyLock::new(|| { 16 | let mut github = octocrab::OctocrabBuilder::new(); 17 | if let Ok(token) = std::env::var("GITHUB_TOKEN") { 18 | github = github.personal_token(token); 19 | } 20 | github.build().expect("Could not build GitHub client") 21 | }); 22 | 23 | pub static CURSEFORGE_API: LazyLock = LazyLock::new(|| { 24 | furse::Furse::new(std::env::var("CURSEFORGE_API_KEY").unwrap_or(String::from( 25 | "$2a$10$sI.yRk4h4R49XYF94IIijOrO4i3W3dAFZ4ssOlNE10GYrDhc2j8K.", 26 | ))) 27 | }); 28 | 29 | pub static MODRINTH_API: LazyLock> = LazyLock::new(|| { 30 | ferinth::Ferinth::<()>::new( 31 | "ferium", 32 | // TODO: option_env!("CARGO_PKG_VERSION"), 33 | None, 34 | Some("Discord: therookiecoder"), 35 | ) 36 | }); 37 | 38 | pub static BASE_DIRS: LazyLock = 39 | LazyLock::new(|| BaseDirs::new().expect("Could not get OS specific directories")); 40 | 41 | pub static PROJECT_DIRS: LazyLock = LazyLock::new(|| { 42 | ProjectDirs::from("", "", "ferium").expect("Could not get OS specific directories") 43 | }); 44 | 45 | /// Gets the default Minecraft instance directory based on the current compilation `target_os` 46 | pub fn get_minecraft_dir() -> PathBuf { 47 | #[cfg(target_os = "macos")] 48 | { 49 | BASE_DIRS.data_dir().join("minecraft") 50 | } 51 | #[cfg(target_os = "windows")] 52 | { 53 | BASE_DIRS.data_dir().join(".minecraft") 54 | } 55 | #[cfg(not(any(target_os = "macos", target_os = "windows")))] 56 | { 57 | BASE_DIRS.home_dir().join(".minecraft") 58 | } 59 | } 60 | 61 | /// Read `source` and return the data as a string 62 | /// 63 | /// A wrapper for dealing with the read buffer. 64 | pub fn read_wrapper(mut source: impl std::io::Read) -> std::io::Result { 65 | let mut buffer = String::new(); 66 | source.read_to_string(&mut buffer)?; 67 | Ok(buffer) 68 | } 69 | -------------------------------------------------------------------------------- /libium/src/modpack/add.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::structs::{Config, ModpackIdentifier}, 3 | CURSEFORGE_API, MODRINTH_API, 4 | }; 5 | use ferinth::structures::project::{Project, ProjectType}; 6 | use furse::structures::mod_structs::Mod; 7 | use reqwest::StatusCode; 8 | 9 | type Result = std::result::Result; 10 | #[derive(thiserror::Error, Debug)] 11 | pub enum Error { 12 | #[error("Modpack is already added to profile")] 13 | AlreadyAdded, 14 | #[error("The provided modpack does not exist")] 15 | DoesNotExist, 16 | #[error("The project is not a modpack")] 17 | NotAModpack, 18 | #[error("Modrinth: {0}")] 19 | ModrinthError(ferinth::Error), 20 | #[error("CurseForge: {0}")] 21 | CurseForgeError(furse::Error), 22 | } 23 | 24 | impl From for Error { 25 | fn from(err: furse::Error) -> Self { 26 | if let furse::Error::ReqwestError(source) = &err { 27 | if Some(StatusCode::NOT_FOUND) == source.status() { 28 | Self::DoesNotExist 29 | } else { 30 | Self::CurseForgeError(err) 31 | } 32 | } else { 33 | Self::CurseForgeError(err) 34 | } 35 | } 36 | } 37 | 38 | impl From for Error { 39 | fn from(err: ferinth::Error) -> Self { 40 | if let ferinth::Error::ReqwestError(source) = &err { 41 | if Some(StatusCode::NOT_FOUND) == source.status() { 42 | Self::DoesNotExist 43 | } else { 44 | Self::ModrinthError(err) 45 | } 46 | } else { 47 | Self::ModrinthError(err) 48 | } 49 | } 50 | } 51 | 52 | /// Check if the project of `project_id` exists and is a modpack 53 | /// 54 | /// Returns the project struct 55 | pub async fn curseforge(config: &Config, project_id: i32) -> Result { 56 | let project = CURSEFORGE_API.get_mod(project_id).await?; 57 | 58 | // Check if project has already been added 59 | if config.modpacks.iter().any(|modpack| { 60 | modpack.name == project.name 61 | || ModpackIdentifier::CurseForgeModpack(project.id) == modpack.identifier 62 | }) { 63 | Err(Error::AlreadyAdded) 64 | 65 | // Check if the project is a modpack 66 | } else if !project.links.website_url.as_str().contains("modpacks") { 67 | Err(Error::NotAModpack) 68 | } else { 69 | Ok(project) 70 | } 71 | } 72 | 73 | /// Check if the project of `project_id` exists and is a modpack 74 | /// 75 | /// Returns the project struct 76 | pub async fn modrinth(config: &Config, project_id: &str) -> Result { 77 | let project = MODRINTH_API.project_get(project_id).await?; 78 | 79 | // Check if project has already been added 80 | if config.modpacks.iter().any(|modpack| { 81 | modpack.name == project.title 82 | || matches!( 83 | &modpack.identifier, 84 | ModpackIdentifier::ModrinthModpack(proj_id) if proj_id == &project.id 85 | ) 86 | }) { 87 | Err(Error::AlreadyAdded) 88 | 89 | // Check if the project is modpack 90 | } else if project.project_type != ProjectType::Modpack { 91 | Err(Error::NotAModpack) 92 | } else { 93 | Ok(project) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /libium/src/modpack/curseforge/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod structs; 2 | -------------------------------------------------------------------------------- /libium/src/modpack/curseforge/structs.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct Manifest { 6 | /// Information about how to setup Minecraft 7 | pub minecraft: Minecraft, 8 | /// The type of this manifest ?? 9 | pub manifest_type: ManifestType, 10 | pub manifest_version: i32, 11 | pub name: String, 12 | pub version: String, 13 | pub author: String, 14 | /// The files this modpack needs 15 | pub files: Vec, 16 | /// A directory of overrides to install 17 | pub overrides: String, 18 | } 19 | 20 | #[derive(Deserialize, Serialize, Debug, Clone)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct Minecraft { 23 | pub version: String, 24 | /// A list of mod loaders that can be used 25 | pub mod_loaders: Vec, 26 | } 27 | 28 | #[derive(Deserialize, Serialize, Debug, Clone)] 29 | #[serde(rename_all = "camelCase")] 30 | pub enum ManifestType { 31 | MinecraftModpack, 32 | } 33 | 34 | #[derive(Deserialize, Serialize, Debug, Clone)] 35 | pub struct ModpackFile { 36 | #[serde(rename = "projectID")] 37 | pub project_id: i32, 38 | #[serde(rename = "fileID")] 39 | pub file_id: i32, 40 | pub required: bool, 41 | } 42 | 43 | #[derive(Deserialize, Serialize, Debug, Clone)] 44 | #[serde(rename_all = "camelCase")] 45 | pub struct ModpackModLoader { 46 | /// The name/ID of the mod loader 47 | pub id: String, 48 | /// Whether this is the recommended mod loader 49 | pub primary: bool, 50 | } 51 | -------------------------------------------------------------------------------- /libium/src/modpack/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | pub mod curseforge; 3 | pub mod modrinth; 4 | 5 | pub use zip_extensions::{zip_create_from_directory, zip_extract}; 6 | 7 | use crate::read_wrapper; 8 | use std::io::{Read, Seek}; 9 | use zip::{result::ZipResult, ZipArchive}; 10 | 11 | /// Returns the contents of the `file_name` from the provided `input` zip file if it exists 12 | pub fn read_file_from_zip(input: impl Read + Seek, file_name: &str) -> ZipResult> { 13 | let mut zip_file = ZipArchive::new(input)?; 14 | 15 | let ret = if let Ok(entry) = zip_file.by_name(file_name) { 16 | Ok(Some(read_wrapper(entry)?)) 17 | } else { 18 | Ok(None) 19 | }; 20 | ret 21 | } 22 | -------------------------------------------------------------------------------- /libium/src/modpack/modrinth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod structs; 2 | 3 | use std::{ 4 | fs::{canonicalize, read_dir, File}, 5 | io::{copy, Write}, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use zip::{write::SimpleFileOptions, ZipWriter}; 10 | use zip_extensions::ZipWriterExtensions; 11 | 12 | /// Create a Modrinth modpack at `output` using the provided `metadata` and optional `overrides` 13 | pub fn create( 14 | output: &Path, 15 | metadata: &str, 16 | overrides: Option<&Path>, 17 | additional_mods: Option<&Path>, 18 | ) -> zip::result::ZipResult<()> { 19 | let mut writer = ZipWriter::new(File::create(output)?); 20 | let options = SimpleFileOptions::default(); 21 | 22 | // Add metadata to the zip file 23 | writer.start_file("modrinth.index.json", options)?; 24 | writer.write_all(metadata.as_bytes())?; 25 | 26 | // Add additional (non-Modrinth) mods to the zip file 27 | if let Some(path) = additional_mods { 28 | for entry in read_dir(path)? 29 | .flatten() 30 | .filter(|entry| entry.file_type().map(|e| e.is_file()).unwrap_or(false)) 31 | { 32 | let entry = canonicalize(entry.path())?; 33 | writer.start_file( 34 | PathBuf::from("overrides") 35 | .join("mods") 36 | .with_file_name(entry.file_name().unwrap()) 37 | .to_string_lossy(), 38 | options, 39 | )?; 40 | copy(&mut File::open(entry)?, &mut writer)?; 41 | } 42 | } 43 | 44 | // Add the overrides to the zip file 45 | if let Some(overrides) = overrides { 46 | writer.create_from_directory(&overrides.to_owned())?; 47 | } 48 | 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /libium/src/modpack/modrinth/structs.rs: -------------------------------------------------------------------------------- 1 | use ferinth::structures::{project::ProjectSupportRange, version::Hash, Int}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{collections::HashMap, path::PathBuf}; 4 | use url::Url; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Clone)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct Metadata { 9 | /// The version of the format, stored as a number. 10 | /// The current value at the time of writing is `1`. 11 | pub format_version: Int, 12 | pub game: Game, 13 | pub version_id: String, 14 | /// Human readable name of the modpack 15 | pub name: String, 16 | /// A short description of this modpack 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub summary: Option, 19 | /// A list of files for the modpack that needs 20 | pub files: Vec, 21 | /// A list of IDs and version numbers that launchers will use in order to know what to install 22 | pub dependencies: HashMap, 23 | } 24 | 25 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 26 | #[serde(rename_all = "kebab-case")] 27 | pub enum DependencyID { 28 | Minecraft, 29 | Forge, 30 | Neoforge, 31 | FabricLoader, 32 | QuiltLoader, 33 | } 34 | 35 | #[derive(Deserialize, Serialize, Debug, Clone)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct ModpackFile { 38 | /// The destination path of this file, relative to the Minecraft instance directory 39 | pub path: PathBuf, 40 | pub hashes: Hash, 41 | /// The specific environment this file exists on 42 | #[serde(skip_serializing_if = "Option::is_none")] 43 | pub env: Option, 44 | /// HTTPS URLs where this file may be downloaded 45 | pub downloads: Vec, 46 | /// The size of the file in bytes 47 | pub file_size: Int, 48 | } 49 | 50 | #[derive(Deserialize, Serialize, Debug, Clone)] 51 | #[serde(rename_all = "camelCase")] 52 | pub struct ModpackFileEnvironment { 53 | client: ProjectSupportRange, 54 | server: ProjectSupportRange, 55 | } 56 | 57 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 58 | #[serde(rename_all = "camelCase")] 59 | pub enum Game { 60 | Minecraft, 61 | } 62 | -------------------------------------------------------------------------------- /libium/src/scan.rs: -------------------------------------------------------------------------------- 1 | use crate::{CURSEFORGE_API, MODRINTH_API}; 2 | use futures_util::{try_join, TryFutureExt}; 3 | use sha1::{Digest, Sha1}; 4 | use std::{ 5 | collections::HashMap, 6 | fs::{read, read_dir}, 7 | path::Path, 8 | }; 9 | 10 | #[derive(thiserror::Error, Debug)] 11 | #[error(transparent)] 12 | pub enum Error { 13 | IOError(#[from] std::io::Error), 14 | ModrinthError(#[from] ferinth::Error), 15 | CurseForgeError(#[from] furse::Error), 16 | } 17 | type Result = std::result::Result; 18 | 19 | /// Scans `dir_path` and return the filename, Modrinth project ID, and CurseForge mod ID for each JAR file 20 | /// 21 | /// Calls `hashing_complete` after reading and hashing files is done. 22 | pub async fn scan( 23 | dir_path: impl AsRef, 24 | hashing_complete: impl Fn(), 25 | ) -> Result, Option)>> { 26 | let mut filenames = HashMap::new(); 27 | let mut mr_hashes = vec![]; 28 | let mut cf_hashes = vec![]; 29 | 30 | for entry in read_dir(dir_path)? { 31 | let path = entry?.path(); 32 | if path.is_file() 33 | && path 34 | .extension() 35 | .is_some_and(|ext| ext.eq_ignore_ascii_case("jar")) 36 | { 37 | let bytes = read(&path)?; 38 | 39 | let mr_hash = format!("{:x}", Sha1::digest(&bytes)); 40 | let cf_hash = furse::cf_fingerprint(&bytes); 41 | 42 | if let Some(filename) = path.file_name() { 43 | // Only add the hashes if this file wasn't already hashed 44 | if filenames.insert(cf_hash, filename.to_owned()).is_none() { 45 | mr_hashes.push(mr_hash); 46 | cf_hashes.push(cf_hash); 47 | } 48 | } 49 | } 50 | } 51 | 52 | hashing_complete(); 53 | 54 | let (mr_results, cf_results) = try_join!( 55 | MODRINTH_API 56 | .version_get_from_multiple_hashes(mr_hashes.clone()) 57 | .map_err(Error::from), 58 | CURSEFORGE_API 59 | .get_fingerprint_matches(cf_hashes.clone()) 60 | .map_err(Error::from), 61 | )?; 62 | 63 | // Elide explicit type parameters when https://github.com/rust-lang/rust/issues/90879 is resolved. 64 | let mut mr_results = 65 | HashMap::<_, _>::from_iter(mr_results.into_iter().map(|(k, v)| (k, v.project_id))); 66 | let mut cf_results = HashMap::<_, _>::from_iter( 67 | cf_results 68 | .exact_fingerprints 69 | .into_iter() 70 | .zip(cf_results.exact_matches.into_iter().map(|m| m.id)), 71 | ); 72 | 73 | Ok(mr_hashes 74 | .iter() 75 | .zip(&cf_hashes) 76 | .map(|(mr, cf)| { 77 | ( 78 | filenames 79 | .remove(cf) 80 | .expect("Missing filename in hashmap") 81 | .to_string_lossy() 82 | .into_owned(), 83 | mr_results.remove(mr), 84 | cf_results.remove(&(*cf as i64)), 85 | ) 86 | }) 87 | .collect()) 88 | } 89 | -------------------------------------------------------------------------------- /libium/src/upgrade/check.rs: -------------------------------------------------------------------------------- 1 | use super::Metadata; 2 | use crate::{ 3 | config::filters::{Filter, ReleaseChannel}, 4 | iter_ext::{IterExt, IterExtPositions}, 5 | MODRINTH_API, 6 | }; 7 | use ferinth::structures::tag::GameVersionType; 8 | use regex::Regex; 9 | use std::{collections::HashSet, sync::OnceLock}; 10 | 11 | #[derive(thiserror::Error, Debug)] 12 | #[error(transparent)] 13 | pub enum Error { 14 | VersionGrouping(#[from] ferinth::Error), 15 | FilenameRegex(#[from] regex::Error), 16 | #[error("The following filter(s) were empty: {}", _0.iter().display(", "))] 17 | FilterEmpty(Vec), 18 | #[error("Failed to find a compatible combination")] 19 | IntersectFailure, 20 | } 21 | pub type Result = std::result::Result; 22 | 23 | static VERSION_GROUPS: OnceLock>> = OnceLock::new(); 24 | 25 | /// Gets groups of versions that are considered minor updates in terms of mod compatibility 26 | /// 27 | /// This is determined by Modrinth's `major` parameter for game versions. 28 | pub async fn get_version_groups() -> Result<&'static Vec>> { 29 | if let Some(v) = VERSION_GROUPS.get() { 30 | Ok(v) 31 | } else { 32 | let versions = MODRINTH_API.tag_list_game_versions().await?; 33 | let mut v = vec![vec![]]; 34 | for version in versions { 35 | if version.version_type == GameVersionType::Release { 36 | // Push the version to the latest group 37 | v.last_mut().unwrap().push(version.version); 38 | // Create a new group if a new major versions is present 39 | if version.major { 40 | v.push(vec![]); 41 | } 42 | } 43 | } 44 | let _ = VERSION_GROUPS.set(v); 45 | 46 | Ok(VERSION_GROUPS.get().unwrap()) 47 | } 48 | } 49 | 50 | impl Filter { 51 | /// Returns the indices of `download_files` that have successfully filtered through `self` 52 | /// 53 | /// This function fails if getting version groups fails, or the regex files to parse. 54 | pub async fn filter( 55 | &self, 56 | download_files: impl Iterator + Clone, 57 | ) -> Result> { 58 | Ok(match self { 59 | Filter::ModLoaderPrefer(loaders) => loaders 60 | .iter() 61 | .map(move |l| { 62 | download_files 63 | .clone() 64 | .positions(|f| f.loaders.contains(l)) 65 | .collect_hashset() 66 | }) 67 | .find(|v| !v.is_empty()) 68 | .unwrap_or_default(), 69 | 70 | Filter::ModLoaderAny(loaders) => download_files 71 | .positions(|f| loaders.iter().any(|l| f.loaders.contains(l))) 72 | .collect_hashset(), 73 | 74 | Filter::GameVersionStrict(versions) => download_files 75 | .positions(|f| versions.iter().any(|vc| f.game_versions.contains(vc))) 76 | .collect_hashset(), 77 | 78 | Filter::GameVersionMinor(versions) => { 79 | let mut final_versions = vec![]; 80 | for group in get_version_groups().await? { 81 | if group.iter().any(|v| versions.contains(v)) { 82 | final_versions.extend(group.clone()); 83 | } 84 | } 85 | 86 | download_files 87 | .positions(|f| final_versions.iter().any(|vc| f.game_versions.contains(vc))) 88 | .collect_hashset() 89 | } 90 | 91 | Filter::ReleaseChannel(channel) => download_files 92 | .positions(|f| match channel { 93 | ReleaseChannel::Alpha => true, 94 | ReleaseChannel::Beta => { 95 | f.channel == ReleaseChannel::Beta || f.channel == ReleaseChannel::Release 96 | } 97 | ReleaseChannel::Release => f.channel == ReleaseChannel::Release, 98 | }) 99 | .collect_hashset(), 100 | 101 | Filter::Filename(regex) => { 102 | let regex = Regex::new(regex)?; 103 | download_files 104 | .positions(|f| regex.is_match(&f.filename)) 105 | .collect_hashset() 106 | } 107 | 108 | Filter::Title(regex) => { 109 | let regex = Regex::new(regex)?; 110 | download_files 111 | .positions(|f| regex.is_match(&f.title)) 112 | .collect_hashset() 113 | } 114 | 115 | Filter::Description(regex) => { 116 | let regex = Regex::new(regex)?; 117 | download_files 118 | .positions(|f| regex.is_match(&f.description)) 119 | .collect_hashset() 120 | } 121 | }) 122 | } 123 | } 124 | 125 | /// Assumes that the provided `download_files` are sorted in the order of preference (e.g. chronological) 126 | pub async fn select_latest( 127 | download_files: impl Iterator + Clone, 128 | filters: Vec, 129 | ) -> Result { 130 | let mut filter_results = vec![]; 131 | let mut run_last = vec![]; 132 | 133 | for filter in &filters { 134 | if let Filter::ModLoaderPrefer(_) = filter { 135 | // ModLoaderPrefer has to be run last 136 | run_last.push(( 137 | filter, 138 | filter.filter(download_files.clone().enumerate()).await?, 139 | )); 140 | } else { 141 | filter_results.push(( 142 | filter, 143 | filter.filter(download_files.clone().enumerate()).await?, 144 | )); 145 | } 146 | } 147 | 148 | let empty_filtrations = filter_results 149 | .iter() 150 | .chain(run_last.iter()) 151 | .filter_map(|(filter, indices)| { 152 | if indices.is_empty() { 153 | Some(filter.to_string()) 154 | } else { 155 | None 156 | } 157 | }) 158 | .collect_vec(); 159 | if !empty_filtrations.is_empty() { 160 | return Err(Error::FilterEmpty(empty_filtrations)); 161 | } 162 | 163 | // Get the indices of the filtrations 164 | let mut filter_results = filter_results.into_iter().map(|(_, set)| set); 165 | 166 | // Intersect all the index_sets by folding the HashSet::intersection method 167 | // Ref: https://www.reddit.com/r/rust/comments/5v35l6/intersection_of_more_than_two_sets 168 | // Here we're getting the non-ModLoaderPrefer indices first 169 | let final_indices = filter_results 170 | .next() 171 | .map(|set_1| { 172 | filter_results.fold(set_1, |set_a, set_b| { 173 | set_a.intersection(&set_b).copied().collect_hashset() 174 | }) 175 | }) 176 | .unwrap_or_default(); 177 | 178 | let download_files = download_files.into_iter().enumerate().filter_map(|(i, f)| { 179 | if final_indices.contains(&i) { 180 | Some((i, f)) 181 | } else { 182 | None 183 | } 184 | }); 185 | 186 | let mut filter_results = vec![]; 187 | for (filter, _) in run_last { 188 | filter_results.push(filter.filter(download_files.clone()).await?) 189 | } 190 | let mut filter_results = filter_results.into_iter(); 191 | 192 | let final_index = filter_results 193 | .next() 194 | .and_then(|set_1| { 195 | filter_results 196 | .fold(set_1, |set_a, set_b| { 197 | set_a.intersection(&set_b).copied().collect_hashset() 198 | }) 199 | .into_iter() 200 | .min() 201 | }) 202 | .ok_or(Error::IntersectFailure)?; 203 | 204 | Ok(final_index) 205 | } 206 | -------------------------------------------------------------------------------- /libium/src/upgrade/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod check; 2 | pub mod mod_downloadable; 3 | pub mod modpack_downloadable; 4 | 5 | use crate::{ 6 | config::{ 7 | filters::ReleaseChannel, 8 | structs::{ModIdentifier, ModLoader}, 9 | }, 10 | iter_ext::IterExt as _, 11 | modpack::modrinth::structs::ModpackFile as ModpackModFile, 12 | version_ext::VersionExt, 13 | }; 14 | use ferinth::structures::version::{ 15 | DependencyType as MRDependencyType, Version as MRVersion, VersionType, 16 | }; 17 | use furse::structures::file_structs::{ 18 | File as CFFile, FileRelationType as CFFileRelationType, FileReleaseType, 19 | }; 20 | use octocrab::models::repos::{Asset as GHAsset, Release as GHRelease}; 21 | use reqwest::{Client, Url}; 22 | use std::{ 23 | fs::{create_dir_all, rename, OpenOptions}, 24 | io::{BufWriter, Write}, 25 | path::{Path, PathBuf}, 26 | str::FromStr, 27 | }; 28 | 29 | #[derive(Debug, thiserror::Error)] 30 | #[error(transparent)] 31 | pub enum Error { 32 | ReqwestError(#[from] reqwest::Error), 33 | IOError(#[from] std::io::Error), 34 | } 35 | type Result = std::result::Result; 36 | 37 | #[derive(Debug, Clone)] 38 | pub struct Metadata { 39 | /// The title of the GitHub Release, Modrinth Version, or CurseForge File 40 | pub title: String, 41 | /// The body of the GitHub Release, or the changelog of the Modrinth Version 42 | pub description: String, 43 | pub filename: String, 44 | 45 | pub channel: ReleaseChannel, 46 | 47 | pub game_versions: Vec, 48 | pub loaders: Vec, 49 | } 50 | 51 | #[derive(Debug, Clone)] 52 | pub struct DownloadData { 53 | pub download_url: Url, 54 | /// The path of the downloaded file relative to the output directory 55 | /// 56 | /// The filename by default, but can be configured with subdirectories for modpacks. 57 | pub output: PathBuf, 58 | /// The length of the file in bytes 59 | pub length: usize, 60 | /// The dependencies this file has 61 | pub dependencies: Vec, 62 | /// Other mods this file is incompatible with 63 | pub conflicts: Vec, 64 | } 65 | 66 | #[derive(Debug, thiserror::Error)] 67 | #[error("The developer of this project has denied third party applications from downloading it")] 68 | /// Contains the mod ID and file ID 69 | pub struct DistributionDeniedError(pub i32, pub i32); 70 | 71 | pub fn try_from_cf_file( 72 | file: CFFile, 73 | ) -> std::result::Result<(Metadata, DownloadData), DistributionDeniedError> { 74 | Ok(( 75 | Metadata { 76 | title: file.display_name, 77 | description: String::new(), // Changelog requires a separate request 78 | filename: file.file_name.clone(), 79 | channel: match file.release_type { 80 | FileReleaseType::Release => ReleaseChannel::Release, 81 | FileReleaseType::Beta => ReleaseChannel::Beta, 82 | FileReleaseType::Alpha => ReleaseChannel::Alpha, 83 | }, 84 | loaders: file 85 | .game_versions 86 | .iter() 87 | .filter_map(|s| ModLoader::from_str(s).ok()) 88 | .collect_vec(), 89 | game_versions: file.game_versions, 90 | }, 91 | DownloadData { 92 | download_url: file 93 | .download_url 94 | .ok_or(DistributionDeniedError(file.mod_id, file.id))?, 95 | output: file.file_name.as_str().into(), 96 | length: file.file_length as usize, 97 | dependencies: file 98 | .dependencies 99 | .iter() 100 | .filter_map(|d| { 101 | if d.relation_type == CFFileRelationType::RequiredDependency { 102 | Some(ModIdentifier::CurseForgeProject(d.mod_id)) 103 | } else { 104 | None 105 | } 106 | }) 107 | .collect_vec(), 108 | conflicts: file 109 | .dependencies 110 | .iter() 111 | .filter_map(|d| { 112 | if d.relation_type == CFFileRelationType::Incompatible { 113 | Some(ModIdentifier::CurseForgeProject(d.mod_id)) 114 | } else { 115 | None 116 | } 117 | }) 118 | .collect_vec(), 119 | }, 120 | )) 121 | } 122 | 123 | pub fn from_mr_version(version: MRVersion) -> (Metadata, DownloadData) { 124 | ( 125 | Metadata { 126 | title: version.name.clone(), 127 | description: version.changelog.as_ref().cloned().unwrap_or_default(), 128 | filename: version.get_version_file().filename.clone(), 129 | channel: match version.version_type { 130 | VersionType::Release => ReleaseChannel::Release, 131 | VersionType::Beta => ReleaseChannel::Beta, 132 | VersionType::Alpha => ReleaseChannel::Alpha, 133 | }, 134 | loaders: version 135 | .loaders 136 | .iter() 137 | .filter_map(|s| ModLoader::from_str(s).ok()) 138 | .collect_vec(), 139 | 140 | game_versions: version.game_versions.clone(), 141 | }, 142 | DownloadData { 143 | download_url: version.get_version_file().url.clone(), 144 | output: version.get_version_file().filename.as_str().into(), 145 | length: version.get_version_file().size, 146 | dependencies: version 147 | .dependencies 148 | .clone() 149 | .into_iter() 150 | .filter_map(|d| { 151 | if d.dependency_type == MRDependencyType::Required { 152 | match (d.project_id, d.version_id) { 153 | (Some(proj_id), Some(ver_id)) => { 154 | Some(ModIdentifier::PinnedModrinthProject(proj_id, ver_id)) 155 | } 156 | (Some(proj_id), None) => Some(ModIdentifier::ModrinthProject(proj_id)), 157 | _ => { 158 | eprintln!("Project ID not available"); 159 | None 160 | } 161 | } 162 | } else { 163 | None 164 | } 165 | }) 166 | .collect_vec(), 167 | conflicts: version 168 | .dependencies 169 | .into_iter() 170 | .filter_map(|d| { 171 | if d.dependency_type == MRDependencyType::Incompatible { 172 | match (d.project_id, d.version_id) { 173 | (Some(proj_id), Some(ver_id)) => { 174 | Some(ModIdentifier::PinnedModrinthProject(proj_id, ver_id)) 175 | } 176 | (Some(proj_id), None) => Some(ModIdentifier::ModrinthProject(proj_id)), 177 | _ => { 178 | eprintln!("Project ID not available"); 179 | None 180 | } 181 | } 182 | } else { 183 | None 184 | } 185 | }) 186 | .collect_vec(), 187 | }, 188 | ) 189 | } 190 | 191 | pub fn from_modpack_file(file: ModpackModFile) -> DownloadData { 192 | DownloadData { 193 | download_url: file 194 | .downloads 195 | .first() 196 | .expect("Download URLs not provided") 197 | .clone(), 198 | output: file.path, 199 | length: file.file_size, 200 | dependencies: Vec::new(), 201 | conflicts: Vec::new(), 202 | } 203 | } 204 | 205 | pub fn from_gh_releases( 206 | releases: impl IntoIterator, 207 | ) -> Vec<(Metadata, DownloadData)> { 208 | releases 209 | .into_iter() 210 | .flat_map(|release| { 211 | release.assets.into_iter().map(move |asset| { 212 | ( 213 | Metadata { 214 | title: release.name.clone().unwrap_or_default(), 215 | description: release.body.clone().unwrap_or_default(), 216 | channel: if release.prerelease { 217 | ReleaseChannel::Beta 218 | } else { 219 | ReleaseChannel::Release 220 | }, 221 | game_versions: asset 222 | .name 223 | .trim_end_matches(".jar") 224 | .split(['-', '_', '+']) 225 | .map(|s| s.trim_start_matches("mc")) 226 | .map(ToOwned::to_owned) 227 | .collect_vec(), 228 | loaders: asset 229 | .name 230 | .trim_end_matches(".jar") 231 | .split(['-', '_', '+']) 232 | .filter_map(|s| ModLoader::from_str(s).ok()) 233 | .collect_vec(), 234 | filename: asset.name.clone(), 235 | }, 236 | DownloadData { 237 | download_url: asset.browser_download_url, 238 | output: asset.name.into(), 239 | length: asset.size as usize, 240 | dependencies: Vec::new(), 241 | conflicts: Vec::new(), 242 | }, 243 | ) 244 | }) 245 | }) 246 | .collect_vec() 247 | } 248 | 249 | pub fn from_gh_asset(asset: GHAsset) -> DownloadData { 250 | DownloadData { 251 | download_url: asset.browser_download_url, 252 | output: asset.name.into(), 253 | length: asset.size as usize, 254 | dependencies: Vec::new(), 255 | conflicts: Vec::new(), 256 | } 257 | } 258 | 259 | impl DownloadData { 260 | /// Consumes `self` and downloads the file to the `output_dir` 261 | /// 262 | /// The `update` closure is called with the chunk length whenever a chunk is downloaded and written. 263 | /// 264 | /// Returns the total size of the file and the filename. 265 | pub async fn download( 266 | self, 267 | client: Client, 268 | output_dir: impl AsRef, 269 | update: impl Fn(usize) + Send, 270 | ) -> Result<(usize, String)> { 271 | let (filename, url, size) = (self.filename(), self.download_url, self.length); 272 | let out_file_path = output_dir.as_ref().join(&self.output); 273 | let temp_file_path = out_file_path.with_extension("part"); 274 | if let Some(up_dir) = out_file_path.parent() { 275 | create_dir_all(up_dir)?; 276 | } 277 | 278 | let mut temp_file = BufWriter::with_capacity( 279 | size, 280 | OpenOptions::new() 281 | .append(true) 282 | .create(true) 283 | .open(&temp_file_path)?, 284 | ); 285 | 286 | let mut response = client.get(url).send().await?; 287 | 288 | while let Some(chunk) = response.chunk().await? { 289 | temp_file.write_all(&chunk)?; 290 | update(chunk.len()); 291 | } 292 | temp_file.flush()?; 293 | rename(temp_file_path, out_file_path)?; 294 | Ok((size, filename)) 295 | } 296 | 297 | pub fn filename(&self) -> String { 298 | self.output 299 | .file_name() 300 | .unwrap_or_default() 301 | .to_string_lossy() 302 | .to_string() 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /libium/src/upgrade/mod_downloadable.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | from_gh_asset, from_gh_releases, from_mr_version, try_from_cf_file, DistributionDeniedError, 3 | DownloadData, 4 | }; 5 | use crate::{ 6 | config::{ 7 | filters::Filter, 8 | structs::{Mod, ModIdentifier}, 9 | }, 10 | iter_ext::IterExt as _, 11 | CURSEFORGE_API, GITHUB_API, MODRINTH_API, 12 | }; 13 | use std::cmp::Reverse; 14 | 15 | #[derive(Debug, thiserror::Error)] 16 | #[error(transparent)] 17 | pub enum Error { 18 | DistributionDenied(#[from] DistributionDeniedError), 19 | CheckError(#[from] super::check::Error), 20 | #[error("The pin provided is an invalid identifier")] 21 | InvalidPinID(#[from] std::num::ParseIntError), 22 | #[error("Modrinth: {0}")] 23 | ModrinthError(#[from] ferinth::Error), 24 | #[error("CurseForge: {0}")] 25 | CurseForgeError(#[from] furse::Error), 26 | #[error("GitHub: {0:#?}")] 27 | GitHubError(#[from] octocrab::Error), 28 | } 29 | type Result = std::result::Result; 30 | 31 | impl Mod { 32 | pub async fn fetch_download_file( 33 | &self, 34 | mut profile_filters: Vec, 35 | ) -> Result { 36 | match &self.identifier { 37 | ModIdentifier::PinnedCurseForgeProject(mod_id, pin) => { 38 | Ok(try_from_cf_file(CURSEFORGE_API.get_mod_file(*mod_id, *pin).await?)?.1) 39 | } 40 | ModIdentifier::PinnedModrinthProject(_, pin) => { 41 | Ok(from_mr_version(MODRINTH_API.version_get(pin).await?).1) 42 | } 43 | ModIdentifier::PinnedGitHubRepository((owner, repo), pin) => Ok(from_gh_asset( 44 | GITHUB_API 45 | .repos(owner, repo) 46 | .release_assets() 47 | .get(*pin as u64) 48 | .await?, 49 | )), 50 | id => { 51 | let download_files = match &id { 52 | ModIdentifier::CurseForgeProject(id) => { 53 | let mut files = CURSEFORGE_API.get_mod_files(*id).await?; 54 | files.sort_unstable_by_key(|f| Reverse(f.file_date)); 55 | files 56 | .into_iter() 57 | .map(|f| try_from_cf_file(f).map_err(Into::into)) 58 | .collect::>>()? 59 | } 60 | ModIdentifier::ModrinthProject(id) => MODRINTH_API 61 | .version_list(id) 62 | .await? 63 | .into_iter() 64 | .map(from_mr_version) 65 | .collect_vec(), 66 | ModIdentifier::GitHubRepository(owner, repo) => GITHUB_API 67 | .repos(owner, repo) 68 | .releases() 69 | .list() 70 | .send() 71 | .await 72 | .map(|r| from_gh_releases(r.items))?, 73 | _ => unreachable!(), 74 | }; 75 | 76 | let index = super::check::select_latest( 77 | download_files.iter().map(|(m, _)| m), 78 | if self.override_filters { 79 | self.filters.clone() 80 | } else { 81 | profile_filters.extend(self.filters.clone()); 82 | profile_filters 83 | }, 84 | ) 85 | .await?; 86 | Ok(download_files.into_iter().nth(index).unwrap().1) 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /libium/src/upgrade/modpack_downloadable.rs: -------------------------------------------------------------------------------- 1 | use super::{from_mr_version, try_from_cf_file, DistributionDeniedError}; 2 | use crate::{config::structs::ModpackIdentifier, CURSEFORGE_API, MODRINTH_API, PROJECT_DIRS}; 3 | use reqwest::Client; 4 | use std::{fs::create_dir_all, path::PathBuf}; 5 | 6 | #[derive(Debug, thiserror::Error)] 7 | #[error(transparent)] 8 | pub enum Error { 9 | /// The user can manually download the modpack zip file and place it in 10 | /// `/downloaded` to mitigate this. 11 | /// However, they will have to manually update the modpack file. 12 | /// 13 | /// Ferium cache path: 14 | /// - Windows: `%LOCALAPPDATA%/ferium/cache` 15 | /// - Linux: `${XDG_CACHE_HOME}/ferium` or `~/.cache/ferium` 16 | /// - MacOS: `~/Library/Caches/ferium` 17 | DistributionDenied(#[from] DistributionDeniedError), 18 | ModrinthError(#[from] ferinth::Error), 19 | CurseForgeError(#[from] furse::Error), 20 | ReqwestError(#[from] reqwest::Error), 21 | DownloadError(#[from] super::Error), 22 | IOError(#[from] std::io::Error), 23 | } 24 | type Result = std::result::Result; 25 | 26 | impl ModpackIdentifier { 27 | pub async fn download_file( 28 | &self, 29 | total: impl FnOnce(usize) + Send, 30 | update: impl Fn(usize) + Send, 31 | ) -> Result { 32 | let (_, download_data) = match self { 33 | ModpackIdentifier::CurseForgeModpack(id) => { 34 | try_from_cf_file(CURSEFORGE_API.get_mod_files(*id).await?.swap_remove(0))? 35 | } 36 | ModpackIdentifier::ModrinthModpack(id) => { 37 | from_mr_version(MODRINTH_API.version_list(id).await?.swap_remove(0)) 38 | } 39 | }; 40 | 41 | let cache_dir = PROJECT_DIRS.cache_dir().join("downloaded"); 42 | let modpack_path = cache_dir.join(&download_data.output); 43 | if !modpack_path.exists() { 44 | create_dir_all(&cache_dir)?; 45 | total(download_data.length); 46 | download_data 47 | .download(Client::new(), &cache_dir, update) 48 | .await?; 49 | } 50 | 51 | Ok(modpack_path) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /libium/src/version_ext.rs: -------------------------------------------------------------------------------- 1 | use ferinth::structures::version::{Version, VersionFile}; 2 | 3 | pub trait VersionExt { 4 | /// Gets the primary (or first) version file of a version 5 | fn get_version_file(&self) -> &VersionFile; 6 | /// Consumes and returns the primary (or first) version file of a version 7 | fn into_version_file(self) -> VersionFile; 8 | } 9 | 10 | impl VersionExt for Version { 11 | fn get_version_file(&self) -> &VersionFile { 12 | self.files 13 | .iter() 14 | .find(|f| f.primary) 15 | .unwrap_or(&self.files[0]) 16 | } 17 | 18 | fn into_version_file(mut self) -> VersionFile { 19 | let fallback = self.files.swap_remove(0); 20 | self.files 21 | .into_iter() 22 | .find(|f| f.primary) 23 | .unwrap_or(fallback) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /media/list_verbose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/media/list_verbose.png -------------------------------------------------------------------------------- /media/profile_info_and_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/media/profile_info_and_list.png -------------------------------------------------------------------------------- /media/upgrade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/media/upgrade.png -------------------------------------------------------------------------------- /src/add.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize as _; 2 | use libium::{add::Error, iter_ext::IterExt as _}; 3 | use std::collections::HashMap; 4 | 5 | pub fn display_successes_failures(successes: &[String], failures: Vec<(String, Error)>) -> bool { 6 | if !successes.is_empty() { 7 | println!( 8 | "{} {}", 9 | "Successfully added".green(), 10 | successes.iter().map(|s| s.bold()).display(", ") 11 | ); 12 | 13 | // No need to print the ID again if there is only one 14 | } else if failures.len() == 1 { 15 | let err = &failures[0].1; 16 | return if matches!(err, libium::add::Error::AlreadyAdded) { 17 | println!("{}", err.to_string().yellow()); 18 | false 19 | } else { 20 | println!("{}", err.to_string().red()); 21 | true 22 | }; 23 | } 24 | 25 | let mut grouped_errors = HashMap::new(); 26 | 27 | for (id, error) in failures { 28 | grouped_errors 29 | .entry(error.to_string()) 30 | .or_insert_with(Vec::new) 31 | .push(id); 32 | } 33 | 34 | let pad_len = grouped_errors 35 | .keys() 36 | .map(String::len) 37 | .max() 38 | .unwrap_or(0) 39 | .clamp(0, 50); 40 | 41 | let mut exit_error = false; 42 | for (err, ids) in grouped_errors { 43 | println!( 44 | "{:pad_len$}: {}", 45 | // Change already added into a warning 46 | if err == libium::add::Error::AlreadyAdded.to_string() { 47 | err.yellow() 48 | } else { 49 | exit_error = true; 50 | err.red() 51 | }, 52 | ids.iter().map(|s| s.italic()).display(", ") 53 | ); 54 | } 55 | 56 | exit_error 57 | } 58 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | 3 | use crate::DEFAULT_PARALLEL_TASKS; 4 | use clap::{Args, Parser, Subcommand, ValueEnum, ValueHint}; 5 | use clap_complete::Shell; 6 | use libium::config::{ 7 | filters::{self, Filter}, 8 | structs::ModLoader, 9 | }; 10 | use std::path::PathBuf; 11 | 12 | #[derive(Clone, Debug, Parser)] 13 | #[clap(author, version, about)] 14 | pub struct Ferium { 15 | #[clap(subcommand)] 16 | pub subcommand: SubCommands, 17 | /// Sets the number of worker threads the tokio runtime will use. 18 | /// You can also use the environment variable `TOKIO_WORKER_THREADS`. 19 | #[clap(long, short)] 20 | pub threads: Option, 21 | /// Specify the maximum number of simultaneous parallel tasks. 22 | #[clap(long, short = 'p', default_value_t = DEFAULT_PARALLEL_TASKS)] 23 | pub parallel_tasks: usize, 24 | /// Set a GitHub personal access token for increasing the GitHub API rate limit. 25 | #[clap(long, visible_alias = "gh", env = "GITHUB_TOKEN")] 26 | pub github_token: Option, 27 | /// Set a custom Curseforge API key. 28 | #[clap(long, visible_alias = "cf", env = "CURSEFORGE_API_KEY")] 29 | pub curseforge_api_key: Option, 30 | /// Set the file to read the config from. 31 | /// This does not change the `cache` and `tmp` directories. 32 | /// You can also use the environment variable `FERIUM_CONFIG_FILE`. 33 | #[clap(long, short, visible_aliases = ["config", "conf"])] 34 | #[clap(value_hint(ValueHint::FilePath))] 35 | pub config_file: Option, 36 | } 37 | 38 | #[derive(Clone, Debug, Subcommand)] 39 | pub enum SubCommands { 40 | /* TODO: 41 | Use this for filter arguments: 42 | https://docs.rs/clap/latest/clap/_derive/_tutorial/chapter_3/index.html#argument-relations 43 | */ 44 | /// Add mods to the profile 45 | Add { 46 | /// The identifier(s) of the mod/project/repository 47 | /// 48 | /// The Modrinth project ID is specified at the bottom of the left sidebar under 'Technical information'. 49 | /// You can also use the project slug in the URL. 50 | /// The Curseforge project ID is specified at the top of the right sidebar under 'About Project'. 51 | /// The GitHub identifier is the repository's full name, e.g. `gorilla-devs/ferium`. 52 | #[clap(required = true)] 53 | identifiers: Vec, 54 | 55 | /// Temporarily ignore game version and mod loader checks and add the mod anyway 56 | #[clap(long, short, visible_alias = "override")] 57 | force: bool, 58 | 59 | /// Pin a mod to a specific version 60 | #[clap(long, short, visible_alias = "lock")] 61 | pin: Option, 62 | 63 | #[command(flatten)] 64 | filters: FilterArguments, 65 | }, 66 | /// Scan the profile's output directory (or the specified directory) for mods and add them to the profile 67 | Scan { 68 | /// The platform you prefer mods to be added from. 69 | /// If a mod isn't available from this platform, the other platform will still be used. 70 | #[clap(long, short, default_value_t)] 71 | platform: Platform, 72 | /// The directory to scan mods from. 73 | /// Defaults to the profile's output directory. 74 | #[clap(long, short, 75 | visible_aliases = ["dir", "folder"], 76 | aliases = ["output_directory", "out_dir"] 77 | )] 78 | directory: Option, 79 | /// Temporarily ignore game version and mod loader checks and add the mods anyway 80 | #[clap(long, short, visible_alias = "override")] 81 | force: bool, 82 | }, 83 | /// Print shell auto completions for the specified shell 84 | Complete { 85 | /// The shell to generate auto completions for 86 | #[clap(value_enum)] 87 | shell: Shell, 88 | }, 89 | /// List all the mods in the profile, and with some their metadata if verbose 90 | #[clap(visible_alias = "mods")] 91 | List { 92 | /// Show additional information about the mod 93 | #[clap(long, short)] 94 | verbose: bool, 95 | /// Output information in markdown format and alphabetical order 96 | /// 97 | /// Useful for creating modpack mod lists. 98 | /// Complements the verbose flag. 99 | #[clap(long, short, visible_alias = "md")] 100 | markdown: bool, 101 | }, 102 | /// Add, configure, delete, switch, list, or upgrade modpacks 103 | Modpack { 104 | #[clap(subcommand)] 105 | subcommand: Option, 106 | }, 107 | /// List all the modpacks with their data 108 | Modpacks, 109 | /// Create, configure, delete, switch, or list profiles 110 | Profile { 111 | #[clap(subcommand)] 112 | subcommand: Option, 113 | }, 114 | /// List all the profiles with their data 115 | Profiles, 116 | /// Remove mods and/or repositories from the profile. 117 | /// Optionally, provide a list of names or IDs of the mods to remove. 118 | #[clap(visible_alias = "rm")] 119 | Remove { 120 | /// List of project IDs or case-insensitive names of mods to remove 121 | mod_names: Vec, 122 | }, 123 | /// Download and install the latest compatible version of your mods 124 | #[clap(visible_aliases = ["download", "install"])] 125 | Upgrade, 126 | } 127 | 128 | #[derive(Clone, Debug, Subcommand)] 129 | pub enum ProfileSubCommands { 130 | /// Configure the current profile's name, Minecraft version, mod loader, and output directory. 131 | /// Optionally, provide the settings to change as arguments. 132 | #[clap(visible_aliases = ["config", "conf"])] 133 | Configure { 134 | /// The Minecraft version(s) to consider as compatible 135 | #[clap(long, short = 'v')] 136 | game_versions: Vec, 137 | /// The mod loader(s) to consider as compatible 138 | #[clap(long, short = 'l')] 139 | #[clap(value_enum)] 140 | mod_loaders: Vec, 141 | /// The name of the profile 142 | #[clap(long, short)] 143 | name: Option, 144 | /// The directory to output mods to 145 | #[clap(long, short)] 146 | #[clap(value_hint(ValueHint::DirPath))] 147 | output_dir: Option, 148 | }, 149 | /// Create a new profile. 150 | /// Optionally, provide the settings as arguments. 151 | /// Use the import flag to import mods from another profile. 152 | #[clap(visible_alias = "new")] 153 | Create { 154 | /// Copy over the mods from an existing profile. 155 | /// Optionally, provide the name of the profile to import mods from. 156 | #[clap(long, short, visible_aliases = ["copy", "duplicate"])] 157 | #[expect(clippy::option_option)] 158 | import: Option>, 159 | /// The Minecraft version to check compatibility for 160 | #[clap(long, short = 'v')] 161 | game_version: Vec, 162 | /// The mod loader to check compatibility for 163 | #[clap(long, short)] 164 | #[clap(value_enum)] 165 | mod_loader: Option, 166 | /// The name of the profile 167 | #[clap(long, short)] 168 | name: Option, 169 | /// The directory to output mods to 170 | #[clap(long, short)] 171 | #[clap(value_hint(ValueHint::DirPath))] 172 | output_dir: Option, 173 | }, 174 | /// Delete a profile. 175 | /// Optionally, provide the name of the profile to delete. 176 | #[clap(visible_aliases = ["remove", "rm"])] 177 | Delete { 178 | /// The name of the profile to delete 179 | profile_name: Option, 180 | /// The name of the profile to switch to afterwards 181 | #[clap(long, short)] 182 | switch_to: Option, 183 | }, 184 | /// Show information about the current profile 185 | Info, 186 | /// List all the profiles with their data 187 | List, 188 | /// Switch between different profiles. 189 | /// Optionally, provide the name of the profile to switch to. 190 | Switch { 191 | /// The name of the profile to switch to 192 | profile_name: Option, 193 | }, 194 | } 195 | 196 | #[derive(Clone, Debug, Subcommand)] 197 | pub enum ModpackSubCommands { 198 | /// Add a modpack to the config 199 | Add { 200 | /// The identifier of the modpack/project 201 | /// 202 | /// The Modrinth project ID is specified at the bottom of the left sidebar under 'Technical information'. 203 | /// You can also use the project slug for this. 204 | /// The Curseforge project ID is specified at the top of the right sidebar under 'About Project'. 205 | identifier: String, 206 | /// The Minecraft instance directory to install the modpack to 207 | #[clap(long, short)] 208 | #[clap(value_hint(ValueHint::DirPath))] 209 | output_dir: Option, 210 | /// Whether to install the modpack's overrides to the output directory. 211 | /// This will override existing files when upgrading. 212 | #[clap(long, short)] 213 | install_overrides: Option, 214 | }, 215 | /// Configure the current modpack's output directory and installation of overrides. 216 | /// Optionally, provide the settings to change as arguments. 217 | #[clap(visible_aliases = ["config", "conf"])] 218 | Configure { 219 | /// The Minecraft instance directory to install the modpack to 220 | #[clap(long, short)] 221 | #[clap(value_hint(ValueHint::DirPath))] 222 | output_dir: Option, 223 | /// Whether to install the modpack's overrides to the output directory. 224 | /// This will override existing files when upgrading. 225 | #[clap(long, short)] 226 | install_overrides: Option, 227 | }, 228 | /// Delete a modpack. 229 | /// Optionally, provide the name of the modpack to delete. 230 | #[clap(visible_aliases = ["remove", "rm"])] 231 | Delete { 232 | /// The name of the modpack to delete 233 | modpack_name: Option, 234 | /// The name of the profile to switch to afterwards 235 | #[clap(long, short)] 236 | switch_to: Option, 237 | }, 238 | /// Show information about the current modpack 239 | Info, 240 | /// List all the modpacks with their data 241 | List, 242 | /// Switch between different modpacks. 243 | /// Optionally, provide the name of the modpack to switch to. 244 | Switch { 245 | /// The name of the modpack to switch to 246 | modpack_name: Option, 247 | }, 248 | /// Download and install the latest version of the modpack 249 | #[clap(visible_aliases = ["download", "install"])] 250 | Upgrade, 251 | } 252 | 253 | #[derive(Clone, Default, Debug, Args)] 254 | #[group(id = "loader", multiple = false)] 255 | pub struct FilterArguments { 256 | #[clap(long)] 257 | pub override_profile: bool, 258 | 259 | #[clap(long, short = 'l', group = "loader")] 260 | pub mod_loader_prefer: Vec, 261 | #[clap(long, group = "loader")] 262 | pub mod_loader_any: Vec, 263 | 264 | #[clap(long, short = 'v', group = "version")] 265 | pub game_version_strict: Vec, 266 | #[clap(long, group = "version")] 267 | pub game_version_minor: Vec, 268 | 269 | #[clap(long, short = 'c')] 270 | pub release_channel: Option, 271 | 272 | #[clap(long, short = 'n')] 273 | pub filename: Option, 274 | #[clap(long, short = 't')] 275 | pub title: Option, 276 | #[clap(long, short = 'd')] 277 | pub description: Option, 278 | } 279 | 280 | impl From for Vec { 281 | fn from(value: FilterArguments) -> Self { 282 | let mut filters = vec![]; 283 | 284 | if !value.mod_loader_prefer.is_empty() { 285 | filters.push(Filter::ModLoaderPrefer(value.mod_loader_prefer)); 286 | } 287 | if !value.mod_loader_any.is_empty() { 288 | filters.push(Filter::ModLoaderAny(value.mod_loader_any)); 289 | } 290 | if !value.game_version_strict.is_empty() { 291 | filters.push(Filter::GameVersionStrict(value.game_version_strict)); 292 | } 293 | if !value.game_version_minor.is_empty() { 294 | filters.push(Filter::GameVersionMinor(value.game_version_minor)); 295 | } 296 | if let Some(release_channel) = value.release_channel { 297 | filters.push(Filter::ReleaseChannel(release_channel)); 298 | } 299 | if let Some(regex) = value.filename { 300 | filters.push(Filter::Filename(regex)); 301 | } 302 | if let Some(regex) = value.title { 303 | filters.push(Filter::Title(regex)); 304 | } 305 | if let Some(regex) = value.description { 306 | filters.push(Filter::Description(regex)); 307 | } 308 | 309 | filters 310 | } 311 | } 312 | 313 | #[derive(Clone, Copy, Debug, Default, ValueEnum)] 314 | pub enum Platform { 315 | #[default] 316 | #[clap(alias = "mr")] 317 | Modrinth, 318 | #[clap(alias = "cf")] 319 | Curseforge, 320 | } 321 | 322 | impl std::fmt::Display for Platform { 323 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 324 | match self { 325 | Self::Modrinth => write!(f, "modrinth"), 326 | Self::Curseforge => write!(f, "curseforge"), 327 | } 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/download.rs: -------------------------------------------------------------------------------- 1 | use crate::{default_semaphore, SEMAPHORE, STYLE_BYTE, TICK}; 2 | use anyhow::{anyhow, bail, Error, Result}; 3 | use colored::Colorize as _; 4 | use fs_extra::{ 5 | dir::{copy as copy_dir, CopyOptions as DirCopyOptions}, 6 | file::{move_file, CopyOptions as FileCopyOptions}, 7 | }; 8 | use indicatif::ProgressBar; 9 | use libium::{iter_ext::IterExt as _, upgrade::DownloadData}; 10 | use parking_lot::Mutex; 11 | use std::{ 12 | ffi::OsString, 13 | fs::{copy, create_dir_all, read_dir, remove_file}, 14 | path::{Path, PathBuf}, 15 | sync::Arc, 16 | time::Duration, 17 | }; 18 | use tokio::task::JoinSet; 19 | 20 | /// Check the given `directory` 21 | /// 22 | /// - If there are files there that are not in `to_download` or `to_install`, they will be moved to `directory`/.old 23 | /// - If a file in `to_download` or `to_install` is already there, it will be removed from the respective vector 24 | /// - If the file is a `.part` file or if the move failed, the file will be deleted 25 | pub async fn clean( 26 | directory: &Path, 27 | to_download: &mut Vec, 28 | to_install: &mut Vec<(OsString, PathBuf)>, 29 | ) -> Result<()> { 30 | let dupes = find_dupes_by_key(to_download, DownloadData::filename); 31 | if !dupes.is_empty() { 32 | println!( 33 | "{}", 34 | format!( 35 | "Warning: {} duplicate files were found {}. Remove the mod it belongs to", 36 | dupes.len(), 37 | dupes 38 | .into_iter() 39 | .map(|i| to_download.swap_remove(i).filename()) 40 | .display(", ") 41 | ) 42 | .yellow() 43 | .bold() 44 | ); 45 | } 46 | create_dir_all(directory.join(".old"))?; 47 | for file in read_dir(directory)? { 48 | let file = file?; 49 | // If it's a file 50 | if file.file_type()?.is_file() { 51 | let filename = file.file_name(); 52 | let filename = filename.to_string_lossy(); 53 | let filename = filename.as_ref(); 54 | // If it is already downloaded 55 | if let Some(index) = to_download 56 | .iter() 57 | .position(|thing| filename == thing.filename()) 58 | { 59 | // Don't download it 60 | to_download.swap_remove(index); 61 | // Likewise, if it is already installed 62 | } else if let Some(index) = to_install.iter().position(|thing| filename == thing.0) { 63 | // Don't install it 64 | to_install.swap_remove(index); 65 | // Or else, move the file to `directory`/.old 66 | // If the file is a `.part` file or if the move failed, delete the file 67 | } else if filename.ends_with("part") 68 | || move_file( 69 | file.path(), 70 | directory.join(".old").join(filename), 71 | &FileCopyOptions::new(), 72 | ) 73 | .is_err() 74 | { 75 | remove_file(file.path())?; 76 | } 77 | } 78 | } 79 | Ok(()) 80 | } 81 | 82 | /// Construct a `to_install` vector from the `directory` 83 | pub fn read_overrides(directory: &Path) -> Result> { 84 | let mut to_install = Vec::new(); 85 | if directory.exists() { 86 | for file in read_dir(directory)? { 87 | let file = file?; 88 | to_install.push((file.file_name(), file.path())); 89 | } 90 | } 91 | Ok(to_install) 92 | } 93 | 94 | /// Download and install the files in `to_download` and `to_install` to `output_dir` 95 | pub async fn download( 96 | output_dir: PathBuf, 97 | to_download: Vec, 98 | to_install: Vec<(OsString, PathBuf)>, 99 | ) -> Result<()> { 100 | let progress_bar = Arc::new(Mutex::new( 101 | ProgressBar::new( 102 | to_download 103 | .iter() 104 | .map(|downloadable| downloadable.length as u64) 105 | .sum(), 106 | ) 107 | .with_style(STYLE_BYTE.clone()), 108 | )); 109 | progress_bar 110 | .lock() 111 | .enable_steady_tick(Duration::from_millis(100)); 112 | let mut tasks = JoinSet::new(); 113 | let client = reqwest::Client::new(); 114 | 115 | for downloadable in to_download { 116 | let progress_bar = Arc::clone(&progress_bar); 117 | let client = client.clone(); 118 | let output_dir = output_dir.clone(); 119 | 120 | tasks.spawn(async move { 121 | let _permit = SEMAPHORE.get_or_init(default_semaphore).acquire().await?; 122 | 123 | let (length, filename) = downloadable 124 | .download(client, &output_dir, |additional| { 125 | progress_bar.lock().inc(additional as u64); 126 | }) 127 | .await?; 128 | progress_bar.lock().println(format!( 129 | "{} Downloaded {:>7} {}", 130 | &*TICK, 131 | size::Size::from_bytes(length) 132 | .format() 133 | .with_base(size::Base::Base10) 134 | .to_string(), 135 | filename.dimmed(), 136 | )); 137 | Ok::<(), Error>(()) 138 | }); 139 | } 140 | for res in tasks.join_all().await { 141 | res?; 142 | } 143 | Arc::try_unwrap(progress_bar) 144 | .map_err(|_| anyhow!("Failed to run threads to completion"))? 145 | .into_inner() 146 | .finish_and_clear(); 147 | for (name, path) in to_install { 148 | if path.is_file() { 149 | copy(path, output_dir.join(&name))?; 150 | } else if path.is_dir() { 151 | let mut copy_options = DirCopyOptions::new(); 152 | copy_options.overwrite = true; 153 | copy_dir(path, &output_dir, ©_options)?; 154 | } else { 155 | bail!("Could not determine whether installable is a file or folder") 156 | } 157 | println!( 158 | "{} Installed {}", 159 | &*TICK, 160 | name.to_string_lossy().dimmed() 161 | ); 162 | } 163 | 164 | Ok(()) 165 | } 166 | 167 | /// Find duplicates of the items in `slice` using a value obtained by the `key` closure 168 | /// 169 | /// Returns the indices of duplicate items in reverse order for easy removal 170 | fn find_dupes_by_key(slice: &mut [T], key: F) -> Vec 171 | where 172 | V: Eq + Ord, 173 | F: Fn(&T) -> V, 174 | { 175 | let mut indices = Vec::new(); 176 | if slice.len() < 2 { 177 | return indices; 178 | } 179 | slice.sort_unstable_by_key(&key); 180 | for i in 0..(slice.len() - 1) { 181 | if key(&slice[i]) == key(&slice[i + 1]) { 182 | indices.push(i); 183 | } 184 | } 185 | indices.reverse(); 186 | indices 187 | } 188 | -------------------------------------------------------------------------------- /src/file_picker.rs: -------------------------------------------------------------------------------- 1 | use libium::BASE_DIRS; 2 | use std::{ 3 | io::Result, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | #[cfg(feature = "gui")] 8 | /// Uses the system file picker to pick a file, with a `default` path 9 | fn show_folder_picker(default: impl AsRef, prompt: impl Into) -> Option { 10 | rfd::FileDialog::new() 11 | .set_can_create_directories(true) 12 | .set_directory(default) 13 | .set_title(prompt) 14 | .pick_folder() 15 | } 16 | 17 | #[cfg(not(feature = "gui"))] 18 | /// Uses a terminal input to pick a file, with a `default` path 19 | fn show_folder_picker(default: impl AsRef, prompt: impl Into) -> Option { 20 | inquire::Text::new(&prompt.into()) 21 | .with_default(&default.as_ref().display().to_string()) 22 | .prompt() 23 | .ok() 24 | .map(Into::into) 25 | } 26 | 27 | /// Picks a folder using the terminal or system file picker (depending on the feature flag `gui`) 28 | /// 29 | /// The `default` path is shown/opened at first and the `name` is what folder the user is supposed to be picking (e.g. output directory) 30 | pub fn pick_folder( 31 | default: impl AsRef, 32 | prompt: impl Into, 33 | name: impl AsRef, 34 | ) -> Result> { 35 | show_folder_picker(default, prompt) 36 | .map(|raw_in| { 37 | let path = raw_in 38 | .components() 39 | .map(|c| { 40 | if c.as_os_str() == "~" { 41 | BASE_DIRS.home_dir().as_os_str() 42 | } else { 43 | c.as_os_str() 44 | } 45 | }) 46 | .collect::() 47 | .canonicalize()?; 48 | 49 | println!( 50 | "✔ \x1b[01m{}\x1b[0m · \x1b[32m{}\x1b[0m", 51 | name.as_ref(), 52 | path.display(), 53 | ); 54 | 55 | Ok(path) 56 | }) 57 | .transpose() 58 | } 59 | -------------------------------------------------------------------------------- /src/subcommands/list.rs: -------------------------------------------------------------------------------- 1 | use crate::TICK; 2 | use anyhow::{Context as _, Result}; 3 | use colored::Colorize as _; 4 | use ferinth::structures::{project::Project, user::TeamMember}; 5 | use furse::structures::mod_structs::Mod; 6 | use libium::{ 7 | config::structs::{ModIdentifier, Profile}, 8 | iter_ext::IterExt as _, 9 | CURSEFORGE_API, GITHUB_API, MODRINTH_API, 10 | }; 11 | use octocrab::models::{repos::Release, Repository}; 12 | use tokio::task::JoinSet; 13 | 14 | enum Metadata { 15 | CF(Box), 16 | MD(Box, Vec), 17 | GH(Box, Vec), 18 | } 19 | impl Metadata { 20 | fn name(&self) -> &str { 21 | match self { 22 | Metadata::CF(p) => &p.name, 23 | Metadata::MD(p, _) => &p.title, 24 | Metadata::GH(p, _) => &p.name, 25 | } 26 | } 27 | 28 | #[expect(clippy::unwrap_used)] 29 | fn id(&self) -> ModIdentifier { 30 | match self { 31 | Metadata::CF(p) => ModIdentifier::CurseForgeProject(p.id), 32 | Metadata::MD(p, _) => ModIdentifier::ModrinthProject(p.id.clone()), 33 | Metadata::GH(p, _) => { 34 | ModIdentifier::GitHubRepository(p.owner.clone().unwrap().login, p.name.clone()) 35 | } 36 | } 37 | } 38 | 39 | fn slug(&self) -> &str { 40 | match self { 41 | Metadata::CF(p) => &p.slug, 42 | Metadata::MD(p, _) => &p.slug, 43 | Metadata::GH(p, _) => &p.name, 44 | } 45 | } 46 | } 47 | 48 | pub async fn verbose(profile: &mut Profile, markdown: bool) -> Result<()> { 49 | if !markdown { 50 | eprint!("Querying metadata... "); 51 | } 52 | 53 | let mut tasks = JoinSet::new(); 54 | let mut mr_ids = Vec::new(); 55 | let mut cf_ids = Vec::new(); 56 | for mod_ in &profile.mods { 57 | match mod_.identifier.clone() { 58 | ModIdentifier::CurseForgeProject(project_id) => cf_ids.push(project_id), 59 | ModIdentifier::ModrinthProject(project_id) => mr_ids.push(project_id), 60 | ModIdentifier::GitHubRepository(owner, repo) => { 61 | let repo = GITHUB_API.repos(owner, repo); 62 | tasks.spawn(async move { 63 | Ok::<_, anyhow::Error>(( 64 | repo.get().await?, 65 | repo.releases().list().send().await?, 66 | )) 67 | }); 68 | } 69 | _ => todo!(), 70 | } 71 | } 72 | 73 | let mr_projects = if mr_ids.is_empty() { 74 | vec![] 75 | } else { 76 | MODRINTH_API 77 | .project_get_multiple(&mr_ids.iter().map(AsRef::as_ref).collect_vec()) 78 | .await? 79 | }; 80 | let mr_teams_members = if mr_projects.is_empty() { 81 | vec![] 82 | } else { 83 | MODRINTH_API 84 | .team_multiple_list_members(&mr_projects.iter().map(|p| p.team.as_ref()).collect_vec()) 85 | .await? 86 | }; 87 | 88 | let cf_projects = if cf_ids.is_empty() { 89 | vec![] 90 | } else { 91 | CURSEFORGE_API.get_mods(cf_ids).await? 92 | }; 93 | 94 | let mut metadata = Vec::new(); 95 | for (project, members) in mr_projects.into_iter().zip(mr_teams_members) { 96 | metadata.push(Metadata::MD(Box::new(project), members)); 97 | } 98 | for project in cf_projects { 99 | metadata.push(Metadata::CF(Box::new(project))); 100 | } 101 | for res in tasks.join_all().await { 102 | let (repo, releases) = res?; 103 | metadata.push(Metadata::GH(Box::new(repo), releases.items)); 104 | } 105 | metadata.sort_unstable_by_key(|e| e.name().to_lowercase()); 106 | 107 | if !markdown { 108 | println!("{}", &*TICK); 109 | } 110 | 111 | for project in &metadata { 112 | let mod_ = profile 113 | .mods 114 | .iter_mut() 115 | .find(|mod_| mod_.identifier == project.id()) 116 | .context("Could not find expected mod")?; 117 | 118 | mod_.name = project.name().to_string(); 119 | mod_.slug = Some(project.slug().to_string()); 120 | 121 | if markdown { 122 | match project { 123 | Metadata::CF(p) => curseforge_md(p), 124 | Metadata::MD(p, t) => modrinth_md(p, t), 125 | Metadata::GH(p, _) => github_md(p), 126 | } 127 | } else { 128 | match project { 129 | Metadata::CF(p) => curseforge(p), 130 | Metadata::MD(p, t) => modrinth(p, t), 131 | Metadata::GH(p, r) => github(p, r), 132 | } 133 | } 134 | } 135 | 136 | Ok(()) 137 | } 138 | 139 | pub fn curseforge(project: &Mod) { 140 | println!( 141 | " 142 | {} 143 | {}\n 144 | Link: {} 145 | Source: {} 146 | Project ID: {} 147 | Open Source: {} 148 | Downloads: {} 149 | Authors: {} 150 | Categories: {}", 151 | project.name.bold(), 152 | project.summary.trim().italic(), 153 | project.links.website_url.to_string().blue().underline(), 154 | "CurseForge Mod".dimmed(), 155 | project.id.to_string().dimmed(), 156 | project 157 | .links 158 | .source_url 159 | .as_ref() 160 | .map_or("No".red(), |url| format!( 161 | "Yes ({})", 162 | url.to_string().blue().underline() 163 | ) 164 | .green()), 165 | project.download_count.to_string().yellow(), 166 | project 167 | .authors 168 | .iter() 169 | .map(|author| &author.name) 170 | .display(", ") 171 | .to_string() 172 | .cyan(), 173 | project 174 | .categories 175 | .iter() 176 | .map(|category| &category.name) 177 | .display(", ") 178 | .to_string() 179 | .magenta(), 180 | ); 181 | } 182 | 183 | pub fn modrinth(project: &Project, team_members: &[TeamMember]) { 184 | println!( 185 | " 186 | {} 187 | {}\n 188 | Link: {} 189 | Source: {} 190 | Project ID: {} 191 | Open Source: {} 192 | Downloads: {} 193 | Authors: {} 194 | Categories: {} 195 | License: {}{}", 196 | project.title.bold(), 197 | project.description.italic(), 198 | format!("https://modrinth.com/mod/{}", project.slug) 199 | .blue() 200 | .underline(), 201 | "Modrinth Mod".dimmed(), 202 | project.id.dimmed(), 203 | project.source_url.as_ref().map_or("No".red(), |url| { 204 | format!("Yes ({})", url.to_string().blue().underline()).green() 205 | }), 206 | project.downloads.to_string().yellow(), 207 | team_members 208 | .iter() 209 | .map(|member| &member.user.username) 210 | .display(", ") 211 | .to_string() 212 | .cyan(), 213 | project 214 | .categories 215 | .iter() 216 | .display(", ") 217 | .to_string() 218 | .magenta(), 219 | { 220 | if project.license.name.is_empty() { 221 | "Custom" 222 | } else { 223 | &project.license.name 224 | } 225 | }, 226 | project.license.url.as_ref().map_or(String::new(), |url| { 227 | format!(" ({})", url.to_string().blue().underline()) 228 | }), 229 | ); 230 | } 231 | 232 | #[expect(clippy::unwrap_used)] 233 | pub fn github(repo: &Repository, releases: &[Release]) { 234 | // Calculate number of downloads 235 | let mut downloads = 0; 236 | for release in releases { 237 | for asset in &release.assets { 238 | downloads += asset.download_count; 239 | } 240 | } 241 | 242 | println!( 243 | " 244 | {}{}\n 245 | Link: {} 246 | Source: {} 247 | Identifier: {} 248 | Open Source: {} 249 | Downloads: {} 250 | Authors: {} 251 | Topics: {} 252 | License: {}", 253 | &repo.name.bold(), 254 | repo.description 255 | .as_ref() 256 | .map_or(String::new(), |description| { 257 | format!("\n {description}") 258 | }) 259 | .italic(), 260 | repo.html_url 261 | .as_ref() 262 | .unwrap() 263 | .to_string() 264 | .blue() 265 | .underline(), 266 | "GitHub Repository".dimmed(), 267 | repo.full_name.as_ref().unwrap().dimmed(), 268 | "Yes".green(), 269 | downloads.to_string().yellow(), 270 | repo.owner.as_ref().unwrap().login.cyan(), 271 | repo.topics.as_ref().map_or("".into(), |topics| topics 272 | .iter() 273 | .display(", ") 274 | .to_string() 275 | .magenta()), 276 | repo.license 277 | .as_ref() 278 | .map_or("None".into(), |license| format!( 279 | "{}{}", 280 | license.name, 281 | license.html_url.as_ref().map_or(String::new(), |url| { 282 | format!(" ({})", url.to_string().blue().underline()) 283 | }) 284 | )), 285 | ); 286 | } 287 | 288 | pub fn curseforge_md(project: &Mod) { 289 | println!( 290 | " 291 | **[{}]({})** 292 | _{}_ 293 | 294 | | | | 295 | |-------------|-----------------| 296 | | Source | CurseForge `{}` | 297 | | Open Source | {} | 298 | | Authors | {} | 299 | | Categories | {} |", 300 | project.name.trim(), 301 | project.links.website_url, 302 | project.summary.trim(), 303 | project.id, 304 | project 305 | .links 306 | .source_url 307 | .as_ref() 308 | .map_or("No".into(), |url| format!("[Yes]({url})")), 309 | project 310 | .authors 311 | .iter() 312 | .map(|author| format!("[{}]({})", author.name, author.url)) 313 | .display(", "), 314 | project 315 | .categories 316 | .iter() 317 | .map(|category| &category.name) 318 | .display(", "), 319 | ); 320 | } 321 | 322 | pub fn modrinth_md(project: &Project, team_members: &[TeamMember]) { 323 | println!( 324 | " 325 | **[{}](https://modrinth.com/mod/{})** 326 | _{}_ 327 | 328 | | | | 329 | |-------------|---------------| 330 | | Source | Modrinth `{}` | 331 | | Open Source | {} | 332 | | Author | {} | 333 | | Categories | {} |", 334 | project.title.trim(), 335 | project.id, 336 | project.description.trim(), 337 | project.id, 338 | project 339 | .source_url 340 | .as_ref() 341 | .map_or("No".into(), |url| { format!("[Yes]({url})") }), 342 | team_members 343 | .iter() 344 | .map(|member| format!( 345 | "[{}](https://modrinth.com/user/{})", 346 | member.user.username, member.user.id 347 | )) 348 | .display(", "), 349 | project.categories.iter().display(", "), 350 | ); 351 | } 352 | 353 | #[expect(clippy::unwrap_used)] 354 | pub fn github_md(repo: &Repository) { 355 | println!( 356 | " 357 | **[{}]({})**{} 358 | 359 | | | | 360 | |-------------|-------------| 361 | | Source | GitHub `{}` | 362 | | Open Source | Yes | 363 | | Owner | [{}]({}) |{}", 364 | repo.name, 365 | repo.html_url.as_ref().unwrap(), 366 | repo.description 367 | .as_ref() 368 | .map_or(String::new(), |description| { 369 | format!(" \n_{}_", description.trim()) 370 | }), 371 | repo.full_name.as_ref().unwrap(), 372 | repo.owner.as_ref().unwrap().login, 373 | repo.owner.as_ref().unwrap().html_url, 374 | repo.topics.as_ref().map_or(String::new(), |topics| format!( 375 | "\n| Topics | {} |", 376 | topics.iter().display(", ") 377 | )), 378 | ); 379 | } 380 | -------------------------------------------------------------------------------- /src/subcommands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod list; 2 | pub mod modpack; 3 | pub mod profile; 4 | mod remove; 5 | mod upgrade; 6 | pub use remove::remove; 7 | pub use upgrade::upgrade; 8 | -------------------------------------------------------------------------------- /src/subcommands/modpack/add.rs: -------------------------------------------------------------------------------- 1 | use super::check_output_directory; 2 | use crate::{file_picker::pick_folder, TICK}; 3 | use anyhow::{Context as _, Result}; 4 | use colored::Colorize as _; 5 | use inquire::Confirm; 6 | use libium::{ 7 | config::structs::{Config, Modpack, ModpackIdentifier}, 8 | get_minecraft_dir, 9 | iter_ext::IterExt as _, 10 | modpack::add, 11 | }; 12 | use std::path::PathBuf; 13 | 14 | pub async fn curseforge( 15 | config: &mut Config, 16 | project_id: i32, 17 | output_dir: Option, 18 | install_overrides: Option, 19 | ) -> Result<()> { 20 | eprint!("Checking modpack... "); 21 | let project = add::curseforge(config, project_id).await?; 22 | println!("{} ({})", *TICK, project.name); 23 | println!("Where should the modpack be installed to?"); 24 | let output_dir = match output_dir { 25 | Some(some) => some, 26 | None => pick_folder( 27 | get_minecraft_dir(), 28 | "Pick an output directory", 29 | "Output Directory", 30 | )? 31 | .context("Please pick an output directory")?, 32 | }; 33 | check_output_directory(&output_dir)?; 34 | let install_overrides = match install_overrides { 35 | Some(some) => some, 36 | None => Confirm::new("Should overrides be installed?") 37 | .with_default(true) 38 | .prompt() 39 | .unwrap_or_default(), 40 | }; 41 | if install_overrides { 42 | println!( 43 | "{}", 44 | "WARNING: Files in your output directory may be overwritten by modpack overrides" 45 | .yellow() 46 | .bold() 47 | ); 48 | } 49 | config.modpacks.push(Modpack { 50 | name: project.name, 51 | identifier: ModpackIdentifier::CurseForgeModpack(project.id), 52 | output_dir, 53 | install_overrides, 54 | }); 55 | // Make added modpack active 56 | config.active_modpack = config.modpacks.len() - 1; 57 | Ok(()) 58 | } 59 | 60 | pub async fn modrinth( 61 | config: &mut Config, 62 | project_id: &str, 63 | output_dir: Option, 64 | install_overrides: Option, 65 | ) -> Result<()> { 66 | eprint!("Checking modpack... "); 67 | let project = add::modrinth(config, project_id).await?; 68 | println!("{} ({})", *TICK, project.title); 69 | println!("Where should the modpack be installed to?"); 70 | let output_dir = match output_dir { 71 | Some(some) => some, 72 | None => pick_folder( 73 | get_minecraft_dir(), 74 | "Pick an output directory", 75 | "Output Directory", 76 | )? 77 | .context("Please pick an output directory")?, 78 | }; 79 | check_output_directory(&output_dir)?; 80 | let install_overrides = match install_overrides { 81 | Some(some) => some, 82 | None => Confirm::new("Should overrides be installed?") 83 | .with_default(true) 84 | .prompt() 85 | .unwrap_or_default(), 86 | }; 87 | if install_overrides { 88 | println!( 89 | "{}", 90 | "WARNING: Configs in your output directory may be overwritten by modpack overrides" 91 | .yellow() 92 | .bold() 93 | ); 94 | } 95 | if !project.donation_urls.is_empty() { 96 | println!( 97 | "Consider supporting the mod creator on {}", 98 | project 99 | .donation_urls 100 | .iter() 101 | .map(|this| format!( 102 | "{} ({})", 103 | this.platform.bold(), 104 | this.url.to_string().blue().underline() 105 | )) 106 | .display(", ") 107 | ); 108 | } 109 | config.modpacks.push(Modpack { 110 | name: project.title, 111 | identifier: ModpackIdentifier::ModrinthModpack(project.id), 112 | output_dir, 113 | install_overrides, 114 | }); 115 | // Make added modpack active 116 | config.active_modpack = config.modpacks.len() - 1; 117 | Ok(()) 118 | } 119 | -------------------------------------------------------------------------------- /src/subcommands/modpack/configure.rs: -------------------------------------------------------------------------------- 1 | use super::check_output_directory; 2 | use crate::file_picker::pick_folder; 3 | use anyhow::Result; 4 | use colored::Colorize as _; 5 | use inquire::Confirm; 6 | use libium::config::structs::Modpack; 7 | use std::path::PathBuf; 8 | 9 | pub fn configure( 10 | modpack: &mut Modpack, 11 | output_dir: Option, 12 | install_overrides: Option, 13 | ) -> Result<()> { 14 | match output_dir { 15 | Some(output_dir) => { 16 | check_output_directory(&output_dir)?; 17 | modpack.output_dir = output_dir; 18 | } 19 | None => { 20 | if let Some(dir) = pick_folder( 21 | &modpack.output_dir, 22 | "Pick an output directory", 23 | "Output Directory", 24 | )? { 25 | check_output_directory(&dir)?; 26 | modpack.output_dir = dir; 27 | } 28 | } 29 | } 30 | modpack.install_overrides = if let Some(install_overrides) = install_overrides { 31 | install_overrides 32 | } else { 33 | let install_overrides = Confirm::new("Should overrides be installed?") 34 | .with_default(modpack.install_overrides) 35 | .prompt() 36 | .unwrap_or(modpack.install_overrides); 37 | if install_overrides { 38 | println!( 39 | "{}", 40 | "WARNING: Configs in your output directory may be overwritten by modpack overrides" 41 | .yellow() 42 | .bold() 43 | ); 44 | } 45 | install_overrides 46 | }; 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /src/subcommands/modpack/delete.rs: -------------------------------------------------------------------------------- 1 | use super::switch; 2 | use anyhow::{Context as _, Result}; 3 | use colored::Colorize as _; 4 | use inquire::Select; 5 | use libium::{ 6 | config::structs::{Config, ModpackIdentifier}, 7 | iter_ext::IterExt as _, 8 | }; 9 | use std::cmp::Ordering; 10 | 11 | pub fn delete( 12 | config: &mut Config, 13 | modpack_name: Option, 14 | switch_to: Option, 15 | ) -> Result<()> { 16 | // If the modpack name has been provided as an option 17 | let selection = if let Some(modpack_name) = modpack_name { 18 | config 19 | .modpacks 20 | .iter() 21 | .position(|modpack| modpack.name.eq_ignore_ascii_case(&modpack_name)) 22 | .context("The modpack name provided does not exist")? 23 | } else { 24 | let modpack_names = config 25 | .modpacks 26 | .iter() 27 | .map(|modpack| { 28 | format!( 29 | "{} {}", 30 | match &modpack.identifier { 31 | ModpackIdentifier::CurseForgeModpack(id) => 32 | format!("{} {:8}", "CF".red(), id.to_string().dimmed()), 33 | ModpackIdentifier::ModrinthModpack(id) => 34 | format!("{} {:8}", "MR".green(), id.dimmed()), 35 | }, 36 | modpack.name.bold(), 37 | ) 38 | }) 39 | .collect_vec(); 40 | 41 | if let Ok(selection) = Select::new("Select which modpack to delete", modpack_names) 42 | .with_starting_cursor(config.active_modpack) 43 | .raw_prompt() 44 | { 45 | selection.index 46 | } else { 47 | return Ok(()); 48 | } 49 | }; 50 | config.modpacks.remove(selection); 51 | 52 | match config.active_modpack.cmp(&selection) { 53 | // If the currently selected modpack is being removed 54 | Ordering::Equal => { 55 | // And there is more than one modpack 56 | if config.modpacks.len() > 1 { 57 | // Let the user pick which modpack to switch to 58 | switch(config, switch_to)?; 59 | } else { 60 | config.active_modpack = 0; 61 | } 62 | } 63 | // If the active modpack comes after the removed modpack 64 | Ordering::Greater => { 65 | // Decrement the index by one 66 | config.active_modpack -= 1; 67 | } 68 | Ordering::Less => (), 69 | } 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /src/subcommands/modpack/info.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize as _; 2 | use libium::config::structs::{Modpack, ModpackIdentifier}; 3 | 4 | pub fn info(modpack: &Modpack, active: bool) { 5 | println!( 6 | "{}{} 7 | \r Output directory: {} 8 | \r Identifier: {} 9 | \r Install Overrides: {}\n", 10 | modpack.name.bold(), 11 | if active { " *" } else { "" }, 12 | modpack.output_dir.display().to_string().blue().underline(), 13 | match &modpack.identifier { 14 | ModpackIdentifier::CurseForgeModpack(id) => 15 | format!("{:10} {}", "CurseForge".red(), id.to_string().dimmed()), 16 | ModpackIdentifier::ModrinthModpack(id) => 17 | format!("{:10} {}", "Modrinth".green(), id.dimmed()), 18 | }, 19 | modpack.install_overrides 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/subcommands/modpack/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | mod configure; 3 | mod delete; 4 | mod info; 5 | mod switch; 6 | mod upgrade; 7 | pub use configure::configure; 8 | pub use delete::delete; 9 | pub use info::info; 10 | pub use switch::switch; 11 | pub use upgrade::upgrade; 12 | 13 | use crate::file_picker::pick_folder; 14 | use anyhow::{ensure, Context as _, Result}; 15 | use fs_extra::dir::{copy, CopyOptions}; 16 | use inquire::Confirm; 17 | use libium::BASE_DIRS; 18 | use std::{fs::read_dir, path::Path}; 19 | 20 | pub fn check_output_directory(output_dir: &Path) -> Result<()> { 21 | ensure!( 22 | output_dir.is_absolute(), 23 | "The provided output directory is not absolute, i.e. it is a relative path" 24 | ); 25 | 26 | for check_dir in [output_dir.join("mods"), output_dir.join("resourcepacks")] { 27 | let mut backup = false; 28 | if check_dir.exists() { 29 | for file in read_dir(&check_dir)? { 30 | let file = file?; 31 | if file.path().is_file() && file.file_name() != ".DS_Store" { 32 | backup = true; 33 | break; 34 | } 35 | } 36 | } 37 | if backup { 38 | println!( 39 | "There are files in the {} folder in your output directory, these will be deleted when you upgrade.", 40 | check_dir.file_name().context("Unable to get folder name")?.to_string_lossy() 41 | ); 42 | if Confirm::new("Would like to create a backup?") 43 | .prompt() 44 | .unwrap_or_default() 45 | { 46 | let backup_dir = pick_folder( 47 | BASE_DIRS.home_dir(), 48 | "Where should the backup be made?", 49 | "Output Directory", 50 | )? 51 | .context("Please pick an output directory")?; 52 | copy(check_dir, backup_dir, &CopyOptions::new())?; 53 | } 54 | } 55 | } 56 | Ok(()) 57 | } 58 | -------------------------------------------------------------------------------- /src/subcommands/modpack/switch.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use colored::Colorize as _; 3 | use inquire::Select; 4 | use libium::{ 5 | config::structs::{Config, ModpackIdentifier}, 6 | iter_ext::IterExt as _, 7 | }; 8 | 9 | pub fn switch(config: &mut Config, modpack_name: Option) -> Result<()> { 10 | if config.modpacks.len() <= 1 { 11 | config.active_modpack = 0; 12 | Err(anyhow!("There is only 1 modpack in your config")) 13 | } else if let Some(modpack_name) = modpack_name { 14 | match config 15 | .modpacks 16 | .iter() 17 | .position(|modpack| modpack.name.eq_ignore_ascii_case(&modpack_name)) 18 | { 19 | Some(selection) => { 20 | config.active_modpack = selection; 21 | Ok(()) 22 | } 23 | None => Err(anyhow!("The modpack provided does not exist")), 24 | } 25 | } else { 26 | let modpack_info = config 27 | .modpacks 28 | .iter() 29 | .map(|modpack| { 30 | format!( 31 | "{} {}", 32 | match &modpack.identifier { 33 | ModpackIdentifier::CurseForgeModpack(id) => 34 | format!("{} {:8}", "CF".red(), id.to_string().dimmed()), 35 | ModpackIdentifier::ModrinthModpack(id) => 36 | format!("{} {:8}", "MR".green(), id.dimmed()), 37 | }, 38 | modpack.name.bold(), 39 | ) 40 | }) 41 | .collect_vec(); 42 | 43 | let mut select = Select::new("Select which modpack to switch to", modpack_info); 44 | if config.active_modpack < config.modpacks.len() { 45 | select.starting_cursor = config.active_modpack; 46 | } 47 | if let Ok(selection) = select.raw_prompt() { 48 | config.active_modpack = selection.index; 49 | } 50 | Ok(()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/subcommands/modpack/upgrade.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | download::{clean, download, read_overrides}, 3 | STYLE_BYTE, TICK, 4 | }; 5 | use anyhow::{Context as _, Result}; 6 | use colored::Colorize as _; 7 | use indicatif::ProgressBar; 8 | use libium::{ 9 | config::structs::{Modpack, ModpackIdentifier}, 10 | iter_ext::IterExt as _, 11 | modpack::{ 12 | curseforge::structs::Manifest as CFManifest, modrinth::structs::Metadata as MRMetadata, 13 | read_file_from_zip, zip_extract, 14 | }, 15 | upgrade::{from_modpack_file, try_from_cf_file, DistributionDeniedError, DownloadData}, 16 | CURSEFORGE_API, 17 | }; 18 | use std::{ 19 | fs::File, 20 | io::BufReader, 21 | path::{Path, PathBuf}, 22 | time::Duration, 23 | }; 24 | use tokio::task::JoinSet; 25 | 26 | pub async fn upgrade(modpack: &'_ Modpack) -> Result<()> { 27 | let mut to_download: Vec = Vec::new(); 28 | let mut to_install = Vec::new(); 29 | let install_msg; 30 | 31 | let progress_bar = ProgressBar::new(0).with_style(STYLE_BYTE.clone()); 32 | let modpack_filepath = modpack 33 | .identifier 34 | .download_file( 35 | |total| { 36 | progress_bar.println("Downloading Modpack".bold().to_string()); 37 | progress_bar.enable_steady_tick(Duration::from_millis(100)); 38 | progress_bar.set_length(total as u64); 39 | }, 40 | |additional| { 41 | progress_bar.inc(additional as u64); 42 | }, 43 | ) 44 | .await?; 45 | let modpack_file = File::open(&modpack_filepath)?; 46 | progress_bar.finish_and_clear(); 47 | 48 | match &modpack.identifier { 49 | ModpackIdentifier::CurseForgeModpack(_) => { 50 | let manifest: CFManifest = serde_json::from_str( 51 | &read_file_from_zip(BufReader::new(modpack_file), "manifest.json")? 52 | .context("Does not contain manifest")?, 53 | )?; 54 | 55 | eprint!("\n{}", "Determining files to download... ".bold()); 56 | 57 | let file_ids = manifest.files.iter().map(|file| file.file_id).collect(); 58 | let files = CURSEFORGE_API.get_files(file_ids).await?; 59 | println!("{} Fetched {} mods", &*TICK, files.len()); 60 | 61 | let mut tasks = JoinSet::new(); 62 | let mut msg_shown = false; 63 | for file in files.into_iter().flatten() { 64 | match try_from_cf_file(file) { 65 | Ok((_, mut downloadable)) => { 66 | downloadable.output = PathBuf::from( 67 | if Path::new(&downloadable.filename()) 68 | .extension() 69 | .is_some_and(|ext| ext.eq_ignore_ascii_case(".zip")) 70 | { 71 | "resourcepacks" 72 | } else { 73 | "mods" 74 | }, 75 | ) 76 | .join(downloadable.filename()); 77 | to_download.push(downloadable); 78 | } 79 | Err(DistributionDeniedError(mod_id, file_id)) => { 80 | if !msg_shown { 81 | println!("\n{}", "The following mod(s) have denied 3rd parties such as Ferium from downloading it".red().bold()); 82 | } 83 | msg_shown = true; 84 | tasks.spawn(async move { 85 | let project = CURSEFORGE_API.get_mod(mod_id).await?; 86 | eprintln!( 87 | "- {} 88 | \r {}", 89 | project.name.bold(), 90 | format!("{}/download/{file_id}", project.links.website_url) 91 | .blue() 92 | .underline(), 93 | ); 94 | Ok::<(), furse::Error>(()) 95 | }); 96 | } 97 | } 98 | } 99 | 100 | for res in tasks.join_all().await { 101 | res?; 102 | } 103 | 104 | install_msg = format!( 105 | "You can play this modpack using Minecraft {} with {}", 106 | manifest.minecraft.version, 107 | manifest 108 | .minecraft 109 | .mod_loaders 110 | .iter() 111 | .map(|this| &this.id) 112 | .display(", ") 113 | ); 114 | 115 | if modpack.install_overrides { 116 | let tmp_dir = libium::PROJECT_DIRS 117 | .cache_dir() 118 | .join("extracted") 119 | .join(manifest.name); 120 | zip_extract(&modpack_filepath, &tmp_dir)?; 121 | to_install = read_overrides(&tmp_dir.join(manifest.overrides))?; 122 | } 123 | } 124 | ModpackIdentifier::ModrinthModpack(_) => { 125 | let metadata: MRMetadata = serde_json::from_str( 126 | &read_file_from_zip(BufReader::new(modpack_file), "modrinth.index.json")? 127 | .context("Does not contain metadata file")?, 128 | )?; 129 | 130 | for file in metadata.files { 131 | to_download.push(from_modpack_file(file)); 132 | } 133 | 134 | install_msg = format!( 135 | "You can play this modpack using the following:\n{}", 136 | metadata 137 | .dependencies 138 | .iter() 139 | .map(|this| format!("{:?} {}", this.0, this.1)) 140 | .display("\n") 141 | ); 142 | 143 | if modpack.install_overrides { 144 | let tmp_dir = libium::PROJECT_DIRS 145 | .cache_dir() 146 | .join("extracted") 147 | .join(metadata.name); 148 | zip_extract(&modpack_filepath, &tmp_dir)?; 149 | to_install = read_overrides(&tmp_dir.join("overrides"))?; 150 | } 151 | } 152 | } 153 | clean( 154 | &modpack.output_dir.join("mods"), 155 | &mut to_download, 156 | &mut Vec::new(), 157 | ) 158 | .await?; 159 | clean( 160 | &modpack.output_dir.join("resourcepacks"), 161 | &mut to_download, 162 | &mut Vec::new(), 163 | ) 164 | .await?; 165 | // TODO: Check for `to_install` files that are already installed 166 | if to_download.is_empty() && to_install.is_empty() { 167 | println!("\n{}", "All up to date!".bold()); 168 | } else { 169 | println!( 170 | "\n{}\n", 171 | format!("Downloading {} Mod Files", to_download.len()).bold() 172 | ); 173 | download(modpack.output_dir.clone(), to_download, to_install).await?; 174 | } 175 | println!("\n{}", install_msg.bold()); 176 | Ok(()) 177 | } 178 | -------------------------------------------------------------------------------- /src/subcommands/profile/configure.rs: -------------------------------------------------------------------------------- 1 | use super::{check_output_directory, pick_minecraft_versions, pick_mod_loader}; 2 | use crate::file_picker::pick_folder; 3 | use anyhow::{Context as _, Result}; 4 | use inquire::{Select, Text}; 5 | use libium::{ 6 | config::filters::ProfileParameters as _, 7 | config::structs::{ModLoader, Profile}, 8 | }; 9 | use std::path::PathBuf; 10 | 11 | pub async fn configure( 12 | profile: &mut Profile, 13 | game_versions: Vec, 14 | mod_loaders: Vec, 15 | name: Option, 16 | output_dir: Option, 17 | ) -> Result<()> { 18 | let mut interactive = true; 19 | 20 | if !game_versions.is_empty() { 21 | *profile 22 | .filters 23 | .game_versions_mut() 24 | .context("Active profile does not filter by game version")? = game_versions; 25 | 26 | interactive = false; 27 | } 28 | if !mod_loaders.is_empty() { 29 | *profile 30 | .filters 31 | .mod_loaders_mut() 32 | .context("Active profile does not filter mod loader")? = mod_loaders; 33 | 34 | interactive = false; 35 | } 36 | if let Some(name) = name { 37 | profile.name = name; 38 | interactive = false; 39 | } 40 | if let Some(output_dir) = output_dir { 41 | profile.output_dir = output_dir; 42 | interactive = false; 43 | } 44 | 45 | if interactive { 46 | let items = vec![ 47 | // Show a file dialog 48 | "Mods output directory", 49 | // Show a picker of Minecraft versions to select from 50 | "Minecraft version", 51 | // Show a picker to change mod loader 52 | "Mod loader", 53 | // Show a dialog to change name 54 | "Profile Name", 55 | // Quit the configuration 56 | "Quit", 57 | ]; 58 | 59 | while let Ok(selection) = 60 | Select::new("Which setting would you like to change", items.clone()).raw_prompt() 61 | { 62 | match selection.index { 63 | 0 => { 64 | if let Some(dir) = pick_folder( 65 | &profile.output_dir, 66 | "Pick an output directory", 67 | "Output Directory", 68 | )? { 69 | check_output_directory(&dir).await?; 70 | profile.output_dir = dir; 71 | } 72 | } 73 | 1 => { 74 | let Some(versions) = profile.filters.game_versions_mut() else { 75 | println!("Active profile does not filter by game version"); 76 | continue; 77 | }; 78 | 79 | if let Ok(selection) = pick_minecraft_versions(versions).await { 80 | *versions = selection; 81 | } 82 | } 83 | 2 => { 84 | let Some(loaders) = profile.filters.mod_loaders_mut() else { 85 | println!("Active profile does not filter mod loader"); 86 | continue; 87 | }; 88 | 89 | if let Ok(selection) = pick_mod_loader(loaders.first()) { 90 | *loaders = match selection { 91 | ModLoader::Quilt => vec![ModLoader::Quilt, ModLoader::Fabric], 92 | loader => vec![loader], 93 | } 94 | } 95 | } 96 | 3 => { 97 | if let Ok(new_name) = Text::new("Change the profile's name") 98 | .with_default(&profile.name) 99 | .prompt() 100 | { 101 | profile.name = new_name; 102 | } else { 103 | continue; 104 | } 105 | } 106 | 4 => break, 107 | _ => unreachable!(), 108 | } 109 | println!(); 110 | } 111 | } 112 | 113 | Ok(()) 114 | } 115 | -------------------------------------------------------------------------------- /src/subcommands/profile/create.rs: -------------------------------------------------------------------------------- 1 | use super::{check_output_directory, pick_minecraft_versions, pick_mod_loader}; 2 | use crate::file_picker::pick_folder; 3 | use anyhow::{bail, ensure, Context as _, Result}; 4 | use colored::Colorize as _; 5 | use inquire::{ 6 | validator::{ErrorMessage, Validation}, 7 | Confirm, Select, Text, 8 | }; 9 | use libium::{ 10 | config::structs::{Config, ModLoader, Profile}, 11 | get_minecraft_dir, 12 | iter_ext::IterExt as _, 13 | }; 14 | use std::path::PathBuf; 15 | 16 | #[expect(clippy::option_option)] 17 | pub async fn create( 18 | config: &mut Config, 19 | import: Option>, 20 | game_versions: Option>, 21 | mod_loader: Option, 22 | name: Option, 23 | output_dir: Option, 24 | ) -> Result<()> { 25 | let mut profile = match (game_versions, mod_loader, name, output_dir) { 26 | (Some(game_versions), Some(mod_loader), Some(name), output_dir) => { 27 | for profile in &config.profiles { 28 | ensure!( 29 | !profile.name.eq_ignore_ascii_case(&name), 30 | "A profile with name {name} already exists" 31 | ); 32 | } 33 | let output_dir = output_dir.unwrap_or_else(|| get_minecraft_dir().join("mods")); 34 | ensure!( 35 | output_dir.is_absolute(), 36 | "The provided output directory is not absolute, i.e. it is a relative path" 37 | ); 38 | 39 | Profile::new(name, output_dir, game_versions, mod_loader) 40 | } 41 | (None, None, None, None) => { 42 | let mut selected_mods_dir = get_minecraft_dir().join("mods"); 43 | println!( 44 | "The default mods directory is {}", 45 | selected_mods_dir.display() 46 | ); 47 | if Confirm::new("Would you like to specify a custom mods directory?") 48 | .prompt() 49 | .unwrap_or_default() 50 | { 51 | if let Some(dir) = pick_folder( 52 | &selected_mods_dir, 53 | "Pick an output directory", 54 | "Output Directory", 55 | )? { 56 | check_output_directory(&dir).await?; 57 | selected_mods_dir = dir; 58 | } 59 | } 60 | 61 | let profiles = config.profiles.clone(); 62 | let name = Text::new("What should this profile be called") 63 | .with_validator(move |s: &str| { 64 | Ok(if profiles.iter().any(|p| p.name.eq_ignore_ascii_case(s)) { 65 | Validation::Invalid(ErrorMessage::Custom( 66 | "A profile with that name already exists".to_owned(), 67 | )) 68 | } else { 69 | Validation::Valid 70 | }) 71 | }) 72 | .prompt()?; 73 | 74 | Profile::new( 75 | name, 76 | selected_mods_dir, 77 | pick_minecraft_versions(&[]).await?, 78 | pick_mod_loader(None)?, 79 | ) 80 | } 81 | _ => { 82 | bail!("Provide the name, game version, mod loader, and output directory options to create a profile") 83 | } 84 | }; 85 | 86 | if let Some(from) = import { 87 | ensure!( 88 | !config.profiles.is_empty(), 89 | "There are no profiles configured to import mods from" 90 | ); 91 | 92 | // If the profile name has been provided as an option 93 | if let Some(profile_name) = from { 94 | let selection = config 95 | .profiles 96 | .iter() 97 | .position(|profile| profile.name.eq_ignore_ascii_case(&profile_name)) 98 | .context("The profile name provided does not exist")?; 99 | profile.mods.clone_from(&config.profiles[selection].mods); 100 | } else { 101 | let profile_names = config 102 | .profiles 103 | .iter() 104 | .map(|profile| &profile.name) 105 | .collect_vec(); 106 | if let Ok(selection) = 107 | Select::new("Select which profile to import mods from", profile_names) 108 | .with_starting_cursor(config.active_profile) 109 | .raw_prompt() 110 | { 111 | profile 112 | .mods 113 | .clone_from(&config.profiles[selection.index].mods); 114 | } 115 | }; 116 | } 117 | 118 | println!( 119 | "{}", 120 | "After adding your mods, remember to run `ferium upgrade` to download them!".yellow() 121 | ); 122 | 123 | config.profiles.push(profile); 124 | config.active_profile = config.profiles.len() - 1; // Make created profile active 125 | Ok(()) 126 | } 127 | -------------------------------------------------------------------------------- /src/subcommands/profile/delete.rs: -------------------------------------------------------------------------------- 1 | use super::switch; 2 | use anyhow::{Context as _, Result}; 3 | use colored::Colorize as _; 4 | use inquire::Select; 5 | use libium::{ 6 | config::{filters::ProfileParameters as _, structs::Config}, 7 | iter_ext::IterExt as _, 8 | }; 9 | use std::cmp::Ordering; 10 | 11 | pub fn delete( 12 | config: &mut Config, 13 | profile_name: Option, 14 | switch_to: Option, 15 | ) -> Result<()> { 16 | // If the profile name has been provided as an option 17 | let selection = if let Some(profile_name) = profile_name { 18 | config 19 | .profiles 20 | .iter() 21 | .position(|profile| profile.name.eq_ignore_ascii_case(&profile_name)) 22 | .context("The profile name provided does not exist")? 23 | } else { 24 | let profile_names = config 25 | .profiles 26 | .iter() 27 | .map(|profile| { 28 | format!( 29 | "{:6} {:7} {} {}", 30 | profile 31 | .filters 32 | .mod_loader() 33 | .map(ToString::to_string) 34 | .unwrap_or_default() 35 | .purple(), 36 | profile 37 | .filters 38 | .game_versions() 39 | .map(|v| v.iter().display(", ")) 40 | .unwrap_or_default() 41 | .green(), 42 | profile.name.bold(), 43 | format!("({} mods)", profile.mods.len()).yellow(), 44 | ) 45 | }) 46 | .collect_vec(); 47 | 48 | if let Ok(selection) = Select::new("Select which profile to delete", profile_names) 49 | .with_starting_cursor(config.active_profile) 50 | .raw_prompt() 51 | { 52 | selection.index 53 | } else { 54 | return Ok(()); 55 | } 56 | }; 57 | config.profiles.remove(selection); 58 | 59 | match config.active_profile.cmp(&selection) { 60 | // If the currently selected profile is being removed 61 | Ordering::Equal => { 62 | // And there is more than one profile 63 | if config.profiles.len() > 1 { 64 | // Let the user pick which profile to switch to 65 | switch(config, switch_to)?; 66 | } else { 67 | config.active_profile = 0; 68 | } 69 | } 70 | // If the active profile comes after the removed profile 71 | Ordering::Greater => { 72 | // Decrement the index by one 73 | config.active_profile -= 1; 74 | } 75 | Ordering::Less => (), 76 | } 77 | 78 | Ok(()) 79 | } 80 | -------------------------------------------------------------------------------- /src/subcommands/profile/info.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | use libium::{ 3 | config::{filters::ProfileParameters as _, structs::Profile}, 4 | iter_ext::IterExt as _, 5 | }; 6 | 7 | pub fn info(profile: &Profile, active: bool) { 8 | println!( 9 | "{}{} 10 | \r Output directory: {}{}{} 11 | \r Mods: {}\n", 12 | if active { 13 | profile.name.bold().italic() 14 | } else { 15 | profile.name.bold() 16 | }, 17 | if active { " *" } else { "" }, 18 | profile.output_dir.display().to_string().blue().underline(), 19 | profile 20 | .filters 21 | .game_versions() 22 | .map(|v| format!( 23 | "\n Minecraft Version: {}", 24 | v.iter() 25 | .map(AsRef::as_ref) 26 | .map(Colorize::green) 27 | .display(", ") 28 | )) 29 | .unwrap_or_default(), 30 | profile 31 | .filters 32 | .mod_loader() 33 | .map(|l| format!("\n Mod Loader: {}", l.to_string().purple())) 34 | .unwrap_or_default(), 35 | profile.mods.len().to_string().yellow(), 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/subcommands/profile/mod.rs: -------------------------------------------------------------------------------- 1 | mod configure; 2 | mod create; 3 | mod delete; 4 | mod info; 5 | mod switch; 6 | pub use configure::configure; 7 | pub use create::create; 8 | pub use delete::delete; 9 | pub use info::info; 10 | pub use switch::switch; 11 | 12 | use crate::file_picker::pick_folder; 13 | use anyhow::{ensure, Context as _, Result}; 14 | use colored::Colorize as _; 15 | use ferinth::Ferinth; 16 | use fs_extra::dir::{copy, CopyOptions}; 17 | use inquire::{Confirm, MultiSelect, Select}; 18 | use libium::{config::structs::ModLoader, iter_ext::IterExt as _, BASE_DIRS}; 19 | use std::{ 20 | fs::{create_dir_all, read_dir}, 21 | path::PathBuf, 22 | }; 23 | 24 | #[expect(clippy::unwrap_used, reason = "All variants are present")] 25 | pub fn pick_mod_loader(default: Option<&ModLoader>) -> Result { 26 | let options = [ 27 | ModLoader::Fabric, 28 | ModLoader::Quilt, 29 | ModLoader::NeoForge, 30 | ModLoader::Forge, 31 | ]; 32 | let mut picker = Select::new("Which mod loader do you use?", options.into()); 33 | if let Some(default) = default { 34 | picker.starting_cursor = options.iter().position(|l| l == default).unwrap(); 35 | } 36 | Ok(picker.prompt()?) 37 | } 38 | 39 | pub async fn pick_minecraft_versions(default: &[String]) -> Result> { 40 | let mut versions = Ferinth::default().tag_list_game_versions().await?; 41 | versions.sort_by(|a, b| { 42 | // Sort by release type (release > snapshot > beta > alpha) then in reverse chronological order 43 | a.version_type 44 | .cmp(&b.version_type) 45 | .then(b.date.cmp(&a.date)) 46 | }); 47 | let mut default_indices = vec![]; 48 | let display_versions = versions 49 | .iter() 50 | .enumerate() 51 | .map(|(i, v)| { 52 | if default.contains(&v.version) { 53 | default_indices.push(i); 54 | } 55 | if v.major { 56 | v.version.bold() 57 | } else { 58 | v.version.clone().into() 59 | } 60 | }) 61 | .collect_vec(); 62 | 63 | let selected_versions = 64 | MultiSelect::new("Which version of Minecraft do you play?", display_versions) 65 | .with_default(&default_indices) 66 | .raw_prompt()? 67 | .into_iter() 68 | .map(|s| s.index) 69 | .collect_vec(); 70 | 71 | Ok(versions 72 | .into_iter() 73 | .enumerate() 74 | .filter_map(|(i, v)| { 75 | if selected_versions.contains(&i) { 76 | Some(v.version) 77 | } else { 78 | None 79 | } 80 | }) 81 | .collect_vec()) 82 | } 83 | 84 | pub async fn check_output_directory(output_dir: &PathBuf) -> Result<()> { 85 | ensure!( 86 | output_dir.is_absolute(), 87 | "The provided output directory is not absolute, i.e. it is a relative path" 88 | ); 89 | if output_dir.file_name() != Some(std::ffi::OsStr::new("mods")) { 90 | println!("{}", "Warning! The output directory is not called `mods`. Most mod loaders will load from a directory called `mods`.".bright_yellow()); 91 | } 92 | 93 | let mut backup = false; 94 | if output_dir.exists() { 95 | for file in read_dir(output_dir)? { 96 | let file = file?; 97 | if file.path().is_file() && file.file_name() != ".DS_Store" { 98 | backup = true; 99 | break; 100 | } 101 | } 102 | } 103 | if backup { 104 | println!( 105 | "There are files in your output directory, these will be deleted when you upgrade." 106 | ); 107 | if Confirm::new("Would like to create a backup?") 108 | .prompt() 109 | .unwrap_or_default() 110 | { 111 | let backup_dir = pick_folder( 112 | BASE_DIRS.home_dir(), 113 | "Where should the backup be made?", 114 | "Output Directory", 115 | )? 116 | .context("Please pick a backup directory")?; 117 | create_dir_all(&backup_dir)?; 118 | copy(output_dir, backup_dir, &CopyOptions::new())?; 119 | } 120 | } 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /src/subcommands/profile/switch.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use colored::Colorize as _; 3 | use inquire::Select; 4 | use libium::{ 5 | config::{filters::ProfileParameters as _, structs::Config}, 6 | iter_ext::IterExt as _, 7 | }; 8 | 9 | pub fn switch(config: &mut Config, profile_name: Option) -> Result<()> { 10 | if config.profiles.len() <= 1 { 11 | Err(anyhow!("There is only 1 profile in your config")) 12 | } else if let Some(profile_name) = profile_name { 13 | match config 14 | .profiles 15 | .iter() 16 | .position(|profile| profile.name.eq_ignore_ascii_case(&profile_name)) 17 | { 18 | Some(selection) => { 19 | config.active_profile = selection; 20 | Ok(()) 21 | } 22 | None => Err(anyhow!("The profile provided does not exist")), 23 | } 24 | } else { 25 | let profile_info = config 26 | .profiles 27 | .iter() 28 | .map(|profile| { 29 | format!( 30 | "{:8} {:7} {} {}", 31 | profile 32 | .filters 33 | .mod_loader() 34 | .map(|l| l.to_string().purple()) 35 | .unwrap_or_default(), 36 | profile 37 | .filters 38 | .game_versions() 39 | .map(|v| v[0].green()) 40 | .unwrap_or_default(), 41 | profile.name.bold(), 42 | format!("({} mods)", profile.mods.len()).yellow(), 43 | ) 44 | }) 45 | .collect_vec(); 46 | 47 | let mut select = Select::new("Select which profile to switch to", profile_info); 48 | if config.active_profile < config.profiles.len() { 49 | select.starting_cursor = config.active_profile; 50 | } 51 | if let Ok(selection) = select.raw_prompt() { 52 | config.active_profile = selection.index; 53 | } 54 | Ok(()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/subcommands/remove.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use colored::Colorize as _; 3 | use inquire::MultiSelect; 4 | use libium::{ 5 | config::structs::{ModIdentifier, Profile}, 6 | iter_ext::IterExt as _, 7 | }; 8 | 9 | /// If `to_remove` is empty, display a list of projects in the profile to select from and remove selected ones 10 | /// 11 | /// Else, search the given strings with the projects' name and IDs and remove them 12 | pub fn remove(profile: &mut Profile, to_remove: Vec) -> Result<()> { 13 | let mut indices_to_remove = if to_remove.is_empty() { 14 | let mod_info = profile 15 | .mods 16 | .iter() 17 | .map(|mod_| { 18 | format!( 19 | "{:11} {}", 20 | match &mod_.identifier { 21 | ModIdentifier::CurseForgeProject(id) => format!("CF {:8}", id.to_string()), 22 | ModIdentifier::ModrinthProject(id) => format!("MR {id:8}"), 23 | ModIdentifier::GitHubRepository(..) => "GH".to_string(), 24 | _ => todo!(), 25 | }, 26 | match &mod_.identifier { 27 | ModIdentifier::ModrinthProject(_) | ModIdentifier::CurseForgeProject(_) => 28 | mod_.name.clone(), 29 | ModIdentifier::GitHubRepository(owner, repo) => format!("{owner}/{repo}"), 30 | _ => todo!(), 31 | }, 32 | ) 33 | }) 34 | .collect_vec(); 35 | MultiSelect::new("Select mods to remove", mod_info.clone()) 36 | .raw_prompt_skippable()? 37 | .unwrap_or_default() 38 | .iter() 39 | .map(|o| o.index) 40 | .collect_vec() 41 | } else { 42 | let mut items_to_remove = Vec::new(); 43 | for to_remove in to_remove { 44 | if let Some(index) = profile.mods.iter().position(|mod_| { 45 | mod_.name.eq_ignore_ascii_case(&to_remove) 46 | || match &mod_.identifier { 47 | ModIdentifier::CurseForgeProject(id) => id.to_string() == to_remove, 48 | ModIdentifier::ModrinthProject(id) => id == &to_remove, 49 | ModIdentifier::GitHubRepository(owner, name) => { 50 | format!("{owner}/{name}").eq_ignore_ascii_case(&to_remove) 51 | } 52 | _ => todo!(), 53 | } 54 | || mod_ 55 | .slug 56 | .as_ref() 57 | .is_some_and(|slug| to_remove.eq_ignore_ascii_case(slug)) 58 | }) { 59 | items_to_remove.push(index); 60 | } else { 61 | bail!("A mod with ID or name {to_remove} is not present in this profile"); 62 | } 63 | } 64 | items_to_remove 65 | }; 66 | 67 | // Sort the indices in ascending order to fix moving indices during removal 68 | indices_to_remove.sort_unstable(); 69 | indices_to_remove.reverse(); 70 | 71 | let mut removed = Vec::new(); 72 | for index in indices_to_remove { 73 | removed.push(profile.mods.swap_remove(index).name); 74 | } 75 | 76 | if !removed.is_empty() { 77 | println!( 78 | "Removed {}", 79 | removed.iter().map(|txt| txt.bold()).display(", ") 80 | ); 81 | } 82 | 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /src/subcommands/upgrade.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | default_semaphore, 3 | download::{clean, download}, 4 | CROSS, SEMAPHORE, STYLE_NO, TICK, 5 | }; 6 | use anyhow::{anyhow, bail, Result}; 7 | use colored::Colorize as _; 8 | use indicatif::ProgressBar; 9 | use libium::{ 10 | config::{ 11 | filters::ProfileParameters as _, 12 | structs::{Mod, ModIdentifier, ModLoader, Profile}, 13 | }, 14 | upgrade::{mod_downloadable, DownloadData}, 15 | }; 16 | use parking_lot::Mutex; 17 | use std::{ 18 | fs::read_dir, 19 | mem::take, 20 | sync::{mpsc, Arc}, 21 | time::Duration, 22 | }; 23 | use tokio::task::JoinSet; 24 | 25 | /// Get the latest compatible downloadable for the mods in `profile` 26 | /// 27 | /// If an error occurs with a resolving task, instead of failing immediately, 28 | /// resolution will continue and the error return flag is set to true. 29 | pub async fn get_platform_downloadables(profile: &Profile) -> Result<(Vec, bool)> { 30 | let progress_bar = Arc::new(Mutex::new(ProgressBar::new(0).with_style(STYLE_NO.clone()))); 31 | let mut tasks = JoinSet::new(); 32 | let mut done_mods = Vec::new(); 33 | let (mod_sender, mod_rcvr) = mpsc::channel(); 34 | 35 | // Wrap it again in an Arc so that I can count the references to it, 36 | // because I cannot drop the main thread's sender due to the recursion 37 | let mod_sender = Arc::new(mod_sender); 38 | 39 | println!("{}\n", "Determining the Latest Compatible Versions".bold()); 40 | progress_bar 41 | .lock() 42 | .enable_steady_tick(Duration::from_millis(100)); 43 | let pad_len = profile 44 | .mods 45 | .iter() 46 | .map(|m| m.name.len()) 47 | .max() 48 | .unwrap_or(20) 49 | .clamp(20, 50); 50 | 51 | for mod_ in profile.mods.clone() { 52 | mod_sender.send(mod_)?; 53 | } 54 | 55 | let mut initial = true; 56 | 57 | // A race condition exists where if the last task drops its sender before this thread receives the message, 58 | // that particular message will get ignored. I used the ostrich algorithm to solve this. 59 | 60 | // `initial` accounts for the edge case where at first, 61 | // no tasks have been spawned yet but there are messages in the channel 62 | while Arc::strong_count(&mod_sender) > 1 || initial { 63 | if let Ok(mod_) = mod_rcvr.try_recv() { 64 | initial = false; 65 | 66 | if done_mods.contains(&mod_.identifier) { 67 | continue; 68 | } 69 | 70 | done_mods.push(mod_.identifier.clone()); 71 | progress_bar.lock().inc_length(1); 72 | 73 | let filters = profile.filters.clone(); 74 | let dep_sender = Arc::clone(&mod_sender); 75 | let progress_bar = Arc::clone(&progress_bar); 76 | 77 | tasks.spawn(async move { 78 | let permit = SEMAPHORE.get_or_init(default_semaphore).acquire().await?; 79 | 80 | let result = mod_.fetch_download_file(filters).await; 81 | 82 | drop(permit); 83 | 84 | progress_bar.lock().inc(1); 85 | match result { 86 | Ok(mut download_file) => { 87 | progress_bar.lock().println(format!( 88 | "{} {:pad_len$} {}", 89 | TICK.clone(), 90 | mod_.name, 91 | download_file.filename().dimmed() 92 | )); 93 | for dep in take(&mut download_file.dependencies) { 94 | dep_sender.send(Mod::new( 95 | format!( 96 | "Dependency: {}", 97 | match &dep { 98 | ModIdentifier::CurseForgeProject(id) => id.to_string(), 99 | ModIdentifier::ModrinthProject(id) 100 | | ModIdentifier::PinnedModrinthProject(id, _) => 101 | id.to_owned(), 102 | _ => unreachable!(), 103 | } 104 | ), 105 | match dep { 106 | ModIdentifier::PinnedModrinthProject(id, _) => { 107 | ModIdentifier::ModrinthProject(id) 108 | } 109 | _ => dep, 110 | }, 111 | vec![], 112 | false, 113 | ))?; 114 | } 115 | Ok(Some(download_file)) 116 | } 117 | Err(err) => { 118 | if let mod_downloadable::Error::ModrinthError( 119 | ferinth::Error::RateLimitExceeded(_), 120 | ) = err 121 | { 122 | // Immediately fail if the rate limit has been exceeded 123 | progress_bar.lock().finish_and_clear(); 124 | bail!(err); 125 | } 126 | progress_bar.lock().println(format!( 127 | "{}", 128 | format!("{CROSS} {:pad_len$} {err}", mod_.name).red() 129 | )); 130 | Ok(None) 131 | } 132 | } 133 | }); 134 | } 135 | } 136 | 137 | Arc::try_unwrap(progress_bar) 138 | .map_err(|_| anyhow!("Failed to run threads to completion"))? 139 | .into_inner() 140 | .finish_and_clear(); 141 | 142 | let tasks = tasks 143 | .join_all() 144 | .await 145 | .into_iter() 146 | .collect::>>()?; 147 | 148 | let error = tasks.iter().any(Option::is_none); 149 | let to_download = tasks.into_iter().flatten().collect(); 150 | 151 | Ok((to_download, error)) 152 | } 153 | 154 | pub async fn upgrade(profile: &Profile) -> Result<()> { 155 | let (mut to_download, error) = get_platform_downloadables(profile).await?; 156 | let mut to_install = Vec::new(); 157 | if profile.output_dir.join("user").exists() 158 | && profile.filters.mod_loader() != Some(&ModLoader::Quilt) 159 | { 160 | for file in read_dir(profile.output_dir.join("user"))? { 161 | let file = file?; 162 | let path = file.path(); 163 | if path.is_file() 164 | && path 165 | .extension() 166 | .is_some_and(|ext| ext.eq_ignore_ascii_case("jar")) 167 | { 168 | to_install.push((file.file_name(), path)); 169 | } 170 | } 171 | } 172 | 173 | clean(&profile.output_dir, &mut to_download, &mut to_install).await?; 174 | to_download 175 | .iter_mut() 176 | // Download directly to the output directory 177 | .map(|thing| thing.output = thing.filename().into()) 178 | .for_each(drop); // Doesn't drop any data, just runs the iterator 179 | if to_download.is_empty() && to_install.is_empty() { 180 | println!("\n{}", "All up to date!".bold()); 181 | } else { 182 | println!("\n{}\n", "Downloading Mod Files".bold()); 183 | download(profile.output_dir.clone(), to_download, to_install).await?; 184 | } 185 | 186 | if error { 187 | Err(anyhow!( 188 | "\nCould not get the latest compatible version of some mods" 189 | )) 190 | } else { 191 | Ok(()) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | #![expect(clippy::unwrap_used)] 2 | 3 | use crate::{ 4 | actual_main, 5 | cli::{Ferium, FilterArguments, ModpackSubCommands, Platform, ProfileSubCommands, SubCommands}, 6 | }; 7 | use libium::config::structs::ModLoader; 8 | use std::{ 9 | assert_matches::assert_matches, 10 | env::current_dir, 11 | fs::{copy, create_dir_all}, 12 | path::PathBuf, 13 | }; 14 | 15 | const DEFAULT: Ferium = Ferium { 16 | subcommand: SubCommands::Profile { subcommand: None }, 17 | threads: None, 18 | parallel_tasks: 10, 19 | github_token: None, 20 | curseforge_api_key: None, 21 | config_file: None, 22 | }; 23 | 24 | fn get_args(subcommand: SubCommands, config_file: Option<&str>) -> Ferium { 25 | let running = PathBuf::from(".") 26 | .join("tests") 27 | .join("configs") 28 | .join("running") 29 | .join(format!("{:X}.json", rand::random::())); 30 | let _ = create_dir_all(running.parent().unwrap()); 31 | if let Some(config_file) = config_file { 32 | copy(format!("./tests/configs/{config_file}.json"), &running).unwrap(); 33 | } 34 | Ferium { 35 | subcommand, 36 | config_file: Some(running), 37 | ..DEFAULT 38 | } 39 | } 40 | 41 | // TODO 42 | // #[tokio::test(flavor = "multi_thread")] 43 | // async fn arg_parse() {} 44 | 45 | #[tokio::test(flavor = "multi_thread")] 46 | async fn create_profile_no_profiles_to_import() { 47 | assert_matches!( 48 | actual_main(get_args( 49 | SubCommands::Profile { 50 | subcommand: Some(ProfileSubCommands::Create { 51 | // There should be no other profiles to import mods from 52 | import: Some(None), 53 | game_version: vec!["1.21.4".to_owned()], 54 | mod_loader: Some(ModLoader::Fabric), 55 | name: Some("Test Profile".to_owned()), 56 | output_dir: Some(current_dir().unwrap().join("tests").join("mods")), 57 | }) 58 | }, 59 | None, 60 | )) 61 | .await, 62 | Err(_), 63 | ); 64 | } 65 | 66 | #[tokio::test(flavor = "multi_thread")] 67 | async fn create_profile_rel_dir() { 68 | assert_matches!( 69 | actual_main(get_args( 70 | SubCommands::Profile { 71 | subcommand: Some(ProfileSubCommands::Create { 72 | // There should be no other profiles to import mods from 73 | import: Some(None), 74 | game_version: vec!["1.21.4".to_owned()], 75 | mod_loader: Some(ModLoader::Fabric), 76 | name: Some("Test Profile".to_owned()), 77 | output_dir: Some(PathBuf::from(".").join("tests").join("mods")), 78 | }) 79 | }, 80 | None, 81 | )) 82 | .await, 83 | Err(_), 84 | ); 85 | } 86 | 87 | #[tokio::test(flavor = "multi_thread")] 88 | async fn create_profile_import_mods() { 89 | assert_matches!( 90 | actual_main(get_args( 91 | SubCommands::Profile { 92 | subcommand: Some(ProfileSubCommands::Create { 93 | // There should be no other profiles to import mods from 94 | import: Some(Some("Default Modded".to_owned())), 95 | game_version: vec!["1.21.4".to_owned()], 96 | mod_loader: Some(ModLoader::Fabric), 97 | name: Some("Test Profile".to_owned()), 98 | output_dir: Some(current_dir().unwrap().join("tests").join("mods")), 99 | }) 100 | }, 101 | Some("one_profile_full"), 102 | )) 103 | .await, 104 | Ok(()), 105 | ); 106 | } 107 | 108 | #[tokio::test(flavor = "multi_thread")] 109 | async fn create_profile_existing_name() { 110 | assert_matches!( 111 | actual_main(get_args( 112 | SubCommands::Profile { 113 | subcommand: Some(ProfileSubCommands::Create { 114 | import: None, 115 | game_version: vec!["1.21.4".to_owned()], 116 | mod_loader: Some(ModLoader::Fabric), 117 | name: Some("Default Modded".to_owned()), 118 | output_dir: Some(current_dir().unwrap().join("tests").join("mods")) 119 | }) 120 | }, 121 | None, 122 | )) 123 | .await, 124 | Ok(()), 125 | ); 126 | } 127 | 128 | #[tokio::test(flavor = "multi_thread")] 129 | async fn create_profile() { 130 | assert_matches!( 131 | actual_main(get_args( 132 | SubCommands::Profile { 133 | subcommand: Some(ProfileSubCommands::Create { 134 | import: None, 135 | game_version: vec!["1.21.4".to_owned()], 136 | mod_loader: Some(ModLoader::Fabric), 137 | name: Some("Test Profile".to_owned()), 138 | output_dir: Some(current_dir().unwrap().join("tests").join("mods")) 139 | }) 140 | }, 141 | None, 142 | )) 143 | .await, 144 | Ok(()), 145 | ); 146 | } 147 | 148 | #[tokio::test(flavor = "multi_thread")] 149 | async fn add_modrinth() { 150 | assert_matches!( 151 | actual_main(get_args( 152 | SubCommands::Add { 153 | identifiers: vec!["starlight".to_owned()], 154 | force: false, 155 | pin: None, 156 | filters: FilterArguments::default(), 157 | }, 158 | Some("empty_profile"), 159 | )) 160 | .await, 161 | Ok(()), 162 | ); 163 | } 164 | 165 | #[tokio::test(flavor = "multi_thread")] 166 | async fn add_curseforge() { 167 | assert_matches!( 168 | actual_main(get_args( 169 | SubCommands::Add { 170 | identifiers: vec!["591388".to_owned()], 171 | force: false, 172 | pin: None, 173 | filters: FilterArguments::default(), 174 | }, 175 | Some("empty_profile"), 176 | )) 177 | .await, 178 | Ok(()), 179 | ); 180 | } 181 | 182 | #[tokio::test(flavor = "multi_thread")] 183 | async fn add_github() { 184 | assert_matches!( 185 | actual_main(get_args( 186 | SubCommands::Add { 187 | identifiers: vec!["CaffeineMC/sodium".to_owned()], 188 | force: false, 189 | pin: None, 190 | filters: FilterArguments::default(), 191 | }, 192 | Some("empty_profile"), 193 | )) 194 | .await, 195 | Ok(()), 196 | ); 197 | } 198 | 199 | #[tokio::test(flavor = "multi_thread")] 200 | async fn add_all() { 201 | assert_matches!( 202 | actual_main(get_args( 203 | SubCommands::Add { 204 | identifiers: vec![ 205 | "starlight".to_owned(), 206 | "591388".to_owned(), 207 | "CaffeineMC/sodium".to_owned() 208 | ], 209 | force: false, 210 | pin: None, 211 | filters: FilterArguments::default(), 212 | }, 213 | Some("empty_profile"), 214 | )) 215 | .await, 216 | Ok(()), 217 | ); 218 | } 219 | 220 | #[tokio::test(flavor = "multi_thread")] 221 | async fn already_added() { 222 | assert_matches!( 223 | actual_main(get_args( 224 | SubCommands::Add { 225 | identifiers: vec![ 226 | "starlight".to_owned(), 227 | "591388".to_owned(), 228 | "CaffeineMC/sodium".to_owned() 229 | ], 230 | force: false, 231 | pin: None, 232 | filters: FilterArguments::default(), 233 | }, 234 | Some("one_profile_full"), 235 | )) 236 | .await, 237 | Ok(()), 238 | ); 239 | } 240 | 241 | #[tokio::test(flavor = "multi_thread")] 242 | async fn scan() { 243 | assert_matches!( 244 | actual_main(get_args( 245 | SubCommands::Scan { 246 | platform: Platform::default(), 247 | directory: Some(current_dir().unwrap().join("tests").join("test_mods")), 248 | force: false, 249 | }, 250 | Some("empty_profile"), 251 | )) 252 | .await, 253 | Ok(()), 254 | ); 255 | } 256 | 257 | #[tokio::test(flavor = "multi_thread")] 258 | async fn modpack_add_modrinth() { 259 | assert_matches!( 260 | actual_main(get_args( 261 | SubCommands::Modpack { 262 | subcommand: Some(ModpackSubCommands::Add { 263 | identifier: "1KVo5zza".to_owned(), 264 | output_dir: Some(current_dir().unwrap().join("tests").join("mods")), 265 | install_overrides: Some(true), 266 | }) 267 | }, 268 | Some("empty") 269 | )) 270 | .await, 271 | Ok(()), 272 | ); 273 | } 274 | 275 | #[tokio::test(flavor = "multi_thread")] 276 | async fn modpack_add_curseforge() { 277 | assert_matches!( 278 | actual_main(get_args( 279 | SubCommands::Modpack { 280 | subcommand: Some(ModpackSubCommands::Add { 281 | identifier: "452013".to_owned(), 282 | output_dir: Some(current_dir().unwrap().join("tests").join("mods")), 283 | install_overrides: Some(true), 284 | }) 285 | }, 286 | Some("empty") 287 | )) 288 | .await, 289 | Ok(()), 290 | ); 291 | } 292 | 293 | #[tokio::test(flavor = "multi_thread")] 294 | async fn list_no_profile() { 295 | assert_matches!( 296 | actual_main(get_args( 297 | SubCommands::List { 298 | verbose: false, 299 | markdown: false 300 | }, 301 | Some("empty"), 302 | )) 303 | .await, 304 | Err(_), 305 | ); 306 | } 307 | 308 | #[tokio::test(flavor = "multi_thread")] 309 | async fn list_empty_profile() { 310 | assert_matches!( 311 | actual_main(get_args( 312 | SubCommands::List { 313 | verbose: false, 314 | markdown: false 315 | }, 316 | Some("empty_profile"), 317 | )) 318 | .await, 319 | Err(_), 320 | ); 321 | } 322 | 323 | #[tokio::test(flavor = "multi_thread")] 324 | async fn list() { 325 | assert_matches!( 326 | actual_main(get_args( 327 | SubCommands::List { 328 | verbose: false, 329 | markdown: false 330 | }, 331 | Some("one_profile_full"), 332 | )) 333 | .await, 334 | Ok(()), 335 | ); 336 | } 337 | 338 | #[tokio::test(flavor = "multi_thread")] 339 | async fn list_verbose() { 340 | assert_matches!( 341 | actual_main(get_args( 342 | SubCommands::List { 343 | verbose: true, 344 | markdown: false 345 | }, 346 | Some("one_profile_full"), 347 | )) 348 | .await, 349 | Ok(()), 350 | ); 351 | } 352 | 353 | #[tokio::test(flavor = "multi_thread")] 354 | async fn list_markdown() { 355 | assert_matches!( 356 | actual_main(get_args( 357 | SubCommands::List { 358 | verbose: true, 359 | markdown: true 360 | }, 361 | Some("one_profile_full"), 362 | )) 363 | .await, 364 | Ok(()), 365 | ); 366 | } 367 | 368 | #[tokio::test(flavor = "multi_thread")] 369 | async fn list_profiles() { 370 | assert_matches!( 371 | actual_main(get_args( 372 | SubCommands::Profiles, 373 | Some("two_profiles_one_empty"), 374 | )) 375 | .await, 376 | Ok(()), 377 | ); 378 | } 379 | 380 | #[tokio::test(flavor = "multi_thread")] 381 | async fn list_modpacks() { 382 | assert_matches!( 383 | actual_main(get_args( 384 | SubCommands::Modpacks, 385 | Some("two_modpacks_mdactive"), 386 | )) 387 | .await, 388 | Ok(()), 389 | ); 390 | } 391 | 392 | #[tokio::test(flavor = "multi_thread")] 393 | async fn upgrade() { 394 | assert_matches!( 395 | actual_main(get_args(SubCommands::Upgrade, Some("one_profile_full"))).await, 396 | Ok(()), 397 | ); 398 | } 399 | 400 | #[tokio::test(flavor = "multi_thread")] 401 | async fn upgrade_md_modpacks() { 402 | assert_matches!( 403 | actual_main(get_args( 404 | SubCommands::Modpack { 405 | subcommand: Some(ModpackSubCommands::Upgrade) 406 | }, 407 | Some("two_modpacks_mdactive") 408 | )) 409 | .await, 410 | Ok(()), 411 | ); 412 | } 413 | 414 | #[tokio::test(flavor = "multi_thread")] 415 | async fn upgrade_cf_modpack() { 416 | assert_matches!( 417 | actual_main(get_args( 418 | SubCommands::Modpack { 419 | subcommand: Some(ModpackSubCommands::Upgrade) 420 | }, 421 | Some("two_modpacks_cfactive") 422 | )) 423 | .await, 424 | Ok(()), 425 | ); 426 | } 427 | 428 | #[tokio::test(flavor = "multi_thread")] 429 | async fn profile_switch() { 430 | assert_matches!( 431 | actual_main(get_args( 432 | SubCommands::Profile { 433 | subcommand: Some(ProfileSubCommands::Switch { 434 | profile_name: Some("Profile Two".to_owned()) 435 | }) 436 | }, 437 | Some("two_profiles_one_empty") 438 | )) 439 | .await, 440 | Ok(()), 441 | ); 442 | } 443 | 444 | #[tokio::test(flavor = "multi_thread")] 445 | async fn modpack_switch() { 446 | assert_matches!( 447 | actual_main(get_args( 448 | SubCommands::Modpack { 449 | subcommand: Some(ModpackSubCommands::Switch { 450 | modpack_name: Some("MR Fabulously Optimised".to_owned()) 451 | }) 452 | }, 453 | Some("two_modpacks_cfactive") 454 | )) 455 | .await, 456 | Ok(()), 457 | ); 458 | } 459 | 460 | #[tokio::test(flavor = "multi_thread")] 461 | async fn remove_fail() { 462 | assert_matches!( 463 | actual_main(get_args( 464 | SubCommands::Remove { 465 | mod_names: vec![ 466 | "starlght (fabric)".to_owned(), 467 | "incendum".to_owned(), 468 | "sodum".to_owned(), 469 | ] 470 | }, 471 | Some("two_profiles_one_empty") 472 | )) 473 | .await, 474 | Err(_), 475 | ); 476 | } 477 | 478 | #[tokio::test(flavor = "multi_thread")] 479 | async fn remove_name() { 480 | assert_matches!( 481 | actual_main(get_args( 482 | SubCommands::Remove { 483 | mod_names: vec![ 484 | "starlight (fabric)".to_owned(), 485 | "incendium".to_owned(), 486 | "sodium".to_owned(), 487 | ] 488 | }, 489 | Some("two_profiles_one_empty") 490 | )) 491 | .await, 492 | Ok(()), 493 | ); 494 | } 495 | 496 | #[tokio::test(flavor = "multi_thread")] 497 | async fn remove_id() { 498 | assert_matches!( 499 | actual_main(get_args( 500 | SubCommands::Remove { 501 | mod_names: vec![ 502 | "H8CaAYZC".to_owned(), 503 | "591388".to_owned(), 504 | "caffeinemc/sodium".to_owned(), 505 | ] 506 | }, 507 | Some("two_profiles_one_empty") 508 | )) 509 | .await, 510 | Ok(()), 511 | ); 512 | } 513 | 514 | #[tokio::test(flavor = "multi_thread")] 515 | async fn remove_slug() { 516 | // Load the slugs into the config first 517 | let mut args = get_args( 518 | SubCommands::List { 519 | verbose: true, 520 | markdown: false, 521 | }, 522 | Some("two_profiles_one_empty"), 523 | ); 524 | assert_matches!(actual_main(args.clone()).await, Ok(())); 525 | 526 | args.subcommand = SubCommands::Remove { 527 | mod_names: vec![ 528 | "starlight".to_owned(), 529 | "incendium".to_owned(), 530 | "sodium".to_owned(), 531 | ], 532 | }; 533 | assert_matches!(actual_main(args).await, Ok(())); 534 | } 535 | 536 | #[tokio::test(flavor = "multi_thread")] 537 | async fn delete_profile() { 538 | assert_matches!( 539 | actual_main(get_args( 540 | SubCommands::Profile { 541 | subcommand: Some(ProfileSubCommands::Delete { 542 | profile_name: Some("Profile Two".to_owned()), 543 | switch_to: None 544 | }) 545 | }, 546 | Some("two_profiles_one_empty") 547 | )) 548 | .await, 549 | Ok(()), 550 | ); 551 | } 552 | 553 | #[tokio::test(flavor = "multi_thread")] 554 | async fn delete_modpack() { 555 | assert_matches!( 556 | actual_main(get_args( 557 | SubCommands::Modpack { 558 | subcommand: Some(ModpackSubCommands::Delete { 559 | modpack_name: Some("MR Fabulously Optimised".to_owned()), 560 | switch_to: None 561 | }) 562 | }, 563 | Some("two_modpacks_cfactive") 564 | )) 565 | .await, 566 | Ok(()), 567 | ); 568 | } 569 | -------------------------------------------------------------------------------- /tests/configs/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "active_profile": 0, 3 | "active_modpack": 0, 4 | "profiles": [], 5 | "modpacks": [] 6 | } -------------------------------------------------------------------------------- /tests/configs/empty_profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "active_profile": 0, 3 | "active_modpack": 0, 4 | "profiles": [ 5 | { 6 | "name": "Test profile", 7 | "output_dir": "./tests/mods", 8 | "game_version": "1.18.2", 9 | "mod_loader": "Fabric", 10 | "mods": [] 11 | } 12 | ], 13 | "modpacks": [] 14 | } -------------------------------------------------------------------------------- /tests/configs/one_profile_full.json: -------------------------------------------------------------------------------- 1 | { 2 | "active_profile": 0, 3 | "active_modpack": 0, 4 | "profiles": [ 5 | { 6 | "name": "Default Modded", 7 | "output_dir": "./tests/mods", 8 | "game_version": "1.18.2", 9 | "mod_loader": "Fabric", 10 | "mods": [ 11 | { 12 | "name": "Starlight (Fabric)", 13 | "identifier": { 14 | "ModrinthProject": "H8CaAYZC" 15 | } 16 | }, 17 | { 18 | "name": "Incendium", 19 | "identifier": { 20 | "CurseForgeProject": 591388 21 | } 22 | }, 23 | { 24 | "name": "sodium", 25 | "identifier": { 26 | "GitHubRepository": [ 27 | "CaffeineMC", 28 | "sodium" 29 | ] 30 | } 31 | } 32 | ] 33 | } 34 | ], 35 | "modpacks": [] 36 | } -------------------------------------------------------------------------------- /tests/configs/two_modpacks_cfactive.json: -------------------------------------------------------------------------------- 1 | { 2 | "active_profile": 0, 3 | "active_modpack": 0, 4 | "profiles": [], 5 | "modpacks": [ 6 | { 7 | "name": "CF Fabulously Optimised", 8 | "output_dir": "./tests/cf_modpack", 9 | "install_overrides": true, 10 | "identifier": { 11 | "CurseForgeModpack": 396246 12 | } 13 | }, 14 | { 15 | "name": "MR Fabulously Optimised", 16 | "output_dir": "./tests/md_modpack", 17 | "install_overrides": true, 18 | "identifier": { 19 | "ModrinthModpack": "1KVo5zza" 20 | } 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /tests/configs/two_modpacks_mdactive.json: -------------------------------------------------------------------------------- 1 | { 2 | "active_profile": 0, 3 | "active_modpack": 1, 4 | "profiles": [], 5 | "modpacks": [ 6 | { 7 | "name": "CF Fabulously Optimised", 8 | "output_dir": "./tests/cf_modpack", 9 | "install_overrides": true, 10 | "identifier": { 11 | "CurseForgeModpack": 396246 12 | } 13 | }, 14 | { 15 | "name": "MR Fabulously Optimised", 16 | "output_dir": "./tests/md_modpack", 17 | "install_overrides": true, 18 | "identifier": { 19 | "ModrinthModpack": "1KVo5zza" 20 | } 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /tests/configs/two_profiles_one_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "active_profile": 0, 3 | "active_modpack": 0, 4 | "profiles": [ 5 | { 6 | "name": "Profile One", 7 | "output_dir": "./tests/mods", 8 | "game_version": "1.18.2", 9 | "mod_loader": "Fabric", 10 | "mods": [ 11 | { 12 | "name": "Starlight (Fabric)", 13 | "identifier": { 14 | "ModrinthProject": "H8CaAYZC" 15 | } 16 | }, 17 | { 18 | "name": "Incendium", 19 | "identifier": { 20 | "CurseForgeProject": 591388 21 | } 22 | }, 23 | { 24 | "name": "sodium", 25 | "identifier": { 26 | "GitHubRepository": [ 27 | "CaffeineMC", 28 | "sodium" 29 | ] 30 | } 31 | } 32 | ] 33 | }, 34 | { 35 | "name": "Profile Two", 36 | "output_dir": "./tests/mods", 37 | "game_version": "1.18.2", 38 | "mod_loader": "Fabric", 39 | "mods": [] 40 | } 41 | ], 42 | "modpacks": [] 43 | } -------------------------------------------------------------------------------- /tests/test_mods/Incendium.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/tests/test_mods/Incendium.jar -------------------------------------------------------------------------------- /tests/test_mods/Sodium.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/tests/test_mods/Sodium.jar -------------------------------------------------------------------------------- /tests/test_mods/Starlight Duplicate.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/tests/test_mods/Starlight Duplicate.jar -------------------------------------------------------------------------------- /tests/test_mods/Starlight.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorilla-devs/ferium/9b9d085003d033b82261d159e05c9707e0f35dc8/tests/test_mods/Starlight.jar --------------------------------------------------------------------------------