├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug.yaml │ ├── 2-backend.yaml │ └── 3-feature.yaml └── workflows │ ├── check.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── RELEASE-CHECKLIST.md ├── _completion.zsh ├── cliff.toml ├── clippy.toml ├── crates └── pacdef │ ├── Cargo.toml │ ├── build.rs │ └── src │ ├── backend │ ├── actual │ │ ├── arch.rs │ │ ├── debian.rs │ │ ├── fedora.rs │ │ ├── flatpak.rs │ │ ├── mod.rs │ │ ├── python.rs │ │ ├── rust.rs │ │ ├── rustup │ │ │ ├── helpers.rs │ │ │ ├── mod.rs │ │ │ └── types.rs │ │ └── void.rs │ ├── backend_trait.rs │ ├── mod.rs │ ├── root.rs │ └── todo_per_backend.rs │ ├── cli.rs │ ├── cmd.rs │ ├── config.rs │ ├── core.rs │ ├── env.rs │ ├── errors.rs │ ├── grouping │ ├── group.rs │ ├── mod.rs │ ├── package.rs │ └── section.rs │ ├── lib.rs │ ├── main.rs │ ├── path.rs │ ├── prelude.rs │ ├── review │ ├── datastructures.rs │ ├── mod.rs │ └── strategy.rs │ ├── search.rs │ └── ui.rs └── man ├── pacdef.8 └── pacdef.toml.5 /.github/ISSUE_TEMPLATE/1-bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug", "triage"] 5 | body: 6 | - type: input 7 | id: what-intent 8 | attributes: 9 | label: In one sentence, what did you try to achieve? 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: what-expected 15 | attributes: 16 | label: What did you expect to happen? 17 | description: Tell us what should have happened. 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: what-happened 23 | attributes: 24 | label: What happened? 25 | description: Tell us what actually happened. 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: reproduce 31 | attributes: 32 | label: How can we reproduce this? 33 | description: Provide the exact steps to reproduce the issue. 34 | value: | 35 | 1. 36 | 2. 37 | 3. 38 | 4. 39 | 40 | - type: textarea 41 | id: version 42 | attributes: 43 | label: Version of pacdef 44 | description: Paste the complete output of `pacdef version`. 45 | render: shell 46 | validations: 47 | required: true 48 | 49 | - type: textarea 50 | id: config 51 | attributes: 52 | label: Pacdef config 53 | description: Paste the content of your pacdef config file, normally found under `~/.config/pacdef/pacdef.yaml`. 54 | render: yaml 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | id: os 60 | attributes: 61 | label: What operating system and version are you encountering this issue on? 62 | description: Most OS produce output for `lsb_release -a`. 63 | render: shell 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: logs 69 | attributes: 70 | label: Relevant log output 71 | description: | 72 | Run the pacdef command in question with the environment variable `RUST_BACKTRACE=full`. 73 | Please copy and paste any relevant log output. 74 | This will be automatically formatted into code, so no need for backticks. 75 | render: shell 76 | validations: 77 | required: true 78 | 79 | - type: textarea 80 | id: information 81 | attributes: 82 | label: Additional information 83 | description: Any additional information that may be relevant to this bug report. 84 | validations: 85 | required: false 86 | 87 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-backend.yaml: -------------------------------------------------------------------------------- 1 | name: Package manager request 2 | description: Request support for an additional package manager / backend 3 | title: "[Backend request]: " 4 | labels: ["backend request", "triage"] 5 | body: 6 | - type: input 7 | id: backend 8 | attributes: 9 | label: What's the name of the package manager pacdef should support? 10 | validations: 11 | required: true 12 | 13 | - type: input 14 | id: ecosystem 15 | attributes: 16 | label: What operating system or software ecosystem does it relate to? 17 | validations: 18 | required: true 19 | 20 | - type: checkboxes 21 | id: implement 22 | attributes: 23 | label: Would you be willing to implement this? We would support you through this process. 24 | description: Tick the box if so. 25 | options: 26 | - label: Yes, I would like to implement this. 27 | 28 | - type: textarea 29 | id: information 30 | attributes: 31 | label: Additional information 32 | description: Any additional information that may be relevant. 33 | validations: 34 | required: false 35 | 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Request an additional feature for pacdef 3 | title: "[Feature request]: " 4 | labels: ["enhancement", "triage"] 5 | body: 6 | - type: input 7 | id: intent 8 | attributes: 9 | label: In one sentence, what would the new feature allow the user to do? 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: problem 15 | attributes: 16 | label: Describe what problem this new feature would solve. 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: works 22 | attributes: 23 | label: How should this feature in your opinion work? 24 | validations: 25 | required: true 26 | 27 | - type: checkboxes 28 | id: implement 29 | attributes: 30 | label: Would you be willing to implement this? We would support you through this process. 31 | description: Tick the box if so. 32 | options: 33 | - label: Yes, I would like to implement this. 34 | 35 | - type: textarea 36 | id: information 37 | attributes: 38 | label: Additional information 39 | description: Any additional information that may be relevant. 40 | validations: 41 | required: false 42 | 43 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'main' 8 | - 'devel' 9 | paths: 10 | - 'crates/**.rs' 11 | - 'crates/**/Cargo.toml' 12 | - 'Cargo.*' 13 | - "clippy.toml" 14 | pull_request: 15 | branches: 16 | - 'main' 17 | - 'devel' 18 | paths: 19 | - 'crates/**.rs' 20 | - 'crates/**/Cargo.toml' 21 | - 'Cargo.*' 22 | - "clippy.toml" 23 | 24 | env: 25 | CARGO_TERM_COLOR: always 26 | 27 | jobs: 28 | test: 29 | runs-on: ubuntu-latest 30 | container: 31 | image: archlinux 32 | steps: 33 | - name: Install Packages 34 | run: pacman -Syu git rust clang gcc libarchive pkgconf apt --noconfirm --needed 35 | 36 | - name: Checkout 37 | uses: actions/checkout@v3 38 | 39 | - name: Format 40 | if: '!cancelled()' 41 | run: cargo fmt -- --check 42 | 43 | - name: Build 44 | if: '!cancelled()' 45 | run: cargo build --locked --features arch,debian 46 | 47 | - name: Clippy 48 | if: '!cancelled()' 49 | run: cargo clippy --features arch,debian -- -Dwarnings 50 | 51 | - name: Test 52 | if: '!cancelled()' 53 | run: cargo test --workspace --features arch,debian 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release: 9 | name: release 10 | runs-on: ubuntu-latest 11 | container: 12 | image: ghcr.io/archlinux/archlinux:base-devel 13 | strategy: 14 | matrix: 15 | features: [default, arch, debian] 16 | steps: 17 | - name: Install dependencies 18 | run: pacman -Syy --noconfirm rust apt git 19 | - uses: actions/checkout@master 20 | - name: Build 21 | run: cargo build --release --features ${{ matrix.features }} 22 | - name: Create temporary directory 23 | run: mkdir pacdef-${{ matrix.features }} 24 | - name: Copy artifacts to directory 25 | run: cp -R target/release/pacdef _completion.zsh man LICENSE README.md pacdef-${{ matrix.features }} 26 | - name: Create archive from directory 27 | run: tar czf pacdef-${{ matrix.features }}.tar.gz pacdef-${{ matrix.features }} 28 | - name: Upload to release 29 | uses: softprops/action-gh-release@v2 30 | with: 31 | files: pacdef-${{ matrix.features }}.tar.gz 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | flamegraph.svg 2 | perf.data 3 | perf.data.old 4 | **/target 5 | _pacdef 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## General Steps 4 | 5 | Thank you for considering to contribute to `pacdef`. The recommended workflow is 6 | this: 7 | 8 | 1. Open a github issue, mention that you would like to fix the issue in a PR. 9 | 2. Wait for approval. 10 | 3. Fork the repository and implement your fix / feature. 11 | 4. Make sure your code generates no warnings, and passes `rustfmt` and `clippy`. 12 | 5. Open the pull request. 13 | 14 | ## Rust-Analyzer Issues 15 | 16 | Rust Analyzer may not work unless both the `pacutils` and `apt` packages are 17 | installed. On Arch that is, this may vary on other distros. 18 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "alpm" 16 | version = "3.0.5" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "310ec5dc25b236ee96bebf975af2d2de85e61001a7c39a0a7436a414ff3f6490" 19 | dependencies = [ 20 | "alpm-sys", 21 | "bitflags", 22 | ] 23 | 24 | [[package]] 25 | name = "alpm-sys" 26 | version = "3.0.0" 27 | source = "registry+https://github.com/rust-lang/crates.io-index" 28 | checksum = "08a17e0cf15a06d4b86e30c606ee8808ad791300f3bd5e364c30360354b010bd" 29 | dependencies = [ 30 | "pkg-config", 31 | ] 32 | 33 | [[package]] 34 | name = "anstream" 35 | version = "0.6.13" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" 38 | dependencies = [ 39 | "anstyle", 40 | "anstyle-parse", 41 | "anstyle-query", 42 | "anstyle-wincon", 43 | "colorchoice", 44 | "utf8parse", 45 | ] 46 | 47 | [[package]] 48 | name = "anstyle" 49 | version = "1.0.6" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" 52 | 53 | [[package]] 54 | name = "anstyle-parse" 55 | version = "0.2.3" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" 58 | dependencies = [ 59 | "utf8parse", 60 | ] 61 | 62 | [[package]] 63 | name = "anstyle-query" 64 | version = "1.0.2" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" 67 | dependencies = [ 68 | "windows-sys 0.52.0", 69 | ] 70 | 71 | [[package]] 72 | name = "anstyle-wincon" 73 | version = "3.0.2" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 76 | dependencies = [ 77 | "anstyle", 78 | "windows-sys 0.52.0", 79 | ] 80 | 81 | [[package]] 82 | name = "anyhow" 83 | version = "1.0.82" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" 86 | 87 | [[package]] 88 | name = "bitflags" 89 | version = "2.5.0" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 92 | 93 | [[package]] 94 | name = "cc" 95 | version = "1.0.92" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "2678b2e3449475e95b0aa6f9b506a28e61b3dc8996592b983695e8ebb58a8b41" 98 | 99 | [[package]] 100 | name = "clap" 101 | version = "4.5.4" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" 104 | dependencies = [ 105 | "clap_builder", 106 | "clap_derive", 107 | ] 108 | 109 | [[package]] 110 | name = "clap_builder" 111 | version = "4.5.2" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" 114 | dependencies = [ 115 | "anstream", 116 | "anstyle", 117 | "clap_lex", 118 | "strsim", 119 | ] 120 | 121 | [[package]] 122 | name = "clap_derive" 123 | version = "4.5.4" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" 126 | dependencies = [ 127 | "heck", 128 | "proc-macro2", 129 | "quote", 130 | "syn", 131 | ] 132 | 133 | [[package]] 134 | name = "clap_lex" 135 | version = "0.7.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" 138 | 139 | [[package]] 140 | name = "codespan-reporting" 141 | version = "0.11.1" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" 144 | dependencies = [ 145 | "termcolor", 146 | "unicode-width", 147 | ] 148 | 149 | [[package]] 150 | name = "colorchoice" 151 | version = "1.0.0" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 154 | 155 | [[package]] 156 | name = "const_format" 157 | version = "0.2.32" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "e3a214c7af3d04997541b18d432afaff4c455e79e2029079647e72fc2bd27673" 160 | dependencies = [ 161 | "const_format_proc_macros", 162 | ] 163 | 164 | [[package]] 165 | name = "const_format_proc_macros" 166 | version = "0.2.32" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" 169 | dependencies = [ 170 | "proc-macro2", 171 | "quote", 172 | "unicode-xid", 173 | ] 174 | 175 | [[package]] 176 | name = "cxx" 177 | version = "1.0.121" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "21db378d04296a84d8b7d047c36bb3954f0b46529db725d7e62fb02f9ba53ccc" 180 | dependencies = [ 181 | "cc", 182 | "cxxbridge-flags", 183 | "cxxbridge-macro", 184 | "link-cplusplus", 185 | ] 186 | 187 | [[package]] 188 | name = "cxx-build" 189 | version = "1.0.121" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "3e5262a7fa3f0bae2a55b767c223ba98032d7c328f5c13fa5cdc980b77fc0658" 192 | dependencies = [ 193 | "cc", 194 | "codespan-reporting", 195 | "once_cell", 196 | "proc-macro2", 197 | "quote", 198 | "scratch", 199 | "syn", 200 | ] 201 | 202 | [[package]] 203 | name = "cxxbridge-flags" 204 | version = "1.0.121" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "be8dcadd2e2fb4a501e1d9e93d6e88e6ea494306d8272069c92d5a9edf8855c0" 207 | 208 | [[package]] 209 | name = "cxxbridge-macro" 210 | version = "1.0.121" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "ad08a837629ad949b73d032c637653d069e909cffe4ee7870b02301939ce39cc" 213 | dependencies = [ 214 | "proc-macro2", 215 | "quote", 216 | "syn", 217 | ] 218 | 219 | [[package]] 220 | name = "enum_dispatch" 221 | version = "0.3.13" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" 224 | dependencies = [ 225 | "once_cell", 226 | "proc-macro2", 227 | "quote", 228 | "syn", 229 | ] 230 | 231 | [[package]] 232 | name = "errno" 233 | version = "0.3.8" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" 236 | dependencies = [ 237 | "libc", 238 | "windows-sys 0.52.0", 239 | ] 240 | 241 | [[package]] 242 | name = "heck" 243 | version = "0.5.0" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 246 | 247 | [[package]] 248 | name = "itoa" 249 | version = "1.0.11" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 252 | 253 | [[package]] 254 | name = "libc" 255 | version = "0.2.153" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" 258 | 259 | [[package]] 260 | name = "link-cplusplus" 261 | version = "1.0.9" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "9d240c6f7e1ba3a28b0249f774e6a9dd0175054b52dfbb61b16eb8505c3785c9" 264 | dependencies = [ 265 | "cc", 266 | ] 267 | 268 | [[package]] 269 | name = "linux-raw-sys" 270 | version = "0.4.13" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" 273 | 274 | [[package]] 275 | name = "log" 276 | version = "0.4.21" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" 279 | 280 | [[package]] 281 | name = "memchr" 282 | version = "2.7.2" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" 285 | 286 | [[package]] 287 | name = "once_cell" 288 | version = "1.19.0" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 291 | 292 | [[package]] 293 | name = "pacdef" 294 | version = "1.6.0" 295 | dependencies = [ 296 | "alpm", 297 | "anyhow", 298 | "clap", 299 | "const_format", 300 | "enum_dispatch", 301 | "libc", 302 | "log", 303 | "path-absolutize", 304 | "regex", 305 | "rust-apt", 306 | "serde", 307 | "serde_json", 308 | "termios", 309 | "toml", 310 | "walkdir", 311 | ] 312 | 313 | [[package]] 314 | name = "path-absolutize" 315 | version = "3.1.1" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" 318 | dependencies = [ 319 | "path-dedot", 320 | ] 321 | 322 | [[package]] 323 | name = "path-dedot" 324 | version = "3.1.1" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" 327 | dependencies = [ 328 | "once_cell", 329 | ] 330 | 331 | [[package]] 332 | name = "pkg-config" 333 | version = "0.3.30" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" 336 | 337 | [[package]] 338 | name = "proc-macro2" 339 | version = "1.0.79" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" 342 | dependencies = [ 343 | "unicode-ident", 344 | ] 345 | 346 | [[package]] 347 | name = "quote" 348 | version = "1.0.36" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 351 | dependencies = [ 352 | "proc-macro2", 353 | ] 354 | 355 | [[package]] 356 | name = "regex" 357 | version = "1.10.4" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" 360 | dependencies = [ 361 | "aho-corasick", 362 | "memchr", 363 | "regex-automata", 364 | "regex-syntax", 365 | ] 366 | 367 | [[package]] 368 | name = "regex-automata" 369 | version = "0.4.6" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" 372 | dependencies = [ 373 | "aho-corasick", 374 | "memchr", 375 | "regex-syntax", 376 | ] 377 | 378 | [[package]] 379 | name = "regex-syntax" 380 | version = "0.8.3" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" 383 | 384 | [[package]] 385 | name = "rust-apt" 386 | version = "0.7.0" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "0e9557eab06d169f090b13776868518119738af5cd345dace292e8a113e7ea7d" 389 | dependencies = [ 390 | "cxx", 391 | "cxx-build", 392 | "terminal_size", 393 | ] 394 | 395 | [[package]] 396 | name = "rustix" 397 | version = "0.38.32" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" 400 | dependencies = [ 401 | "bitflags", 402 | "errno", 403 | "libc", 404 | "linux-raw-sys", 405 | "windows-sys 0.52.0", 406 | ] 407 | 408 | [[package]] 409 | name = "ryu" 410 | version = "1.0.17" 411 | source = "registry+https://github.com/rust-lang/crates.io-index" 412 | checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" 413 | 414 | [[package]] 415 | name = "same-file" 416 | version = "1.0.6" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 419 | dependencies = [ 420 | "winapi-util", 421 | ] 422 | 423 | [[package]] 424 | name = "scratch" 425 | version = "1.0.7" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" 428 | 429 | [[package]] 430 | name = "serde" 431 | version = "1.0.197" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" 434 | dependencies = [ 435 | "serde_derive", 436 | ] 437 | 438 | [[package]] 439 | name = "serde_derive" 440 | version = "1.0.197" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" 443 | dependencies = [ 444 | "proc-macro2", 445 | "quote", 446 | "syn", 447 | ] 448 | 449 | [[package]] 450 | name = "serde_json" 451 | version = "1.0.115" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" 454 | dependencies = [ 455 | "itoa", 456 | "ryu", 457 | "serde", 458 | ] 459 | 460 | [[package]] 461 | name = "strsim" 462 | version = "0.11.1" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 465 | 466 | [[package]] 467 | name = "syn" 468 | version = "2.0.58" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" 471 | dependencies = [ 472 | "proc-macro2", 473 | "quote", 474 | "unicode-ident", 475 | ] 476 | 477 | [[package]] 478 | name = "termcolor" 479 | version = "1.4.1" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 482 | dependencies = [ 483 | "winapi-util", 484 | ] 485 | 486 | [[package]] 487 | name = "terminal_size" 488 | version = "0.3.0" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" 491 | dependencies = [ 492 | "rustix", 493 | "windows-sys 0.48.0", 494 | ] 495 | 496 | [[package]] 497 | name = "termios" 498 | version = "0.3.3" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" 501 | dependencies = [ 502 | "libc", 503 | ] 504 | 505 | [[package]] 506 | name = "toml" 507 | version = "0.4.10" 508 | source = "registry+https://github.com/rust-lang/crates.io-index" 509 | checksum = "758664fc71a3a69038656bee8b6be6477d2a6c315a6b81f7081f591bffa4111f" 510 | dependencies = [ 511 | "serde", 512 | ] 513 | 514 | [[package]] 515 | name = "unicode-ident" 516 | version = "1.0.12" 517 | source = "registry+https://github.com/rust-lang/crates.io-index" 518 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 519 | 520 | [[package]] 521 | name = "unicode-width" 522 | version = "0.1.11" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" 525 | 526 | [[package]] 527 | name = "unicode-xid" 528 | version = "0.2.4" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" 531 | 532 | [[package]] 533 | name = "utf8parse" 534 | version = "0.2.1" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 537 | 538 | [[package]] 539 | name = "walkdir" 540 | version = "2.5.0" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 543 | dependencies = [ 544 | "same-file", 545 | "winapi-util", 546 | ] 547 | 548 | [[package]] 549 | name = "winapi" 550 | version = "0.3.9" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 553 | dependencies = [ 554 | "winapi-i686-pc-windows-gnu", 555 | "winapi-x86_64-pc-windows-gnu", 556 | ] 557 | 558 | [[package]] 559 | name = "winapi-i686-pc-windows-gnu" 560 | version = "0.4.0" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 563 | 564 | [[package]] 565 | name = "winapi-util" 566 | version = "0.1.6" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" 569 | dependencies = [ 570 | "winapi", 571 | ] 572 | 573 | [[package]] 574 | name = "winapi-x86_64-pc-windows-gnu" 575 | version = "0.4.0" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 578 | 579 | [[package]] 580 | name = "windows-sys" 581 | version = "0.48.0" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 584 | dependencies = [ 585 | "windows-targets 0.48.5", 586 | ] 587 | 588 | [[package]] 589 | name = "windows-sys" 590 | version = "0.52.0" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 593 | dependencies = [ 594 | "windows-targets 0.52.5", 595 | ] 596 | 597 | [[package]] 598 | name = "windows-targets" 599 | version = "0.48.5" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 602 | dependencies = [ 603 | "windows_aarch64_gnullvm 0.48.5", 604 | "windows_aarch64_msvc 0.48.5", 605 | "windows_i686_gnu 0.48.5", 606 | "windows_i686_msvc 0.48.5", 607 | "windows_x86_64_gnu 0.48.5", 608 | "windows_x86_64_gnullvm 0.48.5", 609 | "windows_x86_64_msvc 0.48.5", 610 | ] 611 | 612 | [[package]] 613 | name = "windows-targets" 614 | version = "0.52.5" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" 617 | dependencies = [ 618 | "windows_aarch64_gnullvm 0.52.5", 619 | "windows_aarch64_msvc 0.52.5", 620 | "windows_i686_gnu 0.52.5", 621 | "windows_i686_gnullvm", 622 | "windows_i686_msvc 0.52.5", 623 | "windows_x86_64_gnu 0.52.5", 624 | "windows_x86_64_gnullvm 0.52.5", 625 | "windows_x86_64_msvc 0.52.5", 626 | ] 627 | 628 | [[package]] 629 | name = "windows_aarch64_gnullvm" 630 | version = "0.48.5" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 633 | 634 | [[package]] 635 | name = "windows_aarch64_gnullvm" 636 | version = "0.52.5" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" 639 | 640 | [[package]] 641 | name = "windows_aarch64_msvc" 642 | version = "0.48.5" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 645 | 646 | [[package]] 647 | name = "windows_aarch64_msvc" 648 | version = "0.52.5" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" 651 | 652 | [[package]] 653 | name = "windows_i686_gnu" 654 | version = "0.48.5" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 657 | 658 | [[package]] 659 | name = "windows_i686_gnu" 660 | version = "0.52.5" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" 663 | 664 | [[package]] 665 | name = "windows_i686_gnullvm" 666 | version = "0.52.5" 667 | source = "registry+https://github.com/rust-lang/crates.io-index" 668 | checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" 669 | 670 | [[package]] 671 | name = "windows_i686_msvc" 672 | version = "0.48.5" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 675 | 676 | [[package]] 677 | name = "windows_i686_msvc" 678 | version = "0.52.5" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" 681 | 682 | [[package]] 683 | name = "windows_x86_64_gnu" 684 | version = "0.48.5" 685 | source = "registry+https://github.com/rust-lang/crates.io-index" 686 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 687 | 688 | [[package]] 689 | name = "windows_x86_64_gnu" 690 | version = "0.52.5" 691 | source = "registry+https://github.com/rust-lang/crates.io-index" 692 | checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" 693 | 694 | [[package]] 695 | name = "windows_x86_64_gnullvm" 696 | version = "0.48.5" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 699 | 700 | [[package]] 701 | name = "windows_x86_64_gnullvm" 702 | version = "0.52.5" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" 705 | 706 | [[package]] 707 | name = "windows_x86_64_msvc" 708 | version = "0.48.5" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 711 | 712 | [[package]] 713 | name = "windows_x86_64_msvc" 714 | version = "0.52.5" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" 717 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | edition = "2021" 7 | license = "GPL-3.0-or-later" 8 | repository = "https://github.com/steven-omaha/pacdef" 9 | readme = "README.md" 10 | keywords = ["package-manager", "linux", "declarative", "cli"] 11 | categories = ["command-line-utilities"] 12 | rust-version = "1.74" 13 | 14 | [workspace.dependencies] 15 | pacdef = { path = "crates/pacdef" } 16 | 17 | [profile.release] 18 | lto = "off" 19 | opt-level = "z" 20 | strip = true 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![check](https://github.com/steven-omaha/pacdef/actions/workflows/check.yml/badge.svg)](https://github.com/steven-omaha/pacdef/actions/workflows/check.yml) 2 | 3 | # pacdef 4 | 5 | multi-backend declarative package manager for Linux 6 | 7 | 8 | ## Installation 9 | 10 | ### Arch Linux 11 | `pacdef` is available in the AUR [as stable release](https://aur.archlinux.org/packages/pacdef) or [development version](https://aur.archlinux.org/packages/pacdef-git) or [binary version](https://aur.archlinux.org/packages/pacdef-bin). 12 | 13 | ### binaries for other Linux versions 14 | For every release since 1.5.0 you can download binaries for some other Linux versions. 15 | Check out the assets for the [latest release](https://github.com/steven-omaha/pacdef/releases). 16 | 17 | ### from source 18 | 19 | Install it from [crates.io](https://crates.io/crates/pacdef) using this command. 20 | ```bash 21 | $ cargo install [-F [,...]] pacdef 22 | ``` 23 | 24 | See below ("[supported backends](#supported-backends)") for the feature flags you will need for your distribution. 25 | 26 | ### shell completion 27 | 28 | For Arch Linux, zsh completion will work automatically when you install pacdef from the AUR packages. 29 | For other distributions you must copy the `_completion.zsh` file to the right folder manually and rename it to `_pacdef`. 30 | 31 | ## Use-case 32 | 33 | `pacdef` allows the user to have consistent packages among multiple Linux machines and different backends by managing packages in group files. 34 | The idea is that (1) any package in the group files ("managed packages") will be installed explicitly, and (2) explicitly installed packages *not* found in any of the group files ("unmanaged packages") will be removed. 35 | The group files are maintained outside of `pacdef` by any VCS, like git. 36 | 37 | If you work with multiple Linux machines and have asked yourself "*Why do I have the program that I use every day on my other machine not installed here?*", then `pacdef` is the tool for you. 38 | 39 | 40 | ### Of groups, sections, and packages 41 | 42 | `pacdef` manages multiple package groups (group files) that, e.g., may be tied to a specific use-case. 43 | Each group has one or more section(s) which correspond to a specific backend, like your system's package manager (`pacman`, `apt`, ...), or your programming languages package manger (`cargo`, `pip`, ...). 44 | Each section contains one or more packages that can be installed respective package manager. 45 | 46 | This image illustrates the relationship. 47 | ``` 48 | 1 n 1 n 1 n 49 | pacdef ----> group ----> section ----> package 50 | ``` 51 | 52 | 53 | 54 | ### Example 55 | 56 | Let's assume you have the following group files. 57 | 58 | `base`: 59 | 60 | ```ini 61 | [arch] 62 | paru 63 | zsh 64 | 65 | [rust] 66 | pacdef 67 | topgrade 68 | ``` 69 | 70 | `development`: 71 | 72 | ```ini 73 | [arch] 74 | rustup 75 | rust-analyzer 76 | 77 | [rust] 78 | cargo-tree 79 | flamegraph 80 | ``` 81 | 82 | Pacdef will make sure you have the following packages installed for each package manager: 83 | 84 | - Arch (`pacman`, AUR helpers): paru, zsh, rustup, rust-analyzer 85 | - Rust (`cargo`): pacdef, topgrade, cargo-tree, flamegraph 86 | 87 | Note that the name of the section corresponds to the ecosystem it relates to, rather than the package manager it uses. 88 | 89 | ## Supported backends 90 | 91 | At the moment, supported backends are the following. 92 | Pull requests for additional backends are welcome! 93 | 94 | | Application | Package Manager | Section | feature flag | Notes | 95 | | ------------ | --------------- | ----------- | ------------ | ---------------------------------------------------------------------------------------- | 96 | | Arch Linux | `pacman` | `[arch]` | `arch` | includes pacman-wrapping AUR helpers (configurable) | 97 | | Debian | `apt` | `[debian]` | `debian` | minimum supported apt-version 2.0.2 ([see upstream](https://gitlab.com/volian/rust-apt)) | 98 | | Fedora Linux | `dnf` | `[fedora]` | built-in | | 99 | | Flatpak | `flatpak` | `[flatpak]` | built-in | can manage either system-wide or per-user installation (configurable) | 100 | | Python | `pip` | `[python]` | built-in | | 101 | | Rust | `cargo` | `[rust]` | built-in | | 102 | | Rustup | `rustup` | `[rustup]` | built-in | See the comments [below](#rustup) about the syntax of the packages in the group file. | 103 | | Void Linux | `xbps` | `[void]` | built-in | | 104 | 105 | Backends that have a `feature flag` require setting the respective flag for the build process. 106 | The appropriate system libraries and their header files must be present on the machine and be detectable by `pkg-config`. 107 | For backends that state "built-in", they are always supported during compile time. 108 | Any backend can be disabled during runtime (see below, "[Configuration](#configuration)"). 109 | 110 | For example, to build `pacdef` with support for Debian Linux, you can run one of the two commands. 111 | * (recommended) `cargo install -F debian pacdef`, this downloads and builds it from [https://crates.io](https://crates.io) 112 | * in a clone of this repository, `cargo install --path . -F debian` 113 | 114 | ### Example 115 | 116 | This tree shows my pacdef repository (not the `pacdef` config dir). 117 | ``` 118 | . 119 | ├── generic 120 | │ ├── audio 121 | │ ├── base 122 | │ ├── desktop 123 | │ ├── private 124 | │ ├── rust 125 | │ ├── wayland 126 | │ ├── wireless 127 | │ ├── work 128 | │ └── xorg 129 | ├── hosts 130 | │ ├── hostname_a 131 | │ ├── hostname_b 132 | │ └── hostname_c 133 | └── pacdef.toml 134 | ``` 135 | 136 | - The `base` group holds all packages I need unconditionally, and includes things like zfs, 137 | [paru](https://github.com/Morganamilo/paru) and [neovim](https://github.com/neovim/neovim). 138 | - In `xorg` and `wayland` I have stored the respective graphic servers and DEs. 139 | - `wireless` contains tools like `iwd` and `bluez-utils` for machines with wireless interfaces. 140 | - Under `hosts` I have one file for each machine I use. The filenames match the corresponding hostname. The packages 141 | are specific to one machine only, like device drivers, or any programs I use exclusively on that machine. 142 | 143 | Usage on different machines: 144 | 145 | - home server: `base private hostname_a` 146 | - private PC: `audio base desktop private rust wayland hostname_b` 147 | - work PC: `base desktop rust work xorg hostname_c` 148 | 149 | 150 | ## Commands 151 | 152 | | Subcommand | Description | 153 | |-----------------------------------|-----------------------------------------------------------------------| 154 | | `group import [...]` | create a symlink to the specified group file(s) in your groups folder | 155 | | `group export [args] ...` | export (move) a non-symlink group and re-import it as symlink | 156 | | `group list` | list names of all groups | 157 | | `group new [-e] [...]` | create new groups, use `-e` to edit them immediately after creation | 158 | | `group remove [...]` | remove a previously imported group | 159 | | `group show [...]` | show contents of a group | 160 | | `package clean [--noconfirm]` | remove all unmanaged packages | 161 | | `package review` | for each unmanaged package interactively decide what to do | 162 | | `package search ` | search for managed packages that match the search string | 163 | | `package sync [--noconfirm]` | install managed packages | 164 | | `package unmanaged` | show all unmanaged packages | 165 | | `version` | show version information, supported backends | 166 | 167 | ### Aliases 168 | 169 | Most subcommands have aliases. 170 | For example, instead of `pacdef package sync` you can write `pacdef p sy`, and `pacdef group show` would become `pacdef g s`. 171 | 172 | Use `--help` or the zsh completion to find the right aliases. 173 | 174 | 175 | ## Configuration 176 | 177 | On first execution, it will create an empty config file under `$XDG_CONFIG_HOME/pacdef/pacdef.toml`. 178 | The following key-value pairs can be set. 179 | The listed values are the defaults. 180 | 181 | ```toml 182 | aur_helper = "paru" # AUR helper to use on Arch Linux (paru, yay, ...) 183 | aur_rm_args = [] # additional args to pass to AUR helper when removing packages (optional) 184 | disabled_backends = [] # backends that pacdef should not manage, e.g. ["python"], this can reduce runtime if the package manager is notoriously slow (like pip) 185 | 186 | warn_not_symlinks = true # warn if a group file is not a symlink 187 | flatpak_systemwide = true # whether flatpak packages should be installed system-wide or per user 188 | pip_binary = "pip" # choose whether to use pipx instead of pip for python package management (see below, 'pitfalls while using pipx') 189 | ``` 190 | 191 | 192 | ## Group file syntax 193 | 194 | Group files loosely follow the syntax for `ini`-files. 195 | 196 | 1. Sections begin by their name in brackets. 197 | 2. One package per line. 198 | 3. Anything after a `#` is ignored. 199 | 4. Empty lines are ignored. 200 | 5. If a package exists in multiple repositories, the repo can be specified as prefix followed by a forward slash. 201 | The package manager must understand this notation. 202 | 203 | Example: 204 | ```ini 205 | [arch] 206 | alacritty 207 | firefox # this comment is ignored 208 | libreoffice-fresh 209 | mycustomrepo/zsh-theme-powerlevel10k 210 | 211 | [rust] 212 | cargo-update 213 | topgrade 214 | ``` 215 | 216 | ### Rustup 217 | 218 | Rustup packages are managed quite differently. For referring to the syntax, have a look [below](#group-file-syntax). 219 | In contrast to other package managers, rustup handles package naming very differently. 220 | These packages are either of the form `toolchain/` or `component//`, where can be stable, nightly, or any explicit rust version. 221 | The `` field has to be substituted with the name of the component you want installed. 222 | 223 | Example: 224 | 225 | ```ini 226 | [rustup] 227 | component/stable/rust-analyzer 228 | toolchain/stable 229 | component/stable/cargo 230 | component/stable/rust-src 231 | component/stable/rustc 232 | toolchain/1.70.0 233 | component/1.70.0/cargo 234 | component/1.70.0/clippy 235 | component/1.70.0/rust-docs 236 | component/1.70.0/rust-src 237 | component/1.70.0/rust-std 238 | component/1.70.0/rustc 239 | component/1.70.0/rustfmt 240 | ``` 241 | 242 | ## Misc. 243 | 244 | ### Automation 245 | 246 | Pacdef is supported by [topgrade](https://github.com/topgrade-rs/topgrade). 247 | 248 | ### Naming 249 | 250 | `pacdef` combines the words "package" and "define". 251 | 252 | 253 | ### minimum supported rust version (MSRV) 254 | 255 | MSRV is 1.74 due to dependencies that require this specific version. Development is conducted against the latest stable version. 256 | 257 | 258 | ### Pitfalls while using pipx 259 | 260 | Some packages like [mdformat-myst](https://github.com/executablebooks/mdformat-myst) do not provide an executable themselves but rather act as a plugin to their dependency, which is mdformat in this case. Please install such packages explicitly by running `pipx install --include-deps`. 261 | -------------------------------------------------------------------------------- /RELEASE-CHECKLIST.md: -------------------------------------------------------------------------------- 1 | Release Checklist 2 | ----------------- 3 | * Ensure local `main` is up to date with respect to `origin/main`. 4 | * Run `cargo update` and review dependency updates. 5 | Commit updated `Cargo.lock` with "chore(release): update lockfile". 6 | * Run `cargo outdated` and review semver incompatible updates. 7 | Unless there is a strong motivation otherwise, review and update every dependency. 8 | * Update the date and version in all man pages: "chore(release): bump man pages". 9 | * Run `cargo release -p pacdef `. 10 | Verify everything works as expected. 11 | * Rerun `cargo publish` with `--execute.` 12 | * Generate GitHub release with `git cliff` 13 | * Bump the AUR package. 14 | -------------------------------------------------------------------------------- /_completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef pacdef 2 | 3 | _pacdef() { 4 | integer ret=1 5 | local line 6 | 7 | if [ -z "${XDG_CONFIG_HOME}" ]; then 8 | GROUPDIR="~/.config/pacdef/groups" 9 | else 10 | GROUPDIR="${XDG_CONFIG_HOME}/pacdef/groups" 11 | fi 12 | 13 | function _subcommands { 14 | local -a subcommands 15 | subcommands=( 16 | 'group:manage groups' 17 | 'g:manage groups' 18 | 'package:manage packages' 19 | 'p:manage packages' 20 | 'version:show version' 21 | ) 22 | _describe 'pacdef subcommand' subcommands 23 | } 24 | 25 | function _group_actions { 26 | local -a group_actions 27 | group_actions=( 28 | 'ed:edit an imported group file' 29 | 'edit:edit an imported group file' 30 | 'ex:export a non-symlink group' 31 | 'export:export a non-symlink group' 32 | 'l:show names of imported groups' 33 | 'list:show names of imported groups' 34 | 'i:import a new group file' 35 | 'import:import a new group file' 36 | 'n:create a new group file' 37 | 'new:create a new group file' 38 | 'r:remove a group file' 39 | 'remove:remove a group file' 40 | 's:show packages under an imported group' 41 | 'show:show packages under an imported group' 42 | ) 43 | _describe 'pacdef group action' group_actions 44 | } 45 | 46 | 47 | function _package_actions { 48 | local -a package_actions 49 | package_actions=( 50 | 'c:uninstall packages not managed by pacdef' 51 | 'clean:uninstall packages not managed by pacdef' 52 | 'r:review unmanaged packages' 53 | 'review:review unmanaged packages' 54 | 'se:show the group containing a package' 55 | 'search:show the group containing a package' 56 | 'sy:install all packages from imported groups' 57 | 'sync:install all packages from imported groups' 58 | 'u:show explicitly installed packages not managed by pacdef' 59 | 'unmanaged:show explicitly installed packages not managed by pacdef' 60 | ) 61 | _describe 'pacdef package action' package_actions 62 | } 63 | 64 | _arguments -C \ 65 | "1: :_subcommands" \ 66 | "*::arg:->args" \ 67 | && ret=0 68 | 69 | case $state in 70 | (args) 71 | case $line[1] in 72 | (p|package) 73 | case $line[2] in 74 | (se|search) 75 | _arguments \ 76 | "2:regex:" && ret=0 77 | ;; 78 | (c|clean|r|review|sy|sync|u|unmanaged) 79 | _message "no more arguments" && ret=0 80 | ;; 81 | *) 82 | _arguments \ 83 | "1: :_package_actions" \ 84 | "*::arg:->args" && ret=0 85 | ;; 86 | esac 87 | ;; 88 | (g|group) 89 | case $line[2] in 90 | (l|list) 91 | _message "no more arguments" && ret=0 92 | ;; 93 | (ed|edit|r|remove|s|show) 94 | _arguments "*:group file(s):_files -W '$GROUPDIR'" && ret=0 95 | 96 | ;; 97 | (i|import) 98 | _arguments "*:new group file(s):_files" && ret=0 99 | ;; 100 | (n|new) 101 | _arguments \ 102 | {-e,--edit}"[edit group file after creating them]" \ 103 | "*:new group name(s):" \ 104 | && ret=0 105 | ;; 106 | *) _arguments \ 107 | "1: :_group_actions" \ 108 | "*::arg:->args" && ret=0 109 | ;; 110 | esac 111 | ;; 112 | version) 113 | _message "no more arguments" && ret=0 114 | ;; 115 | *) 116 | _message "unknown subcommand" && ret=1 117 | ;; 118 | esac 119 | ;; 120 | esac 121 | 122 | return ret 123 | } 124 | 125 | _pacdef 126 | 127 | 128 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Changelog 12 | """ 13 | # template for the changelog body 14 | # https://tera.netlify.app/docs/ 15 | body = """ 16 | {% for group, commits in commits | group_by(attribute="group") %} 17 | ### {{ group | striptags | trim | upper_first }} 18 | {% for commit in commits 19 | | filter(attribute="scope") 20 | | sort(attribute="scope") %} 21 | - {{commit.scope}}:{% if commit.breaking %} [**breaking**]{% endif %} \ 22 | {{ commit.message }} 23 | {%- endfor -%} 24 | {% raw %}\n{% endraw %}\ 25 | {%- for commit in commits %} 26 | {%- if commit.scope -%} 27 | {% else -%} 28 | -{% if commit.breaking %} [**breaking**]{% endif %} \ 29 | {{ commit.message }} 30 | {% endif -%} 31 | {% endfor -%} 32 | {% endfor %}\n 33 | """ 34 | # remove the leading and trailing whitespace from the template 35 | trim = true 36 | # changelog footer 37 | footer = """ 38 | 39 | """ 40 | 41 | [git] 42 | # parse the commits based on https://www.conventionalcommits.org 43 | conventional_commits = true 44 | # filter out the commits that are not conventional 45 | filter_unconventional = true 46 | # process each line of a commit as an individual commit 47 | split_commits = false 48 | # regex for preprocessing the commit messages 49 | commit_preprocessors = [ 50 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))" }, 51 | ] 52 | # regex for parsing and grouping commits 53 | commit_parsers = [ 54 | { message = "^feat", group = "Features" }, 55 | { message = "^fix", group = "Bug Fixes" }, 56 | { message = "^doc", group = "Documentation" }, 57 | { message = "^perf", group = "Performance" }, 58 | { message = "^refactor", group = "Refactor" }, 59 | { message = "^style", group = "Styling" }, 60 | { message = "^test", group = "Testing" }, 61 | { message = "^ci", group = "CI" }, 62 | { message = "^chore\\(release\\)", skip = true }, 63 | { message = "^chore", group = "Miscellaneous Tasks" }, 64 | { body = ".*security", group = "Security" }, 65 | ] 66 | # protect breaking changes from being skipped due to matching a skipping commit_parser 67 | protect_breaking_commits = false 68 | # filter out the commits that are not matched by commit parsers 69 | filter_commits = false 70 | # glob pattern for matching git tags 71 | tag_pattern = "v[0-9]*" 72 | # regex for skipping tags 73 | skip_tags = "v0.1.0-beta.1" 74 | # regex for ignoring tags 75 | ignore_tags = "" 76 | # sort the tags topologically 77 | topo_order = false 78 | # sort the commits inside sections by oldest/newest order 79 | sort_commits = "newest" 80 | 81 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | cognitive-complexity-threshold = 10 2 | -------------------------------------------------------------------------------- /crates/pacdef/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pacdef" 3 | description = "multi-backend declarative package manager for Linux" 4 | version = "1.6.0" 5 | 6 | edition.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | readme.workspace = true 10 | keywords.workspace = true 11 | categories.workspace = true 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | clap = { version = "4.5", features = ["derive"] } 16 | const_format = { version = "0.2", default-features = false } 17 | path-absolutize = "3.1" 18 | regex = { version = "1.10", default-features = false, features = ["std"] } 19 | termios = "0.3" 20 | walkdir = "2.5" 21 | libc = "0.2" 22 | enum_dispatch = "0.3" 23 | log = { version = "0.4", features = ["std"] } 24 | 25 | serde = { version = "1.0", features = ["derive"] } 26 | serde_json = "1.0" 27 | toml = "0.4" 28 | 29 | # backends 30 | alpm = { version = "3.0", optional = true } 31 | rust-apt = { version = "0.7", optional = true } 32 | 33 | [features] 34 | default = [] 35 | arch = ["dep:alpm"] 36 | debian = ["dep:rust-apt"] 37 | -------------------------------------------------------------------------------- /crates/pacdef/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | let git_hash = match Command::new("git") 5 | .args(["rev-parse", "--short", "HEAD"]) 6 | .output() 7 | { 8 | Ok(output) => String::from_utf8(output.stdout).expect("git output is utf-8"), 9 | _ => String::new(), 10 | }; 11 | 12 | println!("cargo::rustc-env=GIT_HASH={git_hash}"); 13 | } 14 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/arch.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::process::Command; 3 | 4 | use alpm::Alpm; 5 | use alpm::PackageReason::Explicit; 6 | use anyhow::{Context, Result}; 7 | 8 | use crate::cmd::run_external_command; 9 | use crate::prelude::*; 10 | 11 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 12 | pub struct Arch { 13 | pub binary: String, 14 | pub aur_rm_args: Vec, 15 | } 16 | impl Arch { 17 | pub fn new(config: &Config) -> Self { 18 | Self { 19 | binary: config.aur_helper.clone(), 20 | aur_rm_args: config.aur_rm_args.clone(), 21 | } 22 | } 23 | } 24 | 25 | impl Backend for Arch { 26 | fn backend_info(&self) -> BackendInfo { 27 | BackendInfo { 28 | binary: self.binary.clone(), 29 | section: "arch", 30 | switches_info: &["--query", "--info"], 31 | switches_install: &["--sync"], 32 | switches_noconfirm: &["--noconfirm"], 33 | switches_remove: &["--remove", "--recursive"], 34 | switches_make_dependency: Some(&["--database", "--asdeps"]), 35 | } 36 | } 37 | 38 | fn get_all_installed_packages(&self) -> Result { 39 | let alpm_packages = get_all_installed_packages_from_alpm() 40 | .context("getting all installed packages from alpm")?; 41 | 42 | let result = convert_to_pacdef_packages(alpm_packages); 43 | Ok(result) 44 | } 45 | 46 | fn get_explicitly_installed_packages(&self) -> Result { 47 | let alpm_packages = get_explicitly_installed_packages_from_alpm() 48 | .context("getting all installed packages from alpm")?; 49 | let result = convert_to_pacdef_packages(alpm_packages); 50 | Ok(result) 51 | } 52 | 53 | /// Install the specified packages. 54 | fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 55 | let backend_info = self.backend_info(); 56 | 57 | let mut cmd = Command::new(&self.binary); 58 | 59 | cmd.args(backend_info.switches_install); 60 | 61 | if noconfirm { 62 | cmd.args(backend_info.switches_noconfirm); 63 | } 64 | 65 | for p in packages { 66 | cmd.arg(format!("{p}")); 67 | } 68 | 69 | run_external_command(cmd) 70 | } 71 | 72 | /// Remove the specified packages. 73 | fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 74 | let backend_info = self.backend_info(); 75 | 76 | let mut cmd = Command::new(&self.binary); 77 | 78 | cmd.args(backend_info.switches_remove); 79 | cmd.args(&self.aur_rm_args); 80 | 81 | if noconfirm { 82 | cmd.args(backend_info.switches_noconfirm); 83 | } 84 | 85 | for p in packages { 86 | cmd.arg(format!("{p}")); 87 | } 88 | 89 | run_external_command(cmd) 90 | } 91 | } 92 | 93 | fn get_all_installed_packages_from_alpm() -> Result> { 94 | let db = get_db_handle().context("getting DB handle")?; 95 | let result = db 96 | .localdb() 97 | .pkgs() 98 | .iter() 99 | .map(|p| p.name().to_string()) 100 | .collect(); 101 | Ok(result) 102 | } 103 | 104 | fn get_explicitly_installed_packages_from_alpm() -> Result> { 105 | let db = get_db_handle().context("getting DB handle")?; 106 | let result = db 107 | .localdb() 108 | .pkgs() 109 | .iter() 110 | .filter(|p| p.reason() == Explicit) 111 | .map(|p| p.name().to_string()) 112 | .collect(); 113 | Ok(result) 114 | } 115 | 116 | fn convert_to_pacdef_packages(packages: HashSet) -> Packages { 117 | packages.into_iter().map(Package::from).collect() 118 | } 119 | 120 | fn get_db_handle() -> Result { 121 | Alpm::new("/", "/var/lib/pacman").context("connecting to DB using expected default values") 122 | } 123 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/debian.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use rust_apt::cache::PackageSort; 3 | use rust_apt::new_cache; 4 | 5 | use crate::backend::root::build_base_command_with_privileges; 6 | use crate::cmd::run_external_command; 7 | use crate::prelude::*; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 10 | pub struct Debian {} 11 | impl Debian { 12 | pub fn new() -> Self { 13 | Self {} 14 | } 15 | } 16 | impl Default for Debian { 17 | fn default() -> Self { 18 | Self::new() 19 | } 20 | } 21 | 22 | impl Backend for Debian { 23 | fn backend_info(&self) -> BackendInfo { 24 | BackendInfo { 25 | binary: "apt".to_string(), 26 | section: "debian", 27 | switches_info: &["show"], 28 | switches_install: &["install"], 29 | switches_noconfirm: &["--yes"], 30 | switches_remove: &["remove"], 31 | switches_make_dependency: Some(&[]), 32 | } 33 | } 34 | 35 | fn get_all_installed_packages(&self) -> Result { 36 | let cache = new_cache!()?; 37 | let sort = PackageSort::default().installed(); 38 | 39 | let mut result = Packages::new(); 40 | for pkg in cache.packages(&sort)? { 41 | result.insert(Package::from(pkg.name().to_string())); 42 | } 43 | Ok(result) 44 | } 45 | 46 | fn get_explicitly_installed_packages(&self) -> Result { 47 | let cache = new_cache!()?; 48 | let sort = PackageSort::default().installed().manually_installed(); 49 | 50 | let mut result = Packages::new(); 51 | for pkg in cache.packages(&sort)? { 52 | result.insert(Package::from(pkg.name().to_string())); 53 | } 54 | Ok(result) 55 | } 56 | 57 | fn make_dependency(&self, packages: &Packages) -> Result<()> { 58 | let mut cmd = build_base_command_with_privileges("apt-mark"); 59 | cmd.arg("auto"); 60 | for p in packages { 61 | cmd.arg(format!("{p}")); 62 | } 63 | 64 | run_external_command(cmd) 65 | } 66 | 67 | /// Install the specified packages. 68 | fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 69 | let backend_info = self.backend_info(); 70 | 71 | let mut cmd = build_base_command_with_privileges(&backend_info.binary); 72 | 73 | cmd.args(backend_info.switches_install); 74 | 75 | if noconfirm { 76 | cmd.args(backend_info.switches_noconfirm); 77 | } 78 | 79 | for p in packages { 80 | cmd.arg(format!("{p}")); 81 | } 82 | 83 | run_external_command(cmd) 84 | } 85 | 86 | /// Remove the specified packages. 87 | fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 88 | let backend_info = self.backend_info(); 89 | 90 | let mut cmd = build_base_command_with_privileges(&backend_info.binary); 91 | cmd.args(backend_info.switches_remove); 92 | 93 | if noconfirm { 94 | cmd.args(backend_info.switches_noconfirm); 95 | } 96 | 97 | for p in packages { 98 | cmd.arg(format!("{p}")); 99 | } 100 | 101 | run_external_command(cmd) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/fedora.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::cmd::run_external_command; 6 | use crate::prelude::*; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 9 | pub struct Fedora {} 10 | impl Fedora { 11 | pub fn new() -> Self { 12 | Self {} 13 | } 14 | } 15 | impl Default for Fedora { 16 | fn default() -> Self { 17 | Self::new() 18 | } 19 | } 20 | 21 | /// These repositories are ignored when storing the packages 22 | /// as these are present by default on any sane fedora system 23 | const DEFAULT_REPOS: [&str; 5] = ["koji", "fedora", "updates", "anaconda", "@"]; 24 | 25 | /// These switches are responsible for 26 | /// getting the packages explicitly installed by the user 27 | const SWITCHES_FETCH_USER: Switches = &[ 28 | "repoquery", 29 | "--userinstalled", 30 | "--queryformat", 31 | "%{from_repo}/%{name}", 32 | ]; 33 | 34 | /// These switches are responsible for 35 | /// getting all the packages installed on the system 36 | const SWITCHES_FETCH_GLOBAL: Switches = &[ 37 | "repoquery", 38 | "--installed", 39 | "--queryformat", 40 | "%{from_repo}/%{name}", 41 | ]; 42 | 43 | impl Backend for Fedora { 44 | fn backend_info(&self) -> BackendInfo { 45 | BackendInfo { 46 | binary: "dnf".to_string(), 47 | section: "fedora", 48 | switches_info: &["info"], 49 | switches_install: &["install"], 50 | switches_noconfirm: &["--assumeyes"], 51 | switches_remove: &["remove"], 52 | switches_make_dependency: None, 53 | } 54 | } 55 | 56 | fn get_all_installed_packages(&self) -> Result { 57 | let mut cmd = Command::new(self.backend_info().binary); 58 | cmd.args(SWITCHES_FETCH_GLOBAL); 59 | 60 | let output = String::from_utf8(cmd.output()?.stdout)?; 61 | let packages = output.lines().map(create_package).collect(); 62 | 63 | Ok(packages) 64 | } 65 | 66 | fn get_explicitly_installed_packages(&self) -> Result { 67 | let mut cmd = Command::new(self.backend_info().binary); 68 | cmd.args(SWITCHES_FETCH_USER); 69 | 70 | let output = String::from_utf8(cmd.output()?.stdout)?; 71 | let packages = output.lines().map(create_package).collect(); 72 | 73 | Ok(packages) 74 | } 75 | 76 | /// Install the specified packages. 77 | fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 78 | let backend_info = self.backend_info(); 79 | 80 | let mut cmd = Command::new("sudo"); 81 | cmd.arg(backend_info.binary); 82 | cmd.args(backend_info.switches_install); 83 | 84 | if noconfirm { 85 | cmd.args(backend_info.switches_noconfirm); 86 | } 87 | 88 | for p in packages { 89 | cmd.arg(&p.name); 90 | if let Some(repo) = p.repo.as_ref() { 91 | cmd.args(["--repo", repo]); 92 | } 93 | } 94 | 95 | // add these two repositories as these are needed for many dependencies 96 | cmd.args(["--repo", "updates"]); 97 | cmd.args(["--repo", "fedora"]); 98 | 99 | run_external_command(cmd) 100 | } 101 | 102 | /// Show information from package manager for package. 103 | fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 104 | let backend_info = self.backend_info(); 105 | 106 | let mut cmd = Command::new("sudo"); 107 | cmd.arg(backend_info.binary); 108 | cmd.args(backend_info.switches_remove); 109 | 110 | if noconfirm { 111 | cmd.args(backend_info.switches_noconfirm); 112 | } 113 | 114 | for p in packages { 115 | cmd.arg(&p.name); 116 | } 117 | 118 | run_external_command(cmd) 119 | } 120 | 121 | fn show_package_info(&self, package: &Package) -> Result<()> { 122 | let backend_info = self.backend_info(); 123 | 124 | let mut cmd = Command::new(backend_info.binary); 125 | cmd.args(backend_info.switches_info); 126 | cmd.arg(&package.name); 127 | 128 | run_external_command(cmd) 129 | } 130 | 131 | fn make_dependency(&self, _: &Packages) -> Result<()> { 132 | panic!("Not supported by the package manager!") 133 | } 134 | } 135 | 136 | fn create_package(package: &str) -> Package { 137 | if DEFAULT_REPOS.iter().any(|repo| package.contains(repo)) && !package.contains("copr") { 138 | let package = package.split('/').nth(1).expect("Cannot be empty!"); 139 | package.into() 140 | } else { 141 | package.into() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/flatpak.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::cmd::run_external_command; 6 | use crate::prelude::*; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 9 | pub struct Flatpak { 10 | pub systemwide: bool, 11 | } 12 | impl Flatpak { 13 | pub fn new(config: &Config) -> Self { 14 | Self { 15 | systemwide: config.flatpak_systemwide, 16 | } 17 | } 18 | 19 | fn get_switches_runtime(&self) -> Switches { 20 | if self.systemwide { 21 | &[] 22 | } else { 23 | &["--user"] 24 | } 25 | } 26 | 27 | fn get_installed_packages(&self, include_implicit: bool) -> Result { 28 | let mut cmd = Command::new(self.backend_info().binary); 29 | cmd.args(["list", "--columns=application"]); 30 | if !include_implicit { 31 | cmd.arg("--app"); 32 | } 33 | if !self.systemwide { 34 | cmd.arg("--user"); 35 | } 36 | 37 | let output = String::from_utf8(cmd.output()?.stdout)?; 38 | Ok(output.lines().map(Package::from).collect::()) 39 | } 40 | } 41 | 42 | impl Backend for Flatpak { 43 | fn backend_info(&self) -> BackendInfo { 44 | BackendInfo { 45 | binary: "flatpak".to_string(), 46 | section: "flatpak", 47 | switches_info: &["info"], 48 | switches_install: &["install"], 49 | switches_noconfirm: &["--assumeyes"], 50 | switches_remove: &["uninstall"], 51 | switches_make_dependency: None, 52 | } 53 | } 54 | 55 | fn get_all_installed_packages(&self) -> Result { 56 | self.get_installed_packages(true) 57 | } 58 | 59 | fn get_explicitly_installed_packages(&self) -> Result { 60 | self.get_installed_packages(false) 61 | } 62 | 63 | /// Install the specified packages. 64 | fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 65 | let backend_info = self.backend_info(); 66 | 67 | let mut cmd = Command::new(backend_info.binary); 68 | cmd.args(backend_info.switches_install); 69 | cmd.args(self.get_switches_runtime()); 70 | 71 | if noconfirm { 72 | cmd.args(backend_info.switches_noconfirm); 73 | } 74 | 75 | for p in packages { 76 | cmd.arg(format!("{p}")); 77 | } 78 | 79 | run_external_command(cmd) 80 | } 81 | 82 | fn make_dependency(&self, _: &Packages) -> Result<()> { 83 | panic!("not supported by {}", self.backend_info().binary) 84 | } 85 | 86 | /// Remove the specified packages. 87 | fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 88 | let backend_info = self.backend_info(); 89 | 90 | let mut cmd = Command::new(backend_info.binary); 91 | cmd.args(backend_info.switches_remove); 92 | cmd.args(self.get_switches_runtime()); 93 | 94 | if noconfirm { 95 | cmd.args(backend_info.switches_noconfirm); 96 | } 97 | 98 | for p in packages { 99 | cmd.arg(format!("{p}")); 100 | } 101 | 102 | run_external_command(cmd) 103 | } 104 | 105 | /// Show information from package manager for package. 106 | fn show_package_info(&self, package: &Package) -> Result<()> { 107 | let backend_info = self.backend_info(); 108 | 109 | let mut cmd = Command::new(backend_info.binary); 110 | cmd.args(backend_info.switches_info); 111 | cmd.args(self.get_switches_runtime()); 112 | cmd.arg(format!("{package}")); 113 | 114 | run_external_command(cmd) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "arch")] 2 | pub mod arch; 3 | #[cfg(feature = "debian")] 4 | pub mod debian; 5 | pub mod fedora; 6 | pub mod flatpak; 7 | pub mod python; 8 | pub mod rust; 9 | pub mod rustup; 10 | pub mod void; 11 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/python.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | use anyhow::Context; 4 | use anyhow::Result; 5 | use serde_json::Value; 6 | 7 | use crate::prelude::*; 8 | 9 | macro_rules! ERROR{ 10 | ($bin:expr) => { 11 | panic!("Cannot use {} for package management in python. Please use a valid package manager like pip or pipx.", $bin) 12 | }; 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 16 | pub struct Python { 17 | pub binary: String, 18 | } 19 | impl Python { 20 | pub fn new(config: &Config) -> Self { 21 | Self { 22 | binary: config.pip_binary.to_string(), 23 | } 24 | } 25 | 26 | fn get_switches_runtime(&self) -> Switches { 27 | match self.backend_info().binary.as_str() { 28 | "pip" => &["list", "--format", "json", "--not-required", "--user"], 29 | "pipx" => &["list", "--json"], 30 | _ => ERROR!(self.backend_info().binary), 31 | } 32 | } 33 | fn get_switches_explicit(&self) -> Switches { 34 | match self.backend_info().binary.as_str() { 35 | "pip" => &["list", "--format", "json", "--user"], 36 | "pipx" => &["list", "--json"], 37 | _ => ERROR!(self.backend_info().binary), 38 | } 39 | } 40 | 41 | fn extract_packages(&self, output: Value) -> Result { 42 | match self.backend_info().binary.as_str() { 43 | "pip" => extract_pacdef_packages(output), 44 | "pipx" => extract_pacdef_packages_pipx(output), 45 | _ => ERROR!(self.backend_info().binary), 46 | } 47 | } 48 | } 49 | 50 | impl Backend for Python { 51 | fn backend_info(&self) -> BackendInfo { 52 | BackendInfo { 53 | binary: self.binary.clone(), 54 | section: "python", 55 | switches_info: &["show"], 56 | switches_install: &["install"], 57 | switches_noconfirm: &[], 58 | switches_remove: &["uninstall"], 59 | switches_make_dependency: None, 60 | } 61 | } 62 | 63 | fn get_all_installed_packages(&self) -> Result { 64 | let mut cmd = Command::new(self.backend_info().binary); 65 | let output = run_pip_command(&mut cmd, self.get_switches_runtime())?; 66 | self.extract_packages(output) 67 | } 68 | 69 | fn get_explicitly_installed_packages(&self) -> Result { 70 | let mut cmd = Command::new(self.backend_info().binary); 71 | let output = run_pip_command(&mut cmd, self.get_switches_explicit())?; 72 | self.extract_packages(output) 73 | } 74 | 75 | fn make_dependency(&self, _packages: &Packages) -> Result<()> { 76 | panic!("not supported by {}", self.binary) 77 | } 78 | } 79 | 80 | fn run_pip_command(cmd: &mut Command, args: &[&str]) -> Result { 81 | cmd.args(args); 82 | let output = String::from_utf8(cmd.output()?.stdout)?; 83 | let val: Value = serde_json::from_str(&output)?; 84 | Ok(val) 85 | } 86 | 87 | fn extract_pacdef_packages(value: Value) -> Result { 88 | let result = value 89 | .as_array() 90 | .context("getting inner json array")? 91 | .iter() 92 | .map(|node| node["name"].as_str().expect("should always be a string")) 93 | .map(Package::from) 94 | .collect(); 95 | Ok(result) 96 | } 97 | 98 | fn extract_pacdef_packages_pipx(value: Value) -> Result { 99 | let result = value["venvs"] 100 | .as_object() 101 | .context("getting inner json object")? 102 | .iter() 103 | .map(|(name, _)| Package::from(name.as_str())) 104 | .collect(); 105 | Ok(result) 106 | } 107 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/rust.rs: -------------------------------------------------------------------------------- 1 | use std::fs::read_to_string; 2 | use std::io::ErrorKind::NotFound; 3 | use std::path::PathBuf; 4 | 5 | use anyhow::{bail, Context, Result}; 6 | use serde_json::Value; 7 | 8 | use crate::prelude::*; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 11 | pub struct Rust {} 12 | impl Rust { 13 | pub fn new() -> Self { 14 | Self {} 15 | } 16 | } 17 | impl Default for Rust { 18 | fn default() -> Self { 19 | Self::new() 20 | } 21 | } 22 | 23 | impl Backend for Rust { 24 | fn backend_info(&self) -> BackendInfo { 25 | BackendInfo { 26 | binary: "cargo".to_string(), 27 | section: "rust", 28 | switches_info: &["search", "--limit", "1"], 29 | switches_install: &["install"], 30 | switches_noconfirm: &[], 31 | switches_remove: &["uninstall"], 32 | switches_make_dependency: None, 33 | } 34 | } 35 | 36 | fn get_all_installed_packages(&self) -> Result { 37 | let file = get_crates_file().context("getting path to crates file")?; 38 | 39 | let content = match read_to_string(file) { 40 | Ok(string) => string, 41 | Err(err) if err.kind() == NotFound => { 42 | log::warn!("no crates file found for cargo. Assuming no crates installed yet."); 43 | return Ok(Packages::new()); 44 | } 45 | Err(err) => bail!(err), 46 | }; 47 | 48 | let json: Value = 49 | serde_json::from_str(&content).context("parsing JSON from crates file")?; 50 | extract_packages(&json).context("extracting packages from crates file") 51 | } 52 | 53 | fn get_explicitly_installed_packages(&self) -> Result { 54 | self.get_all_installed_packages() 55 | .context("getting all installed packages") 56 | } 57 | 58 | fn make_dependency(&self, _: &Packages) -> Result<()> { 59 | panic!("not supported by {}", self.backend_info().binary) 60 | } 61 | } 62 | 63 | fn extract_packages(json: &Value) -> Result { 64 | let result: Packages = json 65 | .get("installs") 66 | .context("get 'installs' field from json")? 67 | .as_object() 68 | .context("getting object")? 69 | .into_iter() 70 | .map(|(name, _)| name) 71 | .map(|name| { 72 | name.split_whitespace() 73 | .next() 74 | .expect("identifier is whitespace-delimited") 75 | }) 76 | .map(|name| Package::try_from(name).expect("name is valid")) 77 | .collect(); 78 | 79 | Ok(result) 80 | } 81 | 82 | fn get_crates_file() -> Result { 83 | let mut result = crate::path::get_cargo_home().context("getting cargo home dir")?; 84 | result.push(".crates2.json"); 85 | Ok(result) 86 | } 87 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/rustup/helpers.rs: -------------------------------------------------------------------------------- 1 | use super::types::RustupPackage; 2 | 3 | pub fn toolchain_of_component_was_already_removed( 4 | removed_toolchains: &[String], 5 | component: &RustupPackage, 6 | ) -> bool { 7 | removed_toolchains.contains(&component.toolchain) 8 | } 9 | 10 | pub fn install_components(line: &str, toolchain: &str, val: &mut Vec) { 11 | let mut chunks = line.splitn(3, '-'); 12 | let component = chunks.next().expect("Component name is empty!"); 13 | match component { 14 | // these are the only components that have a single word name 15 | "cargo" | "rustfmt" | "clippy" | "miri" | "rls" | "rustc" => { 16 | val.push([toolchain, component].join("/")); 17 | } 18 | // all the others have two words hyphenated as component names 19 | _ => { 20 | let component = [ 21 | component, 22 | chunks 23 | .next() 24 | .expect("No such component is managed by rustup"), 25 | ] 26 | .join("-"); 27 | val.push([toolchain, component.as_str()].join("/")); 28 | } 29 | } 30 | } 31 | 32 | pub fn group_components_by_toolchains(components: Vec) -> Vec> { 33 | let mut result = vec![]; 34 | 35 | let mut toolchains: Vec = vec![]; 36 | 37 | for component in components { 38 | let index = toolchains 39 | .iter() 40 | .enumerate() 41 | .find(|(_, toolchain)| toolchain == &&component.toolchain) 42 | .map(|(idx, _)| idx) 43 | .unwrap_or_else(|| { 44 | toolchains.push(component.toolchain.clone()); 45 | result.push(vec![]); 46 | toolchains.len() - 1 47 | }); 48 | 49 | result 50 | .get_mut(index) 51 | .expect( 52 | "either the index already existed or we just pushed the element with that index", 53 | ) 54 | .push(component); 55 | } 56 | 57 | result 58 | } 59 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/rustup/mod.rs: -------------------------------------------------------------------------------- 1 | mod helpers; 2 | mod types; 3 | 4 | use crate::cmd::run_external_command; 5 | use crate::prelude::*; 6 | use anyhow::{bail, Context, Result}; 7 | use std::process::Command; 8 | 9 | use self::helpers::{ 10 | group_components_by_toolchains, install_components, toolchain_of_component_was_already_removed, 11 | }; 12 | use self::types::{Repotype, RustupPackage}; 13 | 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 15 | pub struct Rustup {} 16 | impl Rustup { 17 | pub fn new() -> Self { 18 | Self {} 19 | } 20 | } 21 | impl Default for Rustup { 22 | fn default() -> Self { 23 | Self::new() 24 | } 25 | } 26 | 27 | impl Backend for Rustup { 28 | fn backend_info(&self) -> BackendInfo { 29 | BackendInfo { 30 | binary: "rustup".to_string(), 31 | section: "rustup", 32 | switches_install: &["component", "add"], 33 | switches_info: &["component", "list", "--installed"], 34 | switches_noconfirm: &[], 35 | switches_remove: &["component", "remove"], 36 | switches_make_dependency: None, 37 | } 38 | } 39 | 40 | fn get_all_installed_packages(&self) -> Result { 41 | let toolchains_vec = self 42 | .run_toolchain_command(Repotype::Toolchain.get_info_switches()) 43 | .context("Getting installed toolchains")?; 44 | 45 | let toolchains: Packages = toolchains_vec 46 | .iter() 47 | .map(|name| ["toolchain", name].join("/").into()) 48 | .collect(); 49 | 50 | let components: Packages = self 51 | .run_component_command(Repotype::Component.get_info_switches(), &toolchains_vec) 52 | .context("Getting installed components")? 53 | .iter() 54 | .map(|name| ["component", name].join("/").into()) 55 | .collect(); 56 | 57 | let mut packages = Packages::new(); 58 | 59 | packages.extend(toolchains); 60 | packages.extend(components); 61 | 62 | Ok(packages) 63 | } 64 | 65 | fn get_explicitly_installed_packages(&self) -> Result { 66 | self.get_all_installed_packages() 67 | .context("Getting all installed packages") 68 | } 69 | 70 | fn make_dependency(&self, _: &Packages) -> Result<()> { 71 | panic!("Not supported by {}", self.backend_info().binary) 72 | } 73 | 74 | fn install_packages(&self, packages: &Packages, _: bool) -> Result<()> { 75 | let packages = RustupPackage::from_pacdef_packages(packages)?; 76 | 77 | let (toolchains, components) = 78 | RustupPackage::sort_packages_into_toolchains_and_components(packages); 79 | 80 | self.install_toolchains(toolchains)?; 81 | self.install_components(components)?; 82 | 83 | Ok(()) 84 | } 85 | 86 | fn remove_packages(&self, packages: &Packages, _: bool) -> Result<()> { 87 | let rustup_packages = RustupPackage::from_pacdef_packages(packages)?; 88 | 89 | let (toolchains, components) = 90 | RustupPackage::sort_packages_into_toolchains_and_components(rustup_packages); 91 | 92 | let removed_toolchains = self.remove_toolchains(toolchains)?; 93 | 94 | self.remove_components(components, removed_toolchains)?; 95 | 96 | Ok(()) 97 | } 98 | } 99 | 100 | impl Rustup { 101 | fn run_component_command(&self, args: &[&str], toolchains: &[String]) -> Result> { 102 | let mut val = Vec::new(); 103 | 104 | for toolchain in toolchains { 105 | let mut cmd = Command::new(self.backend_info().binary); 106 | cmd.args(args).arg(toolchain); 107 | 108 | let output = String::from_utf8(cmd.output()?.stdout)?; 109 | 110 | for component in output.lines() { 111 | install_components(component, toolchain, &mut val); 112 | } 113 | } 114 | 115 | Ok(val) 116 | } 117 | 118 | fn run_toolchain_command(&self, args: &[&str]) -> Result> { 119 | let mut cmd = Command::new(self.backend_info().binary); 120 | cmd.args(args); 121 | 122 | let output = String::from_utf8(cmd.output()?.stdout)?; 123 | 124 | let mut val = Vec::new(); 125 | 126 | for line in output.lines() { 127 | let toolchain = line.split('-').next(); 128 | match toolchain { 129 | Some(name) => val.push(name.to_string()), 130 | None => bail!("Toolchain name not provided!"), 131 | } 132 | } 133 | 134 | Ok(val) 135 | } 136 | 137 | fn install_toolchains(&self, toolchains: Vec) -> Result<()> { 138 | if toolchains.is_empty() { 139 | return Ok(()); 140 | } 141 | let mut cmd = Command::new(self.backend_info().binary); 142 | cmd.args(Repotype::Toolchain.get_install_switches()); 143 | 144 | for toolchain in toolchains { 145 | cmd.arg(&toolchain.toolchain); 146 | } 147 | 148 | run_external_command(cmd).context("installing toolchains")?; 149 | 150 | Ok(()) 151 | } 152 | 153 | fn install_components(&self, components: Vec) -> Result<()> { 154 | if components.is_empty() { 155 | return Ok(()); 156 | } 157 | 158 | let components_by_toolchain = group_components_by_toolchains(components); 159 | 160 | for components_for_one_toolchain in components_by_toolchain { 161 | let mut cmd = Command::new(self.backend_info().binary); 162 | cmd.args(Repotype::Component.get_install_switches()); 163 | 164 | let the_toolchain = &components_for_one_toolchain 165 | .first() 166 | .expect("will have at least one element") 167 | .toolchain; 168 | 169 | cmd.arg(the_toolchain); 170 | 171 | for component_package in &components_for_one_toolchain { 172 | let actual_component = component_package 173 | .component 174 | .as_ref() 175 | .expect("constructor makes sure this is Some"); 176 | 177 | cmd.arg(actual_component); 178 | } 179 | 180 | run_external_command(cmd) 181 | .with_context(|| format!("installing [{components_for_one_toolchain:?}]"))?; 182 | } 183 | 184 | Ok(()) 185 | } 186 | 187 | fn remove_toolchains(&self, toolchains: Vec) -> Result> { 188 | let mut removed_toolchains = vec![]; 189 | if !toolchains.is_empty() { 190 | let mut cmd = Command::new(self.backend_info().binary); 191 | cmd.args(Repotype::Toolchain.get_remove_switches()); 192 | 193 | for toolchain_package in &toolchains { 194 | let name = toolchain_package.toolchain.as_str(); 195 | cmd.arg(name); 196 | removed_toolchains.push(name.to_string()); 197 | } 198 | 199 | run_external_command(cmd) 200 | .with_context(|| format!("removing toolchains [{toolchains:?}]"))?; 201 | } 202 | Ok(removed_toolchains) 203 | } 204 | 205 | fn remove_components( 206 | &self, 207 | components: Vec, 208 | removed_toolchains: Vec, 209 | ) -> Result<()> { 210 | for component_package in components { 211 | let mut cmd = Command::new(self.backend_info().binary); 212 | cmd.args(Repotype::Component.get_remove_switches()); 213 | 214 | if toolchain_of_component_was_already_removed(&removed_toolchains, &component_package) { 215 | continue; 216 | } 217 | 218 | cmd.arg(&component_package.toolchain); 219 | cmd.arg( 220 | component_package 221 | .component 222 | .as_ref() 223 | .expect("the constructor ensures this cannot be None"), 224 | ); 225 | 226 | run_external_command(cmd) 227 | .with_context(|| format!("removing component {component_package:?}"))?; 228 | } 229 | Ok(()) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/rustup/types.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Context, Result}; 2 | 3 | use crate::prelude::*; 4 | 5 | #[derive(Debug)] 6 | pub enum Repotype { 7 | Toolchain, 8 | Component, 9 | } 10 | 11 | /// A package as used exclusively in the rustup backend. Contrary to other packages, this does not 12 | /// have an (optional) repository and a name, but is either a component or a toolchain, has a 13 | /// toolchain version, and if it is a toolchain also a name. 14 | #[derive(Debug)] 15 | pub struct RustupPackage { 16 | /// Whether it is a toolchain or a component. 17 | pub repotype: Repotype, 18 | /// The name of the toolchain this belongs to (stable, nightly, a pinned version) 19 | pub toolchain: String, 20 | /// If it is a toolchain, it will not have a component name. 21 | /// If it is a component, this will be its name. 22 | pub component: Option, 23 | } 24 | 25 | impl Repotype { 26 | fn try_from(value: T) -> Result 27 | where 28 | T: AsRef, 29 | { 30 | let value = value.as_ref(); 31 | let result = match value { 32 | "toolchain" => Self::Toolchain, 33 | "component" => Self::Component, 34 | _ => bail!("{} is neither toolchain nor component", value), 35 | }; 36 | Ok(result) 37 | } 38 | 39 | pub fn get_install_switches(self) -> Switches { 40 | match self { 41 | Self::Toolchain => &["toolchain", "install"], 42 | Self::Component => &["component", "add", "--toolchain"], 43 | } 44 | } 45 | 46 | pub fn get_remove_switches(self) -> Switches { 47 | match self { 48 | Self::Toolchain => &["toolchain", "uninstall"], 49 | Self::Component => &["component", "remove", "--toolchain"], 50 | } 51 | } 52 | 53 | pub fn get_info_switches(self) -> Switches { 54 | match self { 55 | Self::Toolchain => &["toolchain", "list"], 56 | Self::Component => &["component", "list", "--installed", "--toolchain"], 57 | } 58 | } 59 | } 60 | 61 | impl RustupPackage { 62 | /// Creates a new [`RustupPackage`]. 63 | /// 64 | /// # Panics 65 | /// 66 | /// Panics if 67 | /// - repotype is Toolchain and component is Some, or 68 | /// - repotype is Component and component is None. 69 | fn new(repotype: Repotype, toolchain: String, component: Option) -> Self { 70 | match repotype { 71 | Repotype::Toolchain => assert!(component.is_none()), 72 | Repotype::Component => assert!(component.is_some()), 73 | }; 74 | 75 | Self { 76 | repotype, 77 | toolchain, 78 | component, 79 | } 80 | } 81 | 82 | pub fn sort_packages_into_toolchains_and_components( 83 | packages: Vec, 84 | ) -> (Vec, Vec) { 85 | let mut toolchains = vec![]; 86 | let mut components = vec![]; 87 | 88 | for package in packages { 89 | match package.repotype { 90 | Repotype::Toolchain => toolchains.push(package), 91 | Repotype::Component => components.push(package), 92 | } 93 | } 94 | 95 | (toolchains, components) 96 | } 97 | 98 | pub fn from_pacdef_packages(packages: &Packages) -> Result> { 99 | let mut result = vec![]; 100 | 101 | for package in packages { 102 | let rustup_package = Self::try_from(package).with_context(|| { 103 | format!( 104 | "converting pacdef package {} to rustup package", 105 | package.name 106 | ) 107 | })?; 108 | result.push(rustup_package); 109 | } 110 | 111 | Ok(result) 112 | } 113 | } 114 | 115 | impl TryFrom<&Package> for RustupPackage { 116 | type Error = anyhow::Error; 117 | 118 | fn try_from(package: &Package) -> Result { 119 | let repo = package.repo.as_ref().context("getting repo from package")?; 120 | let repotype = Repotype::try_from(repo).context("getting repotype")?; 121 | 122 | let (toolchain, component) = match repotype { 123 | Repotype::Toolchain => (package.name.to_string(), None), 124 | Repotype::Component => { 125 | let (toolchain, component) = package 126 | .name 127 | .split_once('/') 128 | .context("splitting package into toolchain and component")?; 129 | (toolchain.to_string(), Some(component.into())) 130 | } 131 | }; 132 | 133 | Ok(Self::new(repotype, toolchain, component)) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/actual/void.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | use anyhow::Result; 4 | use regex::Regex; 5 | 6 | use crate::backend::root::build_base_command_with_privileges; 7 | use crate::cmd::run_external_command; 8 | use crate::prelude::*; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 11 | pub struct Void {} 12 | impl Void { 13 | pub fn new() -> Self { 14 | Self {} 15 | } 16 | } 17 | impl Default for Void { 18 | fn default() -> Self { 19 | Self::new() 20 | } 21 | } 22 | 23 | const INSTALL_BINARY: Text = "xbps-install"; 24 | const REMOVE_BINARY: Text = "xbps-remove"; 25 | const QUERY_BINARY: Text = "xbps-query"; 26 | const PKGDB_BINARY: Text = "xbps-pkgdb"; 27 | 28 | impl Backend for Void { 29 | fn backend_info(&self) -> BackendInfo { 30 | BackendInfo { 31 | binary: "xbps-install".to_string(), 32 | section: "void", 33 | switches_info: &[], 34 | switches_install: &["-S"], 35 | switches_noconfirm: &["-y"], 36 | switches_remove: &["-R"], 37 | switches_make_dependency: Some(&["-m", "auto"]), 38 | } 39 | } 40 | 41 | fn get_all_installed_packages(&self) -> Result { 42 | // Removes the package status and description from output 43 | let re_str_1 = r"^ii |^uu |^hr |^\?\? | .*"; 44 | // Removes the package version from output 45 | let re_str_2 = r"-[^-]*$"; 46 | let re1 = Regex::new(re_str_1)?; 47 | let re2 = Regex::new(re_str_2)?; 48 | let mut cmd = Command::new(QUERY_BINARY); 49 | cmd.args(["-l"]); 50 | let output = String::from_utf8(cmd.output()?.stdout)?; 51 | 52 | let packages = output 53 | .lines() 54 | .map(|line| { 55 | let result = re1.replace_all(line, ""); 56 | let result = re2.replace_all(&result, ""); 57 | result.to_string().into() 58 | }) 59 | .collect(); 60 | Ok(packages) 61 | } 62 | 63 | fn get_explicitly_installed_packages(&self) -> Result { 64 | // Removes the package version from output 65 | let re_str = r"-[^-]*$"; 66 | let re = Regex::new(re_str)?; 67 | let mut cmd = Command::new(QUERY_BINARY); 68 | cmd.args(["-m"]); 69 | let output = String::from_utf8(cmd.output()?.stdout)?; 70 | 71 | let packages = output 72 | .lines() 73 | .map(|line| { 74 | let result = re.replace_all(line, "").to_string(); 75 | result.into() 76 | }) 77 | .collect(); 78 | Ok(packages) 79 | } 80 | 81 | /// Install the specified packages. 82 | fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 83 | let backend_info = self.backend_info(); 84 | 85 | let mut cmd = build_base_command_with_privileges(INSTALL_BINARY); 86 | cmd.args(backend_info.switches_install); 87 | 88 | if noconfirm { 89 | cmd.args(backend_info.switches_noconfirm); 90 | } 91 | 92 | for p in packages { 93 | cmd.arg(format!("{p}")); 94 | } 95 | 96 | run_external_command(cmd) 97 | } 98 | 99 | fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 100 | let backend_info = self.backend_info(); 101 | 102 | let mut cmd = build_base_command_with_privileges(REMOVE_BINARY); 103 | cmd.args(backend_info.switches_remove); 104 | 105 | if noconfirm { 106 | cmd.args(backend_info.switches_noconfirm); 107 | } 108 | 109 | for p in packages { 110 | cmd.arg(format!("{p}")); 111 | } 112 | 113 | run_external_command(cmd) 114 | } 115 | 116 | fn make_dependency(&self, packages: &Packages) -> Result<()> { 117 | let backend_info = self.backend_info(); 118 | 119 | let mut cmd = build_base_command_with_privileges(PKGDB_BINARY); 120 | cmd.args( 121 | backend_info 122 | .switches_make_dependency 123 | .expect("void should support make make dependency"), 124 | ); 125 | 126 | for p in packages { 127 | cmd.arg(format!("{p}")); 128 | } 129 | 130 | run_external_command(cmd) 131 | } 132 | 133 | /// Show information from package manager for package. 134 | fn show_package_info(&self, package: &Package) -> Result<()> { 135 | let backend_info = self.backend_info(); 136 | 137 | let mut cmd = Command::new(QUERY_BINARY); 138 | cmd.args(backend_info.switches_info); 139 | cmd.arg(format!("{package}")); 140 | 141 | run_external_command(cmd) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/backend_trait.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::process::Command; 3 | 4 | use anyhow::Result; 5 | 6 | use crate::cmd::run_external_command; 7 | use crate::prelude::*; 8 | 9 | pub type Switches = &'static [&'static str]; 10 | pub type Text = &'static str; 11 | 12 | /// A bundle of small of bits of info associated with a backend. 13 | pub struct BackendInfo { 14 | /// The binary name when calling the backend. 15 | pub binary: String, 16 | /// The name of the section in the group files. 17 | pub section: Text, 18 | /// CLI switches for the package manager to show information for 19 | /// packages. 20 | pub switches_info: Switches, 21 | /// CLI switches for the package manager to install packages. 22 | pub switches_install: Switches, 23 | /// CLI switches for the package manager to perform `sync` and `clean` without 24 | /// confirmation. 25 | pub switches_noconfirm: Switches, 26 | /// CLI switches for the package manager to remove packages. 27 | pub switches_remove: Switches, 28 | /// CLI switches for the package manager to mark packages as 29 | /// dependency. This is not supported by all package managers. 30 | pub switches_make_dependency: Option, 31 | } 32 | 33 | /// The trait of a struct that is used as a backend. 34 | #[enum_dispatch::enum_dispatch] 35 | pub trait Backend { 36 | /// Return the [`BackendInfo`] associated with this backend. 37 | fn backend_info(&self) -> BackendInfo; 38 | 39 | fn supports_as_dependency(&self) -> bool { 40 | self.backend_info().switches_make_dependency.is_some() 41 | } 42 | 43 | /// Get all packages that are installed in the system. 44 | /// 45 | /// # Errors 46 | /// 47 | /// This function shall return an error if the installed packages cannot be 48 | /// determined. 49 | fn get_all_installed_packages(&self) -> Result; 50 | 51 | /// Get all packages that were installed in the system explicitly. 52 | /// 53 | /// # Errors 54 | /// 55 | /// This function shall return an error if the explicitly installed packages 56 | /// cannot be determined. 57 | fn get_explicitly_installed_packages(&self) -> Result; 58 | 59 | /// Assign each of the packages to an individual group by editing the 60 | /// group files. 61 | /// 62 | /// # Errors 63 | /// 64 | /// Returns an Error if any of the groups fails to save their given packages. 65 | fn assign_group(&self, to_assign: Vec<(Package, Group)>) -> Result<()> { 66 | let mut group_package_map: BTreeMap = BTreeMap::new(); 67 | 68 | for (package, group) in to_assign { 69 | group_package_map.entry(group).or_default().insert(package); 70 | } 71 | 72 | let section_header = format!("[{}]", self.backend_info().section); 73 | 74 | for (group, packages) in group_package_map { 75 | group.save_packages(§ion_header, &packages)?; 76 | } 77 | 78 | Ok(()) 79 | } 80 | 81 | /// Install the specified packages. If `noconfirm` is `true`, pass the corresponding 82 | /// switch to the package manager. Return the [`ExitStatus`] from the package manager. 83 | /// 84 | /// # Errors 85 | /// 86 | /// This function will return an error if the package manager cannot be run or it 87 | /// returns an error. 88 | fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 89 | let backend_info = self.backend_info(); 90 | 91 | let mut cmd = Command::new(self.backend_info().binary); 92 | cmd.args(backend_info.switches_install); 93 | 94 | if noconfirm { 95 | cmd.args(backend_info.switches_noconfirm); 96 | } 97 | 98 | for p in packages { 99 | cmd.arg(format!("{p}")); 100 | } 101 | 102 | run_external_command(cmd) 103 | } 104 | 105 | /// Mark the packages as non-explicit / dependency using the underlying 106 | /// package manager. 107 | /// 108 | /// # Panics 109 | /// 110 | /// This method shall panic when the backend does not support dependent packages. 111 | /// 112 | /// # Errors 113 | /// 114 | /// Returns an error if the external command fails. 115 | fn make_dependency(&self, packages: &Packages) -> Result<()> { 116 | let backend_info = self.backend_info(); 117 | 118 | let mut cmd = Command::new(backend_info.binary); 119 | 120 | if let Some(switches_make_dependency) = backend_info.switches_make_dependency { 121 | cmd.args(switches_make_dependency); 122 | } 123 | 124 | for p in packages { 125 | cmd.arg(format!("{p}")); 126 | } 127 | 128 | run_external_command(cmd) 129 | } 130 | 131 | /// Remove the specified packages. 132 | /// 133 | /// # Errors 134 | /// 135 | /// Returns an error if the external command fails. 136 | fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> { 137 | let backend_info = self.backend_info(); 138 | 139 | let mut cmd = Command::new(backend_info.binary); 140 | cmd.args(backend_info.switches_remove); 141 | 142 | if noconfirm { 143 | cmd.args(backend_info.switches_noconfirm); 144 | } 145 | 146 | for p in packages { 147 | cmd.arg(format!("{p}")); 148 | } 149 | 150 | run_external_command(cmd) 151 | } 152 | 153 | /// Show information from package manager for package. 154 | /// 155 | /// # Errors 156 | /// 157 | /// Returns an error if the external command fails. 158 | fn show_package_info(&self, package: &Package) -> Result<()> { 159 | let backend_info = self.backend_info(); 160 | 161 | let mut cmd = Command::new(backend_info.binary); 162 | cmd.args(backend_info.switches_info); 163 | cmd.arg(format!("{package}")); 164 | 165 | run_external_command(cmd) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod actual; 2 | pub mod backend_trait; 3 | mod root; 4 | pub mod todo_per_backend; 5 | 6 | use std::fmt::Display; 7 | 8 | use crate::prelude::*; 9 | use anyhow::{Context, Result}; 10 | 11 | /// A backend with its associated managed packages 12 | pub struct ManagedBackend { 13 | /// All managed packages for this backend, i.e. all packages 14 | /// under the corresponding section in all group files. 15 | pub packages: Packages, 16 | pub any_backend: AnyBackend, 17 | } 18 | 19 | impl ManagedBackend { 20 | /// Get unmanaged packages 21 | /// 22 | /// # Errors 23 | /// 24 | /// Returns an error if the backend fails to get the explicitly installed packages. 25 | pub fn get_unmanaged_packages_sorted(&self) -> Result { 26 | let installed = self 27 | .any_backend 28 | .get_explicitly_installed_packages() 29 | .context("could not get explicitly installed packages")?; 30 | 31 | let diff = installed.difference(&self.packages).cloned().collect(); 32 | 33 | Ok(diff) 34 | } 35 | 36 | /// Get missing packages 37 | /// 38 | /// # Errors 39 | /// 40 | /// Returns an error if the backend fails to get the installed packages. 41 | pub fn get_missing_packages_sorted(&self) -> Result { 42 | let installed = self 43 | .any_backend 44 | .get_all_installed_packages() 45 | .context("could not get installed packages")?; 46 | 47 | let diff = self.packages.difference(&installed).cloned().collect(); 48 | 49 | Ok(diff) 50 | } 51 | } 52 | 53 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 54 | #[enum_dispatch::enum_dispatch(Backend)] 55 | pub enum AnyBackend { 56 | #[cfg(feature = "arch")] 57 | Arch(actual::arch::Arch), 58 | #[cfg(feature = "debian")] 59 | Debian(actual::debian::Debian), 60 | Flatpak(Flatpak), 61 | Fedora(Fedora), 62 | Python(Python), 63 | Rust(Rust), 64 | Rustup(Rustup), 65 | Void(Void), 66 | } 67 | impl AnyBackend { 68 | /// Returns an iterator of every variant of backend. 69 | pub fn all(config: &Config) -> impl Iterator { 70 | vec![ 71 | #[cfg(feature = "arch")] 72 | Self::Arch(actual::arch::Arch::new(config)), 73 | #[cfg(feature = "debian")] 74 | Self::Debian(actual::debian::Debian::new()), 75 | Self::Flatpak(Flatpak::new(config)), 76 | Self::Fedora(Fedora::new()), 77 | Self::Python(Python::new(config)), 78 | Self::Rust(Rust::new()), 79 | Self::Rustup(Rustup::new()), 80 | Self::Void(Void::new()), 81 | ] 82 | .into_iter() 83 | } 84 | 85 | pub fn from_section(section: &str, config: &Config) -> Result { 86 | match section { 87 | #[cfg(feature = "arch")] 88 | "arch" => Ok(Self::Arch(actual::arch::Arch::new(config))), 89 | #[cfg(feature = "debian")] 90 | "debian" => Ok(Self::Debian(actual::debian::Debian::new())), 91 | "flatpak" => Ok(Self::Flatpak(Flatpak::new(config))), 92 | "fedora" => Ok(Self::Fedora(Fedora::new())), 93 | "python" => Ok(Self::Python(Python::new(config))), 94 | "rust" => Ok(Self::Rust(Rust::new())), 95 | "rustup" => Ok(Self::Rustup(Rustup::new())), 96 | "void" => Ok(Self::Void(Void::new())), 97 | _ => Err(anyhow::anyhow!( 98 | "no matching backend for the section: {section}" 99 | )), 100 | } 101 | } 102 | } 103 | impl Display for AnyBackend { 104 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 105 | write!(f, "{}", self.backend_info().section) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/root.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | pub fn we_are_root() -> bool { 4 | let uid = unsafe { libc::geteuid() }; 5 | uid == 0 6 | } 7 | 8 | pub fn build_base_command_with_privileges(binary: &str) -> Command { 9 | let cmd = if we_are_root() { 10 | Command::new(binary) 11 | } else { 12 | let mut cmd = Command::new("sudo"); 13 | cmd.arg(binary); 14 | cmd 15 | }; 16 | cmd 17 | } 18 | -------------------------------------------------------------------------------- /crates/pacdef/src/backend/todo_per_backend.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use anyhow::{Context, Result}; 4 | 5 | use crate::prelude::*; 6 | 7 | /// A vector of tuples containing a Backends and a vector of unmanaged packages 8 | /// for that backend. 9 | /// 10 | /// This struct is used to store a list of unmanaged packages or missing packages 11 | /// for all backends. 12 | #[derive(Debug)] 13 | pub struct ToDoPerBackend(Vec<(AnyBackend, Packages)>); 14 | impl ToDoPerBackend { 15 | pub fn new() -> Self { 16 | Self(vec![]) 17 | } 18 | 19 | pub fn push(&mut self, item: (AnyBackend, Packages)) { 20 | self.0.push(item); 21 | } 22 | 23 | pub fn iter(&self) -> impl Iterator { 24 | self.0.iter() 25 | } 26 | 27 | pub fn nothing_to_do_for_all_backends(&self) -> bool { 28 | self.0.iter().all(|(_, diff)| diff.is_empty()) 29 | } 30 | 31 | pub fn install_missing_packages(&self, noconfirm: bool) -> Result<()> { 32 | for (backend, packages) in &self.0 { 33 | if packages.is_empty() { 34 | continue; 35 | } 36 | 37 | backend 38 | .install_packages(packages, noconfirm) 39 | .with_context(|| format!("installing packages for {backend}"))?; 40 | } 41 | Ok(()) 42 | } 43 | 44 | pub fn remove_unmanaged_packages(&self, noconfirm: bool) -> Result<()> { 45 | for (backend, packages) in &self.0 { 46 | if packages.is_empty() { 47 | continue; 48 | } 49 | 50 | backend 51 | .remove_packages(packages, noconfirm) 52 | .with_context(|| format!("removing packages for {backend}"))?; 53 | } 54 | Ok(()) 55 | } 56 | 57 | pub fn show(&self) -> Result<()> { 58 | let mut parts = vec![]; 59 | 60 | for (backend, packages) in self.iter() { 61 | if packages.is_empty() { 62 | continue; 63 | } 64 | 65 | let mut segment = String::new(); 66 | 67 | segment.write_str(&format!("[{backend}]"))?; 68 | for package in packages { 69 | segment.write_str(&format!("\n{package}"))?; 70 | } 71 | 72 | parts.push(segment); 73 | } 74 | 75 | let mut output = String::new(); 76 | let mut iter = parts.iter().peekable(); 77 | 78 | while let Some(part) = iter.next() { 79 | output.write_str(part)?; 80 | if iter.peek().is_some() { 81 | output.write_str("\n\n")?; 82 | } 83 | } 84 | 85 | println!("{output}"); 86 | 87 | Ok(()) 88 | } 89 | } 90 | impl Default for ToDoPerBackend { 91 | fn default() -> Self { 92 | Self::new() 93 | } 94 | } 95 | 96 | impl IntoIterator for ToDoPerBackend { 97 | type Item = (AnyBackend, Packages); 98 | 99 | type IntoIter = std::vec::IntoIter; 100 | 101 | fn into_iter(self) -> Self::IntoIter { 102 | self.0.into_iter() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /crates/pacdef/src/cli.rs: -------------------------------------------------------------------------------- 1 | //! The clap declarative command line interface 2 | 3 | use std::path::PathBuf; 4 | 5 | use clap::{Args, Parser, Subcommand}; 6 | 7 | #[derive(Parser)] 8 | #[command( 9 | version, 10 | author, 11 | arg_required_else_help(true), 12 | subcommand_required(true), 13 | disable_help_subcommand(true), 14 | disable_version_flag(true) 15 | )] 16 | /// multi-backend declarative package manager for Linux 17 | pub struct MainArguments { 18 | #[command(subcommand)] 19 | pub subcommand: MainSubcommand, 20 | } 21 | 22 | #[derive(Subcommand)] 23 | pub enum MainSubcommand { 24 | Group(GroupArguments), 25 | Package(PackageArguments), 26 | Version(VersionArguments), 27 | } 28 | 29 | #[derive(Args)] 30 | #[command( 31 | arg_required_else_help(true), 32 | visible_alias("g"), 33 | subcommand_required(true) 34 | )] 35 | /// manage groups 36 | pub struct GroupArguments { 37 | #[command(subcommand)] 38 | pub group_action: GroupAction, 39 | } 40 | 41 | #[derive(Subcommand)] 42 | pub enum GroupAction { 43 | Edit(EditGroupAction), 44 | Export(ExportGroupAction), 45 | Import(ImportGroupAction), 46 | List(ListGroupAction), 47 | New(NewGroupAction), 48 | Remove(RemoveGroupAction), 49 | Show(ShowGroupAction), 50 | } 51 | 52 | #[derive(Args)] 53 | #[command(arg_required_else_help(true), visible_alias("ed"))] 54 | /// edit one or more existing group 55 | pub struct EditGroupAction { 56 | #[arg(required(true), num_args(1..))] 57 | /// a previously imported group 58 | pub edit_groups: Vec, 59 | } 60 | 61 | #[derive(Args)] 62 | #[command(arg_required_else_help(true), visible_alias("ex"))] 63 | /// export one or more group files 64 | pub struct ExportGroupAction { 65 | #[arg(required(true), num_args(1..))] 66 | /// the file to export as group 67 | pub export_groups: Vec, 68 | 69 | #[arg(short, long)] 70 | /// (optional) the directory under which to save the group 71 | pub output_dir: Option, 72 | 73 | #[arg(short, long)] 74 | /// overwrite output files if they exist 75 | pub force: bool, 76 | } 77 | 78 | #[derive(Args)] 79 | #[command(arg_required_else_help(true), visible_alias("i"))] 80 | /// import one or more group files 81 | pub struct ImportGroupAction { 82 | #[arg(required(true), num_args(1..))] 83 | /// the file to import as group 84 | pub import_groups: Vec, 85 | } 86 | 87 | #[derive(Args)] 88 | #[command(visible_alias("l"))] 89 | /// list names of imported groups 90 | pub struct ListGroupAction {} 91 | 92 | #[derive(Args)] 93 | #[command(arg_required_else_help(true), visible_alias("n"))] 94 | /// create new group files 95 | pub struct NewGroupAction { 96 | #[arg(required(true), num_args(1..))] 97 | /// the groups to create 98 | pub new_groups: Vec, 99 | 100 | #[arg(short, long)] 101 | /// edit the new group files after creation 102 | pub edit: bool, 103 | } 104 | 105 | #[derive(Args)] 106 | #[command(arg_required_else_help(true), visible_alias("r"))] 107 | /// remove one or more previously imported groups 108 | pub struct RemoveGroupAction { 109 | #[arg(required(true), num_args(1..))] 110 | /// a previously imported group that will be removed 111 | pub remove_groups: Vec, 112 | } 113 | 114 | #[derive(Args)] 115 | #[command(arg_required_else_help(true), visible_alias("s"))] 116 | /// show packages under an imported group 117 | pub struct ShowGroupAction { 118 | #[arg(required(true), num_args(1..))] 119 | /// group file(s) to show 120 | pub show_groups: Vec, 121 | } 122 | 123 | #[derive(Args)] 124 | #[command( 125 | arg_required_else_help(true), 126 | subcommand_required(true), 127 | visible_alias("p") 128 | )] 129 | /// manage packages 130 | pub struct PackageArguments { 131 | #[command(subcommand)] 132 | pub package_action: PackageAction, 133 | } 134 | 135 | #[derive(Subcommand)] 136 | pub enum PackageAction { 137 | Clean(CleanPackageAction), 138 | Review(ReviewPackageAction), 139 | Search(SearchPackageAction), 140 | Sync(SyncPackageAction), 141 | Unmanaged(UnmanagedPackageAction), 142 | } 143 | 144 | #[derive(Args)] 145 | #[command(visible_alias("c"))] 146 | /// remove unmanaged packages 147 | pub struct CleanPackageAction { 148 | #[arg(long)] 149 | /// do not ask for any confirmation 150 | pub no_confirm: bool, 151 | } 152 | 153 | #[derive(Args)] 154 | #[command(visible_alias("r"))] 155 | /// review unmanaged packages 156 | pub struct ReviewPackageAction {} 157 | 158 | #[derive(Args)] 159 | #[command(arg_required_else_help(true), visible_alias("se"))] 160 | /// search for packages which match a provided regex 161 | pub struct SearchPackageAction { 162 | #[arg(required(true))] 163 | /// the regular expression the package must match 164 | pub regex: String, 165 | } 166 | 167 | #[derive(Args)] 168 | #[command(visible_alias("sy"))] 169 | /// install packages from all imported groups 170 | pub struct SyncPackageAction { 171 | #[arg(long)] 172 | /// do not ask for any confirmation 173 | pub no_confirm: bool, 174 | } 175 | 176 | #[derive(Args)] 177 | #[command(visible_alias("u"))] 178 | /// show explicitly installed packages not managed by pacdef 179 | pub struct UnmanagedPackageAction {} 180 | 181 | #[derive(Args)] 182 | pub struct VersionArguments {} 183 | -------------------------------------------------------------------------------- /crates/pacdef/src/cmd.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::process::Command; 3 | 4 | use anyhow::{ensure, Context, Result}; 5 | 6 | use crate::env::{get_editor, should_print_debug_info}; 7 | 8 | /// Run the editor and pass the provided files as arguments. The workdir is set 9 | /// to the parent of the first file. 10 | pub fn run_edit_command

(files: &[P]) -> Result<()> 11 | where 12 | P: AsRef, 13 | { 14 | fn inner(files: &[&Path]) -> Result<()> { 15 | let mut cmd = Command::new(get_editor().context("getting suitable editor")?); 16 | cmd.current_dir( 17 | files[0] 18 | .parent() 19 | .context("getting parent dir of first file argument")?, 20 | ); 21 | for f in files { 22 | cmd.arg(f.to_string_lossy().to_string()); 23 | } 24 | run_external_command(cmd) 25 | } 26 | 27 | let files: Vec<_> = files.iter().map(|p| p.as_ref()).collect(); 28 | inner(&files) 29 | } 30 | 31 | /// Run an external command. Use the anyhow framework to bubble up errors if they occur. Will print 32 | /// the full command to be executed when pacdef is in debug mode. 33 | /// 34 | /// # Errors 35 | /// 36 | /// This function will return an error if the command cannot be run or if it returns a non-zero 37 | /// exit status. In case of an error the full command will be part of the error message. 38 | pub fn run_external_command(mut cmd: Command) -> Result<()> { 39 | if should_print_debug_info() { 40 | println!("will run the following command"); 41 | dbg!(&cmd); 42 | } 43 | 44 | let exit_status = cmd 45 | .status() 46 | .with_context(|| format!("running command [{cmd:?}]"))?; 47 | 48 | let success = exit_status.success(); 49 | ensure!( 50 | success, 51 | "command [{cmd:?}] returned non-zero exit status {success}" 52 | ); 53 | Ok(()) 54 | } 55 | -------------------------------------------------------------------------------- /crates/pacdef/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{create_dir_all, read_to_string, File}; 2 | use std::io::{ErrorKind, Write}; 3 | use std::path::Path; 4 | 5 | use anyhow::{bail, Context, Result}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use crate::prelude::*; 9 | 10 | // Update the master README if fields change. 11 | /// Config for the program, as listed in `$XDG_CONFIG_HOME/pacdef/pacdef.toml`. 12 | #[derive(Debug, Serialize, Deserialize)] 13 | pub struct Config { 14 | /// The AUR helper to use for Arch Linux. 15 | #[serde(default = "aur_helper")] 16 | pub aur_helper: String, 17 | /// Additional arguments to pass to `aur_helper` when removing a package. 18 | #[serde(default)] 19 | pub aur_rm_args: Vec, 20 | /// Install Flatpak packages system-wide 21 | #[serde(default = "yes")] 22 | pub flatpak_systemwide: bool, 23 | /// Warn the user when a group is not a symlink. 24 | #[serde(default = "yes")] 25 | pub warn_not_symlinks: bool, 26 | /// Backends the user does not want to use even though the binary exists. 27 | #[serde(default)] 28 | pub disabled_backends: Vec, 29 | /// Choose whether to use pipx instead of pip for python package management 30 | #[serde(default = "pip")] 31 | pub pip_binary: String, 32 | } 33 | 34 | fn yes() -> bool { 35 | true 36 | } 37 | 38 | fn aur_helper() -> String { 39 | "paru".into() 40 | } 41 | 42 | fn pip() -> String { 43 | "pip".into() 44 | } 45 | 46 | impl Config { 47 | /// Load the config from the associated file. 48 | /// 49 | /// # Errors 50 | /// 51 | /// This function will return an error if the config file exists but cannot be 52 | /// read, its contents are not UTF-8, or the file is malformed. 53 | pub fn load(config_file: &Path) -> Result { 54 | let from_file = read_to_string(config_file); 55 | 56 | let content = match from_file { 57 | Ok(content) => content, 58 | Err(e) => { 59 | if e.kind() == ErrorKind::NotFound { 60 | bail!(Error::ConfigFileNotFound) 61 | } 62 | bail!("unexpected error occurred: {e:?}"); 63 | } 64 | }; 65 | 66 | toml::from_str(&content).context("parsing toml config") 67 | } 68 | 69 | /// Save the instance of [`Config`] to disk. 70 | /// 71 | /// # Errors 72 | /// 73 | /// This function will return an error if the config file cannot be saved to disk. 74 | pub fn save(&self, file: &Path) -> Result<()> { 75 | let content = toml::to_string(&self).context("converting Config to toml")?; 76 | 77 | let parent = file.parent().context("getting parent of config dir")?; 78 | if !parent.is_dir() { 79 | create_dir_all(parent) 80 | .with_context(|| format!("creating dir {}", parent.to_string_lossy()))?; 81 | } 82 | 83 | let mut output = File::create(file).context("creating default config file")?; 84 | write!(output, "{content}").context("writing default config")?; 85 | 86 | Ok(()) 87 | } 88 | } 89 | 90 | impl Default for Config { 91 | fn default() -> Self { 92 | Self { 93 | aur_helper: "paru".into(), 94 | aur_rm_args: vec![], 95 | flatpak_systemwide: true, 96 | warn_not_symlinks: true, 97 | disabled_backends: vec![], 98 | pip_binary: "pip".into(), 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /crates/pacdef/src/core.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::env::current_dir; 3 | use std::fs::{copy, create_dir_all, remove_file, rename, File}; 4 | use std::os::unix::fs::symlink; 5 | use std::path::{Path, PathBuf}; 6 | use std::process::Command; 7 | 8 | use anyhow::{bail, ensure, Context, Result}; 9 | use const_format::formatcp; 10 | 11 | use crate::cmd::{run_edit_command, run_external_command}; 12 | use crate::env::{get_editor, should_print_debug_info}; 13 | use crate::grouping::group::groups_to_backend_packages; 14 | use crate::path::{binary_in_path, get_absolutized_file_paths, get_group_dir}; 15 | use crate::prelude::*; 16 | use crate::review::review; 17 | use crate::search::search_packages; 18 | use crate::ui::get_user_confirmation; 19 | 20 | impl MainArguments { 21 | /// Run the action that was provided by the user as first argument. 22 | /// 23 | /// For convenience sake, all called functions take a `&self` argument, even if 24 | /// these are not strictly required. 25 | /// 26 | /// # Errors 27 | /// 28 | /// This function propagates errors from the underlying functions. 29 | pub fn run(self, groups: &Groups, config: &Config) -> Result<()> { 30 | match self.subcommand { 31 | MainSubcommand::Group(group) => group.run(groups), 32 | MainSubcommand::Package(package) => package.run(groups, config), 33 | MainSubcommand::Version(version) => version.run(config), 34 | } 35 | } 36 | } 37 | 38 | impl VersionArguments { 39 | /// If the crate was compiled from git, return `pacdef, ()`. 40 | /// Otherwise return `pacdef, `. 41 | fn run(self, config: &Config) -> Result<()> { 42 | let backends = get_included_backends(config); 43 | let mut result = format!("pacdef, version: {}\n", get_version_string()); 44 | result.push_str("supported backends:"); 45 | for b in backends { 46 | result.push_str("\n "); 47 | result.push_str(b); 48 | } 49 | 50 | println!("{}", result); 51 | 52 | Ok(()) 53 | } 54 | } 55 | 56 | impl GroupArguments { 57 | fn run(self, groups: &Groups) -> Result<()> { 58 | match self.group_action { 59 | GroupAction::Edit(edit) => edit.run(groups), 60 | GroupAction::Export(export) => export.run(groups), 61 | GroupAction::Import(import) => import.run(), 62 | GroupAction::List(list) => list.run(groups), 63 | GroupAction::New(new) => new.run(), 64 | GroupAction::Remove(remove) => remove.run(groups), 65 | GroupAction::Show(show) => show.run(groups), 66 | } 67 | } 68 | } 69 | 70 | impl EditGroupAction { 71 | fn run(self, groups: &Groups) -> Result<()> { 72 | let group_files: Vec<_> = find_groups_by_name(&self.edit_groups, groups) 73 | .context("getting group files for args")? 74 | .into_iter() 75 | .map(|g| g.path.as_path()) 76 | .collect(); 77 | 78 | let mut cmd = Command::new(get_editor().context("getting suitable editor")?); 79 | cmd.current_dir( 80 | group_files[0] 81 | .parent() 82 | .context("getting parent dir of first file argument")?, 83 | ); 84 | for group_file in group_files { 85 | cmd.arg(group_file.to_string_lossy().to_string()); 86 | } 87 | run_external_command(cmd)?; 88 | 89 | Ok(()) 90 | } 91 | } 92 | 93 | impl ExportGroupAction { 94 | /// Export pacdef groups by moving a group file to an output dir. The path of the 95 | /// group file relative to the group base dir will be replicated under the output 96 | /// directory. 97 | /// 98 | /// By default, the output dir is the current working directory. `output_dir` may be 99 | /// specified to the path of another directory, in which case `output_dir` must 100 | /// exist. 101 | /// 102 | /// If `force` is `true`, the output file will be overwritten if it exists. 103 | /// 104 | /// # Errors 105 | /// 106 | /// This function will return an error if 107 | /// - the group file is a symlink (in which case exporting makes no sense), 108 | /// - the output file exists and `force` is not `true`, or 109 | /// - the user does not have permission to write to the output dir. 110 | /// 111 | /// # Limitations 112 | /// 113 | /// At the moment we cannot export nested group dirs. The user would have to 114 | /// export every group file individually, or use a shell glob. 115 | fn run(self, groups: &Groups) -> Result<()> { 116 | let groups = find_groups_by_name(&self.export_groups, groups)?; 117 | let output_dir = match self.output_dir { 118 | Some(p) => p, 119 | None => current_dir().context("no output dir specified, getting current directory")?, 120 | }; 121 | 122 | ensure!( 123 | output_dir.exists() && output_dir.is_dir(), 124 | "output must be a directory and exist" 125 | ); 126 | 127 | for group in &groups { 128 | ensure!(!&group.path.is_symlink(), "cannot export symlinks"); 129 | 130 | let mut exported_path = output_dir.clone(); 131 | exported_path.push(PathBuf::from(&group.name)); 132 | 133 | ensure!( 134 | !self.force && !exported_path.exists(), 135 | "{exported_path:?} already exists" 136 | ); 137 | 138 | create_parent(&exported_path) 139 | .with_context(|| format!("creating parent dir of {exported_path:?}"))?; 140 | move_file(&group.path, &exported_path).context("moving file")?; 141 | symlink(&exported_path, &group.path).context("creating symlink to exported file")?; 142 | } 143 | 144 | Ok(()) 145 | } 146 | } 147 | 148 | impl ImportGroupAction { 149 | fn run(self) -> Result<()> { 150 | let files = get_absolutized_file_paths(&self.import_groups)?; 151 | let groups_dir = get_group_dir()?; 152 | 153 | for target in files { 154 | let target_name = target 155 | .file_name() 156 | .context("path should not end in '..'")? 157 | .to_str() 158 | .context("filename is not valid UTF-8")?; 159 | 160 | if !target.exists() { 161 | log::warn!("file {target_name} does not exist, skipping"); 162 | continue; 163 | } 164 | 165 | let mut link = groups_dir.clone(); 166 | link.push(target_name); 167 | 168 | if link.exists() { 169 | log::warn!("group {target_name} already exists, skipping"); 170 | } else { 171 | symlink(target, link)?; 172 | } 173 | } 174 | 175 | Ok(()) 176 | } 177 | } 178 | 179 | impl ListGroupAction { 180 | /// Print the alphabetically sorted names of all groups to stdout. 181 | /// 182 | /// This methods cannot return an error. It returns a `Result` to be consistent 183 | /// with other methods. 184 | fn run(self, groups: &Groups) -> Result<()> { 185 | let mut vec: Vec<_> = groups.iter().collect(); 186 | vec.sort_unstable(); 187 | for g in vec { 188 | println!("{}", g.name); 189 | } 190 | 191 | Ok(()) 192 | } 193 | } 194 | 195 | impl NewGroupAction { 196 | /// Create empty group files. 197 | /// 198 | /// If `edit` is `true`, the editor will be run to edit the files after they are 199 | /// created. 200 | /// 201 | /// # Errors 202 | /// 203 | /// This function will return an error if 204 | /// - a group name is `.` or `..`, 205 | /// - a group with the same name already exists, 206 | /// - the editor cannot be run, or 207 | /// - if we do not have permission to write to the group dir. 208 | fn run(&self) -> Result<()> { 209 | let group_path = get_group_dir()?; 210 | 211 | // prevent group names that resolve to directories 212 | for new_group in &self.new_groups { 213 | ensure!( 214 | new_group != "." && new_group != "..", 215 | Error::InvalidGroupName(new_group.clone()) 216 | ); 217 | } 218 | 219 | let paths: Vec<_> = self 220 | .new_groups 221 | .iter() 222 | .map(|name| { 223 | let mut base = group_path.clone(); 224 | base.push(name); 225 | base 226 | }) 227 | .collect(); 228 | 229 | for file in &paths { 230 | ensure!(!file.exists(), Error::GroupAlreadyExists(file.clone())); 231 | } 232 | 233 | for file in &paths { 234 | File::create(file)?; 235 | } 236 | 237 | if self.edit { 238 | run_edit_command(&paths).context("running editor")?; 239 | } 240 | 241 | Ok(()) 242 | } 243 | } 244 | 245 | impl RemoveGroupAction { 246 | fn run(self, groups: &Groups) -> Result<()> { 247 | let found = find_groups_by_name(&self.remove_groups, groups)?; 248 | 249 | for group in found { 250 | remove_file(&group.path)?; 251 | } 252 | 253 | Ok(()) 254 | } 255 | } 256 | 257 | impl ShowGroupAction { 258 | fn run(self, groups: &Groups) -> Result<()> { 259 | let mut errors = vec![]; 260 | let mut found_groups = vec![]; 261 | 262 | // make sure all args exist before doing anything 263 | for show_group in &self.show_groups { 264 | let possible_group = groups.iter().find(|group| group.name == *show_group); 265 | 266 | let Some(group) = possible_group else { 267 | errors.push(show_group.to_string()); 268 | continue; 269 | }; 270 | 271 | found_groups.push(group); 272 | } 273 | 274 | // return an error if any arg was not found 275 | ensure!(errors.is_empty(), Error::MultipleGroupsNotFound(errors)); 276 | 277 | let show_more_than_one_group = self.show_groups.len() > 1; 278 | 279 | let mut iter = found_groups.into_iter().peekable(); 280 | 281 | while let Some(group) = iter.next() { 282 | if show_more_than_one_group { 283 | let name = &group.name; 284 | println!("{name}"); 285 | for _ in 0..name.len() { 286 | print!("-"); 287 | } 288 | println!(); 289 | } 290 | 291 | println!("{group}"); 292 | if iter.peek().is_some() { 293 | println!(); 294 | } 295 | } 296 | 297 | Ok(()) 298 | } 299 | } 300 | 301 | impl PackageArguments { 302 | fn run(self, groups: &Groups, config: &Config) -> Result<()> { 303 | match self.package_action { 304 | PackageAction::Clean(clean) => clean.run(groups, config), 305 | PackageAction::Review(review) => review.run(groups, config), 306 | PackageAction::Search(search) => search.run(groups), 307 | PackageAction::Sync(sync) => sync.run(groups, config), 308 | PackageAction::Unmanaged(unmanaged) => unmanaged.run(groups, config), 309 | } 310 | } 311 | } 312 | 313 | impl CleanPackageAction { 314 | fn run(self, groups: &Groups, config: &Config) -> Result<()> { 315 | let to_remove = get_unmanaged_packages(groups, config)?; 316 | 317 | if to_remove.nothing_to_do_for_all_backends() { 318 | println!("nothing to do"); 319 | return Ok(()); 320 | } 321 | 322 | println!("Would remove the following packages:\n"); 323 | to_remove.show().context("printing things to do")?; 324 | 325 | println!(); 326 | if self.no_confirm { 327 | println!("proceeding without confirmation"); 328 | } else if !get_user_confirmation()? { 329 | return Ok(()); 330 | } 331 | 332 | to_remove.remove_unmanaged_packages(self.no_confirm) 333 | } 334 | } 335 | 336 | impl ReviewPackageAction { 337 | fn run(self, groups: &Groups, config: &Config) -> Result<()> { 338 | review(get_unmanaged_packages(groups, config)?, groups) 339 | } 340 | } 341 | 342 | impl SearchPackageAction { 343 | fn run(self, groups: &Groups) -> Result<()> { 344 | search_packages(&self.regex, groups) 345 | } 346 | } 347 | 348 | impl SyncPackageAction { 349 | fn run(self, groups: &Groups, config: &Config) -> Result<()> { 350 | let to_install = get_missing_packages(groups, config)?; 351 | 352 | if to_install.nothing_to_do_for_all_backends() { 353 | println!("nothing to do"); 354 | return Ok(()); 355 | } 356 | 357 | println!("Would install the following packages:\n"); 358 | to_install.show().context("printing things to do")?; 359 | 360 | println!(); 361 | if self.no_confirm { 362 | println!("proceeding without confirmation"); 363 | } else if !get_user_confirmation()? { 364 | return Ok(()); 365 | } 366 | 367 | to_install.install_missing_packages(self.no_confirm) 368 | } 369 | } 370 | 371 | impl UnmanagedPackageAction { 372 | fn run(self, groups: &Groups, config: &Config) -> Result<()> { 373 | let unmanaged_per_backend = &get_unmanaged_packages(groups, config)?; 374 | 375 | if unmanaged_per_backend.nothing_to_do_for_all_backends() { 376 | return Ok(()); 377 | } 378 | 379 | unmanaged_per_backend 380 | .show() 381 | .context("printing things to do") 382 | } 383 | } 384 | 385 | fn get_missing_packages(groups: &Groups, config: &Config) -> Result { 386 | let backend_packages = groups_to_backend_packages(groups, config)?; 387 | 388 | let mut to_install = ToDoPerBackend::new(); 389 | 390 | for (any_backend, packages) in &backend_packages { 391 | let backend_info = any_backend.backend_info(); 392 | 393 | if config 394 | .disabled_backends 395 | .contains(&backend_info.section.to_string()) 396 | { 397 | continue; 398 | } 399 | 400 | if !binary_in_path(&backend_info.binary)? { 401 | continue; 402 | } 403 | 404 | let managed_backend = ManagedBackend { 405 | packages: packages.clone(), 406 | any_backend: any_backend.clone(), 407 | }; 408 | 409 | match managed_backend.get_missing_packages_sorted() { 410 | Ok(diff) => to_install.push((any_backend.clone(), diff)), 411 | Err(error) => show_backend_query_error(&error, any_backend), 412 | }; 413 | } 414 | 415 | Ok(to_install) 416 | } 417 | 418 | /// Get a list of unmanaged packages per backend. 419 | /// 420 | /// This method loops through all enabled `Backend`s whose binary is in `PATH`. 421 | /// 422 | /// # Errors 423 | /// 424 | /// This function will propagate errors from the individual backends. 425 | fn get_unmanaged_packages(groups: &Groups, config: &Config) -> Result { 426 | let backend_packages = groups_to_backend_packages(groups, config)?; 427 | 428 | let mut todo_unmanaged = ToDoPerBackend::new(); 429 | 430 | for (any_backend, packages) in &backend_packages { 431 | let backend_info = any_backend.backend_info(); 432 | if config 433 | .disabled_backends 434 | .contains(&backend_info.section.to_string()) 435 | { 436 | continue; 437 | } 438 | 439 | if !binary_in_path(&backend_info.binary)? { 440 | continue; 441 | } 442 | 443 | let managed_backend = ManagedBackend { 444 | packages: packages.clone(), 445 | any_backend: any_backend.clone(), 446 | }; 447 | 448 | match managed_backend.get_unmanaged_packages_sorted() { 449 | Ok(unmanaged) => todo_unmanaged.push((any_backend.clone(), unmanaged)), 450 | Err(error) => show_backend_query_error(&error, any_backend), 451 | }; 452 | } 453 | 454 | Ok(todo_unmanaged) 455 | } 456 | 457 | /// Create the parent directory of the `path` if that directory does not exist. 458 | /// 459 | /// Do nothing otherwise. 460 | /// 461 | /// # Panics 462 | /// 463 | /// Panics if the path does not have a parent. 464 | /// 465 | /// # Errors 466 | /// 467 | /// This function will propagate errors from [`std::fs::create_dir_all`]. 468 | fn create_parent(path: &Path) -> Result<()> { 469 | let parent = &path.parent().expect("this should never be /"); 470 | if !parent.is_dir() { 471 | create_dir_all(parent).context("creating parent dir")?; 472 | } 473 | Ok(()) 474 | } 475 | 476 | /// Move a file from one place to another. 477 | /// 478 | /// At first [`std::fs::rename`] is used, which fails if `from` and `to` reside under 479 | /// different filesystems. In case that happens, we will resort to copying the files 480 | /// and then removing `from`. 481 | /// 482 | /// # Errors 483 | /// 484 | /// This function will return an error if we lack permission to write the file. 485 | fn move_file(from: P, to: Q) -> Result<()> 486 | where 487 | P: AsRef, 488 | Q: AsRef, 489 | { 490 | let from = from.as_ref(); 491 | let to = to.as_ref(); 492 | match rename(from, to) { 493 | Ok(_) => (), 494 | Err(e) => { 495 | // CrossesDevices is nightly. See rust #86442. 496 | // We cannot check that here, so we just assume that 497 | // that would be the error if permissions are okay. 498 | if e.kind() == std::io::ErrorKind::PermissionDenied { 499 | bail!(e); 500 | } 501 | copy(from, to).with_context(|| format!("copying {from:?} to {to:?}"))?; 502 | remove_file(from).with_context(|| format!("deleting {from:?}"))?; 503 | } 504 | }; 505 | Ok(()) 506 | } 507 | 508 | /// For the provided names, get the group with the same name. 509 | /// 510 | /// # Errors 511 | /// 512 | /// This function will return an error if any of the file names do not match one 513 | /// of group names. 514 | fn find_groups_by_name<'a>(names: &[String], groups: &'a Groups) -> Result> { 515 | let name_group_map: HashMap<&str, &Group> = 516 | groups.iter().map(|g| (g.name.as_str(), g)).collect(); 517 | 518 | let mut result = Vec::new(); 519 | 520 | for file in names { 521 | match name_group_map.get(file.as_str()) { 522 | Some(group) => { 523 | result.push(*group); 524 | } 525 | None => bail!(Error::GroupFileNotFound(file.clone())), 526 | } 527 | } 528 | 529 | Ok(result) 530 | } 531 | 532 | /// Show the error chain for an error that has occurred when a backend was queried 533 | /// if the `RUST_BACKTRACE` env variable is set to `1` or `full`. 534 | fn show_backend_query_error(error: &anyhow::Error, backend: &AnyBackend) { 535 | if should_print_debug_info() { 536 | log::warn!( 537 | "skipping backend '{backend}': {}", 538 | error.chain().map(|x| x.to_string()).collect::() 539 | ); 540 | } else { 541 | log::warn!("skipping backend '{backend}': {error}"); 542 | } 543 | } 544 | 545 | /// If the crate was compiled from git, return ` ()`. Otherwise 546 | /// return ``. 547 | pub const fn get_version_string() -> &'static str { 548 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 549 | const HASH: &str = env!("GIT_HASH"); 550 | 551 | if HASH.is_empty() { 552 | VERSION 553 | } else { 554 | formatcp!("{VERSION} ({HASH})") 555 | } 556 | } 557 | 558 | /// Get a vector with the names of all backends, sorted alphabetically. 559 | fn get_included_backends(config: &Config) -> Vec<&'static str> { 560 | let mut result = vec![]; 561 | for backend in AnyBackend::all(config) { 562 | result.push(backend.backend_info().section); 563 | } 564 | result.sort_unstable(); 565 | result 566 | } 567 | -------------------------------------------------------------------------------- /crates/pacdef/src/env.rs: -------------------------------------------------------------------------------- 1 | use std::env::var; 2 | 3 | use anyhow::{anyhow, Result}; 4 | 5 | pub fn get_editor() -> Result { 6 | check_vars_in_order(&["EDITOR", "VISUAL"]).ok_or_else(|| anyhow!("could not find editor")) 7 | } 8 | 9 | fn check_vars_in_order(vars: &[&str]) -> Option { 10 | vars.iter().find_map(|v| var(v).ok()) 11 | } 12 | 13 | fn get_single_var(variable: &str) -> Option { 14 | var(variable).ok() 15 | } 16 | 17 | /// Determine if debug information should be printed. Will return `true` if RUST_BACKTRACE equals 18 | /// "s" or "full". 19 | pub fn should_print_debug_info() -> bool { 20 | match get_single_var("RUST_BACKTRACE") { 21 | Some(value) if ["s", "full"].contains(&value.as_str()) => true, 22 | Some(_) => false, 23 | None => false, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /crates/pacdef/src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error as ErrorTrait; 2 | use std::fmt::Display; 3 | use std::path::PathBuf; 4 | 5 | /// Error types for pacdef. 6 | #[derive(Debug)] 7 | #[non_exhaustive] 8 | pub enum Error { 9 | /// Package search yields no results. 10 | NoPackagesFound, 11 | /// Config file not found. 12 | ConfigFileNotFound, 13 | /// Group file not found. 14 | GroupFileNotFound(String), 15 | /// Group already exists. 16 | GroupAlreadyExists(PathBuf), 17 | /// Invalid group name ('.' or '..') 18 | InvalidGroupName(String), 19 | /// Multiple groups not found. 20 | MultipleGroupsNotFound(Vec), 21 | } 22 | 23 | impl Display for Error { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match self { 26 | Self::NoPackagesFound => write!(f, "no packages matching query"), 27 | Self::ConfigFileNotFound => write!(f, "config file not found"), 28 | Self::GroupFileNotFound(name) => write!(f, "group file '{name}' not found"), 29 | Self::GroupAlreadyExists(path) => { 30 | write!(f, "group file '{}' already exists", path.to_string_lossy()) 31 | } 32 | Self::InvalidGroupName(name) => write!(f, "group name '{name}' is not valid"), 33 | Self::MultipleGroupsNotFound(vec) => { 34 | write!( 35 | f, 36 | "could not find the following groups: [{}]", 37 | vec.join(", ") 38 | ) 39 | } 40 | } 41 | } 42 | } 43 | 44 | impl ErrorTrait for Error {} 45 | -------------------------------------------------------------------------------- /crates/pacdef/src/grouping/group.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, BTreeSet}; 2 | use std::fmt::Display; 3 | use std::fs::{create_dir, read_to_string, File}; 4 | use std::hash::Hash; 5 | use std::io::Write; 6 | use std::path::{Path, PathBuf}; 7 | 8 | use anyhow::{Context, Result}; 9 | use path_absolutize::Absolutize; 10 | use walkdir::WalkDir; 11 | 12 | use crate::path::get_relative_path; 13 | 14 | use crate::prelude::*; 15 | 16 | /// A set of groups 17 | pub type Groups = BTreeSet; 18 | 19 | pub type BackendPackages = BTreeMap; 20 | pub fn groups_to_backend_packages(groups: &Groups, config: &Config) -> Result { 21 | let mut backend_packages = BackendPackages::new(); 22 | 23 | for group in groups { 24 | for section in &group.sections { 25 | backend_packages 26 | .entry(AnyBackend::from_section(§ion.name, config)?) 27 | .or_default() 28 | .extend(section.packages.iter().cloned()); 29 | } 30 | } 31 | 32 | Ok(backend_packages) 33 | } 34 | 35 | /// Representation of a group file. 36 | #[derive(Debug, Clone)] 37 | pub struct Group { 38 | /// Name of the group (file name from which it was read, relative to the group 39 | /// base dir). 40 | pub name: String, 41 | /// The sections in the file which in turn hold the packages. 42 | pub sections: Sections, 43 | /// The absolute path of the original file. 44 | pub path: PathBuf, 45 | /// Whether the main program should warn this group being loaded from a symlink. 46 | pub warn_symlink: bool, 47 | } 48 | 49 | impl Group { 50 | /// Load all group files from the pacdef group dir by traversing through the group dir. 51 | /// 52 | /// This method will print a warning if `warn_not_symlinks` is true and a group 53 | /// file is not a symlink or does not reside under a symlink dir. 54 | /// 55 | /// # Errors 56 | /// 57 | /// This function will return an error if any of the files under `group_dir` cannot 58 | /// be accessed. 59 | pub fn load(group_dir: &Path, warn_not_symlinks: bool) -> Result { 60 | let mut result = Groups::new(); 61 | 62 | if !group_dir.is_dir() { 63 | // we only need to create the innermost dir. The rest was already created from when 64 | // we loaded the config 65 | create_dir(group_dir).context("group dir does not exist, creating")?; 66 | } 67 | 68 | let mut symlink_dirs = Vec::new(); 69 | 70 | for entry in WalkDir::new(group_dir).follow_links(true).min_depth(1) { 71 | let file = entry?; 72 | let path = file.path().absolutize_from(group_dir)?.to_path_buf(); 73 | 74 | if path.is_dir() { 75 | if warn_not_symlinks && path.is_symlink() { 76 | symlink_dirs.push(path); 77 | } 78 | continue; 79 | } 80 | 81 | let should_warn_about_symlinks = warn_not_symlinks 82 | && !path.is_symlink() 83 | && !is_child_of_any_dir(&path, &symlink_dirs); 84 | 85 | let group = Self::try_from(path.as_path(), group_dir, should_warn_about_symlinks) 86 | .with_context(|| format!("reading group file {path:?}"))?; 87 | 88 | result.insert(group); 89 | } 90 | 91 | Ok(result) 92 | } 93 | } 94 | 95 | /// Check if `path` is a child of any of the [`PathBuf`] in `dirs`. All paths should be 96 | /// absolute. 97 | fn is_child_of_any_dir(path: &Path, dirs: &[PathBuf]) -> bool { 98 | dirs.iter() 99 | // pair `path` with every item from `symlink_dirs` 100 | .zip([path].iter().cycle()) 101 | // for every pair, test if all path elements of the dir are present in the file path 102 | .map(|(dir, file)| { 103 | dir.iter() 104 | .zip(file.iter()) 105 | .map(|(dir_elem, file_elem)| dir_elem == file_elem) 106 | .all(|path_element_equal| path_element_equal) 107 | }) 108 | // it suffices if that holds for any of the generated pairs 109 | .any(|is_child| is_child) 110 | } 111 | 112 | impl PartialOrd for Group { 113 | fn partial_cmp(&self, other: &Self) -> Option { 114 | Some(self.cmp(other)) 115 | } 116 | } 117 | 118 | impl Ord for Group { 119 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 120 | self.name.cmp(&other.name) 121 | } 122 | } 123 | 124 | impl Hash for Group { 125 | fn hash(&self, state: &mut H) { 126 | self.name.hash(state); 127 | } 128 | } 129 | 130 | impl PartialEq for Group { 131 | fn eq(&self, other: &Self) -> bool { 132 | self.name == other.name 133 | } 134 | } 135 | 136 | impl Eq for Group { 137 | fn assert_receiver_is_total_eq(&self) {} 138 | } 139 | 140 | impl Group { 141 | /// Load the group from `path`. Determine the name from the path relative to the 142 | /// `group_dir`. 143 | /// 144 | /// # Warnings 145 | /// 146 | /// This function will print a warning if any section in the group file cannot 147 | /// be processed, or the file contains no sections. 148 | /// 149 | /// # Errors 150 | /// 151 | /// This function will return an error if the group file cannot be read. 152 | fn try_from

(path: P, group_dir: P, warn_symlink: bool) -> Result 153 | where 154 | P: AsRef, 155 | { 156 | let path = path.as_ref(); 157 | let content = read_to_string(path).context("reading file content")?; 158 | 159 | let name = extract_group_name(path, group_dir.as_ref()); 160 | 161 | let mut lines = content.lines().peekable(); 162 | let mut sections = Sections::new(); 163 | 164 | while lines.peek().is_some() { 165 | let result = Section::try_from_lines(&mut lines).context("reading section"); 166 | match result { 167 | Ok(section) => { 168 | sections.insert(section); 169 | } 170 | Err(e) => { 171 | let err = e.root_cause(); 172 | log::warn!("could not process a section under group '{name}': {err}"); 173 | } 174 | } 175 | } 176 | 177 | if sections.is_empty() { 178 | log::warn!("no sections found in group '{name}'"); 179 | } 180 | 181 | let path = path.into(); 182 | 183 | Ok(Self { 184 | name, 185 | sections, 186 | path, 187 | warn_symlink, 188 | }) 189 | } 190 | 191 | /// Add the new `packages` to the group file under the section `section_header`. If 192 | /// the section header does not yet exist, it is created. The packages are written 193 | /// in the provided order immediately after the header. 194 | /// 195 | /// # Errors 196 | /// 197 | /// This function returns an error if the group file cannot be read, or if the 198 | /// file cannot be written to. 199 | pub fn save_packages(&self, section_header: &str, packages: &Packages) -> Result<()> { 200 | let mut content = read_to_string(&self.path) 201 | .with_context(|| format!("reading existing file contents from {:?}", &self.path))?; 202 | 203 | if content.contains(section_header) { 204 | write_packages_to_existing_section(&mut content, section_header, packages) 205 | .context("existing section")?; 206 | } else { 207 | add_new_section_with_packages(&mut content, section_header, packages); 208 | } 209 | 210 | let mut file = File::create(&self.path) 211 | .with_context(|| format!("creating descriptor to output file {:?}", &self.path))?; 212 | 213 | write!(file, "{content}").with_context(|| format!("writing file {:?}", &self.path)) 214 | } 215 | } 216 | 217 | /// Extract the group name from its path relative to the group path. 218 | /// All subdirectories are concatenated using `'/'`. 219 | /// 220 | /// # Example 221 | /// 222 | /// If the group dir is `~/.config/pacdef/groups`, and the group file is 223 | /// `~/.config/pacdef/groups/generic/base`, then the group name is 224 | /// `"generic/base"`. 225 | /// 226 | /// # Panics 227 | /// 228 | /// Panics if `path` and `group_path` are identical. 229 | fn extract_group_name(path: &Path, group_path: &Path) -> String { 230 | get_relative_path(path, group_path) 231 | .iter() 232 | .map(|p| p.to_string_lossy().to_string()) 233 | .reduce(|mut a, b| { 234 | a.push('/'); 235 | a.push_str(&b); 236 | a 237 | }) 238 | .expect("must have at least one element") 239 | } 240 | 241 | impl Display for Group { 242 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 243 | let mut sections: Vec<_> = self.sections.iter().collect(); 244 | sections.sort_unstable(); 245 | 246 | let mut iter = sections.into_iter().peekable(); 247 | 248 | while let Some(section) = iter.next() { 249 | section.fmt(f)?; 250 | if iter.peek().is_some() { 251 | f.write_str("\n\n")?; 252 | } 253 | } 254 | Ok(()) 255 | } 256 | } 257 | 258 | /// Add some packages to an existing section in the content of a group file. 259 | /// 260 | /// # Errors 261 | /// 262 | /// This function will return an error if the header cannot be found in 263 | /// the file content. 264 | fn write_packages_to_existing_section( 265 | group_file_content: &mut String, 266 | section_header: &str, 267 | packages: &Packages, 268 | ) -> Result<()> { 269 | let idx_of_first_package_line_in_section = 270 | find_first_package_line_in_section(group_file_content, section_header)?; 271 | 272 | let after = group_file_content.split_off(idx_of_first_package_line_in_section); 273 | 274 | for p in packages { 275 | group_file_content.push_str(&format!("{p}\n")); 276 | } 277 | 278 | group_file_content.push_str(&after); 279 | Ok(()) 280 | } 281 | 282 | /// Find the index to the first line in `group_file_content` after the 283 | /// given `section_header`. 284 | /// 285 | /// # Errors 286 | /// 287 | /// This function will return an error if the `section_header` does not 288 | /// exist in `group_file_content`, or if the line containing the 289 | /// `section_header` is not newline-terminated. 290 | fn find_first_package_line_in_section( 291 | group_file_content: &str, 292 | section_header: &str, 293 | ) -> Result { 294 | let section_start = group_file_content 295 | .find(section_header) 296 | .context("finding first package after section header")?; 297 | 298 | let distance_to_next_newline = group_file_content[section_start..] 299 | .find('\n') 300 | .context("getting next newline")?; 301 | 302 | Ok(section_start + distance_to_next_newline + 1) // + 1 to be after the newline 303 | } 304 | 305 | /// Append a new section with some packages to the content of a group file. 306 | fn add_new_section_with_packages( 307 | group_file_content: &mut String, 308 | section_header: &str, 309 | packages: &Packages, 310 | ) { 311 | group_file_content.push('\n'); 312 | group_file_content.push_str(section_header); 313 | group_file_content.push('\n'); 314 | for p in packages { 315 | group_file_content.push_str(&format!("{p}\n")); 316 | } 317 | } 318 | 319 | #[cfg(test)] 320 | mod tests { 321 | use std::path::PathBuf; 322 | 323 | #[test] 324 | fn extract_group_name() { 325 | let path = PathBuf::from("/a/b/c/d/e"); 326 | let group_path = PathBuf::from("/a/b/c"); 327 | let expected = String::from("d/e"); 328 | 329 | let result = super::extract_group_name(&path, &group_path); 330 | assert_eq!(result, expected); 331 | } 332 | 333 | #[test] 334 | fn is_child_of_any_symlink_dir() { 335 | let path = PathBuf::from("/a/b/c/d/e"); 336 | let dir = PathBuf::from("/z"); 337 | let mut symlink_dirs = vec![dir]; 338 | 339 | let result = super::is_child_of_any_dir(&path, &symlink_dirs); 340 | assert!(!result); 341 | 342 | symlink_dirs.push(PathBuf::from("/a/b/c")); 343 | 344 | let result = super::is_child_of_any_dir(&path, &symlink_dirs); 345 | assert!(result); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /crates/pacdef/src/grouping/mod.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | This module reflects the relationship between groups, sections / backends and 3 | packages. 4 | 5 | A [`Group`] contains one (strictly spoken zero, but this doesn't make sense) or 6 | more [`Section`]s, which relate to individual backends. Each section contains 7 | one (strictly spoken zero) or more [`Package`]s. On start-up `pacdef` will load 8 | all groups using [`Group::load`], which in turn will get all packages from all 9 | sections. 10 | */ 11 | 12 | pub mod group; 13 | pub mod package; 14 | pub mod section; 15 | -------------------------------------------------------------------------------- /crates/pacdef/src/grouping/package.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | use std::collections::BTreeSet; 3 | use std::fmt::{Display, Write}; 4 | 5 | pub type Packages = BTreeSet; 6 | 7 | /// A struct to represent a single package, consisting of a `name`, and 8 | /// optionally a `repo`. 9 | #[derive(Debug, Clone)] 10 | pub struct Package { 11 | /// The name of the package 12 | pub name: String, 13 | /// Optionally, which repository the package belongs to 14 | pub repo: Option, 15 | } 16 | 17 | fn remove_comment_and_trim_whitespace(s: &str) -> &str { 18 | s.split('#') // remove comment 19 | .next() 20 | .expect("line contains something") 21 | .trim() // remove whitespace 22 | } 23 | 24 | impl From for Package { 25 | fn from(value: String) -> Self { 26 | let trimmed = remove_comment_and_trim_whitespace(&value); 27 | debug_assert!(!trimmed.is_empty(), "empty package names are not allowed"); 28 | 29 | let (name, repo) = Self::split_into_name_and_repo(trimmed); 30 | Self { name, repo } 31 | } 32 | } 33 | 34 | impl From<&str> for Package { 35 | fn from(value: &str) -> Self { 36 | Self::from(value.to_string()) 37 | } 38 | } 39 | 40 | impl Package { 41 | /// From a string that contains a package name, optionally prefixed by a 42 | /// repository, return the package name as well as the repository if it 43 | /// exists. 44 | /// 45 | /// # Panics 46 | /// 47 | /// Panics if `string` is empty. 48 | fn split_into_name_and_repo(string: &str) -> (String, Option) { 49 | if let Some((before, after)) = string.split_once('/') { 50 | (after.to_string(), Some(before.to_string())) 51 | } else { 52 | (string.to_string(), None) 53 | } 54 | } 55 | 56 | /// Try to parse a string (from a line in a group file) and return a package. 57 | /// From the string, any possible comment is removed and whitespace is trimmed.package 58 | /// Returns `None` if there is nothing left after trimming. 59 | pub fn try_from(s: S) -> Option 60 | where 61 | S: AsRef, 62 | { 63 | let trimmed = remove_comment_and_trim_whitespace(s.as_ref()); 64 | if trimmed.is_empty() { 65 | return None; 66 | } 67 | 68 | let (name, repo) = Self::split_into_name_and_repo(trimmed); 69 | Some(Self { name, repo }) 70 | } 71 | } 72 | 73 | impl PartialEq for Package { 74 | fn eq(&self, other: &Self) -> bool { 75 | self.cmp(other).is_eq() 76 | } 77 | } 78 | impl Eq for Package {} 79 | impl PartialOrd for Package { 80 | fn partial_cmp(&self, other: &Self) -> Option { 81 | Some(self.cmp(other)) 82 | } 83 | } 84 | impl Ord for Package { 85 | fn cmp(&self, other: &Self) -> Ordering { 86 | self.name 87 | .cmp(&other.name) 88 | .then(self.repo.as_ref().map_or(Ordering::Equal, |self_repo| { 89 | other 90 | .repo 91 | .as_ref() 92 | .map_or(Ordering::Equal, |other_repo| self_repo.cmp(other_repo)) 93 | })) 94 | } 95 | } 96 | 97 | impl Display for Package { 98 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 99 | match &self.repo { 100 | None => (), 101 | Some(repo) => { 102 | f.write_str(repo)?; 103 | f.write_char('/')?; 104 | } 105 | } 106 | f.write_str(&self.name) 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::Package; 113 | 114 | #[test] 115 | fn split_into_name_and_repo() { 116 | let x = "repo/name".to_string(); 117 | let (name, repo) = Package::split_into_name_and_repo(&x); 118 | assert_eq!(name, "name"); 119 | assert_eq!(repo, Some("repo".to_string())); 120 | 121 | let x = "something".to_string(); 122 | let (name, repo) = super::Package::split_into_name_and_repo(&x); 123 | assert_eq!(name, "something"); 124 | assert_eq!(repo, None); 125 | } 126 | 127 | #[test] 128 | fn from() { 129 | let x = "myrepo/somepackage # ".to_string(); 130 | let p = Package::try_from(x).expect("this should be a valid package line"); 131 | assert_eq!(p.name, "somepackage"); 132 | assert_eq!(p.repo, Some("myrepo".to_string())); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /crates/pacdef/src/grouping/section.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | use std::fmt::{Display, Write}; 3 | use std::hash::Hash; 4 | use std::iter::Peekable; 5 | 6 | use anyhow::{ensure, Context, Result}; 7 | 8 | use crate::prelude::*; 9 | 10 | pub type Sections = BTreeSet

; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct Section { 14 | pub name: String, 15 | pub packages: Packages, 16 | } 17 | 18 | impl Section { 19 | pub fn new(name: String, packages: Packages) -> Self { 20 | Self { name, packages } 21 | } 22 | 23 | pub fn try_from_lines<'a>(iter: &mut Peekable>) -> Result { 24 | let name = find_next_section_name(iter)?; 25 | 26 | let mut packages = Packages::new(); 27 | 28 | while next_line_might_be_package(iter) { 29 | if let Some(package) = Package::try_from(iter.next().expect("we checked this is some")) 30 | { 31 | insert_package(package, &mut packages); 32 | } 33 | } 34 | 35 | ensure!(!packages.is_empty(), "[{name}] is empty"); 36 | 37 | Ok(Self::new(name, packages)) 38 | } 39 | } 40 | 41 | fn insert_package(package: Package, packages: &mut Packages) { 42 | let package_name = package.name.clone(); 43 | let newly_inserted = packages.insert(package); 44 | 45 | if !newly_inserted { 46 | log::warn!("{package_name} occurs twice in the same section"); 47 | } 48 | } 49 | 50 | fn next_line_might_be_package<'a>(iter: &mut Peekable>) -> bool { 51 | // `while let` chains are unstable, unfortunately 52 | iter.peek().is_some() 53 | && !iter 54 | .peek() 55 | .expect("we checked this is some") 56 | .starts_with('[') 57 | } 58 | 59 | fn find_next_section_name<'a>( 60 | iter: &mut Peekable>, 61 | ) -> Result { 62 | let name = iter 63 | .find(|line| line.starts_with('[')) 64 | .context("finding beginning of next section")? 65 | .trim() 66 | .trim_start_matches('[') 67 | .trim_end_matches(']') 68 | .to_string(); 69 | Ok(name) 70 | } 71 | 72 | impl Hash for Section { 73 | fn hash(&self, state: &mut H) { 74 | self.name.hash(state); 75 | } 76 | } 77 | 78 | impl PartialEq for Section { 79 | fn eq(&self, other: &Self) -> bool { 80 | self.name == other.name 81 | } 82 | } 83 | 84 | impl Eq for Section { 85 | fn assert_receiver_is_total_eq(&self) {} 86 | } 87 | 88 | impl PartialOrd for Section { 89 | fn partial_cmp(&self, other: &Self) -> Option { 90 | Some(self.cmp(other)) 91 | } 92 | } 93 | 94 | impl Ord for Section { 95 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 96 | self.name.cmp(&other.name) 97 | } 98 | } 99 | 100 | impl Display for Section { 101 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 102 | f.write_fmt(format_args!("[{}]\n", &self.name))?; 103 | 104 | let mut packages: Vec<_> = self.packages.iter().collect(); 105 | packages.sort_unstable(); 106 | 107 | let mut iter = packages.iter().peekable(); 108 | 109 | while let Some(package) = iter.next() { 110 | package.fmt(f)?; 111 | if iter.peek().is_some() { 112 | f.write_char('\n')?; 113 | } 114 | } 115 | 116 | Ok(()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /crates/pacdef/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This library contains all logic that happens in `pacdef`. 2 | 3 | #![warn( 4 | clippy::as_conversions, 5 | clippy::cognitive_complexity, 6 | clippy::explicit_iter_loop, 7 | clippy::explicit_into_iter_loop, 8 | clippy::map_entry, 9 | clippy::missing_errors_doc, 10 | clippy::missing_panics_doc, 11 | clippy::option_if_let_else, 12 | clippy::redundant_pub_crate, 13 | clippy::semicolon_if_nothing_returned, 14 | clippy::unnecessary_wraps, 15 | clippy::unused_self, 16 | clippy::unwrap_used, 17 | clippy::use_debug, 18 | clippy::use_self, 19 | clippy::wildcard_dependencies, 20 | missing_docs 21 | )] 22 | 23 | pub(crate) mod backend; 24 | #[allow(missing_docs)] 25 | pub mod cli; 26 | 27 | mod cmd; 28 | mod config; 29 | #[allow(clippy::unused_self, clippy::unnecessary_wraps)] 30 | mod core; 31 | mod env; 32 | mod errors; 33 | mod grouping; 34 | mod review; 35 | mod search; 36 | mod ui; 37 | 38 | #[allow(unused_imports)] 39 | mod prelude; 40 | 41 | pub mod path; 42 | 43 | pub use prelude::{Config, Error, Group}; 44 | -------------------------------------------------------------------------------- /crates/pacdef/src/main.rs: -------------------------------------------------------------------------------- 1 | //! Main program for `pacdef`. 2 | 3 | #![warn( 4 | clippy::as_conversions, 5 | clippy::option_if_let_else, 6 | clippy::redundant_pub_crate, 7 | clippy::semicolon_if_nothing_returned, 8 | clippy::unnecessary_wraps, 9 | clippy::unused_self, 10 | clippy::unwrap_used, 11 | clippy::use_debug, 12 | clippy::use_self, 13 | clippy::wildcard_dependencies, 14 | missing_docs 15 | )] 16 | 17 | use std::path::Path; 18 | use std::process::{ExitCode, Termination}; 19 | 20 | use anyhow::{bail, Context, Result}; 21 | 22 | use clap::Parser; 23 | use pacdef::cli::MainArguments; 24 | use pacdef::path::{get_config_path, get_config_path_old_version, get_group_dir}; 25 | use pacdef::{Config, Error as PacdefError, Group}; 26 | 27 | const MAJOR_UPDATE_MESSAGE: &str = "VERSION UPGRADE 28 | You seem to have used version 1.x of pacdef before. 29 | In version 2.0 the config file needed to be changed from yaml to toml. 30 | Check out https://github.com/steven-omaha/pacdef/blob/main/README.md#configuration for new syntax information. 31 | This message will not appear again. 32 | ------"; 33 | 34 | struct PacdefLogger; 35 | 36 | impl log::Log for PacdefLogger { 37 | fn enabled(&self, _: &log::Metadata) -> bool { 38 | true 39 | } 40 | 41 | fn log(&self, record: &log::Record) { 42 | if self.enabled(record.metadata()) { 43 | eprintln!("{} - {}", record.level(), record.args()); 44 | } 45 | } 46 | 47 | fn flush(&self) {} 48 | } 49 | 50 | fn main() -> ExitCode { 51 | log::set_boxed_logger(Box::new(PacdefLogger)) 52 | .map(|()| log::set_max_level(log::LevelFilter::Info)) 53 | .expect("no other loggers should have been set"); 54 | 55 | handle_final_result(main_inner()) 56 | } 57 | 58 | /// Skip printing the error chain when searching packages yields no results, 59 | /// otherwise report error chain. 60 | #[allow(clippy::option_if_let_else)] 61 | fn handle_final_result(result: Result<()>) -> ExitCode { 62 | match result { 63 | Ok(_) => ExitCode::SUCCESS, 64 | Err(ref e) => { 65 | if let Some(root_error) = e.root_cause().downcast_ref::() { 66 | log::error!("{root_error}"); 67 | ExitCode::FAILURE 68 | } else { 69 | result.report() 70 | } 71 | } 72 | } 73 | } 74 | 75 | fn main_inner() -> Result<()> { 76 | let main_arguments = MainArguments::parse(); 77 | 78 | let config_file = get_config_path().context("getting config file")?; 79 | 80 | let config = match Config::load(&config_file).context("loading config file") { 81 | Ok(config) => config, 82 | Err(e) => { 83 | if let Some(crate_error) = e.downcast_ref::() { 84 | match crate_error { 85 | PacdefError::ConfigFileNotFound => load_default_config(&config_file)?, 86 | _ => bail!("unexpected error: {crate_error}"), 87 | } 88 | } else { 89 | bail!("unexpected error: {e:?}"); 90 | } 91 | } 92 | }; 93 | 94 | let group_dir = get_group_dir().context("resolving group dir")?; 95 | let groups = Group::load(&group_dir, config.warn_not_symlinks) 96 | .with_context(|| format!("loading groups under {}", group_dir.to_string_lossy()))?; 97 | 98 | if groups.is_empty() { 99 | log::warn!("no group files found"); 100 | } 101 | 102 | for group in groups.iter() { 103 | if group.warn_symlink { 104 | log::warn!( 105 | "group file {} is not a symlink", 106 | group.path.to_string_lossy() 107 | ); 108 | } 109 | } 110 | 111 | main_arguments.run(&groups, &config) 112 | } 113 | 114 | fn load_default_config(config_file: &Path) -> Result { 115 | if get_config_path_old_version()?.exists() { 116 | println!("{MAJOR_UPDATE_MESSAGE}"); 117 | } 118 | 119 | if !config_file.exists() { 120 | create_empty_config_file(config_file)?; 121 | } 122 | 123 | Ok(Config::default()) 124 | } 125 | 126 | fn create_empty_config_file(config_file: &Path) -> Result<()> { 127 | let config_dir = &config_file.parent().context("getting parent dir")?; 128 | std::fs::create_dir_all(config_dir).context("creating parent dir")?; 129 | std::fs::File::create(config_file).context("creating empty config file")?; 130 | Ok(()) 131 | } 132 | -------------------------------------------------------------------------------- /crates/pacdef/src/path.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | All functions related to `pacdef`'s internal paths. 3 | */ 4 | 5 | use std::path::PathBuf; 6 | use std::{env, path::Path}; 7 | 8 | use anyhow::{Context, Result}; 9 | use path_absolutize::Absolutize; 10 | 11 | const CONFIG_FILE_NAME: &str = "pacdef.toml"; 12 | const CONFIG_FILE_NAME_OLD: &str = "pacdef.yaml"; 13 | 14 | /// Get the group directory where all group files are located. This is 15 | /// `$XDG_CONFIG_HOME/pacdef/groups`, which defaults to `$HOME/.config/pacdef/groups`. 16 | /// 17 | /// # Errors 18 | /// 19 | /// This function returns an error if both `$XDG_CONFIG_HOME` and `$HOME` are undefined. 20 | pub fn get_group_dir() -> Result { 21 | let mut result = get_pacdef_base_dir().context("getting pacdef base dir")?; 22 | result.push("groups"); 23 | Ok(result) 24 | } 25 | 26 | /// Get the base directory for `pacdef`'s config files. 27 | /// 28 | /// # Errors 29 | /// 30 | /// This function will return an error if `$XDG_CONFIG_HOME` cannot be determined. 31 | pub fn get_pacdef_base_dir() -> Result { 32 | let mut dir = get_xdg_config_home().context("getting XDG_CONFIG_HOME")?; 33 | dir.push("pacdef"); 34 | Ok(dir) 35 | } 36 | 37 | /// Get the path to the cargo home directory. 38 | /// 39 | /// # Errors 40 | /// 41 | /// This function will return an error if neither the `$CARGO_HOME` nor 42 | /// the `$HOME` environment variables are set. 43 | pub fn get_cargo_home() -> Result { 44 | if let Ok(config) = env::var("CARGO_HOME") { 45 | Ok(config.into()) 46 | } else { 47 | let mut config = get_home_dir().context("falling back to $HOME/.cargo")?; 48 | config.push(".cargo"); 49 | Ok(config) 50 | } 51 | } 52 | 53 | /// Get the path to the XDG config directory. 54 | /// 55 | /// # Errors 56 | /// 57 | /// This function will return an error if neither the `$XDG_CONFIG_HOME` nor 58 | /// the `$HOME` environment variables are set. 59 | fn get_xdg_config_home() -> Result { 60 | if let Ok(config) = env::var("XDG_CONFIG_HOME") { 61 | Ok(config.into()) 62 | } else { 63 | let mut config = get_home_dir().context("falling back to $HOME/.config")?; 64 | config.push(".config"); 65 | Ok(config) 66 | } 67 | } 68 | 69 | /// Get the home directory of the current user from the `$HOME` environment 70 | /// variable. 71 | /// 72 | /// # Errors 73 | /// 74 | /// This function will return an error if the `$HOME` variable is not set. 75 | pub fn get_home_dir() -> Result { 76 | Ok(env::var("HOME").context("getting $HOME variable")?.into()) 77 | } 78 | 79 | /// Get the path to the pacdef config file. This is `$XDG_CONFIG_HOME/pacdef/pacdef.toml`. 80 | /// 81 | /// # Errors 82 | /// 83 | /// This function returns an error if both `$XDG_CONFIG_HOME` and `$HOME` are 84 | /// undefined. 85 | pub fn get_config_path() -> Result { 86 | let mut file = get_pacdef_base_dir().context("getting pacdef base dir for config file")?; 87 | file.push(CONFIG_FILE_NAME); 88 | Ok(file) 89 | } 90 | 91 | /// Get the path to the pacdef config file from version 0.x. This is 92 | /// `$XDG_CONFIG_HOME/pacdef/pacdef.conf`. 93 | /// 94 | /// # Errors 95 | /// 96 | /// This function returns an error if both `$XDG_CONFIG_HOME` and `$HOME` are 97 | /// undefined. 98 | pub fn get_config_path_old_version() -> Result { 99 | let mut file = get_pacdef_base_dir().context("getting pacdef base dir for config file")?; 100 | file.push(CONFIG_FILE_NAME_OLD); 101 | Ok(file) 102 | } 103 | 104 | /// Determine if a program `name` exists in the folders in the `$PATH` variable. 105 | /// 106 | /// # Errors 107 | /// 108 | /// This function returns an error if `$PATH` is not set. 109 | pub fn binary_in_path(name: &str) -> Result { 110 | let paths = env::var_os("PATH").context("getting $PATH")?; 111 | for dir in env::split_paths(&paths) { 112 | let full_path = dir.join(name); 113 | if full_path.is_file() { 114 | return Ok(true); 115 | } 116 | } 117 | Ok(false) 118 | } 119 | 120 | /// Determine the relative path of `full_path` in relation to `base_path`. 121 | /// 122 | /// # Panics 123 | /// 124 | /// Panics if at least one element in `base_path` does not match the corresponding 125 | /// element in `full_path`. 126 | pub fn get_relative_path

(full_path: P, base_path: P) -> PathBuf 127 | where 128 | P: AsRef, 129 | { 130 | let mut file_iter = full_path.as_ref().iter(); 131 | base_path 132 | .as_ref() 133 | .iter() 134 | .zip(&mut file_iter) 135 | .for_each(|(a, b)| assert_eq!(a, b)); 136 | let relative_path: PathBuf = file_iter.collect(); 137 | relative_path 138 | } 139 | 140 | /// For each file argument, return the absolute path to the file. 141 | /// 142 | /// # Errors 143 | /// 144 | /// Returns an error if any of the files cannot be absolutized. 145 | pub fn get_absolutized_file_paths(arg_match: &[String]) -> Result> { 146 | let mut result = vec![]; 147 | 148 | for item in arg_match { 149 | let path: PathBuf = item.into(); 150 | let absolute = path 151 | .absolutize() 152 | .with_context(|| format!("absolutizing {path:?}"))? 153 | .into_owned(); 154 | result.push(absolute); 155 | } 156 | 157 | Ok(result) 158 | } 159 | 160 | #[cfg(test)] 161 | mod tests { 162 | use std::path::PathBuf; 163 | 164 | use super::get_relative_path; 165 | 166 | #[test] 167 | fn relative_path() { 168 | let full = PathBuf::from("/a/b/c/d/e"); 169 | let base = PathBuf::from("/a/b/c"); 170 | let relative = get_relative_path(full, base); 171 | assert_eq!(relative, PathBuf::from("d/e")); 172 | } 173 | 174 | #[test] 175 | #[should_panic] 176 | fn relative_path_panic() { 177 | let full = PathBuf::from("/a/b/z/d/e"); 178 | let base = PathBuf::from("/a/b/c"); 179 | get_relative_path(full, base); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /crates/pacdef/src/prelude.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "arch")] 2 | pub use crate::backend::actual::arch::Arch; 3 | #[cfg(feature = "debian")] 4 | pub use crate::backend::actual::debian::Debian; 5 | pub use crate::backend::actual::{ 6 | fedora::Fedora, flatpak::Flatpak, python::Python, rust::Rust, rustup::Rustup, void::Void, 7 | }; 8 | pub use crate::backend::backend_trait::{Backend, BackendInfo, Switches, Text}; 9 | pub use crate::backend::todo_per_backend::ToDoPerBackend; 10 | pub use crate::backend::AnyBackend; 11 | pub use crate::backend::ManagedBackend; 12 | pub use crate::cli::CleanPackageAction; 13 | pub use crate::cli::EditGroupAction; 14 | pub use crate::cli::ExportGroupAction; 15 | pub use crate::cli::GroupAction; 16 | pub use crate::cli::GroupArguments; 17 | pub use crate::cli::ImportGroupAction; 18 | pub use crate::cli::ListGroupAction; 19 | pub use crate::cli::MainArguments; 20 | pub use crate::cli::MainSubcommand; 21 | pub use crate::cli::NewGroupAction; 22 | pub use crate::cli::PackageAction; 23 | pub use crate::cli::PackageArguments; 24 | pub use crate::cli::RemoveGroupAction; 25 | pub use crate::cli::ReviewPackageAction; 26 | pub use crate::cli::SearchPackageAction; 27 | pub use crate::cli::ShowGroupAction; 28 | pub use crate::cli::SyncPackageAction; 29 | pub use crate::cli::UnmanagedPackageAction; 30 | pub use crate::cli::VersionArguments; 31 | pub use crate::config::Config; 32 | pub use crate::errors::Error; 33 | pub use crate::grouping::{ 34 | group::{Group, Groups}, 35 | package::{Package, Packages}, 36 | section::{Section, Sections}, 37 | }; 38 | pub use crate::path::binary_in_path; 39 | pub use crate::path::get_absolutized_file_paths; 40 | pub use crate::path::get_cargo_home; 41 | pub use crate::path::get_config_path; 42 | pub use crate::path::get_config_path_old_version; 43 | pub use crate::path::get_group_dir; 44 | pub use crate::path::get_home_dir; 45 | pub use crate::path::get_pacdef_base_dir; 46 | pub use crate::path::get_relative_path; 47 | -------------------------------------------------------------------------------- /crates/pacdef/src/review/datastructures.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use super::strategy::Strategy; 4 | 5 | #[derive(Debug, PartialEq)] 6 | pub enum ReviewAction { 7 | AsDependency(Package), 8 | Delete(Package), 9 | AssignGroup(Package, Group), 10 | } 11 | 12 | #[derive(Debug)] 13 | pub enum ReviewIntention { 14 | AsDependency, 15 | AssignGroup, 16 | Delete, 17 | Info, 18 | Invalid, 19 | Skip, 20 | Quit, 21 | Apply, 22 | } 23 | 24 | #[derive(Debug)] 25 | pub struct ReviewsPerBackend { 26 | items: Vec<(AnyBackend, Vec)>, 27 | } 28 | 29 | impl ReviewsPerBackend { 30 | pub fn new() -> Self { 31 | Self { items: vec![] } 32 | } 33 | 34 | pub fn nothing_to_do(&self) -> bool { 35 | self.items.iter().all(|(_, vec)| vec.is_empty()) 36 | } 37 | 38 | pub fn push(&mut self, value: (AnyBackend, Vec)) { 39 | self.items.push(value); 40 | } 41 | 42 | /// Convert the reviews per backend to a vector of [`Strategy`], where one `Strategy` contains 43 | /// all actions that must be executed for a [`Backend`]. 44 | /// 45 | /// If there are no actions for a `Backend`, then that `Backend` is removed from the return 46 | /// value. 47 | pub fn into_strategies(self) -> Vec { 48 | let mut result = vec![]; 49 | 50 | for (backend, actions) in self { 51 | let mut to_delete = Packages::new(); 52 | let mut assign_group = vec![]; 53 | let mut as_dependency = Packages::new(); 54 | 55 | extract_actions( 56 | actions, 57 | &mut to_delete, 58 | &mut assign_group, 59 | &mut as_dependency, 60 | ); 61 | 62 | result.push(Strategy::new( 63 | backend, 64 | to_delete, 65 | as_dependency, 66 | assign_group, 67 | )); 68 | } 69 | 70 | result.retain(|s| !s.nothing_to_do()); 71 | 72 | result 73 | } 74 | } 75 | 76 | impl IntoIterator for ReviewsPerBackend { 77 | type Item = (AnyBackend, Vec); 78 | 79 | type IntoIter = std::vec::IntoIter<(AnyBackend, Vec)>; 80 | 81 | fn into_iter(self) -> Self::IntoIter { 82 | self.items.into_iter() 83 | } 84 | } 85 | 86 | pub enum ContinueWithReview { 87 | Yes, 88 | No, 89 | NoAndApply, 90 | } 91 | 92 | fn extract_actions( 93 | actions: Vec, 94 | to_delete: &mut Packages, 95 | assign_group: &mut Vec<(Package, Group)>, 96 | as_dependency: &mut Packages, 97 | ) { 98 | for action in actions { 99 | match action { 100 | ReviewAction::Delete(package) => { 101 | to_delete.insert(package); 102 | } 103 | ReviewAction::AssignGroup(package, group) => assign_group.push((package, group)), 104 | ReviewAction::AsDependency(package) => { 105 | as_dependency.insert(package); 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crates/pacdef/src/review/mod.rs: -------------------------------------------------------------------------------- 1 | mod datastructures; 2 | mod strategy; 3 | 4 | use std::io::{stdin, stdout, Write}; 5 | 6 | use anyhow::Result; 7 | 8 | use crate::prelude::*; 9 | use crate::ui::{get_user_confirmation, read_single_char_from_terminal}; 10 | 11 | use self::datastructures::{ContinueWithReview, ReviewAction, ReviewIntention, ReviewsPerBackend}; 12 | use self::strategy::Strategy; 13 | 14 | pub fn review(todo_per_backend: ToDoPerBackend, groups: &Groups) -> Result<()> { 15 | let mut reviews = ReviewsPerBackend::new(); 16 | 17 | if todo_per_backend.nothing_to_do_for_all_backends() { 18 | println!("nothing to do"); 19 | return Ok(()); 20 | } 21 | 22 | 'outer: for (backend, packages) in todo_per_backend { 23 | let mut actions = vec![]; 24 | for package in packages { 25 | println!("{}: {package}", backend.backend_info().section); 26 | match get_action_for_package(package, groups, &mut actions, &backend)? { 27 | ContinueWithReview::Yes => continue, 28 | ContinueWithReview::No => return Ok(()), 29 | ContinueWithReview::NoAndApply => { 30 | reviews.push((backend, actions)); 31 | break 'outer; 32 | } 33 | } 34 | } 35 | reviews.push((backend, actions)); 36 | } 37 | 38 | if reviews.nothing_to_do() { 39 | println!("nothing to do"); 40 | return Ok(()); 41 | } 42 | 43 | let strategies: Vec = reviews.into_strategies(); 44 | 45 | println!(); 46 | let mut iter = strategies.iter().peekable(); 47 | 48 | while let Some(strategy) = iter.next() { 49 | strategy.show(); 50 | 51 | if iter.peek().is_some() { 52 | println!(); 53 | } 54 | } 55 | 56 | println!(); 57 | if !get_user_confirmation()? { 58 | return Ok(()); 59 | } 60 | 61 | for strategy in strategies { 62 | strategy.execute()?; 63 | } 64 | 65 | Ok(()) 66 | } 67 | 68 | fn get_action_for_package( 69 | package: Package, 70 | groups: &Groups, 71 | reviews: &mut Vec, 72 | backend: &dyn Backend, 73 | ) -> Result { 74 | loop { 75 | match ask_user_action_for_package(backend.supports_as_dependency())? { 76 | ReviewIntention::AsDependency => { 77 | assert!( 78 | backend.supports_as_dependency(), 79 | "backend does not support dependencies" 80 | ); 81 | reviews.push(ReviewAction::AsDependency(package)); 82 | break; 83 | } 84 | ReviewIntention::AssignGroup => { 85 | if let Ok(Some(group)) = ask_group(groups) { 86 | reviews.push(ReviewAction::AssignGroup(package, group.clone())); 87 | break; 88 | }; 89 | } 90 | ReviewIntention::Delete => { 91 | reviews.push(ReviewAction::Delete(package)); 92 | break; 93 | } 94 | ReviewIntention::Info => { 95 | backend.show_package_info(&package)?; 96 | } 97 | ReviewIntention::Invalid => (), 98 | ReviewIntention::Skip => break, 99 | ReviewIntention::Quit => return Ok(ContinueWithReview::No), 100 | ReviewIntention::Apply => return Ok(ContinueWithReview::NoAndApply), 101 | } 102 | } 103 | Ok(ContinueWithReview::Yes) 104 | } 105 | 106 | /// Ask the user for the desired action, and return the associated 107 | /// [`ReviewIntention`]. The query depends on the capabilities of the backend. 108 | /// 109 | /// # Errors 110 | /// 111 | /// This function will return an error if stdin or stdout cannot be accessed. 112 | fn ask_user_action_for_package(supports_as_dependency: bool) -> Result { 113 | print_query(supports_as_dependency)?; 114 | 115 | match read_single_char_from_terminal()?.to_ascii_lowercase() { 116 | 'a' if supports_as_dependency => Ok(ReviewIntention::AsDependency), 117 | 'd' => Ok(ReviewIntention::Delete), 118 | 'g' => Ok(ReviewIntention::AssignGroup), 119 | 'i' => Ok(ReviewIntention::Info), 120 | 'q' => Ok(ReviewIntention::Quit), 121 | 's' => Ok(ReviewIntention::Skip), 122 | 'p' => Ok(ReviewIntention::Apply), 123 | _ => Ok(ReviewIntention::Invalid), 124 | } 125 | } 126 | 127 | /// Print a space-terminated string that asks the user for the desired action. 128 | /// The items of the string depend on whether the backend supports dependent 129 | /// packages. 130 | /// 131 | /// # Errors 132 | /// 133 | /// This function will return an error if stdout cannot be flushed. 134 | fn print_query(supports_as_dependency: bool) -> Result<()> { 135 | let mut query = String::from("assign to (g)roup, (d)elete, (s)kip, (i)nfo, "); 136 | 137 | if supports_as_dependency { 138 | query.push_str("(a)s dependency, "); 139 | } 140 | 141 | query.push_str("a(p)ply, (q)uit? "); 142 | 143 | print!("{query}"); 144 | stdout().lock().flush()?; 145 | Ok(()) 146 | } 147 | 148 | fn print_enumerated_groups(groups: &Groups) { 149 | let number_digits = get_amount_of_digits_for_number(groups.len()); 150 | 151 | for (i, group) in groups.iter().enumerate() { 152 | println!("{i:>number_digits$}: {}", group.name); 153 | } 154 | } 155 | 156 | fn get_amount_of_digits_for_number(number: usize) -> usize { 157 | number.to_string().len() 158 | } 159 | 160 | fn ask_group(groups: &Groups) -> Result> { 161 | print_enumerated_groups(groups); 162 | let mut buf = String::new(); 163 | stdin().read_line(&mut buf)?; 164 | let reply = buf.trim(); 165 | 166 | let idx: usize = if let Ok(idx) = reply.parse() { 167 | idx 168 | } else { 169 | return Ok(None); 170 | }; 171 | 172 | if idx < groups.len() { 173 | Ok(groups.iter().nth(idx)) 174 | } else { 175 | Ok(None) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /crates/pacdef/src/review/strategy.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::prelude::*; 4 | 5 | #[derive(Debug)] 6 | pub struct Strategy { 7 | backend: AnyBackend, 8 | delete: Packages, 9 | as_dependency: Packages, 10 | assign_group: Vec<(Package, Group)>, 11 | } 12 | 13 | impl Strategy { 14 | pub fn new( 15 | backend: AnyBackend, 16 | delete: Packages, 17 | as_dependency: Packages, 18 | assign_group: Vec<(Package, Group)>, 19 | ) -> Self { 20 | Self { 21 | backend, 22 | delete, 23 | as_dependency, 24 | assign_group, 25 | } 26 | } 27 | 28 | pub fn execute(self) -> Result<()> { 29 | if !self.delete.is_empty() { 30 | self.backend.remove_packages(&self.delete, false)?; 31 | } 32 | 33 | if !self.as_dependency.is_empty() { 34 | self.backend.make_dependency(&self.as_dependency)?; 35 | } 36 | 37 | if !self.assign_group.is_empty() { 38 | self.backend.assign_group(self.assign_group)?; 39 | } 40 | 41 | Ok(()) 42 | } 43 | 44 | pub fn show(&self) { 45 | if self.nothing_to_do() { 46 | return; 47 | } 48 | 49 | println!("[{}]", self.backend.backend_info().section); 50 | 51 | if !self.delete.is_empty() { 52 | println!("delete:"); 53 | for p in &self.delete { 54 | println!(" {p}"); 55 | } 56 | } 57 | 58 | if !self.as_dependency.is_empty() { 59 | println!("as dependency:"); 60 | for p in &self.as_dependency { 61 | println!(" {p}"); 62 | } 63 | } 64 | 65 | if !self.assign_group.is_empty() { 66 | println!("assign groups:"); 67 | for (p, g) in &self.assign_group { 68 | println!(" {p} -> {}", g.name); 69 | } 70 | } 71 | } 72 | 73 | pub fn nothing_to_do(&self) -> bool { 74 | self.delete.is_empty() && self.as_dependency.is_empty() && self.assign_group.is_empty() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /crates/pacdef/src/search.rs: -------------------------------------------------------------------------------- 1 | use std::iter::Peekable; 2 | use std::vec::IntoIter; 3 | 4 | use crate::prelude::*; 5 | use anyhow::{bail, Result}; 6 | use regex::Regex; 7 | 8 | /// Find all packages in all groups whose name match the regex from the 9 | /// command-line arguments. Print the name of the packages per group and 10 | /// section. 11 | /// 12 | /// # Errors 13 | /// 14 | /// This function will return an error if 15 | /// - an invalid regex was provided, or 16 | /// - no matching packages could be found. 17 | pub fn search_packages(regex_str: &str, groups: &Groups) -> Result<()> { 18 | if groups.is_empty() { 19 | bail!(crate::errors::Error::NoPackagesFound); 20 | } 21 | 22 | let re = Regex::new(regex_str)?; 23 | 24 | let mut vec = vec![]; 25 | 26 | for group in groups { 27 | for section in &group.sections { 28 | for package in §ion.packages { 29 | if re.is_match(&package.name) { 30 | vec.push((group, section, package)); 31 | } 32 | } 33 | } 34 | } 35 | 36 | if vec.is_empty() { 37 | bail!(crate::errors::Error::NoPackagesFound); 38 | } 39 | 40 | print_triples(vec); 41 | 42 | Ok(()) 43 | } 44 | 45 | fn print_triples(mut vec: Vec<(&Group, &Section, &Package)>) { 46 | vec.sort_unstable(); 47 | 48 | let mut g0 = String::new(); 49 | let mut s0 = String::new(); 50 | 51 | let mut iter = vec.into_iter().peekable(); 52 | 53 | while let Some((g, s, p)) = iter.next() { 54 | print_group_if_changed(g, &g0, &mut s0); 55 | print_section_if_changed(s, &s0); 56 | println!("{p}"); 57 | save_group_and_section_name(&mut g0, g, &mut s0, s); 58 | print_separator_unless_exhausted(&mut iter, &g0); 59 | } 60 | } 61 | 62 | fn save_group_and_section_name(g0: &mut String, g: &Group, s0: &mut String, s: &Section) { 63 | g0.clone_from(&g.name); 64 | s0.clone_from(&s.name); 65 | } 66 | 67 | fn print_separator_unless_exhausted( 68 | iter: &mut Peekable>, 69 | g0: &String, 70 | ) { 71 | if let Some((g, _, _)) = iter.peek() { 72 | if g.name != *g0 { 73 | println!(); 74 | } 75 | } 76 | } 77 | 78 | fn print_section_if_changed(current: &Section, previous_name: &String) { 79 | if current.name != *previous_name { 80 | println!("[{}]", current.name); 81 | } 82 | } 83 | 84 | fn print_group_if_changed( 85 | current_group: &Group, 86 | previous_group_name: &String, 87 | previous_section_name: &mut String, 88 | ) { 89 | if current_group.name != *previous_group_name { 90 | println!("{}", current_group.name); 91 | for _ in 0..current_group.name.len() { 92 | print!("-"); 93 | } 94 | println!(); 95 | previous_section_name.clear(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /crates/pacdef/src/ui.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Read, Write}; 2 | 3 | use anyhow::{Context, Result}; 4 | use termios::*; 5 | 6 | pub fn get_user_confirmation() -> Result { 7 | print!("Continue? [Y/n] "); 8 | std::io::stdout().flush().context("flushing stdout")?; 9 | 10 | let mut reply = String::new(); 11 | std::io::stdin() 12 | .read_line(&mut reply) 13 | .context("reading stdin")?; 14 | 15 | Ok(reply.trim().is_empty() || reply.to_lowercase().contains('y')) 16 | } 17 | 18 | /// Read a single byte from stdin and interpret it as `char`. Use the 19 | /// `termios` library to switch the terminal to raw mode before reading, 20 | /// and restore the original terminal mode afterwards. 21 | /// 22 | /// # Errors 23 | /// 24 | /// This function will return an error if the data cannot be read or the 25 | /// terminal settings cannot be changed. 26 | pub fn read_single_char_from_terminal() -> Result { 27 | // 0 is the file descriptor for stdin 28 | let fd = 0; 29 | let termios = Termios::from_fd(fd).context("getting stdin fd")?; 30 | let mut new_termios = termios; 31 | new_termios.c_lflag &= !(ICANON | ECHO); 32 | new_termios.c_cc[VMIN] = 1; 33 | new_termios.c_cc[VTIME] = 0; 34 | tcsetattr(fd, TCSANOW, &new_termios).context("setting terminal mode")?; 35 | 36 | let mut input_buffer = [0u8; 1]; 37 | io::stdin() 38 | .read_exact(&mut input_buffer[..]) 39 | .context("reading one byte from stdin")?; 40 | let result: char = input_buffer[0].into(); 41 | 42 | // stdin is not echoed automatically in this terminal mode 43 | println!("{result}"); 44 | 45 | // restore previous settings 46 | tcsetattr(fd, TCSANOW, &termios).context("restoring terminal mode")?; 47 | 48 | Ok(result) 49 | } 50 | -------------------------------------------------------------------------------- /man/pacdef.8: -------------------------------------------------------------------------------- 1 | .TH "PACDEF" "8" "2024\-04\-13" "pacdef v1\&.6\&.0" "Pacdef Manual" 2 | 3 | .SH NAME 4 | pacdef \(em multi-backend declarative package manager 5 | .SH SYNOPSIS 6 | \fIpacdef\fR ... 7 | . 8 | .SH DESCRIPTION 9 | Pacdef allows the user to have consistent packages among multiple Linux machines and different backends by managing packages in group files. 10 | The idea is that (1) any package in the group files ("managed packages") will be installed explicitly, and (2) explicitly installed packages not found in any of the group files ("unmanaged packages") will be removed. 11 | The group files are maintained outside of pacdef by any VCS, like git. 12 | 13 | Pacdef manages multiple package groups (group files) that, e.g., may be tied to a specific use-case. 14 | Each group has one or more sections which correspond to a specific backend, like your system's package manager (pacman, apt, ...), or your programming languages package manger (cargo, pip, ...). 15 | Each section contains one or more packages that can be installed respective package manager. 16 | 17 | .SH CONFIGURATION 18 | Configure pacdef in its config file. See 19 | .BR pacdef.toml(5). 20 | 21 | .SS GROUP FILE SYNTAX 22 | 23 | Group files loosely follow the syntax for ini-files. 24 | 25 | 1. Sections begin by their name in brackets. 26 | .br 27 | 2. One package per line. 28 | .br 29 | 3. Anything after a # is ignored. 30 | .br 31 | 4. Empty lines are ignored. 32 | .br 33 | 5. If a package exists in multiple repositories, the repo can be specified as prefix followed by a forward slash. The package manager must understand this notation. 34 | 35 | 36 | 37 | .SH SUBCOMMANDS 38 | The main subcommands are 'group', 'package' and 'version'. 39 | 40 | ... 41 | .RS 4 42 | All actions related to managing groups. 43 | .sp 44 | [...] 45 | .RS 4 46 | edit the content of an existing group 47 | .RE 48 | . 49 | .sp 50 | [] [...] 51 | .RS 4 52 | Export non-symlink groups by moving and re-importing them. 53 | By default, the output path is the current workdir. 54 | The file path relative to the group base dir will be replicated under the output directory. 55 | 56 | If a specified group is not a symlink, pacdef will return an error. 57 | .sp 58 | -f|--force 59 | .RS 4 60 | Overwrite the output file if it exists. 61 | .RE 62 | .sp 63 | -o|--output 64 | .RS 4 65 | The output dir to use instead of the current workdir. 66 | The dir must exist. 67 | .RE 68 | .RE 69 | . 70 | .sp 71 | [...] 72 | .RS 4 73 | import a new group file or group dir structure 74 | .RE 75 | .sp 76 | 77 | .RS 4 78 | show the sorted names of all imported groups 79 | .RE 80 | .sp 81 | [args] [...] 82 | .RS 4 83 | create a new group file 84 | .sp 85 | -e|--edit 86 | .RS 4 87 | After creating the files, open them in your configured editor as configured in 88 | $EDITOR or $VISUAL. 89 | .RE 90 | .RE 91 | .sp 92 | [...] 93 | .RS 4 94 | remove group file. 95 | \fBWARNING\fR: If the group file is not a symlink, you will loose the file! 96 | . 97 | .RE 98 | .sp 99 | [...] 100 | .RS 4 101 | show content of a group file 102 | .RE 103 | 104 | .RE 105 | 106 | 107 | ... 108 | .RS 4 109 | All actions related to packages. 110 | 111 | .sp 112 | [args] 113 | .RS 4 114 | remove unmanaged packages 115 | .sp 116 | --noconfirm 117 | .RS 4 118 | do not ask for confirmation 119 | .RE 120 | .RE 121 | . 122 | .sp 123 | 124 | .RS 4 125 | for each unmanaged package interactively decide what to do 126 | .RE 127 | .sp 128 | 129 | .RS 4 130 | show packages that match the regular expression. 131 | .RE 132 | .sp 133 | [args] 134 | .RS 4 135 | install managed packages 136 | 137 | --noconfirm 138 | .RS 4 139 | see 'clean' 140 | .RE 141 | .RE 142 | .sp 143 | 144 | .RS 4 145 | show unmanaged packages 146 | .RE 147 | .RE 148 | .sp 149 | version 150 | .RS 4 151 | Show version information (including git revision if it was build from git) and supported backends. 152 | .RE 153 | 154 | 155 | .SH EXIT STATUS 156 | Pacdef exits with status 0 on success, 1 if an error occurs (e.g. package search did not yield any package), and 2 if invalid command line options were specified. 157 | 158 | .SH BUGS 159 | File bugs and feature requests under https://github.com/steven-omaha/pacdef/issues. 160 | 161 | .SH AUTHORS 162 | Mostly 'steven-omaha'. 163 | Contributors under https://github.com/steven-omaha/pacdef/graphs/contributors. 164 | 165 | .SH SEE ALSO 166 | .BR pacdef.toml(5) 167 | 168 | -------------------------------------------------------------------------------- /man/pacdef.toml.5: -------------------------------------------------------------------------------- 1 | .TH "PACDEF.TOML" "5" "2024\-04\-13" "pacdef v1\&.6\&.0" "Pacdef Manual" 2 | 3 | .SH NAME 4 | pacdef.toml \(em pacdef configuration file 5 | .SH SYNOPSIS 6 | $XDG_CONFIG_HOME/pacdef/pacdef.toml 7 | .br 8 | $HOME/.config/pacdef/pacdef.toml 9 | . 10 | .SH DESCRIPTION 11 | This is the config file for 12 | .BR pacdef(8). 13 | During startup, pacdef will try to load the config file as specified in the order in synopsis. 14 | If both $XDG_CONFIG_HOME and $HOME are unset, pacdef will exit with an error. 15 | 16 | The necessary directories are created during first startup. 17 | If the config file does not exist, it will be created without content, in which case the default settings apply. 18 | See OPTIONS for a description of the possible values. 19 | If the file contains any content that is not a valid key-value pair, pacdef will exit with an error. 20 | 21 | 22 | .SH OPTIONS 23 | The options together with their default values. 24 | 25 | .TP 26 | .B aur_helper = "paru" 27 | The AUR helper to use on Arch Linux 28 | 29 | .TP 30 | .B aur_rm_args = [] 31 | Additional arguments to pass to the AUR helper on Arch Linux when removing a package. 32 | Must be a list of strings. 33 | .br 34 | Example: [--recursive] 35 | 36 | .TP 37 | .B disabled_backends = [] 38 | Backends that pacdef should ignore even if the binary exists on the system. 39 | This can reduce runtime if the package manager is notoriously slow (like pip). 40 | .br 41 | Example: [python, flatpak] 42 | 43 | .TP 44 | .B warn_not_symlinks = true 45 | Warn if any group file is not a symlink and is not a child of a symlinked dir inside the group folder. 46 | 47 | .TP 48 | .B flatpak_systemwide = true 49 | Whether flatpak packages should be installed system-wide or per user. 50 | 51 | .TP 52 | .B pip_binary = "pip" 53 | Whether pipx instead of pip should be used for Python package management. 54 | 55 | .SH SEE ALSO 56 | .BR pacdef(8) 57 | 58 | --------------------------------------------------------------------------------