diff --git a/.gitignore b/.gitignore index db8af27..7fd7373 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,5 @@ # Generated by Cargo, will have compiled files and executables. /target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries. -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - # These are backup files generated by rustfmt. **/*.rs.bk diff --git a/.vscode/settings.json b/.vscode/settings.json index 65fd68a..514eb56 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,9 @@ "editor.rulers": [100], "editor.formatOnSave": true, "editor.inlayHints.enabled": "offUnlessPressed", + "editor.quickSuggestions": { + "strings": "on" + }, "rust-analyzer.rustfmt.extraArgs": ["+nightly"], "rust-analyzer.diagnostics.disabled": ["macro-error"], "rust-analyzer.lens.enable": true diff --git a/Cargo.lock b/Cargo.lock new file mode 100755 index 0000000..95092ef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2017 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "anstream" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "arx" +version = "0.1.0" +dependencies = [ + "clap", + "crossterm 0.27.0", + "flate2", + "git2", + "glob-match", + "indicatif", + "inquire", + "kdl", + "miette", + "reqwest", + "run_script", + "tar", + "thiserror", + "tokio", + "unindent", + "walkdir", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "bumpalo" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.1", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "dunce" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453440c271cf5577fd2a40e4942540cb7d0d2f85e27c8d07dd0023c925a67541" + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + +[[package]] +name = "filetime" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94a7bbaa59354bc20dd75b67f23e2797b4490e9d6928203fb105c79e448c86c" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.16", + "windows-sys 0.36.1", +] + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "fsio" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad0ce30be0cc441b325c5d705c8b613a0ca0d92b6a8953d41bd236dc09a36d0" +dependencies = [ + "dunce", + "rand", +] + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "getrandom" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "git2" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf97ba92db08df386e10c8ede66a2a0369bd277090afd8710e19e38de9ec0cd" +dependencies = [ + "bitflags 2.4.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "h2" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "http" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +dependencies = [ + "console", + "instant", + "number_prefix", + "portable-atomic", + "unicode-width", +] + +[[package]] +name = "inquire" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd05e4e63529f3c9c5f5c668c398217f72756ffe48c85266b49692c55accd1f7" +dependencies = [ + "bitflags 2.4.1", + "crossterm 0.25.0", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "tempfile", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" + +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itoa" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" + +[[package]] +name = "jobserver" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "kdl" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062c875482ccb676fd40c804a40e3824d4464c18c364547456d1c8e8e951ae47" +dependencies = [ + "miette", + "nom", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" + +[[package]] +name = "libgit2-sys" +version = "0.16.1+1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a2bb3680b094add03bb3732ec520ece34da31a8cd2d633d1389d0f0fb60d0c" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", + "miette-derive", + "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "native-tls" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi 0.1.19", + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "618febf65336490dfcf20b73f885f5651a0c89c64c2d4a8c3662585a70bf5bd0" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.99", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f9bd0c2710541a3cda73d6f9ac4f1b240de4ae261065d309dbe73d9dceb42f" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "reqwest" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "run_script" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829f98fdc58d78989dd9af83be28bc15c94a7d77f9ecdb54abbbc0b1829ba9c7" +dependencies = [ + "fsio", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" +dependencies = [ + "lazy_static", + "windows-sys 0.36.1", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e8e5d5b70924f74ff5c6d64d9a5acd91422117c60f48c4e07855238a254553" + +[[package]] +name = "serde_json" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38dd04e3c8279e75b31ef29dbdceebfe5ad89f4d0937213c53f7d49d01b3d5a7" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "syn" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall 0.2.16", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2 0.5.5", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fce9567bd60a67d08a16488756721ba392f24f29006402881e43b19aac64307" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeea4303076558a00714b823f9ad67d58a3bbda1df83d8827d21193156e22f7" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-ident" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 1.0.99", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa76fb221a1f8acddf5b54ace85912606980ad661ac7a503b4570ffd3a624dad" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.99", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" + +[[package]] +name = "web-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed055ab27f941423197eb86b2035720b1a3ce40504df082cac2ecc6ed73335a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "xattr" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7dae5072fe1f8db8f8d29059189ac175196e410e40ba42d5d4684ae2f750995" +dependencies = [ + "libc", + "linux-raw-sys", + "rustix", +] diff --git a/Cargo.toml b/Cargo.toml index 0b75687..a7ffcd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,19 +3,27 @@ name = "arx" version = "0.1.0" edition = "2021" authors = ["Vladislav Mamon "] -description = "Simple CLI for scaffolding projects from templates in a touch." +description = "Simple and user-friendly command-line tool for declarative scaffolding." repository = "https://github.com/norskeld/arx" publish = false [dependencies] -chumsky = { version = "0.8.0" } -clap = { version = "3.2.16", features = ["cargo", "derive"] } -flate2 = { version = "1.0.24" } -kdl = "4.2" -petgraph = { version = "0.6.2", features = ["stable_graph"] } -reqwest = { version = "0.11.11", features = ["json"] } -tar = { version = "0.4.38" } -tokio = { version = "1.20.1", features = ["macros", "rt-multi-thread"] } +clap = { version = "4.4.11", features = ["cargo", "derive"] } +crossterm = "0.27.0" +flate2 = { version = "1.0.28" } +git2 = { version = "0.18.1", features = ["vendored-libgit2"] } +glob-match = { version = "0.2.1" } +indicatif = "0.17.8" +inquire = { version = "0.7.0", features = ["editor"] } +kdl = "=4.6.0" +miette = { version = "=5.10.0", features = ["fancy"] } +reqwest = { version = "0.11.22", features = ["json"] } +run_script = { version = "0.10.1" } +tar = { version = "0.4.40" } +thiserror = { version = "1.0.51" } +tokio = { version = "1.35.0", features = ["macros", "fs", "rt-multi-thread"] } +unindent = "0.2.3" +walkdir = { version = "2.4.0" } [profile.release] lto = "thin" diff --git a/LICENSE b/LICENSE index c9afe90..ba1f9a0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 Vladislav Mamon +Copyright (c) 2024 Vladislav Mamon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c6cda21..eb2ff09 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,41 @@ [![Checks](https://img.shields.io/github/workflow/status/norskeld/arx/checks?style=flat-square&colorA=22272d&colorB=22272d&label=checks)](https://github.com/norskeld/arx/actions/workflows/checks.yml) -> `A`ugmented `R`epository E`x`tractor +Simple and user-friendly command-line tool for declarative scaffolding. -Simple CLI for scaffolding projects from templates in a touch. +## Status -## Features +> [!NOTE] +> +> This is an MVP. +> +> - [Spec] was fleshed out and (mostly) implemented. +> - [Spec] is thoroughly commented and temporarily serves as a reference/documentation. +> - Bugs and uncovered edge cases are to be expected. +> - Test coverage is lacking. -`arx` allows you to make copies of git repositories, much like [degit], but with added sugar on top of its basic functionality to help scaffold projects even faster and easier. +## Installation -Some of that sugar includes: +Right now **arx** can only be installed from source via **Cargo**. -- Ability to define [replacement tags](#replacements) (aka placeholders) and simple [actions](#actions) to perform on the repository being copied. This is done via `arx.kdl` config file using the [KDL document language][kdl], which is really easy to grasp, write and read, unlike (**JSON** and **YAML**). -- Automatically generated prompts based on the `arx.kdl` config, that will allow you to interactively replace placeholders with actual values and (optionally) run only selected actions. +### From source (Cargo) -## Replacements +Make sure to [install Rust toolchain][rust-toolchain] first. After that you can install arx using **Cargo**: -> TODO: Document replacements. - -## Actions - -> TODO: Document actions. +```shell +cargo install --locked --git https://github.com/norskeld/arx +``` ## Acknowledgements -Thanks to [Rich Harris][rich-harris] and his [degit] tool for inspiration. `:^)` +Thanks to [Rich Harris][rich-harris] and his [degit] for inspiration. `:^)` ## License -[MIT](./LICENSE) +[MIT](LICENSE) + +[spec]: spec.kdl [degit]: https://github.com/Rich-Harris/degit -[kdl]: https://github.com/kdl-org/kdl [rich-harris]: https://github.com/Rich-Harris diff --git a/arx.kdl b/arx.kdl deleted file mode 100644 index 0a1ad82..0000000 --- a/arx.kdl +++ /dev/null @@ -1,31 +0,0 @@ -// Static replacements -replacements { - R_NAME "Repository name" - R_DESC "Repository description" - R_AUTHOR "Repository author" -} - -// Actions to perform on files -actions { - suite name="init" { - copy from="path/to/file/or/dir" to="path/to/target" - copy from="glob/pattern" to="path/to/target/dir" - - // Can also be used to rename files or directories - move from="path/to/file/or/dir" to="path/to/target" - move from="glob/pattern" to="path/to/target/dir" - - delete "path/to/file/or/dir" - delete "glob/pattern" - } - - suite name="lint" requires="init" { - run "cargo fmt" - } - - suite name="git" requires="init lint" { - run "git init" - run "git add ." - run "git commit -m 'chore: init'" - } -} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..3b9db9d --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.74.0" diff --git a/rustfmt.toml b/rustfmt.toml index 5b21b97..c1c6559 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -22,6 +22,7 @@ match_block_trailing_comma = true # General width constraints. max_width = 100 +struct_lit_width = 40 # Match tweaks. match_arm_leading_pipes = "Always" # Because I'm a weirdo. diff --git a/spec.kdl b/spec.kdl new file mode 100644 index 0000000..08d4605 --- /dev/null +++ b/spec.kdl @@ -0,0 +1,143 @@ +// Options defined here can be overridden from CLI. +options { + // Delete arx config file after we're done. Defaults to `true`. + delete false +} + +// Actions to run after the repository was successfully downloaded and unpacked. All actions or +// suites of actions run sequentially, there is no concurrency or out-of-order execution for +// predictable outcomes. +// +// You can define either suites of actions — named groups of actions — or a flat list of actions, +// but not both. +// +// Notes: +// +// - Unpacking into an existing destination is forbidden. +// - Invalid or unknown actions, nodes or replacements will be skipped. Warnings will be issued. +// - Action failure terminates the main process. +// - No cleanup on failures by default. +actions { + suite "hello" { + // This action simply echoes the argument to stdout. Raw strings are trimmed by default and + // aligned to the leftmost non-whitespace character. Trimming can be disabled with `trim=false`. + echo r#" + Sup! Let's set everything up. We will: + + - Print this message. + - Ask some questions via prompts. + - Initialize git repository (not for real). + - Run some commands that will use input from prompts. + - Commit everything (again, not for real). + "# + } + + // In this suite we run a series of prompts asking different questions. + // + // Answers will be stored globally and available from any _subsequent_ action or suite of actions. + suite "prompts" { + // Text prompt. + input "repo_name" { + hint "Repository name" + default "norskeld/serpent" + } + + // Editor prompt. This runs the default $EDITOR. + editor "repo_desc" { + hint "Repository description" + default "Scaffolded with arx" + } + + // Select prompt. + select "repo_pm" { + hint "Package manager of choice" + options "npm" "pnpm" "yarn" "bun" + } + + // Number prompt. Accepts both integers and floats. + number "magic_number" { + hint "Magic number" + default 42 + } + + // If no default value provided, prompt will become required. + input "repo_pm_args" { + hint "Additional arguments for package manager" + } + + // Simple confirm prompt. + confirm "should_commit" { + hint "Whether to stage and commit changes after scaffolding" + default false + } + } + + suite "git" { + // This action runs a given shell command and prints its output to stdout. + run "echo git init" + } + + // Here we demonstrate using replacements. + suite "replacements" { + // Replace all occurences of given replacements in files that match the glob pattern. + replace in=".template/**" { + "repo_name" + "repo_desc" + } + + // Replace all occurences of given replacements in _all_ files. This is equivalent to "**/*" as + // the glob pattern. + replace { + "repo_pm" + } + + // Trying to run a non-existent replacement will do nothing (a warning will be issued though). + replace { + "NONEXISTENTREPLACEMENT" + } + } + + // In this suite we demonstrate actions for operating on files. All these actions support glob + // patterns, except the `to` field, that should be a relative path. + // + // Note: + // + // - Paths don't expand, i.e. ~ won't expand to the home directory and env vars won't work either. + // - Paths don't escape the target directory, so delete "../../../**/*" won't work. + suite "files" { + cp from=".template/*.toml" to="." + rm ".template/*.toml" + mv from=".template/**/*" to="." + rm ".template" + } + + // Here we demonstrate how to inject prompts' values. + suite "install" { + // To disambiguate whether {repo_pm} is part of a command or is a replacement that should be + // replaced with something, we pass `inject` node that explicitly tells arx what to inject + // into the command. + // + // All replacements are processed _before_ running a command. + run "{repo_pm} install {repo_pm_args}" { + inject "repo_pm" "repo_pm_args" + } + } + + // Here we demonstrate multiline commands using `run`. + suite "commit" { + // Similarly to the `echo` action you can use raw strings to define multiline commands. Plus, + // you don't have to escape anything. + // + // The action below will be executed as if it were two separate `run` actions: + // + // run "git add ." + // run "git commit -m 'chore: init repository'" + // + // You can name `run` actions for clarity, otherwise it will use either the command itself or + // the first line of a multiline command as the hint. + run name="stage and commit" r#" + echo git add . + echo git commit -m 'chore: init repository' + "# + } +} diff --git a/src/actions/actions.rs b/src/actions/actions.rs new file mode 100644 index 0000000..70698fa --- /dev/null +++ b/src/actions/actions.rs @@ -0,0 +1,365 @@ +use std::path::{Path, PathBuf}; +use std::process; + +use crossterm::style::Stylize; +use miette::Diagnostic; +use run_script::ScriptOptions; +use thiserror::Error; +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; +use unindent::Unindent; + +use crate::actions::State; +use crate::config::actions::*; +use crate::config::value::*; +use crate::path::{PathClean, Traverser}; +use crate::spinner::Spinner; + +#[derive(Debug, Diagnostic, Error)] +pub enum ActionError { + #[error("{message}")] + #[diagnostic(code(arx::actions::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + +impl Copy { + pub async fn execute

(&self, root: P) -> miette::Result<()> + where + P: AsRef, + { + let destination = root.as_ref().join(&self.to); + + let traverser = Traverser::new(root.as_ref()) + .ignore_dirs(true) + .contents_first(true) + .pattern(&self.from); + + println!( + "⋅ Copying: {}", + format!("{} ╌╌ {}", &self.from, &self.to).dim() + ); + + for matched in traverser.iter().flatten() { + let name = matched + .path + .file_name() + .ok_or_else(|| miette::miette!("Path should end with valid file name."))?; + + let target = destination.join(name).clean(); + + if !self.overwrite && target.is_file() { + continue; + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to create directory structure for '{}'.", + parent.display() + ), + source, + } + })?; + + fs::copy(&matched.path, &target).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to copy from '{}' to '{}'.", + matched.path.display(), + target.display() + ), + source, + } + })?; + } + + println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); + } + + Ok(()) + } +} + +impl Move { + pub async fn execute

(&self, root: P) -> miette::Result<()> + where + P: AsRef, + { + let destination = root.as_ref().join(&self.to); + + let traverser = Traverser::new(root.as_ref()) + .ignore_dirs(false) + .contents_first(true) + .pattern(&self.from); + + println!( + "⋅ Moving: {}", + format!("{} ╌╌ {}", &self.from, &self.to).dim() + ); + + for matched in traverser.iter().flatten() { + let name = matched + .path + .file_name() + .ok_or_else(|| miette::miette!("Path should end with valid file name."))?; + + let target = destination.join(name).clean(); + + if !self.overwrite { + if let Ok(true) = target.try_exists() { + continue; + } + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to create directory structure for '{}'.", + parent.display() + ), + source, + } + })?; + + fs::rename(&matched.path, &target).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to move from '{}' to '{}'.", + matched.path.display(), + target.display() + ), + source, + } + })?; + } + + println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); + } + + Ok(()) + } +} + +impl Delete { + pub async fn execute

(&self, root: P) -> miette::Result<()> + where + P: AsRef, + { + let traverser = Traverser::new(root.as_ref()) + .ignore_dirs(false) + .contents_first(false) + .pattern(&self.target); + + println!("⋅ Deleting: {}", &self.target.clone().dim()); + + for matched in traverser.iter().flatten() { + let target = &matched.path.clean(); + + if matched.is_file() { + fs::remove_file(target).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to delete file '{}'.", target.display()), + source, + } + })?; + } else if matched.is_dir() { + fs::remove_dir_all(target).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to delete directory '{}'.", target.display()), + source, + } + })?; + } else { + continue; + } + + println!("└─ {}", &target.display()); + } + + Ok(()) + } +} + +impl Echo { + pub async fn execute(&self, state: &State) -> miette::Result<()> { + let message = if self.trim { + self.message.trim() + } else { + &self.message + }; + + let mut message = message.unindent(); + + if let Some(injects) = &self.injects { + for inject in injects { + if let Some(Value::String(value)) = state.get(inject) { + message = message.replace(&format!("{{{inject}}}"), value); + } + } + } + + Ok(println!("{message}")) + } +} + +impl Run { + pub async fn execute

(&self, root: P, state: &State) -> miette::Result<()> + where + P: Into + AsRef, + { + let mut command = self.command.clone(); + let spinner = Spinner::new(); + + if let Some(injects) = &self.injects { + for inject in injects { + if let Some(Value::String(value)) = state.get(inject) { + command = command.replace(&format!("{{{inject}}}"), value); + } + } + } + + let name = self + .name + .clone() + .or_else(|| { + let lines = command.trim().lines().count(); + + if lines > 1 { + Some(command.trim().lines().next().unwrap().to_string() + "...") + } else { + Some(command.clone()) + } + }) + .unwrap(); + + let options = ScriptOptions { + working_directory: Some(root.into()), + ..ScriptOptions::new() + }; + + spinner.set_message(format!("{}", name.clone().grey())); + + // Actually run the script. + let (code, output, err) = run_script::run_script!(command, options) + .map_err(|_| miette::miette!("Failed to run script."))?; + + let has_failed = code > 0; + + // Re-format depending on the exit code. + let name = if has_failed { name.red() } else { name.green() }; + + // Stopping before printing output/errors, otherwise the spinner message won't be cleared. + spinner.stop_with_message(format!("{name}\n",)); + + if has_failed { + if !err.is_empty() { + eprintln!("{err}"); + } + + process::exit(1); + } + + Ok(println!("{}", output.trim())) + } +} + +impl Prompt { + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + match self { + | Self::Confirm(prompt) => prompt.execute(state).await, + | Self::Editor(prompt) => prompt.execute(state).await, + | Self::Input(prompt) => prompt.execute(state).await, + | Self::Number(prompt) => prompt.execute(state).await, + | Self::Select(prompt) => prompt.execute(state).await, + } + } +} + +impl Replace { + pub async fn execute

(&self, root: P, state: &State) -> miette::Result<()> + where + P: AsRef, + { + let spinner = Spinner::new(); + + // If no glob pattern specified, traverse all files. + let pattern = self.glob.clone().unwrap_or("**/*".to_string()); + + let traverser = Traverser::new(root.as_ref()) + .ignore_dirs(true) + .contents_first(true) + .pattern(&pattern); + + if !self.replacements.is_empty() { + spinner.set_message("Performing replacements"); + + for matched in traverser.iter().flatten() { + let mut buffer = String::new(); + + let mut file = File::open(&matched.path).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to open file '{}'.", &matched.path.display()), + source, + } + })?; + + file.read_to_string(&mut buffer).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to read file '{}'.", &matched.path.display()), + source, + } + })?; + + for replacement in &self.replacements { + if let Some(Value::String(value)) = state.get(replacement) { + buffer = buffer.replace(&format!("{{{replacement}}}"), value); + } + } + + let mut result = OpenOptions::new() + .write(true) + .truncate(true) + .open(&matched.path) + .await + .map_err(|source| { + ActionError::Io { + message: format!( + "Failed to open file '{}' for writing.", + &matched.path.display() + ), + source, + } + })?; + + result + .write_all(buffer.as_bytes()) + .await + .map_err(|source| { + ActionError::Io { + message: format!("Failed to write to the file '{}'.", &matched.path.display()), + source, + } + })?; + } + + spinner.stop_with_message("Successfully performed replacements\n"); + } + + Ok(()) + } +} + +impl Unknown { + pub async fn execute(&self) -> miette::Result<()> { + let name = self.name.as_str().yellow(); + let message = format!("! Unknown action: {name}").yellow(); + + Ok(println!("{message}")) + } +} diff --git a/src/actions/executor.rs b/src/actions/executor.rs new file mode 100644 index 0000000..dce5bbe --- /dev/null +++ b/src/actions/executor.rs @@ -0,0 +1,144 @@ +use std::collections::HashMap; +use std::io; + +use crossterm::style::Stylize; +use miette::Diagnostic; +use thiserror::Error; +use tokio::fs; + +use crate::config::{ActionSingle, ActionSuite, Actions, Config, Value}; + +#[derive(Debug, Diagnostic, Error)] +pub enum ExecutorError { + #[error("{message}")] + #[diagnostic(code(arx::actions::executor::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + +#[derive(Debug)] +pub struct State { + /// A map of replacements and associated values. + values: HashMap, +} + +impl State { + /// Create a new state. + pub fn new() -> Self { + Self { values: HashMap::new() } + } + + /// Get a value from the state. + pub fn get(&self, name: &str) -> Option<&Value> { + self.values.get(name) + } + + /// Set a value in the state. + pub fn set + AsRef>(&mut self, name: N, replacement: Value) { + self.values.insert(name.into(), replacement); + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +/// An executor. +#[derive(Debug)] +pub struct Executor { + /// The config to use for execution. + config: Config, +} + +impl Executor { + /// Create a new executor. + pub fn new(config: Config) -> Self { + Self { config } + } + + /// Execute the actions. + pub async fn execute(&self) -> miette::Result<()> { + match &self.config.actions { + | Actions::Suite(suites) => self.suite(suites).await?, + | Actions::Flat(actions) => self.flat(actions).await?, + | Actions::Empty => println!("No actions found."), + }; + + // Delete the config file if needed. + if self.config.options.delete { + fs::remove_file(&self.config.config) + .await + .map_err(|source| { + ExecutorError::Io { + message: "Failed to delete config file.".to_string(), + source, + } + })?; + } + + Ok(()) + } + + /// Execute a suite of actions. + async fn suite(&self, suites: &[ActionSuite]) -> miette::Result<()> { + let mut state = State::new(); + + for ActionSuite { name, actions, .. } in suites { + let hint = "Suite".cyan(); + let name = name.clone().green(); + + println!("[{hint}: {name}]\n"); + + // Man, I hate how peekable iterators work in Rust. + let mut it = actions.iter().peekable(); + + while let Some(action) = it.next() { + self.single(action, &mut state).await?; + + // Do not print a trailing newline if the current and the next actions are prompts to + // slightly improve visual clarity. Essentially, this way prompts are grouped. + if !matches!( + (action, it.peek()), + (ActionSingle::Prompt(_), Some(ActionSingle::Prompt(_))) + ) { + println!(); + } + } + } + + Ok(()) + } + + /// Execute a flat list of actions. + async fn flat(&self, actions: &[ActionSingle]) -> miette::Result<()> { + let mut state = State::new(); + + for action in actions { + self.single(action, &mut state).await?; + println!(); + } + + Ok(()) + } + + /// Execute a single action. + async fn single(&self, action: &ActionSingle, state: &mut State) -> miette::Result<()> { + let root = &self.config.root; + + match action { + | ActionSingle::Copy(action) => action.execute(root).await, + | ActionSingle::Move(action) => action.execute(root).await, + | ActionSingle::Delete(action) => action.execute(root).await, + | ActionSingle::Echo(action) => action.execute(state).await, + | ActionSingle::Run(action) => action.execute(root, state).await, + | ActionSingle::Prompt(action) => action.execute(state).await, + | ActionSingle::Replace(action) => action.execute(root, state).await, + | ActionSingle::Unknown(action) => action.execute().await, + } + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs new file mode 100644 index 0000000..25e87d3 --- /dev/null +++ b/src/actions/mod.rs @@ -0,0 +1,5 @@ +pub use executor::*; + +mod actions; +mod executor; +mod prompts; diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs new file mode 100644 index 0000000..486b5af --- /dev/null +++ b/src/actions/prompts.rs @@ -0,0 +1,184 @@ +use std::fmt::Display; +use std::process; + +use crossterm::style::Stylize; +use inquire::formatter::StringFormatter; +use inquire::ui::{Color, RenderConfig, StyleSheet, Styled}; +use inquire::{required, CustomType}; +use inquire::{Confirm, Editor, InquireError, Select, Text}; + +use crate::actions::State; +use crate::config::prompts::*; +use crate::config::{Number, Value}; + +/// Helper module holding useful functions. +mod helpers { + use super::*; + + /// Returns configured theme. + pub fn theme<'r>() -> RenderConfig<'r> { + let default = RenderConfig::default(); + let stylesheet = StyleSheet::default(); + + let prompt_prefix = Styled::new("?").with_fg(Color::LightYellow); + let answered_prefix = Styled::new("✓").with_fg(Color::LightGreen); + + default + .with_prompt_prefix(prompt_prefix) + .with_answered_prompt_prefix(answered_prefix) + .with_default_value(stylesheet.with_fg(Color::DarkGrey)) + } + + /// Returns a formatter that shows `` if the input is empty. + pub fn empty_formatter<'s>() -> StringFormatter<'s> { + &|input| { + if input.is_empty() { + "".dark_grey().to_string() + } else { + input.to_string() + } + } + } + + /// Helper method that generates `(name, hint, help)`. + pub fn messages(name: S, hint: S) -> (String, String, String) + where + S: Into + AsRef + Display, + { + let name = name.into(); + let hint = format!("{}:", &hint); + let help = format!("The answer will be mapped to: {}", &name); + + (name, hint, help) + } + + /// Handle interruption/cancelation events. + pub fn interrupt(err: InquireError) { + match err { + | InquireError::OperationCanceled => { + process::exit(0); + }, + | InquireError::OperationInterrupted => { + println!("{}", "".red()); + process::exit(0); + }, + | _ => {}, + } + } +} + +impl ConfirmPrompt { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + let (name, hint, help) = helpers::messages(&self.name, &self.hint); + + let mut prompt = Confirm::new(&hint) + .with_help_message(&help) + .with_render_config(helpers::theme()); + + if let Some(default) = self.default { + prompt = prompt.with_default(default); + } + + match prompt.prompt() { + | Ok(value) => state.set(name, Value::Bool(value)), + | Err(err) => helpers::interrupt(err), + } + + Ok(()) + } +} + +impl InputPrompt { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + let (name, hint, help) = helpers::messages(&self.name, &self.hint); + + let mut prompt = Text::new(&hint) + .with_help_message(&help) + .with_formatter(helpers::empty_formatter()) + .with_render_config(helpers::theme()); + + if let Some(default) = &self.default { + prompt = prompt.with_default(default); + } else { + prompt = prompt.with_validator(required!("This field is required.")); + } + + match prompt.prompt() { + | Ok(value) => state.set(name, Value::String(value)), + | Err(err) => helpers::interrupt(err), + } + + Ok(()) + } +} + +impl NumberPrompt { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + let (name, hint, help) = helpers::messages(&self.name, &self.hint); + + let mut prompt = CustomType::::new(&hint) + .with_help_message(&help) + .with_formatter(&|input| input.to_string()) + .with_render_config(helpers::theme()); + + if let Some(default) = &self.default { + prompt = prompt.with_default(default.to_owned()); + } else { + // NOTE: This is a bit confusing, but essentially this message will be showed when no input + // was provided by the user. + prompt = prompt.with_error_message("This field is required."); + } + + match prompt.prompt() { + | Ok(value) => state.set(name, Value::Number(value)), + | Err(err) => helpers::interrupt(err), + } + + Ok(()) + } +} + +impl SelectPrompt { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + let (name, hint, help) = helpers::messages(&self.name, &self.hint); + + let options = self.options.iter().map(String::to_string).collect(); + + let prompt = Select::new(&hint, options) + .with_help_message(&help) + .with_render_config(helpers::theme()); + + match prompt.prompt() { + | Ok(value) => state.set(name, Value::String(value)), + | Err(err) => helpers::interrupt(err), + } + + Ok(()) + } +} + +impl EditorPrompt { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + let (name, hint, help) = helpers::messages(&self.name, &self.hint); + + let mut prompt = Editor::new(&hint) + .with_help_message(&help) + .with_render_config(helpers::theme()); + + if let Some(default) = &self.default { + prompt = prompt.with_predefined_text(default); + } + + match prompt.prompt() { + | Ok(value) => state.set(name, Value::String(value)), + | Err(err) => helpers::interrupt(err), + } + + Ok(()) + } +} diff --git a/src/app.rs b/src/app.rs index a61940d..ed46168 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,63 +1,213 @@ -use std::fmt; - -use clap::Parser; - -use crate::parser; -use crate::repository::{Repository, RepositoryMeta}; -use crate::tar; - -/// Newtype for app errors which get propagated across the app. -#[derive(Debug)] -pub struct AppError(pub String); - -impl fmt::Display for AppError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{message}", message = self.0) - } +use std::fs; +use std::io; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use crossterm::style::Stylize; +use miette::Diagnostic; +use thiserror::Error; + +use crate::actions::Executor; +use crate::config::{Config, ConfigOptionsOverrides}; +use crate::repository::{LocalRepository, RemoteRepository}; +use crate::unpacker::Unpacker; + +#[derive(Debug, Diagnostic, Error)] +pub enum AppError { + #[error("{message}")] + #[diagnostic(code(actions::app::io))] + Io { + message: String, + #[source] + source: io::Error, + }, } #[derive(Parser, Debug)] -#[clap(version, about, long_about = None)] -pub struct App { - /// Repository to download. - #[clap(name = "target")] - target: String, - - /// Directory to download to. - #[clap(name = "path")] - path: Option, - - /// Init git repository. - #[clap(short, long, display_order = 0)] - git: bool, - - /// Remove imp config after download. - #[clap(short, long, display_order = 1)] - remove: bool, - - /// Do not run actions defined in the repository. - #[clap(short, long, display_order = 2)] - ignore: bool, +#[command(version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: BaseCommand, +} - /// Download at specific ref (branch, tag, commit). - #[clap(short, long, display_order = 3)] - meta: Option, +#[derive(Debug, Subcommand)] +pub enum BaseCommand { + /// Scaffold from a remote repository. + Remote { + /// Repository to use for scaffolding. + src: String, + + /// Directory to scaffold to. + path: Option, + + /// Scaffold from a specified ref (branch, tag, or commit). + #[arg(name = "REF", short = 'r', long = "ref")] + meta: Option, + + /// Delete arx config after scaffolding. + #[arg(short, long)] + delete: Option, + }, + /// Scaffold from a local repository. + Local { + /// Repository to use for scaffolding. + src: String, + + /// Directory to scaffold to. + path: Option, + + /// Scaffold from a specified ref (branch, tag, or commit). + #[arg(name = "REF", short = 'r', long = "ref")] + meta: Option, + + /// Delete arx config after scaffolding. + #[arg(short, long)] + delete: Option, + }, } -pub async fn run() -> Result<(), AppError> { - let options = App::parse(); +#[derive(Debug)] +pub struct App { + cli: Cli, +} - // Parse repository information from the CLI argument. - let repository = parser::shortcut(&options.target)?; +impl App { + pub fn new() -> Self { + Self { cli: Cli::parse() } + } - // Now check if any specific meta (ref) was passed, if so, then use it; otherwise use parsed meta. - let meta = options.meta.map_or(repository.meta, RepositoryMeta); - let repository = Repository { meta, ..repository }; + pub async fn run(self) -> miette::Result<()> { + // Slightly tweak miette. + miette::set_hook(Box::new(|_| { + Box::new( + miette::MietteHandlerOpts::new() + .terminal_links(false) + .context_lines(3) + .tab_width(4) + .build(), + ) + }))?; + + // Load the config. + let config = match self.cli.command { + | BaseCommand::Remote { src, path, meta, delete } => { + let options = ConfigOptionsOverrides { delete }; + Self::remote(src, path, meta, options).await? + }, + | BaseCommand::Local { src, path, meta, delete } => { + let options = ConfigOptionsOverrides { delete }; + Self::local(src, path, meta, options).await? + }, + }; + + // Create executor and kick off execution. + let executor = Executor::new(config); + executor.execute().await?; + + Ok(()) + } - // Fetch the tarball as bytes (compressed). - let tarball = repository.fetch().await?; + /// Preparation flow for remote repositories. + async fn remote( + src: String, + path: Option, + meta: Option, + overrides: ConfigOptionsOverrides, + ) -> miette::Result { + // Parse repository. + let remote = RemoteRepository::new(src, meta)?; + + let name = path.unwrap_or(remote.repo.clone()); + let destination = PathBuf::from(name); + + // Check if destination already exists before downloading. + if let Ok(true) = &destination.try_exists() { + miette::bail!( + "Failed to scaffold: '{}' already exists.", + destination.display() + ); + } + + // Fetch the tarball as bytes (compressed). + let tarball = remote.fetch().await?; + + // Decompress and unpack the tarball. + let unpacker = Unpacker::new(tarball); + unpacker.unpack_to(&destination)?; + + // Now we need to read the config (if it is present). + let mut config = Config::new(&destination); + + config.load()?; + config.override_with(overrides); + + Ok(config) + } - tar::unpack(&tarball, &options.path.unwrap_or(repository.repo))?; + /// Preparation flow for local repositories. + async fn local( + src: String, + path: Option, + meta: Option, + overrides: ConfigOptionsOverrides, + ) -> miette::Result { + // Create repository. + let local = LocalRepository::new(src, meta); + + let destination = if let Some(destination) = path { + PathBuf::from(destination) + } else { + local + .source + .file_name() + .map(PathBuf::from) + .unwrap_or_default() + }; + + // Check if destination already exists before performing local clone. + if let Ok(true) = &destination.try_exists() { + miette::bail!( + "Failed to scaffold: '{}' already exists.", + destination.display() + ); + } + + // Copy the directory. + local.copy(&destination)?; + + println!("{}", "~ Cloned repository".dim()); + + // Checkout the ref. + local.checkout(&destination)?; + + println!("{} {}", "~ Checked out ref:".dim(), local.meta.0.dim()); + + // Delete inner .git directory. + let inner_git = destination.join(".git"); + + if let Ok(true) = inner_git.try_exists() { + fs::remove_dir_all(inner_git).map_err(|source| { + AppError::Io { + message: "Failed to remove inner .git directory.".to_string(), + source, + } + })?; + + println!("{}", "~ Removed inner .git directory\n".dim()); + } + + // Now we need to read the config (if it is present). + let mut config = Config::new(&destination); + + config.load()?; + config.override_with(overrides); + + Ok(config) + } +} - Ok(()) +impl Default for App { + fn default() -> Self { + Self::new() + } } diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index b101eed..0000000 --- a/src/config.rs +++ /dev/null @@ -1,247 +0,0 @@ -use kdl::{KdlDocument, KdlEntry, KdlNode}; - -use crate::graph::{DependenciesGraph, Node, Step}; - -/// Represents a replacement action. -#[derive(Debug)] -pub struct Replacement { - /// Replacement tag (name). - /// - /// ```kdl - /// replacements { - /// TAG "Tag description" - /// ^^^ - /// } - /// ``` - pub tag: String, - /// Replacement tag description. If not defined, will fallback to `tag`. - /// - /// ```kdl - /// replacements { - /// TAG "Tag description" - /// ^^^^^^^^^^^^^^^^^ - /// } - /// ``` - pub description: String, -} - -/// Represents an action that can be either an [ActionSuite] *or* an [ActionSingle]. -/// -/// So actions should be defined either like this: -/// -/// ```kdl -/// actions { -/// suite name="suite-one" { ... } -/// suite name="suite-two" { ... } -/// ... -/// } -/// ``` -/// -/// Or like this: -/// -/// ```kdl -/// actions { -/// copy from="path/to/file/or/dir" to="path/to/target" -/// move from="path/to/file/or/dir" to="path/to/target" -/// ... -/// } -/// ``` -#[derive(Debug)] -pub enum Action { - Suite(Vec), - Single(Vec), -} - -/// A suite of actions that contains a flat list of single actions and may also depend on other -/// suites (hence the **requirements** field). -#[derive(Clone, Debug)] -pub struct ActionSuite { - pub name: String, - pub actions: Vec, - pub requirements: Vec, -} - -impl Node for ActionSuite { - type Item = String; - - fn dependencies(&self) -> &[Self::Item] { - &self.requirements[..] - } - - fn matches(&self, dependency: &Self::Item) -> bool { - self.name == *dependency - } -} - -/// A single "atomic" action. -#[derive(Clone, Debug)] -pub enum ActionSingle { - /// Copies a file or directory. Glob-friendly. Overwrites by default. - Copy { - from: Option, - to: Option, - overwrite: bool, - }, - /// Moves a file or directory. Glob-friendly. Overwrites by default. - Move { - from: Option, - to: Option, - overwrite: bool, - }, - /// Deletes a file or directory. Glob-friendly. - Delete { target: Option }, - /// Runs an arbitrary command in the shell. - Run { command: Option }, - /// Fallback action for pattern matching ergonomics. - Unknown, -} - -/// Resolves requirements (dependencies) for an [ActionSuite]. -pub fn resolve_requirements(suites: &[ActionSuite]) -> (Vec, Vec) { - let graph = DependenciesGraph::from(suites); - - graph.fold((vec![], vec![]), |(mut resolved, mut unresolved), next| { - match next { - | Step::Resolved(suite) => resolved.push(suite.clone()), - | Step::Unresolved(dep) => unresolved.push(dep.clone()), - } - - (resolved, unresolved) - }) -} - -/// Gets actions from a KDL document. -pub fn get_actions(doc: &KdlDocument) -> Option { - doc - .get("actions") - .and_then(|node| node.children()) - .map(|children| { - let nodes = children.nodes(); - - if nodes.iter().all(is_suite) { - let suites = nodes.iter().filter_map(to_action_suite).collect::>(); - Action::Suite(suites) - } else { - let actions = nodes.iter().filter_map(to_action).collect::>(); - Action::Single(actions) - } - }) -} - -/// Gets replacements from a KDL document. -pub fn get_replacements(doc: &KdlDocument) -> Option> { - doc - .get("replacements") - .and_then(|node| node.children()) - .map(|children| { - children - .nodes() - .iter() - .filter_map(to_replacement) - .collect::>() - }) -} - -// Helpers and mappers. - -fn to_replacement(node: &KdlNode) -> Option { - let tag = node.name().to_string(); - let description = node - .get(0) - .and_then(entry_to_string) - .unwrap_or_else(|| tag.clone()); - - Some(Replacement { tag, description }) -} - -fn to_action(node: &KdlNode) -> Option { - let action = to_action_single(node); - - if let ActionSingle::Unknown = action { - None - } else { - Some(action) - } -} - -fn to_action_suite(node: &KdlNode) -> Option { - let name = node.get("name").and_then(entry_to_string); - let requirements = node.get("requires").and_then(entry_to_string).map(|value| { - value - .split_ascii_whitespace() - .map(str::to_string) - .collect::>() - }); - - let actions = node.children().map(|children| { - children - .nodes() - .iter() - .map(to_action_single) - .collect::>() - }); - - let suite = ( - name, - actions.unwrap_or_default(), - requirements.unwrap_or_default(), - ); - - match suite { - | (Some(name), actions, requirements) => { - Some(ActionSuite { - name, - actions, - requirements, - }) - }, - | _ => None, - } -} - -/// TODO: This probably should be refactored and abstracted away into something separate. -fn to_action_single(node: &KdlNode) -> ActionSingle { - let action_kind = node.name().to_string(); - - match action_kind.to_ascii_lowercase().as_str() { - | "copy" => { - ActionSingle::Copy { - from: node.get("from").and_then(entry_to_string), - to: node.get("to").and_then(entry_to_string), - overwrite: node - .get("overwrite") - .and_then(|value| value.value().as_bool()) - .unwrap_or(true), - } - }, - | "move" => { - ActionSingle::Move { - from: node.get("from").and_then(entry_to_string), - to: node.get("to").and_then(entry_to_string), - overwrite: node - .get("overwrite") - .and_then(|value| value.value().as_bool()) - .unwrap_or(true), - } - }, - | "delete" => { - ActionSingle::Delete { - target: node.get(0).and_then(entry_to_string), - } - }, - | "run" => { - ActionSingle::Run { - command: node.get(0).and_then(entry_to_string), - } - }, - | _ => ActionSingle::Unknown, - } -} - -fn is_suite(node: &KdlNode) -> bool { - node.name().value().to_string().eq("suite") -} - -fn entry_to_string(entry: &KdlEntry) -> Option { - entry.value().as_string().map(str::to_string) -} diff --git a/src/config/actions.rs b/src/config/actions.rs new file mode 100644 index 0000000..4cab53c --- /dev/null +++ b/src/config/actions.rs @@ -0,0 +1,101 @@ +use std::collections::HashSet; + +use crate::config::prompts::*; + +/// Copies a file or directory. Glob-friendly. Overwrites by default. +#[derive(Debug)] +pub struct Copy { + /// Source(s) to copy. + pub from: String, + /// Where to copy to. + pub to: String, + /// Whether to overwrite or not. Defaults to `true`. + pub overwrite: bool, +} + +/// Moves a file or directory. Glob-friendly. Overwrites by default. +#[derive(Debug)] +pub struct Move { + /// Source(s) to move. + pub from: String, + /// Where to move to. + pub to: String, + /// Whether to overwrite or not. Defaults to `true`. + pub overwrite: bool, +} + +/// Deletes a file or directory. Glob-friendly. +#[derive(Debug)] +pub struct Delete { + /// Target to delete. + pub target: String, +} + +/// Echoes a message to stdout. +#[derive(Debug)] +pub struct Echo { + /// Message to output. + pub message: String, + /// An optional list of placeholders to be injected into the command. + /// + /// ```kdl + /// echo "Hello {R_PM}" { + /// inject "R_PM" + /// } + /// ``` + /// + /// All placeholders are processed _before_ running a command. + pub injects: Option>, + /// Whether to trim multiline message or not. Defaults to `true`. + pub trim: bool, +} + +/// Runs an arbitrary command in the shell. +#[derive(Debug)] +pub struct Run { + /// Command name. Optional, defaults either to the command itself or to the first line of + /// the multiline command. + pub name: Option, + /// Command to run in the shell. + pub command: String, + /// An optional list of placeholders to be injected into the command. Consider the following + /// example: + /// + /// We use `inject` to disambiguate whether `{R_PM}` is part of a command or is a placeholder + /// that should be replaced with something. + /// + /// ```kdl + /// run "{R_PM} install {R_PM_ARGS}" { + /// inject "R_PM" "R_PM_ARGS" + /// } + /// ``` + /// + /// All placeholders are processed _before_ running a command. + pub injects: Option>, +} + +/// Prompt actions. +#[derive(Debug)] +pub enum Prompt { + Input(InputPrompt), + Number(NumberPrompt), + Select(SelectPrompt), + Confirm(ConfirmPrompt), + Editor(EditorPrompt), +} + +/// Execute given replacements using values provided by prompts. Optionally, only apply +/// replacements to files matching the provided glob. +#[derive(Debug)] +pub struct Replace { + /// Replacements to apply. + pub replacements: HashSet, + /// Optional glob to limit files to apply replacements to. + pub glob: Option, +} + +/// Fallback action for pattern matching ergonomics and reporting purposes. +#[derive(Debug)] +pub struct Unknown { + pub name: String, +} diff --git a/src/config/config.rs b/src/config/config.rs new file mode 100644 index 0000000..f230152 --- /dev/null +++ b/src/config/config.rs @@ -0,0 +1,576 @@ +use std::collections::HashSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use kdl::{KdlDocument, KdlNode}; +use miette::{Diagnostic, LabeledSpan, NamedSource, Report}; +use thiserror::Error; + +use crate::config::actions::*; +use crate::config::prompts::*; +use crate::config::value::*; +use crate::config::KdlUtils; + +const CONFIG_NAME: &str = "arx.kdl"; + +/// Helper macro to create a [ConfigError::Diagnostic] in a slightly less verbose way. +macro_rules! diagnostic { + ($source:ident = $code:expr, $($key:ident = $value:expr,)* $fmt:literal $($arg:tt)*) => { + ConfigError::Diagnostic( + miette::Report::from( + miette::diagnostic!($($key = $value,)* $fmt $($arg)*) + ).with_source_code(Arc::clone($code)) + ) + }; +} + +#[derive(Debug, Diagnostic, Error)] +pub enum ConfigError { + #[error("{message}")] + #[diagnostic(code(arx::config::io))] + Io { + message: String, + #[source] + source: io::Error, + }, + + #[error(transparent)] + #[diagnostic(transparent)] + Kdl(kdl::KdlError), + + #[error("{0}")] + #[diagnostic(transparent)] + Diagnostic(Report), +} + +/// Config options. These may be overriden from the CLI. +#[derive(Debug)] +pub struct ConfigOptions { + /// Whether to delete the config after we (successfully) done running. + pub delete: bool, +} + +impl Default for ConfigOptions { + fn default() -> Self { + Self { delete: true } + } +} + +/// Config options that may override parsed options. +#[derive(Debug, Default)] +pub struct ConfigOptionsOverrides { + /// Whether to delete the config after we (successfully) done running. + pub delete: Option, +} + +/// Represents a config actions set that can be a vec of [ActionSuite] *or* [ActionSingle]. +/// +/// So, actions should be defined either like this: +/// +/// ```kdl +/// actions { +/// suite "suite-one" { ... } +/// suite "suite-two" { ... } +/// ... +/// } +/// ``` +/// +/// Or like this: +/// +/// ```kdl +/// actions { +/// cp from="..." to="..." +/// mv from="..." to="..." +/// ... +/// } +/// ``` +#[derive(Debug)] +pub enum Actions { + Suite(Vec), + Flat(Vec), + Empty, +} + +/// A suite of actions that contains a flat list of [ActionSingle]. +#[derive(Debug)] +pub struct ActionSuite { + /// Suite name. + pub name: String, + /// Suite actions to run. + pub actions: Vec, +} + +/// A single "atomic" action. +#[derive(Debug)] +pub enum ActionSingle { + /// Copies a file or directory. Glob-friendly. Overwrites by default. + Copy(Copy), + /// Moves a file or directory. Glob-friendly. Overwrites by default. + Move(Move), + /// Deletes a file or directory. Glob-friendly. + Delete(Delete), + /// Echoes a message to stdout. + Echo(Echo), + /// Runs an arbitrary command in the shell. + Run(Run), + /// Executes a prompt asking a declaratively defined "question". + Prompt(Prompt), + /// Execute given replacements using values provided by prompts. Optionally, only apply + /// replacements to files matching the provided glob. + Replace(Replace), + /// Fallback action for pattern matching ergonomics and reporting purposes. + Unknown(Unknown), +} + +/// Arx config. +#[derive(Debug)] +pub struct Config { + /// Config directory. + pub root: PathBuf, + /// Source. Wrapped in an [Arc] for cheap clones. + pub source: Arc, + /// Config file path. + pub config: PathBuf, + /// Config options. + pub options: ConfigOptions, + /// Actions. + pub actions: Actions, +} + +impl Config { + /// Creates a new config from the given path and options. + pub fn new(root: &Path) -> Self { + let root = root.to_path_buf(); + let config = root.join(CONFIG_NAME); + + // NOTE: Creating dummy source first, will be overwritten with actual data on load. This is done + // because of some limitations around `NamedSource` and related entities like `SourceCode` which + // I couldn't figure out. + let source = Arc::new(NamedSource::new( + config.display().to_string(), + String::default(), + )); + + Self { + config, + options: ConfigOptions::default(), + actions: Actions::Empty, + source, + root, + } + } + + /// Tries to apply the given overrides to the config options. + pub fn override_with(&mut self, overrides: ConfigOptionsOverrides) { + if let Some(delete) = overrides.delete { + self.options.delete = delete; + } + } + + /// Tries to load and parse the config. + pub fn load(&mut self) -> Result<(), ConfigError> { + if self.exists() { + let doc = self.parse()?; + self.options = self.get_config_options(&doc)?; + self.actions = self.get_config_actions(&doc)?; + } + + Ok(()) + } + + /// Checks if the config exists under `self.root`. + fn exists(&self) -> bool { + self.config.try_exists().unwrap_or(false) + } + + /// Reads and parses the config into a [KdlDocument]. + fn parse(&mut self) -> Result { + let filename = self.root.join(CONFIG_NAME); + + let contents = fs::read_to_string(&filename).map_err(|source| { + ConfigError::Io { + message: "Failed to read the config.".to_string(), + source, + } + })?; + + let document = contents.parse().map_err(ConfigError::Kdl)?; + + // Replace dummy source with actual data. + self.source = Arc::new(NamedSource::new(filename.display().to_string(), contents)); + + Ok(document) + } + + /// Tries to parse options from the config. + fn get_config_options(&self, doc: &KdlDocument) -> Result { + let options = doc + .get("options") + .and_then(KdlNode::children) + .map(|children| { + let nodes = children.nodes(); + let mut defaults = ConfigOptions::default(); + + for node in nodes { + let option = node.name().to_string().to_ascii_lowercase(); + + match option.as_str() { + | "delete" => { + defaults.delete = node.get_bool(0).ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::options", + labels = vec![LabeledSpan::at( + node.span().to_owned(), + "this node requires a boolean argument" + )], + "Missing required argument." + ) + })?; + }, + | _ => { + continue; + }, + } + } + + Ok(defaults) + }); + + match options { + | Some(Ok(options)) => Ok(options), + | Some(Err(err)) => Err(err), + | None => Ok(ConfigOptions::default()), + } + } + + /// Tries to parse actions from the config. + fn get_config_actions(&self, doc: &KdlDocument) -> Result { + #[inline] + fn is_suite(node: &KdlNode) -> bool { + node.name().value() == "suite" + } + + #[inline] + fn is_flat(node: &KdlNode) -> bool { + !is_suite(node) + } + + let actions = doc + .get("actions") + .and_then(KdlNode::children) + .map(|children| { + let nodes = children.nodes(); + + // Check if all nodes are suites. + if nodes.iter().all(is_suite) { + let mut suites = Vec::new(); + + for node in nodes.iter() { + let suite = self.get_action_suite(node)?; + suites.push(suite); + } + + Ok(Actions::Suite(suites)) + } + // Check if all nodes are single actions. + else if nodes.iter().all(is_flat) { + let mut actions = Vec::new(); + + for node in nodes.iter() { + let action = self.get_action_single(node)?; + actions.push(action); + } + + Ok(Actions::Flat(actions)) + } + // Otherwise we have invalid actions block. + else { + Err(ConfigError::Diagnostic(miette::miette!( + code = "arx::config::actions", + "You can use either suites of actions or a flat list of single actions. \ + Right now you have a mix of both." + ))) + } + }); + + match actions { + | Some(Ok(action)) => Ok(action), + | Some(Err(err)) => Err(err), + | None => Ok(Actions::Empty), + } + } + + fn get_action_suite(&self, node: &KdlNode) -> Result { + let mut actions = Vec::new(); + + // Fail if we stumbled upon a nameless suite. + let name = self.get_arg_string(node)?; + + if let Some(children) = node.children() { + for children in children.nodes() { + let action = self.get_action_single(children)?; + actions.push(action); + } + } + + Ok(ActionSuite { name, actions }) + } + + fn get_action_single(&self, node: &KdlNode) -> Result { + let kind = node.name().to_string().to_ascii_lowercase(); + + let action = match kind.as_str() { + // Actions for manipulating files and directories. + | "cp" => { + ActionSingle::Copy(Copy { + from: self.get_attr_string(node, "from")?, + to: self.get_attr_string(node, "to")?, + overwrite: node.get_bool("overwrite").unwrap_or(true), + }) + }, + | "mv" => { + ActionSingle::Move(Move { + from: self.get_attr_string(node, "from")?, + to: self.get_attr_string(node, "to")?, + overwrite: node.get_bool("overwrite").unwrap_or(true), + }) + }, + | "rm" => ActionSingle::Delete(Delete { target: self.get_arg_string(node)? }), + // Actions for running commands and echoing output. + | "echo" => { + ActionSingle::Echo(Echo { + message: self.get_arg_string(node)?, + injects: self.get_injects(node), + trim: node.get_bool("trim").unwrap_or(true), + }) + }, + | "run" => { + ActionSingle::Run(Run { + name: node.get_string("name"), + command: self.get_arg_string(node)?, + injects: self.get_injects(node), + }) + }, + // Actions for prompts and replacements. + | "input" => { + let nodes = self.get_children(node, vec!["hint"])?; + + ActionSingle::Prompt(Prompt::Input(InputPrompt { + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_string(nodes), + })) + }, + | "number" => { + let nodes = self.get_children(node, vec!["hint"])?; + + ActionSingle::Prompt(Prompt::Number(NumberPrompt { + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_number(nodes), + })) + }, + | "editor" => { + let nodes = self.get_children(node, vec!["hint"])?; + + ActionSingle::Prompt(Prompt::Editor(EditorPrompt { + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_string(nodes), + })) + }, + | "select" => { + let nodes = self.get_children(node, vec!["hint", "options"])?; + + ActionSingle::Prompt(Prompt::Select(SelectPrompt { + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + options: self.get_options(node, nodes)?, + })) + }, + | "confirm" => { + let nodes = self.get_children(node, vec!["hint"])?; + + ActionSingle::Prompt(Prompt::Confirm(ConfirmPrompt { + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_bool(nodes), + })) + }, + | "replace" => { + let replacements = node + .children() + .map(|children| { + children + .nodes() + .iter() + .map(|node| node.name().value().to_string()) + .collect() + }) + .unwrap_or_default(); + + let glob = node.get_string("in"); + + ActionSingle::Replace(Replace { replacements, glob }) + }, + // Fallback. + | action => ActionSingle::Unknown(Unknown { name: action.to_string() }), + }; + + Ok(action) + } + + fn get_arg_string(&self, node: &KdlNode) -> Result { + let start = node.span().offset(); + let end = start + node.name().len(); + + node.get_string(0).ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![ + LabeledSpan::at(start..end, "this node requires a string argument"), + LabeledSpan::at_offset(end, "argument should be here") + ], + "Missing required argument." + ) + }) + } + + fn get_attr_string(&self, node: &KdlNode, key: &str) -> Result { + node.get_string(key).ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![LabeledSpan::at( + node.span().to_owned(), + format!("this node requires the `{key}` attribute") + )], + "Missing required attribute: `{key}`." + ) + }) + } + + fn get_children<'kdl>( + &self, + node: &'kdl KdlNode, + nodes: Vec<&str>, + ) -> Result<&'kdl KdlDocument, ConfigError> { + let nodes = nodes + .iter() + .map(|node| format!("`{node}`")) + .collect::>() + .join(", "); + + let suffix = if nodes.len() > 1 { "s" } else { "" }; + let message = format!("Missing required child node{suffix}: {nodes}."); + + node.children().ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![LabeledSpan::at( + node.span().to_owned(), + format!("this node requires the following child nodes: {nodes}") + )], + "{message}" + ) + }) + } + + fn get_hint(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result { + let hint = nodes.get("hint").ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![LabeledSpan::at( + parent.span().to_owned(), + "prompts require a `hint` child node" + )], + "Missing prompt hint." + ) + })?; + + self.get_arg_string(hint) + } + + fn get_injects(&self, node: &KdlNode) -> Option> { + node.children().map(|children| { + children + .get_args("inject") + .into_iter() + .filter_map(|arg| arg.as_string().map(str::to_string)) + .collect() + }) + } + + fn get_options(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result, ConfigError> { + let options = nodes.get("options").ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![LabeledSpan::at( + parent.span().to_owned(), + "select prompts require the `options` child node" + )], + "Missing select prompt options." + ) + })?; + + let mut variants = Vec::new(); + + for entry in options.entries() { + let value = entry.value(); + let span = entry.span().to_owned(); + + let value = if value.is_float_value() { + value.as_f64().as_ref().map(f64::to_string) + } else if value.is_i64_value() { + value.as_i64().as_ref().map(i64::to_string) + } else if value.is_string_value() { + value.as_string().map(str::to_string) + } else { + return Err(diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![LabeledSpan::at( + span, + "option values can be either strings or numbers" + )], + "Invalid select option type." + )); + }; + + let option = value.ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::config::actions", + labels = vec![LabeledSpan::at( + span, + "failed to converted this value to a string" + )], + "Failed to convert option value." + ) + })?; + + variants.push(option); + } + + Ok(variants) + } + + fn get_default_string(&self, nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_string(0)) + } + + fn get_default_bool(&self, nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_bool(0)) + } + + fn get_default_number(&self, nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_number(0)) + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..9b44067 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,10 @@ +pub use config::*; +pub use utils::*; +pub use value::*; + +pub mod actions; +pub mod prompts; +pub mod value; + +mod config; +mod utils; diff --git a/src/config/prompts.rs b/src/config/prompts.rs new file mode 100644 index 0000000..434ef2f --- /dev/null +++ b/src/config/prompts.rs @@ -0,0 +1,51 @@ +use crate::config::value::Number; + +#[derive(Debug)] +pub struct InputPrompt { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// Default value if input is empty. + pub default: Option, +} + +#[derive(Debug)] +pub struct NumberPrompt { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// Default value if input is empty. + pub default: Option, +} + +#[derive(Debug)] +pub struct SelectPrompt { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// List of options. + pub options: Vec, +} + +#[derive(Debug)] +pub struct ConfirmPrompt { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description of the prompt. + pub hint: String, + /// Default value. + pub default: Option, +} + +#[derive(Debug)] +pub struct EditorPrompt { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// Default value if input is empty. + pub default: Option, +} diff --git a/src/config/utils.rs b/src/config/utils.rs new file mode 100644 index 0000000..79696e5 --- /dev/null +++ b/src/config/utils.rs @@ -0,0 +1,40 @@ +use kdl::{KdlNode, NodeKey}; + +use crate::config::Number; + +pub trait KdlUtils { + /// Gets an entry by key and tries to map to a [String]. + fn get_string(&self, key: K) -> Option; + + /// Gets an entry by key and tries to map it to a [NumberValue]. + fn get_number(&self, key: K) -> Option; + + /// Gets an entry by key and tries to map it to a [bool]. + fn get_bool(&self, key: K) -> Option; +} + +impl KdlUtils for KdlNode +where + K: Into, +{ + fn get_string(&self, key: K) -> Option { + self + .get(key) + .and_then(|entry| entry.value().as_string().map(str::to_string)) + } + + fn get_number(&self, key: K) -> Option { + self.get(key).and_then(|entry| { + let value = entry.value(); + + value + .as_i64() + .map(Number::Integer) + .or_else(|| value.as_f64().map(Number::Float)) + }) + } + + fn get_bool(&self, key: K) -> Option { + self.get(key).and_then(|entry| entry.value().as_bool()) + } +} diff --git a/src/config/value.rs b/src/config/value.rs new file mode 100644 index 0000000..75a6d6a --- /dev/null +++ b/src/config/value.rs @@ -0,0 +1,50 @@ +use std::fmt::{self, Display}; +use std::str::FromStr; + +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Diagnostic, Error)] +#[error("`{0}` is not a valid number.")] +#[diagnostic(code(arx::config::prompts::parse))] +pub struct NumberParseError(pub String); + +/// Value of a number prompt. +#[derive(Clone, Debug)] +pub enum Number { + /// Integer value. + Integer(i64), + /// Floating point value. + Float(f64), +} + +impl Display for Number { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::Integer(int) => write!(f, "{int}"), + | Self::Float(float) => write!(f, "{float}"), + } + } +} + +impl FromStr for Number { + type Err = NumberParseError; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(Self::Integer) + .or_else(|_| s.parse::().map(Self::Float)) + .map_err(|_| NumberParseError(s.to_string())) + } +} + +/// Replacement value. +#[derive(Debug)] +pub enum Value { + /// A string value. + String(String), + // A number value. + Number(Number), + /// A boolean value. + Bool(bool), +} diff --git a/src/graph.rs b/src/graph.rs deleted file mode 100644 index 919df30..0000000 --- a/src/graph.rs +++ /dev/null @@ -1,108 +0,0 @@ -use petgraph::stable_graph::StableDiGraph; -use petgraph::Direction; - -pub trait Node { - type Item; - - fn dependencies(&self) -> &[Self::Item]; - fn matches(&self, dep: &Self::Item) -> bool; -} - -#[derive(Debug)] -pub enum Step<'a, N: Node> { - Resolved(&'a N), - Unresolved(&'a N::Item), -} - -impl<'a, N: Node> Step<'a, N> { - pub fn is_resolved(&self) -> bool { - match self { - | Step::Resolved(_) => true, - | Step::Unresolved(_) => false, - } - } - - pub fn as_resolved(&self) -> Option<&N> { - match self { - | Step::Resolved(node) => Some(node), - | Step::Unresolved(_) => None, - } - } - - pub fn as_unresolved(&self) -> Option<&N::Item> { - match self { - | Step::Resolved(_) => None, - | Step::Unresolved(requirement) => Some(requirement), - } - } -} - -#[derive(Debug)] -pub struct DependenciesGraph<'a, N: Node> { - graph: StableDiGraph, &'a N::Item>, -} - -impl<'a, N> From<&'a [N]> for DependenciesGraph<'a, N> -where - N: Node, -{ - fn from(nodes: &'a [N]) -> Self { - let mut graph = StableDiGraph::, &'a N::Item>::new(); - - // Insert the input nodes into the graph, and record their positions. We'll be adding the edges - // next, and filling in any unresolved steps we find along the way. - let nodes: Vec<(_, _)> = nodes - .iter() - .map(|node| (node, graph.add_node(Step::Resolved(node)))) - .collect(); - - for (node, index) in nodes.iter() { - for dependency in node.dependencies() { - // Check to see if we can resolve this dependency internally. - if let Some((_, dependent)) = nodes.iter().find(|(dep, _)| dep.matches(dependency)) { - // If we can, just add an edge between the two nodes. - graph.add_edge(*index, *dependent, dependency); - } else { - // If not, create a new Unresolved node, and create an edge to that. - let unresolved = graph.add_node(Step::Unresolved(dependency)); - graph.add_edge(*index, unresolved, dependency); - } - } - } - - Self { graph } - } -} - -impl<'a, N> DependenciesGraph<'a, N> -where - N: Node, -{ - pub fn is_resolvable(&self) -> bool { - self.graph.node_weights().all(Step::is_resolved) - } - - pub fn unresolved(&self) -> impl Iterator { - self.graph.node_weights().filter_map(Step::as_unresolved) - } -} - -impl<'a, N> Iterator for DependenciesGraph<'a, N> -where - N: Node, -{ - type Item = Step<'a, N>; - - fn next(&mut self) -> Option { - // Returns the first node, which does not have any Outgoing edges, which means it is terminal. - for index in self.graph.node_indices().rev() { - let neighbors = self.graph.neighbors_directed(index, Direction::Outgoing); - - if neighbors.count() == 0 { - return self.graph.remove_node(index); - } - } - - None - } -} diff --git a/src/lib.rs b/src/lib.rs index 0818419..b585c68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,9 @@ -#![allow(dead_code)] +#![allow(clippy::module_inception, clippy::enum_variant_names)] +pub(crate) mod actions; pub mod app; -pub mod config; - -mod graph; -mod parser; -mod repository; -mod tar; +pub(crate) mod config; +pub(crate) mod path; +pub(crate) mod repository; +pub(crate) mod spinner; +pub(crate) mod unpacker; diff --git a/src/main.rs b/src/main.rs index a9a02f7..b8de888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,42 +1,25 @@ -use arx::app::{self, AppError}; +use arx::app::App; +use crossterm::style::Stylize; +use miette::Severity; #[tokio::main] -async fn main() -> Result<(), AppError> { - app::run().await +async fn main() { + let app = App::new(); + + if let Err(err) = app.run().await { + let severity = match err.severity().unwrap_or(Severity::Error) { + | Severity::Advice => "Advice:".cyan(), + | Severity::Warning => "Warning:".yellow(), + | Severity::Error => "Error:".red(), + }; + + if err.code().is_some() { + eprintln!("{severity} {err:?}"); + } else { + eprintln!("{severity}\n"); + eprintln!("{err:?}"); + } + + std::process::exit(1); + } } - -// use std::env; -// use std::fs; - -// use kdl::KdlDocument; -// use imp::config::{self, Action}; - -// fn main() -> std::io::Result<()> { -// let filename = env::current_dir()?.join("arx.kdl"); - -// let contents = fs::read_to_string(filename)?; -// let doc: KdlDocument = contents.parse().expect("Failed to parse config file."); - -// let replacements = config::get_replacements(&doc); -// let actions = config::get_actions(&doc); - -// replacements.map(|items| { -// items.iter().for_each(|item| { -// let tag = &item.tag; -// let description = &item.description; - -// println!("{tag} = {description}"); -// }) -// }); - -// actions.map(|action| { -// if let Action::Suite(suites) = action { -// let (resolved, unresolved) = config::resolve_requirements(&suites); - -// println!("Resolved: {resolved:#?}"); -// println!("Unresolved: {unresolved:#?}"); -// } -// }); - -// Ok(()) -// } diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index 55eefc9..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,120 +0,0 @@ -use chumsky::error::Cheap; -use chumsky::prelude::*; - -use crate::app::AppError; -use crate::repository::{Host, Repository, RepositoryHost, RepositoryMeta}; - -type ParseResult = (Option, (String, String), Option); - -/// Parses source argument of the following form: -/// -/// `({host}:){user}/{repo}(#{branch|commit|tag})`. -pub(crate) fn shortcut(input: &str) -> Result { - let host = host().or_not(); - let meta = meta().or_not().then_ignore(end()); - let repo = repository().then(meta); - - let shortcut = host.then(repo).map(|(a, (b, c))| (a, b, c)).parse(input); - - match shortcut { - | Ok(data) => produce_result(data), - | Err(error) => Err(produce_error(error)), - } -} - -/// Parses the repository host. Must be one of: -/// - `github` or `gh` -/// - `gitlab` or `gl` -/// - `bitbucket` or `bb` -fn host() -> impl Parser> { - let host = filter::<_, _, Cheap>(|ch: &char| ch.is_ascii_alphabetic()) - .repeated() - .at_least(1) - .collect::() - .map(|variant| { - match variant.as_str() { - | "github" | "gh" => Host::Known(RepositoryHost::GitHub), - | "gitlab" | "gl" => Host::Known(RepositoryHost::GitLab), - | "bitbucket" | "bb" => Host::Known(RepositoryHost::BitBucket), - | _ => Host::Unknown, - } - }) - .labelled("Host can't be zero-length."); - - host.then_ignore(just(':')) -} - -/// Parses the user name and repository name. -fn repository() -> impl Parser> { - fn is_valid_user(ch: &char) -> bool { - ch.is_ascii_alphanumeric() || ch == &'_' || ch == &'-' - } - - fn is_valid_repo(ch: &char) -> bool { - is_valid_user(ch) || ch == &'.' - } - - let user = filter::<_, _, Cheap>(is_valid_user) - .repeated() - .at_least(1) - .labelled("Must be a valid user name. Allowed symbols: [a-zA-Z0-9_-]") - .collect::(); - - let repo = filter::<_, _, Cheap>(is_valid_repo) - .repeated() - .at_least(1) - .labelled("Must be a valid repository name. Allowed symbols: [a-zA-Z0-9_-.]") - .collect::(); - - user - .then_ignore( - just('/').labelled("There must be a slash between the user name and the repository name."), - ) - .then(repo) -} - -/// Parses the shortcut meta (branch, commit hash, or tag), which may be specified after `#`. -/// -/// TODO: Add some loose validation. -fn meta() -> impl Parser> { - let meta = filter::<_, _, Cheap>(char::is_ascii) - .repeated() - .at_least(1) - .labelled("Meta can't be zero-length.") - .collect::() - .map(RepositoryMeta); - - just('#').ignore_then(meta) -} - -fn produce_result(data: ParseResult) -> Result { - match data { - | (host, (user, repo), meta) => { - let meta = meta.unwrap_or_default(); - let host = host.unwrap_or_default(); - - if let Host::Known(host) = host { - Ok(Repository { - host, - user, - repo, - meta, - }) - } else { - Err(AppError( - "Host must be one of: github/gh, gitlab/gl, or bitbucket/bb.".to_string(), - )) - } - }, - } -} - -fn produce_error(errors: Vec>) -> AppError { - let reduced = errors - .iter() - .filter_map(|error| error.label()) - .map(str::to_string) - .collect::>(); - - AppError(reduced.join("\n")) -} diff --git a/src/path/clean.rs b/src/path/clean.rs new file mode 100644 index 0000000..b156d66 --- /dev/null +++ b/src/path/clean.rs @@ -0,0 +1,168 @@ +use std::path::{Component, Path, PathBuf}; + +/// Implements the [clean] method. +pub trait PathClean { + fn clean(&self) -> PathBuf; +} + +/// [PathClean] implemented for [Path]. +impl PathClean for Path { + fn clean(&self) -> PathBuf { + clean(self) + } +} + +/// Cleans up a [Path]. +/// +/// It performs the following, lexically: +/// +/// - Reduces multiple slashes to a single slash. +/// - Eliminates `.` path name elements (the current directory). +/// - Eliminates `..` path name elements (the parent directory) and the non-`.` non-`..`, element +/// that precedes them. +/// - Eliminates `..` elements that begin a rooted path, that is, replace `/..` by `/` at the +/// beginning of a path. +/// - Leaves intact `..` elements that begin a non-rooted path. +/// +/// If the result is an empty string, returns the string `"."`, representing the current directory. +pub fn clean

(path: P) -> PathBuf +where + P: AsRef, +{ + let mut out = Vec::new(); + + for component in path.as_ref().components() { + match component { + | Component::CurDir => (), + | Component::ParentDir => { + match out.last() { + | Some(Component::RootDir) => (), + | Some(Component::Normal(_)) => { + out.pop(); + }, + | None + | Some(Component::CurDir) + | Some(Component::ParentDir) + | Some(Component::Prefix(_)) => out.push(component), + } + }, + | comp => out.push(comp), + } + } + + if !out.is_empty() { + out.iter().collect() + } else { + PathBuf::from(".") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helpers. + + fn test_cases(cases: Vec<(&str, &str)>) { + for (actual, expected) in cases { + assert_eq!(clean(actual), PathBuf::from(expected)); + } + } + + // Tests. + + #[test] + fn test_trait() { + assert_eq!( + PathBuf::from("/test/../path/").clean(), + PathBuf::from("/path") + ); + + assert_eq!(Path::new("/test/../path/").clean(), PathBuf::from("/path")); + } + + #[test] + fn test_empty_path_is_current_dir() { + assert_eq!(clean(""), PathBuf::from(".")); + } + + #[test] + fn test_clean_paths_dont_change() { + let cases = vec![(".", "."), ("..", ".."), ("/", "/")]; + + test_cases(cases); + } + + #[test] + fn test_replace_multiple_slashes() { + let cases = vec![ + ("/", "/"), + ("//", "/"), + ("///", "/"), + (".//", "."), + ("//..", "/"), + ("..//", ".."), + ("/..//", "/"), + ("/.//./", "/"), + ("././/./", "."), + ("path//to///thing", "path/to/thing"), + ("/path//to///thing", "/path/to/thing"), + ]; + + test_cases(cases); + } + + #[test] + fn test_eliminate_current_dir() { + let cases = vec![ + ("./", "."), + ("/./", "/"), + ("./test", "test"), + ("./test/./path", "test/path"), + ("/test/./path/", "/test/path"), + ("test/path/.", "test/path"), + ]; + + test_cases(cases); + } + + #[test] + fn test_eliminate_parent_dir() { + let cases = vec![ + ("/..", "/"), + ("/../test", "/test"), + ("test/..", "."), + ("test/path/..", "test"), + ("test/../path", "path"), + ("/test/../path", "/path"), + ("test/path/../../", "."), + ("test/path/../../..", ".."), + ("/test/path/../../..", "/"), + ("/test/path/../../../..", "/"), + ("test/path/../../../..", "../.."), + ("test/path/../../another/path", "another/path"), + ("test/path/../../another/path/..", "another"), + ("../test", "../test"), + ("../test/", "../test"), + ("../test/path", "../test/path"), + ("../test/..", ".."), + ]; + + test_cases(cases); + } + + #[test] + #[cfg(windows)] + fn test_windows_paths() { + let cases = vec![ + ("\\..", "\\"), + ("\\..\\test", "\\test"), + ("test\\..", "."), + ("test\\path\\..\\..\\..", ".."), + ("test\\path/..\\../another\\path", "another\\path"), // Mixed + ("/dir\\../otherDir/test.json", "/otherDir/test.json"), // User example + ]; + + test_cases(cases); + } +} diff --git a/src/path/mod.rs b/src/path/mod.rs new file mode 100644 index 0000000..36c4d52 --- /dev/null +++ b/src/path/mod.rs @@ -0,0 +1,5 @@ +pub use clean::*; +pub use traverser::*; + +mod clean; +mod traverser; diff --git a/src/path/traverser.rs b/src/path/traverser.rs new file mode 100644 index 0000000..1249252 --- /dev/null +++ b/src/path/traverser.rs @@ -0,0 +1,157 @@ +use std::path::PathBuf; + +use glob_match::glob_match_with_captures; +use thiserror::Error; +use walkdir::{DirEntry, IntoIter as WalkDirIter, WalkDir}; + +#[derive(Debug, Error)] +pub enum TraverseError { + #[error("Could not read entry while traversing directory.")] + InvalidEntry(walkdir::Error), +} + +#[derive(Debug)] +pub struct Match { + /// Full path. + pub path: PathBuf, + /// Captured path relative to the traverser's root. + pub captured: PathBuf, + /// Original entry. + pub entry: DirEntry, +} + +impl Match { + /// Checks if the match is a directory. + pub fn is_dir(&self) -> bool { + self.entry.file_type().is_dir() + } + + /// Checks if the match is a file. + pub fn is_file(&self) -> bool { + self.entry.file_type().is_file() + } +} + +#[derive(Debug)] +pub struct TraverseOptions { + /// Directory to traverse. + root: PathBuf, + /// Pattern to match the path against. If `None`, all paths will match. + pattern: Option, + /// Whether to ignore directories (not threir contents) when traversing. Defaults to `false`. + ignore_dirs: bool, + /// Whether to traverse contents of directories first (depth-first). Defaults to `false`. + contents_first: bool, +} + +#[derive(Debug)] +pub struct Traverser { + /// Traverser options. + options: TraverseOptions, +} + +impl Traverser { + /// Creates a new (consuming) builder. + pub fn new>(root: P) -> Self { + Self { + options: TraverseOptions { + root: root.into(), + pattern: None, + ignore_dirs: false, + contents_first: false, + }, + } + } + + /// Set the pattern to match the path against. + pub fn pattern(mut self, pattern: &str) -> Self { + self.options.pattern = Some(pattern.to_string()); + self + } + + /// Set whether to ignore directories (not their contents) when traversing or not. + pub fn ignore_dirs(mut self, ignore_dirs: bool) -> Self { + self.options.ignore_dirs = ignore_dirs; + self + } + + /// Set whether to traverse contents of directories first or not. + pub fn contents_first(mut self, contents_first: bool) -> Self { + self.options.contents_first = contents_first; + self + } + + /// Creates an iterator without consuming the traverser builder. + pub fn iter(&self) -> TraverserIterator { + let it = WalkDir::new(&self.options.root) + .contents_first(self.options.contents_first) + .into_iter(); + + let root_pattern = self + .options + .pattern + .as_ref() + .map(|pat| self.options.root.join(pat).display().to_string()); + + TraverserIterator { it, root_pattern, options: &self.options } + } +} + +/// Traverser iterator. +pub struct TraverserIterator<'t> { + /// Inner iterator (using [walkdir::IntoIter]) that is used to do actual traversing. + it: WalkDirIter, + /// Pattern prepended with the root path to avoid conversions on every iteration. + root_pattern: Option, + /// Traverser options. + options: &'t TraverseOptions, +} + +impl<'t> Iterator for TraverserIterator<'t> { + type Item = Result; + + fn next(&mut self) -> Option { + let mut item = self.it.next()?; + + 'skip: loop { + match item { + | Ok(entry) => { + let path = entry.path(); + + // This ignores only _entry_, while still stepping into the directory. + if self.options.ignore_dirs && entry.file_type().is_dir() { + item = self.it.next()?; + + continue 'skip; + } + + if let Some(pattern) = &self.root_pattern { + let candidate = path.display().to_string(); + + if let Some(captures) = glob_match_with_captures(pattern, &candidate) { + let range = captures.first().cloned().unwrap_or_default(); + let captured = PathBuf::from(&candidate[range.start..]); + + return Some(Ok(Match { + path: path.to_path_buf(), + captured, + entry, + })); + } + + item = self.it.next()?; + + continue 'skip; + } + + return Some(Ok(Match { + path: path.to_path_buf(), + captured: path.to_path_buf(), + entry, + })); + }, + | Err(err) => return Some(Err(TraverseError::InvalidEntry(err))), + } + } + } +} diff --git a/src/repository.rs b/src/repository.rs index b463f2d..71c787f 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,37 +1,84 @@ -use crate::app::AppError; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::str::FromStr; -/// Supported hosts. [GitHub][RepositoryHost::GitHub] is the default one. -#[derive(Debug)] -pub(crate) enum RepositoryHost { - GitHub, - GitLab, - BitBucket, +use git2::build::CheckoutBuilder; +use git2::Repository as GitRepository; +use miette::{Diagnostic, LabeledSpan, Report}; +use thiserror::Error; + +use crate::path::Traverser; + +/// Helper macro to create a [ParseError] in a slightly less verbose way. +macro_rules! parse_error { + ($source:ident = $code:expr, $($key:ident = $value:expr,)* $fmt:literal $($arg:tt)*) => { + ParseError( + miette::Report::from( + miette::diagnostic!($($key = $value,)* $fmt $($arg)*) + ).with_source_code($code) + ) + }; } -impl Default for RepositoryHost { - fn default() -> Self { - RepositoryHost::GitHub - } +#[derive(Debug, Diagnostic, Error)] +pub enum RepositoryError { + #[error("{message}")] + #[diagnostic(code(arx::repository::io))] + Io { + message: String, + #[source] + source: io::Error, + }, } -/// Container for a repository host. -#[derive(Debug)] -pub(crate) enum Host { - Known(RepositoryHost), - Unknown, +#[derive(Debug, Diagnostic, Error)] +#[error("{0}")] +#[diagnostic(transparent)] +pub struct ParseError(Report); + +#[derive(Debug, Diagnostic, Error)] +#[diagnostic(code(arx::repository::fetch))] +pub enum FetchError { + #[error("Request failed.")] + RequestFailed, + #[error("Repository download failed with code {code}.\n\n{url}")] + RequestFailedWithCode { code: u16, url: Report }, + #[error("Couldn't get the response body as bytes.")] + RequestBodyFailed, } -impl Default for Host { - fn default() -> Self { - Host::Known(RepositoryHost::default()) - } +#[derive(Debug, Diagnostic, Error)] +#[diagnostic(code(arx::repository::checkout))] +pub enum CheckoutError { + #[error("Failed to open the git repository.")] + OpenFailed(git2::Error), + #[error("Failed to parse revision string `{0}`.")] + RevparseFailed(String), + #[error("Failed to checkout revision (tree).")] + TreeCheckoutFailed, + #[error("Reference name is not a valid UTF-8 string.")] + InvalidRefName, + #[error("Failed to set HEAD to `{0}`.")] + SetHeadFailed(String), + #[error("Failed to detach HEAD to `{0}`.")] + DetachHeadFailed(String), +} + +/// Supported hosts. [GitHub][RepositoryHost::GitHub] is the default one. +#[derive(Debug, Default, PartialEq)] +pub enum RepositoryHost { + #[default] + GitHub, + GitLab, + BitBucket, } -/// Repository meta, i.e. *ref*. +/// Repository meta or *ref*, i.e. branch, tag or commit. /// /// This newtype exists solely for providing the default value. -#[derive(Debug)] -pub(crate) struct RepositoryMeta(pub String); +#[derive(Clone, Debug, PartialEq)] +pub struct RepositoryMeta(pub String); impl Default for RepositoryMeta { fn default() -> Self { @@ -41,23 +88,27 @@ impl Default for RepositoryMeta { } } -#[derive(Debug)] -pub(crate) struct Repository { +/// Represents a remote repository. Repositories of this kind need to be downloaded first. +#[derive(Debug, PartialEq)] +pub struct RemoteRepository { pub host: RepositoryHost, pub user: String, pub repo: String, pub meta: RepositoryMeta, } -impl Repository { +impl RemoteRepository { + /// Creates new `RemoteRepository`. + pub fn new(target: String, meta: Option) -> Result { + let repo = Self::from_str(&target)?; + let meta = meta.map_or(repo.meta, RepositoryMeta); + + Ok(Self { meta, ..repo }) + } + /// Resolves a URL depending on the host and other repository fields. - pub(crate) fn get_tar_url(&self) -> String { - let Repository { - host, - user, - repo, - meta, - } = self; + pub fn get_tar_url(&self) -> String { + let RemoteRepository { host, user, repo, meta } = self; let RepositoryMeta(meta) = meta; @@ -75,24 +126,407 @@ impl Repository { } /// Fetches the tarball using the resolved URL, and reads it into bytes (`Vec`). - pub(crate) async fn fetch(&self) -> Result, AppError> { + pub async fn fetch(&self) -> Result, FetchError> { let url = self.get_tar_url(); - let response = reqwest::get(url).await.map_err(|err| { - err - .status() - .map_or(AppError("Request failed.".to_string()), |status| { - AppError(format!( - "Request failed with the code: {code}.", - code = status.as_u16() - )) - }) + let response = reqwest::get(&url).await.map_err(|err| { + err.status().map_or(FetchError::RequestFailed, |status| { + FetchError::RequestFailedWithCode { + code: status.as_u16(), + url: miette::miette!("URL: {}", url.clone()), + } + }) })?; + let status = response.status(); + + if !status.is_success() { + return Err(FetchError::RequestFailedWithCode { + code: status.as_u16(), + url: miette::miette!("URL: {}", url), + }); + } + response .bytes() .await .map(|bytes| bytes.to_vec()) - .map_err(|_| AppError("Couldn't get the response body as bytes.".to_string())) + .map_err(|_| FetchError::RequestBodyFailed) + } +} + +impl FromStr for RemoteRepository { + type Err = ParseError; + + /// Parses a `&str` into a `RemoteRepository`. + fn from_str(input: &str) -> Result { + #[inline(always)] + fn is_valid_user(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' + } + + #[inline(always)] + fn is_valid_repo(ch: char) -> bool { + is_valid_user(ch) || ch == '.' + } + + let source = input.trim(); + + // Parse host if present or use default otherwise. + let (host, (input, offset)) = if let Some((host, rest)) = source.split_once(':') { + let host = host.to_ascii_lowercase(); + let next_offset = host.len() + 1; + + match host.as_str() { + | "github" | "gh" => (RepositoryHost::GitHub, (rest, next_offset)), + | "gitlab" | "gl" => (RepositoryHost::GitLab, (rest, next_offset)), + | "bitbucket" | "bb" => (RepositoryHost::BitBucket, (rest, next_offset)), + | _ => { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (0, host.len()), + "must be one of: github/gh, gitlab/gl, or bitbucket/bb" + )], + "Invalid host: `{host}`." + )); + }, + } + } else { + (RepositoryHost::default(), (source, 0)) + }; + + // Parse user name. + let (user, (input, offset)) = if let Some((user, rest)) = input.split_once('/') { + let next_offset = offset + user.len() + 1; + + if user.chars().all(is_valid_user) { + (user.to_string(), (rest, next_offset)) + } else { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (offset, user.len()), + "only ASCII alphanumeric characters, _ and - allowed" + )], + "Invalid user name: `{user}`." + )); + } + } else { + return Err(ParseError(miette::miette!("Missing repository name."))); + }; + + // Short-circuit if the rest of the input contains another /. + if let Some(slash_idx) = input.find('/') { + // Ensure we are not triggering false-positive in case there's a ref (after #) with a branch + // name containing slashes. + if matches!(input.find('#'), Some(hash_idx) if slash_idx < hash_idx) { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at((offset + slash_idx, 1), "remove this")], + "Multiple slashes in the input." + )); + } + } + + // Parse repository name. + let (repo, input) = input.split_once('#').map_or_else( + || (input.to_string(), None), + |(repo, rest)| (repo.to_string(), Some(rest)), + ); + + if !repo.chars().all(is_valid_repo) { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (offset, repo.len()), + "only ASCII alphanumeric characters, _, - and . allowed" + ),], + "Invalid repository name: `{repo}`." + )); + } + + // Produce meta if anything left from the input. Empty meta is accepted but ignored, default + // value is used then. + let meta = input + .filter(|input| !input.is_empty()) + .map_or(RepositoryMeta::default(), |input| { + RepositoryMeta(input.to_string()) + }); + + Ok(RemoteRepository { host, user, repo, meta }) + } +} + +/// Represents a local repository. Repositories of this kind don't need to be downloaded, we can +/// simply clone them locally and switch to desired meta (ref). +#[derive(Debug, PartialEq)] +pub struct LocalRepository { + pub source: PathBuf, + pub meta: RepositoryMeta, +} + +impl LocalRepository { + /// Creates new `LocalRepository`. + pub fn new(source: String, meta: Option) -> Self { + Self { + source: PathBuf::from(source), + meta: meta.map_or(RepositoryMeta::default(), RepositoryMeta), + } + } + + /// Copies the repository into the `destination` directory. + pub fn copy(&self, destination: &Path) -> Result<(), RepositoryError> { + let traverser = Traverser::new(self.source.to_owned()) + .pattern("**/*") + .ignore_dirs(true) + .contents_first(true); + + for matched in traverser.iter().flatten() { + let target = destination.join(&matched.captured); + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|source| { + RepositoryError::Io { + message: format!( + "Failed to create directory structure for '{}'.", + parent.display() + ), + source, + } + })?; + + fs::copy(&matched.path, &target).map_err(|source| { + RepositoryError::Io { + message: format!( + "Failed to copy from '{}' to '{}'.", + matched.path.display(), + target.display() + ), + source, + } + })?; + } + } + + Ok(()) + } + + /// Checks out the repository located at the `destination`. + pub fn checkout(&self, destination: &Path) -> Result<(), CheckoutError> { + let RepositoryMeta(meta) = &self.meta; + + // First, try to create Repository. + let repository = GitRepository::open(destination).map_err(CheckoutError::OpenFailed)?; + + // Note: in case of local repositories, instead of HEAD we want to check origin/HEAD first, + // which should be the default branch if the repository has been cloned from a remote. + // Otherwise we fallback to HEAD, which will point to whatever the repository points at the time + // of cloning (can be absolutely arbitrary reference/state). + let meta = if meta == "HEAD" { + repository + .revparse_ext("origin/HEAD") + .ok() + .and_then(|(_, reference)| reference) + .and_then(|reference| reference.name().map(str::to_string)) + .unwrap_or("HEAD".to_string()) + } else { + "HEAD".to_string() + }; + + // Try to find (parse revision) the desired reference: branch, tag or commit. They are encoded + // in two objects: + // + // - `object` contains (among other things) the commit hash. + // - `reference` points to the branch or tag. + let (object, reference) = repository + .revparse_ext(&meta) + .map_err(|_| CheckoutError::RevparseFailed(meta))?; + + // Build checkout options. + let mut checkout = CheckoutBuilder::new(); + + checkout + .skip_unmerged(true) + .remove_untracked(true) + .remove_ignored(true) + .force(); + + // Updates files in the index and working tree. + repository + .checkout_tree(&object, Some(&mut checkout)) + .map_err(|_| CheckoutError::TreeCheckoutFailed)?; + + match reference { + // Here `gref`` is an actual reference like branch or tag. + | Some(gref) => { + let ref_name = gref.name().ok_or(CheckoutError::InvalidRefName)?; + + repository + .set_head(ref_name) + .map_err(|_| CheckoutError::SetHeadFailed(ref_name.to_string()))?; + }, + // This is a commit, detach HEAD. + | None => { + let hash = object.id(); + + repository + .set_head_detached(hash) + .map_err(|_| CheckoutError::DetachHeadFailed(hash.to_string()))?; + }, + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_remote_default() { + assert_eq!( + RemoteRepository::from_str("foo/bar").map_err(|report| report.to_string()), + Ok(RemoteRepository { + host: RepositoryHost::GitHub, + user: "foo".to_string(), + repo: "bar".to_string(), + meta: RepositoryMeta::default() + }) + ); + } + + #[test] + fn parse_remote_missing_reponame() { + assert_eq!( + RemoteRepository::from_str("foo-bar").map_err(|report| report.to_string()), + Err("Missing repository name.".to_string()) + ); + } + + #[test] + fn parse_remote_invalid_username() { + assert_eq!( + RemoteRepository::from_str("foo@bar/baz").map_err(|report| report.to_string()), + Err("Invalid user name: `foo@bar`.".to_string()) + ); + } + + #[test] + fn parse_remote_invalid_reponame() { + assert_eq!( + RemoteRepository::from_str("foo-bar/b@z").map_err(|report| report.to_string()), + Err("Invalid repository name: `b@z`.".to_string()) + ); + } + + #[test] + fn parse_remote_invalid_host() { + assert_eq!( + RemoteRepository::from_str("srht:foo/bar").map_err(|report| report.to_string()), + Err( + parse_error!( + source = "srht:foo/bar", + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (0, 5), + "must be one of: github/gh, gitlab/gl, or bitbucket/bb" + )], + "Invalid host: `srht`." + ) + .to_string() + ) + ); + } + + #[test] + fn parse_remote_meta() { + let cases = [ + ("foo/bar", RepositoryMeta::default()), + ("foo/bar#foo", RepositoryMeta("foo".to_string())), + ("foo/bar#4a5a56fd", RepositoryMeta("4a5a56fd".to_string())), + ( + "foo/bar#feat/some-feature-name", + RepositoryMeta("feat/some-feature-name".to_string()), + ), + ]; + + for (input, meta) in cases { + assert_eq!( + RemoteRepository::from_str(input).map_err(|report| report.to_string()), + Ok(RemoteRepository { + host: RepositoryHost::GitHub, + user: "foo".to_string(), + repo: "bar".to_string(), + meta + }) + ); + } + } + + #[test] + fn parse_remote_hosts() { + let cases = [ + ("github:foo/bar", RepositoryHost::GitHub), + ("gh:foo/bar", RepositoryHost::GitHub), + ("gitlab:foo/bar", RepositoryHost::GitLab), + ("gl:foo/bar", RepositoryHost::GitLab), + ("bitbucket:foo/bar", RepositoryHost::BitBucket), + ("bb:foo/bar", RepositoryHost::BitBucket), + ]; + + for (input, host) in cases { + assert_eq!( + RemoteRepository::from_str(input).map_err(|report| report.to_string()), + Ok(RemoteRepository { + host, + user: "foo".to_string(), + repo: "bar".to_string(), + meta: RepositoryMeta::default() + }) + ); + } + } + + #[test] + fn test_remote_empty_meta() { + assert_eq!( + RemoteRepository::from_str("foo/bar#").map_err(|report| report.to_string()), + Ok(RemoteRepository { + host: RepositoryHost::GitHub, + user: "foo".to_string(), + repo: "bar".to_string(), + meta: RepositoryMeta::default() + }) + ); + } + + #[test] + fn parse_remote_ambiguous_username() { + let cases = [ + ("github/foo", "github", "foo"), + ("gh/foo", "gh", "foo"), + ("gitlab/foo", "gitlab", "foo"), + ("gl/foo", "gl", "foo"), + ("bitbucket/foo", "bitbucket", "foo"), + ("bb/foo", "bb", "foo"), + ]; + + for (input, user, repo) in cases { + assert_eq!( + RemoteRepository::from_str(input).map_err(|report| report.to_string()), + Ok(RemoteRepository { + host: RepositoryHost::default(), + user: user.to_string(), + repo: repo.to_string(), + meta: RepositoryMeta::default() + }) + ); + } } } diff --git a/src/spinner.rs b/src/spinner.rs new file mode 100644 index 0000000..9c85807 --- /dev/null +++ b/src/spinner.rs @@ -0,0 +1,55 @@ +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; + +/// Small wrapper around the `indicatif` spinner. +pub struct Spinner { + spinner: ProgressBar, +} + +impl Spinner { + /// Creates a new spinner. + pub fn new() -> Self { + let style = ProgressStyle::default_spinner().tick_chars("⠋⠙⠚⠒⠂⠂⠒⠲⠴⠦⠖⠒⠐⠐⠒⠓⠋·"); + let spinner = ProgressBar::new_spinner(); + + spinner.set_style(style); + spinner.enable_steady_tick(Duration::from_millis(80)); + + Self { spinner } + } + + /// Sets the message of the spinner. + pub fn set_message(&self, message: S) + where + S: Into + AsRef, + { + self.spinner.set_message(message.into()); + } + + /// Stops the spinner. + #[allow(dead_code)] + pub fn stop(&self) { + self.spinner.finish(); + } + + /// Stops the spinner with the message. + pub fn stop_with_message(&self, message: S) + where + S: Into + AsRef, + { + self.spinner.finish_with_message(message.into()); + } + + /// Stops the spinner and clears the message. + #[allow(dead_code)] + pub fn stop_with_clear(&self) { + self.spinner.finish_and_clear(); + } +} + +impl Default for Spinner { + fn default() -> Self { + Self::new() + } +} diff --git a/src/tar.rs b/src/tar.rs deleted file mode 100644 index 42f4c6f..0000000 --- a/src/tar.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use flate2::bufread::GzDecoder; -use tar::Archive; - -use crate::app::AppError; - -#[cfg(target_os = "windows")] -const USE_XATTRS: bool = false; - -#[cfg(not(target_os = "windows"))] -const USE_XATTRS: bool = true; - -#[cfg(target_os = "windows")] -const USE_PERMISSIONS: bool = false; - -#[cfg(not(target_os = "windows"))] -const USE_PERMISSIONS: bool = true; - -/// Unpacks a given tar archive. -pub(crate) fn unpack(bytes: &[u8], dest: &String) -> Result, AppError> { - let mut archive = Archive::new(GzDecoder::new(bytes)); - let mut written_paths = Vec::new(); - let dest_path = PathBuf::from(dest); - - // Get iterator over the entries. - let raw_entries = archive - .entries() - .map_err(|_| AppError("Couldn't get entries from the tarball.".to_string()))?; - - // Create output structure (if necessary). - create_output_structure(&dest_path)?; - - for mut entry in raw_entries.flatten() { - let entry_path = entry - .path() - .map_err(|_| AppError("Couldn't get the entry's path.".to_string()))?; - - let fixed_path = fix_entry_path(&entry_path, &dest_path); - - entry.set_preserve_permissions(USE_PERMISSIONS); - entry.set_unpack_xattrs(USE_XATTRS); - - entry - .unpack(&fixed_path) - .map_err(|_| AppError("Couldn't unpack the entry.".to_string()))?; - - written_paths.push(fixed_path); - } - - // Deduplicate, because it **will** contain duplicates. - written_paths.dedup(); - - Ok(written_paths) -} - -/// Recursively creates the output structure if there's more than 1 component in the destination -/// path AND if the destination path does not exist. -#[inline(always)] -fn create_output_structure(dest_path: &Path) -> Result<(), AppError> { - // FIXME: The use of `exists` method here is a bit worrisome, since it can open possibilities for - // TOCTOU attacks, so should probably replace with `try_exists`. - if dest_path.iter().count().gt(&1) && !dest_path.exists() { - fs::create_dir_all(&dest_path) - .map_err(|_| AppError("Couldn't create the output structure.".to_string()))?; - } - - Ok(()) -} - -/// Produces a "fixed" path for an entry. -#[inline(always)] -fn fix_entry_path(entry_path: &Path, dest_path: &Path) -> PathBuf { - dest_path - .components() - .chain(entry_path.components().skip(1)) - .fold(PathBuf::new(), |acc, next| acc.join(next)) -} diff --git a/src/unpacker.rs b/src/unpacker.rs new file mode 100644 index 0000000..c20d470 --- /dev/null +++ b/src/unpacker.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use flate2::bufread::GzDecoder; +use miette::Diagnostic; +use tar::Archive; +use thiserror::Error; + +#[cfg(target_os = "windows")] +const USE_XATTRS: bool = false; + +#[cfg(not(target_os = "windows"))] +const USE_XATTRS: bool = true; + +#[cfg(target_os = "windows")] +const USE_PERMISSIONS: bool = false; + +#[cfg(not(target_os = "windows"))] +const USE_PERMISSIONS: bool = true; + +#[derive(Debug, Diagnostic, Error)] +pub enum UnpackError { + #[error("{message}")] + #[diagnostic(code(arx::unpack::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + +pub struct Unpacker { + bytes: Vec, +} + +impl Unpacker { + pub fn new(bytes: Vec) -> Self { + Self { bytes } + } + + /// Unpacks the tar archive to the given [Path]. + pub fn unpack_to(&self, path: &Path) -> Result, UnpackError> { + let mut archive = Archive::new(GzDecoder::new(&self.bytes[..])); + let mut written_paths = Vec::new(); + + // Get iterator over the entries. + let raw_entries = archive.entries().map_err(|source| { + UnpackError::Io { + message: "Couldn't get entries from the tarball.".to_string(), + source, + } + })?; + + // Create output structure (if necessary). + fs::create_dir_all(path).map_err(|source| { + UnpackError::Io { + message: "Couldn't create the output structure.".to_string(), + source, + } + })?; + + for mut entry in raw_entries.flatten() { + let entry_path = entry.path().map_err(|source| { + UnpackError::Io { + message: "Couldn't get the entry's path.".to_string(), + source, + } + })?; + + let fixed_path = fix_entry_path(&entry_path, path); + + entry.set_preserve_permissions(USE_PERMISSIONS); + entry.set_unpack_xattrs(USE_XATTRS); + + entry.unpack(&fixed_path).map_err(|source| { + UnpackError::Io { + message: "Couldn't unpack the entry.".to_string(), + source, + } + })?; + + written_paths.push(fixed_path); + } + + // Deduplicate, because it **will** contain duplicates. + written_paths.dedup(); + + Ok(written_paths) + } +} + +impl From> for Unpacker { + fn from(bytes: Vec) -> Self { + Unpacker::new(bytes) + } +} + +/// Produces a "fixed" path for an entry. +#[inline(always)] +fn fix_entry_path(entry_path: &Path, dest_path: &Path) -> PathBuf { + dest_path + .components() + .chain(entry_path.components().skip(1)) + .fold(PathBuf::new(), |acc, next| acc.join(next)) +}