├── .build.yml ├── .editorconfig ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── doc ├── park.1.sdc └── park.5.sdc ├── misc └── example.png ├── rust-toolchain.toml ├── rustfmt.toml ├── src ├── cli.rs ├── config.rs ├── main.rs ├── parser │ ├── error.rs │ ├── iter.rs │ ├── mod.rs │ ├── node.rs │ └── tree.rs ├── printer.rs └── run.rs └── tests └── data └── something /.build.yml: -------------------------------------------------------------------------------- 1 | image: alpine/edge 2 | packages: 3 | - cargo 4 | - rust 5 | sources: 6 | - https://git.sr.ht/~gbrlsnchs/park 7 | tasks: 8 | - test: | 9 | cd park 10 | cargo test 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = tab 8 | tab_width = 4 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ansi_term" 7 | version = "0.12.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 10 | dependencies = [ 11 | "winapi", 12 | ] 13 | 14 | [[package]] 15 | name = "anyhow" 16 | version = "1.0.68" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" 19 | 20 | [[package]] 21 | name = "bitflags" 22 | version = "1.3.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 25 | 26 | [[package]] 27 | name = "cc" 28 | version = "1.0.78" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" 31 | 32 | [[package]] 33 | name = "clap" 34 | version = "4.0.29" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "4d63b9e9c07271b9957ad22c173bae2a4d9a81127680962039296abcd2f8251d" 37 | dependencies = [ 38 | "bitflags", 39 | "clap_derive", 40 | "clap_lex", 41 | "once_cell", 42 | "strsim", 43 | "terminal_size", 44 | ] 45 | 46 | [[package]] 47 | name = "clap_complete" 48 | version = "4.0.6" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "b7b3c9eae0de7bf8e3f904a5e40612b21fb2e2e566456d177809a48b892d24da" 51 | dependencies = [ 52 | "clap", 53 | ] 54 | 55 | [[package]] 56 | name = "clap_derive" 57 | version = "4.0.21" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "0177313f9f02afc995627906bbd8967e2be069f5261954222dac78290c2b9014" 60 | dependencies = [ 61 | "heck", 62 | "proc-macro-error", 63 | "proc-macro2", 64 | "quote", 65 | "syn", 66 | ] 67 | 68 | [[package]] 69 | name = "clap_lex" 70 | version = "0.3.0" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" 73 | dependencies = [ 74 | "os_str_bytes", 75 | ] 76 | 77 | [[package]] 78 | name = "ctor" 79 | version = "0.1.26" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" 82 | dependencies = [ 83 | "quote", 84 | "syn", 85 | ] 86 | 87 | [[package]] 88 | name = "diff" 89 | version = "0.1.13" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 92 | 93 | [[package]] 94 | name = "errno" 95 | version = "0.2.8" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" 98 | dependencies = [ 99 | "errno-dragonfly", 100 | "libc", 101 | "winapi", 102 | ] 103 | 104 | [[package]] 105 | name = "errno-dragonfly" 106 | version = "0.1.2" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 109 | dependencies = [ 110 | "cc", 111 | "libc", 112 | ] 113 | 114 | [[package]] 115 | name = "heck" 116 | version = "0.4.0" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" 119 | 120 | [[package]] 121 | name = "indoc" 122 | version = "1.0.3" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "e5a75aeaaef0ce18b58056d306c27b07436fbb34b8816c53094b76dd81803136" 125 | dependencies = [ 126 | "unindent", 127 | ] 128 | 129 | [[package]] 130 | name = "io-lifetimes" 131 | version = "1.0.3" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "46112a93252b123d31a119a8d1a1ac19deac4fac6e0e8b0df58f0d4e5870e63c" 134 | dependencies = [ 135 | "libc", 136 | "windows-sys", 137 | ] 138 | 139 | [[package]] 140 | name = "lazy_static" 141 | version = "1.4.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 144 | 145 | [[package]] 146 | name = "libc" 147 | version = "0.2.138" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8" 150 | 151 | [[package]] 152 | name = "linux-raw-sys" 153 | version = "0.1.4" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" 156 | 157 | [[package]] 158 | name = "once_cell" 159 | version = "1.16.0" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" 162 | 163 | [[package]] 164 | name = "os_str_bytes" 165 | version = "6.4.1" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" 168 | 169 | [[package]] 170 | name = "output_vt100" 171 | version = "0.1.3" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" 174 | dependencies = [ 175 | "winapi", 176 | ] 177 | 178 | [[package]] 179 | name = "park" 180 | version = "1.1.0" 181 | dependencies = [ 182 | "ansi_term", 183 | "anyhow", 184 | "clap", 185 | "clap_complete", 186 | "indoc", 187 | "pretty_assertions", 188 | "serde", 189 | "tabwriter", 190 | "thiserror", 191 | "toml", 192 | ] 193 | 194 | [[package]] 195 | name = "pretty_assertions" 196 | version = "0.7.2" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "1cab0e7c02cf376875e9335e0ba1da535775beb5450d21e1dffca068818ed98b" 199 | dependencies = [ 200 | "ansi_term", 201 | "ctor", 202 | "diff", 203 | "output_vt100", 204 | ] 205 | 206 | [[package]] 207 | name = "proc-macro-error" 208 | version = "1.0.4" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 211 | dependencies = [ 212 | "proc-macro-error-attr", 213 | "proc-macro2", 214 | "quote", 215 | "syn", 216 | "version_check", 217 | ] 218 | 219 | [[package]] 220 | name = "proc-macro-error-attr" 221 | version = "1.0.4" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 224 | dependencies = [ 225 | "proc-macro2", 226 | "quote", 227 | "version_check", 228 | ] 229 | 230 | [[package]] 231 | name = "proc-macro2" 232 | version = "1.0.47" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" 235 | dependencies = [ 236 | "unicode-ident", 237 | ] 238 | 239 | [[package]] 240 | name = "quote" 241 | version = "1.0.21" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" 244 | dependencies = [ 245 | "proc-macro2", 246 | ] 247 | 248 | [[package]] 249 | name = "regex" 250 | version = "1.7.0" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" 253 | dependencies = [ 254 | "regex-syntax", 255 | ] 256 | 257 | [[package]] 258 | name = "regex-syntax" 259 | version = "0.6.28" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" 262 | 263 | [[package]] 264 | name = "rustix" 265 | version = "0.36.5" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "a3807b5d10909833d3e9acd1eb5fb988f79376ff10fce42937de71a449c4c588" 268 | dependencies = [ 269 | "bitflags", 270 | "errno", 271 | "io-lifetimes", 272 | "libc", 273 | "linux-raw-sys", 274 | "windows-sys", 275 | ] 276 | 277 | [[package]] 278 | name = "serde" 279 | version = "1.0.133" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a" 282 | dependencies = [ 283 | "serde_derive", 284 | ] 285 | 286 | [[package]] 287 | name = "serde_derive" 288 | version = "1.0.133" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537" 291 | dependencies = [ 292 | "proc-macro2", 293 | "quote", 294 | "syn", 295 | ] 296 | 297 | [[package]] 298 | name = "strsim" 299 | version = "0.10.0" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 302 | 303 | [[package]] 304 | name = "syn" 305 | version = "1.0.105" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "60b9b43d45702de4c839cb9b51d9f529c5dd26a4aff255b42b1ebc03e88ee908" 308 | dependencies = [ 309 | "proc-macro2", 310 | "quote", 311 | "unicode-ident", 312 | ] 313 | 314 | [[package]] 315 | name = "tabwriter" 316 | version = "1.2.1" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "36205cfc997faadcc4b0b87aaef3fbedafe20d38d4959a7ca6ff803564051111" 319 | dependencies = [ 320 | "lazy_static", 321 | "regex", 322 | "unicode-width", 323 | ] 324 | 325 | [[package]] 326 | name = "terminal_size" 327 | version = "0.2.3" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "cb20089a8ba2b69debd491f8d2d023761cbf196e999218c591fa1e7e15a21907" 330 | dependencies = [ 331 | "rustix", 332 | "windows-sys", 333 | ] 334 | 335 | [[package]] 336 | name = "thiserror" 337 | version = "1.0.26" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" 340 | dependencies = [ 341 | "thiserror-impl", 342 | ] 343 | 344 | [[package]] 345 | name = "thiserror-impl" 346 | version = "1.0.26" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" 349 | dependencies = [ 350 | "proc-macro2", 351 | "quote", 352 | "syn", 353 | ] 354 | 355 | [[package]] 356 | name = "toml" 357 | version = "0.5.8" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 360 | dependencies = [ 361 | "serde", 362 | ] 363 | 364 | [[package]] 365 | name = "unicode-ident" 366 | version = "1.0.5" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" 369 | 370 | [[package]] 371 | name = "unicode-width" 372 | version = "0.1.10" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" 375 | 376 | [[package]] 377 | name = "unindent" 378 | version = "0.1.10" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "58ee9362deb4a96cef4d437d1ad49cffc9b9e92d202b6995674e928ce684f112" 381 | 382 | [[package]] 383 | name = "version_check" 384 | version = "0.9.4" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 387 | 388 | [[package]] 389 | name = "winapi" 390 | version = "0.3.9" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 393 | dependencies = [ 394 | "winapi-i686-pc-windows-gnu", 395 | "winapi-x86_64-pc-windows-gnu", 396 | ] 397 | 398 | [[package]] 399 | name = "winapi-i686-pc-windows-gnu" 400 | version = "0.4.0" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 403 | 404 | [[package]] 405 | name = "winapi-x86_64-pc-windows-gnu" 406 | version = "0.4.0" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 409 | 410 | [[package]] 411 | name = "windows-sys" 412 | version = "0.42.0" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" 415 | dependencies = [ 416 | "windows_aarch64_gnullvm", 417 | "windows_aarch64_msvc", 418 | "windows_i686_gnu", 419 | "windows_i686_msvc", 420 | "windows_x86_64_gnu", 421 | "windows_x86_64_gnullvm", 422 | "windows_x86_64_msvc", 423 | ] 424 | 425 | [[package]] 426 | name = "windows_aarch64_gnullvm" 427 | version = "0.42.0" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" 430 | 431 | [[package]] 432 | name = "windows_aarch64_msvc" 433 | version = "0.42.0" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" 436 | 437 | [[package]] 438 | name = "windows_i686_gnu" 439 | version = "0.42.0" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" 442 | 443 | [[package]] 444 | name = "windows_i686_msvc" 445 | version = "0.42.0" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" 448 | 449 | [[package]] 450 | name = "windows_x86_64_gnu" 451 | version = "0.42.0" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" 454 | 455 | [[package]] 456 | name = "windows_x86_64_gnullvm" 457 | version = "0.42.0" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" 460 | 461 | [[package]] 462 | name = "windows_x86_64_msvc" 463 | version = "0.42.0" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" 466 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "park" 3 | version = "1.1.0" 4 | description = "Configuration-based dotfiles manager" 5 | edition = "2021" 6 | publish = false 7 | 8 | [dependencies] 9 | ansi_term = "=0.12.1" 10 | anyhow = "=1.0.68" 11 | serde = { version = "=1.0.133", features = ["derive"] } 12 | tabwriter = { version = "=1.2.1", features = ["ansi_formatting"] } 13 | thiserror = "=1.0.26" 14 | toml = "=0.5.8" 15 | 16 | [dependencies.clap] 17 | version = "=4.0.29" 18 | default-features = false 19 | features = [ 20 | "derive", 21 | "help", 22 | "std", 23 | "suggestions", 24 | "usage", 25 | "wrap_help", 26 | ] 27 | 28 | [dev-dependencies] 29 | indoc = "=1.0.3" 30 | pretty_assertions = "=0.7.2" 31 | 32 | [build-dependencies] 33 | clap_complete = "=4.0.6" 34 | 35 | [build-dependencies.clap] 36 | version = "=4.0.29" 37 | default-features = false 38 | features = ["derive"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Gabriel Sanches 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 4 | associated documentation files (the "Software"), to deal in the Software without restriction, 5 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 6 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 7 | furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial 10 | portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 13 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 14 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 15 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## park 2 | A configuration-based dotfiles manager able to organize dotfiles by symlinking them according 3 | to a configuration file written in TOML. 4 | 5 | [![builds.sr.ht status](https://builds.sr.ht/~gbrlsnchs/park/commits/trunk/tests.yml.svg)](https://builds.sr.ht/~gbrlsnchs/park/commits/trunk/tests.yml?) 6 | 7 | ![park tree preview](./misc/example.png) 8 | 9 | ### Usage 10 | See `park(1)` and `park(5)`. 11 | 12 | ### Contributing 13 | [Use the mailing list](mailto:~gbrlsnchs/park-dev@lists.sr.ht) to 14 | - Report issues 15 | - Request new features 16 | - Send patches 17 | - Discuss development in general 18 | 19 | If applicable, a new ticket will be submitted by maintainers to [the issue 20 | tracker](https://todo.sr.ht/~gbrlsnchs/park) in order to track confirmed bugs or new features. 21 | 22 | ### Building and distributing the project 23 | This project is built entirely in Rust. Build it as you wish for local usage, and package it 24 | to your distro of preference in accordance with its policy on how to package Rust projects. 25 | 26 | > **_NOTE_:** `cargo build` generates shell completions for Bash, ZSH and Fish, which 27 | > are available at `target/completions`, and manpages at `target/doc` (only when 28 | > [`scdoc`](https://git.sr.ht/~sircmpwn/scdoc) is available). 29 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | io::{Error, Write}, 4 | path::{Path, PathBuf}, 5 | process::{Command, Stdio}, 6 | }; 7 | 8 | use crate::cli::Park; 9 | 10 | use clap::CommandFactory; 11 | use clap_complete::{self, Shell}; 12 | 13 | #[path = "src/cli.rs"] 14 | mod cli; 15 | 16 | fn main() -> Result<(), Error> { 17 | println!("cargo:rerun-if-changed=doc"); 18 | 19 | let target_dir = PathBuf::from("target"); 20 | 21 | let completions_dir = target_dir.join("completions"); 22 | fs::create_dir_all(&completions_dir)?; 23 | 24 | let mut app = Park::command(); 25 | let app_name = app.get_name().to_string(); 26 | 27 | for shell in &[Shell::Bash, Shell::Zsh, Shell::Fish] { 28 | clap_complete::generate_to(*shell, &mut app, &app_name, &completions_dir)?; 29 | } 30 | 31 | if Command::new("scdoc").spawn().is_err() { 32 | eprintln!("scdoc not found in PATH, skipping generating manpage templates from doc/"); 33 | 34 | return Ok(()); 35 | } 36 | 37 | build_manpages(&target_dir)?; 38 | 39 | Ok(()) 40 | } 41 | 42 | fn build_manpages(target_dir: &Path) -> Result<(), Error> { 43 | let doc_dir = target_dir.join("doc"); 44 | fs::create_dir_all(&doc_dir)?; 45 | 46 | for doc in fs::read_dir("doc")? { 47 | let doc = doc?; 48 | 49 | let cmd = Command::new("scdoc") 50 | .stdin(Stdio::piped()) 51 | .stdout(Stdio::piped()) 52 | .spawn(); 53 | 54 | let mut cmd = cmd?; 55 | 56 | if let Some(mut stdin) = cmd.stdin.take() { 57 | let doc = fs::read(doc.path())?; 58 | stdin.write_all(&doc)?; 59 | } 60 | 61 | let output = cmd.wait_with_output()?; 62 | let doc = PathBuf::from(doc.file_name()); 63 | let doc = doc.file_stem().unwrap(); 64 | 65 | fs::write(doc_dir.join(doc), output.stdout)?; 66 | } 67 | 68 | Ok(()) 69 | } 70 | -------------------------------------------------------------------------------- /doc/park.1.sdc: -------------------------------------------------------------------------------- 1 | park(1) 2 | 3 | # NAME 4 | 5 | park - Configuration-based dotfiles manager 6 | 7 | # SYNOPSIS 8 | 9 | *park* [_OPTIONS_] [_TAGS_|_TARGET FILTERS_] < _input_ 10 | 11 | # DESCRIPTION 12 | 13 | *park* is a CLI tool for managing dotfiles based on a configuration file 14 | written in TOML. It does so by symlinking your local dotfiles to the respective 15 | places described in the configuration file. 16 | 17 | By default, it won't do anything but print a preview tree of how your dotfiles 18 | will look like according to the given configuration passed via _stdin_. 19 | 20 | If everything in the preview tree looks good to you, then it's just a matter 21 | of running the command again but this time with the appropriate flag: 22 | 23 | *park --link < input* 24 | 25 | Note that, by default, *park* only successfully executes the linking step 26 | if no problems are detected during the analysis step (the one that generates 27 | the preview tree). Some statuses can be worked around by passing additional 28 | flags, while others are not avoidable and require manual intervention in 29 | the host system for *park* to work. See more details in the _OPTIONS_ section. 30 | 31 | # OPTIONS 32 | 33 | *-l*, *--link* 34 | Execute the linking step. 35 | 36 | If any problems are detected during analysis, the linking step will 37 | be aborted and all problematic files will be listed. 38 | 39 | *-r*, *--replace* 40 | Replace mismatched symlinks. 41 | 42 | This allows bypassing the _MISMATCH_ status by forcing the existing 43 | symlink to be replaced. 44 | 45 | *-c*, *--create-dirs* 46 | Create parent directories when needed. 47 | 48 | This will prevent links with status _UNPARENTED_ to return an error 49 | during the linking step by creating all necessary directories that 50 | compose the symlink's path. 51 | 52 | *-h*, *--help* 53 | Show help usage. 54 | 55 | Note that -h shows a short help, while --help shows a long one. 56 | 57 | *-v*, *--version* 58 | Show version. 59 | 60 | # TAGS 61 | 62 | Targets can be guarded by tags. Such targets are not evaluated unless their 63 | respective tags are passed as arguments. These tags need to be prepended 64 | with a plus sign: 65 | 66 | *park +tag1 +tag2* < input 67 | 68 | Note that tags do not deactivate targets. Their sole purpose is to activate 69 | targets on demand. 70 | 71 | The resulting set of tags that *park* uses is a union of tags passed as 72 | arguments with tags set in the configuration file. 73 | 74 | # TARGET FILTERS 75 | 76 | When arguments don't have a plus sign prepended to them, they serve as 77 | target filters. When one or more filters are passed as arguments, *park* 78 | only evaluates targets whose names match such filters: 79 | 80 | *park +tag1 target1 target2* < input 81 | 82 | Note that target filters can be mixed with tags. 83 | 84 | # TARGET STATUSES 85 | 86 | ## READY 87 | The target file is ready to be symlinked 88 | 89 | ## DONE 90 | The target is already symlinked accordingly. 91 | 92 | ## UNPARENTED 93 | The target file is ready to be symlinked but its parent directory will be 94 | created by *park* during linking. 95 | 96 | ## MISMATCH 97 | A symlink exists, but it points to a different target file. 98 | 99 | ## CONFLICT 100 | Another file already exists where the symlink would be created. 101 | 102 | ## OBSTRUCTED 103 | The parent path of the symlink is not a directory. 104 | 105 | # SEE ALSO 106 | 107 | _park_(5) 108 | 109 | # AUTHORS 110 | 111 | Developed and maintained by Gabriel Sanches . 112 | 113 | Source code is located at . 114 | -------------------------------------------------------------------------------- /doc/park.5.sdc: -------------------------------------------------------------------------------- 1 | park(5) 2 | 3 | # NAME 4 | 5 | park - configuration schema 6 | 7 | # DESCRIPTION 8 | 9 | *park* reads TOML files from _stdin_ in order to decide how to organize 10 | your dotfiles. 11 | 12 | Refer to for further details about TOML. 13 | 14 | # CONFIGURATION SCHEMA 15 | 16 | The following fields are top-level fields. 17 | 18 | [- *Name* 19 | :- *Type* 20 | :- *Description* 21 | :- *Default* 22 | | *base_dir* 23 | : string 24 | : The path to be used as base directory for symlinks. 25 | : _Empty string_, which means symlinks will end up in the current working 26 | directory. 27 | | *work_dir* 28 | : string 29 | : The path to be used as working directory for symlinks. 30 | : The _current working directory_ is used. 31 | | *tags* 32 | : string array 33 | : List of tags that will be used to evaluate targets. These tags complement 34 | the ones passed as arguments to *park*. 35 | : _Empty array_, which means only tags passed arguments will be considered. 36 | | *targets* 37 | : _target_ table 38 | : Targets to be evaluated and symlinked by *park*. See the _target_ section 39 | for more details. 40 | : _Empty table_, which means there's nothing for *park* to do. 41 | 42 | ## target 43 | 44 | [- *Name* 45 | :- *Type* 46 | :- *Description* 47 | :- *Default* 48 | | *link* 49 | : _link_ table 50 | : Information about the respective symlink file. See the _link_ section 51 | for more details. 52 | : _Empty table_, uses the defaults from _link_. 53 | | *tags* 54 | : _tags_ table 55 | : List of conjuctive and disjunctive tags that guard the target. See the 56 | _tags_ section for more details. 57 | : _Empty table_, uses the defaults from _tags_. 58 | 59 | ## link 60 | 61 | [- *Name* 62 | :- *Type* 63 | :- *Description* 64 | :- *Default* 65 | | *base_dir* 66 | : string 67 | : Base directory for the link in particular. 68 | : _Empty string_, uses the top-level base directory. 69 | | *name* 70 | : string 71 | : The name of the resulting symlink. 72 | : _Empty string_, uses the target name as the symlink name. 73 | 74 | ## tags 75 | [- *Name* 76 | :- *Type* 77 | :- *Description* 78 | :- *Default* 79 | | *all_of* 80 | : string array 81 | : List of conjunctive tags that guard the target, that is, all tags listed 82 | must be passed to *park* for the target to be considered. 83 | : _Empty array_, which means no conjunctive tags guard the target. 84 | | *any_of* 85 | : string array 86 | : List of disjunctive tags that guard the target, that is, at least one of 87 | the tags listed must be passed to *park* for the target to be considered. 88 | : _Empty array_, which means no disjunctive tags guard the target. 89 | 90 | # SEE ALSO 91 | 92 | _park_(1) 93 | 94 | # AUTHORS 95 | 96 | Developed and maintained by Gabriel Sanches . 97 | 98 | Source code is located at . 99 | -------------------------------------------------------------------------------- /misc/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbrlsnchs/park/c0da832774c74c39b7f4c7cd051acd446298dca3/misc/example.png -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgAction, Parser}; 2 | 3 | /// park is a CLI tool for managing dotfiles based on a TOML file. 4 | /// 5 | /// See park(1) for more details about usage, and park(5) for how to use a 6 | /// configuration file with it. 7 | #[derive(Default, Parser)] 8 | #[command( 9 | about, 10 | long_about, 11 | version, 12 | max_term_width = 80, 13 | disable_help_flag = true, 14 | disable_version_flag = true 15 | )] 16 | pub struct Park { 17 | /// Execute the linking step. 18 | /// 19 | /// If any problems are detected during analysis, the linking step will be aborted and 20 | /// all problematic files will be listed. 21 | #[arg(long, short)] 22 | pub link: bool, 23 | 24 | /// Replace mismatched symlinks. 25 | /// 26 | /// This allows bypassing the MISMATCH status by forcing the existing symlink to be 27 | /// replaced. 28 | #[arg(long, short)] 29 | pub replace: bool, 30 | 31 | /// Create parent directories when needed. 32 | /// 33 | /// This will prevent links with status UNPARENTED to return an error during the linking 34 | /// step by creating all necessary directories that compose the symlink's path. 35 | #[arg(long, short)] 36 | pub create_dirs: bool, 37 | 38 | /// Show help usage. 39 | /// 40 | /// Use -h to show the short help, or --help to show the long one (or even better, 41 | /// read the man pages). 42 | #[arg(long, short, action = ArgAction::Help)] 43 | pub help: Option, 44 | 45 | /// Show version. 46 | /// 47 | /// The version format is 'park '. Use it wisely. 48 | #[arg(long, short, action = ArgAction::Version)] 49 | pub version: Option, 50 | 51 | /// List of tags (appended with a plus sign) or target names (for filtering purposes). 52 | #[arg()] 53 | pub filters: Vec, 54 | } 55 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeMap, HashSet}, 3 | path::PathBuf, 4 | }; 5 | 6 | use serde::Deserialize; 7 | 8 | pub type TargetMap = BTreeMap; 9 | pub type TagSet = HashSet; 10 | 11 | #[derive(Debug, Default, Deserialize, PartialEq)] 12 | /// The main configuration for Park. 13 | pub struct Config { 14 | pub tags: Option, 15 | pub base_dir: Option, 16 | pub work_dir: Option, 17 | pub targets: Option, 18 | } 19 | 20 | #[derive(Debug, Default, Deserialize, PartialEq)] 21 | /// Represents configuration for a dotfile. 22 | pub struct Target { 23 | /// Link options of a dotfile. 24 | pub link: Option, 25 | /// Tags under which a dotfile should be managed. 26 | pub tags: Option, 27 | } 28 | 29 | #[derive(Debug, Default, Deserialize, PartialEq)] 30 | /// Configuration for constraints that toggle certain dotfiles on and off. 31 | pub struct Tags { 32 | /// These tags are evaluated conjunctively. 33 | pub all_of: Option, 34 | /// These tags are evaluated disjunctively. 35 | pub any_of: Option, 36 | } 37 | 38 | #[derive(Debug, Default, Deserialize, PartialEq)] 39 | /// Configuration for the symlink of dotfiles. 40 | pub struct Link { 41 | /// The place where the symlink gets created in. 42 | pub base_dir: Option, 43 | /// Filename for the symlink. 44 | pub name: Option, 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use indoc::indoc; 50 | use pretty_assertions::assert_eq; 51 | 52 | use super::*; 53 | 54 | #[test] 55 | fn deserialize_empty_config() { 56 | let got: Config = toml::from_str("").unwrap(); 57 | 58 | assert_eq!(got, Config::default()); 59 | } 60 | 61 | #[test] 62 | fn deserialize_config_without_targets() { 63 | let got: Config = toml::from_str(indoc! {r#" 64 | tags = ["foo", "bar"] 65 | base_dir = "test" 66 | "#}) 67 | .unwrap(); 68 | 69 | assert_eq!( 70 | got, 71 | Config { 72 | tags: Some(TagSet::from(["foo".into(), "bar".into(),])), 73 | base_dir: Some("test".into()), 74 | work_dir: None, 75 | targets: None, 76 | } 77 | ); 78 | } 79 | 80 | #[test] 81 | fn deserialize_config_with_empty_targets() { 82 | let got: Config = toml::from_str(indoc! {r#" 83 | tags = ["foo", "bar"] 84 | base_dir = "test" 85 | work_dir = "somewhere" 86 | targets = {} 87 | "#}) 88 | .unwrap(); 89 | 90 | assert_eq!( 91 | got, 92 | Config { 93 | tags: Some(TagSet::from(["foo".into(), "bar".into()])), 94 | base_dir: Some("test".into()), 95 | work_dir: Some("somewhere".into()), 96 | targets: Some(TargetMap::new()), 97 | } 98 | ); 99 | } 100 | 101 | #[test] 102 | fn deserialize_config_with_default_targets() { 103 | let got: Config = toml::from_str(indoc! {r#" 104 | tags = ["foo", "bar"] 105 | base_dir = "test" 106 | 107 | [targets.baz] 108 | 109 | [targets.qux] 110 | "#}) 111 | .unwrap(); 112 | 113 | assert_eq!( 114 | got, 115 | Config { 116 | tags: Some(TagSet::from(["foo".into(), "bar".into(),])), 117 | base_dir: Some("test".into()), 118 | work_dir: None, 119 | targets: Some(TargetMap::from([ 120 | ( 121 | "baz".into(), 122 | Target { 123 | link: None, 124 | tags: None, 125 | }, 126 | ), 127 | ( 128 | "qux".into(), 129 | Target { 130 | link: None, 131 | tags: None, 132 | }, 133 | ), 134 | ])), 135 | } 136 | ); 137 | } 138 | 139 | #[test] 140 | fn deserialize_config_with_custom_targets() { 141 | let got: Config = toml::from_str(indoc! {r#" 142 | tags = ["foo", "bar"] 143 | base_dir = "test" 144 | 145 | [targets.baz] 146 | link.name = "BAZ" 147 | tags.all_of = ["baz"] 148 | 149 | [targets.qux] 150 | link.base_dir = "elsewhere" 151 | tags.any_of = ["qux"] 152 | "#}) 153 | .unwrap(); 154 | 155 | assert_eq!( 156 | got, 157 | Config { 158 | tags: Some(TagSet::from(["foo".into(), "bar".into()])), 159 | base_dir: Some("test".into()), 160 | work_dir: None, 161 | targets: Some(TargetMap::from([ 162 | ( 163 | "baz".into(), 164 | Target { 165 | link: Some(Link { 166 | name: Some("BAZ".into()), 167 | base_dir: None, 168 | }), 169 | tags: Some(Tags { 170 | all_of: Some(TagSet::from(["baz".into()])), 171 | any_of: None, 172 | }), 173 | }, 174 | ), 175 | ( 176 | "qux".into(), 177 | Target { 178 | link: Some(Link { 179 | name: None, 180 | base_dir: Some("elsewhere".into()), 181 | }), 182 | tags: Some(Tags { 183 | all_of: None, 184 | any_of: Some(TagSet::from(["qux".into()])), 185 | }), 186 | }, 187 | ), 188 | ])), 189 | } 190 | ); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | io::{self, Read}, 4 | }; 5 | 6 | use cli::Park; 7 | 8 | use anyhow::Result; 9 | use clap::Parser; 10 | use run::Env; 11 | 12 | mod cli; 13 | mod config; 14 | mod parser; 15 | mod printer; 16 | mod run; 17 | 18 | // TODO: Test CLI interactions. 19 | fn main() -> Result<()> { 20 | let args = Park::parse(); 21 | 22 | let mut input = String::new(); 23 | 24 | let stdin = io::stdin(); 25 | let mut handle = stdin.lock(); 26 | handle.read_to_string(&mut input)?; 27 | 28 | let stdout = io::stdout(); 29 | let handle = stdout.lock(); 30 | 31 | run::run( 32 | Env { 33 | colored: env::var_os("NO_COLOR").is_none(), 34 | home: env::var_os("HOME"), 35 | }, 36 | &input, 37 | handle, 38 | args, 39 | )?; 40 | 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /src/parser/error.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error as StdError, 3 | fmt::{Display, Formatter}, 4 | io::{Error as IoError, ErrorKind as IoErrorKind}, 5 | path::PathBuf, 6 | }; 7 | 8 | use super::tree::Problems; 9 | 10 | #[derive(Debug, PartialEq)] 11 | pub enum Error { 12 | InternalError(PathBuf), 13 | IoError(IoErrorKind), 14 | BadFiles(Problems), 15 | } 16 | 17 | impl Display for Error { 18 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 19 | match self { 20 | Self::InternalError(link_path) => { 21 | write!(f, "there's an error associated with {:?}", link_path) 22 | } 23 | Self::IoError(io_err) => IoError::new(*io_err, "unexpected IO error").fmt(f), 24 | Self::BadFiles(problems) => { 25 | let len = problems.len(); 26 | 27 | writeln!(f, "found {} problematic target(s):", len)?; 28 | 29 | for (idx, (path, status)) in problems.iter().enumerate() { 30 | write!(f, "\t- {:?} at {:?}", status, path)?; 31 | 32 | if idx != len - 1 { 33 | writeln!(f)?; 34 | } 35 | } 36 | 37 | Ok(()) 38 | } 39 | } 40 | } 41 | } 42 | 43 | impl StdError for Error {} 44 | -------------------------------------------------------------------------------- /src/parser/iter.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use super::node::Node; 4 | 5 | /// Some metadata for a node inside a tree. 6 | #[derive(Debug, PartialEq)] 7 | pub struct NodeMetadata { 8 | /// Level of the node. Root is at level 0. 9 | pub level: usize, 10 | /// Whether the node is the last of its siblings. 11 | pub last_sibling: bool, 12 | } 13 | 14 | /// Iteration element. Holds all relevant data from a node. 15 | #[derive(Debug, PartialEq)] 16 | pub struct Element { 17 | pub metadata: NodeMetadata, 18 | pub target_path: PathBuf, 19 | pub link_path: Option, 20 | } 21 | 22 | /// Iterator that visits nodes using preorder traversal. 23 | pub struct Iter<'a> { 24 | stack: Vec>, 25 | path_stack: Vec<&'a Path>, 26 | } 27 | 28 | impl<'a> From<&'a Node> for Iter<'a> { 29 | fn from(root: &'a Node) -> Self { 30 | Iter { 31 | stack: vec![State { 32 | node: root, 33 | segment: None, 34 | metadata: NodeMetadata { 35 | level: 0, // root is always at level 0 36 | last_sibling: false, // doesn't really matter 37 | }, 38 | }], 39 | path_stack: Vec::new(), 40 | } 41 | } 42 | } 43 | 44 | impl<'a> Iterator for Iter<'a> { 45 | type Item = Element; 46 | 47 | fn next(&mut self) -> Option { 48 | let State { 49 | metadata: info, 50 | segment, 51 | node, 52 | } = self.stack.pop()?; 53 | 54 | while info.level > 0 && info.level <= self.path_stack.len() { 55 | self.path_stack.pop(); 56 | } 57 | 58 | if let Some(segment) = segment { 59 | self.path_stack.push(segment); 60 | } 61 | 62 | if let Some(children) = node.get_children() { 63 | for (idx, (segment, child)) in children.iter().rev().enumerate() { 64 | self.stack.push(State { 65 | metadata: NodeMetadata { 66 | level: info.level + 1, 67 | last_sibling: idx == 0, 68 | }, 69 | segment: Some(segment), 70 | node: child, 71 | }); 72 | } 73 | } 74 | 75 | Some(Element { 76 | metadata: info, 77 | target_path: self.path_stack.iter().collect(), 78 | link_path: node.get_link_path().map(PathBuf::from), 79 | }) 80 | } 81 | } 82 | 83 | /// Iteration state. 84 | struct State<'a> { 85 | metadata: NodeMetadata, 86 | segment: Option<&'a Path>, 87 | node: &'a Node, 88 | } 89 | 90 | #[cfg(test)] 91 | mod tests { 92 | use pretty_assertions::assert_eq; 93 | 94 | use crate::parser::node::Edges; 95 | 96 | use super::*; 97 | 98 | #[test] 99 | fn iterate_in_correct_order() { 100 | let root = Node::Branch(Edges::from([ 101 | ( 102 | "baz".into(), 103 | Node::Branch(Edges::from([("qux".into(), Node::Leaf("test/qux".into()))])), 104 | ), 105 | ( 106 | "foo".into(), 107 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("test/bar".into()))])), 108 | ), 109 | ("test".into(), Node::Leaf("something/else".into())), 110 | ])); 111 | let mut iter = Iter { 112 | stack: Vec::from([State { 113 | node: &root, 114 | segment: None, 115 | metadata: NodeMetadata { 116 | level: 0, 117 | last_sibling: false, 118 | }, 119 | }]), 120 | path_stack: Vec::new(), 121 | }; 122 | 123 | assert_eq!( 124 | iter.next(), 125 | Some(Element { 126 | metadata: NodeMetadata { 127 | level: 0, 128 | last_sibling: false 129 | }, 130 | target_path: "".into(), 131 | link_path: None, 132 | }), 133 | ); 134 | assert_eq!( 135 | iter.next(), 136 | Some(Element { 137 | metadata: NodeMetadata { 138 | level: 1, 139 | last_sibling: false 140 | }, 141 | target_path: "baz".into(), 142 | link_path: None, 143 | }), 144 | ); 145 | assert_eq!( 146 | iter.next(), 147 | Some(Element { 148 | metadata: NodeMetadata { 149 | level: 2, 150 | last_sibling: true 151 | }, 152 | target_path: "baz/qux".into(), 153 | link_path: Some("test/qux".into()), 154 | }), 155 | ); 156 | assert_eq!( 157 | iter.next(), 158 | Some(Element { 159 | metadata: NodeMetadata { 160 | level: 1, 161 | last_sibling: false 162 | }, 163 | target_path: "foo".into(), 164 | link_path: None, 165 | }), 166 | ); 167 | assert_eq!( 168 | iter.next(), 169 | Some(Element { 170 | metadata: NodeMetadata { 171 | level: 2, 172 | last_sibling: true 173 | }, 174 | target_path: "foo/bar".into(), 175 | link_path: Some("test/bar".into()), 176 | }), 177 | ); 178 | assert_eq!( 179 | iter.next(), 180 | Some(Element { 181 | metadata: NodeMetadata { 182 | level: 1, 183 | last_sibling: true 184 | }, 185 | target_path: "test".into(), 186 | link_path: Some("something/else".into()), 187 | }), 188 | ); 189 | assert_eq!(iter.next(), None); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/parser/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | pub mod iter; 3 | pub mod node; 4 | pub mod tree; 5 | -------------------------------------------------------------------------------- /src/parser/node.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsStr, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use thiserror::Error; 7 | 8 | use super::iter::{Element, Iter}; 9 | 10 | /// Possible states a link node can be in. 11 | #[derive(Clone, Debug, PartialEq)] 12 | pub enum Status { 13 | /// Unknown state, probably because the node wasn't analyzed. 14 | Unknown, 15 | /// The target can by symlinked without any conflicts. 16 | Ready, 17 | /// The target is already symlinked accordingly. 18 | Done, 19 | /// Link exists, but points to a different target. 20 | Mismatch, 21 | /// Target can be created but the parent directory will need to be created as well. 22 | Unparented, 23 | /// Another file already exists in the link path. 24 | Conflict, 25 | /// The file supposed to serve as the link directory is not a directory. 26 | Obstructed, 27 | } 28 | 29 | #[derive(Debug, Error, PartialEq)] 30 | pub enum Error { 31 | #[error("node for link {1:?} at segment {0:?} cannot be inserted because it is not a branch")] 32 | NotABranch(PathBuf, PathBuf), 33 | #[error("node for link {1:?} at segment {0:?} already exists as a leaf")] 34 | LeafExists(PathBuf, PathBuf), 35 | #[error("cannot add empty link path")] 36 | EmptySegment, 37 | } 38 | 39 | /// A vector of edges. 40 | pub type Edges = Vec; 41 | 42 | /// An edge holds both its path and its respective node. 43 | pub type Edge = (PathBuf, Node); 44 | 45 | /// Node for a recursive tree that holds symlink paths. It is either a branch or a leaf. 46 | #[derive(Debug, PartialEq)] 47 | pub enum Node { 48 | Branch(Edges), 49 | Leaf(PathBuf), 50 | } 51 | 52 | impl Default for Node { 53 | fn default() -> Self { 54 | Self::Branch(Edges::default()) 55 | } 56 | } 57 | 58 | impl Node { 59 | /// Adds new paths to the node. Each segment becomes a new node. 60 | pub fn add(&mut self, segments: Vec<&OsStr>, link_path: PathBuf) -> Result<(), Error> { 61 | let segments = segments.split_first(); 62 | if segments.is_none() { 63 | return Err(Error::EmptySegment); 64 | } 65 | 66 | // TODO: Handle error. 67 | let (key, segments) = segments.unwrap(); 68 | let key = PathBuf::from(key); 69 | 70 | match self { 71 | Self::Branch(edges) => { 72 | let current_slot = edges.iter_mut().find(|(path, _)| path == &key); 73 | let is_leaf = segments.is_empty(); 74 | 75 | if is_leaf { 76 | if current_slot.is_some() { 77 | return Err(Error::LeafExists(key, link_path)); 78 | } 79 | 80 | edges.push((key, Self::Leaf(link_path))); 81 | } else if let Some(edge) = current_slot { 82 | let (_, ref mut branch_node) = edge; 83 | 84 | branch_node.add(segments.into(), link_path)?; 85 | } else { 86 | let mut branch_node = Self::Branch(Edges::new()); 87 | branch_node.add(segments.into(), link_path)?; 88 | edges.push((key, branch_node)); 89 | } 90 | Ok(()) 91 | } 92 | Self::Leaf { .. } => Err(Error::NotABranch(key, link_path)), 93 | } 94 | } 95 | 96 | /// Returns the node's children if it's a branch, otherwise returns None. 97 | pub fn get_children(&self) -> Option<&Edges> { 98 | match self { 99 | Self::Branch(edges) => Some(edges), 100 | Self::Leaf(..) => None, 101 | } 102 | } 103 | 104 | /// Returns the node's link path if it's a leaf, otherwise returns None. 105 | pub fn get_link_path(&self) -> Option<&Path> { 106 | match self { 107 | Self::Branch(_) => None, 108 | Self::Leaf(path) => Some(path), 109 | } 110 | } 111 | } 112 | 113 | impl<'a> IntoIterator for &'a Node { 114 | type Item = Element; 115 | type IntoIter = Iter<'a>; 116 | 117 | fn into_iter(self) -> Self::IntoIter { 118 | self.into() 119 | } 120 | } 121 | 122 | #[cfg(test)] 123 | 124 | mod tests { 125 | use std::ffi::OsString; 126 | 127 | use pretty_assertions::assert_eq; 128 | 129 | use super::*; 130 | 131 | #[test] 132 | fn test_add_nodes() { 133 | struct Test<'a> { 134 | description: &'a str, 135 | input: (Node, Vec<&'a OsStr>, PathBuf), 136 | output: (Node, Result<(), Error>), 137 | } 138 | 139 | let foo = OsString::from("foo"); 140 | let bar = OsString::from("bar"); 141 | let baz = OsString::from("baz"); 142 | let capital_e = OsString::from("E"); 143 | 144 | let test_cases = Vec::from([ 145 | Test { 146 | description: "simple first node", 147 | input: ( 148 | Node::Branch(Edges::new()), 149 | Vec::from([&foo[..]]), 150 | "test/foo".into(), 151 | ), 152 | output: ( 153 | Node::Branch(Edges::from([("foo".into(), Node::Leaf("test/foo".into()))])), 154 | Ok(()), 155 | ), 156 | }, 157 | Test { 158 | description: "add sibling node to existing one", 159 | input: ( 160 | Node::Branch(Edges::from([("foo".into(), Node::Leaf("test/foo".into()))])), 161 | Vec::from([&bar[..]]), 162 | "yay/bar".into(), 163 | ), 164 | output: ( 165 | Node::Branch(Edges::from([ 166 | ("foo".into(), Node::Leaf("test/foo".into())), 167 | ("bar".into(), Node::Leaf("yay/bar".into())), 168 | ])), 169 | Ok(()), 170 | ), 171 | }, 172 | Test { 173 | description: "add nested node", 174 | input: ( 175 | Node::Branch(Edges::new()), 176 | Vec::from([&foo[..], &bar[..]]), 177 | "test/bar".into(), 178 | ), 179 | output: ( 180 | Node::Branch(Edges::from([( 181 | "foo".into(), 182 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("test/bar".into()))])), 183 | )])), 184 | Ok(()), 185 | ), 186 | }, 187 | Test { 188 | description: "add sibling to nested node", 189 | input: ( 190 | Node::Branch(Edges::from([( 191 | "foo".into(), 192 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("test/bar".into()))])), 193 | )])), 194 | Vec::from([&foo[..], &baz[..]]), 195 | "yay/baz".into(), 196 | ), 197 | output: ( 198 | Node::Branch(Edges::from([( 199 | "foo".into(), 200 | Node::Branch(Edges::from([ 201 | ("bar".into(), Node::Leaf("test/bar".into())), 202 | ("baz".into(), Node::Leaf("yay/baz".into())), 203 | ])), 204 | )])), 205 | Ok(()), 206 | ), 207 | }, 208 | Test { 209 | description: "add existing node path", 210 | input: ( 211 | Node::Branch(Edges::from([( 212 | "foo".into(), 213 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("test/bar".into()))])), 214 | )])), 215 | Vec::from([&foo[..], &bar[..]]), 216 | "please/let_me_in".into(), 217 | ), 218 | output: ( 219 | Node::Branch(Edges::from([( 220 | "foo".into(), 221 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("test/bar".into()))])), 222 | )])), 223 | Err(Error::LeafExists("bar".into(), "please/let_me_in".into())), 224 | ), 225 | }, 226 | Test { 227 | description: "add node to a leaf node", 228 | input: ( 229 | Node::Branch(Edges::from([("foo".into(), Node::Leaf("test/foo".into()))])), 230 | Vec::from([&foo[..], &bar[..]]), 231 | "please/let_me_in".into(), 232 | ), 233 | output: ( 234 | Node::Branch(Edges::from([("foo".into(), Node::Leaf("test/foo".into()))])), 235 | Err(Error::NotABranch("bar".into(), "please/let_me_in".into())), 236 | ), 237 | }, 238 | Test { 239 | description: "add node to a leaf node", 240 | input: ( 241 | Node::Branch(Edges::new()), 242 | Vec::new(), 243 | "please/let_me_in".into(), 244 | ), 245 | output: (Node::Branch(Edges::new()), Err(Error::EmptySegment)), 246 | }, 247 | Test { 248 | description: "nodes don't get sorted anymore", 249 | input: ( 250 | Node::Branch(Edges::from([ 251 | ("C".into(), Node::Leaf("1".into())), 252 | ("Z".into(), Node::Leaf("2".into())), 253 | ("B".into(), Node::Leaf("3".into())), 254 | ("A".into(), Node::Leaf("4".into())), 255 | ])), 256 | Vec::from([&capital_e[..]]), 257 | "5".into(), 258 | ), 259 | output: ( 260 | Node::Branch(Edges::from([ 261 | ("C".into(), Node::Leaf("1".into())), 262 | ("Z".into(), Node::Leaf("2".into())), 263 | ("B".into(), Node::Leaf("3".into())), 264 | ("A".into(), Node::Leaf("4".into())), 265 | ("E".into(), Node::Leaf("5".into())), 266 | ])), 267 | Ok(()), 268 | ), 269 | }, 270 | ]); 271 | 272 | for case in test_cases { 273 | let (mut tree, segments, link) = case.input; 274 | 275 | let result = tree.add(segments, link); 276 | let (want_tree, want_result) = case.output; 277 | 278 | assert_eq!( 279 | tree, want_tree, 280 | "mismatch when adding nodes: {:?}", 281 | case.description 282 | ); 283 | assert_eq!(result, want_result); 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/parser/tree.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeMap, HashMap, HashSet}, 3 | env, fs, 4 | io::Error as IoError, 5 | os::unix::fs as unix_fs, 6 | path::PathBuf, 7 | }; 8 | 9 | use crate::config::{Config, TagSet, Tags, Target}; 10 | 11 | use super::{ 12 | error::Error, 13 | iter::Element as IterElement, 14 | node::{Error as NodeError, Node, Status}, 15 | }; 16 | 17 | pub type Statuses = HashMap; 18 | pub type Problems = BTreeMap; 19 | 20 | #[derive(Debug, Default, PartialEq)] 21 | pub struct LinkOpts { 22 | pub replace: bool, 23 | pub create_dirs: bool, 24 | } 25 | 26 | /// Structure representing all dotfiles after reading a configuration for Park. 27 | #[derive(Debug, Default, PartialEq)] 28 | pub struct Tree { 29 | pub root: Node, 30 | pub work_dir: PathBuf, 31 | pub statuses: Statuses, 32 | pub problems: Problems, 33 | pub link_opts: LinkOpts, 34 | } 35 | 36 | impl Tree { 37 | /// Parses a configuration and returns a tree based on it. 38 | pub fn parse( 39 | config: Config, 40 | filters: (TagSet, HashSet), 41 | link_opts: LinkOpts, 42 | ) -> Result { 43 | let (mut runtime_tags, target_filters) = filters; 44 | let targets = config.targets.unwrap_or_default(); 45 | 46 | let cwd = env::current_dir().unwrap_or_default(); 47 | let work_dir = config.work_dir.unwrap_or(cwd); 48 | 49 | let mut tree = Tree { 50 | work_dir, 51 | link_opts, 52 | ..Tree::default() 53 | }; 54 | 55 | let Config { 56 | base_dir: default_base_dir, 57 | tags: default_tags, 58 | .. 59 | } = config; 60 | 61 | let default_base_dir = default_base_dir.unwrap_or_default(); 62 | 63 | if let Some(default_tags) = default_tags { 64 | runtime_tags.extend(default_tags); 65 | } 66 | 67 | for (target_path, target) in targets { 68 | if !target_filters.is_empty() && !target_filters.contains(&target_path) { 69 | continue; 70 | } 71 | 72 | let Target { 73 | link, 74 | tags: target_tags, 75 | } = target; 76 | 77 | let target_tags = target_tags.unwrap_or_default(); 78 | 79 | let Tags { all_of, any_of } = target_tags; 80 | let (all_of, any_of) = (all_of.unwrap_or_default(), any_of.unwrap_or_default()); 81 | 82 | if !all_of.is_empty() && !all_of.iter().all(|tag| runtime_tags.contains(tag)) { 83 | continue; 84 | } 85 | 86 | if !any_of.is_empty() && !any_of.iter().any(|tag| runtime_tags.contains(tag)) { 87 | continue; 88 | } 89 | 90 | let link = link.unwrap_or_default(); 91 | let base_dir = link.base_dir.as_ref().unwrap_or(&default_base_dir); 92 | let link_path = link.name.map_or_else( 93 | || { 94 | target_path 95 | .file_name() 96 | .map(|file_name| base_dir.join(file_name)) 97 | .unwrap() 98 | }, 99 | |name| base_dir.join(name), 100 | ); 101 | tree.root.add(target_path.iter().collect(), link_path)?; 102 | } 103 | 104 | Ok(tree) 105 | } 106 | 107 | /// Analyze the tree's nodes in order to check viability for symlinks to be done. 108 | /// This means it will iterate the tree and update each node's status. 109 | pub fn analyze(&mut self) -> Result<(), IoError> { 110 | let Tree { 111 | ref mut statuses, 112 | ref mut problems, 113 | ref root, 114 | .. 115 | } = self; 116 | 117 | 'check: for IterElement { 118 | link_path, 119 | target_path, 120 | .. 121 | } in root 122 | { 123 | if let Some(link_path) = link_path { 124 | if let Some(parent) = link_path.parent() { 125 | for parent in parent.ancestors() { 126 | if parent.exists() && !parent.is_dir() { 127 | problems.insert(link_path, Status::Obstructed); 128 | 129 | continue 'check; 130 | } 131 | } 132 | } 133 | 134 | if let Ok(existing_target_path) = link_path.read_link() { 135 | let target_path = self.work_dir.join(target_path); 136 | 137 | if existing_target_path == target_path { 138 | statuses.insert(link_path, Status::Done); 139 | } else if self.link_opts.replace { 140 | statuses.insert(link_path, Status::Mismatch); 141 | } else { 142 | problems.insert(link_path, Status::Mismatch); 143 | } 144 | 145 | continue; 146 | } 147 | 148 | let link_exists = link_path.exists(); 149 | let link_parent_exists = link_path.parent().map_or(true, |parent| { 150 | parent.as_os_str().is_empty() || parent.exists() 151 | }); 152 | 153 | if link_exists { 154 | problems.insert(link_path, Status::Conflict); 155 | } else if link_parent_exists { 156 | statuses.insert(link_path, Status::Ready); 157 | } else if self.link_opts.create_dirs { 158 | statuses.insert(link_path, Status::Unparented); 159 | } else { 160 | problems.insert(link_path, Status::Unparented); 161 | } 162 | } 163 | } 164 | 165 | Ok(()) 166 | } 167 | 168 | pub fn link(self) -> Result<(), Error> { 169 | if !self.problems.is_empty() { 170 | return Err(Error::BadFiles(self.problems)); 171 | } 172 | 173 | let links: Result, Error> = self 174 | .root 175 | .into_iter() 176 | .filter(|IterElement { link_path, .. }| link_path.is_some()) // filters branches 177 | .filter(|IterElement { link_path, .. }| { 178 | match self.statuses.get(link_path.as_ref().unwrap()) { 179 | Some(Status::Done) => false, 180 | _ => true, 181 | } 182 | }) 183 | .map( 184 | |IterElement { 185 | target_path, 186 | link_path, 187 | .. 188 | }| { 189 | let link_path = link_path.unwrap(); 190 | 191 | if let Some(status) = self.statuses.get(&link_path) { 192 | match status { 193 | Status::Unknown | Status::Conflict | Status::Obstructed => { 194 | return Err(Error::InternalError(link_path)) 195 | } 196 | Status::Mismatch => { 197 | if let Err(err) = fs::remove_file(&link_path) { 198 | return Err(Error::IoError(err.kind())); 199 | } 200 | } 201 | Status::Unparented => { 202 | if let Some(link_parent_dir) = link_path.parent() { 203 | if let Err(err) = fs::create_dir_all(link_parent_dir) { 204 | return Err(Error::IoError(err.kind())); 205 | } 206 | } 207 | } 208 | _ => {} 209 | } 210 | 211 | return Ok((self.work_dir.join(target_path), link_path.clone())); 212 | } 213 | 214 | Err(Error::InternalError(link_path)) 215 | }, 216 | ) 217 | .collect(); 218 | 219 | let mut created_links = Vec::new(); 220 | for (target_path, link_path) in links? { 221 | if let Err(err) = unix_fs::symlink(target_path, &link_path) { 222 | return Err(Error::IoError(err.kind())); 223 | }; 224 | 225 | created_links.push(link_path); 226 | } 227 | 228 | Ok(()) 229 | } 230 | } 231 | 232 | #[cfg(test)] 233 | mod tests { 234 | use std::{fs, path::PathBuf}; 235 | 236 | use pretty_assertions::assert_eq; 237 | 238 | use crate::{ 239 | config::{Link, TagSet, Tags, TargetMap}, 240 | parser::node::Edges, 241 | }; 242 | 243 | use super::*; 244 | 245 | #[test] 246 | fn parse() -> Result<(), IoError> { 247 | struct Test<'a> { 248 | description: &'a str, 249 | input: (Config, (TagSet, HashSet), LinkOpts), 250 | output: Result, 251 | } 252 | 253 | let current_dir = &env::current_dir()?; 254 | 255 | let test_cases = Vec::from([ 256 | Test { 257 | description: "simple config with a single target", 258 | input: ( 259 | Config { 260 | targets: Some(TargetMap::from([("foo".into(), Target::default())])), 261 | ..Config::default() 262 | }, 263 | (TagSet::default(), HashSet::default()), 264 | LinkOpts { 265 | replace: true, 266 | create_dirs: true, 267 | }, 268 | ), 269 | output: Ok(Tree { 270 | root: Node::Branch(Edges::from([("foo".into(), Node::Leaf("foo".into()))])), 271 | work_dir: current_dir.into(), 272 | link_opts: LinkOpts { 273 | replace: true, 274 | create_dirs: true, 275 | }, 276 | ..Tree::default() 277 | }), 278 | }, 279 | Test { 280 | description: "simple config with a nested target", 281 | input: ( 282 | Config { 283 | targets: Some(TargetMap::from([("foo/bar".into(), Target::default())])), 284 | ..Config::default() 285 | }, 286 | (TagSet::from([]), HashSet::from([])), 287 | LinkOpts::default(), 288 | ), 289 | output: Ok(Tree { 290 | root: Node::Branch(Edges::from([( 291 | "foo".into(), 292 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("bar".into()))])), 293 | )])), 294 | work_dir: current_dir.into(), 295 | ..Tree::default() 296 | }), 297 | }, 298 | Test { 299 | description: "target with custom options", 300 | input: ( 301 | Config { 302 | targets: Some(TargetMap::from([( 303 | "foo".into(), 304 | Target { 305 | link: Some(Link { 306 | name: Some("new_name".into()), 307 | ..Link::default() 308 | }), 309 | ..Target::default() 310 | }, 311 | )])), 312 | ..Config::default() 313 | }, 314 | (TagSet::from([]), HashSet::from([])), 315 | LinkOpts::default(), 316 | ), 317 | output: Ok(Tree { 318 | root: Node::Branch(Edges::from([( 319 | "foo".into(), 320 | Node::Leaf("new_name".into()), 321 | )])), 322 | work_dir: current_dir.into(), 323 | ..Tree::default() 324 | }), 325 | }, 326 | Test { 327 | description: "target disabled due to conjunctive tags", 328 | input: ( 329 | Config { 330 | targets: Some(TargetMap::from([( 331 | "foo".into(), 332 | Target { 333 | tags: Some(Tags { 334 | all_of: Some(TagSet::from(["test".into()])), 335 | any_of: Some(TagSet::from(["foo/bar".into()])), 336 | }), 337 | ..Target::default() 338 | }, 339 | )])), 340 | ..Config::default() 341 | }, 342 | ( 343 | TagSet::from(["foo".into(), "bar".into()]), 344 | HashSet::from([]), 345 | ), 346 | LinkOpts::default(), 347 | ), 348 | output: Ok(Tree { 349 | root: Node::Branch(Edges::from([])), 350 | work_dir: current_dir.into(), 351 | ..Tree::default() 352 | }), 353 | }, 354 | Test { 355 | description: "target enabled with tags #1", 356 | input: ( 357 | Config { 358 | targets: Some(TargetMap::from([( 359 | "foo".into(), 360 | Target { 361 | tags: Some(Tags { 362 | all_of: Some(TagSet::from(["test".into()])), 363 | ..Tags::default() 364 | }), 365 | ..Target::default() 366 | }, 367 | )])), 368 | ..Config::default() 369 | }, 370 | (TagSet::from(["test".into()]), HashSet::from([])), 371 | LinkOpts::default(), 372 | ), 373 | output: Ok(Tree { 374 | root: Node::Branch(Edges::from([("foo".into(), Node::Leaf("foo".into()))])), 375 | work_dir: current_dir.into(), 376 | ..Tree::default() 377 | }), 378 | }, 379 | Test { 380 | description: "target enabled with tags #2", 381 | input: ( 382 | Config { 383 | targets: Some(TargetMap::from([( 384 | "foo".into(), 385 | Target { 386 | tags: Some(Tags { 387 | all_of: Some(TagSet::from(["test".into()])), 388 | any_of: Some(TagSet::from(["foo".into(), "bar".into()])), 389 | }), 390 | ..Target::default() 391 | }, 392 | )])), 393 | ..Config::default() 394 | }, 395 | ( 396 | TagSet::from(["test".into(), "bar".into()]), 397 | HashSet::from([]), 398 | ), 399 | LinkOpts::default(), 400 | ), 401 | output: Ok(Tree { 402 | root: Node::Branch(Edges::from([("foo".into(), Node::Leaf("foo".into()))])), 403 | work_dir: current_dir.into(), 404 | ..Tree::default() 405 | }), 406 | }, 407 | Test { 408 | description: "target disabled due to disjunctive tags", 409 | input: ( 410 | Config { 411 | targets: Some(TargetMap::from([( 412 | "foo".into(), 413 | Target { 414 | tags: Some(Tags { 415 | all_of: Some(TagSet::from(["test".into()])), 416 | any_of: Some(TagSet::from(["foo".into(), "bar".into()])), 417 | }), 418 | ..Target::default() 419 | }, 420 | )])), 421 | ..Config::default() 422 | }, 423 | (TagSet::from(["test".into()]), HashSet::from([])), 424 | LinkOpts::default(), 425 | ), 426 | output: Ok(Tree { 427 | work_dir: current_dir.into(), 428 | ..Tree::default() 429 | }), 430 | }, 431 | Test { 432 | description: "target enabled with tags #3", 433 | input: ( 434 | Config { 435 | targets: Some(TargetMap::from([( 436 | "foo".into(), 437 | Target { 438 | tags: Some(Tags { 439 | any_of: Some(TagSet::from(["test".into()])), 440 | ..Tags::default() 441 | }), 442 | ..Target::default() 443 | }, 444 | )])), 445 | ..Config::default() 446 | }, 447 | (TagSet::from(["test".into()]), HashSet::from([])), 448 | LinkOpts::default(), 449 | ), 450 | output: Ok(Tree { 451 | root: Node::Branch(Edges::from([("foo".into(), Node::Leaf("foo".into()))])), 452 | work_dir: current_dir.into(), 453 | ..Tree::default() 454 | }), 455 | }, 456 | Test { 457 | description: "target using its file name as link name", 458 | input: ( 459 | Config { 460 | targets: Some(TargetMap::from([("foo/bar/baz".into(), Target::default())])), 461 | ..Config::default() 462 | }, 463 | (TagSet::from([]), HashSet::from([])), 464 | LinkOpts::default(), 465 | ), 466 | output: Ok(Tree { 467 | root: Node::Branch(Edges::from([( 468 | "foo".into(), 469 | Node::Branch(Edges::from([( 470 | "bar".into(), 471 | Node::Branch(Edges::from([("baz".into(), Node::Leaf("baz".into()))])), 472 | )])), 473 | )])), 474 | work_dir: current_dir.into(), 475 | ..Tree::default() 476 | }), 477 | }, 478 | Test { 479 | description: "target enabled with target name filtering", 480 | input: ( 481 | Config { 482 | targets: Some(TargetMap::from([ 483 | ("foo/bar".into(), Target::default()), 484 | ("baz/qux".into(), Target::default()), 485 | ])), 486 | ..Config::default() 487 | }, 488 | (TagSet::from([]), HashSet::from(["foo/bar".into()])), 489 | LinkOpts::default(), 490 | ), 491 | output: Ok(Tree { 492 | root: Node::Branch(Edges::from([( 493 | "foo".into(), 494 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("bar".into()))])), 495 | )])), 496 | work_dir: current_dir.into(), 497 | ..Tree::default() 498 | }), 499 | }, 500 | ]); 501 | 502 | for case in test_cases { 503 | let got = Tree::parse(case.input.0, case.input.1, case.input.2); 504 | 505 | assert_eq!(got, case.output, "bad result for {:?}", case.description); 506 | } 507 | 508 | Ok(()) 509 | } 510 | 511 | #[test] 512 | fn analyze_tree() -> Result<(), IoError> { 513 | struct Test<'a> { 514 | description: &'a str, 515 | input: Tree, 516 | output: Tree, 517 | } 518 | 519 | let current_dir = &env::current_dir()?; 520 | 521 | let test_cases = Vec::from([ 522 | Test { 523 | description: "single target should be ready", 524 | input: Tree { 525 | root: Node::Branch(Edges::from([( 526 | "tests/foo".into(), 527 | Node::Leaf("tests/foo".into()), 528 | )])), 529 | work_dir: current_dir.into(), 530 | ..Tree::default() 531 | }, 532 | output: Tree { 533 | root: Node::Branch(Edges::from([( 534 | "tests/foo".into(), 535 | Node::Leaf("tests/foo".into()), 536 | )])), 537 | work_dir: current_dir.into(), 538 | statuses: Statuses::from([("tests/foo".into(), Status::Ready)]), 539 | ..Tree::default() 540 | }, 541 | }, 542 | Test { 543 | description: 544 | "single target whose parent is nonexistent should be unparented (problem)", 545 | input: Tree { 546 | root: Node::Branch(Edges::from([( 547 | "xxx/foo".into(), 548 | Node::Leaf("xxx/foo".into()), 549 | )])), 550 | work_dir: current_dir.into(), 551 | ..Tree::default() 552 | }, 553 | output: Tree { 554 | root: Node::Branch(Edges::from([( 555 | "xxx/foo".into(), 556 | Node::Leaf("xxx/foo".into()), 557 | )])), 558 | work_dir: current_dir.into(), 559 | problems: Problems::from([("xxx/foo".into(), Status::Unparented)]), 560 | ..Tree::default() 561 | }, 562 | }, 563 | Test { 564 | description: "single target whose parent is nonexistent should be unparented (ok)", 565 | input: Tree { 566 | root: Node::Branch(Edges::from([( 567 | "xxx/foo".into(), 568 | Node::Leaf("xxx/foo".into()), 569 | )])), 570 | work_dir: current_dir.into(), 571 | link_opts: LinkOpts { 572 | create_dirs: true, 573 | ..LinkOpts::default() 574 | }, 575 | ..Tree::default() 576 | }, 577 | output: Tree { 578 | root: Node::Branch(Edges::from([( 579 | "xxx/foo".into(), 580 | Node::Leaf("xxx/foo".into()), 581 | )])), 582 | work_dir: current_dir.into(), 583 | link_opts: LinkOpts { 584 | create_dirs: true, 585 | ..LinkOpts::default() 586 | }, 587 | statuses: Statuses::from([("xxx/foo".into(), Status::Unparented)]), 588 | ..Tree::default() 589 | }, 590 | }, 591 | Test { 592 | description: "single target whose base directory is empty", 593 | input: Tree { 594 | root: Node::Branch(Edges::from([("foo".into(), Node::Leaf("foo".into()))])), 595 | work_dir: current_dir.into(), 596 | ..Tree::default() 597 | }, 598 | output: Tree { 599 | root: Node::Branch(Edges::from([("foo".into(), Node::Leaf("foo".into()))])), 600 | work_dir: current_dir.into(), 601 | statuses: Statuses::from([("foo".into(), Status::Ready)]), 602 | ..Tree::default() 603 | }, 604 | }, 605 | Test { 606 | description: "single target has conflict", 607 | input: Tree { 608 | root: Node::Branch(Edges::from([( 609 | "LICENSE".into(), 610 | Node::Leaf("LICENSE".into()), 611 | )])), 612 | work_dir: current_dir.into(), 613 | ..Tree::default() 614 | }, 615 | output: Tree { 616 | root: Node::Branch(Edges::from([( 617 | "LICENSE".into(), 618 | Node::Leaf("LICENSE".into()), 619 | )])), 620 | work_dir: current_dir.into(), 621 | problems: Problems::from([("LICENSE".into(), Status::Conflict)]), 622 | ..Tree::default() 623 | }, 624 | }, 625 | Test { 626 | description: "single target with wrong existing link (ok)", 627 | input: Tree { 628 | root: Node::Branch(Edges::from([( 629 | "something".into(), 630 | Node::Leaf("tests/data/something".into()), 631 | )])), 632 | work_dir: current_dir.into(), 633 | link_opts: LinkOpts { 634 | replace: true, 635 | ..LinkOpts::default() 636 | }, 637 | ..Tree::default() 638 | }, 639 | output: Tree { 640 | root: Node::Branch(Edges::from([( 641 | "something".into(), 642 | Node::Leaf("tests/data/something".into()), 643 | )])), 644 | work_dir: current_dir.into(), 645 | link_opts: LinkOpts { 646 | replace: true, 647 | ..LinkOpts::default() 648 | }, 649 | statuses: Statuses::from([("tests/data/something".into(), Status::Mismatch)]), 650 | ..Tree::default() 651 | }, 652 | }, 653 | Test { 654 | description: "single target with correct existing link", 655 | input: Tree { 656 | root: Node::Branch(Edges::from([( 657 | "something".into(), 658 | Node::Leaf("tests/data/something".into()), 659 | )])), 660 | work_dir: "test".into(), 661 | ..Tree::default() 662 | }, 663 | output: Tree { 664 | root: Node::Branch(Edges::from([( 665 | "something".into(), 666 | Node::Leaf("tests/data/something".into()), 667 | )])), 668 | work_dir: "test".into(), 669 | statuses: Statuses::from([("tests/data/something".into(), Status::Done)]), 670 | ..Tree::default() 671 | }, 672 | }, 673 | Test { 674 | description: "link with invalid parent directory", 675 | input: Tree { 676 | root: Node::Branch(Edges::from([( 677 | "something".into(), 678 | Node::Leaf("LICENSE/something".into()), 679 | )])), 680 | work_dir: "test".into(), 681 | ..Tree::default() 682 | }, 683 | output: Tree { 684 | root: Node::Branch(Edges::from([( 685 | "something".into(), 686 | Node::Leaf("LICENSE/something".into()), 687 | )])), 688 | work_dir: "test".into(), 689 | problems: Problems::from([("LICENSE/something".into(), Status::Obstructed)]), 690 | ..Tree::default() 691 | }, 692 | }, 693 | Test { 694 | description: "link with invalid parent path", 695 | input: Tree { 696 | root: Node::Branch(Edges::from([( 697 | "something".into(), 698 | Node::Leaf("LICENSE/foo/bar/something".into()), 699 | )])), 700 | work_dir: "test".into(), 701 | ..Tree::default() 702 | }, 703 | output: Tree { 704 | root: Node::Branch(Edges::from([( 705 | "something".into(), 706 | Node::Leaf("LICENSE/foo/bar/something".into()), 707 | )])), 708 | work_dir: "test".into(), 709 | problems: Problems::from([( 710 | "LICENSE/foo/bar/something".into(), 711 | Status::Obstructed, 712 | )]), 713 | ..Tree::default() 714 | }, 715 | }, 716 | ]); 717 | 718 | for mut case in test_cases { 719 | case.input.analyze()?; 720 | 721 | assert_eq!( 722 | case.input, case.output, 723 | "bad result for {:?}", 724 | case.description 725 | ); 726 | } 727 | 728 | Ok(()) 729 | } 730 | 731 | #[test] 732 | fn link() -> Result<(), IoError> { 733 | struct Test<'a> { 734 | description: &'a str, 735 | input: Tree, 736 | output: Result<(), Error>, 737 | files_created: Vec, 738 | dirs_created: Vec, 739 | } 740 | 741 | let test_cases = Vec::from([ 742 | Test { 743 | description: "nothing to be done", 744 | input: Tree { 745 | root: Node::Branch(Edges::from([( 746 | "foo".into(), 747 | Node::Leaf("tests/data/foo".into()), 748 | )])), 749 | work_dir: "fake_path".into(), 750 | statuses: Statuses::from([("tests/data/foo".into(), Status::Done)]), 751 | ..Tree::default() 752 | }, 753 | output: Ok(()), 754 | files_created: Vec::from([]), 755 | dirs_created: Vec::from([]), 756 | }, 757 | Test { 758 | description: "simple link", 759 | input: Tree { 760 | root: Node::Branch(Edges::from([( 761 | "foo".into(), 762 | Node::Leaf("tests/data/foo".into()), 763 | )])), 764 | work_dir: "fake_path".into(), 765 | statuses: Statuses::from([("tests/data/foo".into(), Status::Ready)]), 766 | ..Tree::default() 767 | }, 768 | output: Ok(()), 769 | files_created: Vec::from(["tests/data/foo".into()]), 770 | dirs_created: Vec::from([]), 771 | }, 772 | Test { 773 | description: "simple unparented link", 774 | input: Tree { 775 | root: Node::Branch(Edges::from([( 776 | "foo".into(), 777 | Node::Leaf("tests/xxx/foo".into()), 778 | )])), 779 | work_dir: "fake_path".into(), 780 | statuses: Statuses::from([("tests/xxx/foo".into(), Status::Unparented)]), 781 | ..Tree::default() 782 | }, 783 | output: Ok(()), 784 | files_created: Vec::from(["tests/xxx/foo".into()]), 785 | dirs_created: Vec::from(["tests/xxx".into()]), 786 | }, 787 | Test { 788 | description: "bad unparented link", 789 | input: Tree { 790 | root: Node::Branch(Edges::from([( 791 | "foo".into(), 792 | Node::Leaf("tests/xxx/foo".into()), 793 | )])), 794 | work_dir: "fake_path".into(), 795 | problems: Problems::from([("tests/xxx/foo".into(), Status::Unparented)]), 796 | ..Tree::default() 797 | }, 798 | output: Err(Error::BadFiles(Problems::from([( 799 | "tests/xxx/foo".into(), 800 | Status::Unparented, 801 | )]))), 802 | files_created: Vec::from([]), 803 | dirs_created: Vec::from([]), 804 | }, 805 | Test { 806 | description: "multiple links", 807 | input: Tree { 808 | root: Node::Branch(Edges::from([ 809 | ("foo".into(), Node::Leaf("tests/data/foo".into())), 810 | ("bar".into(), Node::Leaf("tests/data/bar".into())), 811 | ])), 812 | work_dir: "fake_path".into(), 813 | statuses: Statuses::from([ 814 | ("tests/data/foo".into(), Status::Ready), 815 | ("tests/data/bar".into(), Status::Ready), 816 | ]), 817 | ..Tree::default() 818 | }, 819 | output: Ok(()), 820 | files_created: Vec::from(["tests/data/foo".into(), "tests/data/bar".into()]), 821 | dirs_created: Vec::from([]), 822 | }, 823 | Test { 824 | description: "bad link with conflict", 825 | input: Tree { 826 | root: Node::Branch(Edges::from([( 827 | "something".into(), 828 | Node::Leaf("tests/data/something".into()), 829 | )])), 830 | work_dir: "fake_path".into(), 831 | statuses: Statuses::from([("tests/data/something".into(), Status::Conflict)]), 832 | ..Tree::default() 833 | }, 834 | output: Err(Error::InternalError("tests/data/something".into())), 835 | files_created: Vec::from([]), 836 | dirs_created: Vec::from([]), 837 | }, 838 | Test { 839 | description: "bad link with obstruction", 840 | input: Tree { 841 | root: Node::Branch(Edges::from([( 842 | "something".into(), 843 | Node::Leaf("tests/data/something".into()), 844 | )])), 845 | work_dir: "fake_path".into(), 846 | statuses: Statuses::from([("tests/data/something".into(), Status::Obstructed)]), 847 | ..Tree::default() 848 | }, 849 | output: Err(Error::InternalError("tests/data/something".into())), 850 | files_created: Vec::from([]), 851 | dirs_created: Vec::from([]), 852 | }, 853 | Test { 854 | input: Tree { 855 | root: Node::Branch(Edges::from([( 856 | "something".into(), 857 | Node::Leaf("tests/data/something".into()), 858 | )])), 859 | work_dir: "fake_path".into(), 860 | statuses: Statuses::from([("tests/data/something".into(), Status::Mismatch)]), 861 | ..Tree::default() 862 | }, 863 | description: "replace mismatch", 864 | output: Ok(()), 865 | files_created: Vec::from(["tests/data/something".into()]), 866 | dirs_created: Vec::from([]), 867 | }, 868 | Test { 869 | input: Tree { 870 | root: Node::Branch(Edges::from([( 871 | "something".into(), 872 | Node::Leaf("tests/data/something".into()), 873 | )])), 874 | work_dir: "fake_path".into(), 875 | problems: Problems::from([("tests/data/something".into(), Status::Mismatch)]), 876 | ..Tree::default() 877 | }, 878 | description: "bad link with mismatch", 879 | output: Err(Error::BadFiles(Problems::from([( 880 | "tests/data/something".into(), 881 | Status::Mismatch, 882 | )]))), 883 | files_created: Vec::from([]), 884 | dirs_created: Vec::from([]), 885 | }, 886 | ]); 887 | 888 | for case in test_cases { 889 | let got = case.input.link(); 890 | 891 | let mut file_assertions = Vec::from([]); 892 | let mut dir_assertions = Vec::from([]); 893 | for file in &case.files_created { 894 | let link_path = PathBuf::from("fake_path").join(file.file_name().unwrap()); 895 | 896 | file_assertions.push((file.read_link()?, link_path)); 897 | 898 | fs::remove_file(file)?; 899 | } 900 | 901 | for dir in &case.dirs_created { 902 | dir_assertions.push((dir.is_dir(), dir)); 903 | 904 | fs::remove_dir_all(dir)?; 905 | } 906 | 907 | for (got, want) in file_assertions { 908 | assert_eq!( 909 | got, want, 910 | "wrong target path for {:?} in {}", 911 | want, case.description 912 | ); 913 | } 914 | 915 | for (is_dir, dir_path) in dir_assertions { 916 | assert!( 917 | is_dir, 918 | "did not create dir at {:?} in {}", 919 | dir_path, case.description 920 | ); 921 | } 922 | 923 | assert_eq!(got, case.output, "bad result for {:?}", case.description); 924 | } 925 | 926 | // Reset testing symlink. 927 | unix_fs::symlink("test/something", "tests/data/something") 928 | } 929 | } 930 | -------------------------------------------------------------------------------- /src/printer.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::{OsStr, OsString}, 3 | fmt::{Display, Error as FmtError, Formatter, Result as FmtResult}, 4 | io::Write, 5 | str, 6 | }; 7 | 8 | use ansi_term::{Colour, Style}; 9 | use tabwriter::TabWriter; 10 | 11 | use crate::parser::{ 12 | iter::{Element as IterElement, NodeMetadata}, 13 | node::Status, 14 | tree::Tree, 15 | }; 16 | 17 | pub struct Printer<'a> { 18 | pub tree: &'a Tree, 19 | pub colored: bool, 20 | pub home: Option, 21 | } 22 | 23 | impl<'a> Printer<'a> { 24 | fn resolve_style(&self, style: Style) -> Style { 25 | if self.colored { 26 | style 27 | } else { 28 | Style::new() 29 | } 30 | } 31 | 32 | fn replace_home(&self, s: S) -> String 33 | where 34 | S: AsRef, 35 | { 36 | if let Some(home) = self.home.as_ref().and_then(|s| s.to_str()) { 37 | return s.as_ref().replacen(home, "~", 1); 38 | } 39 | 40 | s.as_ref().into() 41 | } 42 | } 43 | 44 | impl<'a> Display for Printer<'a> { 45 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 46 | let table = Vec::new(); 47 | let mut tab_writer = TabWriter::new(table).padding(1); 48 | 49 | let mut indent_blocks = Vec::::new(); 50 | 51 | for IterElement { 52 | metadata: NodeMetadata { 53 | last_sibling: last_edge, 54 | level, 55 | }, 56 | target_path, 57 | link_path, 58 | } in &self.tree.root 59 | { 60 | if level == 0 { 61 | let cwd = self.resolve_style(Colour::White.italic()).paint({ 62 | let path = self.replace_home(self.tree.work_dir.to_string_lossy()); 63 | 64 | if self.colored { 65 | path 66 | } else { 67 | format!("({})", path) 68 | } 69 | }); 70 | 71 | if writeln!(tab_writer, ". {}", cwd,).is_err() { 72 | return Err(FmtError); 73 | } 74 | 75 | continue; 76 | } 77 | 78 | while level <= indent_blocks.len() { 79 | indent_blocks.pop(); 80 | } 81 | 82 | indent_blocks.push(last_edge); 83 | 84 | for (idx, has_indent_guide) in indent_blocks.iter().enumerate() { 85 | let is_leaf = idx == level - 1; 86 | 87 | let segment = match (has_indent_guide, is_leaf) { 88 | (true, true) => "└── ", 89 | (false, true) => "├── ", 90 | (true, _) => " ", 91 | (false, _) => "│ ", 92 | }; 93 | 94 | if write!( 95 | tab_writer, 96 | "{}", 97 | self.resolve_style(Colour::White.normal()).paint(segment) 98 | ) 99 | .is_err() 100 | { 101 | return Err(FmtError); 102 | } 103 | } 104 | 105 | if let Some(link_path) = link_path { 106 | let default_status = Status::Unknown; 107 | let status = self 108 | .tree 109 | .problems 110 | .get(&link_path) 111 | .or_else(|| self.tree.statuses.get(&link_path)) 112 | .unwrap_or(&default_status); 113 | 114 | let status_style = self.resolve_style( 115 | match status { 116 | Status::Unknown => Colour::White, 117 | Status::Done => Colour::Blue, 118 | Status::Ready => Colour::Green, 119 | Status::Mismatch | Status::Unparented => Colour::Yellow, 120 | Status::Conflict | Status::Obstructed => Colour::Red, 121 | } 122 | .reverse(), 123 | ); 124 | let status = if self.colored { 125 | format!(" {:?} ", status) 126 | } else { 127 | format!("[{:?}]", status) 128 | } 129 | .to_uppercase(); 130 | 131 | let target_segment: Vec<&OsStr> = target_path.iter().collect(); 132 | let is_leaf = level == target_segment.len(); 133 | let target_path = target_path.file_name().unwrap().to_string_lossy(); 134 | if writeln!( 135 | tab_writer, 136 | "{target_path}\t{link_path}\t{status}", 137 | target_path = { 138 | let mut style = Style::new(); 139 | 140 | if is_leaf { 141 | style = Colour::Cyan.bold(); 142 | } 143 | 144 | self.resolve_style(style).paint(target_path) 145 | }, 146 | link_path = self.resolve_style(Colour::Purple.italic()).paint({ 147 | let path = self.replace_home(link_path.to_string_lossy()); 148 | 149 | if self.colored { 150 | format!(" {} ", path) 151 | } else { 152 | format!("({})", path) 153 | } 154 | }), 155 | status = status_style.paint(status), 156 | ) 157 | .is_err() 158 | { 159 | return Err(FmtError); 160 | }; 161 | } else { 162 | let path = target_path.file_name().unwrap(); 163 | if writeln!(tab_writer, "{}\t\t", path.to_string_lossy()).is_err() { 164 | return Err(FmtError); 165 | }; 166 | } 167 | } 168 | 169 | match tab_writer.into_inner() { 170 | Err(_) => return Err(FmtError), 171 | Ok(w) => { 172 | write!(f, "{}", str::from_utf8(&w).unwrap())?; 173 | } 174 | } 175 | 176 | Ok(()) 177 | } 178 | } 179 | 180 | #[cfg(test)] 181 | mod tests { 182 | use std::io::Error as IoError; 183 | 184 | use indoc::indoc; 185 | use pretty_assertions::assert_eq; 186 | 187 | use crate::parser::{ 188 | node::{Edges, Node}, 189 | tree::Statuses, 190 | }; 191 | 192 | use super::*; 193 | 194 | #[test] 195 | fn format_tree() -> Result<(), IoError> { 196 | let tree = Tree { 197 | root: Node::Branch(Edges::from([ 198 | ( 199 | "baz".into(), 200 | Node::Branch(Edges::from([("qux".into(), Node::Leaf("test/qux".into()))])), 201 | ), 202 | ( 203 | "corge".into(), 204 | Node::Branch(Edges::from([ 205 | ("anything".into(), Node::Leaf("file/file".into())), 206 | ("gralt".into(), Node::Leaf("test/gralt".into())), 207 | ( 208 | "something".into(), 209 | Node::Leaf("tests/data/something".into()), 210 | ), 211 | ( 212 | "s0m37h1ng".into(), 213 | Node::Leaf("tests/none/s0m37h1ng".into()), 214 | ), 215 | ])), 216 | ), 217 | ( 218 | "foo".into(), 219 | Node::Branch(Edges::from([("bar".into(), Node::Leaf("bar".into()))])), 220 | ), 221 | ( 222 | "quux".into(), 223 | Node::Branch(Edges::from([("quuz".into(), Node::Leaf("quuz".into()))])), 224 | ), 225 | ])), 226 | statuses: Statuses::from([ 227 | ("bar".into(), Status::Unknown), 228 | ("test/qux".into(), Status::Done), 229 | ("quuz".into(), Status::Ready), 230 | ("tests/data/something".into(), Status::Mismatch), 231 | ("tests/none/s0m37h1ng".into(), Status::Unparented), 232 | ("test/gralt".into(), Status::Conflict), 233 | ("file/file".into(), Status::Obstructed), 234 | ]), 235 | work_dir: "test".into(), 236 | ..Tree::default() 237 | }; 238 | 239 | { 240 | let printer = Printer { 241 | tree: &tree, 242 | colored: true, 243 | home: Some("file".into()), 244 | }; 245 | 246 | println!("\n{}", printer); 247 | 248 | let target_color = Colour::Cyan.bold(); 249 | let link_color = Colour::Purple.italic(); 250 | let symbols_color = Colour::White.normal(); 251 | 252 | assert_eq!( 253 | printer.to_string(), 254 | format!( 255 | indoc! {" 256 | . {current_dir} 257 | {t_bar}baz 258 | {straight_bar}{l_bar}{tgt1} {test_qux} {done} 259 | {t_bar}corge 260 | {straight_bar}{t_bar}{tgt2} {file_file} {obstructed} 261 | {straight_bar}{t_bar}{tgt3} {test_gralt} {conflict} 262 | {straight_bar}{t_bar}{tgt4} {tests_data_something} {mismatch} 263 | {straight_bar}{l_bar}{tgt5} {tests_none_something} {unparented} 264 | {t_bar}foo 265 | {straight_bar}{l_bar}{tgt6} {bar} {unknown} 266 | {l_bar}quux 267 | {blank}{l_bar}{tgt7} {quuz} {ready} 268 | "}, 269 | t_bar = symbols_color.paint("├── "), 270 | l_bar = symbols_color.paint("└── "), 271 | tgt1 = target_color.paint("qux"), 272 | tgt2 = target_color.paint("anything"), 273 | tgt3 = target_color.paint("gralt"), 274 | tgt4 = target_color.paint("something"), 275 | tgt5 = target_color.paint("s0m37h1ng"), 276 | tgt6 = target_color.paint("bar"), 277 | tgt7 = target_color.paint("quuz"), 278 | straight_bar = symbols_color.paint("│ "), 279 | blank = symbols_color.paint(" "), 280 | current_dir = Colour::White.italic().paint("test"), 281 | bar = link_color.paint(" bar "), 282 | test_qux = link_color.paint(" test/qux "), 283 | quuz = link_color.paint(" quuz "), 284 | tests_data_something = link_color.paint(" tests/data/something "), 285 | tests_none_something = link_color.paint(" tests/none/s0m37h1ng "), 286 | test_gralt = link_color.paint(" test/gralt "), 287 | file_file = link_color.paint(" ~/file "), 288 | unknown = Colour::White.reverse().paint(" UNKNOWN "), 289 | done = Colour::Blue.reverse().paint(" DONE "), 290 | ready = Colour::Green.reverse().paint(" READY "), 291 | unparented = Colour::Yellow.reverse().paint(" UNPARENTED "), 292 | mismatch = Colour::Yellow.reverse().paint(" MISMATCH "), 293 | conflict = Colour::Red.reverse().paint(" CONFLICT "), 294 | obstructed = Colour::Red.reverse().paint(" OBSTRUCTED "), 295 | ), 296 | "invalid colored output", 297 | ); 298 | } 299 | 300 | { 301 | let printer = Printer { 302 | tree: &tree, 303 | colored: false, 304 | home: Some("file".into()), 305 | }; 306 | 307 | println!("\n{}", printer); 308 | 309 | assert_eq!( 310 | printer.to_string(), 311 | format!(indoc! {" 312 | . (test) 313 | ├── baz 314 | │ └── qux (test/qux) [DONE] 315 | ├── corge 316 | │ ├── anything (~/file) [OBSTRUCTED] 317 | │ ├── gralt (test/gralt) [CONFLICT] 318 | │ ├── something (tests/data/something) [MISMATCH] 319 | │ └── s0m37h1ng (tests/none/s0m37h1ng) [UNPARENTED] 320 | ├── foo 321 | │ └── bar (bar) [UNKNOWN] 322 | └── quux 323 | └── quuz (quuz) [READY] 324 | "}), 325 | "invalid non-colored output", 326 | ); 327 | } 328 | 329 | Ok(()) 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/run.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::{ffi::OsString, io::Write}; 3 | 4 | use anyhow::{Context, Result}; 5 | 6 | use crate::cli::Park; 7 | use crate::parser::tree::LinkOpts; 8 | use crate::{config::Config, parser::tree::Tree, printer::Printer}; 9 | 10 | pub struct Env { 11 | pub colored: bool, 12 | pub home: Option, 13 | } 14 | 15 | /// Runs the program, parsing STDIN for a config file. 16 | pub fn run(env: Env, input: &str, mut stdout: W, cli: Park) -> Result<()> 17 | where 18 | W: Write, 19 | { 20 | let config: Config = 21 | toml::from_str(input).with_context(|| "could not read input configuration")?; 22 | let Park { 23 | link, 24 | filters, 25 | replace, 26 | create_dirs, 27 | .. 28 | } = cli; 29 | 30 | let (tags, targets): (Vec, Vec) = 31 | filters.into_iter().partition(|s| s.starts_with('+')); 32 | 33 | let tags = tags.iter().map(|s| &s[1..]).map(|s| s.into()).collect(); 34 | let targets = targets.iter().map(PathBuf::from).collect(); 35 | 36 | let mut tree = Tree::parse( 37 | config, 38 | (tags, targets), 39 | LinkOpts { 40 | replace, 41 | create_dirs, 42 | }, 43 | ) 44 | .with_context(|| "could not parse target")?; 45 | 46 | tree.analyze() 47 | .with_context(|| "could not analyze targets")?; 48 | 49 | if link { 50 | tree.link().with_context(|| "could not link targets")?; 51 | } else { 52 | write!( 53 | stdout, 54 | "{}", 55 | Printer { 56 | tree: &tree, 57 | colored: env.colored, 58 | home: env.home, 59 | } 60 | ) 61 | .with_context(|| "could not print preview tree")?; 62 | } 63 | 64 | Ok(()) 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use std::{env, fs, path::PathBuf, str}; 70 | 71 | use ansi_term::Colour; 72 | use indoc::indoc; 73 | use pretty_assertions::assert_eq; 74 | 75 | use super::*; 76 | 77 | #[test] 78 | fn test_running_without_args() -> Result<()> { 79 | let input = indoc! {r#" 80 | base_dir = "tests" 81 | 82 | [targets.0xDEADBABE] 83 | tags.all_of = ["0xDEADBABE"] 84 | 85 | [targets.0xDEADBEEF] 86 | "#}; 87 | 88 | { 89 | let mut stdout = Vec::new(); 90 | 91 | run( 92 | Env { 93 | colored: true, 94 | home: None, 95 | }, 96 | input, 97 | &mut stdout, 98 | Park::default(), 99 | )?; 100 | 101 | let target_color = Colour::Cyan.bold(); 102 | let link_color = Colour::Purple.italic(); 103 | let symbols_color = Colour::White.normal(); 104 | let current_dir = env::current_dir().unwrap_or_default(); 105 | 106 | assert_eq!( 107 | String::from_utf8(stdout).unwrap(), 108 | format!( 109 | indoc! {" 110 | . {current_dir} 111 | {l_bar}{tgt} {beef} {ready} 112 | "}, 113 | tgt = target_color.paint("0xDEADBEEF"), 114 | l_bar = symbols_color.paint("└── "), 115 | current_dir = Colour::White.italic().paint(current_dir.to_string_lossy()), 116 | beef = link_color.paint(" tests/0xDEADBEEF "), 117 | ready = Colour::Green.reverse().paint(" READY "), 118 | ), 119 | "with color", 120 | ); 121 | } 122 | 123 | { 124 | let mut stdout = Vec::new(); 125 | 126 | run( 127 | Env { 128 | colored: false, 129 | home: None, 130 | }, 131 | &input, 132 | &mut stdout, 133 | Park { 134 | filters: vec!["+0xDEADBABE".into()], 135 | ..Park::default() 136 | }, 137 | )?; 138 | 139 | let current_dir = env::current_dir().unwrap_or_default(); 140 | 141 | assert_eq!( 142 | String::from_utf8(stdout).unwrap(), 143 | format!( 144 | indoc! {" 145 | . ({current_dir}) 146 | ├── 0xDEADBABE (tests/0xDEADBABE) [READY] 147 | └── 0xDEADBEEF (tests/0xDEADBEEF) [READY] 148 | "}, 149 | current_dir = current_dir.to_string_lossy(), 150 | ), 151 | "without color", 152 | ); 153 | } 154 | 155 | Ok(()) 156 | } 157 | 158 | #[test] 159 | fn test_running_with_tags_as_args() -> Result<()> { 160 | let input = indoc! {r#" 161 | base_dir = "tests" 162 | 163 | [targets.foo] 164 | tags.all_of = ["foo"] 165 | 166 | [targets.bar] 167 | "#}; 168 | 169 | let mut stdout = Vec::new(); 170 | 171 | run( 172 | Env { 173 | colored: true, 174 | home: None, 175 | }, 176 | &input, 177 | &mut stdout, 178 | Park { 179 | filters: vec!["+foo".into()], 180 | ..Park::default() 181 | }, 182 | )?; 183 | 184 | let target_color = Colour::Cyan.bold(); 185 | let link_color = Colour::Purple.italic(); 186 | let symbols_color = Colour::White.normal(); 187 | let current_dir = env::current_dir().unwrap_or_default(); 188 | 189 | assert_eq!( 190 | String::from_utf8(stdout).unwrap(), 191 | format!( 192 | indoc! {" 193 | . {current_dir} 194 | {t_bar}{tgt1} {bar} {ready} 195 | {l_bar}{tgt2} {foo} {ready} 196 | "}, 197 | tgt1 = target_color.paint("bar"), 198 | tgt2 = target_color.paint("foo"), 199 | t_bar = symbols_color.paint("├── "), 200 | l_bar = symbols_color.paint("└── "), 201 | current_dir = Colour::White.italic().paint(current_dir.to_string_lossy()), 202 | foo = link_color.paint(" tests/foo "), 203 | bar = link_color.paint(" tests/bar "), 204 | ready = Colour::Green.reverse().paint(" READY "), 205 | ), 206 | "invalid colored output", 207 | ); 208 | 209 | Ok(()) 210 | } 211 | 212 | #[test] 213 | fn test_running_with_target_filters_as_args() -> Result<()> { 214 | let input = indoc! {r#" 215 | base_dir = "tests" 216 | 217 | [targets.foo] 218 | 219 | [targets.bar] 220 | "#}; 221 | 222 | let mut stdout = Vec::new(); 223 | 224 | env::remove_var("NO_COLOR"); 225 | run( 226 | Env { 227 | colored: true, 228 | home: None, 229 | }, 230 | &input, 231 | &mut stdout, 232 | Park { 233 | filters: vec!["foo".into()], 234 | ..Park::default() 235 | }, 236 | )?; 237 | 238 | let link_color = Colour::Purple.italic(); 239 | let target_color = Colour::Cyan.bold(); 240 | let symbols_color = Colour::White.normal(); 241 | let current_dir = env::current_dir().unwrap_or_default(); 242 | 243 | assert_eq!( 244 | String::from_utf8(stdout).unwrap(), 245 | format!( 246 | indoc! {" 247 | . {current_dir} 248 | {l_bar}{tgt} {foo} {ready} 249 | "}, 250 | l_bar = symbols_color.paint("└── "), 251 | current_dir = Colour::White.italic().paint(current_dir.to_string_lossy()), 252 | tgt = target_color.paint("foo"), 253 | foo = link_color.paint(" tests/foo "), 254 | ready = Colour::Green.reverse().paint(" READY "), 255 | ), 256 | "invalid colored output", 257 | ); 258 | 259 | Ok(()) 260 | } 261 | 262 | #[test] 263 | fn test_linking() -> Result<()> { 264 | let input = indoc! {r#" 265 | base_dir = "tests" 266 | 267 | [targets.skip_me] 268 | tags.all_of = ["dont_skip"] 269 | 270 | [targets.my_symlink] 271 | "#}; 272 | let mut stdout = Vec::new(); 273 | 274 | run( 275 | Env { 276 | colored: true, 277 | home: None, 278 | }, 279 | input, 280 | &mut stdout, 281 | Park { 282 | link: true, 283 | ..Park::default() 284 | }, 285 | )?; 286 | 287 | let link_path = "tests/my_symlink"; 288 | let link = PathBuf::from(link_path).read_link(); 289 | fs::remove_file(link_path)?; 290 | 291 | assert!(link.is_ok()); 292 | assert_eq!(str::from_utf8(&stdout).unwrap(), ""); 293 | 294 | Ok(()) 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /tests/data/something: -------------------------------------------------------------------------------- 1 | test/something --------------------------------------------------------------------------------