├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── bench ├── clippy.toml ├── release └── src ├── config.rs ├── fmt.rs ├── fs.rs ├── load.rs ├── main.rs ├── mem.rs ├── module.rs ├── net.rs ├── systemd.rs └── temp.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: cargo build --verbose 14 | 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - run: cargo test --verbose 20 | 21 | clippy: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions-rs/toolchain@v1 26 | with: 27 | profile: minimal 28 | toolchain: stable 29 | override: true 30 | - run: rustup component add clippy 31 | - uses: actions-rs/cargo@v1 32 | with: 33 | command: clippy 34 | args: -- -D warnings 35 | 36 | fmt: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions-rs/toolchain@v1 41 | with: 42 | profile: minimal 43 | toolchain: stable 44 | override: true 45 | - run: rustup component add rustfmt 46 | - uses: actions-rs/cargo@v1 47 | with: 48 | command: fmt 49 | args: --all -- --check 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - "*.*.**" 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | deb-release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions-rs/toolchain@v1 20 | with: 21 | profile: minimal 22 | toolchain: stable 23 | target: x86_64-unknown-linux-musl 24 | - uses: taiki-e/install-action@v2 25 | with: 26 | tool: cargo-deb 27 | - run: cargo deb --target x86_64-unknown-linux-musl 28 | - uses: softprops/action-gh-release@v1 29 | with: 30 | files: target/x86_64-unknown-linux-musl/debian/*.deb 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # https://pre-commit.com 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.6.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-executables-have-shebangs 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-toml 13 | - id: check-vcs-permalinks 14 | - id: check-xml 15 | - id: check-yaml 16 | - id: end-of-file-fixer 17 | - id: fix-byte-order-marker 18 | - id: mixed-line-ending 19 | args: 20 | - --fix=no 21 | - id: trailing-whitespace 22 | args: 23 | - --markdown-linebreak-ext=md 24 | 25 | - repo: https://github.com/doublify/pre-commit-rust 26 | rev: v1.0 27 | hooks: 28 | - id: cargo-check 29 | - id: clippy 30 | - id: fmt 31 | 32 | - repo: https://github.com/shellcheck-py/shellcheck-py 33 | rev: v0.9.0.6 34 | hooks: 35 | - id: shellcheck 36 | 37 | - repo: https://github.com/pre-commit/mirrors-prettier 38 | rev: v2.7.1 39 | hooks: 40 | - id: prettier 41 | args: 42 | - --print-width=120 43 | - --write 44 | stages: [commit] 45 | 46 | - repo: https://github.com/compilerla/conventional-pre-commit 47 | rev: v3.1.0 48 | hooks: 49 | - id: conventional-pre-commit 50 | stages: [commit-msg] 51 | args: [chore, config, doc, test] 52 | -------------------------------------------------------------------------------- /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 = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "ansi_term" 31 | version = "0.12.1" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 34 | dependencies = [ 35 | "winapi", 36 | ] 37 | 38 | [[package]] 39 | name = "anyhow" 40 | version = "1.0.95" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 43 | dependencies = [ 44 | "backtrace", 45 | ] 46 | 47 | [[package]] 48 | name = "atty" 49 | version = "0.2.14" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 52 | dependencies = [ 53 | "hermit-abi 0.1.19", 54 | "libc", 55 | "winapi", 56 | ] 57 | 58 | [[package]] 59 | name = "autocfg" 60 | version = "1.4.0" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 63 | 64 | [[package]] 65 | name = "backtrace" 66 | version = "0.3.74" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 69 | dependencies = [ 70 | "addr2line", 71 | "cfg-if", 72 | "libc", 73 | "miniz_oxide", 74 | "object", 75 | "rustc-demangle", 76 | "windows-targets", 77 | ] 78 | 79 | [[package]] 80 | name = "bitflags" 81 | version = "1.3.2" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 84 | 85 | [[package]] 86 | name = "bitflags" 87 | version = "2.6.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 90 | 91 | [[package]] 92 | name = "cfg-if" 93 | version = "1.0.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 96 | 97 | [[package]] 98 | name = "clap" 99 | version = "3.2.25" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" 102 | dependencies = [ 103 | "atty", 104 | "bitflags 1.3.2", 105 | "clap_lex", 106 | "indexmap 1.9.3", 107 | "termcolor", 108 | "textwrap", 109 | ] 110 | 111 | [[package]] 112 | name = "clap_lex" 113 | version = "0.2.4" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" 116 | dependencies = [ 117 | "os_str_bytes", 118 | ] 119 | 120 | [[package]] 121 | name = "either" 122 | version = "1.13.0" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" 125 | 126 | [[package]] 127 | name = "equivalent" 128 | version = "1.0.1" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 131 | 132 | [[package]] 133 | name = "gimli" 134 | version = "0.31.1" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 137 | 138 | [[package]] 139 | name = "hashbrown" 140 | version = "0.12.3" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 143 | 144 | [[package]] 145 | name = "hashbrown" 146 | version = "0.15.2" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 149 | 150 | [[package]] 151 | name = "hermit-abi" 152 | version = "0.1.19" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 155 | dependencies = [ 156 | "libc", 157 | ] 158 | 159 | [[package]] 160 | name = "hermit-abi" 161 | version = "0.3.9" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 164 | 165 | [[package]] 166 | name = "indexmap" 167 | version = "1.9.3" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 170 | dependencies = [ 171 | "autocfg", 172 | "hashbrown 0.12.3", 173 | ] 174 | 175 | [[package]] 176 | name = "indexmap" 177 | version = "2.7.0" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" 180 | dependencies = [ 181 | "equivalent", 182 | "hashbrown 0.15.2", 183 | ] 184 | 185 | [[package]] 186 | name = "itertools" 187 | version = "0.13.0" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 190 | dependencies = [ 191 | "either", 192 | ] 193 | 194 | [[package]] 195 | name = "libc" 196 | version = "0.2.169" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 199 | 200 | [[package]] 201 | name = "lock_api" 202 | version = "0.4.12" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 205 | dependencies = [ 206 | "autocfg", 207 | "scopeguard", 208 | ] 209 | 210 | [[package]] 211 | name = "memchr" 212 | version = "2.7.4" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 215 | 216 | [[package]] 217 | name = "miniz_oxide" 218 | version = "0.8.2" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" 221 | dependencies = [ 222 | "adler2", 223 | ] 224 | 225 | [[package]] 226 | name = "motd" 227 | version = "1.3.2" 228 | dependencies = [ 229 | "ansi_term", 230 | "anyhow", 231 | "clap", 232 | "itertools", 233 | "libc", 234 | "num_cpus", 235 | "regex", 236 | "serde", 237 | "serde_regex", 238 | "serial_test", 239 | "termsize", 240 | "toml", 241 | "walkdir", 242 | "xdg", 243 | ] 244 | 245 | [[package]] 246 | name = "num_cpus" 247 | version = "1.16.0" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" 250 | dependencies = [ 251 | "hermit-abi 0.3.9", 252 | "libc", 253 | ] 254 | 255 | [[package]] 256 | name = "object" 257 | version = "0.36.7" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 260 | dependencies = [ 261 | "memchr", 262 | ] 263 | 264 | [[package]] 265 | name = "once_cell" 266 | version = "1.20.2" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 269 | 270 | [[package]] 271 | name = "os_str_bytes" 272 | version = "6.6.1" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" 275 | 276 | [[package]] 277 | name = "parking_lot" 278 | version = "0.12.3" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 281 | dependencies = [ 282 | "lock_api", 283 | "parking_lot_core", 284 | ] 285 | 286 | [[package]] 287 | name = "parking_lot_core" 288 | version = "0.9.10" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 291 | dependencies = [ 292 | "cfg-if", 293 | "libc", 294 | "redox_syscall", 295 | "smallvec", 296 | "windows-targets", 297 | ] 298 | 299 | [[package]] 300 | name = "proc-macro2" 301 | version = "1.0.92" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 304 | dependencies = [ 305 | "unicode-ident", 306 | ] 307 | 308 | [[package]] 309 | name = "quote" 310 | version = "1.0.38" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 313 | dependencies = [ 314 | "proc-macro2", 315 | ] 316 | 317 | [[package]] 318 | name = "redox_syscall" 319 | version = "0.5.8" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" 322 | dependencies = [ 323 | "bitflags 2.6.0", 324 | ] 325 | 326 | [[package]] 327 | name = "regex" 328 | version = "1.11.1" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 331 | dependencies = [ 332 | "aho-corasick", 333 | "memchr", 334 | "regex-automata", 335 | "regex-syntax", 336 | ] 337 | 338 | [[package]] 339 | name = "regex-automata" 340 | version = "0.4.9" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 343 | dependencies = [ 344 | "aho-corasick", 345 | "memchr", 346 | "regex-syntax", 347 | ] 348 | 349 | [[package]] 350 | name = "regex-syntax" 351 | version = "0.8.5" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 354 | 355 | [[package]] 356 | name = "rustc-demangle" 357 | version = "0.1.24" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 360 | 361 | [[package]] 362 | name = "same-file" 363 | version = "1.0.6" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 366 | dependencies = [ 367 | "winapi-util", 368 | ] 369 | 370 | [[package]] 371 | name = "scc" 372 | version = "2.3.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "28e1c91382686d21b5ac7959341fcb9780fa7c03773646995a87c950fa7be640" 375 | dependencies = [ 376 | "sdd", 377 | ] 378 | 379 | [[package]] 380 | name = "scopeguard" 381 | version = "1.2.0" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 384 | 385 | [[package]] 386 | name = "sdd" 387 | version = "3.0.5" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "478f121bb72bbf63c52c93011ea1791dca40140dfe13f8336c4c5ac952c33aa9" 390 | 391 | [[package]] 392 | name = "serde" 393 | version = "1.0.217" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 396 | dependencies = [ 397 | "serde_derive", 398 | ] 399 | 400 | [[package]] 401 | name = "serde_derive" 402 | version = "1.0.217" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 405 | dependencies = [ 406 | "proc-macro2", 407 | "quote", 408 | "syn", 409 | ] 410 | 411 | [[package]] 412 | name = "serde_regex" 413 | version = "1.1.0" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" 416 | dependencies = [ 417 | "regex", 418 | "serde", 419 | ] 420 | 421 | [[package]] 422 | name = "serde_spanned" 423 | version = "0.6.8" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 426 | dependencies = [ 427 | "serde", 428 | ] 429 | 430 | [[package]] 431 | name = "serial_test" 432 | version = "3.2.0" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" 435 | dependencies = [ 436 | "once_cell", 437 | "parking_lot", 438 | "scc", 439 | "serial_test_derive", 440 | ] 441 | 442 | [[package]] 443 | name = "serial_test_derive" 444 | version = "3.2.0" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" 447 | dependencies = [ 448 | "proc-macro2", 449 | "quote", 450 | "syn", 451 | ] 452 | 453 | [[package]] 454 | name = "smallvec" 455 | version = "1.13.2" 456 | source = "registry+https://github.com/rust-lang/crates.io-index" 457 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 458 | 459 | [[package]] 460 | name = "syn" 461 | version = "2.0.94" 462 | source = "registry+https://github.com/rust-lang/crates.io-index" 463 | checksum = "987bc0be1cdea8b10216bd06e2ca407d40b9543468fafd3ddfb02f36e77f71f3" 464 | dependencies = [ 465 | "proc-macro2", 466 | "quote", 467 | "unicode-ident", 468 | ] 469 | 470 | [[package]] 471 | name = "termcolor" 472 | version = "1.4.1" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 475 | dependencies = [ 476 | "winapi-util", 477 | ] 478 | 479 | [[package]] 480 | name = "termsize" 481 | version = "0.1.9" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "6f11ff5c25c172608d5b85e2fb43ee9a6d683a7f4ab7f96ae07b3d8b590368fd" 484 | dependencies = [ 485 | "libc", 486 | "winapi", 487 | ] 488 | 489 | [[package]] 490 | name = "textwrap" 491 | version = "0.16.1" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" 494 | 495 | [[package]] 496 | name = "toml" 497 | version = "0.8.19" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 500 | dependencies = [ 501 | "serde", 502 | "serde_spanned", 503 | "toml_datetime", 504 | "toml_edit", 505 | ] 506 | 507 | [[package]] 508 | name = "toml_datetime" 509 | version = "0.6.8" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 512 | dependencies = [ 513 | "serde", 514 | ] 515 | 516 | [[package]] 517 | name = "toml_edit" 518 | version = "0.22.22" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" 521 | dependencies = [ 522 | "indexmap 2.7.0", 523 | "serde", 524 | "serde_spanned", 525 | "toml_datetime", 526 | "winnow", 527 | ] 528 | 529 | [[package]] 530 | name = "unicode-ident" 531 | version = "1.0.14" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 534 | 535 | [[package]] 536 | name = "walkdir" 537 | version = "2.5.0" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 540 | dependencies = [ 541 | "same-file", 542 | "winapi-util", 543 | ] 544 | 545 | [[package]] 546 | name = "winapi" 547 | version = "0.3.9" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 550 | dependencies = [ 551 | "winapi-i686-pc-windows-gnu", 552 | "winapi-x86_64-pc-windows-gnu", 553 | ] 554 | 555 | [[package]] 556 | name = "winapi-i686-pc-windows-gnu" 557 | version = "0.4.0" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 560 | 561 | [[package]] 562 | name = "winapi-util" 563 | version = "0.1.9" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 566 | dependencies = [ 567 | "windows-sys", 568 | ] 569 | 570 | [[package]] 571 | name = "winapi-x86_64-pc-windows-gnu" 572 | version = "0.4.0" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 575 | 576 | [[package]] 577 | name = "windows-sys" 578 | version = "0.59.0" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 581 | dependencies = [ 582 | "windows-targets", 583 | ] 584 | 585 | [[package]] 586 | name = "windows-targets" 587 | version = "0.52.6" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 590 | dependencies = [ 591 | "windows_aarch64_gnullvm", 592 | "windows_aarch64_msvc", 593 | "windows_i686_gnu", 594 | "windows_i686_gnullvm", 595 | "windows_i686_msvc", 596 | "windows_x86_64_gnu", 597 | "windows_x86_64_gnullvm", 598 | "windows_x86_64_msvc", 599 | ] 600 | 601 | [[package]] 602 | name = "windows_aarch64_gnullvm" 603 | version = "0.52.6" 604 | source = "registry+https://github.com/rust-lang/crates.io-index" 605 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 606 | 607 | [[package]] 608 | name = "windows_aarch64_msvc" 609 | version = "0.52.6" 610 | source = "registry+https://github.com/rust-lang/crates.io-index" 611 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 612 | 613 | [[package]] 614 | name = "windows_i686_gnu" 615 | version = "0.52.6" 616 | source = "registry+https://github.com/rust-lang/crates.io-index" 617 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 618 | 619 | [[package]] 620 | name = "windows_i686_gnullvm" 621 | version = "0.52.6" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 624 | 625 | [[package]] 626 | name = "windows_i686_msvc" 627 | version = "0.52.6" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 630 | 631 | [[package]] 632 | name = "windows_x86_64_gnu" 633 | version = "0.52.6" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 636 | 637 | [[package]] 638 | name = "windows_x86_64_gnullvm" 639 | version = "0.52.6" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 642 | 643 | [[package]] 644 | name = "windows_x86_64_msvc" 645 | version = "0.52.6" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 648 | 649 | [[package]] 650 | name = "winnow" 651 | version = "0.6.22" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "39281189af81c07ec09db316b302a3e67bf9bd7cbf6c820b50e35fee9c2fa980" 654 | dependencies = [ 655 | "memchr", 656 | ] 657 | 658 | [[package]] 659 | name = "xdg" 660 | version = "2.5.2" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" 663 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "motd" 3 | version = "1.3.2" 4 | authors = ["desbma "] 5 | description = "Dynamically generate Linux MOTD SSH banner" 6 | license = "GPL-3.0-only" 7 | edition = "2021" 8 | 9 | [profile.release] 10 | lto = true 11 | codegen-units = 1 12 | panic = "abort" 13 | strip = true 14 | 15 | [profile.release-tiny] 16 | inherits = "release" 17 | opt-level = "z" 18 | 19 | [dev-dependencies] 20 | serial_test = { version = "3.2.0", default-features = false } 21 | 22 | [dependencies] 23 | ansi_term = { version = "0.12.1", default-features = false } 24 | anyhow = { version = "1.0.95", default-features = false, features = ["std", "backtrace"] } 25 | clap = { version = "3.2.25", default-features = false, features = ["std", "color"] } 26 | itertools = { version = "0.13.0", default-features = false, features = ["use_std"] } 27 | libc = { version = "0.2.169", default-features = false } 28 | num_cpus = { version = "1.16.0", default-features = false } 29 | regex = { version = "1.11.1", default-features = false, features = ["std"] } 30 | serde = { version = "1.0.217", default-features = false, features = ["derive", "std"] } 31 | serde_regex = { version = "1.1.0", default-features = false } 32 | termsize = { version = "0.1.9", default-features = false } 33 | toml = { version = "0.8.19", default-features = false, features = ["parse"] } 34 | walkdir = { version = "2.5.0", default-features = false } 35 | xdg = { version = "2.5.2", default-features = false } 36 | 37 | [lints.rust] 38 | # https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html 39 | explicit_outlives_requirements = "warn" 40 | missing_docs = "warn" 41 | non_ascii_idents = "deny" 42 | redundant-lifetimes = "warn" 43 | single-use-lifetimes = "warn" 44 | unit-bindings = "warn" 45 | unreachable_pub = "warn" 46 | unused_crate_dependencies = "warn" 47 | unused-lifetimes = "warn" 48 | unused-qualifications = "warn" 49 | 50 | [lints.clippy] 51 | pedantic = { level = "warn", priority = -1 } 52 | cast_possible_truncation = "allow" 53 | cast_precision_loss = "allow" 54 | cast_sign_loss = "allow" 55 | # below lints are from clippy::restriction, and assume clippy >= 1.81 56 | # https://rust-lang.github.io/rust-clippy/master/index.html#/?levels=allow&groups=restriction 57 | allow_attributes = "warn" 58 | clone_on_ref_ptr = "warn" 59 | dbg_macro = "warn" 60 | empty_enum_variants_with_brackets = "warn" 61 | expect_used = "warn" 62 | field_scoped_visibility_modifiers = "warn" 63 | fn_to_numeric_cast_any = "warn" 64 | format_push_string = "warn" 65 | if_then_some_else_none = "warn" 66 | impl_trait_in_params = "warn" 67 | infinite_loop = "warn" 68 | lossy_float_literal = "warn" 69 | # missing_docs_in_private_items = "warn" 70 | mixed_read_write_in_expression = "warn" 71 | multiple_inherent_impl = "warn" 72 | needless_raw_strings = "warn" 73 | panic = "warn" 74 | pub_without_shorthand = "warn" 75 | redundant_type_annotations = "warn" 76 | ref_patterns = "warn" 77 | renamed_function_params = "warn" 78 | rest_pat_in_fully_bound_structs = "warn" 79 | same_name_method = "warn" 80 | self_named_module_files = "warn" 81 | semicolon_inside_block = "warn" 82 | shadow_unrelated = "warn" 83 | str_to_string = "warn" 84 | string_slice = "warn" 85 | string_to_string = "warn" 86 | tests_outside_test_module = "warn" 87 | try_err = "warn" 88 | undocumented_unsafe_blocks = "warn" 89 | unnecessary_safety_comment = "warn" 90 | unnecessary_safety_doc = "warn" 91 | unneeded_field_pattern = "warn" 92 | unseparated_literal_suffix = "warn" 93 | # unwrap_used = "warn" 94 | verbose_file_reads = "warn" 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MOTD 2 | 3 | [![Build status](https://github.com/desbma/motd/actions/workflows/ci.yml/badge.svg)](https://github.com/desbma/motd/actions) 4 | [![AUR version](https://img.shields.io/aur/version/motd.svg?style=flat)](https://aur.archlinux.org/packages/motd/) 5 | [![License](https://img.shields.io/github/license/desbma/motd.svg?style=flat)](https://github.com/desbma/motd/blob/master/LICENSE) 6 | 7 | Dynamically generate Linux MOTD SSH banner 8 | 9 | ## Goals 10 | 11 | - Should be very fast (no perceived visual latency, even under high load) 12 | - Display relevant system information, and colorize anormal measures in orange if something is suspicious, red if it requires immediate action 13 | - Be reasonably portable across Linux boxes (rsync'ing the binary should work) 14 | - Learn Rust :) 15 | 16 | ## Information displayed 17 | 18 | - system load (orange/red if close/above CPU count) 19 | - memory/swap usage 20 | - filesystem usage (orange/red if almost full) 21 | - hardware temperatures (CPU, HDD...) (orange/red if too hot) 22 | - network interface bandwidth 23 | - Systemd units in failed state (red) 24 | 25 | ## Screenshot 26 | 27 | [![Imgur](https://i.imgur.com/OPrRqKzl.png)](https://i.imgur.com/OPrRqKz.png) 28 | 29 | ## Installation 30 | 31 | ### From source 32 | 33 | You need a Rust build environment for example from [rustup](https://rustup.rs/). 34 | 35 | ``` 36 | cargo build --release 37 | install -Dm 755 -t /usr/local/bin target/release/motd 38 | ``` 39 | 40 | ### Debian package 41 | 42 | See [GitHub releases](https://github.com/desbma/motd/releases) for Debian packages built for each tagged version. 43 | 44 | ### From the AUR 45 | 46 | Arch Linux users can install the [motd AUR package](https://aur.archlinux.org/packages/motd/). 47 | 48 | ## Configuration 49 | 50 | Configuration is **optional**, and allows you to exclude for example some filesystems or temperature sensors based on regular expressions. 51 | 52 | Example of `~/.config/motd/config.toml` config file: 53 | 54 | ``` 55 | [fs] 56 | mount_path_blacklist = ["^/dev($|/)", "^/run($|/)"] 57 | mount_type_blacklist = ["^tmpfs$"] 58 | 59 | [temp] 60 | hwmon_label_blacklist = ["^CPUTIN$", "^SYSTIN$"] 61 | 62 | ``` 63 | 64 | ## License 65 | 66 | [GPLv3](https://www.gnu.org/licenses/gpl-3.0-standalone.html) 67 | -------------------------------------------------------------------------------- /bench: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | cargo build --release 4 | 5 | for section in {l,m,s,f,t,u,n} 6 | do 7 | hyperfine "./target/release/motd -s ${section}" 8 | done 9 | 10 | hyperfine "./target/release/motd -s l,m,s,f,t,u,n" 11 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | allow-expect-in-tests = true 2 | allow-panic-in-tests = true 3 | allow-unwrap-in-tests = true 4 | avoid-breaking-exported-api = false 5 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | set -o pipefail 4 | 5 | readonly VERSION=${1:?} 6 | 7 | 8 | cd "$(git rev-parse --show-toplevel)" 9 | 10 | sed -i "s/^\(version = \"\).*\(\"\)/\1$VERSION\2/w /dev/stdout" Cargo.toml 11 | 12 | cargo update 13 | cargo check && cargo test -- --test-threads=1 14 | 15 | git add Cargo.{toml,lock} 16 | 17 | git commit -m "chore: version ${VERSION}" 18 | 19 | git tag -m "Version ${VERSION}" "${VERSION}" 20 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Local configuration 2 | 3 | /// Local configuration 4 | #[derive(Debug, Default, serde::Deserialize)] 5 | #[serde(default)] 6 | pub(crate) struct Config { 7 | /// Filesystem module config 8 | pub fs: FsConfig, 9 | 10 | /// Temp module config 11 | pub temp: TempConfig, 12 | } 13 | 14 | /// Filesystem module config 15 | #[derive(Debug, Default, serde::Deserialize)] 16 | #[serde(default)] 17 | pub(crate) struct FsConfig { 18 | /// Exclude filesystem whose type match any of theses regexs 19 | #[serde(with = "serde_regex")] 20 | pub mount_type_blacklist: Vec, 21 | /// Exclude filesystem whose mount point match any of theses regexs 22 | #[serde(with = "serde_regex")] 23 | pub mount_path_blacklist: Vec, 24 | } 25 | 26 | /// Temp module config 27 | #[derive(Debug, Default, serde::Deserialize)] 28 | #[serde(default)] 29 | pub(crate) struct TempConfig { 30 | /// Exclude temp probes label (/sys/class/hwmon/hwmon*/temp*_label files) matching any of theses regexs 31 | #[serde(with = "serde_regex")] 32 | pub hwmon_label_blacklist: Vec, 33 | // TODO blacklist for names too (/sys/class/hwmon/hwmon*/name)? 34 | } 35 | 36 | /// Parse local configuration 37 | pub(crate) fn parse_config() -> anyhow::Result { 38 | let binary_name = env!("CARGO_PKG_NAME"); 39 | let xdg_dirs = xdg::BaseDirectories::with_prefix(binary_name)?; 40 | let config = if let Some(config_filepath) = xdg_dirs.find_config_file("config.toml") { 41 | let toml_data = std::fs::read_to_string(config_filepath)?; 42 | toml::from_str(&toml_data)? 43 | } else { 44 | Config::default() 45 | }; 46 | Ok(config) 47 | } 48 | -------------------------------------------------------------------------------- /src/fmt.rs: -------------------------------------------------------------------------------- 1 | /// Format numeric value with K/M/G/T prefix 2 | pub(crate) fn format_kmgt(val: u64, unit: &str) -> String { 3 | const K: u64 = 1024; 4 | const M: u64 = K * 1024; 5 | const G: u64 = M * 1024; 6 | const T: u64 = G * 1024; 7 | if val >= T { 8 | format!("{:.1} T{}", val as f32 / T as f32, unit) 9 | } else if val >= G { 10 | format!("{:.1} G{}", val as f32 / G as f32, unit) 11 | } else if val >= M { 12 | format!("{:.1} M{}", val as f32 / M as f32, unit) 13 | } else if val >= K { 14 | format!("{:.1} K{}", val as f32 / K as f32, unit) 15 | } else { 16 | format!("{val} {unit}") 17 | } 18 | } 19 | 20 | /// Format numeric value with k/M/G/T prefix 21 | pub(crate) fn format_kmgt_si(val: u64, unit: &str) -> String { 22 | const K_SI: u64 = 1000; 23 | const M_SI: u64 = K_SI * 1000; 24 | const G_SI: u64 = M_SI * 1000; 25 | const T_SI: u64 = G_SI * 1000; 26 | if val >= T_SI { 27 | format!("{:.1} T{}", val as f32 / T_SI as f32, unit) 28 | } else if val >= G_SI { 29 | format!("{:.1} G{}", val as f32 / G_SI as f32, unit) 30 | } else if val >= M_SI { 31 | format!("{:.1} M{}", val as f32 / M_SI as f32, unit) 32 | } else if val >= K_SI { 33 | format!("{:.1} k{}", val as f32 / K_SI as f32, unit) 34 | } else { 35 | format!("{val} {unit}") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/fs.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp, 3 | collections::HashSet, 4 | ffi::{CStr, CString, OsStr}, 5 | fmt, io, mem, 6 | os::unix::ffi::OsStrExt, 7 | path::{Path, PathBuf}, 8 | sync::atomic::Ordering, 9 | }; 10 | 11 | use ansi_term::{ 12 | Colour::{Red, Yellow}, 13 | Style, 14 | }; 15 | use libc::{endmntent, getmntent, setmntent, statvfs}; 16 | 17 | use crate::{ 18 | config, 19 | fmt::format_kmgt, 20 | module::{ModuleData, TERM_COLUMNS}, 21 | }; 22 | 23 | const MIN_FS_BAR_LEN: usize = 30; 24 | 25 | /// Information on a filesystem 26 | pub(crate) struct FsMountInfo { 27 | mount_path: PathBuf, 28 | used_bytes: u64, 29 | total_bytes: u64, 30 | } 31 | 32 | /// Information on all filesystems 33 | pub(crate) struct FsInfo { 34 | mounts: Vec, 35 | } 36 | 37 | /// Fetch filesystem information for all filesystems 38 | pub(crate) fn fetch(cfg: &config::FsConfig) -> anyhow::Result { 39 | let mut mounts = Vec::new(); 40 | 41 | // Open mount list file 42 | // Note: /etc/mtab is a symlink to /proc/self/mounts 43 | let path = CString::new("/proc/mounts")?; 44 | let mode = CString::new("r")?; 45 | // SAFETY: libc call 46 | let mount_file = unsafe { setmntent(path.as_ptr(), mode.as_ptr()) }; 47 | anyhow::ensure!(!mount_file.is_null(), "setmntent failed"); 48 | 49 | // Loop over mounts 50 | let mut known_devices = HashSet::new(); 51 | loop { 52 | // SAFETY: libc call 53 | let mount = unsafe { getmntent(mount_file) }; 54 | if mount.is_null() { 55 | break; 56 | } 57 | let mount_path_raw; 58 | let fs_type; 59 | let fs_dev; 60 | // SAFETY: get getmntend output 61 | unsafe { 62 | mount_path_raw = CStr::from_ptr((*mount).mnt_dir); 63 | fs_type = CStr::from_ptr((*mount).mnt_type).to_str()?; 64 | fs_dev = CStr::from_ptr((*mount).mnt_fsname).to_str()?; 65 | } 66 | let mount_path: &Path = OsStr::from_bytes(mount_path_raw.to_bytes()).as_ref(); 67 | 68 | // Exclusions 69 | if cfg.mount_type_blacklist.iter().any(|r| r.is_match(fs_type)) { 70 | continue; 71 | } 72 | if let Some(mount_path) = mount_path.to_str() { 73 | if cfg 74 | .mount_path_blacklist 75 | .iter() 76 | .any(|r| r.is_match(mount_path)) 77 | { 78 | continue; 79 | } 80 | } 81 | 82 | // Exclude mounts of devices already mounted (avoids duplicate for bind mounts or btrfs subvolumes) 83 | if fs_dev.starts_with('/') { 84 | if known_devices.contains(&fs_dev) { 85 | continue; 86 | } 87 | known_devices.insert(fs_dev); 88 | } 89 | 90 | // Get filesystem info 91 | let Ok(mount_info) = fetch_mount_info(mount_path) else { 92 | continue; 93 | }; 94 | if mount_info.total_bytes == 0 { 95 | // procfs, sysfs... 96 | continue; 97 | } 98 | mounts.push(mount_info); 99 | } 100 | 101 | // Close mount list file 102 | // SAFETY: libc call 103 | unsafe { 104 | endmntent(mount_file); 105 | } // endmntent always returns 1 106 | 107 | mounts.sort_by(|a, b| a.mount_path.cmp(&b.mount_path)); 108 | 109 | Ok(ModuleData::Fs(FsInfo { mounts })) 110 | } 111 | 112 | /// Fetch detailed filesystem information 113 | #[allow(clippy::allow_attributes, clippy::unnecessary_cast)] // 32/64 bits 114 | fn fetch_mount_info(mount_path: &Path) -> Result { 115 | // SAFETY: libc call arg 116 | let mut fs_stat: statvfs = unsafe { mem::zeroed() }; 117 | let mount_point = CString::new(mount_path.as_os_str().as_bytes())?; 118 | // SAFETY: libc call 119 | let rc = unsafe { statvfs(mount_point.as_ptr(), &mut fs_stat) }; 120 | if rc != 0 { 121 | return Err(io::Error::last_os_error()); 122 | } 123 | 124 | let total_bytes = fs_stat.f_blocks * fs_stat.f_bsize as u64; 125 | let used_bytes = total_bytes - fs_stat.f_bfree * fs_stat.f_bsize as u64; 126 | 127 | Ok(FsMountInfo { 128 | total_bytes, 129 | used_bytes, 130 | mount_path: mount_path.to_path_buf(), 131 | }) 132 | } 133 | 134 | /// Generate a bar to represent filesystem usage 135 | #[expect(clippy::string_slice)] 136 | pub(crate) fn get_fs_bar(mount_info: &FsMountInfo, length: usize, style: Style) -> String { 137 | assert!(length >= MIN_FS_BAR_LEN); 138 | 139 | let bar_text = format!( 140 | "{} / {} ({:.1}%)", 141 | format_kmgt(mount_info.used_bytes, "B"), 142 | format_kmgt(mount_info.total_bytes, "B"), 143 | 100.0 * mount_info.used_bytes as f32 / mount_info.total_bytes as f32 144 | ); 145 | 146 | // Center bar text inside fill chars 147 | let bar_text_len = bar_text.len(); 148 | let fill_count_before = (length - 2 - bar_text_len) / 2; 149 | let chars_used = 150 | ((length - 2) as u64 * mount_info.used_bytes / mount_info.total_bytes) as usize; 151 | 152 | let bar_char = '█'; 153 | 154 | let pos1 = cmp::min(chars_used, fill_count_before); 155 | let pos2 = fill_count_before; 156 | let pos3 = cmp::max( 157 | fill_count_before, 158 | cmp::min(chars_used, fill_count_before + bar_text_len), 159 | ); 160 | let pos4 = fill_count_before + bar_text_len; 161 | let pos5 = cmp::max(chars_used, fill_count_before + bar_text_len); 162 | 163 | format!( 164 | "{}{}{}{}{}{}{}{}", 165 | style.paint("▕"), 166 | style.paint(bar_char.to_string().repeat(pos1)), 167 | style.paint(' '.to_string().repeat(pos2 - pos1)), 168 | style.reverse().paint(&bar_text[0..(pos3 - pos2)]), 169 | style.paint(&bar_text[(pos3 - pos2)..]), 170 | style.paint(bar_char.to_string().repeat(pos5 - pos4)), 171 | style.paint(' '.to_string().repeat(length - 2 - pos5)), 172 | style.paint("▏"), 173 | ) 174 | } 175 | 176 | fn ellipsis(s: &str, max_len: usize) -> String { 177 | assert!(max_len >= 1); 178 | 179 | if s.chars().count() <= max_len { 180 | s.to_owned() 181 | } else { 182 | let mut new_s: String = s.to_owned().chars().take(max_len - 1).collect(); // truncate on unicode char boundaries 183 | new_s.push('…'); 184 | new_s 185 | } 186 | } 187 | 188 | impl fmt::Display for FsInfo { 189 | /// Output filesystem information 190 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 191 | let term_width = cmp::max(TERM_COLUMNS.load(Ordering::SeqCst), MIN_FS_BAR_LEN + 3); 192 | let path_max_len = term_width - 1 - MIN_FS_BAR_LEN; 193 | 194 | let pretty_mount_paths: Vec = self 195 | .mounts 196 | .iter() 197 | .map(|x| { 198 | Ok(ellipsis( 199 | x.mount_path 200 | .to_str() 201 | .ok_or_else(|| anyhow::anyhow!("Unable to decode mount point"))?, 202 | path_max_len, 203 | )) 204 | }) 205 | .collect::>>() 206 | .map_err(|_| fmt::Error)?; 207 | let max_path_len = pretty_mount_paths 208 | .iter() 209 | .map(|x| x.chars().count()) 210 | .max() 211 | .unwrap(); 212 | 213 | for (mount_info, pretty_mount_path) in self.mounts.iter().zip(pretty_mount_paths) { 214 | let fs_usage = mount_info.used_bytes as f32 / mount_info.total_bytes as f32; 215 | let text_style = if fs_usage >= 0.95 { 216 | Red.normal() 217 | } else if fs_usage >= 0.85 { 218 | Yellow.normal() 219 | } else { 220 | Style::new() 221 | }; 222 | 223 | writeln!( 224 | f, 225 | "{}{} {}", 226 | text_style.paint(&pretty_mount_path), 227 | text_style.paint(" ".repeat(max_path_len - pretty_mount_path.chars().count())), 228 | get_fs_bar( 229 | mount_info, 230 | cmp::max(term_width - max_path_len - 1, MIN_FS_BAR_LEN), 231 | text_style 232 | ) 233 | )?; 234 | } 235 | 236 | Ok(()) 237 | } 238 | } 239 | 240 | #[cfg(test)] 241 | mod tests { 242 | use serial_test::serial; 243 | 244 | use super::*; 245 | 246 | #[test] 247 | #[serial] 248 | fn test_output_fs_info() { 249 | TERM_COLUMNS.store(40, Ordering::SeqCst); 250 | assert_eq!( 251 | format!( 252 | "{}", 253 | FsInfo { 254 | mounts: vec![ 255 | FsMountInfo { 256 | mount_path: PathBuf::from("/foo/bar"), 257 | used_bytes: 234_560, 258 | total_bytes: 7_891_011 259 | }, 260 | FsMountInfo { 261 | mount_path: PathBuf::from("/foo/baz"), 262 | used_bytes: 2_345_600_000, 263 | total_bytes: 7_891_011_000 264 | } 265 | ] 266 | }, 267 | ), 268 | "/foo/bar ▕ \u{1b}[7m\u{1b}[0m229.1 KB / 7.5 MB (3.0%) ▏\n/foo/baz ▕███\u{1b}[7m2.2 G\u{1b}[0mB / 7.3 GB (29.7%) ▏\n" 269 | ); 270 | assert_eq!( 271 | format!( 272 | "{}", 273 | FsInfo { 274 | mounts: vec![FsMountInfo { 275 | mount_path: PathBuf::from("/0123456789"), 276 | used_bytes: 500, 277 | total_bytes: 1000 278 | },] 279 | }, 280 | ), 281 | "/0123456… ▕███\u{1b}[7m500 B / 100\u{1b}[0m0 B (50.0%) ▏\n" 282 | ); 283 | } 284 | 285 | #[test] 286 | fn test_get_fs_bar() { 287 | assert_eq!( 288 | get_fs_bar( 289 | &FsMountInfo{ 290 | mount_path: PathBuf::from("/foo/bar"), 291 | used_bytes: 23456, 292 | total_bytes: 7_891_011 293 | }, 294 | 40, 295 | Red.normal() 296 | ), 297 | "\u{1b}[31m▕\u{1b}[0m\u{1b}[31m\u{1b}[0m\u{1b}[31m \u{1b}[0m\u{1b}[7;31m\u{1b}[0m\u{1b}[31m22.9 KB / 7.5 MB (0.3%)\u{1b}[0m\u{1b}[31m\u{1b}[0m\u{1b}[31m \u{1b}[0m\u{1b}[31m▏\u{1b}[0m" 298 | ); 299 | assert_eq!( 300 | get_fs_bar( 301 | &FsMountInfo { 302 | mount_path: PathBuf::from("/foo/bar"), 303 | used_bytes: 0, 304 | total_bytes: 7_891_011 305 | }, 306 | 40, 307 | Style::new() 308 | ), 309 | "▕ \u{1b}[7m\u{1b}[0m0 B / 7.5 MB (0.0%) ▏" 310 | ); 311 | assert_eq!( 312 | get_fs_bar( 313 | &FsMountInfo { 314 | mount_path: PathBuf::from("/foo/bar"), 315 | used_bytes: 434_560, 316 | total_bytes: 7_891_011 317 | }, 318 | 40, 319 | Style::new() 320 | ), 321 | "▕██ \u{1b}[7m\u{1b}[0m424.4 KB / 7.5 MB (5.5%) ▏" 322 | ); 323 | assert_eq!( 324 | get_fs_bar( 325 | &FsMountInfo { 326 | mount_path: PathBuf::from("/foo/bar"), 327 | used_bytes: 4_891_011_000, 328 | total_bytes: 7_891_011_000 329 | }, 330 | 40, 331 | Style::new() 332 | ), 333 | "▕███████\u{1b}[7m4.6 GB / 7.3 GB \u{1b}[0m(62.0%) ▏" 334 | ); 335 | assert_eq!( 336 | get_fs_bar( 337 | &FsMountInfo { 338 | mount_path: PathBuf::from("/foo/bar"), 339 | used_bytes: 4_891_011_000, 340 | total_bytes: 7_891_011_000 341 | }, 342 | 30, 343 | Style::new() 344 | ), 345 | "▕██\u{1b}[7m4.6 GB / 7.3 GB\u{1b}[0m (62.0%) ▏" 346 | ); 347 | assert_eq!( 348 | get_fs_bar( 349 | &FsMountInfo { 350 | mount_path: PathBuf::from("/foo/bar"), 351 | used_bytes: 4_891_011_000, 352 | total_bytes: 7_891_011_000 353 | }, 354 | 50, 355 | Style::new() 356 | ), 357 | "▕████████████\u{1b}[7m4.6 GB / 7.3 GB (\u{1b}[0m62.0%) ▏" 358 | ); 359 | assert_eq!( 360 | get_fs_bar( 361 | &FsMountInfo { 362 | mount_path: PathBuf::from("/foo/bar"), 363 | used_bytes: 6_891_011_000_000, 364 | total_bytes: 7_891_011_000_000 365 | }, 366 | 40, 367 | Style::new() 368 | ), 369 | "▕███████\u{1b}[7m6.3 TB / 7.2 TB (87.3%)\u{1b}[0m███ ▏" 370 | ); 371 | assert_eq!( 372 | get_fs_bar( 373 | &FsMountInfo { 374 | mount_path: PathBuf::from("/foo/bar"), 375 | used_bytes: 7_891_011_000_000, 376 | total_bytes: 7_891_011_000_000 377 | }, 378 | 40, 379 | Style::new() 380 | ), 381 | "▕███████\u{1b}[7m7.2 TB / 7.2 TB (100.0%)\u{1b}[0m███████▏" 382 | ); 383 | } 384 | 385 | #[test] 386 | fn test_ellipsis() { 387 | assert_eq!(ellipsis("", 3), "…"); 388 | assert_eq!(ellipsis("", 4), ""); 389 | assert_eq!(ellipsis("", 5), ""); 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /src/load.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, fs, str::FromStr, sync::atomic::Ordering}; 2 | 3 | use ansi_term::Colour::{Red, Yellow}; 4 | 5 | use crate::module::{ModuleData, CPU_COUNT}; 6 | 7 | /// Names of failed Systemd units 8 | #[derive(Debug)] 9 | pub(crate) struct LoadInfo { 10 | /// Load average 1 minute 11 | load_avg_1m: f32, 12 | /// Load average 5 minutes 13 | load_avg_5m: f32, 14 | /// Load average 15 minutes 15 | load_avg_15m: f32, 16 | /// Total task count 17 | task_count: u32, 18 | } 19 | 20 | /// Fetch load information from /proc/loadavg 21 | #[expect(clippy::similar_names)] 22 | pub(crate) fn fetch() -> anyhow::Result { 23 | let line = fs::read_to_string("/proc/loadavg")?; 24 | 25 | let mut tokens_it = line.split(' '); 26 | let load_avg_1m = f32::from_str( 27 | tokens_it 28 | .next() 29 | .ok_or_else(|| anyhow::anyhow!("Failed to parse load average 1m"))?, 30 | )?; 31 | let load_avg_5m = f32::from_str( 32 | tokens_it 33 | .next() 34 | .ok_or_else(|| anyhow::anyhow!("Failed to parse load average 5m"))?, 35 | )?; 36 | let load_avg_15m = f32::from_str( 37 | tokens_it 38 | .next() 39 | .ok_or_else(|| anyhow::anyhow!("Failed to parse load average 15m"))?, 40 | )?; 41 | 42 | let task_count = u32::from_str( 43 | tokens_it 44 | .next() 45 | .ok_or_else(|| anyhow::anyhow!("Failed to parse task count"))? 46 | .split('/') 47 | .nth(1) 48 | .ok_or_else(|| anyhow::anyhow!("Failed to parse task count"))?, 49 | )?; 50 | 51 | Ok(ModuleData::Load(LoadInfo { 52 | load_avg_1m, 53 | load_avg_5m, 54 | load_avg_15m, 55 | task_count, 56 | })) 57 | } 58 | 59 | impl fmt::Display for LoadInfo { 60 | /// Output load information 61 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 62 | let cpu_count = CPU_COUNT.load(Ordering::SeqCst); 63 | writeln!( 64 | f, 65 | "Load avg 1min: {}, 5 min: {}, 15 min: {}", 66 | colorize_load(self.load_avg_1m, cpu_count), 67 | colorize_load(self.load_avg_5m, cpu_count), 68 | colorize_load(self.load_avg_15m, cpu_count) 69 | )?; 70 | writeln!(f, "Tasks: {}", self.task_count) 71 | } 72 | } 73 | 74 | /// Colorize load string 75 | fn colorize_load(load: f32, cpu_count: usize) -> String { 76 | if load >= cpu_count as f32 { 77 | Red.paint(load.to_string()).to_string() 78 | } else if load >= cpu_count as f32 * 0.8 { 79 | Yellow.paint(load.to_string()).to_string() 80 | } else { 81 | load.to_string() 82 | } 83 | } 84 | 85 | #[cfg(test)] 86 | mod tests { 87 | use super::*; 88 | 89 | #[test] 90 | fn test_output_load_info() { 91 | CPU_COUNT.store(3, Ordering::SeqCst); 92 | assert_eq!( 93 | format!( 94 | "{}", 95 | LoadInfo { 96 | load_avg_1m: 1.1, 97 | load_avg_5m: 2.9, 98 | load_avg_15m: 3.1, 99 | task_count: 12345, 100 | }, 101 | ), 102 | "Load avg 1min: 1.1, 5 min: \u{1b}[33m2.9\u{1b}[0m, 15 min: \u{1b}[31m3.1\u{1b}[0m\nTasks: 12345\n" 103 | ); 104 | } 105 | 106 | #[test] 107 | fn test_colorize_load() { 108 | assert_eq!(colorize_load(7.9, 10), "7.9"); 109 | assert_eq!(colorize_load(8.0, 10), "\u{1b}[33m8\u{1b}[0m"); 110 | assert_eq!(colorize_load(8.1, 10), "\u{1b}[33m8.1\u{1b}[0m"); 111 | assert_eq!(colorize_load(9.9, 10), "\u{1b}[33m9.9\u{1b}[0m"); 112 | assert_eq!(colorize_load(10.0, 10), "\u{1b}[31m10\u{1b}[0m"); 113 | assert_eq!(colorize_load(10.1, 10), "\u{1b}[31m10.1\u{1b}[0m"); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! MOTD banner generator 2 | 3 | use std::{cmp, iter::Iterator, path::Path, str::FromStr, sync::atomic::Ordering, thread}; 4 | 5 | use ansi_term::Colour::Red; 6 | use anyhow::Context; 7 | use clap::{App, Arg}; 8 | use itertools::Itertools; 9 | 10 | use crate::module::ModuleData; 11 | 12 | mod config; 13 | mod fmt; 14 | mod fs; 15 | mod load; 16 | mod mem; 17 | mod module; 18 | mod net; 19 | mod systemd; 20 | mod temp; 21 | 22 | /// Output section 23 | #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] 24 | enum Section { 25 | Load, 26 | Mem, 27 | Swap, 28 | FS, 29 | Temps, 30 | Network, 31 | SDFailedUnits, 32 | } 33 | 34 | /// Parsed command line arguments 35 | struct CLArgs { 36 | /// Maximum terminal columns to use 37 | term_columns: usize, 38 | 39 | /// Sections to display 40 | sections: Vec
, 41 | 42 | /// Whether or not to display each section title 43 | show_section_titles: bool, 44 | } 45 | 46 | /// Fallback terminal column count (width), if it could not be detected 47 | const FALLBACK_TERM_COLUMNS: usize = 80; 48 | 49 | /// Message shown when there is a delay 50 | const LOADING_MSG: &str = "Loading…"; 51 | 52 | /// Output section header to stdout 53 | fn output_title(title: &str, columns: usize) { 54 | println!("{:─^width$}", format!(" {title} "), width = columns); 55 | } 56 | 57 | /// Output section title and lines 58 | fn output_section( 59 | title: &str, 60 | lines: Result, 61 | show_title: bool, 62 | delayed: bool, 63 | columns: usize, 64 | ) { 65 | if delayed { 66 | eprint!("\r{}\r", " ".repeat(LOADING_MSG.len())); 67 | } 68 | match lines { 69 | Ok(lines) => { 70 | if !lines.is_empty() { 71 | if show_title { 72 | output_title(title, columns); 73 | } 74 | print!("{lines}"); 75 | } 76 | } 77 | Err(err) => { 78 | eprintln!( 79 | "{}", 80 | Red.paint(format!("Failed to get data for '{title}' section: {err}")) 81 | ); 82 | } 83 | } 84 | } 85 | 86 | /// Get Section from letter 87 | fn section_to_letter(section: Section) -> &'static str { 88 | match section { 89 | Section::Load => "l", 90 | Section::Mem => "m", 91 | Section::Swap => "s", 92 | Section::FS => "f", 93 | Section::Temps => "t", 94 | Section::Network => "n", 95 | Section::SDFailedUnits => "u", 96 | } 97 | } 98 | 99 | /// Get letter from Section 100 | fn pretty_section_name(section: &Section) -> &str { 101 | match section { 102 | Section::Load => "Load", 103 | Section::Mem => "Memory usage", 104 | Section::Swap => "Swap usage", 105 | Section::FS => "Filesystem usage", 106 | Section::Temps => "Hardware temperatures", 107 | Section::Network => "Network", 108 | Section::SDFailedUnits => "Systemd failed units", 109 | } 110 | } 111 | 112 | /// Get Section from letter 113 | fn letter_to_section(letter: &str) -> Section { 114 | match letter { 115 | "l" => Section::Load, 116 | "m" => Section::Mem, 117 | "s" => Section::Swap, 118 | "f" => Section::FS, 119 | "t" => Section::Temps, 120 | "n" => Section::Network, 121 | "u" => Section::SDFailedUnits, 122 | _ => unreachable!(), // validated by clap 123 | } 124 | } 125 | 126 | /// Validate a isize integer string for Clap usage 127 | fn validator_isize(s: &str) -> Result<(), String> { 128 | match isize::from_str(s) { 129 | Ok(_) => Ok(()), 130 | Err(_) => Err("Not a valid integer value".to_owned()), 131 | } 132 | } 133 | 134 | /// Parse and validate command line arguments 135 | fn parse_cl_args() -> CLArgs { 136 | // Default values 137 | let default_term_columns_string = format!("-{FALLBACK_TERM_COLUMNS}"); 138 | let sections_str: Vec<&'static str> = [ 139 | Section::Load, 140 | Section::Mem, 141 | Section::Swap, 142 | Section::FS, 143 | Section::Temps, 144 | Section::Network, 145 | Section::SDFailedUnits, 146 | ] 147 | .into_iter() 148 | .map(section_to_letter) 149 | .collect(); 150 | let default_sections_string = sections_str 151 | .iter() 152 | .filter(|l| { 153 | if **l == "u" { 154 | Path::new("/run/systemd/system").is_dir() 155 | } else { 156 | true 157 | } 158 | }) 159 | .join(","); 160 | 161 | // Clap arg matching 162 | let matches = App::new("motd") 163 | .version(env!("CARGO_PKG_VERSION")) 164 | .about("Show dynamic summary of system information") 165 | .author("desbma") 166 | .arg( 167 | Arg::with_name("SECTIONS") 168 | .short('s') 169 | .long("sections") 170 | .takes_value(true) 171 | .multiple(true) 172 | .use_delimiter(true) 173 | .default_value(&default_sections_string) 174 | .possible_values(§ions_str) 175 | .help( 176 | "Sections to display. \ 177 | l: System load. \ 178 | m: Memory. \ 179 | s: Swap.\ 180 | f: Filesystem usage. \ 181 | t: Hardware temperatures. \ 182 | n: Network interface stats. \ 183 | u: Systemd failed units." 184 | ), 185 | ) 186 | .arg( 187 | Arg::with_name("NO_TITLES") 188 | .short('n') 189 | .long("no-titles") 190 | .help("Do not display section titles."), 191 | ) 192 | .arg( 193 | Arg::with_name("COLUMNS") 194 | .short('c') 195 | .long("columns") 196 | .takes_value(true) 197 | .allow_hyphen_values(true) 198 | .validator(validator_isize) 199 | .default_value(&default_term_columns_string) 200 | .help("Maximum terminal columns to use. Set to 0 to autotetect. -X to use autodetected value or X, whichever is lower."), 201 | ) 202 | .get_matches(); 203 | 204 | // Post Clap parsing 205 | let sections = matches 206 | .values_of("SECTIONS") 207 | .unwrap() 208 | .map(letter_to_section) 209 | .unique() 210 | .collect(); 211 | let term_columns: usize = match isize::from_str(matches.value_of("COLUMNS").unwrap()).unwrap() { 212 | 0 => { 213 | // Autodetect 214 | termsize::get() 215 | // Detection failed, fallback to default 216 | .unwrap_or(termsize::Size { 217 | rows: 0, 218 | cols: FALLBACK_TERM_COLUMNS as u16, 219 | }) 220 | .cols as usize 221 | } 222 | v if v < 0 => { 223 | // Autodetect with minimum 224 | cmp::min( 225 | -v as usize, 226 | termsize::get() 227 | // Detection failed, fallback to default 228 | .unwrap_or(termsize::Size { 229 | rows: 0, 230 | cols: FALLBACK_TERM_COLUMNS as u16, 231 | }) 232 | .cols as usize, 233 | ) 234 | } 235 | // Passthrough 236 | v => v as usize, 237 | }; 238 | let show_section_titles = !matches.is_present("NO_TITLES"); 239 | 240 | CLArgs { 241 | term_columns, 242 | sections, 243 | show_section_titles, 244 | } 245 | } 246 | 247 | fn main() -> anyhow::Result<()> { 248 | let cl_args = parse_cl_args(); 249 | let cfg = config::parse_config().context("Failed to parse config file")?; 250 | 251 | module::CPU_COUNT.store(num_cpus::get(), Ordering::SeqCst); 252 | module::TERM_COLUMNS.store(cl_args.term_columns, Ordering::SeqCst); 253 | 254 | thread::scope(|scope| -> anyhow::Result<_> { 255 | let mut section_futs: Vec>> = 256 | Vec::with_capacity(cl_args.sections.len()); 257 | 258 | for section in &cl_args.sections { 259 | let section_fut = match section { 260 | Section::Load => scope.spawn(load::fetch), 261 | Section::Mem => scope.spawn(mem::fetch), 262 | Section::Swap => scope.spawn(|| { 263 | // TODO fetch only once? 264 | let mi = mem::fetch()?; 265 | if let ModuleData::Memory(mi) = mi { 266 | Ok(ModuleData::Swap(mem::SwapInfo::from(mi))) 267 | } else { 268 | unreachable!(); 269 | } 270 | }), 271 | Section::FS => scope.spawn(|| fs::fetch(&cfg.fs)), 272 | Section::Temps => scope.spawn(|| temp::fetch(&cfg.temp)), 273 | Section::SDFailedUnits => scope.spawn(systemd::fetch), 274 | Section::Network => scope.spawn(net::fetch), 275 | }; 276 | section_futs.push(section_fut); 277 | } 278 | 279 | for (section_fut, section) in section_futs.into_iter().zip(cl_args.sections.iter()) { 280 | let delayed = !section_fut.is_finished(); 281 | if delayed { 282 | eprint!("{LOADING_MSG}"); 283 | } 284 | let lines = section_fut 285 | .join() 286 | .map_err(|e| anyhow::anyhow!("Failed to join thread: {:?}", e))? 287 | .map(|d| format!("{d}")) 288 | .map_err(|e| format!("{e}")); 289 | output_section( 290 | pretty_section_name(section), 291 | lines, 292 | cl_args.show_section_titles, 293 | delayed, 294 | cl_args.term_columns, 295 | ); 296 | } 297 | 298 | Ok(()) 299 | }) 300 | } 301 | -------------------------------------------------------------------------------- /src/mem.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fmt, 4 | fs::File, 5 | io::{BufRead, BufReader}, 6 | str::FromStr, 7 | sync::atomic::Ordering, 8 | }; 9 | 10 | use ansi_term::Style; 11 | 12 | use crate::{ 13 | fmt::format_kmgt, 14 | module::{ModuleData, TERM_COLUMNS}, 15 | }; 16 | 17 | pub(crate) struct MemInfo { 18 | /// Map of memory usage info, unit is kB or page count 19 | vals: HashMap, 20 | } 21 | 22 | pub(crate) struct SwapInfo { 23 | mem: MemInfo, 24 | } 25 | 26 | impl From for SwapInfo { 27 | fn from(mi: MemInfo) -> Self { 28 | Self { mem: mi } 29 | } 30 | } 31 | 32 | /// Fetch memory usage info from procfs 33 | pub(crate) fn fetch() -> anyhow::Result { 34 | let mut vals = HashMap::new(); 35 | let file = File::open("/proc/meminfo")?; 36 | let reader = BufReader::new(file); 37 | for line in reader.lines() { 38 | // Parse line 39 | let line_str = line?; 40 | let mut tokens_it = line_str.split(':'); 41 | let key = tokens_it 42 | .next() 43 | .ok_or_else(|| anyhow::anyhow!("Failed to parse memory info"))? 44 | .to_owned(); 45 | let val_str = tokens_it 46 | .next() 47 | .ok_or_else(|| anyhow::anyhow!("Failed to parse memory value"))? 48 | .trim_start(); 49 | let val = u64::from_str( 50 | val_str 51 | .split(' ') 52 | .next() 53 | .ok_or_else(|| anyhow::anyhow!("Failed to parse memory value"))?, 54 | )?; 55 | 56 | // Store info 57 | vals.insert(key, val); 58 | } 59 | 60 | Ok(ModuleData::Memory(MemInfo { vals })) 61 | } 62 | 63 | /// Memory bar section 64 | struct BarPart { 65 | /// Section text 66 | label: Vec, 67 | /// Percentage of full bar this section should fill 68 | prct: f32, 69 | /// Bar text style 70 | text_style: Style, 71 | /// Bar fill char style 72 | fill_style: Style, 73 | /// Char to use to fill bar 74 | bar_char: char, 75 | } 76 | 77 | /// Print memory bar 78 | fn display_bar(parts: &[BarPart], f: &mut dyn fmt::Write) -> fmt::Result { 79 | // Compute part lengths and handle rounding 80 | let term_columns = TERM_COLUMNS.load(Ordering::SeqCst); 81 | let mut part_lens_int: Vec = parts 82 | .iter() 83 | .map(|part| ((term_columns - 2) as f32 * part.prct / 100.0) as usize) 84 | .collect(); 85 | while &part_lens_int.iter().sum() + (2_usize) < term_columns { 86 | // Compute fractional parts 87 | let part_lens_frac: Vec = parts 88 | .iter() 89 | .zip(&part_lens_int) 90 | .map(|(part, &part_len_int)| { 91 | f32::max( 92 | 0.0, 93 | ((term_columns - 2) as f32 * part.prct / 100.0) - part_len_int as f32, 94 | ) 95 | }) 96 | .collect(); 97 | 98 | // Find part_lens_frac first maximum, add 1 to corresponding integer part 99 | *part_lens_frac 100 | .iter() 101 | .zip(&mut part_lens_int) 102 | .rev() // max_by gets last maximum, this allows getting the first 103 | .max_by(|(a_frac, _a_int), (b_frac, _b_int)| a_frac.partial_cmp(b_frac).unwrap()) 104 | .unwrap() 105 | .1 += 1; 106 | } 107 | 108 | write!(f, "▕")?; 109 | 110 | for (part, part_len) in parts.iter().zip(part_lens_int) { 111 | // Build longest label that fits 112 | let mut label = String::new(); 113 | for label_part in &part.label { 114 | if label.len() + label_part.len() <= part_len { 115 | label += label_part; 116 | } else { 117 | break; 118 | } 119 | } 120 | 121 | // Center bar text inside fill chars 122 | let label_len = label.len(); 123 | let fill_count_before = (part_len - label_len) / 2; 124 | let fill_count_after = if (part_len - label_len) % 2 == 1 { 125 | fill_count_before + 1 126 | } else { 127 | fill_count_before 128 | }; 129 | write!( 130 | f, 131 | "{}", 132 | &part 133 | .fill_style 134 | .paint(part.bar_char.to_string().repeat(fill_count_before)) 135 | )?; 136 | write!(f, "{}", &part.text_style.paint(&label))?; 137 | write!( 138 | f, 139 | "{}", 140 | &part 141 | .fill_style 142 | .paint(part.bar_char.to_string().repeat(fill_count_after)) 143 | )?; 144 | } 145 | 146 | writeln!(f, "▏")?; 147 | 148 | Ok(()) 149 | } 150 | 151 | impl MemInfo { 152 | /// Print memory stat numbers 153 | fn display_stats(&self, keys: &[&str], total_key: &str, f: &mut dyn fmt::Write) -> fmt::Result { 154 | let max_key_len = keys.iter().map(|x| x.len()).max().unwrap(); 155 | let mac_size_str_len = keys 156 | .iter() 157 | .map(|&x| format_kmgt(self.vals[x] * 1024, "B").len()) 158 | .max() 159 | .unwrap(); 160 | 161 | for &key in keys { 162 | let size_str = format_kmgt(self.vals[key] * 1024, "B"); 163 | write!( 164 | f, 165 | "{}: {}{}", 166 | key, 167 | " ".repeat(max_key_len - key.len() + mac_size_str_len - size_str.len()), 168 | size_str 169 | )?; 170 | if key != total_key { 171 | write!( 172 | f, 173 | " ({: >4.1}%)", 174 | 100.0 * self.vals[key] as f32 / self.vals[total_key] as f32 175 | )?; 176 | } 177 | 178 | writeln!(f)?; 179 | } 180 | 181 | Ok(()) 182 | } 183 | } 184 | 185 | impl fmt::Display for MemInfo { 186 | /// Output memory info 187 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 188 | self.display_stats( 189 | &["MemTotal", "MemFree", "Dirty", "Cached", "Buffers"], 190 | "MemTotal", 191 | f, 192 | )?; 193 | 194 | let total_mem_mb = self.vals["MemTotal"] / 1024; 195 | let cache_mem_mb = self.vals["Cached"] / 1024; 196 | let buffer_mem_mb = self.vals["Buffers"] / 1024; 197 | let free_mem_mb = self.vals["MemFree"] / 1024; 198 | let used_mem_mb = total_mem_mb - cache_mem_mb - buffer_mem_mb - free_mem_mb; 199 | 200 | let mut mem_bar_parts = Vec::new(); 201 | 202 | let used_prct = 100.0 * used_mem_mb as f32 / total_mem_mb as f32; 203 | let used_bar_text: Vec = vec![ 204 | "Used".to_owned(), 205 | format!(" {:.1}GB", used_mem_mb as f32 / 1024.0), 206 | format!(" ({used_prct:.1}%)"), 207 | ]; 208 | mem_bar_parts.push(BarPart { 209 | label: used_bar_text, 210 | prct: used_prct, 211 | text_style: Style::new().reverse(), 212 | fill_style: Style::new(), 213 | bar_char: '█', 214 | }); 215 | 216 | let cached_prct = 100.0 * (cache_mem_mb + buffer_mem_mb) as f32 / total_mem_mb as f32; 217 | let cached_bar_text: Vec = vec![ 218 | "Cached".to_owned(), 219 | format!(" {:.1}GB", (cache_mem_mb + buffer_mem_mb) as f32 / 1024.0), 220 | format!(" ({cached_prct:.1}%)"), 221 | ]; 222 | mem_bar_parts.push(BarPart { 223 | label: cached_bar_text, 224 | prct: cached_prct, 225 | text_style: Style::new().dimmed().reverse(), 226 | fill_style: Style::new().dimmed(), 227 | bar_char: '█', 228 | }); 229 | 230 | let free_prct = 100.0 * free_mem_mb as f32 / total_mem_mb as f32; 231 | let free_bar_text: Vec = vec![ 232 | "Free".to_owned(), 233 | format!(" {:.1}GB", free_mem_mb as f32 / 1024.0), 234 | format!(" ({free_prct:.1}%)"), 235 | ]; 236 | mem_bar_parts.push(BarPart { 237 | label: free_bar_text, 238 | prct: free_prct, 239 | text_style: Style::new(), 240 | fill_style: Style::new(), 241 | bar_char: ' ', 242 | }); 243 | 244 | display_bar(&mem_bar_parts, f)?; 245 | 246 | Ok(()) 247 | } 248 | } 249 | 250 | impl fmt::Display for SwapInfo { 251 | /// Output swap info 252 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 253 | if self.mem.vals["SwapTotal"] > 0 { 254 | self.mem 255 | .display_stats(&["SwapTotal", "SwapFree"], "SwapTotal", f)?; 256 | 257 | let total_swap_mb = self.mem.vals["SwapTotal"] / 1024; 258 | let free_swap_mb = self.mem.vals["SwapFree"] / 1024; 259 | let used_swap_mb = total_swap_mb - free_swap_mb; 260 | 261 | let mut swap_bar_parts = Vec::new(); 262 | 263 | let used_prct = 100.0 * used_swap_mb as f32 / total_swap_mb as f32; 264 | let used_bar_text: Vec = vec![ 265 | "Used".to_owned(), 266 | format!(" {:.1}GB", used_swap_mb as f32 / 1024.0), 267 | format!(" ({used_prct:.1}%)"), 268 | ]; 269 | swap_bar_parts.push(BarPart { 270 | label: used_bar_text, 271 | prct: used_prct, 272 | text_style: Style::new().reverse(), 273 | fill_style: Style::new(), 274 | bar_char: '█', 275 | }); 276 | 277 | let free_prct = 100.0 * free_swap_mb as f32 / total_swap_mb as f32; 278 | let free_bar_text: Vec = vec![ 279 | "Swap free".to_owned(), 280 | format!(" {:.1}GB", free_swap_mb as f32 / 1024.0), 281 | format!(" ({free_prct:.1}%)"), 282 | ]; 283 | swap_bar_parts.push(BarPart { 284 | label: free_bar_text, 285 | prct: free_prct, 286 | text_style: Style::new(), 287 | fill_style: Style::new(), 288 | bar_char: ' ', 289 | }); 290 | 291 | display_bar(&swap_bar_parts, f)?; 292 | } 293 | 294 | Ok(()) 295 | } 296 | } 297 | 298 | #[cfg(test)] 299 | #[expect(clippy::shadow_unrelated)] 300 | mod tests { 301 | use ansi_term::Colour::*; 302 | use serial_test::serial; 303 | 304 | use super::*; 305 | 306 | #[test] 307 | #[serial] 308 | fn test_output_bar() { 309 | // Check rounding 310 | TERM_COLUMNS.store(102, Ordering::SeqCst); 311 | let mut f = String::new(); 312 | display_bar( 313 | &[ 314 | BarPart { 315 | label: vec![ 316 | "part1".to_owned(), 317 | "PART1".to_owned(), 318 | "P_A_R_T_1".to_owned(), 319 | ], 320 | prct: 100.0 / 3.0, 321 | text_style: Style::new(), 322 | fill_style: Style::new(), 323 | bar_char: '#', 324 | }, 325 | BarPart { 326 | label: vec![ 327 | "part2".to_owned(), 328 | "PART2".to_owned(), 329 | "P_A_R_T_2".to_owned(), 330 | ], 331 | prct: 100.0 / 3.0, 332 | text_style: Style::new(), 333 | fill_style: Style::new(), 334 | bar_char: 'X', 335 | }, 336 | BarPart { 337 | label: vec![ 338 | "part3".to_owned(), 339 | "PART3".to_owned(), 340 | "P_A_R_T_3".to_owned(), 341 | ], 342 | prct: 100.0 / 3.0, 343 | text_style: Style::new(), 344 | fill_style: Style::new(), 345 | bar_char: '%', 346 | }, 347 | ], 348 | &mut f, 349 | ) 350 | .unwrap(); 351 | assert_eq!( 352 | f, 353 | "▕#######part1PART1P_A_R_T_1########XXXXXXXpart2PART2P_A_R_T_2XXXXXXX%%%%%%%part3PART3P_A_R_T_3%%%%%%%▏\n" 354 | ); 355 | 356 | f.clear(); 357 | display_bar( 358 | &[ 359 | BarPart { 360 | label: vec![ 361 | "part1".to_owned(), 362 | "PART1".to_owned(), 363 | "P_A_R_T_1".to_owned(), 364 | ], 365 | prct: 20.34, 366 | text_style: Style::new(), 367 | fill_style: Style::new(), 368 | bar_char: '#', 369 | }, 370 | BarPart { 371 | label: vec![ 372 | "part2".to_owned(), 373 | "PART2".to_owned(), 374 | "P_A_R_T_2".to_owned(), 375 | ], 376 | prct: 30.32, 377 | text_style: Style::new(), 378 | fill_style: Style::new(), 379 | bar_char: 'X', 380 | }, 381 | BarPart { 382 | label: vec![ 383 | "part3".to_owned(), 384 | "PART3".to_owned(), 385 | "P_A_R_T_3".to_owned(), 386 | ], 387 | prct: 48.33, 388 | text_style: Style::new(), 389 | fill_style: Style::new(), 390 | bar_char: '%', 391 | }, 392 | ], 393 | &mut f, 394 | ) 395 | .unwrap(); 396 | assert_eq!( 397 | f, 398 | "▕#part1PART1P_A_R_T_1#XXXXXpart2PART2P_A_R_T_2XXXXXX%%%%%%%%%%%%%%%part3PART3P_A_R_T_3%%%%%%%%%%%%%%%▏\n" 399 | ); 400 | 401 | f.clear(); 402 | display_bar( 403 | &[ 404 | BarPart { 405 | label: vec![ 406 | "part1".to_owned(), 407 | "PART1".to_owned(), 408 | "P_A_R_T_1".to_owned(), 409 | ], 410 | prct: 20.5, 411 | text_style: Style::new(), 412 | fill_style: Style::new(), 413 | bar_char: '#', 414 | }, 415 | BarPart { 416 | label: vec![ 417 | "part2".to_owned(), 418 | "PART2".to_owned(), 419 | "P_A_R_T_2".to_owned(), 420 | ], 421 | prct: 30.6, 422 | text_style: Style::new(), 423 | fill_style: Style::new(), 424 | bar_char: 'X', 425 | }, 426 | BarPart { 427 | label: vec![ 428 | "part3".to_owned(), 429 | "PART3".to_owned(), 430 | "P_A_R_T_3".to_owned(), 431 | ], 432 | prct: 48.9, 433 | text_style: Style::new(), 434 | fill_style: Style::new(), 435 | bar_char: '%', 436 | }, 437 | ], 438 | &mut f, 439 | ) 440 | .unwrap(); 441 | assert_eq!( 442 | f, 443 | "▕part1PART1P_A_R_T_1#XXXXXXpart2PART2P_A_R_T_2XXXXXX%%%%%%%%%%%%%%%part3PART3P_A_R_T_3%%%%%%%%%%%%%%%▏\n" 444 | ); 445 | 446 | TERM_COLUMNS.store(80, Ordering::SeqCst); 447 | f.clear(); 448 | display_bar( 449 | &[ 450 | BarPart { 451 | label: vec![ 452 | "part1".to_owned(), 453 | "PART1".to_owned(), 454 | "P_A_R_T_1".to_owned(), 455 | ], 456 | prct: 100.0 / 3.0, 457 | text_style: Style::new(), 458 | fill_style: Style::new(), 459 | bar_char: '#', 460 | }, 461 | BarPart { 462 | label: vec![ 463 | "part2".to_owned(), 464 | "PART2".to_owned(), 465 | "P_A_R_T_2".to_owned(), 466 | ], 467 | prct: 100.0 / 3.0, 468 | text_style: Style::new(), 469 | fill_style: Style::new(), 470 | bar_char: 'X', 471 | }, 472 | BarPart { 473 | label: vec![ 474 | "part3".to_owned(), 475 | "PART3".to_owned(), 476 | "P_A_R_T_3".to_owned(), 477 | ], 478 | prct: 100.0 / 3.0, 479 | text_style: Style::new(), 480 | fill_style: Style::new(), 481 | bar_char: '%', 482 | }, 483 | ], 484 | &mut f, 485 | ) 486 | .unwrap(); 487 | assert_eq!( 488 | f, 489 | "▕###part1PART1P_A_R_T_1####XXXpart2PART2P_A_R_T_2XXXX%%%part3PART3P_A_R_T_3%%%%▏\n" 490 | ); 491 | 492 | TERM_COLUMNS.store(50, Ordering::SeqCst); 493 | f.clear(); 494 | display_bar( 495 | &[ 496 | BarPart { 497 | label: vec![ 498 | "part1".to_owned(), 499 | "PART1".to_owned(), 500 | "P_A_R_T_1".to_owned(), 501 | ], 502 | prct: 100.0 / 3.0, 503 | text_style: Style::new(), 504 | fill_style: Style::new(), 505 | bar_char: '#', 506 | }, 507 | BarPart { 508 | label: vec![ 509 | "part2".to_owned(), 510 | "PART2".to_owned(), 511 | "P_A_R_T_2".to_owned(), 512 | ], 513 | prct: 100.0 / 3.0, 514 | text_style: Style::new(), 515 | fill_style: Style::new(), 516 | bar_char: 'X', 517 | }, 518 | BarPart { 519 | label: vec![ 520 | "part3".to_owned(), 521 | "PART3".to_owned(), 522 | "P_A_R_T_3".to_owned(), 523 | ], 524 | prct: 100.0 / 3.0, 525 | text_style: Style::new(), 526 | fill_style: Style::new(), 527 | bar_char: '%', 528 | }, 529 | ], 530 | &mut f, 531 | ) 532 | .unwrap(); 533 | assert_eq!(f, "▕###part1PART1###XXXpart2PART2XXX%%%part3PART3%%%▏\n"); 534 | 535 | TERM_COLUMNS.store(30, Ordering::SeqCst); 536 | f.clear(); 537 | display_bar( 538 | &[ 539 | BarPart { 540 | label: vec![ 541 | "part1".to_owned(), 542 | "PART1".to_owned(), 543 | "P_A_R_T_1".to_owned(), 544 | ], 545 | prct: 100.0 / 3.0, 546 | text_style: Style::new(), 547 | fill_style: Style::new(), 548 | bar_char: '#', 549 | }, 550 | BarPart { 551 | label: vec![ 552 | "part2".to_owned(), 553 | "PART2".to_owned(), 554 | "P_A_R_T_2".to_owned(), 555 | ], 556 | prct: 100.0 / 3.0, 557 | text_style: Style::new(), 558 | fill_style: Style::new(), 559 | bar_char: 'X', 560 | }, 561 | BarPart { 562 | label: vec![ 563 | "part3".to_owned(), 564 | "PART3".to_owned(), 565 | "P_A_R_T_3".to_owned(), 566 | ], 567 | prct: 100.0 / 3.0, 568 | text_style: Style::new(), 569 | fill_style: Style::new(), 570 | bar_char: '%', 571 | }, 572 | ], 573 | &mut f, 574 | ) 575 | .unwrap(); 576 | assert_eq!(f, "▕part1PART1XXpart2XX%%part3%%▏\n"); 577 | 578 | TERM_COLUMNS.store(15, Ordering::SeqCst); 579 | f.clear(); 580 | display_bar( 581 | &[ 582 | BarPart { 583 | label: vec![ 584 | "part1".to_owned(), 585 | "PART1".to_owned(), 586 | "P_A_R_T_1".to_owned(), 587 | ], 588 | prct: 100.0 / 3.0, 589 | text_style: Style::new(), 590 | fill_style: Style::new(), 591 | bar_char: '#', 592 | }, 593 | BarPart { 594 | label: vec![ 595 | "part2".to_owned(), 596 | "PART2".to_owned(), 597 | "P_A_R_T_2".to_owned(), 598 | ], 599 | prct: 100.0 / 3.0, 600 | text_style: Style::new(), 601 | fill_style: Style::new(), 602 | bar_char: 'X', 603 | }, 604 | BarPart { 605 | label: vec![ 606 | "part3".to_owned(), 607 | "PART3".to_owned(), 608 | "P_A_R_T_3".to_owned(), 609 | ], 610 | prct: 100.0 / 3.0, 611 | text_style: Style::new(), 612 | fill_style: Style::new(), 613 | bar_char: '%', 614 | }, 615 | ], 616 | &mut f, 617 | ) 618 | .unwrap(); 619 | assert_eq!(f, "▕part1XXXX%%%%▏\n"); 620 | 621 | TERM_COLUMNS.store(50, Ordering::SeqCst); 622 | f.clear(); 623 | display_bar( 624 | &[ 625 | BarPart { 626 | label: vec![ 627 | "part1".to_owned(), 628 | "PART1".to_owned(), 629 | "P_A_R_T_1".to_owned(), 630 | ], 631 | prct: 15.0, 632 | text_style: Style::new(), 633 | fill_style: Style::new(), 634 | bar_char: '#', 635 | }, 636 | BarPart { 637 | label: vec![ 638 | "part2".to_owned(), 639 | "PART2".to_owned(), 640 | "P_A_R_T_2".to_owned(), 641 | ], 642 | prct: 20.0, 643 | text_style: Style::new(), 644 | fill_style: Style::new(), 645 | bar_char: 'X', 646 | }, 647 | BarPart { 648 | label: vec![ 649 | "part3".to_owned(), 650 | "PART3".to_owned(), 651 | "P_A_R_T_3".to_owned(), 652 | ], 653 | prct: 65.0, 654 | text_style: Style::new(), 655 | fill_style: Style::new(), 656 | bar_char: '%', 657 | }, 658 | ], 659 | &mut f, 660 | ) 661 | .unwrap(); 662 | assert_eq!(f, "▕#part1#part2PART2%%%%%%part3PART3P_A_R_T_3%%%%%%▏\n"); 663 | 664 | f.clear(); 665 | display_bar( 666 | &[ 667 | BarPart { 668 | label: vec![ 669 | "part1".to_owned(), 670 | "PART1".to_owned(), 671 | "P_A_R_T_1".to_owned(), 672 | ], 673 | prct: 15.0, 674 | text_style: Red.bold(), 675 | fill_style: Red.underline(), 676 | bar_char: '#', 677 | }, 678 | BarPart { 679 | label: vec![ 680 | "part2".to_owned(), 681 | "PART2".to_owned(), 682 | "P_A_R_T_2".to_owned(), 683 | ], 684 | prct: 20.0, 685 | text_style: Yellow.dimmed(), 686 | fill_style: Yellow.italic(), 687 | bar_char: 'X', 688 | }, 689 | BarPart { 690 | label: vec![ 691 | "part3".to_owned(), 692 | "PART3".to_owned(), 693 | "P_A_R_T_3".to_owned(), 694 | ], 695 | prct: 65.0, 696 | text_style: Blue.reverse(), 697 | fill_style: Blue.blink(), 698 | bar_char: '%', 699 | }, 700 | ], 701 | &mut f, 702 | ) 703 | .unwrap(); 704 | assert_eq!( 705 | f, 706 | "▕\u{1b}[4;31m#\u{1b}[0m\u{1b}[1;31mpart1\u{1b}[0m\u{1b}[4;31m#\u{1b}[0m\u{1b}[3;33m\u{1b}[0m\u{1b}[2;33mpart2PART2\u{1b}[0m\u{1b}[3;33m\u{1b}[0m\u{1b}[5;34m%%%%%%\u{1b}[0m\u{1b}[7;34mpart3PART3P_A_R_T_3\u{1b}[0m\u{1b}[5;34m%%%%%%\u{1b}[0m▏\n" 707 | ); 708 | } 709 | 710 | #[test] 711 | fn test_output_mem_stats() { 712 | let mut vals = HashMap::new(); 713 | vals.insert("stat1".to_owned(), 123); 714 | vals.insert("stat22222222".to_owned(), 1_234_567); 715 | vals.insert("stat3333".to_owned(), 123_456_789); 716 | vals.insert("itsatrap".to_owned(), 999); 717 | let mem_info = MemInfo { vals }; 718 | 719 | let mut f = String::new(); 720 | mem_info 721 | .display_stats(&["stat1", "stat22222222", "stat3333"], "stat3333", &mut f) 722 | .unwrap(); 723 | assert_eq!( 724 | f, 725 | "stat1: 123.0 KB ( 0.0%)\nstat22222222: 1.2 GB ( 1.0%)\nstat3333: 117.7 GB\n" 726 | ); 727 | } 728 | 729 | #[test] 730 | #[serial] 731 | fn test_output_mem() { 732 | let mut vals = HashMap::new(); 733 | vals.insert("MemTotal".to_owned(), 12345); 734 | vals.insert("MemFree".to_owned(), 1234); 735 | vals.insert("Dirty".to_owned(), 2134); 736 | vals.insert("Cached".to_owned(), 3124); 737 | vals.insert("Buffers".to_owned(), 4321); 738 | vals.insert("itsatrap".to_owned(), 1024); 739 | let mem_info = MemInfo { vals }; 740 | 741 | TERM_COLUMNS.store(80, Ordering::SeqCst); 742 | assert_eq!( 743 | format!("{}", &mem_info), 744 | "MemTotal: 12.1 MB\nMemFree: 1.2 MB (10.0%)\nDirty: 2.1 MB (17.3%)\nCached: 3.1 MB (25.3%)\nBuffers: 4.2 MB (35.0%)\n▕████\u{1b}[7mUsed 0.0GB (33.3%)\u{1b}[0m████\u{1b}[2m█████████████\u{1b}[0m\u{1b}[2;7mCached 0.0GB (58.3%)\u{1b}[0m\u{1b}[2m█████████████\u{1b}[0m Free ▏\n" 745 | ); 746 | 747 | TERM_COLUMNS.store(30, Ordering::SeqCst); 748 | assert_eq!( 749 | format!("{}", &mem_info), 750 | "MemTotal: 12.1 MB\nMemFree: 1.2 MB (10.0%)\nDirty: 2.1 MB (17.3%)\nCached: 3.1 MB (25.3%)\nBuffers: 4.2 MB (35.0%)\n▕██\u{1b}[7mUsed\u{1b}[0m███\u{1b}[2m██\u{1b}[0m\u{1b}[2;7mCached 0.0GB\u{1b}[0m\u{1b}[2m██\u{1b}[0m ▏\n" 751 | ); 752 | } 753 | 754 | #[test] 755 | #[serial] 756 | fn test_output_swap() { 757 | let mut vals = HashMap::new(); 758 | vals.insert("SwapTotal".to_owned(), 12_345_678); 759 | vals.insert("SwapFree".to_owned(), 2_345_678); 760 | vals.insert("itsatrap".to_owned(), 1024); 761 | let mem_info = MemInfo { vals }; 762 | let swap_info = SwapInfo::from(mem_info); 763 | 764 | TERM_COLUMNS.store(80, Ordering::SeqCst); 765 | assert_eq!( 766 | format!("{}", &swap_info), 767 | "SwapTotal: 11.8 GB\nSwapFree: 2.2 GB (19.0%)\n▕██████████████████████\u{1b}[7mUsed 9.5GB (81.0%)\u{1b}[0m███████████████████████Swap free 2.2GB▏\n" 768 | ); 769 | 770 | TERM_COLUMNS.store(30, Ordering::SeqCst); 771 | assert_eq!( 772 | format!("{}", &swap_info), 773 | "SwapTotal: 11.8 GB\nSwapFree: 2.2 GB (19.0%)\n▕██\u{1b}[7mUsed 9.5GB (81.0%)\u{1b}[0m███ ▏\n" 774 | ); 775 | 776 | let mut vals = HashMap::new(); 777 | vals.insert("SwapTotal".to_owned(), 0); 778 | vals.insert("SwapFree".to_owned(), 0); 779 | vals.insert("itsatrap".to_owned(), 1024); 780 | let mem_info = MemInfo { vals }; 781 | let swap_info = SwapInfo::from(mem_info); 782 | 783 | assert!(format!("{}", &swap_info).is_empty()); 784 | } 785 | } 786 | -------------------------------------------------------------------------------- /src/module.rs: -------------------------------------------------------------------------------- 1 | //! Module common stuff 2 | 3 | use std::{fmt, sync::atomic::AtomicUsize}; 4 | 5 | use crate::{ 6 | fs::FsInfo, 7 | load::LoadInfo, 8 | mem::{MemInfo, SwapInfo}, 9 | net::NetworkStats, 10 | systemd::FailedUnits, 11 | temp::HardwareTemps, 12 | }; 13 | 14 | pub(crate) enum ModuleData { 15 | Load(LoadInfo), 16 | Memory(MemInfo), 17 | Swap(SwapInfo), 18 | Fs(FsInfo), 19 | HardwareTemps(HardwareTemps), 20 | Systemd(FailedUnits), 21 | Network(NetworkStats), 22 | } 23 | 24 | // TODO use enum dispatch 25 | impl fmt::Display for ModuleData { 26 | /// Output load information 27 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 28 | match self { 29 | Self::Load(i) => i.fmt(f), 30 | Self::Memory(i) => i.fmt(f), 31 | Self::Swap(i) => i.fmt(f), 32 | Self::Fs(i) => i.fmt(f), 33 | Self::HardwareTemps(i) => i.fmt(f), 34 | Self::Systemd(i) => i.fmt(f), 35 | Self::Network(i) => i.fmt(f), 36 | } 37 | } 38 | } 39 | 40 | // Global stuff, intitialized by main function or unit tests 41 | pub(crate) static CPU_COUNT: AtomicUsize = AtomicUsize::new(0); 42 | pub(crate) static TERM_COLUMNS: AtomicUsize = AtomicUsize::new(0); 43 | -------------------------------------------------------------------------------- /src/net.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::BTreeMap, 3 | fmt, 4 | fs::{self, DirEntry, File}, 5 | io::{Read, Seek}, 6 | thread::sleep, 7 | time::{Duration, Instant}, 8 | }; 9 | 10 | use ansi_term::Colour::{Red, Yellow}; 11 | 12 | use crate::{fmt::format_kmgt_si, module::ModuleData}; 13 | 14 | /// Network interface pending stats 15 | struct PendingInterfaceStats { 16 | /// Rx byte count 17 | rx_bytes: u64, 18 | /// Tx byte count 19 | tx_bytes: u64, 20 | /// Rx bytes count sysfs file 21 | rx_bytes_file: File, 22 | /// Tx bytes count sysfs file 23 | tx_bytes_file: File, 24 | /// Timestamp 25 | ts: Instant, 26 | /// Interface speed 27 | line_bps: Option, 28 | } 29 | 30 | type NetworkPendingStats = BTreeMap; 31 | 32 | /// Network interface stats 33 | #[expect(clippy::struct_field_names)] 34 | pub(crate) struct InterfaceStats { 35 | /// Rx bits/s 36 | rx_bps: u64, 37 | /// Tx bits/s 38 | tx_bps: u64, 39 | /// Interface speed 40 | line_bps: Option, 41 | } 42 | 43 | pub(crate) struct NetworkStats { 44 | interfaces: BTreeMap, 45 | } 46 | 47 | const MIN_DELAY_BETWEEN_NET_SAMPLES_MS: u64 = 30; 48 | 49 | pub(crate) fn fetch() -> anyhow::Result { 50 | let mut sample = get_network_stats()?; 51 | let stats = update_network_stats(&mut sample)?; 52 | Ok(ModuleData::Network(stats)) 53 | } 54 | 55 | #[expect(clippy::verbose_file_reads)] 56 | fn read_interface_stats( 57 | rx_bytes_file: &mut File, 58 | tx_bytes_file: &mut File, 59 | ) -> anyhow::Result<(u64, u64, Instant)> { 60 | let mut rx_str = String::new(); 61 | rx_bytes_file.read_to_string(&mut rx_str)?; 62 | let rx_bytes = rx_str.trim_end().parse::()?; 63 | 64 | let mut tx_str = String::new(); 65 | tx_bytes_file.read_to_string(&mut tx_str)?; 66 | let tx_bytes = tx_str.trim_end().parse::()?; 67 | 68 | Ok((rx_bytes, tx_bytes, Instant::now())) 69 | } 70 | 71 | /// Get network stats first sample 72 | fn get_network_stats() -> anyhow::Result { 73 | let mut stats: NetworkPendingStats = NetworkPendingStats::new(); 74 | 75 | let mut dir_entries: Vec = fs::read_dir("/sys/class/net")? 76 | .filter_map(Result::ok) 77 | .collect(); 78 | dir_entries.sort_by_key(DirEntry::file_name); 79 | for dir_entry in dir_entries { 80 | let itf_name = dir_entry.file_name().clone().into_string().unwrap(); 81 | if itf_name == "lo" { 82 | continue; 83 | } 84 | let itf_dir = dir_entry.path(); 85 | 86 | let mut rx_bytes_file = File::open(itf_dir.join("statistics/rx_bytes"))?; 87 | let mut tx_bytes_file = File::open(itf_dir.join("statistics/tx_bytes"))?; 88 | let (rx_bytes, tx_bytes, ts) = 89 | read_interface_stats(&mut rx_bytes_file, &mut tx_bytes_file)?; 90 | 91 | rx_bytes_file.rewind()?; 92 | tx_bytes_file.rewind()?; 93 | 94 | let line_bps = if itf_dir.join("tun_flags").exists() { 95 | /* tun always report 10 Mbps even if we can exceed that limit */ 96 | None 97 | } else { 98 | fs::read_to_string(itf_dir.join("speed")) 99 | .ok() 100 | .and_then(|speed_str| { 101 | speed_str 102 | .trim_end() 103 | // Some interfaces (bridges) report -1 104 | .parse::() 105 | .map(|speed| speed * 1_000_000) 106 | .ok() 107 | }) 108 | }; 109 | 110 | stats.insert( 111 | itf_name, 112 | PendingInterfaceStats { 113 | rx_bytes, 114 | tx_bytes, 115 | rx_bytes_file, 116 | tx_bytes_file, 117 | ts, 118 | line_bps, 119 | }, 120 | ); 121 | } 122 | 123 | Ok(stats) 124 | } 125 | 126 | /// Get network stats second sample and build interface stats 127 | fn update_network_stats(pending_stats: &mut NetworkPendingStats) -> anyhow::Result { 128 | let mut stats = BTreeMap::new(); 129 | 130 | for (itf_name, pending_itf_stats) in pending_stats.iter_mut() { 131 | // Ensure there is sufficient time between samples 132 | let now = Instant::now(); 133 | let ms_since_first_sample = now.duration_since(pending_itf_stats.ts).as_millis() as u64; 134 | if ms_since_first_sample < MIN_DELAY_BETWEEN_NET_SAMPLES_MS { 135 | let sleep_delay_ms = MIN_DELAY_BETWEEN_NET_SAMPLES_MS - ms_since_first_sample; 136 | sleep(Duration::from_millis(sleep_delay_ms)); 137 | } 138 | 139 | // Read sample 140 | let (rx_bytes2, tx_bytes2, ts2) = read_interface_stats( 141 | &mut pending_itf_stats.rx_bytes_file, 142 | &mut pending_itf_stats.tx_bytes_file, 143 | )?; 144 | 145 | // Convert to speed 146 | let ts_delta_ms = ts2.duration_since(pending_itf_stats.ts).as_millis(); 147 | let rx_bps = 1000 * (rx_bytes2 - pending_itf_stats.rx_bytes) * 8 / ts_delta_ms as u64; 148 | let tx_bps = 1000 * (tx_bytes2 - pending_itf_stats.tx_bytes) * 8 / ts_delta_ms as u64; 149 | stats.insert( 150 | itf_name.to_string(), 151 | InterfaceStats { 152 | rx_bps, 153 | tx_bps, 154 | line_bps: pending_itf_stats.line_bps, 155 | }, 156 | ); 157 | } 158 | 159 | Ok(NetworkStats { interfaces: stats }) 160 | } 161 | 162 | /// Colorize network speed string 163 | fn colorize_speed(val: u64, line_rate: Option, s: String) -> String { 164 | if let Some(line_rate) = line_rate { 165 | if val >= line_rate * 90 / 100 { 166 | Red.paint(s).to_string() 167 | } else if val >= line_rate * 80 / 100 { 168 | Yellow.paint(s).to_string() 169 | } else { 170 | s 171 | } 172 | } else { 173 | s 174 | } 175 | } 176 | 177 | impl fmt::Display for NetworkStats { 178 | /// Output network stats 179 | #[expect(clippy::similar_names)] 180 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 181 | let unit = "b/s"; 182 | let Some(max_itf_len) = self.interfaces.keys().map(String::len).max() else { 183 | return Ok(()); 184 | }; 185 | let mac_rx_str_len = self 186 | .interfaces 187 | .values() 188 | .map(|v| format_kmgt_si(v.rx_bps, unit).len()) 189 | .max() 190 | .unwrap(); 191 | let mac_tx_str_len = self 192 | .interfaces 193 | .values() 194 | .map(|v| format_kmgt_si(v.tx_bps, unit).len()) 195 | .max() 196 | .unwrap(); 197 | 198 | for (itf_name, itf_stats) in &self.interfaces { 199 | let name_pad = " ".repeat(max_itf_len - itf_name.len()); 200 | let rx_str = format_kmgt_si(itf_stats.rx_bps, unit); 201 | let rx_pad = " ".repeat(mac_rx_str_len - rx_str.len()); 202 | let tx_str = format_kmgt_si(itf_stats.tx_bps, unit); 203 | let tx_pad = " ".repeat(mac_tx_str_len - tx_str.len()); 204 | writeln!( 205 | f, 206 | "{}:{} ↓ {}{} ↑ {}{}", 207 | itf_name, 208 | name_pad, 209 | rx_pad, 210 | colorize_speed(itf_stats.rx_bps, itf_stats.line_bps, rx_str), 211 | tx_pad, 212 | colorize_speed(itf_stats.tx_bps, itf_stats.line_bps, tx_str) 213 | )?; 214 | } 215 | 216 | Ok(()) 217 | } 218 | } 219 | 220 | #[cfg(test)] 221 | mod tests { 222 | use super::*; 223 | 224 | #[test] 225 | fn test_output_network_stats() { 226 | let mut stats = BTreeMap::new(); 227 | stats.insert( 228 | "i1".to_owned(), 229 | InterfaceStats { 230 | rx_bps: 1, 231 | tx_bps: 1_234_567, 232 | line_bps: None, 233 | }, 234 | ); 235 | stats.insert( 236 | "interface2".to_owned(), 237 | InterfaceStats { 238 | rx_bps: 1_234_567_890, 239 | tx_bps: 1_234, 240 | line_bps: None, 241 | }, 242 | ); 243 | stats.insert( 244 | "itf3".to_owned(), 245 | InterfaceStats { 246 | rx_bps: 799_999, 247 | tx_bps: 800_000, 248 | line_bps: Some(1_000_000), 249 | }, 250 | ); 251 | stats.insert( 252 | "itf4".to_owned(), 253 | InterfaceStats { 254 | rx_bps: 900_000, 255 | tx_bps: 899_999, 256 | line_bps: Some(1_000_000), 257 | }, 258 | ); 259 | stats.insert( 260 | "itf5".to_owned(), 261 | InterfaceStats { 262 | rx_bps: 900_000_001, 263 | tx_bps: 800_000_001, 264 | line_bps: Some(1_000_000_000), 265 | }, 266 | ); 267 | assert_eq!( 268 | format!("{}", NetworkStats { interfaces: stats }), 269 | "i1: ↓ 1 b/s ↑ 1.2 Mb/s\ninterface2: ↓ 1.2 Gb/s ↑ 1.2 kb/s\nitf3: ↓ 800.0 kb/s ↑ \u{1b}[33m800.0 kb/s\u{1b}[0m\nitf4: ↓ \u{1b}[31m900.0 kb/s\u{1b}[0m ↑ \u{1b}[33m900.0 kb/s\u{1b}[0m\nitf5: ↓ \u{1b}[31m900.0 Mb/s\u{1b}[0m ↑ \u{1b}[33m800.0 Mb/s\u{1b}[0m\n" 270 | ); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/systemd.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt, 3 | io::BufRead, 4 | process::{Command, Stdio}, 5 | thread, 6 | }; 7 | 8 | use ansi_term::Colour::Red; 9 | 10 | use crate::module::ModuleData; 11 | 12 | /// Names of failed Systemd units 13 | #[derive(Debug)] 14 | pub(crate) struct FailedUnits { 15 | system: Vec, 16 | user: Vec, 17 | } 18 | 19 | /// Systemd running mode 20 | enum SystemdMode { 21 | System, 22 | User, 23 | } 24 | 25 | /// Get name of Systemd units in failed state 26 | pub(crate) fn fetch() -> anyhow::Result { 27 | let system_fut = thread::spawn(|| fetch_mode(SystemdMode::System)); 28 | let user = fetch_mode(SystemdMode::User)?; 29 | 30 | Ok(ModuleData::Systemd(FailedUnits { 31 | system: system_fut 32 | .join() 33 | .map_err(|e| anyhow::anyhow!("Failed to join thread: {:?}", e))??, 34 | user, 35 | })) 36 | } 37 | 38 | /// Get name of Systemd units in failed state 39 | #[expect(clippy::needless_pass_by_value)] 40 | fn fetch_mode(mode: SystemdMode) -> anyhow::Result> { 41 | let mut args = match mode { 42 | SystemdMode::System => vec![], 43 | SystemdMode::User => vec!["--user"], 44 | }; 45 | args.extend(&["--no-legend", "--plain", "--failed"]); 46 | let output = Command::new("systemctl") 47 | .args(&args) 48 | .stdin(Stdio::null()) 49 | .stderr(Stdio::null()) 50 | .output()?; 51 | anyhow::ensure!(output.status.success(), "systemctl failed"); 52 | 53 | let mut units = Vec::new(); 54 | for line in output.stdout.lines() { 55 | units.push( 56 | line? 57 | .trim_start() 58 | .split(' ') 59 | .next() 60 | .ok_or_else(|| anyhow::anyhow!("Failed to parse systemctl output"))? 61 | .to_owned(), 62 | ); 63 | } 64 | 65 | Ok(units) 66 | } 67 | 68 | impl fmt::Display for FailedUnits { 69 | /// Output names of Systemd units in failed state 70 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 71 | if !self.system.is_empty() { 72 | writeln!(f, "System:")?; 73 | } 74 | for u in &self.system { 75 | writeln!(f, "{}", Red.paint(u))?; 76 | } 77 | if !self.user.is_empty() { 78 | writeln!(f, "User:")?; 79 | } 80 | for u in &self.user { 81 | writeln!(f, "{}", Red.paint(u))?; 82 | } 83 | Ok(()) 84 | } 85 | } 86 | 87 | #[cfg(test)] 88 | mod tests { 89 | use super::*; 90 | 91 | #[test] 92 | fn test_output_failed_units() { 93 | assert_eq!( 94 | format!( 95 | "{}", 96 | FailedUnits { 97 | system: vec!["foo.service".to_owned(), "bar.timer".to_owned()], 98 | user: vec![] 99 | } 100 | ), 101 | "System:\n\u{1b}[31mfoo.service\u{1b}[0m\n\u{1b}[31mbar.timer\u{1b}[0m\n" 102 | ); 103 | assert_eq!( 104 | format!( 105 | "{}", 106 | FailedUnits { 107 | system: vec![], 108 | user: vec!["foo.service".to_owned(), "bar.timer".to_owned()] 109 | } 110 | ), 111 | "User:\n\u{1b}[31mfoo.service\u{1b}[0m\n\u{1b}[31mbar.timer\u{1b}[0m\n" 112 | ); 113 | assert_eq!( 114 | format!( 115 | "{}", 116 | FailedUnits { 117 | system: vec!["foo.service".to_owned(), "bar.timer".to_owned()], 118 | user: vec!["foo2.service".to_owned()] 119 | } 120 | ), 121 | "System:\n\u{1b}[31mfoo.service\u{1b}[0m\n\u{1b}[31mbar.timer\u{1b}[0m\nUser:\n\u{1b}[31mfoo2.service\u{1b}[0m\n" 122 | ); 123 | assert_eq!( 124 | format!( 125 | "{}", 126 | FailedUnits { 127 | system: vec![], 128 | user: vec![] 129 | } 130 | ), 131 | "" 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/temp.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp, fmt, fs, 3 | io::prelude::*, 4 | net::TcpStream, 5 | path::{Path, PathBuf}, 6 | str::FromStr, 7 | }; 8 | 9 | use ansi_term::Colour::{Red, Yellow}; 10 | use anyhow::Context; 11 | 12 | use crate::{config, ModuleData}; 13 | 14 | /// Type of temperature sensor 15 | #[derive(Debug, PartialEq, Eq)] 16 | enum SensorType { 17 | /// CPU sensor 18 | Cpu, 19 | /// Hard drive or SSD/NVM sensor 20 | Drive, 21 | /// Other sensors (typically motherboard), or we just have no clue 22 | OtherOrUnknown, 23 | } 24 | 25 | /// Temperature data 26 | pub(crate) struct SensorTemp { 27 | /// Name of sensor 28 | name: String, 29 | /// Type of sensor 30 | #[expect(dead_code)] 31 | sensor_type: SensorType, 32 | /// Temperature value in Celcius 33 | temp: u32, 34 | /// Temperature above which component is considered anormally hot 35 | temp_warning: u32, 36 | /// Temperature above which component is considered critically hot 37 | temp_critical: u32, 38 | } 39 | 40 | /// Deque of fetched temperature data 41 | pub(crate) struct HardwareTemps { 42 | temps: Vec, 43 | } 44 | 45 | /// Read temperature from a given hwmon sysfs file 46 | fn read_sysfs_temp_value(filepath: &Path) -> anyhow::Result { 47 | let temp_str = read_sysfs_string_value(filepath)?; 48 | let temp_val = temp_str.trim_end().parse::().map(|v| v / 1000)?; 49 | 50 | anyhow::ensure!(temp_val > 0); 51 | 52 | Ok(temp_val) 53 | } 54 | 55 | /// Read string from a given sysfs file 56 | fn read_sysfs_string_value(filepath: &Path) -> anyhow::Result { 57 | Ok(fs::read_to_string(filepath) 58 | .with_context(|| format!("Failed to read {filepath:?}"))? 59 | .trim_end() 60 | .to_owned()) 61 | } 62 | 63 | /// Probe temperatures from hwmon Linux sensors 64 | #[expect(clippy::string_slice, clippy::too_many_lines)] 65 | pub(crate) fn fetch(cfg: &config::TempConfig) -> anyhow::Result { 66 | let mut temps = Vec::new(); 67 | 68 | // 69 | // Hwmon sensors 70 | // 71 | 72 | let re = regex::Regex::new("temp[0-9]+_input").unwrap(); 73 | 74 | for input_temp_filepath in walkdir::WalkDir::new("/sys/class/hwmon") 75 | .follow_links(true) 76 | .min_depth(2) 77 | .max_depth(2) 78 | .sort_by_file_name() 79 | .into_iter() 80 | .filter_entry(|e| !e.path_is_symlink() && e.file_type().is_file()) 81 | .filter_map(Result::ok) 82 | .map(walkdir::DirEntry::into_path) 83 | .filter(|p| re.is_match(p.file_name().unwrap().to_str().unwrap())) 84 | { 85 | let input_temp_filepath_str = input_temp_filepath.to_str().unwrap(); 86 | let filepath_prefix = 87 | input_temp_filepath_str[..input_temp_filepath_str.len() - 6].to_owned(); 88 | 89 | // Read sensor label 90 | let label_filepath = PathBuf::from(&format!("{filepath_prefix}_label")); 91 | let label = if label_filepath.is_file() { 92 | let label = read_sysfs_string_value(&label_filepath)?; 93 | // Exclude from label blacklist 94 | if cfg.hwmon_label_blacklist.iter().any(|r| r.is_match(&label)) { 95 | continue; 96 | } 97 | Some(label) 98 | } else { 99 | None 100 | }; 101 | 102 | // Get sensor driver name 103 | let name_filepath = input_temp_filepath.with_file_name("name"); 104 | let name = read_sysfs_string_value(&name_filepath)?; 105 | 106 | // Deduce type from name 107 | let sensor_type = if let Some(label) = label.as_ref() { 108 | if label.starts_with("CPU ") || label.starts_with("Core ") { 109 | SensorType::Cpu 110 | } else { 111 | SensorType::OtherOrUnknown 112 | } 113 | } else if name == "drivetemp" { 114 | SensorType::Drive 115 | } else { 116 | SensorType::OtherOrUnknown 117 | }; 118 | 119 | // Set drivetemp label 120 | let sensor_name = if let Some(label) = label { 121 | label 122 | } else if sensor_type == SensorType::Drive { 123 | let model_filepath = input_temp_filepath.with_file_name("device/model"); 124 | let model = read_sysfs_string_value(&model_filepath)?; 125 | let block_dirpath = input_temp_filepath.with_file_name("device/block"); 126 | let block_device_name = fs::read_dir(&block_dirpath)? 127 | .next() 128 | .ok_or_else(|| { 129 | anyhow::anyhow!("Unable to get block device from {:?}", block_dirpath) 130 | })?? 131 | .file_name() 132 | .into_string() 133 | .map_err(|e| anyhow::anyhow!("Unable to decode {:?}", e))?; 134 | format!("{block_device_name} ({model})") 135 | } else { 136 | name 137 | }; 138 | 139 | // Read temp 140 | #[expect(clippy::shadow_unrelated)] 141 | let input_temp_filepath = PathBuf::from(&format!("{filepath_prefix}_input")); 142 | let Ok(temp_val) = read_sysfs_temp_value(&input_temp_filepath) else { 143 | continue; 144 | }; 145 | 146 | // Read warning temp 147 | let max_temp_filepath = PathBuf::from(&format!("{filepath_prefix}_max")); 148 | let max_temp_val = read_sysfs_temp_value(&max_temp_filepath).ok(); 149 | 150 | // Read critical temp 151 | let crit_temp_filepath = PathBuf::from(format!("{filepath_prefix}_crit")); 152 | let crit_temp_val = read_sysfs_temp_value(&crit_temp_filepath).ok(); 153 | 154 | // Compute warning & critical temps 155 | let warning_temp; 156 | let crit_temp; 157 | if let (Some(max_temp_val), Some(crit_temp_val)) = (max_temp_val, crit_temp_val) { 158 | let (mut max_temp_val, crit_temp_val) = ( 159 | cmp::min(max_temp_val, crit_temp_val), 160 | cmp::max(max_temp_val, crit_temp_val), 161 | ); 162 | let abs_diff = crit_temp_val - max_temp_val; 163 | let delta = match sensor_type { 164 | SensorType::Cpu => abs_diff / 2, 165 | SensorType::Drive | SensorType::OtherOrUnknown => 5, 166 | }; 167 | if let SensorType::OtherOrUnknown = sensor_type { 168 | if abs_diff > 20 { 169 | max_temp_val = crit_temp_val - 20; 170 | } 171 | } 172 | warning_temp = max_temp_val - delta; 173 | crit_temp = max_temp_val; 174 | } else if let Some(max_temp_val) = max_temp_val { 175 | let delta = match sensor_type { 176 | SensorType::Cpu => 10, 177 | SensorType::Drive | SensorType::OtherOrUnknown => 5, 178 | }; 179 | warning_temp = max_temp_val - delta; 180 | crit_temp = max_temp_val; 181 | } else { 182 | warning_temp = match sensor_type { 183 | // Fallback to default value 184 | SensorType::Cpu => 60, 185 | SensorType::Drive | SensorType::OtherOrUnknown => 50, 186 | }; 187 | crit_temp = match sensor_type { 188 | // Fallback to default value 189 | SensorType::Cpu => 75, 190 | SensorType::Drive | SensorType::OtherOrUnknown => 60, 191 | }; 192 | } 193 | 194 | // Store temp 195 | let sensor_temp = SensorTemp { 196 | name: sensor_name, 197 | sensor_type, 198 | temp: temp_val, 199 | temp_warning: warning_temp, 200 | temp_critical: crit_temp, 201 | }; 202 | temps.push(sensor_temp); 203 | } 204 | 205 | // 206 | // HDD temps 207 | // 208 | 209 | // Connect 210 | if let Ok(mut stream) = TcpStream::connect("127.0.0.1:7634") { 211 | // TODO port const 212 | // Read 213 | let mut data = String::new(); 214 | stream.read_to_string(&mut data)?; 215 | 216 | // Parse 217 | let drives_data: Vec<&str> = data.split('|').collect(); 218 | for drive_data in drives_data.chunks_exact(5) { 219 | let drive_path = normalize_drive_path(&PathBuf::from(drive_data[1]))?; 220 | let pretty_name = drive_data[2]; 221 | let Ok(temp) = u32::from_str(drive_data[3]) else { 222 | continue; 223 | }; 224 | 225 | // Store temp 226 | let sensor_temp = SensorTemp { 227 | name: format!("{} ({})", drive_path.to_str().unwrap(), pretty_name), 228 | sensor_type: SensorType::Drive, 229 | temp, 230 | temp_warning: 45, 231 | temp_critical: 55, 232 | }; 233 | temps.push(sensor_temp); 234 | } 235 | } 236 | 237 | Ok(ModuleData::HardwareTemps(HardwareTemps { temps })) 238 | } 239 | 240 | /// Normalize a drive device path by making it absolute and following links 241 | fn normalize_drive_path(path: &Path) -> anyhow::Result { 242 | let mut path_string = path.to_path_buf(); 243 | let fs_path = Path::new(path); 244 | 245 | if fs::symlink_metadata(path)?.file_type().is_symlink() { 246 | let mut real_path = fs::read_link(path)?; 247 | if !real_path.is_absolute() { 248 | let dirname = fs_path 249 | .parent() 250 | .ok_or_else(|| anyhow::anyhow!("Unable to get drive parent directory"))?; 251 | real_path = dirname.join(real_path).canonicalize()?; 252 | } 253 | path_string = real_path; 254 | } 255 | 256 | Ok(path_string) 257 | } 258 | 259 | /// Colorize a string for terminal display according to temperature level 260 | fn colorize_from_temp(string: String, temp: u32, temp_warning: u32, temp_critical: u32) -> String { 261 | if temp >= temp_critical { 262 | Red.paint(string).to_string() 263 | } else if temp >= temp_warning { 264 | Yellow.paint(string).to_string() 265 | } else { 266 | string 267 | } 268 | } 269 | 270 | impl fmt::Display for HardwareTemps { 271 | /// Output all temperatures 272 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 273 | let max_name_len = self.temps.iter().map(|x| x.name.len()).max(); 274 | for sensor_temp in &self.temps { 275 | let pad = " ".repeat(max_name_len.unwrap() - sensor_temp.name.len()); 276 | let line = format!("{}: {}{} °C", sensor_temp.name, pad, sensor_temp.temp); 277 | writeln!( 278 | f, 279 | "{}", 280 | colorize_from_temp( 281 | line, 282 | sensor_temp.temp, 283 | sensor_temp.temp_warning, 284 | sensor_temp.temp_critical, 285 | ) 286 | )?; 287 | } 288 | 289 | Ok(()) 290 | } 291 | } 292 | 293 | #[cfg(test)] 294 | mod tests { 295 | use super::*; 296 | 297 | #[test] 298 | fn test_output_temps() { 299 | assert_eq!( 300 | format!( 301 | "{}", 302 | HardwareTemps { 303 | temps: vec![ 304 | SensorTemp { 305 | name: "sensor1".to_owned(), 306 | sensor_type: SensorType::Cpu, 307 | temp: 95, 308 | temp_warning: 70, 309 | temp_critical: 80 310 | }, 311 | SensorTemp { 312 | name: "sensor222222222".to_owned(), 313 | sensor_type: SensorType::Drive, 314 | temp: 40, 315 | temp_warning: 70, 316 | temp_critical: 80 317 | }, 318 | SensorTemp { 319 | name: "sensor333".to_owned(), 320 | sensor_type: SensorType::OtherOrUnknown, 321 | temp: 50, 322 | temp_warning: 45, 323 | temp_critical: 60 324 | } 325 | ] 326 | } 327 | ), 328 | "\u{1b}[31msensor1: 95 °C\u{1b}[0m\nsensor222222222: 40 °C\n\u{1b}[33msensor333: 50 °C\u{1b}[0m\n" 329 | ); 330 | } 331 | 332 | #[test] 333 | fn test_colorize_from_temp() { 334 | assert_eq!(colorize_from_temp("hey".to_owned(), 59, 60, 75), "hey"); 335 | assert_eq!( 336 | colorize_from_temp("hey".to_owned(), 60, 60, 75), 337 | "\u{1b}[33mhey\u{1b}[0m" 338 | ); 339 | assert_eq!( 340 | colorize_from_temp("hey".to_owned(), 60, 60, 75), 341 | "\u{1b}[33mhey\u{1b}[0m" 342 | ); 343 | assert_eq!( 344 | colorize_from_temp("hey".to_owned(), 74, 60, 75), 345 | "\u{1b}[33mhey\u{1b}[0m" 346 | ); 347 | assert_eq!( 348 | colorize_from_temp("hey".to_owned(), 75, 60, 75), 349 | "\u{1b}[31mhey\u{1b}[0m" 350 | ); 351 | assert_eq!( 352 | colorize_from_temp("hey".to_owned(), 76, 60, 75), 353 | "\u{1b}[31mhey\u{1b}[0m" 354 | ); 355 | } 356 | } 357 | --------------------------------------------------------------------------------