├── .github └── workflows │ └── flake.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix └── src ├── commands ├── add_root.rs ├── analyze.rs ├── cleanout.rs ├── completions.rs ├── gc.rs ├── gc_roots.rs ├── generations.rs ├── man.rs ├── mod.rs ├── path_info.rs ├── presets.rs └── tidyup_gc_roots.rs ├── config.rs ├── main.rs ├── nix ├── mod.rs ├── profiles.rs ├── roots.rs └── store.rs └── utils ├── caching.rs ├── files.rs ├── fmt.rs ├── interaction.rs ├── journal.rs ├── mod.rs ├── ordered_channel.rs └── terminal.rs /.github/workflows/flake.yml: -------------------------------------------------------------------------------- 1 | name: flake 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | jobs: 8 | call: 9 | uses: jzbor/nix-flake-workflow/.github/workflows/reusable-flake-workflow.yml@main 10 | with: 11 | binary-cache: true 12 | architectures: '[ "x86_64-linux", "aarch64-linux" ]' 13 | arm-runners: true 14 | secrets: 15 | ATTIC_ENDPOINT: ${{ secrets.ATTIC_ENDPOINT }} 16 | ATTIC_CACHE: ${{ secrets.ATTIC_CACHE }} 17 | ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | result* 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.20" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.11" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.7" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.4" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 40 | dependencies = [ 41 | "windows-sys 0.60.2", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 49 | dependencies = [ 50 | "anstyle", 51 | "once_cell_polyfill", 52 | "windows-sys 0.60.2", 53 | ] 54 | 55 | [[package]] 56 | name = "arrayvec" 57 | version = "0.7.6" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 60 | 61 | [[package]] 62 | name = "autocfg" 63 | version = "1.5.0" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 66 | 67 | [[package]] 68 | name = "bitflags" 69 | version = "2.9.2" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" 72 | 73 | [[package]] 74 | name = "clap" 75 | version = "4.5.45" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" 78 | dependencies = [ 79 | "clap_builder", 80 | "clap_derive", 81 | ] 82 | 83 | [[package]] 84 | name = "clap_builder" 85 | version = "4.5.44" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" 88 | dependencies = [ 89 | "anstream", 90 | "anstyle", 91 | "clap_lex", 92 | "strsim", 93 | ] 94 | 95 | [[package]] 96 | name = "clap_complete" 97 | version = "4.5.57" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad" 100 | dependencies = [ 101 | "clap", 102 | ] 103 | 104 | [[package]] 105 | name = "clap_derive" 106 | version = "4.5.45" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" 109 | dependencies = [ 110 | "heck", 111 | "proc-macro2", 112 | "quote", 113 | "syn", 114 | ] 115 | 116 | [[package]] 117 | name = "clap_lex" 118 | version = "0.7.5" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 121 | 122 | [[package]] 123 | name = "clap_mangen" 124 | version = "0.2.29" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "27b4c3c54b30f0d9adcb47f25f61fcce35c4dd8916638c6b82fbd5f4fb4179e2" 127 | dependencies = [ 128 | "clap", 129 | "roff", 130 | ] 131 | 132 | [[package]] 133 | name = "colorchoice" 134 | version = "1.0.4" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 137 | 138 | [[package]] 139 | name = "colored" 140 | version = "3.0.0" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" 143 | dependencies = [ 144 | "windows-sys 0.59.0", 145 | ] 146 | 147 | [[package]] 148 | name = "crossbeam-deque" 149 | version = "0.8.6" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 152 | dependencies = [ 153 | "crossbeam-epoch", 154 | "crossbeam-utils", 155 | ] 156 | 157 | [[package]] 158 | name = "crossbeam-epoch" 159 | version = "0.9.18" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 162 | dependencies = [ 163 | "crossbeam-utils", 164 | ] 165 | 166 | [[package]] 167 | name = "crossbeam-utils" 168 | version = "0.8.21" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 171 | 172 | [[package]] 173 | name = "duration-str" 174 | version = "0.17.0" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "9add086174f60bcbcfde7175e71dcfd99da24dfd12f611d0faf74f4f26e15a06" 177 | dependencies = [ 178 | "rust_decimal", 179 | "serde", 180 | "thiserror", 181 | "winnow", 182 | ] 183 | 184 | [[package]] 185 | name = "either" 186 | version = "1.15.0" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 189 | 190 | [[package]] 191 | name = "equivalent" 192 | version = "1.0.2" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 195 | 196 | [[package]] 197 | name = "errno" 198 | version = "0.3.13" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" 201 | dependencies = [ 202 | "libc", 203 | "windows-sys 0.60.2", 204 | ] 205 | 206 | [[package]] 207 | name = "hashbrown" 208 | version = "0.15.5" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 211 | 212 | [[package]] 213 | name = "heck" 214 | version = "0.5.0" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 217 | 218 | [[package]] 219 | name = "indexmap" 220 | version = "2.10.0" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" 223 | dependencies = [ 224 | "equivalent", 225 | "hashbrown", 226 | ] 227 | 228 | [[package]] 229 | name = "is_terminal_polyfill" 230 | version = "1.70.1" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 233 | 234 | [[package]] 235 | name = "libc" 236 | version = "0.2.175" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 239 | 240 | [[package]] 241 | name = "linux-raw-sys" 242 | version = "0.9.4" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 245 | 246 | [[package]] 247 | name = "memchr" 248 | version = "2.7.5" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 251 | 252 | [[package]] 253 | name = "nix-sweep" 254 | version = "0.7.0" 255 | dependencies = [ 256 | "clap", 257 | "clap_complete", 258 | "clap_mangen", 259 | "colored", 260 | "duration-str", 261 | "rayon", 262 | "rustc-hash", 263 | "rustix", 264 | "serde", 265 | "size", 266 | "toml", 267 | "xdg", 268 | ] 269 | 270 | [[package]] 271 | name = "num-traits" 272 | version = "0.2.19" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 275 | dependencies = [ 276 | "autocfg", 277 | ] 278 | 279 | [[package]] 280 | name = "once_cell_polyfill" 281 | version = "1.70.1" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 284 | 285 | [[package]] 286 | name = "proc-macro2" 287 | version = "1.0.101" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 290 | dependencies = [ 291 | "unicode-ident", 292 | ] 293 | 294 | [[package]] 295 | name = "quote" 296 | version = "1.0.40" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 299 | dependencies = [ 300 | "proc-macro2", 301 | ] 302 | 303 | [[package]] 304 | name = "rayon" 305 | version = "1.11.0" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" 308 | dependencies = [ 309 | "either", 310 | "rayon-core", 311 | ] 312 | 313 | [[package]] 314 | name = "rayon-core" 315 | version = "1.13.0" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" 318 | dependencies = [ 319 | "crossbeam-deque", 320 | "crossbeam-utils", 321 | ] 322 | 323 | [[package]] 324 | name = "roff" 325 | version = "0.2.2" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" 328 | 329 | [[package]] 330 | name = "rust_decimal" 331 | version = "1.37.2" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" 334 | dependencies = [ 335 | "arrayvec", 336 | "num-traits", 337 | ] 338 | 339 | [[package]] 340 | name = "rustc-hash" 341 | version = "2.1.1" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 344 | 345 | [[package]] 346 | name = "rustix" 347 | version = "1.0.8" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" 350 | dependencies = [ 351 | "bitflags", 352 | "errno", 353 | "libc", 354 | "linux-raw-sys", 355 | "windows-sys 0.60.2", 356 | ] 357 | 358 | [[package]] 359 | name = "serde" 360 | version = "1.0.219" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 363 | dependencies = [ 364 | "serde_derive", 365 | ] 366 | 367 | [[package]] 368 | name = "serde_derive" 369 | version = "1.0.219" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 372 | dependencies = [ 373 | "proc-macro2", 374 | "quote", 375 | "syn", 376 | ] 377 | 378 | [[package]] 379 | name = "serde_spanned" 380 | version = "1.0.0" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" 383 | dependencies = [ 384 | "serde", 385 | ] 386 | 387 | [[package]] 388 | name = "size" 389 | version = "0.5.0" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b" 392 | 393 | [[package]] 394 | name = "strsim" 395 | version = "0.11.1" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 398 | 399 | [[package]] 400 | name = "syn" 401 | version = "2.0.106" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 404 | dependencies = [ 405 | "proc-macro2", 406 | "quote", 407 | "unicode-ident", 408 | ] 409 | 410 | [[package]] 411 | name = "thiserror" 412 | version = "2.0.15" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850" 415 | dependencies = [ 416 | "thiserror-impl", 417 | ] 418 | 419 | [[package]] 420 | name = "thiserror-impl" 421 | version = "2.0.15" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0" 424 | dependencies = [ 425 | "proc-macro2", 426 | "quote", 427 | "syn", 428 | ] 429 | 430 | [[package]] 431 | name = "toml" 432 | version = "0.9.5" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" 435 | dependencies = [ 436 | "indexmap", 437 | "serde", 438 | "serde_spanned", 439 | "toml_datetime", 440 | "toml_parser", 441 | "toml_writer", 442 | "winnow", 443 | ] 444 | 445 | [[package]] 446 | name = "toml_datetime" 447 | version = "0.7.0" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" 450 | dependencies = [ 451 | "serde", 452 | ] 453 | 454 | [[package]] 455 | name = "toml_parser" 456 | version = "1.0.2" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" 459 | dependencies = [ 460 | "winnow", 461 | ] 462 | 463 | [[package]] 464 | name = "toml_writer" 465 | version = "1.0.2" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" 468 | 469 | [[package]] 470 | name = "unicode-ident" 471 | version = "1.0.18" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 474 | 475 | [[package]] 476 | name = "utf8parse" 477 | version = "0.2.2" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 480 | 481 | [[package]] 482 | name = "windows-link" 483 | version = "0.1.3" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 486 | 487 | [[package]] 488 | name = "windows-sys" 489 | version = "0.59.0" 490 | source = "registry+https://github.com/rust-lang/crates.io-index" 491 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 492 | dependencies = [ 493 | "windows-targets 0.52.6", 494 | ] 495 | 496 | [[package]] 497 | name = "windows-sys" 498 | version = "0.60.2" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 501 | dependencies = [ 502 | "windows-targets 0.53.3", 503 | ] 504 | 505 | [[package]] 506 | name = "windows-targets" 507 | version = "0.52.6" 508 | source = "registry+https://github.com/rust-lang/crates.io-index" 509 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 510 | dependencies = [ 511 | "windows_aarch64_gnullvm 0.52.6", 512 | "windows_aarch64_msvc 0.52.6", 513 | "windows_i686_gnu 0.52.6", 514 | "windows_i686_gnullvm 0.52.6", 515 | "windows_i686_msvc 0.52.6", 516 | "windows_x86_64_gnu 0.52.6", 517 | "windows_x86_64_gnullvm 0.52.6", 518 | "windows_x86_64_msvc 0.52.6", 519 | ] 520 | 521 | [[package]] 522 | name = "windows-targets" 523 | version = "0.53.3" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" 526 | dependencies = [ 527 | "windows-link", 528 | "windows_aarch64_gnullvm 0.53.0", 529 | "windows_aarch64_msvc 0.53.0", 530 | "windows_i686_gnu 0.53.0", 531 | "windows_i686_gnullvm 0.53.0", 532 | "windows_i686_msvc 0.53.0", 533 | "windows_x86_64_gnu 0.53.0", 534 | "windows_x86_64_gnullvm 0.53.0", 535 | "windows_x86_64_msvc 0.53.0", 536 | ] 537 | 538 | [[package]] 539 | name = "windows_aarch64_gnullvm" 540 | version = "0.52.6" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 543 | 544 | [[package]] 545 | name = "windows_aarch64_gnullvm" 546 | version = "0.53.0" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 549 | 550 | [[package]] 551 | name = "windows_aarch64_msvc" 552 | version = "0.52.6" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 555 | 556 | [[package]] 557 | name = "windows_aarch64_msvc" 558 | version = "0.53.0" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 561 | 562 | [[package]] 563 | name = "windows_i686_gnu" 564 | version = "0.52.6" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 567 | 568 | [[package]] 569 | name = "windows_i686_gnu" 570 | version = "0.53.0" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 573 | 574 | [[package]] 575 | name = "windows_i686_gnullvm" 576 | version = "0.52.6" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 579 | 580 | [[package]] 581 | name = "windows_i686_gnullvm" 582 | version = "0.53.0" 583 | source = "registry+https://github.com/rust-lang/crates.io-index" 584 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 585 | 586 | [[package]] 587 | name = "windows_i686_msvc" 588 | version = "0.52.6" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 591 | 592 | [[package]] 593 | name = "windows_i686_msvc" 594 | version = "0.53.0" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 597 | 598 | [[package]] 599 | name = "windows_x86_64_gnu" 600 | version = "0.52.6" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 603 | 604 | [[package]] 605 | name = "windows_x86_64_gnu" 606 | version = "0.53.0" 607 | source = "registry+https://github.com/rust-lang/crates.io-index" 608 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 609 | 610 | [[package]] 611 | name = "windows_x86_64_gnullvm" 612 | version = "0.52.6" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 615 | 616 | [[package]] 617 | name = "windows_x86_64_gnullvm" 618 | version = "0.53.0" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 621 | 622 | [[package]] 623 | name = "windows_x86_64_msvc" 624 | version = "0.52.6" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 627 | 628 | [[package]] 629 | name = "windows_x86_64_msvc" 630 | version = "0.53.0" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 633 | 634 | [[package]] 635 | name = "winnow" 636 | version = "0.7.12" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" 639 | dependencies = [ 640 | "memchr", 641 | ] 642 | 643 | [[package]] 644 | name = "xdg" 645 | version = "3.0.0" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" 648 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nix-sweep" 3 | version = "0.7.0" 4 | edition = "2024" 5 | description = "Utility to clean up old Nix profile generations and left-over garbage collection roots" 6 | repository = "https://github.com/jzbor/nix-sweep" 7 | license = "MIT" 8 | 9 | [dependencies] 10 | clap = { version = "4.5.20", features = ["derive"] } 11 | clap_complete = "4.5.57" 12 | clap_mangen = "0.2.26" 13 | colored = "3.0.0" 14 | duration-str = { version = "0.17.0", default-features = false, features = ["serde", "calc"] } 15 | rayon = "1.10.0" 16 | rustc-hash = "2.1.1" 17 | rustix = { version = "1.0.8", features = ["termios"] } 18 | serde = { version = "1.0.219", features = ["derive"] } 19 | size = "0.5.0" 20 | toml = "0.9.5" 21 | xdg = "3.0.0" 22 | 23 | [profile.release] 24 | lto = true 25 | 26 | [profile.profiling] 27 | inherits = "release" 28 | debug = true 29 | strip = "none" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 jzbor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nix-sweep 2 | `nix-sweep` aims to provide a nice interface for cleaning up old Nix profile generations and left-over garbage collection roots. 3 | 4 | ![nix-sweep demo](https://files.jzbor.de/github/nix-sweep-demo.gif) 5 | 6 | 7 | ## Size Calculations 8 | Calculating the size of the Nix paths may take a few moments, especially on older hardware. 9 | If you want to avoid that overhead you can use `--no-size` to skip size calculations. 10 | 11 | ## Presets 12 | `nix-sweep` allows you to create presets for clean out criteria, that can then be used with `nix-sweep cleanout`. 13 | 14 | Preset configs are stored as [TOML](https://toml.io) files. 15 | If a preset is present in multiple of those files, then the ones further down in the list override ones further up. 16 | The following locations are checked for preset files: 17 | * `/etc/nix-sweep/presets.toml` 18 | * `$XDG_CONFIG_HOME/nix-sweep/presets.toml`/`~/.config/nix-sweep/presets.toml` 19 | * configuration files passed via `-C`/`--config` 20 | 21 | Example: 22 | ```yaml 23 | [housekeeping] 24 | keep-min = 10 25 | remove-older = 14d 26 | interactive = true 27 | gc = false 28 | ``` 29 | 30 | Presets can be used with the `-p` (`--preset`) flag: 31 | ```console 32 | nix-sweep -p housekeeping system 33 | nix-sweep -p only-remove-really-old system 34 | nix-sweep -p nuke-everything system 35 | ``` 36 | 37 | ## Contributing 38 | Code contributions (pull request) are **currently not accepted**. 39 | If you have any feedback, ideas or bugreports feel free to open a [new issue](https://github.com/jzbor/nix-sweep/issues/new) 40 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "cf": { 4 | "inputs": { 5 | "nixpkgs": "nixpkgs" 6 | }, 7 | "locked": { 8 | "lastModified": 1739211036, 9 | "narHash": "sha256-71S/Co02YIm8r6dc740wmO3lj5I3zGHkYVLxOXc6VPM=", 10 | "owner": "jzbor", 11 | "repo": "cornflakes", 12 | "rev": "720b23be91760665d3a5823d1a8dea84944cd30b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "jzbor", 17 | "repo": "cornflakes", 18 | "type": "github" 19 | } 20 | }, 21 | "crane": { 22 | "locked": { 23 | "lastModified": 1754269165, 24 | "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", 25 | "owner": "ipetkov", 26 | "repo": "crane", 27 | "rev": "444e81206df3f7d92780680e45858e31d2f07a08", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "ipetkov", 32 | "repo": "crane", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1726583932, 39 | "narHash": "sha256-zACxiQx8knB3F8+Ze+1BpiYrI+CbhxyWpcSID9kVhkQ=", 40 | "owner": "nixos", 41 | "repo": "nixpkgs", 42 | "rev": "658e7223191d2598641d50ee4e898126768fe847", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "nixos", 47 | "ref": "nixpkgs-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgs_2": { 53 | "locked": { 54 | "lastModified": 1755186698, 55 | "narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=", 56 | "owner": "nixos", 57 | "repo": "nixpkgs", 58 | "rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c", 59 | "type": "github" 60 | }, 61 | "original": { 62 | "owner": "nixos", 63 | "ref": "nixos-unstable", 64 | "repo": "nixpkgs", 65 | "type": "github" 66 | } 67 | }, 68 | "root": { 69 | "inputs": { 70 | "cf": "cf", 71 | "crane": "crane", 72 | "nixpkgs": "nixpkgs_2" 73 | } 74 | } 75 | }, 76 | "root": "root", 77 | "version": 7 78 | } 79 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Cleanup old nix generations"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | crane.url = "github:ipetkov/crane"; 7 | cf.url = "github:jzbor/cornflakes"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, cf, crane, ... }: ((cf.mkLib nixpkgs).flakeForDefaultSystems (system: 11 | let 12 | pkgs = nixpkgs.legacyPackages.${system}; 13 | craneLib = crane.mkLib pkgs; 14 | in { 15 | packages.default = craneLib.buildPackage rec { 16 | src = craneLib.cleanCargoSource ./.; 17 | strictDeps = true; 18 | 19 | cargoArtifacts = craneLib.buildDepsOnly { 20 | inherit src strictDeps; 21 | }; 22 | 23 | nativeBuildInputs = with pkgs; [ 24 | makeWrapper 25 | installShellFiles 26 | ]; 27 | postFixup = '' 28 | wrapProgram $out/bin/nix-sweep \ 29 | --set PATH ${pkgs.lib.makeBinPath [ pkgs.nix ]} 30 | ln -s $out/bin/nix-sweep $out/bin/lix-sweep 31 | ''; 32 | postInstall = '' 33 | echo "Generating man pages" 34 | mkdir ./manpages 35 | $out/bin/nix-sweep man ./manpages 36 | installManPage ./manpages/* 37 | 38 | echo "Generating shell completions" 39 | mkdir ./completions 40 | $out/bin/nix-sweep completions ./completions 41 | installShellCompletion completions/nix-sweep.{bash,fish,zsh} 42 | ''; 43 | }; 44 | 45 | devShells.default = craneLib.devShell { 46 | inherit (self.packages.${system}.default) name; 47 | 48 | # Additional tools 49 | nativeBuildInputs = []; 50 | }; 51 | })) // (let 52 | mkOptions = { lib, pkgs, defaultProfiles }: rec { 53 | enable = lib.mkEnableOption "Enable nix-sweep"; 54 | 55 | package = lib.mkOption { 56 | type = lib.types.package; 57 | inherit (self.packages.${pkgs.system}) default; 58 | description = "nix-sweep package to use for the service"; 59 | }; 60 | 61 | interval = lib.mkOption { 62 | type = lib.types.str; 63 | default = "daily"; 64 | description = "How often to run nix-sweep (see systemd.time(7) for the format)."; 65 | }; 66 | 67 | profiles = lib.mkOption { 68 | type = lib.types.listOf lib.types.str; 69 | default = defaultProfiles; 70 | description = "What profiles to run nix-sweep on."; 71 | }; 72 | 73 | keepNewer = lib.mkOption { 74 | type = lib.types.nullOr lib.types.str; 75 | default = "7d"; 76 | description = "Keep generations newer than days."; 77 | }; 78 | 79 | removeOlder = lib.mkOption { 80 | type = lib.types.nullOr lib.types.str; 81 | default = "30d"; 82 | description = "Delete generations older than days."; 83 | }; 84 | 85 | keepMin = lib.mkOption { 86 | type = lib.types.nullOr lib.types.int; 87 | default = 10; 88 | description = "Keep at least generations."; 89 | }; 90 | 91 | keepMax = lib.mkOption { 92 | type = lib.types.nullOr lib.types.int; 93 | default = null; 94 | description = "Keep at most generations."; 95 | }; 96 | 97 | gc = lib.mkOption { 98 | type = lib.types.bool; 99 | default = false; 100 | description = "Run Nix garbage collection afterwards."; 101 | }; 102 | 103 | gcBigger = lib.mkOption { 104 | type = lib.types.nullOr lib.types.int; 105 | default = null; 106 | description = "Only perform gc if store is bigger than this many GiB"; 107 | }; 108 | 109 | gcQuota = lib.mkOption { 110 | type = lib.types.nullOr lib.types.int; 111 | default = null; 112 | description = "Only perform gc if store uses more than this many % of its device"; 113 | }; 114 | 115 | gcModest = lib.mkOption { 116 | type = lib.types.bool; 117 | default = false; 118 | description = "Stop gc when meeting the quota or limit"; 119 | }; 120 | 121 | gcInterval = lib.mkOption { 122 | type = lib.types.str; 123 | inherit (interval) default; 124 | description = "How often to run garbage collection via nix-sweep (see systemd.time(7) for the format)."; 125 | }; 126 | }; 127 | 128 | mkServiceScripts = { lib, cfg }: { 129 | "nix-sweep" = lib.strings.concatStringsSep " " ([ 130 | "${cfg.package}/bin/nix-sweep" 131 | "cleanout" 132 | "--non-interactive" 133 | ] ++ (if cfg.gc && cfg.gcInterval == cfg.interval then [ "--gc" ] else []) 134 | ++ (if cfg.gcBigger == null then [] else [ "--gc-bigger" (toString cfg.gcBigger) ]) 135 | ++ (if cfg.gcQuota == null then [] else [ "--gc-quota" (toString cfg.gcQuota) ]) 136 | ++ (if cfg.gcModest then [ "--gc-modest" ] else []) 137 | ++ (if cfg.keepMin == null then [] else [ "--keep-min" (toString cfg.keepMin) ]) 138 | ++ (if cfg.keepMax == null then [] else [ "--keep-max" (toString cfg.keepMax) ]) 139 | ++ (if cfg.keepNewer == null then [] else [ "--keep-newer" cfg.keepNewer ]) 140 | ++ (if cfg.removeOlder == null then [] else [ "--remove-older" cfg.removeOlder ]) 141 | ++ cfg.profiles 142 | ); 143 | 144 | "nix-sweep-gc" = lib.strings.concatStringsSep " " ([ 145 | "${cfg.package}/bin/nix-sweep" 146 | "gc" 147 | "--non-interactive" 148 | ] ++ (if cfg.gcBigger == null then [] else [ "--bigger" (toString cfg.gcBigger) ]) 149 | ++ (if cfg.gcQuota == null then [] else [ "--quota" (toString cfg.gcQuota) ]) 150 | ++ (if cfg.gcModest then [ "--modest" ] else []) 151 | ); 152 | }; 153 | 154 | timerAttrs = { 155 | RandomizedDelaySec = "1h"; 156 | FixedRandomDelay = true; 157 | Persistent = true; 158 | }; 159 | in { 160 | ### NixOS ### 161 | nixosModules.default = { lib, config, pkgs, ...}: 162 | let 163 | cfg = config.services.nix-sweep; 164 | in { 165 | options.services.nix-sweep = mkOptions { 166 | inherit lib pkgs; 167 | defaultProfiles = [ "system" ]; 168 | }; 169 | 170 | config = lib.mkIf cfg.enable { 171 | systemd.timers = { 172 | "nix-sweep" = { 173 | wantedBy = [ "timers.target" ]; 174 | timerConfig = { 175 | OnCalendar = cfg.interval; 176 | Unit = "nix-sweep.service"; 177 | } // timerAttrs; 178 | }; 179 | 180 | "nix-sweep-gc" = lib.mkIf (cfg.gc && cfg.gcInterval != cfg.interval) { 181 | wantedBy = [ "timers.target" ]; 182 | timerConfig = { 183 | OnCalendar = cfg.gcInterval; 184 | Unit = "nix-sweep-gc.service"; 185 | } // timerAttrs; 186 | }; 187 | }; 188 | 189 | systemd.services = let 190 | scripts = mkServiceScripts { inherit lib cfg; }; 191 | in { 192 | "nix-sweep" = { 193 | script = scripts.nix-sweep; 194 | serviceConfig = { 195 | Type = "oneshot"; 196 | User = "root"; 197 | }; 198 | }; 199 | 200 | "nix-sweep-gc" = lib.mkIf (cfg.gc && cfg.gcInterval != cfg.interval) { 201 | script = scripts.nix-sweep-gc; 202 | serviceConfig = { 203 | Type = "oneshot"; 204 | User = "root"; 205 | }; 206 | }; 207 | }; 208 | }; 209 | }; 210 | 211 | ### Home Manager ### 212 | homeModules.default = { lib, config, pkgs, ...}: 213 | let 214 | cfg = config.services.nix-sweep; 215 | in { 216 | options.services.nix-sweep = mkOptions { 217 | inherit lib pkgs; 218 | defaultProfiles = [ "home" "user" ]; 219 | }; 220 | 221 | config = lib.mkIf cfg.enable { 222 | systemd.user.timers = { 223 | "nix-sweep" = { 224 | Install.WantedBy = [ "timers.target" ]; 225 | Timer = { 226 | OnCalendar = cfg.interval; 227 | Unit = "nix-sweep.service"; 228 | } // timerAttrs; 229 | }; 230 | 231 | "nix-sweep-gc" = lib.mkIf (cfg.gc && cfg.gcInterval != cfg.interval) { 232 | Install.WantedBy = [ "timers.target" ]; 233 | Timer = { 234 | OnCalendar = cfg.gcInterval; 235 | Unit = "nix-sweep-gc.service"; 236 | } // timerAttrs; 237 | }; 238 | }; 239 | 240 | systemd.user.services = let 241 | scripts = mkServiceScripts { inherit lib cfg; }; 242 | in { 243 | "nix-sweep".Service = { 244 | ExecStart = scripts.nix-sweep; 245 | Type = "oneshot"; 246 | }; 247 | 248 | "nix-sweep-gc".Service = { 249 | ExecStart = scripts.nix-sweep-gc; 250 | Type = "oneshot"; 251 | }; 252 | }; 253 | }; 254 | }; 255 | }); 256 | } 257 | -------------------------------------------------------------------------------- /src/commands/add_root.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix; 2 | use std::{env, fs}; 3 | use std::path::PathBuf; 4 | 5 | use crate::nix::store::Store; 6 | use crate::utils::fmt::FmtWithEllipsis; 7 | use crate::utils::interaction::conclusion; 8 | 9 | use super::Command; 10 | 11 | 12 | #[derive(clap::Args)] 13 | pub struct AddRootCommand { 14 | /// Where to point the gc root to 15 | target: PathBuf, 16 | 17 | /// The preferred name for the gc root 18 | name: Option, 19 | 20 | /// Point the gc root directly to the corresponding store path 21 | #[clap(short, long)] 22 | direct: bool, 23 | } 24 | 25 | 26 | impl Command for AddRootCommand { 27 | fn run(self) -> Result<(), String> { 28 | if !self.target.exists() { 29 | return Err("Target does not exist".to_owned()); 30 | } 31 | 32 | let canonic = fs::canonicalize(&self.target) 33 | .map_err(|e| e.to_string())?; 34 | if !Store::is_valid_path(&canonic) { 35 | return Err("Target does not point to a store path".to_owned()); 36 | } 37 | 38 | let root_target = if self.direct { 39 | canonic 40 | } else { 41 | self.target.clone() 42 | }; 43 | 44 | let gc_parent = match env::var("USER") { 45 | Ok(user) => PathBuf::from(format!("/nix/var/nix/gcroots/per-user/{}", user)), 46 | Err(_) => PathBuf::from("/nix/var/nix/gcroots"), 47 | }; 48 | 49 | let full_gc_path = match self.name { 50 | Some(n) => gc_parent.join(n), 51 | None => { 52 | let mut count = 0; 53 | while gc_parent.join(format!("gcroot-{}", count)).is_symlink() { 54 | count += 1; 55 | } 56 | gc_parent.join(format!("gcroot-{}", count)) 57 | }, 58 | }; 59 | 60 | unix::fs::symlink(&root_target, &full_gc_path) 61 | .map_err(|e| e.to_string())?; 62 | 63 | let target_str = root_target.to_string_lossy().to_string(); 64 | let target_len = target_str.len(); 65 | let root_str = full_gc_path.to_string_lossy().to_string(); 66 | let root_len = root_str.len(); 67 | conclusion(&format!("Added root for {}\n at {}\n", 68 | FmtWithEllipsis::fitting_terminal(target_str, target_len, 18), 69 | FmtWithEllipsis::fitting_terminal(root_str, root_len, 18))); 70 | 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/commands/analyze.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::{self, Reverse}; 2 | use std::io; 3 | use std::path::PathBuf; 4 | 5 | use colored::Colorize; 6 | use rayon::slice::ParallelSliceMut; 7 | 8 | use crate::utils::terminal::terminal_width; 9 | use crate::utils::{files, journal}; 10 | use crate::utils::fmt::*; 11 | use crate::utils::interaction::{announce, resolve}; 12 | use crate::utils::journal::*; 13 | use crate::nix::profiles::Profile; 14 | use crate::nix::roots::GCRoot; 15 | use crate::nix::store::{Store, StorePath, NIX_STORE}; 16 | 17 | 18 | #[derive(clap::Args)] 19 | pub struct AnalyzeCommand { 20 | /// Don't analyze system journal 21 | #[clap(long)] 22 | no_journal: bool, 23 | 24 | /// Show all gc roots and profiles 25 | #[clap(short, long)] 26 | all: bool, 27 | 28 | /// Show the full path for gc roots and profiles 29 | #[clap(short, long)] 30 | full_paths: bool, 31 | 32 | /// Print information about dead paths 33 | /// 34 | /// Note that this might slow down the program considerably. 35 | #[clap(short, long)] 36 | dead: bool, 37 | 38 | /// Print more information about the closures of *.drv paths 39 | /// 40 | /// Note that this might slow down the program considerably. 41 | #[clap(long)] 42 | drv_closures: bool, 43 | 44 | 45 | /// Show n gc-roots and profiles 46 | #[clap(long, default_value_t = 5)] 47 | show: usize, 48 | } 49 | 50 | struct StoreAnalysis { 51 | nstore_paths: usize, 52 | ndrv_paths: usize, 53 | store_size_naive: u64, 54 | store_size_hl: u64, 55 | drv_size: u64, 56 | journal_size: Option, 57 | blkdev_info: Option<(String, u64)>, 58 | dead_info: Option<(usize, u64)>, 59 | drv_closure_info: Option<(usize, u64)>, 60 | } 61 | 62 | struct ProfileAnalysis { 63 | profiles: Vec<(PathBuf, Option, Option)>, 64 | drained: usize, 65 | } 66 | 67 | struct GCRootsAnalysis { 68 | gc_roots: Vec<(GCRoot, Option)>, 69 | drained: usize, 70 | } 71 | 72 | 73 | 74 | impl StoreAnalysis { 75 | fn create(journal: bool, dead: bool, drv_closures: bool) -> Result { 76 | let store_paths = Store::all_paths()?; 77 | let nstore_paths = store_paths.len(); 78 | let drv_paths: Vec<_> = store_paths.into_iter().filter(StorePath::is_drv).collect(); 79 | let ndrv_paths = drv_paths.len(); 80 | 81 | let mut store_size_naive = 0; 82 | let mut store_size_hl = 0; 83 | let mut drv_size = 0; 84 | let mut journal_size = None; 85 | let mut dead_info = None; 86 | let mut drv_closure_info = None; 87 | 88 | rayon::scope(|s| { 89 | s.spawn(|_| { 90 | store_size_naive = resolve(Store::size_naive()); 91 | }); 92 | 93 | s.spawn(|_| { 94 | store_size_hl = resolve(Store::size()); 95 | }); 96 | 97 | s.spawn(|_| { 98 | if journal && journal_exists() { 99 | journal_size = Some(journal::journal_size()); 100 | } 101 | }); 102 | 103 | s.spawn(|_| { 104 | let paths: Vec<_> = drv_paths.iter().map(|sp| sp.path().clone()).collect(); 105 | drv_size = files::dir_size_considering_hardlinks_all(&paths); 106 | }); 107 | 108 | if drv_closures { 109 | s.spawn(|_| { 110 | let refs: Vec<_> = drv_paths.iter().collect(); 111 | let drv_closure: Vec<_> = StorePath::full_closure(&refs).into_iter().collect(); 112 | let ndrv_closure = drv_closure.len(); 113 | let paths: Vec<_> = drv_closure.iter().map(|sp| sp.path().clone()).collect(); 114 | let drv_closure_size = files::dir_size_considering_hardlinks_all(&paths); 115 | drv_closure_info = Some((ndrv_closure, drv_closure_size)); 116 | }); 117 | } 118 | 119 | if dead { 120 | s.spawn(|_| { 121 | let dead_paths = resolve(Store::paths_dead()); 122 | let paths: Vec<_> = dead_paths.iter().map(|sp| sp.path().clone()).collect(); 123 | dead_info = Some((dead_paths.len(), files::dir_size_considering_hardlinks_all(&paths))); 124 | }) 125 | } 126 | }); 127 | 128 | let blkdev_info = Store::blkdev() 129 | .and_then(|d| files::get_blkdev_size(&d).map(|s| (d, s))) 130 | .ok(); 131 | 132 | Ok(StoreAnalysis { 133 | nstore_paths, store_size_naive, store_size_hl, 134 | ndrv_paths, drv_size, 135 | blkdev_info, drv_closure_info, dead_info, 136 | journal_size, 137 | }) 138 | } 139 | 140 | fn store_size(&self) -> u64 { 141 | cmp::min(self.store_size_naive, self.store_size_hl) 142 | } 143 | 144 | fn hardlinking_savings(&self) -> u64 { 145 | self.store_size_naive - self.store_size_hl 146 | } 147 | 148 | fn report(&self) -> Result<(), String> { 149 | announce("System:"); 150 | 151 | print!("{:<20} {}", format!("{}:", NIX_STORE), FmtSize::new(self.store_size()).left_pad().yellow()); 152 | if let Some((dev, dev_size)) = &self.blkdev_info { 153 | let percent_str = FmtPercentage::new(self.store_size(), *dev_size).left_pad(); 154 | println!("\t({} of {} [{}])", percent_str, dev, size::Size::from_bytes(*dev_size)); 155 | } else { 156 | println!(); 157 | } 158 | 159 | if let Some(journal_size) = self.journal_size { 160 | print!("{:<20} {:>11}", format!("{}:", JOURNAL_PATH), FmtSize::new(journal_size).left_pad().yellow()); 161 | 162 | if let Some((dev, size)) = &self.blkdev_info { 163 | let percent_str = FmtPercentage::new(journal_size, *size).left_pad(); 164 | println!("\t({} of {} [{}])", percent_str, dev, FmtSize::new(*size)); 165 | } else { 166 | println!(); 167 | } 168 | } 169 | 170 | let mut max_metric_len = 0; 171 | max_metric_len = cmp::max(max_metric_len, self.nstore_paths.to_string().len()); 172 | max_metric_len = cmp::max(max_metric_len, self.ndrv_paths.to_string().len()); 173 | if let Some((ndrv_closure, _)) = self.drv_closure_info { 174 | max_metric_len = cmp::max(max_metric_len, ndrv_closure.to_string().len()); 175 | } 176 | if let Some((ndead, _)) = self.drv_closure_info { 177 | max_metric_len = cmp::max(max_metric_len, ndead.to_string().len()); 178 | } 179 | if self.store_size_naive > self.store_size_hl { 180 | max_metric_len = cmp::max(max_metric_len, FmtSize::new(self.hardlinking_savings()).to_string().len()); 181 | } 182 | 183 | let max_desc_len = 34; 184 | 185 | println!(); 186 | println!("{:metric_width$}", 187 | "Number of store paths:", 188 | self.nstore_paths.to_string().bright_blue(), 189 | desc_width = max_desc_len, 190 | metric_width = max_metric_len, 191 | ); 192 | println!("{:metric_width$}\t{} {}", 193 | "Derivation files (*.drv) in store:", 194 | self.ndrv_paths.to_string().cyan(), 195 | FmtSize::new(self.drv_size).left_pad().cyan(), 196 | FmtPercentage::new(self.drv_size, self.store_size_hl).bracketed().left_pad().cyan(), 197 | desc_width = max_desc_len, 198 | metric_width = max_metric_len, 199 | ); 200 | if let Some((ndrv_closure, drv_closure_size)) = self.drv_closure_info { 201 | println!("{:metric_width$}\t{} {}", 202 | "Closure of *.drv files in store:", 203 | ndrv_closure.to_string().bright_cyan(), 204 | FmtSize::new(drv_closure_size).left_pad().bright_cyan(), 205 | FmtPercentage::new(drv_closure_size, self.store_size_hl).bracketed().left_pad().bright_cyan(), 206 | desc_width = max_desc_len, 207 | metric_width = max_metric_len, 208 | ); 209 | } 210 | if let Some((ndead, dead_size)) = self.dead_info { 211 | println!("{:metric_width$}\t{} {}", 212 | "Dead paths (collectable garbage):", 213 | ndead.to_string().magenta(), 214 | FmtSize::new(dead_size).left_pad().magenta(), 215 | FmtPercentage::new(dead_size, self.store_size_hl).bracketed().left_pad().magenta(), 216 | desc_width = max_desc_len, 217 | metric_width = max_metric_len, 218 | ); 219 | } 220 | 221 | println!(); 222 | if self.store_size_naive > self.store_size_hl { 223 | println!("{:metric_width$}", 224 | "Hardlinking currently saves:", 225 | FmtSize::new(self.hardlinking_savings()).to_string().green(), 226 | desc_width = max_desc_len, 227 | metric_width = max_metric_len, 228 | ); 229 | } else { 230 | let pre = "Note:".yellow(); 231 | if terminal_width(io::stdout()).unwrap_or(80) <= 80 { 232 | println!("{pre} It seems like your Nix store is not optimized. You might be able to save space by running `nix-store --optimise` or setting `auto-optimise-store = true`."); 233 | } else { 234 | println!("{pre} It seems like your Nix store is not optimized. You might be able to save"); 235 | println!("space by running `nix-store --optimise` or setting `auto-optimise-store = true`."); 236 | } 237 | } 238 | 239 | Ok(()) 240 | } 241 | } 242 | 243 | impl ProfileAnalysis { 244 | fn create(all: bool, show: usize) -> Result { 245 | let profile_paths = GCRoot::profile_paths()?; 246 | 247 | let mut profiles = Vec::with_capacity(profile_paths.len()); 248 | for path in profile_paths { 249 | let profile = Profile::from_path(path.clone()).ok(); 250 | let size = profile.as_ref() 251 | .and_then(|p| Profile::full_closure_size(p).ok()); 252 | profiles.push((path, profile, size)); 253 | } 254 | 255 | profiles.par_sort_by_key(|(p, _, _)| p.clone()); 256 | profiles.par_sort_by_key(|(_, _, s)| Reverse(*s)); 257 | 258 | let drained = if !all { 259 | profiles.drain(cmp::min(show, profiles.len())..).count() 260 | } else { 261 | 0 262 | }; 263 | 264 | Ok(ProfileAnalysis { profiles, drained }) 265 | } 266 | 267 | fn report(&self, full_paths: bool, store_size: u64) -> Result<(), String> { 268 | announce("Profiles:"); 269 | 270 | let max_path_len = self.profiles.iter() 271 | .map(|(p, _, _)| p.to_string_lossy().len()) 272 | .max() 273 | .unwrap_or(0); 274 | 275 | for (path, profile, size) in &self.profiles { 276 | let path = path.to_string_lossy().to_string(); 277 | let path_str = FmtWithEllipsis::fitting_terminal(path, max_path_len, 30) 278 | .truncate_if(!full_paths) 279 | .right_pad(); 280 | let size_str = FmtOrNA::mapped(*size, FmtSize::new) 281 | .left_pad(); 282 | let percentage_str = FmtOrNA::mapped(*size, |s| FmtPercentage::new(s, store_size) 283 | .bracketed()) 284 | .or_empty() 285 | .left_pad(); 286 | let generations_str = match profile { 287 | Some(profile) => format!("[{} gens]", profile.generations().len()), 288 | None => "n/a".to_owned(), 289 | }; 290 | 291 | println!("{} {} {} {:>14}", 292 | path_str, 293 | size_str.yellow(), 294 | percentage_str, 295 | generations_str.bright_blue(), 296 | ); 297 | } 298 | 299 | if self.drained != 0 { 300 | println!("...and {} more", self.drained); 301 | } 302 | 303 | Ok(()) 304 | } 305 | } 306 | 307 | impl GCRootsAnalysis { 308 | fn create(all: bool, show: usize) -> Result { 309 | let mut gc_roots: Vec<_> = GCRoot::all(false, false, false)? 310 | .into_iter() 311 | .filter(|r| r.is_independent()) 312 | .map(|r| match r.store_path().cloned() { 313 | Ok(path) => (r, Some(path.closure_size())), 314 | Err(_) => (r, None), 315 | }) 316 | .collect(); 317 | 318 | gc_roots.par_sort_by_key(|(r, _)| r.link().clone()); 319 | gc_roots.dedup_by_key(|(r, _)| r.link().clone()); 320 | gc_roots.par_sort_by_key(|(_, s)| Reverse(*s)); 321 | 322 | let drained = if !all { 323 | gc_roots.drain(cmp::min(show, gc_roots.len())..).count() 324 | } else { 325 | 0 326 | }; 327 | 328 | Ok(GCRootsAnalysis { gc_roots, drained }) 329 | } 330 | 331 | fn report(&self, full_paths: bool, store_size: u64) -> Result<(), String> { 332 | announce("GC Roots:"); 333 | 334 | let max_link_len = self.gc_roots.iter() 335 | .map(|(r, _)| r.link().to_string_lossy().len()) 336 | .max() 337 | .unwrap_or(0); 338 | for (root, size) in &self.gc_roots { 339 | let link = root.link().to_string_lossy().to_string(); 340 | let link_str = FmtWithEllipsis::fitting_terminal(link, max_link_len, 20) 341 | .truncate_if(!full_paths) 342 | .right_pad(); 343 | let size_str = FmtOrNA::mapped(*size, FmtSize::new) 344 | .left_pad(); 345 | let percentage_str = FmtOrNA::mapped(*size, |s| FmtPercentage::new(s, store_size).bracketed()) 346 | .or_empty() 347 | .left_pad(); 348 | 349 | println!("{} {} {}", 350 | link_str, 351 | size_str.yellow(), 352 | percentage_str, 353 | ); 354 | } 355 | if self.drained != 0 { 356 | println!("...and {} more", self.drained); 357 | } 358 | 359 | println!(); 360 | let roots: Vec<_> = self.gc_roots.iter() 361 | .map(|tup| tup.0.clone()) 362 | .collect(); 363 | let total_size = GCRoot::full_closure_size(&roots)?; 364 | let size_str = FmtSize::new(total_size).to_string(); 365 | let percentage_str = FmtPercentage::new(total_size, store_size) 366 | .bracketed() 367 | .left_pad(); 368 | println!("Total closure size of independent gc roots:\t{} {}", size_str.yellow(), percentage_str); 369 | 370 | Ok(()) 371 | } 372 | } 373 | 374 | 375 | impl super::Command for AnalyzeCommand { 376 | fn run(self) -> Result<(), String> { 377 | let mut store_analysis = Err("Store indexing not completed yet".to_owned()); 378 | let mut profile_analysis = Err("Profile indexing not completed yet".to_owned()); 379 | let mut gc_roots_analysis = Err("Gc roots indexing not completed yet".to_owned()); 380 | 381 | eprintln!("Indexing store, profiles and gc roots..."); 382 | rayon::scope(|s| { 383 | s.spawn(|_| { 384 | store_analysis = StoreAnalysis::create(!self.no_journal, self.dead, self.drv_closures); 385 | eprintln!("Finished store indexing"); 386 | }); 387 | 388 | s.spawn(|_| { 389 | profile_analysis = ProfileAnalysis::create(self.all, self.show); 390 | eprintln!("Finished profile indexing"); 391 | }); 392 | 393 | s.spawn(|_| { 394 | gc_roots_analysis = GCRootsAnalysis::create(self.all, self.show); 395 | eprintln!("Finished gc roots indexing"); 396 | }); 397 | }); 398 | 399 | let store_analysis = store_analysis?; 400 | let profile_analysis = profile_analysis?; 401 | let gc_roots_analysis = gc_roots_analysis?; 402 | 403 | 404 | store_analysis.report()?; 405 | profile_analysis.report(self.full_paths, store_analysis.store_size())?; 406 | gc_roots_analysis.report(self.full_paths, store_analysis.store_size())?; 407 | 408 | println!(); 409 | Ok(()) 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /src/commands/cleanout.rs: -------------------------------------------------------------------------------- 1 | use std::path; 2 | use std::str::FromStr; 3 | 4 | use colored::Colorize; 5 | 6 | use crate::config::{self, ConfigPreset}; 7 | use crate::utils::interaction::*; 8 | use crate::utils::fmt::FmtAge; 9 | use crate::nix::profiles::Profile; 10 | 11 | use super::gc::GCCommand; 12 | 13 | 14 | #[derive(clap::Args)] 15 | pub struct CleanoutCommand { 16 | /// Settings for clean out criteria 17 | #[clap(short, long, default_value_t = config::DEFAULT_PRESET.to_owned())] 18 | preset: String, 19 | 20 | /// Alternative config file 21 | #[clap(short('C'), long)] 22 | config: Option, 23 | 24 | #[clap(flatten)] 25 | cleanout_config: config::ConfigPreset, 26 | 27 | /// List, but do not actually delete old generations 28 | #[clap(short, long)] 29 | dry_run: bool, 30 | 31 | /// Do not calculate the size of generations 32 | #[clap(long)] 33 | no_size: bool, 34 | 35 | /// Profiles to clean out; valid values: system, user, home, 36 | #[clap(required = true)] 37 | profiles: Vec, 38 | } 39 | 40 | impl super::Command for CleanoutCommand { 41 | fn run(self) -> Result<(), String> { 42 | self.cleanout_config.validate()?; 43 | let config = ConfigPreset::load(&self.preset, self.config.as_ref())? 44 | .override_with(&self.cleanout_config); 45 | let interactive = config.interactive.is_none() || config.interactive == Some(true); 46 | 47 | for profile_str in self.profiles { 48 | let mut profile = Profile::from_str(&profile_str)?; 49 | profile.apply_markers(&config); 50 | 51 | profile.list_generations(!self.no_size, true); 52 | 53 | if self.dry_run { 54 | conclusion("Skipping generation removal (dry run)"); 55 | } else if profile.count_marked() == 0 { 56 | conclusion("Nothing to do"); 57 | } else if interactive { 58 | let confirmation = ask("Do you want to delete the marked generations?", false); 59 | if confirmation { 60 | remove_generations(&profile); 61 | } else { 62 | conclusion("Not touching profile\n"); 63 | } 64 | } else { 65 | remove_generations(&profile); 66 | } 67 | } 68 | 69 | if config.gc == Some(true) { 70 | let gc_cmd = GCCommand::new(interactive, self.dry_run, config.gc_bigger, config.gc_quota, config.gc_modest); 71 | gc_cmd.run()?; 72 | } 73 | 74 | Ok(()) 75 | } 76 | } 77 | 78 | fn remove_generations(profile: &Profile) { 79 | announce(&format!("Removing old generations for profile {}", profile.path().to_string_lossy())); 80 | for generation in profile.generations() { 81 | let age_str = FmtAge::new(generation.age()).to_string(); 82 | if generation.marked() { 83 | println!("{}", format!("-> Removing generation {} ({} old)", generation.number(), age_str).bright_blue()); 84 | resolve(generation.remove()); 85 | } else { 86 | println!("{}", format!("-> Keeping generation {} ({} old)", generation.number(), age_str).bright_black()); 87 | } 88 | } 89 | println!(); 90 | } 91 | 92 | -------------------------------------------------------------------------------- /src/commands/completions.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path; 3 | 4 | use clap::CommandFactory; 5 | use clap_complete::Shell; 6 | 7 | 8 | #[derive(clap::Args)] 9 | pub struct CompletionsCommand { 10 | directory: path::PathBuf, 11 | } 12 | 13 | impl super::Command for CompletionsCommand { 14 | fn run(self) -> Result<(), String> { 15 | let mut command = crate::Args::command(); 16 | let shells = &[ 17 | (Shell::Bash, "bash"), 18 | (Shell::Zsh, "zsh"), 19 | (Shell::Fish, "fish"), 20 | (Shell::PowerShell, "ps1"), 21 | (Shell::Elvish, "elv"), 22 | ]; 23 | 24 | for (shell, ending) in shells { 25 | let mut file = fs::File::create(self.directory.join(format!("nix-sweep.{}", ending))) 26 | .map_err(|e| e.to_string())?; 27 | clap_complete::aot::generate(*shell, &mut command, "nix-sweep", &mut file); 28 | } 29 | 30 | Ok(()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/gc.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::files; 2 | use crate::utils::fmt::{FmtPercentage, FmtSize}; 3 | use crate::utils::interaction::{announce, ask}; 4 | use crate::nix::store::Store; 5 | 6 | 7 | const GIB: u64 = 1024 * 1024 * 1024; 8 | 9 | 10 | #[derive(clap::Args)] 11 | pub struct GCCommand { 12 | /// Do not ask before running garbage collection 13 | #[clap(short('n'), long("non-interactive"), action = clap::ArgAction::SetFalse)] // this is very confusing, but works 14 | interactive: bool, 15 | 16 | /// Ask before running garbage collection 17 | #[clap(short('i'), long("interactive"), overrides_with = "interactive")] 18 | _non_interactive: bool, 19 | 20 | /// Only perform gc if the store is bigger than BIGGER Gibibytes. 21 | #[clap(short, long)] 22 | bigger: Option, 23 | 24 | /// Only perform gc if the store uses more than QUOTA% of its device. 25 | #[clap(short, long, value_parser=clap::value_parser!(u64).range(1..100))] 26 | quota: Option, 27 | 28 | /// Don't actually run garbage collection 29 | #[clap(short, long)] 30 | dry_run: bool, 31 | 32 | /// Collect just as much garbage as to match --bigger or --quota 33 | /// 34 | /// The desired target size of the store is calculated based on --bigger or --quota and then 35 | /// rewritten to match the --max-freed option of nix-store(1). Garbage collection is then 36 | /// performed stopping, as soon as the desired target size is met. 37 | #[clap(short, long)] 38 | modest: bool, 39 | } 40 | 41 | impl GCCommand { 42 | pub fn new(interactive: bool, dry_run: bool, bigger: Option, quota: Option, modest: bool) -> Self { 43 | GCCommand { interactive, dry_run, bigger, quota, _non_interactive: !interactive, modest } 44 | } 45 | } 46 | 47 | impl super::Command for GCCommand { 48 | fn run(self) -> Result<(), String> { 49 | announce("Starting garbage collection"); 50 | if let Some(bigger) = self.bigger { 51 | eprintln!("Calculating store size..."); 52 | let size = Store::size()?; 53 | eprintln!("Store has a size of {} (threshold: {})", FmtSize::new(size), FmtSize::new(bigger * GIB)); 54 | if size <= bigger * GIB { 55 | let msg = format!("Nothing to do: Store size is at {} ({} below the threshold of {})", 56 | FmtSize::new(size), 57 | FmtSize::new(bigger * GIB - size), 58 | FmtSize::new(bigger * GIB)); 59 | eprintln!("\n-> {msg}"); 60 | return Ok(()); 61 | } 62 | } 63 | 64 | if let Some(quota) = self.quota { 65 | eprintln!("Calculating store size..."); 66 | let size = Store::size()?; 67 | let blkdev_size = files::get_blkdev_size(&Store::blkdev()?)?; 68 | let percentage = size * 100 / blkdev_size; 69 | eprintln!("Store uses {percentage}% (quota: {quota}%)"); 70 | if percentage <= quota { 71 | let msg = format!("Nothing to do: Device usage of store is at {} (below the threshold of {})", 72 | FmtPercentage::new(size, blkdev_size), 73 | FmtPercentage::new(quota, 100)); 74 | eprintln!("\n-> {msg}"); 75 | return Ok(()); 76 | } 77 | } 78 | 79 | let max_freed = if self.modest { 80 | if let Some(bigger) = self.bigger { 81 | Some(Store::size()? - bigger * GIB) 82 | } else if let Some(quota) = self.quota { 83 | let blkdev_size = files::get_blkdev_size(&Store::blkdev()?)?; 84 | Some(Store::size()? - quota * blkdev_size / 100) 85 | } else { 86 | return Err("Cannot use --modest without --bigger or --quota being".to_owned()); 87 | } 88 | } else { 89 | None 90 | }; 91 | 92 | if let Some(bytes) = max_freed { 93 | eprintln!("Freeing up to {} (--modest)", FmtSize::new(bytes)); 94 | } 95 | 96 | if self.dry_run { 97 | eprintln!("\n-> Skipping garbage collection (dry run)"); 98 | } else if !self.interactive || ask("\nDo you want to perform garbage collection now?", false) { 99 | eprintln!("Starting garbage collector"); 100 | Store::gc(max_freed)? 101 | } 102 | 103 | Ok(()) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/commands/gc_roots.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Reverse; 2 | use std::time::Duration; 3 | 4 | use colored::Colorize; 5 | use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}; 6 | use rayon::slice::ParallelSliceMut; 7 | 8 | use crate::utils::fmt::*; 9 | use crate::utils::interaction::announce; 10 | use crate::utils::ordered_channel::OrderedChannel; 11 | use crate::nix::roots::GCRoot; 12 | 13 | #[derive(clap::Args)] 14 | pub struct GCRootsCommand { 15 | /// Present the long, verbose form 16 | #[clap(short, long)] 17 | long: bool, 18 | 19 | /// Only print the paths 20 | #[clap(long)] 21 | paths: bool, 22 | 23 | /// Present list as tsv 24 | #[clap(long)] 25 | tsv: bool, 26 | 27 | /// Include profiles 28 | #[clap(short('p'), long)] 29 | include_profiles: bool, 30 | 31 | /// Include current 32 | #[clap(short('c'), long)] 33 | include_current: bool, 34 | 35 | /// Include gc roots that are referenced, but could not be found 36 | #[clap(long)] 37 | include_missing: bool, 38 | 39 | /// Include gc roots from running processes 40 | #[clap(long)] 41 | include_proc: bool, 42 | 43 | /// Exclude gc roots, whose store path is not accessible 44 | #[clap(short, long)] 45 | exclude_inaccessible: bool, 46 | 47 | /// Only show gc roots older than OLDER 48 | #[clap(long, value_parser = |s: &str| duration_str::parse_std(s))] 49 | older: Option, 50 | 51 | /// Only show gc roots newer than NEWER 52 | #[clap(long, value_parser = |s: &str| duration_str::parse_std(s))] 53 | newer: Option, 54 | 55 | /// Do not calculate the size of generations 56 | #[clap(long)] 57 | no_size: bool, 58 | 59 | /// Query Nix for gc roots instead of enumerating the directory 60 | #[clap(long)] 61 | query_nix: bool, 62 | } 63 | 64 | impl super::Command for GCRootsCommand { 65 | fn run(self) -> Result<(), String> { 66 | let print_size = !(self.no_size || self.paths); 67 | let mut roots = GCRoot::all(self.query_nix, self.include_proc, self.include_missing)?; 68 | let nroots_total = roots.len(); 69 | roots.par_sort_by_key(|r| r.link().clone()); 70 | roots.dedup_by_key(|r| r.link().clone()); 71 | roots.par_sort_by_key(|r| Reverse(r.age().cloned().unwrap_or(Duration::MAX))); 72 | 73 | roots = GCRoot::filter_roots(roots, self.include_profiles, self.include_current, 74 | !self.exclude_inaccessible, self.older, self.newer); 75 | let nroots_listed = roots.len(); 76 | 77 | if !self.tsv && !self.paths { 78 | announce(&format!("Listing {nroots_listed} gc roots (out of {nroots_total} total)")); 79 | } 80 | 81 | let max_link_len = roots.iter() 82 | .map(|r| r.link().to_string_lossy().len()) 83 | .max() 84 | .unwrap_or(0); 85 | 86 | let ordered_channel: OrderedChannel<_> = OrderedChannel::new(); 87 | rayon::join( || { 88 | roots.par_iter() 89 | .enumerate() 90 | .map(|(i, root)| match print_size { 91 | true => (i, (root, root.closure_size().ok())), 92 | false => (i, (root, None)), 93 | }) 94 | .for_each(|(i, tup)| ordered_channel.put(i, tup)); 95 | }, || { 96 | for (root, closure_size) in ordered_channel.iter(nroots_listed) { 97 | if self.paths { 98 | println!("{}", root.link().to_string_lossy()); 99 | } else if self.tsv { 100 | let path = root.store_path().as_ref().map(|p| p.path().to_string_lossy().to_string()) 101 | .unwrap_or_default(); 102 | if self.no_size { 103 | println!("{}\t{}", root.link().to_string_lossy(), path); 104 | } else { 105 | let size = closure_size.as_ref().map(|s| s.to_string()) 106 | .unwrap_or(String::from("n/a")); 107 | println!("{}\t{}\t{}", root.link().to_string_lossy(), path, size); 108 | } 109 | } else if self.long { 110 | root.print_fancy(closure_size, !self.no_size); 111 | } else { 112 | root.print_concise(closure_size, !self.no_size, max_link_len); 113 | } 114 | } 115 | }); 116 | 117 | if !self.paths && !self.tsv && !self.no_size { 118 | println!(); 119 | let full_closure = GCRoot::full_closure(&roots); 120 | let total_size = GCRoot::full_closure_size(&roots)?; 121 | println!("Estimated total size: {} ({} store paths)", 122 | FmtSize::new(total_size).to_string().yellow(), full_closure.len()); 123 | } 124 | 125 | if !self.paths && !self.tsv { 126 | println!(); 127 | } 128 | 129 | Ok(()) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/commands/generations.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use crate::nix::profiles::Profile; 4 | 5 | 6 | #[derive(clap::Args)] 7 | pub struct GenerationsCommand { 8 | /// Only print the paths 9 | #[clap(long)] 10 | paths: bool, 11 | 12 | /// Present list as tsv 13 | #[clap(long)] 14 | tsv: bool, 15 | 16 | /// Do not calculate the size of generations 17 | #[clap(long)] 18 | no_size: bool, 19 | 20 | /// Profiles to list; valid values: system, user, home, 21 | #[clap(required = true)] 22 | profiles: Vec, 23 | } 24 | 25 | impl super::Command for GenerationsCommand { 26 | fn run(self) -> Result<(), String> { 27 | for profile_str in self.profiles { 28 | let profile = Profile::from_str(&profile_str)?; 29 | 30 | if self.paths { 31 | for generation in profile.generations() { 32 | println!("{}", generation.path().to_string_lossy()); 33 | } 34 | } else if self.tsv { 35 | for generation in profile.generations() { 36 | let num = generation.number(); 37 | let path = generation.path().to_string_lossy(); 38 | let store_path = generation.store_path() 39 | .map(|sp| sp.path().to_string_lossy().to_string()) 40 | .unwrap_or_default(); 41 | if self.no_size { 42 | println!("{num}\t{path}\t{store_path}"); 43 | } else { 44 | let size = generation.store_path() 45 | .map(|sp| sp.closure_size().to_string()) 46 | .unwrap_or_default(); 47 | println!("{num}\t{path}\t{store_path}\t{size}"); 48 | 49 | } 50 | } 51 | } else { 52 | profile.list_generations(!self.no_size, false); 53 | println!(); 54 | } 55 | } 56 | 57 | Ok(()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/man.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path}; 2 | 3 | use clap::CommandFactory; 4 | 5 | 6 | #[derive(clap::Args)] 7 | pub struct ManCommand { 8 | directory: path::PathBuf, 9 | } 10 | 11 | impl super::Command for ManCommand { 12 | fn run(self) -> Result<(), String> { 13 | // export main 14 | let man = clap_mangen::Man::new(crate::Args::command()); 15 | let mut buffer: Vec = Default::default(); 16 | man.render(&mut buffer) 17 | .map_err(|e| e.to_string())?; 18 | let file = self.directory.join("nix-sweep.1"); 19 | fs::write(&file, buffer) 20 | .map_err(|e| e.to_string())?; 21 | println!("Written {}", file.to_string_lossy()); 22 | 23 | for subcommand in crate::Args::command().get_subcommands() { 24 | let man = clap_mangen::Man::new(subcommand.clone()); 25 | let mut buffer: Vec = Default::default(); 26 | man.render(&mut buffer) 27 | .map_err(|e| e.to_string())?; 28 | let file = self.directory.join(format!("nix-sweep-{subcommand}.1")); 29 | fs::write(&file, buffer) 30 | .map_err(|e| e.to_string())?; 31 | println!("Written {}", file.to_string_lossy()); 32 | } 33 | 34 | Ok(()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add_root; 2 | pub mod analyze; 3 | pub mod cleanout; 4 | pub mod completions; 5 | pub mod gc; 6 | pub mod gc_roots; 7 | pub mod generations; 8 | pub mod man; 9 | pub mod path_info; 10 | pub mod tidyup_gc_roots; 11 | pub mod presets; 12 | 13 | pub trait Command: clap::Args { 14 | fn run(self) -> Result<(), String>; 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/path_info.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::PathBuf; 3 | 4 | use colored::Colorize; 5 | 6 | use crate::utils::fmt::*; 7 | use crate::nix::store::StorePath; 8 | 9 | 10 | #[derive(clap::Args)] 11 | pub struct PathInfoCommand { 12 | /// Paths to get information about 13 | #[clap(required = true)] 14 | paths: Vec, 15 | } 16 | 17 | impl super::Command for PathInfoCommand { 18 | fn run(self) -> Result<(), String> { 19 | for path in &self.paths { 20 | let metadata = fs::symlink_metadata(path) 21 | .map_err(|e| e.to_string())?; 22 | let store_path = StorePath::from_symlink(path)?; 23 | let closure = store_path.closure()?; 24 | let size = store_path.size(); 25 | let naive_size = store_path.size_naive(); 26 | let closure_size = store_path.closure_size(); 27 | let naive_closure_size = store_path.closure_size_naive(); 28 | 29 | println!(); 30 | 31 | if metadata.is_symlink() { 32 | println!("{}", path.to_string_lossy()); 33 | println!(" {}", format!("-> {}", store_path.path().to_string_lossy()).bright_black()); 34 | } else { 35 | println!("{}", store_path.path().to_string_lossy()); 36 | } 37 | 38 | println!(); 39 | 40 | print!(" size: {}", FmtSize::new(size).left_pad().bright_yellow()); 41 | if naive_size > size { 42 | print!(" \t{}", FmtSize::new(naive_size) 43 | .with_prefix::<18>("hardlinking saves ".to_owned()) 44 | .bracketed() 45 | .right_pad() 46 | ); 47 | } 48 | println!(); 49 | 50 | print!(" closure size: {}", FmtSize::new(closure_size).left_pad().yellow()); 51 | if naive_closure_size > closure_size { 52 | print!(" \t{}", FmtSize::new(naive_closure_size - closure_size) 53 | .with_prefix::<18>("hardlinking saves ".to_owned()) 54 | .bracketed() 55 | .right_pad() 56 | ); 57 | } 58 | println!(); 59 | 60 | println!(" paths in closure: {:>align$}", closure.len().to_string().bright_blue(), align = FmtSize::MAX_WIDTH); 61 | println!(); 62 | } 63 | 64 | Ok(()) 65 | 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/presets.rs: -------------------------------------------------------------------------------- 1 | use std::path; 2 | 3 | use colored::Colorize; 4 | 5 | use crate::config::ConfigPreset; 6 | use crate::utils::fmt::FmtWithEllipsis; 7 | use crate::HashMap; 8 | 9 | 10 | #[derive(clap::Args)] 11 | pub struct PresetsCommand { 12 | /// Alternative config file 13 | #[clap(short('C'), long)] 14 | config: Option, 15 | 16 | /// Only print the names 17 | #[clap(long)] 18 | names: bool, 19 | 20 | #[command(flatten)] 21 | queries: Queries, 22 | } 23 | 24 | #[derive(clap::Args, Clone)] 25 | #[group(required = true, multiple = false)] 26 | pub struct Queries { 27 | #[clap(short, long)] 28 | list: bool, 29 | 30 | #[clap(short, long)] 31 | show: Option, 32 | 33 | #[clap(short('a'), long)] 34 | show_all: bool, 35 | } 36 | 37 | impl super::Command for PresetsCommand { 38 | fn run(self) -> Result<(), String> { 39 | 40 | if self.queries.list { 41 | let mut presets: Vec<_> = ConfigPreset::available(self.config.as_ref())?.into_iter().collect(); 42 | presets.sort(); 43 | 44 | if self.names { 45 | presets.iter() 46 | .for_each(|(name, _)| println!("{name}")); 47 | } else { 48 | let preset_len = presets.iter() 49 | .map(|(p, _)| p.len()) 50 | .max() 51 | .unwrap_or(0); 52 | let list_len = presets.iter() 53 | .map(|(_, s)| s.iter().map(|e| e.len() + 2).sum::() - 2) 54 | .max() 55 | .unwrap_or(0); 56 | for (preset, sources) in presets { 57 | println!("{} {}", 58 | FmtWithEllipsis::fitting_terminal(preset, preset_len, list_len + 4) 59 | .right_pad(), 60 | format!("({})", sources.join(",")).bright_black(), 61 | ); 62 | } 63 | } 64 | } 65 | 66 | if let Some(preset_name) = self.queries.show { 67 | let preset = ConfigPreset::load(&preset_name, self.config.as_ref())?; 68 | let mut with_name = HashMap::default(); 69 | with_name.insert(preset_name, preset); 70 | let pretty = toml::to_string_pretty(&with_name) 71 | .map_err(|e| e.to_string())?; 72 | println!("{}", pretty); 73 | return Ok(()); 74 | } 75 | 76 | if self.queries.show_all { 77 | let all = ConfigPreset::load_all(self.config.as_ref())?; 78 | let pretty = toml::to_string_pretty(&all) 79 | .map_err(|e| e.to_string())?; 80 | println!("{}", pretty); 81 | return Ok(()); 82 | } 83 | 84 | Ok(()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/tidyup_gc_roots.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Reverse; 2 | use std::fs; 3 | use std::time::Duration; 4 | 5 | use colored::Colorize; 6 | use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}; 7 | use rayon::slice::ParallelSliceMut; 8 | 9 | use crate::utils::interaction::*; 10 | use crate::utils::ordered_channel::OrderedChannel; 11 | use crate::nix::roots::GCRoot; 12 | 13 | 14 | #[derive(clap::Args)] 15 | pub struct TidyupGCRootsCommand { 16 | /// Delete all qualifying gc roots without asking for user confirmation 17 | #[clap(short, long)] 18 | force: bool, 19 | 20 | /// Include profiles 21 | #[clap(short('p'), long)] 22 | include_profiles: bool, 23 | 24 | /// Include current 25 | #[clap(short('c'), long)] 26 | include_current: bool, 27 | 28 | /// Include gc roots that are referenced, but could not be found 29 | #[clap(long)] 30 | include_missing: bool, 31 | 32 | /// Exclude gc roots, whose store path is not accessible 33 | #[clap(short, long)] 34 | exclude_inaccessible: bool, 35 | 36 | /// Only show gc roots older than OLDER 37 | #[clap(long, value_parser = |s: &str| duration_str::parse_std(s))] 38 | older: Option, 39 | 40 | /// Only show gc roots newer than NEWER 41 | #[clap(long, value_parser = |s: &str| duration_str::parse_std(s))] 42 | newer: Option, 43 | 44 | /// Do not calculate the size of generations 45 | #[clap(long)] 46 | no_size: bool, 47 | } 48 | 49 | impl super::Command for TidyupGCRootsCommand { 50 | fn run(self) -> Result<(), String> { 51 | let mut roots = GCRoot::all(false, false, self.include_missing)?; 52 | let print_size = !(self.no_size || self.force); 53 | 54 | roots.par_sort_by_key(|r| r.link().clone()); 55 | roots.dedup_by_key(|r| r.link().clone()); 56 | roots.par_sort_by_key(|r| Reverse(r.age().cloned().unwrap_or(Duration::MAX))); 57 | 58 | roots = GCRoot::filter_roots(roots, self.include_profiles, self.include_current, 59 | !self.exclude_inaccessible, self.older, self.newer); 60 | let nroots_listed = roots.len(); 61 | 62 | let ordered_channel: OrderedChannel<_> = OrderedChannel::new(); 63 | rayon::join( || { 64 | roots.par_iter() 65 | .enumerate() 66 | .map(|(i, root)| match print_size { 67 | true => (i, (root, root.closure_size().ok())), 68 | false => (i, (root, None)), 69 | }) 70 | .for_each(|(i, tup)| ordered_channel.put(i, tup)); 71 | }, || { 72 | for (root, closure_size) in ordered_channel.iter(nroots_listed) { 73 | if !self.force { 74 | root.print_fancy(closure_size, !self.no_size); 75 | } 76 | 77 | if root.store_path().is_err() { 78 | if self.force { 79 | warn(&format!("Cannot remove as the path is inaccessible: {}", root.link().to_string_lossy())) 80 | } else { 81 | ack("Cannot remove as the path is inaccessible"); 82 | } 83 | } else if self.force || ask("Remove gc root?", false) { 84 | if let Err(e) = fs::remove_file(root.link()) { 85 | println!("{}", format!("Error: {e}").red()); 86 | } 87 | println!("-> Removed gc root '{}'", root.link().to_string_lossy()); 88 | } 89 | } 90 | }); 91 | 92 | if !self.force { 93 | println!(); 94 | } 95 | Ok(()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | use std::time::Duration; 5 | 6 | use clap::Parser; 7 | use duration_str::HumanFormat; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::HashMap; 11 | 12 | 13 | const SYSTEM_CONFIG: &str = "/etc/nix-sweep/presets.toml"; 14 | const APP_PREFIX: &str = "nix-sweep"; 15 | const CONFIG_FILENAME: &str = "presets.toml"; 16 | pub const DEFAULT_PRESET: &str = "default"; 17 | 18 | 19 | #[derive(Debug, Deserialize, Default)] 20 | pub struct ConfigFile(HashMap); 21 | 22 | #[derive(Clone, Debug, Serialize, Deserialize, Parser)] 23 | #[serde(rename_all = "kebab-case")] 24 | pub struct ConfigPreset { 25 | /// Keep at least this many generations 26 | /// 27 | /// Pass 0 to unset this option. 28 | #[clap(long)] 29 | pub keep_min: Option, 30 | 31 | /// Keep at most this many generations 32 | /// 33 | /// Pass 0 to unset this option. 34 | #[clap(long)] 35 | pub keep_max: Option, 36 | 37 | /// Keep all generations newer than this many days 38 | /// 39 | /// Pass 0 to unset this option. 40 | #[clap(long, value_parser = |s: &str| duration_str::parse_std(s))] 41 | #[serde(default, deserialize_with = "duration_str::deserialize_option_duration", serialize_with = "serialize_option_duration")] 42 | pub keep_newer: Option, 43 | 44 | /// Discard all generations older than this many days 45 | /// 46 | /// Pass 0 to unset this option. 47 | #[clap(long, value_parser = |s: &str| duration_str::parse_std(s))] 48 | #[serde(default, deserialize_with = "duration_str::deserialize_option_duration", serialize_with = "serialize_option_duration")] 49 | pub remove_older: Option, 50 | 51 | /// Remove these specific generations 52 | /// 53 | /// You can pass the option multiple times to remove multiple generations. 54 | #[clap(short, long("generation"), id = "GENERATION")] 55 | #[serde(skip)] 56 | pub generations: Vec, 57 | 58 | /// Do not ask before removing generations or running garbage collection 59 | #[clap(short('n'), long("non-interactive"), action = clap::ArgAction::SetFalse)] // this is very confusing, but works 60 | pub interactive: Option, 61 | 62 | /// Ask before removing generations or running garbage collection 63 | #[clap(short('i'), long("interactive"), overrides_with = "interactive", action = clap::ArgAction::SetTrue)] 64 | #[serde(skip_serializing)] 65 | pub _non_interactive: Option, 66 | 67 | /// Run GC afterwards 68 | #[clap(long, action = clap::ArgAction::SetTrue)] 69 | pub gc: Option, 70 | 71 | /// Only perform gc if the store is bigger than BIGGER Gibibytes. 72 | #[clap(long)] 73 | pub gc_bigger: Option, 74 | 75 | /// Only perform gc if the store uses more than QUOTA% of its device. 76 | #[clap(long, value_parser=clap::value_parser!(u64).range(0..100))] 77 | pub gc_quota: Option, 78 | 79 | /// Collect just as much garbage as to match --gc-bigger or --gc-quota 80 | #[clap(long)] 81 | #[serde(default)] 82 | pub gc_modest: bool, 83 | } 84 | 85 | impl ConfigFile { 86 | fn from_str(s: &str) -> Result { 87 | let config: Self = toml::from_str(s) 88 | .map_err(|e| e.to_string())?; 89 | 90 | for (preset_name, preset_config) in &config.0 { 91 | if !preset_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { 92 | return Err(format!("Invalid preset name '{preset_name}' - must only contain alphanumeric characters, dashes and underscores")); 93 | } 94 | 95 | preset_config.validate()?; 96 | } 97 | 98 | Ok(config) 99 | } 100 | 101 | pub fn read_config_file(path: &PathBuf) -> Result { 102 | let s = fs::read_to_string(path) 103 | .map_err(|e| e.to_string())?; 104 | Self::from_str(&s) 105 | } 106 | 107 | fn get_config(path: &PathBuf) -> Result, String> { 108 | if fs::exists(path).map_err(|e| e.to_string())? { 109 | Self::read_config_file(path).map(Some) 110 | } else { 111 | Ok(None) 112 | } 113 | 114 | } 115 | 116 | fn get_system_config() -> Result, String> { 117 | let path = PathBuf::from_str(SYSTEM_CONFIG) 118 | .map_err(|e| e.to_string())?; 119 | Self::get_config(&path) 120 | } 121 | 122 | fn get_user_config() -> Result, String> { 123 | xdg::BaseDirectories::with_prefix(APP_PREFIX) 124 | .get_config_file(CONFIG_FILENAME) 125 | .ok_or(String::from("Unable to open config file")) 126 | .and_then(|d| Self::get_config(&d)) 127 | } 128 | 129 | fn get_preset(&self, s: &str) -> Option<&ConfigPreset> { 130 | self.0.get(s) 131 | } 132 | 133 | fn presets(&self) -> &HashMap { 134 | &self.0 135 | } 136 | } 137 | 138 | impl ConfigPreset { 139 | pub fn available(custom_config_file: Option<&PathBuf>) -> Result>, String> { 140 | let mut avail: HashMap> = HashMap::default(); 141 | 142 | let mut avail_add = |preset: &str, src: &'static str| { 143 | if let Some(sources) = avail.get_mut(preset) { 144 | sources.push(src); 145 | } else { 146 | avail.insert(preset.to_owned(), vec!(src)); 147 | } 148 | }; 149 | 150 | if let Some(sys) = ConfigFile::get_system_config()? { 151 | for preset in sys.presets().keys() { 152 | avail_add(preset, "system"); 153 | } 154 | } 155 | if let Some(user) = ConfigFile::get_user_config()? { 156 | for preset in user.presets().keys() { 157 | avail_add(preset, "user"); 158 | } 159 | } 160 | if let Some(custom) = custom_config_file.map(|c| ConfigFile::read_config_file(&c)) { 161 | for preset in custom?.presets().keys() { 162 | avail_add(preset, "custom"); 163 | } 164 | } 165 | 166 | Ok(avail) 167 | } 168 | 169 | pub fn load(preset_name: &str, custom_config_file: Option<&PathBuf>) -> Result { 170 | let system_config = ConfigFile::get_system_config()?; 171 | let user_config = ConfigFile::get_user_config()?; 172 | let custom_config = match custom_config_file { 173 | Some(path) => Some(ConfigFile::read_config_file(&path)?), 174 | None => None, 175 | }; 176 | 177 | let system_named_preset = system_config.as_ref() 178 | .and_then(|c| c.get_preset(preset_name)); 179 | let user_named_preset = user_config.as_ref() 180 | .and_then(|c| c.get_preset(preset_name)); 181 | let custom_named_preset = custom_config.as_ref() 182 | .and_then(|c| c.get_preset(preset_name)); 183 | 184 | if system_named_preset.is_none() 185 | && user_named_preset.is_none() 186 | && custom_named_preset.is_none() 187 | && preset_name != DEFAULT_PRESET { 188 | return Err(format!("Could not find preset '{preset_name}'")); 189 | } 190 | 191 | let preset = Self::default() 192 | .override_with_opt(system_named_preset) 193 | .override_with_opt(user_named_preset) 194 | .override_with_opt(custom_named_preset) 195 | .finalize(); 196 | 197 | Ok(preset) 198 | } 199 | 200 | pub fn load_all(custom_config_file: Option<&PathBuf>) -> Result, String> { 201 | let system_config = ConfigFile::get_system_config()?; 202 | let user_config = ConfigFile::get_user_config()?; 203 | let custom_config = match custom_config_file { 204 | Some(path) => Some(ConfigFile::read_config_file(&path)?), 205 | None => None, 206 | }; 207 | 208 | let mut final_config: HashMap = HashMap::default(); 209 | 210 | for config_opt in [system_config, user_config, custom_config] { 211 | let config = match config_opt { 212 | Some(c) => c, 213 | None => continue, 214 | }; 215 | 216 | for (preset_name, preset_config) in config.0 { 217 | if let Some(prev) = final_config.get_mut(&preset_name) { 218 | *prev = prev.override_with(&preset_config); 219 | } else { 220 | final_config.insert(preset_name, preset_config); 221 | } 222 | } 223 | } 224 | 225 | for preset_config in final_config.values_mut() { 226 | *preset_config = preset_config.finalize() 227 | } 228 | 229 | Ok(final_config) 230 | } 231 | 232 | pub fn validate(&self) -> Result<(), String> { 233 | if let (Some(min), Some(max)) = (self.keep_min, self.keep_max) 234 | && min > max { 235 | return Err("Invalid configuration - keep-min is greater than keep-max".to_owned()); 236 | } 237 | 238 | if let (Some(newer), Some(older)) = (self.keep_newer, self.remove_older) 239 | && newer > older { 240 | return Err("Invalid configuration - keep-newer is greater than remove-older".to_owned()); 241 | } 242 | 243 | Ok(()) 244 | } 245 | 246 | pub fn override_with(&self, other: &ConfigPreset) -> Self { 247 | let mut keep_min = match (self.keep_min, other.keep_min) { 248 | (None, None) => None, 249 | (_, Some(0)) => None, 250 | (_, Some(val)) => Some(val), 251 | (Some(val), None) => Some(val), 252 | }; 253 | 254 | let mut keep_max = match (self.keep_max, other.keep_max) { 255 | (None, None) => None, 256 | (_, Some(0)) => None, 257 | (_, Some(val)) => Some(val), 258 | (Some(val), None) => Some(val), 259 | }; 260 | 261 | let mut keep_newer = match (self.keep_newer, other.keep_newer) { 262 | (None, None) => None, 263 | (_, Some(Duration::ZERO)) => None, 264 | (_, Some(val)) => Some(val), 265 | (Some(val), None) => Some(val), 266 | }; 267 | 268 | let mut remove_older = match (self.remove_older, other.remove_older) { 269 | (None, None) => None, 270 | (_, Some(Duration::ZERO)) => None, 271 | (_, Some(val)) => Some(val), 272 | (Some(val), None) => Some(val), 273 | }; 274 | 275 | let interactive = match (self.interactive, other.interactive) { 276 | (None, None) => None, 277 | (_, Some(val)) => Some(val), 278 | (Some(val), None) => Some(val), 279 | }; 280 | 281 | let gc = match (self.gc, other.gc) { 282 | (None, None) => None, 283 | (_, Some(val)) => Some(val), 284 | (Some(val), None) => Some(val), 285 | }; 286 | 287 | let gc_bigger = match (self.gc_bigger, other.gc_bigger) { 288 | (None, None) => None, 289 | (_, Some(val)) => Some(val), 290 | (Some(val), None) => Some(val), 291 | }; 292 | 293 | let gc_quota = match (self.gc_quota, other.gc_quota) { 294 | (None, None) => None, 295 | (_, Some(val)) => Some(val), 296 | (Some(val), None) => Some(val), 297 | }; 298 | 299 | 300 | 301 | if keep_min > keep_max && keep_min.is_some() && keep_max.is_some() { 302 | if other.keep_min.is_none() { 303 | keep_min = keep_max; 304 | } else if other.keep_max.is_none() { 305 | keep_max = keep_min; 306 | } else { 307 | panic!("Inconsistent config after load (keep_min: {keep_min:?}, keep_max: {keep_max:?})"); 308 | } 309 | } 310 | 311 | if keep_newer > remove_older && keep_newer.is_some() && remove_older.is_some(){ 312 | if other.keep_newer.is_none() { 313 | keep_newer = remove_older; 314 | } else if other.keep_max.is_none() { 315 | remove_older = keep_newer; 316 | } else { 317 | panic!("Inconsistent config after load (keep_newer: {keep_newer:?}, remove_older: {remove_older:?})"); 318 | } 319 | } 320 | 321 | let gc_modest = self.gc_modest || other.gc_modest; 322 | 323 | ConfigPreset { 324 | keep_min, keep_max, keep_newer, remove_older, 325 | interactive, _non_interactive: None, 326 | gc, gc_bigger, gc_quota, gc_modest, 327 | generations: other.generations.clone(), 328 | } 329 | } 330 | 331 | pub fn override_with_opt(&self, other: Option<&ConfigPreset>) -> Self { 332 | if let Some(preset) = other { 333 | self.override_with(preset) 334 | } else { 335 | (*self).clone() 336 | } 337 | } 338 | 339 | fn finalize(&self) -> Self { 340 | ConfigPreset { 341 | keep_min: if let Some(0) = self.keep_min { None } else { self.keep_min }, 342 | keep_max: if let Some(0) = self.keep_max { None } else { self.keep_max }, 343 | keep_newer: if let Some(Duration::ZERO) = self.keep_newer { None } else { self.keep_newer }, 344 | remove_older: if let Some(Duration::ZERO) = self.remove_older { None } else { self.remove_older }, 345 | interactive: self.interactive, 346 | _non_interactive: None, 347 | gc: self.gc, 348 | gc_bigger: if let Some(0) = self.gc_bigger { None } else { self.gc_bigger }, 349 | gc_quota: if let Some(0) = self.gc_quota { None } else { self.gc_quota }, 350 | gc_modest: self.gc_modest, 351 | generations: self.generations.clone(), 352 | } 353 | } 354 | } 355 | 356 | impl Default for ConfigPreset { 357 | fn default() -> Self { 358 | ConfigPreset { 359 | keep_min: Some(1), 360 | keep_max: None, 361 | keep_newer: None, 362 | remove_older: None, 363 | interactive: None, 364 | _non_interactive: None, 365 | gc: None, 366 | gc_bigger: None, 367 | gc_quota: None, 368 | gc_modest: false, 369 | generations: Vec::default(), 370 | } 371 | } 372 | } 373 | 374 | 375 | fn serialize_option_duration(d: &Option, s: S) -> Result 376 | where 377 | S: serde::Serializer, 378 | { 379 | match d { 380 | Some(d) => s.serialize_some(&d.human_format()), 381 | None => s.serialize_none(), 382 | } 383 | 384 | } 385 | 386 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use std::{env, thread}; 3 | 4 | use clap::Parser; 5 | use rayon::ThreadPoolBuilder; 6 | 7 | use crate::commands::Command; 8 | use crate::utils::interaction::resolve; 9 | 10 | mod config; 11 | mod nix; 12 | mod utils; 13 | mod commands; 14 | 15 | 16 | const THREADS_ENV_VAR: &str = "NIX_SWEEP_NUM_THREADS"; 17 | const MAX_THREADS: usize = 4; 18 | 19 | 20 | type HashMap = rustc_hash::FxHashMap; 21 | type HashSet = rustc_hash::FxHashSet; 22 | type Hasher = rustc_hash::FxHasher; 23 | 24 | /// Utility to clean up old Nix profile generations and left-over garbage collection roots 25 | /// 26 | /// You can adjust the number of worker threads this program uses with the `NIX_SWEEP_NUM_THREADS` env 27 | /// variable. 28 | #[derive(Parser)] 29 | #[command(version, about, long_about)] 30 | pub struct Args { 31 | #[clap(subcommand)] 32 | subcommand: Subcommand, 33 | } 34 | 35 | #[derive(clap::Subcommand)] 36 | enum Subcommand { 37 | /// Add a new garbage collection root 38 | AddRoot(commands::add_root::AddRootCommand), 39 | 40 | /// Analyze store usage 41 | /// 42 | /// This shows the current size and optimization state of the nix store. 43 | /// It also displays the current full closure size of profiles and garbage collection roots, as well as 44 | /// the percentage of total store space that is used by those closures. 45 | Analyze(commands::analyze::AnalyzeCommand), 46 | 47 | /// Clean out old profile generations 48 | /// 49 | /// Positive criteria (e.g. --keep-min, --keep-newer) are prioritized over negative ones 50 | /// (e.g. --keep-max, --remove-older). 51 | /// Passing 0 on any cleanout criterion will reset it to the default behavior. 52 | /// 53 | /// The latest generation as well as the currently active one will not be removed, even if the 54 | /// match the specified criteria. If you want to delete those generations or the entire 55 | /// profile, you will have to do so manually. Please beware of the risks of this operation and 56 | /// the impact it may have on your system state.. 57 | Cleanout(commands::cleanout::CleanoutCommand), 58 | 59 | /// Run garbage collection (short for `nix-store --gc`) 60 | GC(commands::gc::GCCommand), 61 | 62 | /// List garbage collection roots 63 | GCRoots(commands::gc_roots::GCRootsCommand), 64 | 65 | /// List profile generations 66 | Generations(commands::generations::GenerationsCommand), 67 | 68 | /// Show information on a path or a symlink to a path 69 | PathInfo(commands::path_info::PathInfoCommand), 70 | 71 | /// Show information about available presets for `cleanout` 72 | Presets(commands::presets::PresetsCommand), 73 | 74 | /// Selectively remove gc roots 75 | #[clap(aliases = &["tidyup"])] 76 | TidyupGCRoots(commands::tidyup_gc_roots::TidyupGCRootsCommand), 77 | 78 | /// Export shell completions 79 | #[clap(hide(true))] 80 | Completions(commands::completions::CompletionsCommand), 81 | 82 | /// Export manpage 83 | #[clap(hide(true))] 84 | Man(commands::man::ManCommand), 85 | } 86 | 87 | fn init_rayon() -> Result<(), String> { 88 | let nthreads: usize = match env::var(THREADS_ENV_VAR).ok() { 89 | Some(n) => n.parse() 90 | .map_err(|_| format!("Unable to parse {THREADS_ENV_VAR} environment variable"))?, 91 | None => match thread::available_parallelism().ok() { 92 | Some(avail) => cmp::min(avail.into(), MAX_THREADS), 93 | None => MAX_THREADS, 94 | }, 95 | }; 96 | 97 | ThreadPoolBuilder::new() 98 | .num_threads(nthreads) 99 | .build_global() 100 | .map_err(|e| e.to_string()) 101 | } 102 | 103 | fn parse_args() -> Result { 104 | match Args::try_parse() { 105 | Ok(args) => Ok(args), 106 | Err(e) => { 107 | if e.render().to_string().starts_with("error: ") { 108 | let msg = e.render().to_string().chars() 109 | .skip(7) 110 | .enumerate() 111 | .map(|(i, c)| if i == 0 { c.to_ascii_uppercase() } else { c }) 112 | .collect(); 113 | Err(msg) 114 | } else { 115 | e.exit() 116 | } 117 | }, 118 | } 119 | } 120 | 121 | fn main() { 122 | let config = resolve(parse_args()); 123 | resolve(init_rayon()); 124 | 125 | use Subcommand::*; 126 | let res = match config.subcommand { 127 | AddRoot(cmd) => cmd.run(), 128 | Analyze(cmd) => cmd.run(), 129 | Cleanout(cmd) => cmd.run(), 130 | Completions(cmd) => cmd.run(), 131 | GC(cmd) => cmd.run(), 132 | GCRoots(cmd) => cmd.run(), 133 | Generations(cmd) => cmd.run(), 134 | Man(cmd) => cmd.run(), 135 | PathInfo(cmd) => cmd.run(), 136 | TidyupGCRoots(cmd) => cmd.run(), 137 | Presets(cmd) => cmd.run(), 138 | }; 139 | resolve(res); 140 | } 141 | -------------------------------------------------------------------------------- /src/nix/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod profiles; 2 | pub mod roots; 3 | pub mod store; 4 | -------------------------------------------------------------------------------- /src/nix/profiles.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs; 3 | use std::path; 4 | use std::path::Component; 5 | use std::process; 6 | use std::str; 7 | use std::path::{Path, PathBuf}; 8 | use std::str::FromStr; 9 | use std::time::Duration; 10 | use std::time::SystemTime; 11 | 12 | use colored::Colorize; 13 | use rayon::iter::IndexedParallelIterator; 14 | use rayon::iter::IntoParallelRefIterator; 15 | use rayon::iter::ParallelIterator; 16 | 17 | use crate::config; 18 | use crate::utils::files::dir_size_considering_hardlinks_all; 19 | use crate::utils::fmt::FmtAge; 20 | use crate::utils::fmt::FmtSize; 21 | use crate::utils::fmt::Formattable; 22 | use crate::utils::interaction::announce; 23 | use crate::utils::ordered_channel::OrderedChannel; 24 | use crate::nix::store::StorePath; 25 | use crate::HashSet; 26 | 27 | 28 | #[derive(Debug)] 29 | pub struct Profile { 30 | parent: PathBuf, 31 | name: String, 32 | generations: Vec, 33 | } 34 | 35 | #[derive(Eq, Debug)] 36 | pub struct Generation { 37 | number: usize, 38 | path: PathBuf, 39 | profile_path: PathBuf, 40 | age: Duration, 41 | marker: bool, 42 | } 43 | 44 | 45 | impl Profile { 46 | pub fn new(parent: PathBuf, name: String) -> Result { 47 | let full_path = parent.clone().join(&name); 48 | if !fs::exists(&full_path) 49 | .map_err(|e| format!("Unable to check path {}: {}", full_path.to_string_lossy(), e))? { 50 | return Err(format!("Could not find profile '{}'", full_path.to_string_lossy())); 51 | } 52 | 53 | // discover generations 54 | let profile_prefix = format!("{name}-"); 55 | let mut generations: Vec<_> = fs::read_dir(&parent) 56 | .map_err(|e| format!("Unable to read directory {}: {}", parent.to_string_lossy(), e))? 57 | .flatten() 58 | .filter(|e| e.file_name().to_str().map(|n| n.starts_with(&profile_prefix)).unwrap_or(false)) 59 | .map(|e| Generation::new_from_direntry(&name, &e)) 60 | .map(|r| r.unwrap()) 61 | .collect(); 62 | generations.sort(); 63 | 64 | Ok(Profile { parent, name, generations }) 65 | } 66 | 67 | pub fn from_path(path: PathBuf) -> Result { 68 | // get parent and name 69 | let parent = path.parent() 70 | .ok_or(format!("Unable to get parent for profile '{}'", path.to_string_lossy()))? 71 | .to_path_buf(); 72 | let name = match path.components().next_back() { 73 | Some(Component::Normal(s)) => s.to_str() 74 | .ok_or(format!("Cannot convert profile path '{}' to string", path.to_string_lossy()))? 75 | .to_owned(), 76 | _ => return Err(format!("Unable to retrieve profile name for profile '{}'", path.to_string_lossy())), 77 | }; 78 | 79 | Profile::new(parent, name) 80 | } 81 | 82 | pub fn new_user_profile(name: String) -> Result { 83 | let check_path = |path: &str| fs::exists(format!("{path}/{name}")) 84 | .map_err(|e| format!("Unable to check path {path}: {e}")); 85 | let user = env::var("USER") 86 | .map_err(|_| String::from("Unable to read $USER"))?; 87 | 88 | let path = format!("/nix/var/nix/profiles/per-user/{user}"); 89 | if check_path(&path)? { 90 | return Self::new(PathBuf::from(path), name); 91 | } 92 | 93 | let home = env::var("HOME") 94 | .map_err(|_| String::from("Unable to read $USER"))?; 95 | 96 | let path = format!("{home}/.local/state/nix/profiles"); 97 | if check_path(&path)? { 98 | return Self::new(PathBuf::from(path), name); 99 | } 100 | 101 | Err("Could not find profile".to_owned()) 102 | } 103 | 104 | pub fn system() -> Result { 105 | Self::new(PathBuf::from("/nix/var/nix/profiles/"), String::from("system")) 106 | } 107 | 108 | pub fn home() -> Result { 109 | Self::new_user_profile(String::from("home-manager")) 110 | } 111 | 112 | pub fn user() -> Result { 113 | Self::new_user_profile(String::from("profile")) 114 | } 115 | 116 | pub fn apply_markers(&mut self, config: &config::ConfigPreset) { 117 | // negative criteria are applied first 118 | 119 | // mark older generations 120 | if let Some(older) = config.remove_older { 121 | for generation in self.generations.iter_mut() { 122 | if generation.age() >= older { 123 | generation.mark(); 124 | } 125 | } 126 | } 127 | 128 | // mark superfluous generations 129 | if let Some(max) = config.keep_max { 130 | for (i, generation) in self.generations.iter_mut().rev().enumerate() { 131 | if i >= max { 132 | generation.mark(); 133 | } 134 | } 135 | } 136 | 137 | // unmark newer generations 138 | if let Some(newer) = config.keep_newer { 139 | for generation in self.generations.iter_mut() { 140 | if generation.age() < newer { 141 | generation.unmark(); 142 | } 143 | } 144 | } 145 | 146 | // unmark kept generations 147 | if let Some(min) = config.keep_min { 148 | for (i, generation) in self.generations.iter_mut().rev().enumerate() { 149 | if i < min { 150 | generation.unmark(); 151 | } 152 | } 153 | } 154 | 155 | // mark explicitly removed generations 156 | for num in &config.generations { 157 | let generation = self.generations.iter_mut() 158 | .find(|g| g.number() == *num); 159 | if let Some(generation) = generation { 160 | generation.mark(); 161 | } 162 | } 163 | 164 | // always unmark newest generation 165 | if let Some(newest) = self.generations.last_mut() { 166 | newest.unmark() 167 | } 168 | 169 | // always unmark currently active generation 170 | if let Ok(active) = self.active_generation_mut() { 171 | active.unmark() 172 | } 173 | } 174 | 175 | pub fn count_marked(&self) -> usize { 176 | self.generations.iter() 177 | .filter(|g| g.marked()) 178 | .count() 179 | } 180 | 181 | pub fn path(&self) -> PathBuf { 182 | self.parent.clone().join(&self.name) 183 | } 184 | 185 | pub fn generations(&self) -> &[Generation] { 186 | &self.generations 187 | } 188 | 189 | pub fn active_generation(&self) -> Result<&Generation, String> { 190 | let gen_name = fs::read_link(self.path()) 191 | .map(|p| p.to_path_buf()) 192 | .map_err(|e| e.to_string())?; 193 | let gen_path = self.parent.join(gen_name); 194 | 195 | self.generations.iter() 196 | .find(|g| g.path() == gen_path) 197 | .ok_or("Cannot find current generation".to_owned()) 198 | } 199 | 200 | pub fn active_generation_mut(&mut self) -> Result<&mut Generation, String> { 201 | let gen_name = fs::read_link(self.path()) 202 | .map(|p| p.to_path_buf()) 203 | .map_err(|e| e.to_string())?; 204 | let gen_path = self.parent.join(gen_name); 205 | 206 | self.generations.iter_mut() 207 | .find(|g| g.path() == gen_path) 208 | .ok_or("Cannot find current generation".to_owned()) 209 | } 210 | 211 | 212 | pub fn list_generations(&self, print_size: bool, print_markers: bool) { 213 | announce(&format!("Listing generations for profile {}", self.path().to_string_lossy())); 214 | 215 | let store_paths: Vec<_> = self.generations().iter() 216 | .flat_map(|g| g.store_path()) 217 | .collect(); 218 | 219 | let ordered_channel: OrderedChannel<_> = OrderedChannel::new(); 220 | let gens = self.generations(); 221 | let ngens = gens.len(); 222 | 223 | rayon::join( || { 224 | gens.par_iter() 225 | .enumerate() 226 | .map(|(i, g)| { 227 | let active = self.is_active_generation(g); 228 | let size = if print_size { 229 | Some( 230 | g.store_path() 231 | .map(|sp| sp.closure_size()) 232 | .unwrap_or_default() 233 | ) 234 | } else { None }; 235 | (i, active, size) 236 | }) 237 | .for_each(|tup| ordered_channel.put(tup.0, tup)); 238 | }, || { 239 | for (i, active, size) in ordered_channel.iter(ngens) { 240 | gens[i].print_fancy(active, print_markers, size); 241 | } 242 | }); 243 | 244 | if print_size { 245 | let paths: HashSet<_> = store_paths.par_iter() 246 | .flat_map(|sp| sp.closure()) 247 | .flatten() 248 | .collect(); 249 | let kept_paths: HashSet<_> = self.generations().par_iter() 250 | .filter(|g| !g.marked()) 251 | .flat_map(|g| g.store_path()) 252 | .flat_map(|sp| sp.closure()) 253 | .flatten() 254 | .collect(); 255 | 256 | let dirs: Vec<_> = paths.iter().map(|sp| sp.path()) 257 | .cloned() 258 | .collect(); 259 | let kept_dirs: Vec<_> = kept_paths.iter().map(|sp| sp.path()) 260 | .cloned() 261 | .collect(); 262 | let size = dir_size_considering_hardlinks_all(&dirs); 263 | let kept_size = dir_size_considering_hardlinks_all(&kept_dirs); 264 | 265 | 266 | println!(); 267 | println!("Estimated total size: {} ({} store paths)", 268 | FmtSize::new(size).to_string().yellow(), paths.len()); 269 | if print_markers { 270 | println!(" -> after removal: {} ({} store paths)", 271 | FmtSize::new(kept_size).to_string().green(), kept_paths.len()); 272 | } 273 | } 274 | } 275 | 276 | 277 | pub fn is_active_generation(&self, generation: &Generation) -> bool { 278 | let active = match self.active_generation() { 279 | Ok(g) => g, 280 | Err(_) => return false, 281 | }; 282 | active == generation 283 | } 284 | 285 | pub fn full_closure(&self) -> Result, String> { 286 | let closures: Result, _> = self.generations.par_iter() 287 | .map(|g| g.closure()) 288 | .collect(); 289 | let full_closure: HashSet<_> = closures? 290 | .into_iter() 291 | .flatten() 292 | .collect(); 293 | 294 | Ok(full_closure) 295 | } 296 | 297 | pub fn full_closure_size(&self) -> Result { 298 | let full_closure: Vec<_> = self.full_closure()? 299 | .iter() 300 | .map(|sp| sp.path()) 301 | .cloned() 302 | .collect(); 303 | Ok(dir_size_considering_hardlinks_all(&full_closure)) 304 | } 305 | } 306 | 307 | impl Generation { 308 | fn new_from_direntry(name: &str, dirent: &fs::DirEntry) -> Result { 309 | let file_name = dirent.file_name(); 310 | let file_name = file_name.to_string_lossy(); 311 | let suffix = file_name.strip_prefix(name) 312 | .ok_or("Cannot create generation representation (missing profile prefix)")?; 313 | let tokens: Vec<_> = suffix.split('-').collect(); 314 | if tokens.len() != 3 || tokens[2] != "link" { 315 | return Err(format!("Cannot create generation representation ({tokens:?})")) 316 | } 317 | 318 | let profile_path = dirent.path().parent().unwrap() 319 | .join(name); 320 | 321 | let number = str::parse::(tokens[1]) 322 | .map_err(|_| format!("Cannot parse \"{}\" as generation number", tokens[1]))?; 323 | 324 | let last_modified = fs::symlink_metadata(dirent.path()) 325 | .map_err(|e| format!("Unable to get metadata for path {}: {}", dirent.path().to_string_lossy(), e))? 326 | .modified() 327 | .map_err(|e| format!("Unable to get metadata for path {}: {}", dirent.path().to_string_lossy(), e))?; 328 | let now = SystemTime::now(); 329 | let age = now.duration_since(last_modified) 330 | .map_err(|e| format!("Unable to calculate generation age: {e}"))?; 331 | 332 | Ok(Generation { 333 | number, age, 334 | path: dirent.path(), 335 | profile_path, 336 | marker: false, 337 | }) 338 | } 339 | 340 | 341 | 342 | pub fn path(&self) -> &Path { 343 | &self.path 344 | } 345 | 346 | pub fn store_path(&self) -> Result { 347 | StorePath::from_symlink(&self.path) 348 | } 349 | 350 | pub fn number(&self) -> usize { 351 | self.number 352 | } 353 | 354 | pub fn profile_path(&self) -> &Path { 355 | &self.profile_path 356 | } 357 | 358 | pub fn age(&self) -> Duration { 359 | self.age 360 | } 361 | 362 | pub fn mark(&mut self) { 363 | self.marker = true; 364 | } 365 | 366 | pub fn unmark(&mut self) { 367 | self.marker = false; 368 | } 369 | 370 | pub fn marked(&self) -> bool{ 371 | self.marker 372 | } 373 | 374 | pub fn closure(&self) -> Result, String> { 375 | self.store_path().and_then(|sp| sp.closure()) 376 | } 377 | 378 | pub fn remove(&self) -> Result<(), String> { 379 | let result = process::Command::new("nix-env") 380 | .args(["-p", self.profile_path().to_str().unwrap()]) 381 | .args(["--delete-generations", &self.number().to_string()]) 382 | .stdin(process::Stdio::inherit()) 383 | .stdout(process::Stdio::inherit()) 384 | .stderr(process::Stdio::inherit()) 385 | .status(); 386 | 387 | match result { 388 | Ok(status) => if status.success() { 389 | Ok(()) 390 | } else { 391 | Err(format!("Removal of generation {} failed", self.number())) 392 | }, 393 | Err(e) => Err(format!("Removal of generation {} failed: {}", self.number(), e)), 394 | } 395 | } 396 | 397 | pub fn print_fancy(&self, active: bool, print_marker: bool, size: Option) { 398 | let marker = if self.marked() { "would remove".red() } else { "would keep".green() }; 399 | let id_str = format!("[{}]", self.number()).bright_blue(); 400 | 401 | print!("{}\t{}", id_str, 402 | FmtAge::new(self.age()) 403 | .with_suffix::<4>(" old".to_owned()) 404 | .left_pad()); 405 | 406 | if print_marker { 407 | print!(", {marker}"); 408 | } 409 | 410 | if let Some(size) = size { 411 | let closure_size_str = FmtSize::new(size) 412 | .bracketed() 413 | .with_square_brackets() 414 | .right_pad(); 415 | print!(" \t{}", closure_size_str.yellow()); 416 | } 417 | 418 | if active { 419 | print!("\t<- active"); 420 | } 421 | 422 | println!(); 423 | } 424 | } 425 | 426 | impl Ord for Generation { 427 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 428 | self.number.cmp(&other.number) 429 | } 430 | } 431 | 432 | impl PartialOrd for Generation { 433 | fn partial_cmp(&self, other: &Self) -> Option { 434 | Some(self.cmp(other)) 435 | } 436 | } 437 | 438 | impl PartialEq for Generation { 439 | fn eq(&self, other: &Self) -> bool { 440 | self.path.eq(&other.path) 441 | } 442 | } 443 | 444 | impl FromStr for Profile { 445 | type Err = String; 446 | 447 | fn from_str(s: &str) -> Result { 448 | match s { 449 | "user" => Profile::user(), 450 | "home" => Profile::home(), 451 | "system" => Profile::system(), 452 | other => { 453 | let path = path::PathBuf::from_str(other) 454 | .map_err(|e| e.to_string())?; 455 | Profile::from_path(path) 456 | }, 457 | } 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /src/nix/roots.rs: -------------------------------------------------------------------------------- 1 | use std::process; 2 | use std::time::Duration; 3 | use std::time::SystemTime; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | use std::str::FromStr; 7 | 8 | use colored::Colorize; 9 | use rayon::iter::IntoParallelRefIterator; 10 | use rayon::iter::ParallelIterator; 11 | use rayon::slice::ParallelSliceMut; 12 | 13 | use crate::utils::files::dir_size_considering_hardlinks_all; 14 | use crate::utils::fmt::*; 15 | use crate::nix::store::StorePath; 16 | use crate::HashSet; 17 | 18 | use super::store::NIX_STORE; 19 | 20 | 21 | const GC_ROOTS_DIR: &str = "/nix/var/nix/gcroots"; 22 | 23 | 24 | #[derive(Clone)] 25 | pub struct GCRoot { 26 | link: PathBuf, 27 | age: Result, 28 | store_path: Result, 29 | } 30 | 31 | impl GCRoot { 32 | fn new(link: PathBuf) -> Result { 33 | let store_path = StorePath::from_symlink(&link); 34 | Self::new_with_store_path(link, store_path) 35 | } 36 | 37 | fn new_with_store_path(link: PathBuf, store_path: Result) -> Result { 38 | let last_modified = fs::symlink_metadata(&link) 39 | .and_then(|m| m.modified()) 40 | .map_err(|e| format!("Unable to get metadata for path {}: {}", link.to_string_lossy(), e)); 41 | let now = SystemTime::now(); 42 | let age = match last_modified { 43 | Ok(m) => now.duration_since(m) 44 | .map_err(|e| format!("Unable to calculate generation age: {e}")), 45 | Err(e) => Err(e), 46 | }; 47 | 48 | Ok(GCRoot { link, age, store_path }) 49 | } 50 | 51 | pub fn all_search_directory(include_missing: bool) -> Result, String> { 52 | let gc_roots_dir = PathBuf::from_str(GC_ROOTS_DIR) 53 | .map_err(|e| e.to_string())?; 54 | 55 | let mut roots = Vec::new(); 56 | for location in find_links(&gc_roots_dir, Vec::new())? { 57 | let mut link = fs::read_link(&location) 58 | .map_err(|e| e.to_string())?; 59 | if link.starts_with(NIX_STORE) { 60 | link = location; 61 | } 62 | 63 | if include_missing || fs::exists(&link).unwrap_or(true) { 64 | roots.push(GCRoot::new(link)?); 65 | } 66 | 67 | } 68 | 69 | Ok(roots) 70 | } 71 | 72 | pub fn all(query_nix: bool, include_proc: bool, include_missing: bool) -> Result, String> { 73 | if include_proc { 74 | Self::all_with_proc() 75 | } else if query_nix { 76 | let mut roots = Self::all_with_proc()?; 77 | roots.retain(|r| !r.is_proc()); 78 | Ok(roots) 79 | } else { 80 | Self::all_search_directory(include_missing) 81 | } 82 | 83 | } 84 | 85 | pub fn all_with_proc() -> Result, String> { 86 | let output = process::Command::new("nix-store") 87 | .arg("--gc") 88 | .arg("--print-roots") 89 | .stdin(process::Stdio::inherit()) 90 | .stderr(process::Stdio::inherit()) 91 | .output() 92 | .map_err(|e| e.to_string())?; 93 | 94 | if !output.status.success() { 95 | match output.status.code() { 96 | Some(code) => return Err(format!("`nix-store` failed (exit code {code})")), 97 | None => return Err("`nix-store` failed".to_string()), 98 | } 99 | } 100 | 101 | let roots: Vec<_> = String::from_utf8(output.stdout) 102 | .map_err(|e| e.to_string())? 103 | .lines() 104 | .filter_map(|l| l.split_once(" -> ")) 105 | .filter(|(link, _)| *link != "{censored}") 106 | .map(|(link, store_path)| (link, StorePath::new(store_path.into()))) 107 | .map(|(link, store_path)| GCRoot::new_with_store_path(link.into(), store_path)) 108 | .collect::, String>>()?; 109 | 110 | Ok(roots) 111 | } 112 | 113 | pub fn link(&self) -> &PathBuf { 114 | &self.link 115 | } 116 | 117 | pub fn store_path(&self) -> Result<&StorePath, &String> { 118 | self.store_path.as_ref() 119 | } 120 | 121 | pub fn is_accessible(&self) -> bool { 122 | self.store_path().is_ok() 123 | } 124 | 125 | pub fn is_profile(&self) -> bool { 126 | let parent = self.link.parent().unwrap(); 127 | parent.starts_with("/nix/var/nix/profiles") 128 | || parent.ends_with(".local/state/nix/profiles") 129 | } 130 | 131 | pub fn is_current(&self) -> bool { 132 | self.link.starts_with("/run/current-system") 133 | || self.link.starts_with("/run/booted-system") 134 | || self.link.ends_with("home-manager/gcroots/current-home") 135 | || self.link.ends_with("nix/flake-registry.json") 136 | } 137 | 138 | pub fn is_proc(&self) -> bool { 139 | self.link().starts_with("/proc") 140 | } 141 | 142 | pub fn is_independent(&self) -> bool { 143 | !self.is_profile() && !self.is_current() && !self.is_proc() 144 | } 145 | 146 | pub fn age(&self) -> Result<&Duration, &String> { 147 | self.age.as_ref() 148 | } 149 | 150 | pub fn profile_paths() -> Result, String> { 151 | let links: Option> = Self::all(false, false, false)?.into_iter() 152 | .filter(|r| r.is_profile()) 153 | .map(|r| r.link().to_str().map(|s| s.to_owned())) 154 | .collect(); 155 | let mut paths: Vec<_> = links.ok_or(String::from("Unable to format gc root link"))? 156 | .par_iter() 157 | .flat_map(|l| { 158 | let mut s = match l.strip_suffix("-link") { 159 | Some(rem) => rem.to_string(), 160 | None => return None, 161 | }; 162 | 163 | while let Some(last) = s.pop() { 164 | if !last.is_numeric() { 165 | match last { 166 | '-' => return Some(PathBuf::from(s)), 167 | _ => return None, 168 | } 169 | } 170 | } 171 | None 172 | }).collect(); 173 | 174 | paths.par_sort(); 175 | paths.dedup(); 176 | 177 | Ok(paths) 178 | } 179 | 180 | pub fn closure_size(&self) -> Result { 181 | self.store_path.clone().map(|sp| sp.closure_size()) 182 | } 183 | 184 | pub fn full_closure(roots: &[Self]) -> HashSet { 185 | let paths: Vec<_> = roots.iter() 186 | .flat_map(|sp| sp.store_path()) 187 | .collect(); 188 | StorePath::full_closure(&paths) 189 | } 190 | 191 | pub fn full_closure_size(roots: &[Self]) -> Result { 192 | let full_closure: Vec<_> = Self::full_closure(roots) 193 | .iter() 194 | .map(|sp| sp.path()) 195 | .cloned() 196 | .collect(); 197 | Ok(dir_size_considering_hardlinks_all(&full_closure)) 198 | } 199 | 200 | pub fn filter_roots(mut roots: Vec, include_profiles: bool, include_current: bool, include_inaccessible: bool, 201 | older: Option, newer: Option) -> Vec{ 202 | if !include_profiles { 203 | roots.retain(|r| !r.is_profile()); 204 | } 205 | if !include_current { 206 | roots.retain(|r| !r.is_current()); 207 | } 208 | if !include_inaccessible { 209 | roots.retain(|r| r.is_accessible()); 210 | } 211 | 212 | if let Some(older) = older { 213 | roots.retain(|r| match r.age() { 214 | Ok(age) => age > &older, 215 | Err(_) => true, 216 | }) 217 | } 218 | if let Some(newer) = newer { 219 | roots.retain(|r| match r.age() { 220 | Ok(age) => age <= &newer, 221 | Err(_) => true, 222 | }) 223 | } 224 | 225 | roots 226 | } 227 | 228 | pub fn print_concise(&self, closure_size: Option, show_size: bool, max_col_len: usize) { 229 | let size_str = if show_size { 230 | FmtOrNA::mapped(closure_size, FmtSize::new) 231 | .left_pad() 232 | } else { 233 | String::new() 234 | }; 235 | let age_str = FmtOrNA::mapped(self.age().ok(), |s| FmtAge::new(*s).with_suffix::<4>(" old".to_owned())) 236 | .or_empty() 237 | .right_pad(); 238 | 239 | let link = self.link().to_string_lossy().to_string(); 240 | let link_str = FmtWithEllipsis::fitting_terminal(link, max_col_len, 32) 241 | .right_pad(); 242 | 243 | println!("{} {} {}", 244 | link_str, 245 | size_str.yellow(), 246 | age_str.bright_blue()); 247 | } 248 | 249 | pub fn print_fancy(&self, closure_size: Option, show_size: bool) { 250 | let attribute_items: Vec = [ 251 | (self.is_profile(), "profile"), 252 | (self.is_current(), "current"), 253 | (self.is_proc(), "process"), 254 | (self.is_independent(), "independent"), 255 | ].iter() 256 | .map(|(b, n)| if *b { n.to_string() } else { String::new() }) 257 | .filter(|s| !s.is_empty()) 258 | .collect(); 259 | 260 | let attributes = format!("({})", attribute_items.join(", ")); 261 | 262 | let age_str = self.age() 263 | .ok() 264 | .map(|a| FmtAge::new(*a).to_string()); 265 | 266 | let (store_path, size) = if let Ok(store_path) = self.store_path() { 267 | let store_path_str = store_path.path().to_string_lossy().into(); 268 | if let Some(closure_size) = closure_size { 269 | (store_path_str, Some(FmtSize::new(closure_size))) 270 | } else { 271 | (store_path_str, None) 272 | } 273 | } else { 274 | (String::from(""), None) 275 | }; 276 | 277 | println!("\n{}", self.link().to_string_lossy()); 278 | println!("{}", format!(" -> {store_path}").bright_black()); 279 | print!(" "); 280 | match age_str { 281 | Some(age) => print!("age: {}, ", age.bright_blue()), 282 | None => print!("age: {}, ", "n/a".bright_blue()), 283 | } 284 | if show_size { 285 | match size { 286 | Some(size) => print!("closure size: {}, ", size.to_string().yellow()), 287 | None => print!("closure size: {}, ", "n/a".to_string().yellow()), 288 | } 289 | } 290 | println!("type: {}", attributes.blue()); 291 | } 292 | } 293 | 294 | fn find_links(path: &PathBuf, mut links: Vec) -> Result, String> { 295 | let metadata = path.symlink_metadata() 296 | .map_err(|e| e.to_string())?; 297 | let ft = metadata.file_type(); 298 | 299 | if ft.is_dir() { 300 | for entry in fs::read_dir(path).map_err(|e| e.to_string())? { 301 | let child_path = entry 302 | .map_err(|e| e.to_string())? 303 | .path(); 304 | links = find_links(&child_path, links)?; 305 | } 306 | } else if ft.is_symlink() { 307 | links.push(path.clone()); 308 | } 309 | 310 | Ok(links) 311 | } 312 | -------------------------------------------------------------------------------- /src/nix/store.rs: -------------------------------------------------------------------------------- 1 | use std::hash::{Hash, Hasher}; 2 | use std::str::FromStr; 3 | use std::{fs, process}; 4 | use std::path::{Path, PathBuf}; 5 | 6 | use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; 7 | 8 | use crate::utils::caching::Cache; 9 | use crate::utils::files; 10 | use crate::HashSet; 11 | 12 | 13 | pub const NIX_STORE: &str = "/nix/store"; 14 | const CLOSURE_LOOKUP_CHUNK_SIZE: usize = 1024; 15 | static CLOSURE_CACHE: Cache> = Cache::new(); 16 | 17 | 18 | #[derive(Debug, Hash, Eq, PartialEq, Clone)] 19 | pub struct StorePath(PathBuf); 20 | 21 | pub struct Store(); 22 | 23 | 24 | impl Store { 25 | pub fn all_paths() -> Result, String> { 26 | let read_dir = match fs::read_dir(NIX_STORE) { 27 | Ok(rd) => rd, 28 | Err(e) => return Err(e.to_string()), 29 | }; 30 | 31 | let paths = read_dir.into_iter() 32 | .flatten() 33 | .map(|e| e.path()) 34 | .filter(|p| Self::is_valid_path(p)) 35 | .flat_map(StorePath::new) 36 | .collect(); 37 | Ok(paths) 38 | } 39 | 40 | pub fn paths_dead() -> Result, String> { 41 | Self::paths_with_flag("--print-dead") 42 | } 43 | 44 | fn paths_with_flag(flag: &str) -> Result, String> { 45 | let output = process::Command::new("nix-store") 46 | .arg("--gc") 47 | .arg(flag) 48 | .output() 49 | .map_err(|e| e.to_string())?; 50 | 51 | if !output.status.success() { 52 | match output.status.code() { 53 | Some(code) => return Err(format!("`nix-store` failed (exit code {code})")), 54 | None => return Err("`nix-store` failed".to_string()), 55 | } 56 | } 57 | 58 | let paths: HashSet<_> = String::from_utf8(output.stdout) 59 | .map_err(|e| e.to_string())? 60 | .lines() 61 | .flat_map(|p| StorePath::new(p.into())) 62 | .collect(); 63 | 64 | Ok(paths) 65 | } 66 | 67 | pub fn is_valid_path(path: &Path) -> bool { 68 | let file_name = match path.file_name().and_then(|n| n.to_str()) { 69 | Some(file_name) => file_name, 70 | None => return false, 71 | }; 72 | 73 | let is_in_store = path.starts_with(NIX_STORE); 74 | let has_sufficient_length = file_name.len() > 32; 75 | let starts_with_hash = file_name.chars() 76 | .take(32) 77 | .all(|c| c.is_ascii_alphanumeric() && (c.is_lowercase() || c.is_numeric())); 78 | 79 | is_in_store && has_sufficient_length && starts_with_hash 80 | } 81 | 82 | pub fn size_naive() -> Result { 83 | let total_size: u64 = Store::all_paths()? 84 | .iter() 85 | .map(|sp| sp.size_naive()) 86 | .sum(); 87 | Ok(total_size) 88 | } 89 | 90 | pub fn size() -> Result { 91 | let store_path = std::path::PathBuf::from(NIX_STORE); 92 | let size = files::dir_size_considering_hardlinks(&store_path); 93 | Ok(size) 94 | } 95 | 96 | pub fn blkdev() -> Result { 97 | files::blkdev_of_path(Path::new(NIX_STORE)) 98 | } 99 | 100 | pub fn gc(max_freed: Option) -> Result<(), String> { 101 | let mut command = process::Command::new("nix-store"); 102 | command.arg("--gc"); 103 | if let Some(amount) = max_freed { 104 | command.args(["--max-freed".to_owned(), format!("{amount}")]); 105 | } 106 | let result = command 107 | .stdin(process::Stdio::inherit()) 108 | .stdout(process::Stdio::inherit()) 109 | .stderr(process::Stdio::inherit()) 110 | .status(); 111 | 112 | match result { 113 | Ok(status) => if status.success() { 114 | Ok(()) 115 | } else { 116 | Err("Garbage collection failed".to_string()) 117 | }, 118 | Err(e) => Err(format!("Garbage collection failed: {e}")), 119 | } 120 | } 121 | } 122 | 123 | impl StorePath { 124 | pub fn new(path: PathBuf) -> Result { 125 | if !Store::is_valid_path(&path) { 126 | Err(format!("'{}' is not a valid nix store path", path.to_string_lossy())) 127 | } else { 128 | Ok(StorePath(path)) 129 | } 130 | } 131 | 132 | pub fn from_symlink(link: &PathBuf) -> Result { 133 | let path = fs::canonicalize(link) 134 | .map_err(|e| e.to_string())?; 135 | Self::new(path) 136 | } 137 | 138 | pub fn path(&self) -> &PathBuf { 139 | &self.0 140 | } 141 | 142 | pub fn size(&self) -> u64 { 143 | files::dir_size_considering_hardlinks(&self.0) 144 | } 145 | 146 | pub fn size_naive(&self) -> u64 { 147 | files::dir_size_naive(&self.0) 148 | } 149 | 150 | pub fn is_drv(&self) -> bool { 151 | self.0.to_string_lossy().ends_with("drv") 152 | } 153 | 154 | pub fn closure(&self) -> Result, String> { 155 | Self::closure_helper(&[self]) 156 | } 157 | 158 | pub fn closure_size(&self) -> u64 { 159 | let closure: Vec<_> = self.closure().unwrap_or_default() 160 | .iter() 161 | .map(|sp| sp.path()) 162 | .cloned() 163 | .collect(); 164 | files::dir_size_considering_hardlinks_all(&closure) 165 | } 166 | 167 | pub fn closure_size_naive(&self) -> u64 { 168 | self.closure().unwrap_or_default() 169 | .iter() 170 | .map(|sp| sp.path()) 171 | .map(files::dir_size_naive) 172 | .sum() 173 | } 174 | 175 | fn closure_helper(paths: &[&Self]) -> Result, String> { 176 | let key_hash = { 177 | let mut hasher = crate::Hasher::default(); 178 | paths.hash(&mut hasher); 179 | hasher.finish() 180 | }; 181 | if let Some(closure) = CLOSURE_CACHE.lookup(&key_hash) { 182 | return Ok(closure); 183 | } 184 | 185 | let paths: Vec<_> = paths.iter().map(|sp| sp.path().clone()).collect(); 186 | let output = process::Command::new("nix-store") 187 | .arg("--query") 188 | .arg("--requisites") 189 | .args(&paths) 190 | .stdin(process::Stdio::inherit()) 191 | .stderr(process::Stdio::inherit()) 192 | .output() 193 | .map_err(|e| e.to_string())?; 194 | 195 | if !output.status.success() { 196 | match output.status.code() { 197 | Some(code) => return Err(format!("`nix-store` failed (exit code {code})")), 198 | None => return Err("`nix-store` failed".to_string()), 199 | } 200 | } 201 | 202 | let closure: HashSet<_> = String::from_utf8(output.stdout) 203 | .map_err(|e| e.to_string())? 204 | .lines() 205 | .map(PathBuf::from_str) 206 | .collect::, _>>() 207 | .map_err(|e| e.to_string()) 208 | .map(|i| i.into_iter().map(StorePath).collect())?; 209 | 210 | CLOSURE_CACHE.insert(key_hash, closure.clone()); 211 | 212 | Ok(closure) 213 | } 214 | 215 | pub fn full_closure(paths: &[&Self]) -> HashSet { 216 | let chunks: Vec<_> = paths.chunks(CLOSURE_LOOKUP_CHUNK_SIZE).collect(); 217 | chunks.par_iter() 218 | .flat_map(|c| Self::closure_helper(c)) 219 | .flatten() 220 | .collect() 221 | } 222 | 223 | } 224 | -------------------------------------------------------------------------------- /src/utils/caching.rs: -------------------------------------------------------------------------------- 1 | use std::sync::RwLock; 2 | use std::hash::Hash; 3 | 4 | use crate::HashMap; 5 | 6 | 7 | pub struct Cache(RwLock>>); 8 | 9 | 10 | impl Cache { 11 | pub const fn new() -> Self { 12 | Cache(RwLock::new(None)) 13 | } 14 | 15 | pub fn lookup(&self, key: &K) -> Option { 16 | self.0.read().unwrap().as_ref() 17 | .and_then(|cache| cache.get(key).cloned()) 18 | } 19 | 20 | pub fn insert(&self, key: K, value: V) { 21 | let mut cache_opt = self.0.write().unwrap(); 22 | 23 | if let Some(cache) = cache_opt.as_mut() { 24 | cache.insert(key, value); 25 | } else { 26 | let mut cache = HashMap::default(); 27 | cache.insert(key, value); 28 | *cache_opt = Some(cache); 29 | } 30 | } 31 | 32 | pub fn insert_inline(&self, key: K, value: V) -> V { 33 | self.insert(key, value.clone()); 34 | value 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/files.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::num; 3 | use std::os::unix::fs::{FileTypeExt, MetadataExt}; 4 | use std::path::{Path, PathBuf}; 5 | 6 | use rayon::iter::{IntoParallelRefIterator, ParallelBridge, ParallelIterator}; 7 | 8 | use crate::utils::caching::Cache; 9 | use crate::HashMap; 10 | 11 | 12 | static INODE_CACHE: Cache> = Cache::new(); 13 | 14 | type Ino = u64; 15 | type DevId = u64; 16 | type InoKey = (DevId, Ino); 17 | 18 | pub fn dir_size_naive(path: &PathBuf) -> u64 { 19 | let metadata = match path.symlink_metadata() { 20 | Ok(meta) => meta, 21 | Err(_) => return 0, 22 | }; 23 | let ft = metadata.file_type(); 24 | 25 | 26 | 27 | if ft.is_dir() { 28 | let read_dir = match fs::read_dir(path) { 29 | Ok(rd) => rd, 30 | Err(_) => return 0, 31 | }; 32 | read_dir.into_iter() 33 | .flatten() 34 | .par_bridge() 35 | .map(|entry| dir_size_naive(&entry.path())) 36 | .sum() 37 | } else if ft.is_file() { 38 | metadata.len() 39 | } else { 40 | 0 41 | } 42 | } 43 | 44 | pub fn dir_size_considering_hardlinks_all(paths: &[PathBuf]) -> u64 { 45 | let inodes = paths.par_iter() 46 | .map(|p| (p, INODE_CACHE.lookup(p))) 47 | .map(|(p, inoo)| match inoo { 48 | Some(inodes) => inodes, 49 | None => INODE_CACHE.insert_inline(p.clone(), dir_size_hl_helper(p)), 50 | }) 51 | .reduce(HashMap::default, |mut last, next| { last.extend(next); last }); 52 | inodes.values().sum() 53 | } 54 | 55 | pub fn dir_size_considering_hardlinks(path: &PathBuf) -> u64 { 56 | let inodes = match INODE_CACHE.lookup(path) { 57 | Some(inodes) => inodes, 58 | None => INODE_CACHE.insert_inline(path.clone(), dir_size_hl_helper(path)), 59 | }; 60 | inodes.values().sum() 61 | } 62 | 63 | pub fn blkdev_of_path(path: &Path) -> Result { 64 | let dev = path.symlink_metadata() 65 | .map_err(|e| e.to_string())? 66 | .dev(); 67 | find_blkdev(dev) 68 | } 69 | 70 | pub fn find_blkdev(id: u64) -> Result { 71 | fs::read_dir("/dev") 72 | .unwrap() 73 | .flatten() 74 | .flat_map(|e| e.path().file_name().map(|n| (e, n.to_string_lossy().to_string()))) 75 | .flat_map(|(e, n)| e.metadata().map(|m| (n, m))) 76 | .filter(|(_, m)| m.file_type().is_block_device()) 77 | .find(|(_, m)| m.rdev() == id) 78 | .map(|(n, _)| n) 79 | .ok_or(format!("Could not find device for id {id}")) 80 | } 81 | 82 | pub fn get_blkdev_size(name: &str) -> Result { 83 | let size_file_path = PathBuf::from(&format!("/sys/class/block/{name}/size")); 84 | fs::read_to_string(size_file_path) 85 | .map_err(|e| e.to_string())? 86 | .lines() 87 | .next() 88 | .ok_or(String::from("Size file empty"))? 89 | .parse() 90 | .map_err(|e: num::ParseIntError| e.to_string()) 91 | .map(|n: u64| n * 512) 92 | } 93 | 94 | fn dir_size_hl_helper(path: &PathBuf) -> HashMap { 95 | let metadata = match path.symlink_metadata() { 96 | Ok(meta) => meta, 97 | Err(_) => return HashMap::default(), 98 | }; 99 | let ft = metadata.file_type(); 100 | 101 | if ft.is_dir() { 102 | let read_dir = match fs::read_dir(path) { 103 | Ok(rd) => rd, 104 | Err(_) => return HashMap::default(), 105 | }; 106 | 107 | read_dir.into_iter() 108 | .par_bridge() 109 | .flatten() 110 | .map(|e| dir_size_hl_helper(&e.path())) 111 | .reduce(HashMap::default, |mut last, next| { last.extend(next); last }) 112 | } else if ft.is_file() { 113 | let mut new = HashMap::default(); 114 | new.insert((metadata.dev(), metadata.ino()), metadata.len()); 115 | new 116 | } else { 117 | HashMap::default() 118 | } 119 | } 120 | 121 | -------------------------------------------------------------------------------- /src/utils/fmt.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp, io}; 2 | use std::{fmt::Display, time::Duration}; 3 | 4 | use size::Size; 5 | 6 | use super::terminal::terminal_width; 7 | 8 | 9 | pub trait Formattable: Display { 10 | const MAX_WIDTH: usize; 11 | 12 | fn left_pad(&self) -> String { 13 | format!("{:>width$}", self.to_string(), width = Self::MAX_WIDTH) 14 | } 15 | 16 | fn right_pad(&self) -> String { 17 | format!("{: FmtBracketed where Self: Sized { 21 | FmtBracketed::new(self) 22 | } 23 | 24 | fn with_prefix(self, prefix: String) -> FmtPrefix where Self: Sized { 25 | FmtPrefix::new(self, prefix) 26 | } 27 | 28 | fn with_suffix(self, suffix: String) -> FmtSuffix where Self: Sized { 29 | FmtSuffix::new(self, suffix) 30 | } 31 | } 32 | 33 | 34 | 35 | pub struct FmtSize(Size); 36 | pub struct FmtPercentage(u64); 37 | pub struct FmtBracketed(Box, [char; 2]); 38 | pub struct FmtOrNA(Option, bool); 39 | pub struct FmtAge(Duration); 40 | pub struct FmtWithEllipsis(String, usize, bool); 41 | pub struct FmtPrefix(Box, String); 42 | pub struct FmtSuffix(Box, String); 43 | 44 | 45 | impl FmtSize { 46 | pub fn new(bytes: u64) -> Self { 47 | FmtSize(Size::from_bytes(bytes)) 48 | } 49 | } 50 | 51 | impl FmtPercentage { 52 | pub fn new(amount: u64, total: u64) -> Self { 53 | FmtPercentage(amount * 100 / total) 54 | } 55 | } 56 | 57 | impl FmtWithEllipsis { 58 | pub fn fitting_terminal(s: String, preferred_width: usize, leave_space: usize) -> Self { 59 | let actual_width = match terminal_width(io::stdout()).ok() { 60 | Some(tw) => cmp::min(tw.saturating_sub(leave_space), preferred_width), 61 | None => preferred_width, 62 | }; 63 | FmtWithEllipsis(s, actual_width, true) 64 | } 65 | 66 | pub fn truncate_if(mut self, trunc: bool) -> Self { 67 | self.2 = trunc; 68 | self 69 | } 70 | 71 | pub fn right_pad(&self) -> String { 72 | format!("{: FmtBracketed { 77 | pub fn new(obj: T) -> Self { 78 | FmtBracketed(Box::new(obj), ['(', ')']) 79 | } 80 | 81 | pub fn with_square_brackets(mut self) -> Self { 82 | self.1 = ['[', ']']; 83 | self 84 | } 85 | } 86 | 87 | impl FmtOrNA { 88 | pub fn mapped(option: Option, fun: impl Fn(S) -> T) -> Self { 89 | match option { 90 | Some(val) => Self::with(fun(val)), 91 | None => Self::na(), 92 | } 93 | } 94 | 95 | pub fn with(obj: T) -> Self { 96 | FmtOrNA(Some(obj), true) 97 | } 98 | 99 | pub fn na() -> Self { 100 | FmtOrNA(None, true) 101 | } 102 | 103 | pub fn or_empty(mut self) -> Self { 104 | self.1 = false; 105 | self 106 | } 107 | } 108 | 109 | impl FmtAge { 110 | pub fn new(age: Duration) -> Self { 111 | FmtAge(age) 112 | } 113 | } 114 | 115 | impl FmtPrefix { 116 | pub fn new(obj: T, prefix: String) -> Self { 117 | FmtPrefix(Box::new(obj), prefix) 118 | } 119 | } 120 | 121 | impl FmtSuffix { 122 | pub fn new(obj: T, suffix: String) -> Self { 123 | FmtSuffix(Box::new(obj), suffix) 124 | } 125 | } 126 | 127 | 128 | 129 | impl Formattable for FmtSize { 130 | const MAX_WIDTH: usize = 11; 131 | } 132 | 133 | impl Formattable for FmtPercentage { 134 | const MAX_WIDTH: usize = 3; 135 | } 136 | 137 | impl Formattable for FmtBracketed { 138 | const MAX_WIDTH: usize = T::MAX_WIDTH + 2; 139 | } 140 | 141 | impl Formattable for FmtOrNA { 142 | const MAX_WIDTH: usize = [3, T::MAX_WIDTH][(3 < T::MAX_WIDTH) as usize]; 143 | } 144 | 145 | impl Formattable for FmtAge { 146 | const MAX_WIDTH: usize = 9; 147 | } 148 | 149 | impl Formattable for FmtPrefix { 150 | const MAX_WIDTH: usize = T::MAX_WIDTH + ADD; 151 | } 152 | 153 | impl Formattable for FmtSuffix { 154 | const MAX_WIDTH: usize = T::MAX_WIDTH + ADD; 155 | } 156 | 157 | 158 | 159 | impl Display for FmtSize { 160 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 161 | self.0.fmt(f) 162 | } 163 | } 164 | 165 | impl Display for FmtPercentage { 166 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 167 | write!(f, "{}%", self.0) 168 | } 169 | } 170 | 171 | impl Display for FmtBracketed { 172 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 173 | write!(f, "{}{}{}", self.1[0], self.0, self.1[1]) 174 | } 175 | } 176 | 177 | impl Display for FmtOrNA { 178 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 179 | match &self.0 { 180 | Some(val) => write!(f, "{val}"), 181 | None => write!(f, "{}", if self.1 { "n/a" } else { "" }), 182 | } 183 | } 184 | } 185 | 186 | impl Display for FmtAge { 187 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 188 | let seconds = self.0.as_secs(); 189 | let minutes = seconds / 60; 190 | let hours = minutes / 60; 191 | let days = hours / 24; 192 | let weeks = days / 7; 193 | let years = days / 365; 194 | 195 | if minutes < 1 { 196 | write!(f, "{seconds} sec") 197 | } else if hours < 1 { 198 | write!(f, "{minutes} min") 199 | } else if days < 1 { 200 | if hours == 1 { 201 | write!(f, "1 hour") 202 | } else { 203 | write!(f, "{hours} hours") 204 | } 205 | } else if years < 1 { 206 | if days == 1 { 207 | write!(f, "1 day") 208 | } else { 209 | write!(f, "{days} days") 210 | } 211 | } else if years < 3 { 212 | if weeks == 1 { 213 | write!(f, "1 week") 214 | } else { 215 | write!(f, "{weeks} weeks") 216 | } 217 | } else if years == 1 { 218 | write!(f, "1 year") 219 | } else { 220 | write!(f, "{years} years") 221 | } 222 | 223 | } 224 | } 225 | 226 | impl Display for FmtPrefix { 227 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 228 | write!(f, "{}{}", self.1, self.0) 229 | } 230 | } 231 | 232 | impl Display for FmtSuffix { 233 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 234 | write!(f, "{}{}", self.0, self.1) 235 | } 236 | } 237 | 238 | impl Display for FmtWithEllipsis { 239 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 240 | let FmtWithEllipsis(s, width, trunc) = self; 241 | let s = if *trunc && s.len() > *width { 242 | format!("{}...", &s[..width.saturating_sub(3)]) 243 | } else { 244 | s.to_owned() 245 | }; 246 | 247 | write!(f, "{s}") 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/utils/interaction.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::io::Write; 3 | use std::process; 4 | 5 | use colored::Colorize; 6 | 7 | pub fn resolve(result: Result) -> T { 8 | match result { 9 | Ok(t) => t, 10 | Err(e) => { 11 | eprintln!("{} {}", "Error:".red(), e); 12 | process::exit(1) 13 | }, 14 | } 15 | } 16 | 17 | pub fn warn(warning: &str) { 18 | eprintln!("{} {}", "Warning:".yellow(), warning); 19 | } 20 | 21 | pub fn ask(question: &str, default: bool) -> bool { 22 | loop { 23 | match default { 24 | true => print!("{question} [Y/n] "), 25 | false => print!("{question} [y/N] "), 26 | } 27 | let _ = std::io::stdout().flush(); 28 | 29 | let mut input = String::new(); 30 | match std::io::stdin().read_line(&mut input) { 31 | Ok(_) => (), 32 | Err(_) => continue, 33 | }; 34 | 35 | match input.trim() { 36 | "y" | "Y" | "yes" | "Yes" | "YES" => return true, 37 | "n" | "N" | "no" | "No" | "NO" => return false, 38 | "" => return default, 39 | _ => continue, 40 | } 41 | } 42 | } 43 | 44 | pub fn ack(question: &str) { 45 | loop { 46 | print!("{question} [enter] "); 47 | let _ = std::io::stdout().flush(); 48 | 49 | let mut input = String::new(); 50 | match std::io::stdin().read_line(&mut input) { 51 | Ok(_) => (), 52 | Err(_) => continue, 53 | }; 54 | return; 55 | } 56 | } 57 | 58 | pub fn announce(s: &str) { 59 | println!("\n{}", format!("=> {s}").green()); 60 | } 61 | 62 | pub fn conclusion(s: &str) { 63 | println!("\n-> {}", s); 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/journal.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use crate::utils::files; 5 | 6 | pub const JOURNAL_PATH: &str = "/var/log/journal"; 7 | 8 | 9 | pub fn journal_exists() -> bool { 10 | fs::exists(Path::new(JOURNAL_PATH)) 11 | .unwrap_or(false) 12 | } 13 | 14 | pub fn journal_size() -> u64 { 15 | files::dir_size_naive(&PathBuf::from(JOURNAL_PATH)) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod caching; 2 | pub mod files; 3 | pub mod fmt; 4 | pub mod interaction; 5 | pub mod journal; 6 | pub mod ordered_channel; 7 | pub mod terminal; 8 | -------------------------------------------------------------------------------- /src/utils/ordered_channel.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Condvar, Mutex}; 2 | 3 | use crate::HashMap; 4 | 5 | pub struct OrderedChannel { 6 | inner: Mutex>, 7 | cond: Condvar, 8 | } 9 | 10 | pub struct OrderedChannelIterator<'a, T> { 11 | channel: &'a OrderedChannel, 12 | iter_counter: usize, 13 | total: usize, 14 | } 15 | 16 | 17 | impl OrderedChannel { 18 | pub fn new() -> OrderedChannel { 19 | OrderedChannel { 20 | inner: Mutex::new(HashMap::default()), 21 | cond: Condvar::new(), 22 | } 23 | } 24 | 25 | pub fn put(&self, i: usize, object: T) { 26 | let mut inner = self.inner.lock().unwrap(); 27 | inner.insert(i, object); 28 | self.cond.notify_all(); 29 | } 30 | 31 | pub fn get(&self, i: usize) -> T { 32 | let mut inner = self.inner.lock().unwrap(); 33 | loop { 34 | match inner.remove(&i) { 35 | Some(item) => return item, 36 | None => inner = self.cond.wait(inner).unwrap(), 37 | } 38 | } 39 | } 40 | 41 | pub fn iter(&self, total: usize) -> OrderedChannelIterator<'_, T> { 42 | OrderedChannelIterator { channel: self, iter_counter: 0, total } 43 | } 44 | } 45 | 46 | impl Iterator for OrderedChannelIterator<'_, T> { 47 | type Item = T; 48 | 49 | fn next(&mut self) -> Option { 50 | if self.iter_counter == self.total { 51 | return None; 52 | } 53 | 54 | self.iter_counter += 1; 55 | Some(self.channel.get(self.iter_counter - 1)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/terminal.rs: -------------------------------------------------------------------------------- 1 | use std::os::fd; 2 | 3 | use rustix::termios; 4 | 5 | pub fn terminal_width(fd: impl fd::AsFd) -> Result { 6 | if !termios::isatty(&fd) { 7 | Err("Unable to get terminal width: Stream is not a tty".to_owned()) 8 | } else { 9 | match termios::tcgetwinsize(fd) { 10 | Ok(s) => Ok(s.ws_col as usize), 11 | Err(e) => Err(format!("Unable to get terminal width: {e}")), 12 | } 13 | } 14 | } 15 | --------------------------------------------------------------------------------