max / mountaineer
24 files changed,
+2548 insertions,
-0 deletions
| @@ -0,0 +1,4 @@ | |||
| 1 | + | /target | |
| 2 | + | **/*.rs.bk | |
| 3 | + | Cargo.lock.bak | |
| 4 | + | .DS_Store |
| @@ -0,0 +1,865 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "allocator-api2" | |
| 7 | + | version = "0.2.21" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "anstream" | |
| 13 | + | version = "1.0.0" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 16 | + | dependencies = [ | |
| 17 | + | "anstyle", | |
| 18 | + | "anstyle-parse", | |
| 19 | + | "anstyle-query", | |
| 20 | + | "anstyle-wincon", | |
| 21 | + | "colorchoice", | |
| 22 | + | "is_terminal_polyfill", | |
| 23 | + | "utf8parse", | |
| 24 | + | ] | |
| 25 | + | ||
| 26 | + | [[package]] | |
| 27 | + | name = "anstyle" | |
| 28 | + | version = "1.0.14" | |
| 29 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 30 | + | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 31 | + | ||
| 32 | + | [[package]] | |
| 33 | + | name = "anstyle-parse" | |
| 34 | + | version = "1.0.0" | |
| 35 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 36 | + | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 37 | + | dependencies = [ | |
| 38 | + | "utf8parse", | |
| 39 | + | ] | |
| 40 | + | ||
| 41 | + | [[package]] | |
| 42 | + | name = "anstyle-query" | |
| 43 | + | version = "1.1.5" | |
| 44 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | + | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 46 | + | dependencies = [ | |
| 47 | + | "windows-sys 0.61.2", | |
| 48 | + | ] | |
| 49 | + | ||
| 50 | + | [[package]] | |
| 51 | + | name = "anstyle-wincon" | |
| 52 | + | version = "3.0.11" | |
| 53 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 54 | + | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 55 | + | dependencies = [ | |
| 56 | + | "anstyle", | |
| 57 | + | "once_cell_polyfill", | |
| 58 | + | "windows-sys 0.61.2", | |
| 59 | + | ] | |
| 60 | + | ||
| 61 | + | [[package]] | |
| 62 | + | name = "anyhow" | |
| 63 | + | version = "1.0.102" | |
| 64 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 65 | + | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | |
| 66 | + | ||
| 67 | + | [[package]] | |
| 68 | + | name = "bitflags" | |
| 69 | + | version = "2.11.1" | |
| 70 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 71 | + | checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" | |
| 72 | + | ||
| 73 | + | [[package]] | |
| 74 | + | name = "cassowary" | |
| 75 | + | version = "0.3.0" | |
| 76 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 77 | + | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" | |
| 78 | + | ||
| 79 | + | [[package]] | |
| 80 | + | name = "castaway" | |
| 81 | + | version = "0.2.4" | |
| 82 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 83 | + | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | |
| 84 | + | dependencies = [ | |
| 85 | + | "rustversion", | |
| 86 | + | ] | |
| 87 | + | ||
| 88 | + | [[package]] | |
| 89 | + | name = "cfg-if" | |
| 90 | + | version = "1.0.4" | |
| 91 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 92 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 93 | + | ||
| 94 | + | [[package]] | |
| 95 | + | name = "clap" | |
| 96 | + | version = "4.6.1" | |
| 97 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | + | checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" | |
| 99 | + | dependencies = [ | |
| 100 | + | "clap_builder", | |
| 101 | + | "clap_derive", | |
| 102 | + | ] | |
| 103 | + | ||
| 104 | + | [[package]] | |
| 105 | + | name = "clap_builder" | |
| 106 | + | version = "4.6.0" | |
| 107 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 108 | + | checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" | |
| 109 | + | dependencies = [ | |
| 110 | + | "anstream", | |
| 111 | + | "anstyle", | |
| 112 | + | "clap_lex", | |
| 113 | + | "strsim", | |
| 114 | + | ] | |
| 115 | + | ||
| 116 | + | [[package]] | |
| 117 | + | name = "clap_derive" | |
| 118 | + | version = "4.6.1" | |
| 119 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 120 | + | checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" | |
| 121 | + | dependencies = [ | |
| 122 | + | "heck", | |
| 123 | + | "proc-macro2", | |
| 124 | + | "quote", | |
| 125 | + | "syn", | |
| 126 | + | ] | |
| 127 | + | ||
| 128 | + | [[package]] | |
| 129 | + | name = "clap_lex" | |
| 130 | + | version = "1.1.0" | |
| 131 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 132 | + | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | |
| 133 | + | ||
| 134 | + | [[package]] | |
| 135 | + | name = "colorchoice" | |
| 136 | + | version = "1.0.5" | |
| 137 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 138 | + | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 139 | + | ||
| 140 | + | [[package]] | |
| 141 | + | name = "compact_str" | |
| 142 | + | version = "0.8.1" | |
| 143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | + | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" | |
| 145 | + | dependencies = [ | |
| 146 | + | "castaway", | |
| 147 | + | "cfg-if", | |
| 148 | + | "itoa", | |
| 149 | + | "rustversion", | |
| 150 | + | "ryu", | |
| 151 | + | "static_assertions", | |
| 152 | + | ] | |
| 153 | + | ||
| 154 | + | [[package]] | |
| 155 | + | name = "crossterm" | |
| 156 | + | version = "0.28.1" | |
| 157 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 158 | + | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | |
| 159 | + | dependencies = [ | |
| 160 | + | "bitflags", | |
| 161 | + | "crossterm_winapi", | |
| 162 | + | "mio", | |
| 163 | + | "parking_lot", | |
| 164 | + | "rustix", | |
| 165 | + | "signal-hook", | |
| 166 | + | "signal-hook-mio", | |
| 167 | + | "winapi", | |
| 168 | + | ] | |
| 169 | + | ||
| 170 | + | [[package]] | |
| 171 | + | name = "crossterm_winapi" | |
| 172 | + | version = "0.9.1" | |
| 173 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 174 | + | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" | |
| 175 | + | dependencies = [ | |
| 176 | + | "winapi", | |
| 177 | + | ] | |
| 178 | + | ||
| 179 | + | [[package]] | |
| 180 | + | name = "darling" | |
| 181 | + | version = "0.20.11" | |
| 182 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 183 | + | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" | |
| 184 | + | dependencies = [ | |
| 185 | + | "darling_core", | |
| 186 | + | "darling_macro", | |
| 187 | + | ] | |
| 188 | + | ||
| 189 | + | [[package]] | |
| 190 | + | name = "darling_core" | |
| 191 | + | version = "0.20.11" | |
| 192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 193 | + | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" | |
| 194 | + | dependencies = [ | |
| 195 | + | "fnv", | |
| 196 | + | "ident_case", | |
| 197 | + | "proc-macro2", | |
| 198 | + | "quote", | |
| 199 | + | "strsim", | |
| 200 | + | "syn", | |
| 201 | + | ] | |
| 202 | + | ||
| 203 | + | [[package]] | |
| 204 | + | name = "darling_macro" | |
| 205 | + | version = "0.20.11" | |
| 206 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 207 | + | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" | |
| 208 | + | dependencies = [ | |
| 209 | + | "darling_core", | |
| 210 | + | "quote", | |
| 211 | + | "syn", | |
| 212 | + | ] | |
| 213 | + | ||
| 214 | + | [[package]] | |
| 215 | + | name = "either" | |
| 216 | + | version = "1.15.0" | |
| 217 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 218 | + | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" | |
| 219 | + | ||
| 220 | + | [[package]] | |
| 221 | + | name = "equivalent" | |
| 222 | + | version = "1.0.2" | |
| 223 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 224 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 225 | + | ||
| 226 | + | [[package]] | |
| 227 | + | name = "errno" | |
| 228 | + | version = "0.3.14" | |
| 229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 230 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 231 | + | dependencies = [ | |
| 232 | + | "libc", | |
| 233 | + | "windows-sys 0.61.2", | |
| 234 | + | ] | |
| 235 | + | ||
| 236 | + | [[package]] | |
| 237 | + | name = "fnv" | |
| 238 | + | version = "1.0.7" | |
| 239 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 240 | + | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | |
| 241 | + | ||
| 242 | + | [[package]] | |
| 243 | + | name = "foldhash" | |
| 244 | + | version = "0.1.5" | |
| 245 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 246 | + | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 247 | + | ||
| 248 | + | [[package]] | |
| 249 | + | name = "hashbrown" | |
| 250 | + | version = "0.15.5" | |
| 251 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 252 | + | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| 253 | + | dependencies = [ | |
| 254 | + | "allocator-api2", | |
| 255 | + | "equivalent", | |
| 256 | + | "foldhash", | |
| 257 | + | ] | |
| 258 | + | ||
| 259 | + | [[package]] | |
| 260 | + | name = "hashbrown" | |
| 261 | + | version = "0.17.1" | |
| 262 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 263 | + | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 264 | + | ||
| 265 | + | [[package]] | |
| 266 | + | name = "heck" | |
| 267 | + | version = "0.5.0" | |
| 268 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 269 | + | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 270 | + | ||
| 271 | + | [[package]] | |
| 272 | + | name = "ident_case" | |
| 273 | + | version = "1.0.1" | |
| 274 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 275 | + | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | |
| 276 | + | ||
| 277 | + | [[package]] | |
| 278 | + | name = "indexmap" | |
| 279 | + | version = "2.14.0" | |
| 280 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 281 | + | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" | |
| 282 | + | dependencies = [ | |
| 283 | + | "equivalent", | |
| 284 | + | "hashbrown 0.17.1", | |
| 285 | + | ] | |
| 286 | + | ||
| 287 | + | [[package]] | |
| 288 | + | name = "indoc" | |
| 289 | + | version = "2.0.7" | |
| 290 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 291 | + | checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" | |
| 292 | + | dependencies = [ | |
| 293 | + | "rustversion", | |
| 294 | + | ] | |
| 295 | + | ||
| 296 | + | [[package]] | |
| 297 | + | name = "instability" | |
| 298 | + | version = "0.3.10" | |
| 299 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 300 | + | checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" | |
| 301 | + | dependencies = [ | |
| 302 | + | "darling", | |
| 303 | + | "indoc", | |
| 304 | + | "proc-macro2", | |
| 305 | + | "quote", | |
| 306 | + | "syn", | |
| 307 | + | ] | |
| 308 | + | ||
| 309 | + | [[package]] | |
| 310 | + | name = "is_terminal_polyfill" | |
| 311 | + | version = "1.70.2" | |
| 312 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 313 | + | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |
| 314 | + | ||
| 315 | + | [[package]] | |
| 316 | + | name = "itertools" | |
| 317 | + | version = "0.13.0" | |
| 318 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 319 | + | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" | |
| 320 | + | dependencies = [ | |
| 321 | + | "either", | |
| 322 | + | ] | |
| 323 | + | ||
| 324 | + | [[package]] | |
| 325 | + | name = "itoa" | |
| 326 | + | version = "1.0.18" | |
| 327 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 328 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 329 | + | ||
| 330 | + | [[package]] | |
| 331 | + | name = "libc" | |
| 332 | + | version = "0.2.186" | |
| 333 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 334 | + | checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" | |
| 335 | + | ||
| 336 | + | [[package]] | |
| 337 | + | name = "linux-raw-sys" | |
| 338 | + | version = "0.4.15" | |
| 339 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 340 | + | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | |
| 341 | + | ||
| 342 | + | [[package]] | |
| 343 | + | name = "lock_api" | |
| 344 | + | version = "0.4.14" | |
| 345 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 346 | + | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | |
| 347 | + | dependencies = [ | |
| 348 | + | "scopeguard", | |
| 349 | + | ] | |
| 350 | + | ||
| 351 | + | [[package]] | |
| 352 | + | name = "log" | |
| 353 | + | version = "0.4.29" | |
| 354 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 355 | + | checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" | |
| 356 | + | ||
| 357 | + | [[package]] | |
| 358 | + | name = "lru" | |
| 359 | + | version = "0.12.5" | |
| 360 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 361 | + | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" | |
| 362 | + | dependencies = [ | |
| 363 | + | "hashbrown 0.15.5", | |
| 364 | + | ] | |
| 365 | + | ||
| 366 | + | [[package]] | |
| 367 | + | name = "memchr" | |
| 368 | + | version = "2.8.0" | |
| 369 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 370 | + | checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" | |
| 371 | + | ||
| 372 | + | [[package]] | |
| 373 | + | name = "mio" | |
| 374 | + | version = "1.2.0" | |
| 375 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 376 | + | checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" | |
| 377 | + | dependencies = [ | |
| 378 | + | "libc", | |
| 379 | + | "log", | |
| 380 | + | "wasi", | |
| 381 | + | "windows-sys 0.61.2", | |
| 382 | + | ] | |
| 383 | + | ||
| 384 | + | [[package]] | |
| 385 | + | name = "once_cell_polyfill" | |
| 386 | + | version = "1.70.2" | |
| 387 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 388 | + | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 389 | + | ||
| 390 | + | [[package]] | |
| 391 | + | name = "parking_lot" | |
| 392 | + | version = "0.12.5" | |
| 393 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 394 | + | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | |
| 395 | + | dependencies = [ | |
| 396 | + | "lock_api", | |
| 397 | + | "parking_lot_core", | |
| 398 | + | ] | |
| 399 | + | ||
| 400 | + | [[package]] | |
| 401 | + | name = "parking_lot_core" | |
| 402 | + | version = "0.9.12" | |
| 403 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 404 | + | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | |
| 405 | + | dependencies = [ | |
| 406 | + | "cfg-if", | |
| 407 | + | "libc", | |
| 408 | + | "redox_syscall", | |
| 409 | + | "smallvec", | |
| 410 | + | "windows-link", | |
| 411 | + | ] | |
| 412 | + | ||
| 413 | + | [[package]] | |
| 414 | + | name = "paste" | |
| 415 | + | version = "1.0.15" | |
| 416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 417 | + | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | |
| 418 | + | ||
| 419 | + | [[package]] | |
| 420 | + | name = "proc-macro2" | |
| 421 | + | version = "1.0.106" | |
| 422 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 423 | + | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | |
| 424 | + | dependencies = [ | |
| 425 | + | "unicode-ident", | |
| 426 | + | ] | |
| 427 | + | ||
| 428 | + | [[package]] | |
| 429 | + | name = "quote" | |
| 430 | + | version = "1.0.45" | |
| 431 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 432 | + | checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" | |
| 433 | + | dependencies = [ | |
| 434 | + | "proc-macro2", | |
| 435 | + | ] | |
| 436 | + | ||
| 437 | + | [[package]] | |
| 438 | + | name = "ratatui" | |
| 439 | + | version = "0.29.0" | |
| 440 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 441 | + | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" | |
| 442 | + | dependencies = [ | |
| 443 | + | "bitflags", | |
| 444 | + | "cassowary", | |
| 445 | + | "compact_str", | |
| 446 | + | "crossterm", | |
| 447 | + | "indoc", | |
| 448 | + | "instability", | |
| 449 | + | "itertools", | |
| 450 | + | "lru", | |
| 451 | + | "paste", | |
| 452 | + | "strum", | |
| 453 | + | "unicode-segmentation", | |
| 454 | + | "unicode-truncate", | |
| 455 | + | "unicode-width 0.2.0", | |
| 456 | + | ] | |
| 457 | + | ||
| 458 | + | [[package]] | |
| 459 | + | name = "redox_syscall" | |
| 460 | + | version = "0.5.18" | |
| 461 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 462 | + | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | |
| 463 | + | dependencies = [ | |
| 464 | + | "bitflags", | |
| 465 | + | ] | |
| 466 | + | ||
| 467 | + | [[package]] | |
| 468 | + | name = "rustix" | |
| 469 | + | version = "0.38.44" | |
| 470 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 471 | + | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | |
| 472 | + | dependencies = [ | |
| 473 | + | "bitflags", | |
| 474 | + | "errno", | |
| 475 | + | "libc", | |
| 476 | + | "linux-raw-sys", | |
| 477 | + | "windows-sys 0.59.0", | |
| 478 | + | ] | |
| 479 | + | ||
| 480 | + | [[package]] | |
| 481 | + | name = "rustversion" | |
| 482 | + | version = "1.0.22" | |
| 483 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 484 | + | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | |
| 485 | + | ||
| 486 | + | [[package]] | |
| 487 | + | name = "ryu" | |
| 488 | + | version = "1.0.23" | |
| 489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | + | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 491 | + | ||
| 492 | + | [[package]] | |
| 493 | + | name = "scopeguard" | |
| 494 | + | version = "1.2.0" | |
| 495 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 496 | + | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 497 | + | ||
| 498 | + | [[package]] | |
| 499 | + | name = "serde" | |
| 500 | + | version = "1.0.228" |
Lines truncated
| @@ -0,0 +1,18 @@ | |||
| 1 | + | [workspace] | |
| 2 | + | resolver = "3" | |
| 3 | + | members = ["crates/sysop", "crates/sysop-tui"] | |
| 4 | + | ||
| 5 | + | [workspace.package] | |
| 6 | + | edition = "2024" | |
| 7 | + | rust-version = "1.85" | |
| 8 | + | license = "MIT OR Apache-2.0" | |
| 9 | + | ||
| 10 | + | [workspace.dependencies] | |
| 11 | + | sysop-tui = { path = "crates/sysop-tui" } | |
| 12 | + | ratatui = "0.29" | |
| 13 | + | crossterm = "0.28" | |
| 14 | + | anyhow = "1" | |
| 15 | + | clap = { version = "4", features = ["derive"] } | |
| 16 | + | serde = { version = "1", features = ["derive"] } | |
| 17 | + | serde_json = "1" | |
| 18 | + | toml = "0.8" |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "sysop-tui" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | ratatui.workspace = true | |
| 10 | + | crossterm.workspace = true |
| @@ -0,0 +1,106 @@ | |||
| 1 | + | use crossterm::event::{self, Event, KeyCode}; | |
| 2 | + | use ratatui::style::Style; | |
| 3 | + | use ratatui::text::{Line, Span}; | |
| 4 | + | use ratatui::widgets::Paragraph; | |
| 5 | + | use std::io; | |
| 6 | + | use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; | |
| 7 | + | ||
| 8 | + | enum State { | |
| 9 | + | Ok, | |
| 10 | + | Warn, | |
| 11 | + | Fault, | |
| 12 | + | Inactive, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | struct Service { | |
| 16 | + | name: &'static str, | |
| 17 | + | state: State, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | fn state_span(s: &State) -> Span<'static> { | |
| 21 | + | match s { | |
| 22 | + | State::Ok => style::ok("ok"), | |
| 23 | + | State::Warn => style::warn("syncing"), | |
| 24 | + | State::Fault => style::fault("down"), | |
| 25 | + | State::Inactive => style::inactive("stopped"), | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | fn services() -> Vec<Service> { | |
| 30 | + | vec![ | |
| 31 | + | Service { name: "sshd", state: State::Ok }, | |
| 32 | + | Service { name: "unbound", state: State::Ok }, | |
| 33 | + | Service { name: "chronyd", state: State::Ok }, | |
| 34 | + | Service { name: "caddy", state: State::Warn }, | |
| 35 | + | Service { name: "nebula", state: State::Fault }, | |
| 36 | + | Service { name: "tlp", state: State::Inactive }, | |
| 37 | + | ] | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | fn body(items: &[Service], selected: usize) -> Paragraph<'_> { | |
| 41 | + | let lines: Vec<Line> = items | |
| 42 | + | .iter() | |
| 43 | + | .enumerate() | |
| 44 | + | .map(|(i, s)| { | |
| 45 | + | let is_sel = i == selected; | |
| 46 | + | let marker = if is_sel { selection::MARKER } else { " " }; | |
| 47 | + | let line = Line::from(vec![ | |
| 48 | + | Span::raw(format!(" {} ", marker)), | |
| 49 | + | Span::raw(format!("{:<12}", s.name)), | |
| 50 | + | Span::raw(" "), | |
| 51 | + | state_span(&s.state), | |
| 52 | + | ]); | |
| 53 | + | if is_sel { | |
| 54 | + | line.style(selection::selected_style()) | |
| 55 | + | } else { | |
| 56 | + | line | |
| 57 | + | } | |
| 58 | + | }) | |
| 59 | + | .collect(); | |
| 60 | + | Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn main() -> io::Result<()> { | |
| 64 | + | let mut terminal = ratatui::init(); | |
| 65 | + | let services = services(); | |
| 66 | + | let mut idx: usize = 0; | |
| 67 | + | ||
| 68 | + | let footer = Footer::new([ | |
| 69 | + | hint("j/k", "move"), | |
| 70 | + | hint("Enter", "details"), | |
| 71 | + | hint("?", "help"), | |
| 72 | + | hint("q", "quit"), | |
| 73 | + | ]); | |
| 74 | + | ||
| 75 | + | let result: io::Result<()> = loop { | |
| 76 | + | let draw = terminal.draw(|frame| { | |
| 77 | + | let [body_area, foot_area] = layout::split(frame.area()); | |
| 78 | + | frame.render_widget(body(&services, idx), body_area); | |
| 79 | + | frame.render_widget(&footer, foot_area); | |
| 80 | + | }); | |
| 81 | + | if let Err(e) = draw { | |
| 82 | + | break Err(e); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | match event::read() { | |
| 86 | + | Ok(Event::Key(key)) => match classify(key) { | |
| 87 | + | GlobalAction::Quit => break Ok(()), | |
| 88 | + | GlobalAction::Passthrough => match key.code { | |
| 89 | + | KeyCode::Char('j') | KeyCode::Down if idx + 1 < services.len() => { | |
| 90 | + | idx += 1; | |
| 91 | + | } | |
| 92 | + | KeyCode::Char('k') | KeyCode::Up => { | |
| 93 | + | idx = idx.saturating_sub(1); | |
| 94 | + | } | |
| 95 | + | _ => {} | |
| 96 | + | }, | |
| 97 | + | _ => {} | |
| 98 | + | }, | |
| 99 | + | Ok(_) => {} | |
| 100 | + | Err(e) => break Err(e), | |
| 101 | + | } | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | ratatui::restore(); | |
| 105 | + | result | |
| 106 | + | } |
| @@ -0,0 +1,44 @@ | |||
| 1 | + | use crate::palette; | |
| 2 | + | use ratatui::buffer::Buffer; | |
| 3 | + | use ratatui::layout::Rect; | |
| 4 | + | use ratatui::style::Style; | |
| 5 | + | use ratatui::text::{Line, Span}; | |
| 6 | + | use ratatui::widgets::{Paragraph, Widget}; | |
| 7 | + | ||
| 8 | + | pub struct Hint { | |
| 9 | + | pub key: &'static str, | |
| 10 | + | pub label: &'static str, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | pub fn hint(key: &'static str, label: &'static str) -> Hint { | |
| 14 | + | Hint { key, label } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | pub struct Footer { | |
| 18 | + | hints: Vec<Hint>, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl Footer { | |
| 22 | + | pub fn new(hints: impl IntoIterator<Item = Hint>) -> Self { | |
| 23 | + | Self { | |
| 24 | + | hints: hints.into_iter().collect(), | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl Widget for &Footer { | |
| 30 | + | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 31 | + | let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 4); | |
| 32 | + | for (i, h) in self.hints.iter().enumerate() { | |
| 33 | + | if i > 0 { | |
| 34 | + | spans.push(Span::raw(" ")); | |
| 35 | + | } | |
| 36 | + | spans.push(Span::styled(h.key, Style::default().fg(palette::ACCENT))); | |
| 37 | + | spans.push(Span::raw(" ")); | |
| 38 | + | spans.push(Span::styled(h.label, Style::default().fg(palette::DIM))); | |
| 39 | + | } | |
| 40 | + | Paragraph::new(Line::from(spans)) | |
| 41 | + | .style(Style::default().bg(palette::BG)) | |
| 42 | + | .render(area, buf); | |
| 43 | + | } | |
| 44 | + | } |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | use crossterm::event::{KeyCode, KeyEvent}; | |
| 2 | + | ||
| 3 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 4 | + | pub enum GlobalAction { | |
| 5 | + | Help, | |
| 6 | + | Quit, | |
| 7 | + | Back, | |
| 8 | + | Filter, | |
| 9 | + | Command, | |
| 10 | + | Passthrough, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | pub fn classify(key: KeyEvent) -> GlobalAction { | |
| 14 | + | match key.code { | |
| 15 | + | KeyCode::Char('?') => GlobalAction::Help, | |
| 16 | + | KeyCode::Char('q') => GlobalAction::Quit, | |
| 17 | + | KeyCode::Esc => GlobalAction::Back, | |
| 18 | + | KeyCode::Char('/') => GlobalAction::Filter, | |
| 19 | + | KeyCode::Char(':') => GlobalAction::Command, | |
| 20 | + | _ => GlobalAction::Passthrough, | |
| 21 | + | } | |
| 22 | + | } |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 2 | + | ||
| 3 | + | pub fn split(area: Rect) -> [Rect; 2] { | |
| 4 | + | Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area) | |
| 5 | + | } |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | pub mod footer; | |
| 2 | + | pub mod keys; | |
| 3 | + | pub mod layout; | |
| 4 | + | pub mod palette; | |
| 5 | + | pub mod selection; | |
| 6 | + | pub mod style; | |
| 7 | + | ||
| 8 | + | pub use footer::{Footer, Hint, hint}; | |
| 9 | + | pub use keys::{GlobalAction, classify}; | |
| 10 | + | pub use selection::{MARKER, selected_style}; |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | use ratatui::style::Color; | |
| 2 | + | ||
| 3 | + | pub const BG: Color = Color::Rgb(0xf7, 0xf3, 0xee); | |
| 4 | + | pub const FG: Color = Color::Rgb(0x60, 0x5a, 0x52); | |
| 5 | + | pub const DIM: Color = Color::Rgb(0x8c, 0x86, 0x7e); | |
| 6 | + | pub const SELECTION_BG: Color = Color::Rgb(0xdd, 0xd2, 0xbc); | |
| 7 | + | pub const ACCENT: Color = Color::Rgb(0x65, 0x87, 0xbf); | |
| 8 | + | pub const STATE_OK: Color = Color::Rgb(0x5d, 0x7a, 0x55); | |
| 9 | + | pub const STATE_WARN: Color = Color::Rgb(0xa7, 0x8a, 0x5e); | |
| 10 | + | pub const STATE_FAULT: Color = Color::Rgb(0xc4, 0x6a, 0x6a); |
| @@ -0,0 +1,11 @@ | |||
| 1 | + | use crate::palette; | |
| 2 | + | use ratatui::style::{Modifier, Style}; | |
| 3 | + | ||
| 4 | + | pub const MARKER: &str = "▶"; | |
| 5 | + | ||
| 6 | + | pub fn selected_style() -> Style { | |
| 7 | + | Style::default() | |
| 8 | + | .fg(palette::FG) | |
| 9 | + | .bg(palette::SELECTION_BG) | |
| 10 | + | .add_modifier(Modifier::BOLD) | |
| 11 | + | } |
| @@ -0,0 +1,34 @@ | |||
| 1 | + | use crate::palette; | |
| 2 | + | use ratatui::style::{Modifier, Style}; | |
| 3 | + | use ratatui::text::Span; | |
| 4 | + | ||
| 5 | + | pub fn fg(s: impl Into<String>) -> Span<'static> { | |
| 6 | + | Span::styled(s.into(), Style::default().fg(palette::FG)) | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | pub fn dim(s: impl Into<String>) -> Span<'static> { | |
| 10 | + | Span::styled(s.into(), Style::default().fg(palette::DIM)) | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | pub fn bold(s: impl Into<String>) -> Span<'static> { | |
| 14 | + | Span::styled( | |
| 15 | + | s.into(), | |
| 16 | + | Style::default().fg(palette::FG).add_modifier(Modifier::BOLD), | |
| 17 | + | ) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | pub fn ok(s: impl Into<String>) -> Span<'static> { | |
| 21 | + | Span::styled(s.into(), Style::default().fg(palette::STATE_OK)) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | pub fn warn(s: impl Into<String>) -> Span<'static> { | |
| 25 | + | Span::styled(s.into(), Style::default().fg(palette::STATE_WARN)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | pub fn fault(s: impl Into<String>) -> Span<'static> { | |
| 29 | + | Span::styled(s.into(), Style::default().fg(palette::STATE_FAULT)) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub fn inactive(s: impl Into<String>) -> Span<'static> { | |
| 33 | + | dim(s) | |
| 34 | + | } |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "sysop" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "sysop" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | sysop-tui.workspace = true | |
| 14 | + | ratatui.workspace = true | |
| 15 | + | crossterm.workspace = true | |
| 16 | + | anyhow.workspace = true | |
| 17 | + | clap.workspace = true | |
| 18 | + | serde.workspace = true | |
| 19 | + | serde_json.workspace = true | |
| 20 | + | toml.workspace = true |
| @@ -0,0 +1,254 @@ | |||
| 1 | + | # Mountaineer role → tool registry. | |
| 2 | + | # | |
| 3 | + | # Each role is a stable name for a job the OS needs done. The tool filling | |
| 4 | + | # the role is implementation detail and may change between releases; the | |
| 5 | + | # role name does not. `sysop info <role>` reads this file. | |
| 6 | + | # | |
| 7 | + | # Order matters: roles are presented in the list view in the order they | |
| 8 | + | # appear here. Group by concern (core → network → graphical). | |
| 9 | + | ||
| 10 | + | # --- core --- | |
| 11 | + | ||
| 12 | + | [[role]] | |
| 13 | + | name = "init" | |
| 14 | + | description = "Service supervisor and runlevel manager" | |
| 15 | + | tool = "s6 + s6-rc" | |
| 16 | + | packages = ["s6", "s6-rc"] | |
| 17 | + | config_paths = ["/etc/s6/", "/etc/s6-rc/"] | |
| 18 | + | ||
| 19 | + | [[role]] | |
| 20 | + | name = "shell-login" | |
| 21 | + | description = "POSIX /bin/sh" | |
| 22 | + | tool = "busybox ash" | |
| 23 | + | packages = ["busybox"] | |
| 24 | + | config_paths = ["/etc/profile", "/etc/profile.d/"] | |
| 25 | + | ||
| 26 | + | [[role]] | |
| 27 | + | name = "shell-interactive" | |
| 28 | + | description = "Login interactive shell with structured pipelines" | |
| 29 | + | tool = "nushell" | |
| 30 | + | packages = ["nushell"] | |
| 31 | + | config_paths = ["~/.config/nushell/"] | |
| 32 | + | ||
| 33 | + | [[role]] | |
| 34 | + | name = "editor" | |
| 35 | + | description = "Modal editor with LSP and tree-sitter" | |
| 36 | + | tool = "helix" | |
| 37 | + | packages = ["helix"] | |
| 38 | + | config_paths = ["~/.config/helix/"] | |
| 39 | + | ||
| 40 | + | [[role]] | |
| 41 | + | name = "multiplexer" | |
| 42 | + | description = "Terminal multiplexer with discoverable keybindings" | |
| 43 | + | tool = "zellij" | |
| 44 | + | packages = ["zellij"] | |
| 45 | + | config_paths = ["~/.config/zellij/"] | |
| 46 | + | ||
| 47 | + | [[role]] | |
| 48 | + | name = "container-runtime" | |
| 49 | + | description = "Rootless container runtime" | |
| 50 | + | tool = "podman" | |
| 51 | + | packages = ["podman"] | |
| 52 | + | config_paths = ["/etc/containers/", "~/.config/containers/"] | |
| 53 | + | ||
| 54 | + | [[role]] | |
| 55 | + | name = "filesystem" | |
| 56 | + | description = "Root filesystem and pool/dataset management" | |
| 57 | + | tool = "ZFS" | |
| 58 | + | packages = ["zfs", "zfs-lts"] | |
| 59 | + | config_paths = ["/etc/zfs/"] | |
| 60 | + | ||
| 61 | + | [[role]] | |
| 62 | + | name = "ssh" | |
| 63 | + | description = "SSH server, hardened defaults" | |
| 64 | + | tool = "OpenSSH" | |
| 65 | + | packages = ["openssh"] | |
| 66 | + | config_paths = ["/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/"] | |
| 67 | + | ||
| 68 | + | # --- network --- | |
| 69 | + | ||
| 70 | + | [[role]] | |
| 71 | + | name = "firewall" | |
| 72 | + | description = "Stateful packet filter, default-deny" | |
| 73 | + | tool = "nftables" | |
| 74 | + | packages = ["nftables"] | |
| 75 | + | config_paths = ["/etc/nftables.nft", "/etc/nftables.d/"] | |
| 76 | + | ||
| 77 | + | [[role]] | |
| 78 | + | name = "resolver" | |
| 79 | + | description = "Local validating recursive DNS resolver" | |
| 80 | + | tool = "unbound" | |
| 81 | + | packages = ["unbound"] | |
| 82 | + | config_paths = ["/etc/unbound/unbound.conf", "/etc/unbound/conf.d/"] | |
| 83 | + | ||
| 84 | + | [[role]] | |
| 85 | + | name = "time" | |
| 86 | + | description = "NTP client and time synchronisation" | |
| 87 | + | tool = "chrony" | |
| 88 | + | packages = ["chrony"] | |
| 89 | + | config_paths = ["/etc/chrony/chrony.conf"] | |
| 90 | + | ||
| 91 | + | [[role]] | |
| 92 | + | name = "network-wired" | |
| 93 | + | description = "Wired interface configuration" | |
| 94 | + | tool = "ifupdown-ng" | |
| 95 | + | packages = ["ifupdown-ng"] | |
| 96 | + | config_paths = ["/etc/network/interfaces", "/etc/network/interfaces.d/"] | |
| 97 | + | ||
| 98 | + | [[role]] | |
| 99 | + | name = "network-wireless" | |
| 100 | + | description = "Wireless interface daemon" | |
| 101 | + | tool = "iwd" | |
| 102 | + | packages = ["iwd"] | |
| 103 | + | config_paths = ["/etc/iwd/", "/var/lib/iwd/"] | |
| 104 | + | ||
| 105 | + | [[role]] | |
| 106 | + | name = "mesh" | |
| 107 | + | description = "Overlay mesh VPN with mutual auth" | |
| 108 | + | tool = "Nebula" | |
| 109 | + | packages = ["nebula"] | |
| 110 | + | config_paths = ["/etc/nebula/"] | |
| 111 | + | ||
| 112 | + | [[role]] | |
| 113 | + | name = "reverse-proxy" | |
| 114 | + | description = "HTTPS-terminating reverse proxy with auto-TLS" | |
| 115 | + | tool = "Caddy" | |
| 116 | + | packages = ["caddy"] | |
| 117 | + | config_paths = ["/etc/caddy/Caddyfile", "/etc/caddy/conf.d/"] | |
| 118 | + | ||
| 119 | + | [[role]] | |
| 120 | + | name = "mail-outbound" | |
| 121 | + | description = "Outbound mail relay (notifications, alerts)" | |
| 122 | + | tool = "msmtp" | |
| 123 | + | packages = ["msmtp"] | |
| 124 | + | config_paths = ["/etc/msmtprc"] | |
| 125 | + | ||
| 126 | + | # --- observability / data --- | |
| 127 | + | ||
| 128 | + | [[role]] | |
| 129 | + | name = "logs-router" | |
| 130 | + | description = "Structured log collection and routing" | |
| 131 | + | tool = "Vector" | |
| 132 | + | packages = ["vector"] | |
| 133 | + | config_paths = ["/etc/vector/vector.toml"] | |
| 134 | + | ||
| 135 | + | [[role]] | |
| 136 | + | name = "logs-viewer" | |
| 137 | + | description = "Interactive log browser" | |
| 138 | + | tool = "lnav" | |
| 139 | + | packages = ["lnav"] | |
| 140 | + | config_paths = ["~/.config/lnav/"] | |
| 141 | + | ||
| 142 | + | [[role]] | |
| 143 | + | name = "secrets" | |
| 144 | + | description = "Age-encrypted secrets with sops integration" | |
| 145 | + | tool = "age + sops" | |
| 146 | + | packages = ["age", "sops"] | |
| 147 | + | config_paths = ["~/.config/sops/", ".sops.yaml"] | |
| 148 | + | ||
| 149 | + | [[role]] | |
| 150 | + | name = "backup" | |
| 151 | + | description = "Encrypted, deduplicated backups" | |
| 152 | + | tool = "restic" | |
| 153 | + | packages = ["restic"] | |
| 154 | + | config_paths = ["~/.config/restic/"] | |
| 155 | + | ||
| 156 | + | # --- graphical layer (Sysadmin build only) --- | |
| 157 | + | ||
| 158 | + | [[role]] | |
| 159 | + | name = "compositor" | |
| 160 | + | description = "Tiling Wayland compositor" | |
| 161 | + | tool = "river" | |
| 162 | + | packages = ["river"] | |
| 163 | + | config_paths = ["~/.config/river/init"] | |
| 164 | + | ||
| 165 | + | [[role]] | |
| 166 | + | name = "terminal" | |
| 167 | + | description = "Wayland terminal emulator" | |
| 168 | + | tool = "foot" | |
| 169 | + | packages = ["foot"] | |
| 170 | + | config_paths = ["~/.config/foot/foot.ini"] | |
| 171 | + | ||
| 172 | + | [[role]] | |
| 173 | + | name = "bar" | |
| 174 | + | description = "Status bar (read-only system surface)" | |
| 175 | + | tool = "yambar" | |
| 176 | + | packages = ["yambar"] | |
| 177 | + | config_paths = ["~/.config/yambar/config.yml"] | |
| 178 | + | ||
| 179 | + | [[role]] | |
| 180 | + | name = "launcher" | |
| 181 | + | description = "Application launcher" | |
| 182 | + | tool = "fuzzel" | |
| 183 | + | packages = ["fuzzel"] | |
| 184 | + | config_paths = ["~/.config/fuzzel/fuzzel.ini"] | |
| 185 | + | ||
| 186 | + | [[role]] | |
| 187 | + | name = "clipboard" | |
| 188 | + | description = "Clipboard history" | |
| 189 | + | tool = "cliphist" | |
| 190 | + | packages = ["cliphist"] | |
| 191 | + | config_paths = [] | |
| 192 | + | ||
| 193 | + | [[role]] | |
| 194 | + | name = "notifications" | |
| 195 | + | description = "Wayland notification daemon" | |
| 196 | + | tool = "fnott" | |
| 197 | + | packages = ["fnott"] | |
| 198 | + | config_paths = ["~/.config/fnott/fnott.ini"] | |
| 199 | + | ||
| 200 | + | [[role]] | |
| 201 | + | name = "screenshots" | |
| 202 | + | description = "Region capture, annotation, pipeline" | |
| 203 | + | tool = "grim + slurp + swappy" | |
| 204 | + | packages = ["grim", "slurp", "swappy"] | |
| 205 | + | config_paths = ["~/.config/swappy/"] | |
| 206 | + | ||
| 207 | + | [[role]] | |
| 208 | + | name = "lock" | |
| 209 | + | description = "Screen locker" | |
| 210 | + | tool = "swaylock" | |
| 211 | + | packages = ["swaylock"] | |
| 212 | + | config_paths = ["~/.config/swaylock/config"] | |
| 213 | + | ||
| 214 | + | [[role]] | |
| 215 | + | name = "idle" | |
| 216 | + | description = "Idle event dispatcher" | |
| 217 | + | tool = "swayidle" | |
| 218 | + | packages = ["swayidle"] | |
| 219 | + | config_paths = ["~/.config/swayidle/config"] | |
| 220 | + | ||
| 221 | + | [[role]] | |
| 222 | + | name = "wallpaper" | |
| 223 | + | description = "Wallpaper / background" | |
| 224 | + | tool = "swaybg" | |
| 225 | + | packages = ["swaybg"] | |
| 226 | + | config_paths = [] | |
| 227 | + | ||
| 228 | + | [[role]] | |
| 229 | + | name = "audio" | |
| 230 | + | description = "Audio server and session manager" | |
| 231 | + | tool = "pipewire + wireplumber" | |
| 232 | + | packages = ["pipewire", "wireplumber"] | |
| 233 | + | config_paths = ["/etc/pipewire/", "~/.config/pipewire/", "~/.config/wireplumber/"] | |
| 234 | + | ||
| 235 | + | [[role]] | |
| 236 | + | name = "seat" | |
| 237 | + | description = "Seat / device management for non-root Wayland" | |
| 238 | + | tool = "seatd" | |
| 239 | + | packages = ["seatd"] | |
| 240 | + | config_paths = [] | |
| 241 | + | ||
| 242 | + | [[role]] | |
| 243 | + | name = "brightness" | |
| 244 | + | description = "Backlight control" | |
| 245 | + | tool = "brightnessctl" | |
| 246 | + | packages = ["brightnessctl"] | |
| 247 | + | config_paths = [] | |
| 248 | + | ||
| 249 | + | [[role]] | |
| 250 | + | name = "power" | |
| 251 | + | description = "Laptop power management profiles" | |
| 252 | + | tool = "tlp" | |
| 253 | + | packages = ["tlp"] | |
| 254 | + | config_paths = ["/etc/tlp.conf", "/etc/tlp.d/"] |
| @@ -0,0 +1,47 @@ | |||
| 1 | + | use anyhow::{Context, Result}; | |
| 2 | + | use serde::{Deserialize, Serialize}; | |
| 3 | + | use std::fs; | |
| 4 | + | use std::path::PathBuf; | |
| 5 | + | ||
| 6 | + | const DEFAULT_PATH: &str = "/etc/mountaineer/sysop.toml"; | |
| 7 | + | ||
| 8 | + | pub fn path() -> PathBuf { | |
| 9 | + | std::env::var_os("SYSOP_CONFIG") | |
| 10 | + | .map(PathBuf::from) | |
| 11 | + | .unwrap_or_else(|| PathBuf::from(DEFAULT_PATH)) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 15 | + | pub struct Config { | |
| 16 | + | #[serde(default)] | |
| 17 | + | pub motd: Motd, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 21 | + | pub struct Motd { | |
| 22 | + | pub enabled: bool, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | impl Default for Motd { | |
| 26 | + | fn default() -> Self { | |
| 27 | + | Self { enabled: true } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | pub fn load() -> Result<Config> { | |
| 32 | + | let p = path(); | |
| 33 | + | if !p.exists() { | |
| 34 | + | return Ok(Config::default()); | |
| 35 | + | } | |
| 36 | + | let s = fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?; | |
| 37 | + | toml::from_str(&s).with_context(|| format!("parse {}", p.display())) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | pub fn save(cfg: &Config) -> Result<()> { | |
| 41 | + | let p = path(); | |
| 42 | + | if let Some(dir) = p.parent() { | |
| 43 | + | fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; | |
| 44 | + | } | |
| 45 | + | let s = toml::to_string_pretty(cfg).context("serialize config")?; | |
| 46 | + | fs::write(&p, s).with_context(|| format!("write {}", p.display())) | |
| 47 | + | } |
| @@ -0,0 +1,186 @@ | |||
| 1 | + | use crate::roles::{self, Role}; | |
| 2 | + | use anyhow::Result; | |
| 3 | + | use crossterm::event::{self, Event, KeyCode}; | |
| 4 | + | use ratatui::Frame; | |
| 5 | + | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 6 | + | use ratatui::style::Style; | |
| 7 | + | use ratatui::text::{Line, Span}; | |
| 8 | + | use ratatui::widgets::Paragraph; | |
| 9 | + | use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; | |
| 10 | + | ||
| 11 | + | enum View { | |
| 12 | + | List { selected: usize }, | |
| 13 | + | Detail { idx: usize }, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | pub fn run(initial: Option<String>) -> Result<i32> { | |
| 17 | + | let roles = roles::load()?; | |
| 18 | + | ||
| 19 | + | let mut view = match initial.as_deref() { | |
| 20 | + | None => View::List { selected: 0 }, | |
| 21 | + | Some(name) => match roles::find(&roles, name) { | |
| 22 | + | Some(_) => View::Detail { | |
| 23 | + | idx: roles.iter().position(|r| r.name == name).unwrap(), | |
| 24 | + | }, | |
| 25 | + | None => { | |
| 26 | + | eprintln!("sysop: unknown role: {name}"); | |
| 27 | + | eprintln!(" run `sysop info` for the list"); | |
| 28 | + | return Ok(1); | |
| 29 | + | } | |
| 30 | + | }, | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | let mut terminal = ratatui::init(); | |
| 34 | + | let result = event_loop(&mut terminal, &roles, &mut view); | |
| 35 | + | ratatui::restore(); | |
| 36 | + | result.map(|_| 0) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn event_loop( | |
| 40 | + | terminal: &mut ratatui::DefaultTerminal, | |
| 41 | + | roles: &[Role], | |
| 42 | + | view: &mut View, | |
| 43 | + | ) -> Result<()> { | |
| 44 | + | loop { | |
| 45 | + | terminal.draw(|frame| draw(frame, roles, view))?; | |
| 46 | + | ||
| 47 | + | let Event::Key(key) = event::read()? else { | |
| 48 | + | continue; | |
| 49 | + | }; | |
| 50 | + | let action = classify(key); | |
| 51 | + | ||
| 52 | + | match view { | |
| 53 | + | View::List { selected } => match action { | |
| 54 | + | GlobalAction::Quit => return Ok(()), | |
| 55 | + | GlobalAction::Passthrough => match key.code { | |
| 56 | + | KeyCode::Char('j') | KeyCode::Down if *selected + 1 < roles.len() => { | |
| 57 | + | *selected += 1; | |
| 58 | + | } | |
| 59 | + | KeyCode::Char('k') | KeyCode::Up => { | |
| 60 | + | *selected = selected.saturating_sub(1); | |
| 61 | + | } | |
| 62 | + | KeyCode::Enter => { | |
| 63 | + | *view = View::Detail { idx: *selected }; | |
| 64 | + | } | |
| 65 | + | _ => {} | |
| 66 | + | }, | |
| 67 | + | _ => {} | |
| 68 | + | }, | |
| 69 | + | View::Detail { idx } => match action { | |
| 70 | + | GlobalAction::Quit => return Ok(()), | |
| 71 | + | GlobalAction::Back => { | |
| 72 | + | *view = View::List { selected: *idx }; | |
| 73 | + | } | |
| 74 | + | GlobalAction::Passthrough => match key.code { | |
| 75 | + | KeyCode::Char('j') | KeyCode::Down if *idx + 1 < roles.len() => { | |
| 76 | + | *idx += 1; | |
| 77 | + | } | |
| 78 | + | KeyCode::Char('k') | KeyCode::Up => { | |
| 79 | + | *idx = idx.saturating_sub(1); | |
| 80 | + | } | |
| 81 | + | _ => {} | |
| 82 | + | }, | |
| 83 | + | _ => {} | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | fn draw(frame: &mut Frame, roles: &[Role], view: &View) { | |
| 90 | + | let [body, foot] = layout::split(frame.area()); | |
| 91 | + | match view { | |
| 92 | + | View::List { selected } => { | |
| 93 | + | frame.render_widget(list_widget(roles, *selected), body); | |
| 94 | + | frame.render_widget( | |
| 95 | + | &Footer::new([ | |
| 96 | + | hint("j/k", "move"), | |
| 97 | + | hint("Enter", "details"), | |
| 98 | + | hint("q", "quit"), | |
| 99 | + | ]), | |
| 100 | + | foot, | |
| 101 | + | ); | |
| 102 | + | } | |
| 103 | + | View::Detail { idx } => { | |
| 104 | + | draw_detail(frame, &roles[*idx], body); | |
| 105 | + | frame.render_widget( | |
| 106 | + | &Footer::new([ | |
| 107 | + | hint("j/k", "next/prev role"), | |
| 108 | + | hint("Esc", "back"), | |
| 109 | + | hint("q", "quit"), | |
| 110 | + | ]), | |
| 111 | + | foot, | |
| 112 | + | ); | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn list_widget(roles: &[Role], selected: usize) -> Paragraph<'_> { | |
| 118 | + | let name_w = roles.iter().map(|r| r.name.len()).max().unwrap_or(0); | |
| 119 | + | let lines: Vec<Line> = roles | |
| 120 | + | .iter() | |
| 121 | + | .enumerate() | |
| 122 | + | .map(|(i, r)| { | |
| 123 | + | let is_sel = i == selected; | |
| 124 | + | let marker = if is_sel { selection::MARKER } else { " " }; | |
| 125 | + | let line = Line::from(vec![ | |
| 126 | + | Span::raw(format!(" {marker} ")), | |
| 127 | + | Span::raw(format!("{:<name_w$}", r.name)), | |
| 128 | + | Span::raw(" "), | |
| 129 | + | style::dim(r.tool.clone()), | |
| 130 | + | ]); | |
| 131 | + | if is_sel { | |
| 132 | + | line.style(selection::selected_style()) | |
| 133 | + | } else { | |
| 134 | + | line | |
| 135 | + | } | |
| 136 | + | }) | |
| 137 | + | .collect(); | |
| 138 | + | Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn draw_detail(frame: &mut Frame, role: &Role, area: Rect) { | |
| 142 | + | let [_, right] = | |
| 143 | + | Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(area); | |
| 144 | + | let [_, body] = | |
| 145 | + | Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(right); | |
| 146 | + | ||
| 147 | + | let label_w = 12; | |
| 148 | + | let mut lines: Vec<Line> = Vec::new(); | |
| 149 | + | ||
| 150 | + | lines.push(field_line("role", &role.name, label_w)); | |
| 151 | + | lines.push(field_line("tool", &role.tool, label_w)); | |
| 152 | + | lines.push(field_line( | |
| 153 | + | "packages", | |
| 154 | + | &role.packages.join(", "), | |
| 155 | + | label_w, | |
| 156 | + | )); | |
| 157 | + | ||
| 158 | + | if role.config_paths.is_empty() { | |
| 159 | + | lines.push(field_line("config", "(none)", label_w)); | |
| 160 | + | } else { | |
| 161 | + | lines.push(field_line("config", &role.config_paths[0], label_w)); | |
| 162 | + | for extra in &role.config_paths[1..] { | |
| 163 | + | lines.push(continuation_line(extra, label_w)); | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | lines.push(Line::from("")); | |
| 168 | + | lines.push(field_line("description", &role.description, label_w)); | |
| 169 | + | ||
| 170 | + | let para = Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)); | |
| 171 | + | frame.render_widget(para, body); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | fn field_line(label: &str, value: &str, label_w: usize) -> Line<'static> { | |
| 175 | + | Line::from(vec![ | |
| 176 | + | style::dim(format!("{:<label_w$} ", label)), | |
| 177 | + | style::fg(value.to_string()), | |
| 178 | + | ]) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | fn continuation_line(value: &str, label_w: usize) -> Line<'static> { | |
| 182 | + | Line::from(vec![ | |
| 183 | + | Span::raw(" ".repeat(label_w + 2)), | |
| 184 | + | style::fg(value.to_string()), | |
| 185 | + | ]) | |
| 186 | + | } |
| @@ -0,0 +1,79 @@ | |||
| 1 | + | mod config; | |
| 2 | + | mod info; | |
| 3 | + | mod motd; | |
| 4 | + | mod net; | |
| 5 | + | mod roles; | |
| 6 | + | mod services; | |
| 7 | + | mod storage; | |
| 8 | + | mod wifi; | |
| 9 | + | ||
| 10 | + | use clap::{Parser, Subcommand}; | |
| 11 | + | ||
| 12 | + | #[derive(Parser)] | |
| 13 | + | #[command(name = "sysop", about = "Mountaineer operator interface")] | |
| 14 | + | struct Cli { | |
| 15 | + | #[command(subcommand)] | |
| 16 | + | command: Command, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | #[derive(Subcommand)] | |
| 20 | + | enum Command { | |
| 21 | + | /// Show which tool fills a role and where its config lives | |
| 22 | + | Info { | |
| 23 | + | /// Role name (omit for the full list) | |
| 24 | + | role: Option<String>, | |
| 25 | + | }, | |
| 26 | + | /// Login tip rotation (on/off/print) | |
| 27 | + | Motd { | |
| 28 | + | #[command(subcommand)] | |
| 29 | + | action: Option<MotdAction>, | |
| 30 | + | }, | |
| 31 | + | /// s6-rc service health view | |
| 32 | + | Services { | |
| 33 | + | /// One-line summary for the yambar `svc` segment | |
| 34 | + | #[arg(long)] | |
| 35 | + | summary: bool, | |
| 36 | + | }, | |
| 37 | + | /// ZFS pool health view | |
| 38 | + | Storage { | |
| 39 | + | /// One-line summary for the yambar `zfs` segment | |
| 40 | + | #[arg(long)] | |
| 41 | + | summary: bool, | |
| 42 | + | }, | |
| 43 | + | /// Connect to a wireless network | |
| 44 | + | Wifi, | |
| 45 | + | /// Network interface status | |
| 46 | + | Net { | |
| 47 | + | /// One-line summary for the yambar `net` segment | |
| 48 | + | #[arg(long)] | |
| 49 | + | summary: bool, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[derive(Subcommand)] | |
| 54 | + | enum MotdAction { | |
| 55 | + | /// Enable the login tip | |
| 56 | + | On, | |
| 57 | + | /// Disable the login tip | |
| 58 | + | Off, | |
| 59 | + | /// Emit the current tip (called by /etc/profile.d/sysop-motd.sh on login) | |
| 60 | + | Print, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn main() -> anyhow::Result<()> { | |
| 64 | + | let cli = Cli::parse(); | |
| 65 | + | let code = match cli.command { | |
| 66 | + | Command::Info { role } => info::run(role)?, | |
| 67 | + | Command::Motd { action } => match action { | |
| 68 | + | None => motd::status()?, | |
| 69 | + | Some(MotdAction::On) => motd::set(true)?, | |
| 70 | + | Some(MotdAction::Off) => motd::set(false)?, | |
| 71 | + | Some(MotdAction::Print) => motd::print()?, | |
| 72 | + | }, | |
| 73 | + | Command::Services { summary } => services::run(summary)?, | |
| 74 | + | Command::Storage { summary } => storage::run(summary)?, | |
| 75 | + | Command::Wifi => wifi::run()?, | |
| 76 | + | Command::Net { summary } => net::run(summary)?, | |
| 77 | + | }; | |
| 78 | + | std::process::exit(code); | |
| 79 | + | } |
| @@ -0,0 +1,43 @@ | |||
| 1 | + | use crate::config; | |
| 2 | + | use anyhow::Result; | |
| 3 | + | use std::time::SystemTime; | |
| 4 | + | ||
| 5 | + | const TIPS: &[&str] = &[ | |
| 6 | + | "sysop info <role> — show which tool fills a role and where its config lives", | |
| 7 | + | "sysop services — s6-rc service health", | |
| 8 | + | "sysop wifi — network connections", | |
| 9 | + | "sysop storage — ZFS pool state", | |
| 10 | + | "sysop logs <svc> — structured logs for a service", | |
| 11 | + | "sysop help — list all sysop commands", | |
| 12 | + | ]; | |
| 13 | + | ||
| 14 | + | pub fn status() -> Result<i32> { | |
| 15 | + | let cfg = config::load()?; | |
| 16 | + | let state = if cfg.motd.enabled { "on" } else { "off" }; | |
| 17 | + | println!("motd: {state}"); | |
| 18 | + | println!("config: {}", config::path().display()); | |
| 19 | + | Ok(0) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | pub fn set(enabled: bool) -> Result<i32> { | |
| 23 | + | let mut cfg = config::load()?; | |
| 24 | + | cfg.motd.enabled = enabled; | |
| 25 | + | config::save(&cfg)?; | |
| 26 | + | println!("motd: {}", if enabled { "on" } else { "off" }); | |
| 27 | + | Ok(0) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub fn print() -> Result<i32> { | |
| 31 | + | let cfg = config::load()?; | |
| 32 | + | if !cfg.motd.enabled { | |
| 33 | + | return Ok(0); | |
| 34 | + | } | |
| 35 | + | let bucket = SystemTime::now() | |
| 36 | + | .duration_since(SystemTime::UNIX_EPOCH) | |
| 37 | + | .map(|d| d.as_secs() / 60) | |
| 38 | + | .unwrap_or(0) as usize; | |
| 39 | + | let idx = bucket % TIPS.len(); | |
| 40 | + | println!(); | |
| 41 | + | println!(" {}", TIPS[idx]); | |
| 42 | + | Ok(0) | |
| 43 | + | } |
| @@ -0,0 +1,263 @@ | |||
| 1 | + | use anyhow::{Context, Result}; | |
| 2 | + | use crossterm::event::{self, Event, KeyCode}; | |
| 3 | + | use ratatui::style::Style; | |
| 4 | + | use ratatui::text::{Line, Span}; | |
| 5 | + | use ratatui::widgets::Paragraph; | |
| 6 | + | use serde::Deserialize; | |
| 7 | + | use std::process::Command; | |
| 8 | + | use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; | |
| 9 | + | ||
| 10 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | + | pub enum Kind { | |
| 12 | + | Wired, | |
| 13 | + | Wireless, | |
| 14 | + | Loopback, | |
| 15 | + | Other, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 19 | + | pub enum State { | |
| 20 | + | Up, | |
| 21 | + | Down, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl State { | |
| 25 | + | fn span(&self) -> Span<'static> { | |
| 26 | + | match self { | |
| 27 | + | State::Up => style::ok("up"), | |
| 28 | + | State::Down => style::fault("down"), | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[derive(Debug, Clone)] | |
| 34 | + | pub struct Interface { | |
| 35 | + | pub name: String, | |
| 36 | + | pub kind: Kind, | |
| 37 | + | pub state: State, | |
| 38 | + | pub addresses: Vec<String>, | |
| 39 | + | pub ssid: Option<String>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl Interface { | |
| 43 | + | fn is_routable(&self) -> bool { | |
| 44 | + | matches!(self.kind, Kind::Wired | Kind::Wireless) && self.state == State::Up | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | pub trait Backend { | |
| 49 | + | fn list(&self) -> Result<Vec<Interface>>; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | fn detect() -> Box<dyn Backend> { | |
| 53 | + | if Iproute2.probe().is_ok() { | |
| 54 | + | Box::new(Iproute2) | |
| 55 | + | } else { | |
| 56 | + | Box::new(Mock) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | struct Iproute2; | |
| 61 | + | ||
| 62 | + | impl Iproute2 { | |
| 63 | + | fn probe(&self) -> Result<()> { | |
| 64 | + | let out = Command::new("ip") | |
| 65 | + | .arg("-V") | |
| 66 | + | .output() | |
| 67 | + | .context("invoke ip")?; | |
| 68 | + | if out.status.success() { | |
| 69 | + | Ok(()) | |
| 70 | + | } else { | |
| 71 | + | anyhow::bail!("ip unresponsive") | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[derive(Deserialize)] | |
| 77 | + | struct IpLink { | |
| 78 | + | ifname: String, | |
| 79 | + | #[serde(default)] | |
| 80 | + | flags: Vec<String>, | |
| 81 | + | #[serde(default)] | |
| 82 | + | addr_info: Vec<IpAddrEntry>, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | #[derive(Deserialize)] | |
| 86 | + | struct IpAddrEntry { | |
| 87 | + | local: String, | |
| 88 | + | prefixlen: u8, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | impl Backend for Iproute2 { | |
| 92 | + | fn list(&self) -> Result<Vec<Interface>> { | |
| 93 | + | let out = Command::new("ip") | |
| 94 | + | .args(["-json", "addr", "show"]) | |
| 95 | + | .output() | |
| 96 | + | .context("ip addr show")?; | |
| 97 | + | if !out.status.success() { | |
| 98 | + | anyhow::bail!("ip addr show failed"); | |
| 99 | + | } | |
| 100 | + | let links: Vec<IpLink> = | |
| 101 | + | serde_json::from_slice(&out.stdout).context("parse ip -json output")?; | |
| 102 | + | Ok(links | |
| 103 | + | .into_iter() | |
| 104 | + | .map(|link| { | |
| 105 | + | let kind = classify_iface(&link.ifname, &link.flags); | |
| 106 | + | let state = if link.flags.iter().any(|f| f == "UP") { | |
| 107 | + | State::Up | |
| 108 | + | } else { | |
| 109 | + | State::Down | |
| 110 | + | }; | |
| 111 | + | let addresses = link | |
| 112 | + | .addr_info | |
| 113 | + | .into_iter() | |
| 114 | + | .map(|a| format!("{}/{}", a.local, a.prefixlen)) | |
| 115 | + | .collect(); | |
| 116 | + | Interface { | |
| 117 | + | name: link.ifname, | |
| 118 | + | kind, | |
| 119 | + | state, | |
| 120 | + | addresses, | |
| 121 | + | ssid: None, | |
| 122 | + | } | |
| 123 | + | }) | |
| 124 | + | .collect()) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | fn classify_iface(name: &str, flags: &[String]) -> Kind { | |
| 129 | + | if flags.iter().any(|f| f == "LOOPBACK") { | |
| 130 | + | Kind::Loopback | |
| 131 | + | } else if name.starts_with("wl") { | |
| 132 | + | Kind::Wireless | |
| 133 | + | } else if name.starts_with("en") || name.starts_with("eth") { | |
| 134 | + | Kind::Wired | |
| 135 | + | } else { | |
| 136 | + | Kind::Other | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | struct Mock; | |
| 141 | + | ||
| 142 | + | impl Backend for Mock { | |
| 143 | + | fn list(&self) -> Result<Vec<Interface>> { | |
| 144 | + | Ok(vec![ | |
| 145 | + | Interface { | |
| 146 | + | name: "lo".into(), | |
| 147 | + | kind: Kind::Loopback, | |
| 148 | + | state: State::Up, | |
| 149 | + | addresses: vec!["127.0.0.1/8".into(), "::1/128".into()], | |
| 150 | + | ssid: None, | |
| 151 | + | }, | |
| 152 | + | Interface { | |
| 153 | + | name: "eth0".into(), | |
| 154 | + | kind: Kind::Wired, | |
| 155 | + | state: State::Up, | |
| 156 | + | addresses: vec!["192.168.1.50/24".into(), "fe80::a00:27ff:fe4e:66a1/64".into()], | |
| 157 | + | ssid: None, | |
| 158 | + | }, | |
| 159 | + | Interface { | |
| 160 | + | name: "wlan0".into(), | |
| 161 | + | kind: Kind::Wireless, | |
| 162 | + | state: State::Up, | |
| 163 | + | addresses: vec!["192.168.1.51/24".into()], | |
| 164 | + | ssid: Some("home-net".into()), | |
| 165 | + | }, | |
| 166 | + | ]) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | pub fn run(summary: bool) -> Result<i32> { | |
| 171 | + | let interfaces = detect().list()?; | |
| 172 | + | ||
| 173 | + | if summary { | |
| 174 | + | println!("{}", summary_line(&interfaces)); | |
| 175 | + | return Ok(0); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | let mut terminal = ratatui::init(); | |
| 179 | + | let result = event_loop(&mut terminal, &interfaces); | |
| 180 | + | ratatui::restore(); | |
| 181 | + | result.map(|_| 0) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | fn summary_line(interfaces: &[Interface]) -> String { | |
| 185 | + | let wired = interfaces.iter().find(|i| i.kind == Kind::Wired && i.is_routable()); | |
| 186 | + | let wireless = interfaces.iter().find(|i| i.kind == Kind::Wireless && i.is_routable()); | |
| 187 | + | match (wired, wireless) { | |
| 188 | + | (Some(w), _) => format!("{} ↑", w.name), | |
| 189 | + | (None, Some(w)) => match &w.ssid { | |
| 190 | + | Some(ssid) => format!("{} {}", w.name, ssid), | |
| 191 | + | None => format!("{} ↑", w.name), | |
| 192 | + | }, | |
| 193 | + | (None, None) => "net ↓".into(), | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | fn event_loop( | |
| 198 | + | terminal: &mut ratatui::DefaultTerminal, | |
| 199 | + | interfaces: &[Interface], | |
| 200 | + | ) -> Result<()> { | |
| 201 | + | let mut selected: usize = 0; | |
| 202 | + | let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]); | |
| 203 | + | loop { | |
| 204 | + | terminal.draw(|frame| { | |
| 205 | + | let [body, foot] = layout::split(frame.area()); | |
| 206 | + | frame.render_widget(list_widget(interfaces, selected), body); | |
| 207 | + | frame.render_widget(&footer, foot); | |
| 208 | + | })?; | |
| 209 | + | ||
| 210 | + | let Event::Key(key) = event::read()? else { | |
| 211 | + | continue; | |
| 212 | + | }; | |
| 213 | + | match classify(key) { | |
| 214 | + | GlobalAction::Quit => return Ok(()), | |
| 215 | + | GlobalAction::Passthrough => match key.code { | |
| 216 | + | KeyCode::Char('j') | KeyCode::Down if selected + 1 < interfaces.len() => { | |
| 217 | + | selected += 1; | |
| 218 | + | } | |
| 219 | + | KeyCode::Char('k') | KeyCode::Up => { | |
| 220 | + | selected = selected.saturating_sub(1); | |
| 221 | + | } | |
| 222 | + | _ => {} | |
| 223 | + | }, | |
| 224 | + | _ => {} | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | fn list_widget(interfaces: &[Interface], selected: usize) -> Paragraph<'_> { | |
| 230 | + | let name_w = interfaces.iter().map(|i| i.name.len()).max().unwrap_or(0).max(4); | |
| 231 | + | let lines: Vec<Line> = interfaces | |
| 232 | + | .iter() | |
| 233 | + | .enumerate() | |
| 234 | + | .map(|(i, iface)| { | |
| 235 | + | let is_sel = i == selected; | |
| 236 | + | let marker = if is_sel { selection::MARKER } else { " " }; | |
| 237 | + | let addrs = if iface.addresses.is_empty() { | |
| 238 | + | String::new() | |
| 239 | + | } else { | |
| 240 | + | iface.addresses.join(", ") | |
| 241 | + | }; | |
| 242 | + | let ssid_seg = match &iface.ssid { | |
| 243 | + | Some(s) => format!(" {s}"), | |
| 244 | + | None => String::new(), | |
| 245 | + | }; | |
| 246 | + | let line = Line::from(vec![ | |
| 247 | + | Span::raw(format!(" {marker} ")), | |
| 248 | + | Span::raw(format!("{:<name_w$}", iface.name)), | |
| 249 | + | Span::raw(" "), | |
| 250 | + | iface.state.span(), | |
| 251 | + | style::fg(ssid_seg), | |
| 252 | + | Span::raw(" "), | |
| 253 | + | style::dim(addrs), | |
| 254 | + | ]); | |
| 255 | + | if is_sel { | |
| 256 | + | line.style(selection::selected_style()) | |
| 257 | + | } else { | |
| 258 | + | line | |
| 259 | + | } | |
| 260 | + | }) | |
| 261 | + | .collect(); | |
| 262 | + | Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) | |
| 263 | + | } |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | use anyhow::{Context, Result}; | |
| 2 | + | use serde::Deserialize; | |
| 3 | + | ||
| 4 | + | const EMBEDDED: &str = include_str!("../data/roles.toml"); | |
| 5 | + | ||
| 6 | + | #[derive(Debug, Clone, Deserialize)] | |
| 7 | + | pub struct Role { | |
| 8 | + | pub name: String, | |
| 9 | + | pub description: String, | |
| 10 | + | pub tool: String, | |
| 11 | + | pub packages: Vec<String>, | |
| 12 | + | pub config_paths: Vec<String>, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | #[derive(Debug, Deserialize)] | |
| 16 | + | struct Registry { | |
| 17 | + | role: Vec<Role>, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | pub fn load() -> Result<Vec<Role>> { | |
| 21 | + | let reg: Registry = toml::from_str(EMBEDDED).context("parse embedded roles.toml")?; | |
| 22 | + | Ok(reg.role) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | pub fn find<'a>(roles: &'a [Role], name: &str) -> Option<&'a Role> { | |
| 26 | + | roles.iter().find(|r| r.name == name) | |
| 27 | + | } |