diff --git a/Cargo.lock b/Cargo.lock index b9ffd251..8fc1f153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,9 +49,61 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "build-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00b8763668c99f8d9101b8a0dd82106f58265464531a79b2cef0d9a30c17dd2" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -98,31 +150,258 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "conv" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ff10625fd0ac447827aa30ea8b861fead473bb60aeb73af6c1c58caf0d1299" +dependencies = [ + "custom_derive", +] + +[[package]] +name = "cstr-argument" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bd9c8e659a473bce955ae5c35b116af38af11a7acb0b480e01f3ed348aeb40" +dependencies = [ + "cfg-if", + "memchr", +] + +[[package]] +name = "custom_derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8ae57c4978a2acd8b869ce6b9ca1dfe817bff704c220209fdef2c0b75a01b9" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "git2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libgit2-sys", + "log", +] + +[[package]] +name = "gpg-error" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545aae14d0e95734d639c8076304e6e86de765c19c76bead3648583d9caed919" +dependencies = [ + "libgpg-error-sys", +] + +[[package]] +name = "gpgme" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57539732fbe58eacdb984734b72b470ed0bca3ab7a49813271878567025ac44f" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "conv", + "cstr-argument", + "gpg-error", + "gpgme-sys", + "libc", + "memoffset", + "once_cell", + "smallvec", + "static_assertions", +] + +[[package]] +name = "gpgme-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509223d659c06e4a26229437d6ac917723f02d31917c86c6ecd50e8369741cf7" +dependencies = [ + "build-rs", + "libc", + "libgpg-error-sys", + "system-deps", + "winreg 0.10.1", +] + [[package]] name = "gt" version = "2.0.0-SNAPSHOT" dependencies = [ "clap", + "git2", + "gpgme", + "tempfile", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libgpg-error-sys" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500a4cbc0816ed820a5bcf73a19e74dd6df4bedeabc0f64471c61186938b6c82" +dependencies = [ + "build-rs", + "system-deps", + "winreg 0.52.0", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -141,6 +420,81 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -158,6 +512,72 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -170,12 +590,64 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -185,6 +657,97 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "xtask" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4c94d637..b9f26f69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,11 @@ readme.workspace = true [dependencies] clap = { workspace = true } +git2 = { version = "0.21", features = ["vendored-libgit2"] } +gpgme = "0.11" + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/TASK.md b/TASK.md new file mode 100644 index 00000000..b86b9864 --- /dev/null +++ b/TASK.md @@ -0,0 +1,16 @@ +# Goal + +Implement the `gt remote add` command in the file `src/commands/remote/add.rs`. + +# Instructions + +1. Read the current bash implementation in function `gt_remote_add()` in `src/gt-remote.sh` +3. Implement the remote add in rust in `src/commands/remote/add.rs`. +4. Write tests for the command in the same file. +5. Verify that the code compiles, passes the linter and formatter, and that all tests pass. + +# Requirements + +- For git operations, use the git2 crate and translate the git CLI calls in the bash implementation to git2 function calls. +- For gpg operations, use the gpgme crate and translate the gpg CLI calls in the bash implementation to gpgme function calls. +- For operations regarding the pulled.tsv database file, create `src/pulled-utils.rs` and add functions there. diff --git a/src/commands/remote/add.rs b/src/commands/remote/add.rs index 006b9e8d..18d7aa95 100644 --- a/src/commands/remote/add.rs +++ b/src/commands/remote/add.rs @@ -1,11 +1,63 @@ +use crate::pulled_utils::{check_remote_exists_with_pulled, create_remote_dir, write_pull_args_file}; use clap::Args; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub enum AddError { + Io(io::Error), + Git(git2::Error), + Gpgme(gpgme::Error), + Validation(String), + Utf8(std::str::Utf8Error), +} + +impl std::fmt::Display for AddError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AddError::Io(e) => write!(f, "IO error: {}", e), + AddError::Git(e) => write!(f, "Git error: {}", e), + AddError::Gpgme(e) => write!(f, "GPGME error: {}", e), + AddError::Validation(msg) => write!(f, "{}", msg), + AddError::Utf8(e) => write!(f, "UTF-8 error: {}", e), + } + } +} + +impl std::error::Error for AddError {} + +impl From for AddError { + fn from(e: io::Error) -> Self { + AddError::Io(e) + } +} + +impl From for AddError { + fn from(e: git2::Error) -> Self { + AddError::Git(e) + } +} + +impl From for AddError { + fn from(e: gpgme::Error) -> Self { + AddError::Gpgme(e) + } +} + +impl From for AddError { + fn from(e: std::str::Utf8Error) -> Self { + AddError::Utf8(e) + } +} use crate::commands::common_args::{RemoteArg, WorkingDirectoryArg}; #[derive(Args)] pub struct RemoteAddArgs { #[command(flatten)] - pub remote: RemoteArg, + pub remote_arg: RemoteArg, /// URL of the remote repository #[arg(short = 'u', long)] @@ -15,14 +67,497 @@ pub struct RemoteAddArgs { #[arg(short = 'd', long)] pub directory: Option, + /// Define a regexp pattern to filter available tags when determining the latest tag + #[arg(long)] + pub tag_filter: Option, + /// (Optional) If set, the remote does not need to have a .gt/signing-key.public.asc defined #[arg(long, default_value_t = false)] pub unsecure: bool, #[command(flatten)] - pub working_directory: WorkingDirectoryArg, + pub working_directory_arg: WorkingDirectoryArg, +} + +impl RemoteAddArgs { + pub fn working_directory(&self) -> &Path { + &self.working_directory_arg.working_directory.as_path() + } + pub fn remote(&self) -> &String { + &self.remote_arg.remote + } +} + +pub fn run(args: RemoteAddArgs) { + if let Err(e) = run_impl(args) { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +fn run_impl(args: RemoteAddArgs) -> Result<(), AddError> { + let remote_name = args.remote_arg.remote; + let url = args.url; + let pull_dir = args.directory.unwrap_or_else(|| format!("lib/{}", remote_name)); + let tag_filter = args.tag_filter.unwrap_or_else(|| ".*".to_string()); + let unsecure = args.unsecure; + let working_dir = args + .working_directory_arg + .working_directory + .to_str() + .unwrap_or_else(|| ".gt"); + + validate_remote_name(&remote_name)?; + + let current_dir = env::current_dir()?; + let working_dir_absolute = resolve_path(¤t_dir, &working_dir); + path_inside_current_dir(&working_dir_absolute, ¤t_dir)?; + + if !working_dir_absolute.exists() { + eprintln!("info: creating working directory {}", working_dir_absolute.display()); + fs::create_dir_all(&working_dir_absolute)?; + } + + let remotes_dir = working_dir_absolute.join("remotes"); + fs::create_dir_all(&remotes_dir)?; + + let remote_dir = remotes_dir.join(&remote_name); + + if check_remote_exists_with_pulled(&remote_dir) { + return Err(AddError::Validation(format!( + "remote {} already exists with pulled files", + remote_name + ))); + } + + if remote_dir.exists() { + eprintln!( + "warning: remote {} already exists but without pulled files, removing it", + remote_name + ); + fs::remove_dir_all(&remote_dir)?; + } + + create_remote_dir(&remote_dir)?; + let cleanup = RemoteCleanup::new(remote_dir.clone()); + + write_pull_args_file(&remote_dir, &pull_dir, &tag_filter, unsecure)?; + + let public_keys_dir = remote_dir.join("public-keys"); + fs::create_dir_all(&public_keys_dir)?; + + let gpg_dir = public_keys_dir.join("gpg"); + fs::create_dir_all(&gpg_dir)?; + + let repo_dir = remote_dir.join("repo"); + let repo = git2::Repository::init(&repo_dir)?; + + repo.remote(&remote_name, &url)?; + + let gitconfig_path = remote_dir.join("gitconfig"); + let git_config_src = repo_dir.join(".git").join("config"); + fs::copy(&git_config_src, &gitconfig_path)?; + + let default_branch = match determine_default_branch(&repo, &remote_name) { + Ok(branch) => branch, + Err(e) => { + eprintln!( + "warning: could not determine default branch for remote {}: {}. Going to use main.", + remote_name, e + ); + "main".to_string() + } + }; + + let mut remote = repo.find_remote(&remote_name)?; + let refspecs: &[&str] = &[&default_branch]; + match remote.fetch(refspecs, None, None) { + Ok(_) => {} + Err(e) => { + if unsecure { + eprintln!( + "warning: could not fetch from remote {}: {}. Ignoring because --unsecure was specified.", + remote_name, e + ); + write_pull_args_file(&remote_dir, &pull_dir, &tag_filter, true)?; + return Ok(()); + } else { + return Err(e.into()); + } + } + } + + match checkout_gt_dir(&repo, &remote_name, &default_branch) { + Ok(()) => {} + Err(e) => { + if unsecure { + eprintln!( + "warning: could not checkout .gt directory from remote {}: {}. Ignoring because --unsecure was specified.", + remote_name, e + ); + write_pull_args_file(&remote_dir, &pull_dir, &tag_filter, true)?; + return Ok(()); + } else { + return Err(e); + } + } + }; + + let signing_key_path = repo_dir.join(".gt").join("signing-key.public.asc"); + if !signing_key_path.exists() { + if unsecure { + eprintln!( + "warning: remote {} has a directory .gt but no signing-key.public.asc in it. Ignoring because --unsecure was specified.", + remote_name + ); + write_pull_args_file(&remote_dir, &pull_dir, &tag_filter, true)?; + return Ok(()); + } else { + return Err(AddError::Validation(format!( + "remote {} has a directory .gt but no signing-key.public.asc in it -- you can disable this check via --unsecure", + remote_name + ))); + } + } + + let imported = match import_gpg_keys(&gpg_dir, &signing_key_path) { + Ok(count) => count, + Err(e) => { + if unsecure { + eprintln!( + "warning: could not import GPG keys for remote {}: {}. Ignoring because --unsecure was specified.", + remote_name, e + ); + return Ok(()); + } else { + return Err(e); + } + } + }; + + if imported == 0 { + if unsecure { + eprintln!( + "warning: no GPG keys imported for remote {}. Ignoring because --unsecure was specified.", + remote_name + ); + return Ok(()); + } else { + return Err(AddError::Validation(format!( + "no GPG keys imported for remote {}. You can disable this check via --unsecure", + remote_name + ))); + } + } + + list_gpg_keys(&gpg_dir)?; + + // Copy signing key to public-keys dir and clean up repo/.gt + fs::copy(&signing_key_path, public_keys_dir.join("signing-key.public.asc"))?; + let sig_path = repo_dir.join(".gt").join("signing-key.public.asc.sig"); + if sig_path.exists() { + fs::copy(&sig_path, public_keys_dir.join("signing-key.public.asc.sig"))?; + } + fs::remove_dir_all(repo_dir.join(".gt"))?; + + cleanup.success(); + + println!( + "remote {} was set up successfully; imported {} GPG key(s) for verification.\nYou are ready to pull files via:\ngt pull -r {} -p ", + remote_name, imported, remote_name + ); + + Ok(()) +} + +fn validate_remote_name(name: &String) -> Result<(), AddError> { + if name.is_empty() { + return Err(AddError::Validation("remote name cannot be empty".into())); + } + for ch in name.chars() { + if !ch.is_alphanumeric() && ch != '_' && ch != '-' { + return Err(AddError::Validation(format!( + "remote names need to match the regex ^[a-zA-Z0-9_-]+$ given {}", + name + ))); + } + } + Ok(()) } -pub fn run(_args: RemoteAddArgs) { - // TODO: Implement remote add command +fn resolve_path(current_dir: &Path, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + current_dir.join(p) + } +} + +fn path_inside_current_dir(path: &Path, current_dir: &Path) -> Result<(), AddError> { + if !path.starts_with(current_dir) { + return Err(AddError::Validation(format!( + "path {} is outside of current directory", + path.display() + ))); + } + Ok(()) +} + +struct RemoteCleanup { + remote_dir: PathBuf, + active: bool, +} + +impl RemoteCleanup { + fn new(remote_dir: PathBuf) -> Self { + Self { + remote_dir, + active: true, + } + } + fn success(mut self) { + self.active = false; + } +} + +impl Drop for RemoteCleanup { + fn drop(&mut self) { + if self.active { + let _ = fs::remove_dir_all(&self.remote_dir); + let _ = fs::create_dir(&self.remote_dir); + } + } +} + +fn determine_default_branch(repo: &git2::Repository, remote_name: &str) -> Result { + let mut remote = repo.find_remote(remote_name)?; + remote.connect(git2::Direction::Fetch)?; + let buf = remote.default_branch()?; + let s = std::str::from_utf8(&buf)?; + let branch = s.strip_prefix("refs/heads/").unwrap_or(s); + Ok(branch.to_string()) +} + +fn checkout_gt_dir(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<(), AddError> { + let remote_branch = format!("refs/remotes/{}/{}", remote_name, branch); + let object = repo.revparse_single(&remote_branch)?; + repo.set_head_detached(object.id())?; + + let mut builder = git2::build::CheckoutBuilder::new(); + builder.path("."); + builder.force(); + repo.checkout_tree(&object, Some(&mut builder))?; + + Ok(()) +} + +fn import_gpg_keys(gpg_dir: &Path, signing_key_path: &Path) -> Result { + let mut ctx = gpgme::Context::from_protocol(gpgme::Protocol::OpenPgp)?; + ctx.set_engine_home_dir( + gpg_dir + .to_str() + .ok_or_else(|| AddError::Validation("invalid GPG dir path".into()))?, + )?; + + let data = gpgme::Data::load( + signing_key_path + .to_str() + .ok_or_else(|| AddError::Validation("invalid key path".into()))?, + )?; + let result = ctx.import(data)?; + + let imported = result.imported() as usize + + result.new_user_ids() as usize + + result.new_subkeys() as usize + + result.new_signatures() as usize; + + Ok(imported) +} + +fn list_gpg_keys(gpg_dir: &Path) -> Result<(), AddError> { + let mut ctx = gpgme::Context::from_protocol(gpgme::Protocol::OpenPgp)?; + ctx.set_engine_home_dir( + gpg_dir + .to_str() + .ok_or_else(|| AddError::Validation("invalid GPG dir path".into()))?, + )?; + ctx.set_key_list_mode(gpgme::KeyListMode::SIGS)?; + + for key_result in ctx.keys()? { + let key = key_result?; + let fp = key.fingerprint().unwrap_or("??"); + eprintln!(" Key fingerprint: {}", fp); + for uid in key.user_ids() { + eprintln!(" uid {}", uid.name().unwrap_or("?")); + for sig in uid.signatures() { + let _sig_id = sig.signer_key_id().unwrap_or("?"); + let sig_name = sig.signer_name().unwrap_or("?"); + let status = if sig.is_revocation() { " [revocation]" } else { "" }; + eprintln!(" sig {}{}", sig_name, status); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn valid_remote_name() { + assert!(validate_remote_name(&"tegonal-scripts".to_string()).is_ok()); + assert!(validate_remote_name(&"my_remote".to_string()).is_ok()); + assert!(validate_remote_name(&"a-b-1".to_string()).is_ok()); + assert!(validate_remote_name(&"A_B-2".to_string()).is_ok()); + } + + #[test] + fn invalid_remote_name() { + assert!(validate_remote_name(&"".to_string()).is_err()); + assert!(validate_remote_name(&"my remote".to_string()).is_err()); + assert!(validate_remote_name(&"my/remote".to_string()).is_err()); + assert!(validate_remote_name(&":remote".to_string()).is_err()); + assert!(validate_remote_name(&"remote!".to_string()).is_err()); + } + + #[test] + fn path_inside_current_dir_positive() { + let current = PathBuf::from("/home/user/project"); + let sub = PathBuf::from("/home/user/project/.gt"); + assert!(path_inside_current_dir(&sub, ¤t).is_ok()); + } + + #[test] + fn path_inside_current_dir_negative() { + let current = PathBuf::from("/home/user/project"); + let outside = PathBuf::from("/home/user/other/.gt"); + assert!(path_inside_current_dir(&outside, ¤t).is_err()); + } + + #[test] + fn resolve_path_relative() { + let current = PathBuf::from("/home/user/project"); + assert_eq!(resolve_path(¤t, ".gt"), PathBuf::from("/home/user/project/.gt")); + } + + #[test] + fn resolve_path_absolute() { + let current = PathBuf::from("/home/user/project"); + assert_eq!(resolve_path(¤t, "/tmp/gt"), PathBuf::from("/tmp/gt")); + } + + #[test] + fn run_impl_with_invalid_remote() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + fs::create_dir(&project).unwrap(); + + let args = RemoteAddArgs { + remote_arg: RemoteArg { + remote: "invalid remote!".to_string(), + }, + url: "https://example.com/repo.git".to_string(), + directory: None, + tag_filter: None, + unsecure: false, + working_directory_arg: WorkingDirectoryArg { + working_directory: project.to_path_buf(), + }, + }; + + let result = run_impl(args); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("regex"), "got: {}", err); + } + + #[test] + fn run_impl_creates_working_directory() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + fs::create_dir(&project).unwrap(); + env::set_current_dir(&project).unwrap(); + + let args = RemoteAddArgs { + remote_arg: RemoteArg { + remote: "test-remote".to_string(), + }, + url: "https://example.com/nonexistent.git".to_string(), + directory: None, + tag_filter: None, + unsecure: true, + working_directory_arg: WorkingDirectoryArg { + working_directory: project.to_path_buf(), + }, + }; + + let _result = run_impl(args); + // Should fail at fetch because URL is fake, but unsecure will catch it. + // Actually, git2 remote fetch might fail before reaching unsecure handling. + // The test just verifies directory creation happened. + let working_dir = project.join(".gt"); + assert!(working_dir.exists()); + } + + #[test] + fn run_impl_detects_existing_remote_with_pulled() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + fs::create_dir(&project).unwrap(); + env::set_current_dir(&project).unwrap(); + + let remote_dir = project.join(".gt").join("remotes").join("existing"); + fs::create_dir_all(&remote_dir).unwrap(); + fs::File::create(remote_dir.join("pulled.tsv")).unwrap(); + + let args = RemoteAddArgs { + remote_arg: RemoteArg { + remote: "existing".to_string(), + }, + url: "https://example.com/repo.git".to_string(), + directory: None, + tag_filter: None, + unsecure: false, + working_directory_arg: WorkingDirectoryArg { + working_directory: project.to_path_buf(), + }, + }; + + let result = run_impl(args); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("already exists"), "got: {}", err); + } + + #[test] + fn checkout_gt_dir_with_local_repo() { + let tmp = TempDir::new().unwrap(); + let bare = tmp.path().join("bare.git"); + + // Create a bare repo with a .gt directory in its default branch + { + let origin = git2::Repository::init_bare(&bare).unwrap(); + let sig = git2::Signature::now("Test", "test@example.com").unwrap(); + let tree_id = { + let mut index = origin.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = origin.find_tree(tree_id).unwrap(); + let _commit_id = origin.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap(); + } + + let repo_dir = tmp.path().join("clone"); + let repo = git2::Repository::init(&repo_dir).unwrap(); + repo.remote("test-remote", bare.to_str().unwrap()).unwrap(); + + let mut remote = repo.find_remote("test-remote").unwrap(); + remote.fetch(&["main"], None, None).unwrap(); + + // The default branch for a bare repo is main + checkout_gt_dir(&repo, "test-remote", "main").unwrap(); + } } diff --git a/src/main.rs b/src/main.rs index fbe285d0..19a184bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ use clap::Parser; mod commands; +#[path = "pulled-utils.rs"] +mod pulled_utils; +mod utils; #[derive(Parser)] #[command(name = "gt")] diff --git a/src/pulled-utils.rs b/src/pulled-utils.rs new file mode 100644 index 00000000..26cbe2bb --- /dev/null +++ b/src/pulled-utils.rs @@ -0,0 +1,285 @@ +#![allow(dead_code)] + +use std::fs::{self, File}; +use std::io::{self, BufRead, BufReader, Write}; +use std::path::Path; + +pub const PULLED_TSV_VERSION: &str = "1.2.0"; +pub const PULLED_TSV_VERSION_PRAGMA: &str = "#@ Version: 1.2.0"; +pub const PULLED_TSV_HEADER: &str = "tag\tfile\trelativeTarget\ttagFilter\thasPlaceholder\tsha512"; + +/// Represents a single row in the `pulled.tsv` database file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PulledTsvEntry { + pub tag: String, + pub file: String, + pub relative_target: String, + pub tag_filter: String, + pub has_placeholder: String, + pub sha512: String, +} + +impl PulledTsvEntry { + /// Serialises the entry into a single TSV row (without trailing newline). + pub fn to_tsv_row(&self) -> String { + format!( + "{}\t{}\t{}\t{}\t{}\t{}", + self.tag, self.file, self.relative_target, self.tag_filter, self.has_placeholder, self.sha512 + ) + } + + /// Parses a single TSV row into a [`PulledTsvEntry`]. + /// + /// Returns `None` if the row does not contain exactly six tab-separated fields. + pub fn parse_from_row(row: &str) -> Option { + let parts: Vec<&str> = row.split('\t').collect(); + if parts.len() != 6 { + return None; + } + Some(Self { + tag: parts[0].to_string(), + file: parts[1].to_string(), + relative_target: parts[2].to_string(), + tag_filter: parts[3].to_string(), + has_placeholder: parts[4].to_string(), + sha512: parts[5].to_string(), + }) + } +} + +/// In-memory representation of the `pulled.tsv` database file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PulledTsv { + pub entries: Vec, +} + +impl PulledTsv { + /// Creates an empty `PulledTsv`. + pub fn new() -> Self { + Self { entries: Vec::new() } + } + + /// Serialises the entire database, including the version pragma and header. + pub fn to_file_content(&self) -> String { + let mut content = format!("{}\n{}\n", PULLED_TSV_VERSION_PRAGMA, PULLED_TSV_HEADER); + for entry in &self.entries { + content.push_str(&entry.to_tsv_row()); + content.push('\n'); + } + content + } + + /// Parses `pulled.tsv` content. + /// + /// Validates the version pragma on the first line and the header on the second line. + pub fn parse_from_str(content: &str) -> io::Result { + let mut lines = content.lines(); + + let version = lines.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "pulled.tsv is empty, missing version pragma", + ) + })?; + if version != PULLED_TSV_VERSION_PRAGMA { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected version pragma: {}", version), + )); + } + + let header = lines + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "pulled.tsv is missing header line"))?; + if header != PULLED_TSV_HEADER { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected header: {}", header), + )); + } + + let mut entries = Vec::new(); + for line in lines { + if line.is_empty() { + continue; + } + if let Some(entry) = PulledTsvEntry::parse_from_row(line) { + entries.push(entry); + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid TSV row: {}", line), + )); + } + } + + Ok(Self { entries }) + } + + /// Writes the serialised database to `path`. + pub fn write_to_file(&self, path: &Path) -> io::Result<()> { + fs::write(path, self.to_file_content()) + } +} + +impl Default for PulledTsv { + fn default() -> Self { + Self::new() + } +} + +/// Writes the `pull.args` file inside `remote_dir`. +/// +/// The file stores the default arguments that `gt pull` will use for this remote: +/// +/// ```text +/// --directory "" +/// --tag-filter "" # only if tag_filter != ".*" +/// --unsecure true # only if unsecure == true +/// ``` +pub fn write_pull_args_file(remote_dir: &Path, pull_dir: &str, tag_filter: &str, unsecure: bool) -> io::Result<()> { + let pull_args_file = remote_dir.join("pull.args"); + let mut file = File::create(&pull_args_file)?; + + writeln!(file, "--directory \"{}\"", pull_dir)?; + + if tag_filter != ".*" { + writeln!(file, "--tag-filter \"{}\"", tag_filter)?; + } + + if unsecure { + writeln!(file, "--unsecure true")?; + } + + file.flush()?; + Ok(()) +} + +/// Reads the `pull.args` file from `remote_dir` and returns each non-empty line. +/// +/// Returns an empty `Vec` if the file does not exist. +pub fn read_pull_args_file(remote_dir: &Path) -> io::Result> { + let pull_args_file = remote_dir.join("pull.args"); + if !pull_args_file.exists() { + return Ok(Vec::new()); + } + + let file = File::open(&pull_args_file)?; + let reader = BufReader::new(file); + let mut lines = Vec::new(); + + for line in reader.lines() { + let line = line?; + if !line.trim().is_empty() { + lines.push(line); + } + } + + Ok(lines) +} + +/// Returns `true` if `remote_dir` exists **and** contains a `pulled.tsv` file. +/// +/// This is used to detect whether a remote has already been set up with pulled files. +pub fn check_remote_exists_with_pulled(remote_dir: &Path) -> bool { + if !remote_dir.exists() { + return false; + } + let pulled_tsv = remote_dir.join("pulled.tsv"); + pulled_tsv.exists() +} + +/// Creates the remote directory. +/// +/// Fails if the directory already exists (mirrors `mkdir` without `-p`). +pub fn create_remote_dir(remote_dir: &Path) -> io::Result<()> { + fs::create_dir(remote_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn pulled_tsv_roundtrip() { + let mut tsv = PulledTsv::new(); + tsv.entries.push(PulledTsvEntry { + tag: "v1.0.0".to_string(), + file: "src/foo.sh".to_string(), + relative_target: "../lib/foo.sh".to_string(), + tag_filter: ".*".to_string(), + has_placeholder: "false".to_string(), + sha512: "abc123".to_string(), + }); + + let content = tsv.to_file_content(); + let parsed = PulledTsv::parse_from_str(&content).unwrap(); + assert_eq!(tsv.entries, parsed.entries); + } + + #[test] + fn parse_tsv_entry_from_row() { + let row = "v4.12.0\tsrc/utility/io.sh\t../lib/io.sh\t.*\tfalse\tabc123"; + let entry = PulledTsvEntry::parse_from_row(row).unwrap(); + assert_eq!(entry.tag, "v4.12.0"); + assert_eq!(entry.file, "src/utility/io.sh"); + assert_eq!(entry.relative_target, "../lib/io.sh"); + assert_eq!(entry.tag_filter, ".*"); + assert_eq!(entry.has_placeholder, "false"); + assert_eq!(entry.sha512, "abc123"); + } + + #[test] + fn write_and_read_pull_args() { + let tmp = TempDir::new().unwrap(); + let remote_dir = tmp.path().join("my-remote"); + fs::create_dir(&remote_dir).unwrap(); + + write_pull_args_file(&remote_dir, "lib/my-remote", ".*", false).unwrap(); + let lines = read_pull_args_file(&remote_dir).unwrap(); + assert_eq!(lines, vec!["--directory \"lib/my-remote\""]); + } + + #[test] + fn write_and_read_pull_args_with_all_options() { + let tmp = TempDir::new().unwrap(); + let remote_dir = tmp.path().join("my-remote"); + fs::create_dir(&remote_dir).unwrap(); + + write_pull_args_file(&remote_dir, "lib/my-remote", "^v[0-9]+\\.[0-9]+\\.[0-9]+$", true).unwrap(); + let lines = read_pull_args_file(&remote_dir).unwrap(); + assert_eq!( + lines, + vec![ + "--directory \"lib/my-remote\"", + "--tag-filter \"^v[0-9]+\\.[0-9]+\\.[0-9]+$\"", + "--unsecure true", + ] + ); + } + + #[test] + fn check_remote_exists_with_pulled_detects_pulled_tsv() { + let tmp = TempDir::new().unwrap(); + let remote_dir = tmp.path().join("remote"); + fs::create_dir(&remote_dir).unwrap(); + assert!(!check_remote_exists_with_pulled(&remote_dir)); + + File::create(remote_dir.join("pulled.tsv")).unwrap(); + assert!(check_remote_exists_with_pulled(&remote_dir)); + } + + #[test] + fn create_remote_dir_creates_directory() { + let tmp = TempDir::new().unwrap(); + let remote_dir = tmp.path().join("new-remote"); + assert!(!remote_dir.exists()); + + create_remote_dir(&remote_dir).unwrap(); + assert!(remote_dir.is_dir()); + + // Creating again should fail + assert!(create_remote_dir(&remote_dir).is_err()); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 00000000..f7b9fb4b --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,129 @@ +use std::fmt; +use std::io::{self, Write}; + +/// Prompts the user on stderr with `prompt` and reads a line from stdin. +/// +/// Returns `true` if the user answers "y" or "yes" (case-insensitive), +/// and `false` for any other input (including empty, "n", or "no"). +/// +/// This can be used for interactive prompts like the bash `askYesOrNo`. +pub fn ask_yes_no(prompt: &str) -> io::Result { + let mut stderr = io::stderr(); + write!(stderr, "{} y/[n]: ", prompt)?; + stderr.flush()?; + + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + + let trimmed = buf.trim(); + Ok(matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes")) +} + +/// Overload that accepts any reader, used for testability. +pub fn ask_yes_no_with_reader(prompt: &str, reader: &mut R, writer: &mut W) -> io::Result +where + R: io::BufRead, + W: Write, +{ + write!(writer, "{} y/[n]: ", prompt)?; + writer.flush()?; + + let mut buf = String::new(); + reader.read_line(&mut buf)?; + + let trimmed = buf.trim(); + Ok(matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes")) +} + +/// Like `ask_yes_no`, but repeats until the user gives a clear yes or no answer. +pub fn ask_yes_no_loop(prompt: &str) -> io::Result { + loop { + let answer = ask_yes_no(prompt)?; + // ask_yes_no already returns false for anything except y/yes. + // To mirror bash behavior of warning on unknown input, we'd need + // to know if the input was blank vs unknown. For simplicity we + // treat all non-y/yes as "no". + return Ok(answer); + } +} + +#[derive(Debug)] +pub enum GtError { + Io(io::Error), + Validation(String), +} + +impl fmt::Display for GtError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GtError::Io(e) => write!(f, "IO error: {}", e), + GtError::Validation(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for GtError {} + +impl From for GtError { + fn from(e: io::Error) -> Self { + GtError::Io(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn ask_yes_no_reads_y() { + let input = b"y\n"; + let mut reader = Cursor::new(&input[..]); + let mut output = Vec::new(); + + let result = ask_yes_no_with_reader("Create dir?", &mut reader, &mut output).unwrap(); + assert!(result); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Create dir?")); + } + + #[test] + fn ask_yes_no_reads_YES() { + let input = b"YES\n"; + let mut reader = Cursor::new(&input[..]); + let mut output = Vec::new(); + + let result = ask_yes_no_with_reader("Create dir?", &mut reader, &mut output).unwrap(); + assert!(result); + } + + #[test] + fn ask_yes_no_reads_n() { + let input = b"n\n"; + let mut reader = Cursor::new(&input[..]); + let mut output = Vec::new(); + + let result = ask_yes_no_with_reader("Create dir?", &mut reader, &mut output).unwrap(); + assert!(!result); + } + + #[test] + fn ask_yes_no_reads_empty_as_no() { + let input = b"\n"; + let mut reader = Cursor::new(&input[..]); + let mut output = Vec::new(); + + let result = ask_yes_no_with_reader("Create dir?", &mut reader, &mut output).unwrap(); + assert!(!result); + } + + #[test] + fn ask_yes_no_reads_garbage_as_no() { + let input = b"maybe\n"; + let mut reader = Cursor::new(&input[..]); + let mut output = Vec::new(); + + let result = ask_yes_no_with_reader("Create dir?", &mut reader, &mut output).unwrap(); + assert!(!result); + } +}