diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0c0d613..22fb055 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,16 +2,18 @@ name: Rust on: push: - branches: ["main"] + branches: ['main'] pull_request: - branches: ["main"] env: CARGO_TERM_COLOR: always jobs: - build: - runs-on: ubuntu-latest + build-test: + strategy: + matrix: + platform: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -59,7 +61,7 @@ jobs: cache-on-failure: true - run: cargo doc --workspace --all-features --no-deps --document-private-items env: - RUSTDOCFLAGS: "--cfg docsrs -D warnings" + RUSTDOCFLAGS: '--cfg docsrs -D warnings' fmt: runs-on: ubuntu-latest diff --git a/.vscode/settings.json b/.vscode/settings.json index b18b810..63d38ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "rust-analyzer.rustfmt.extraArgs": ["+nightly"], "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer" - } + }, + "rust-analyzer.cargo.features": "all" } diff --git a/Cargo.lock b/Cargo.lock index fd6db2a..e133eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + [[package]] name = "arbitrary" version = "1.3.2" @@ -148,6 +154,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fa7e488fcdc4688b7872e6fdf3d6a8ecbf2611621fee099bdb953f160eec2e" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7839989c32a79e473193cbc65e5750d6bf7e41fe75afa24b41728ee66a57b283" +dependencies = [ + "darling", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bstr" version = "1.10.0" @@ -197,11 +226,45 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cc" -version = "1.1.10" +version = "1.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +checksum = "e9d013ecb737093c0e86b151a7b837993cf9ec6c502946cfb44bedc392421e0b" +dependencies = [ + "shlex", +] [[package]] name = "cfg-if" @@ -221,9 +284,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.15" +version = "4.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac" dependencies = [ "clap_builder", "clap_derive", @@ -231,9 +294,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.15" +version = "4.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" +checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73" dependencies = [ "anstream", "anstyle", @@ -259,6 +322,20 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +[[package]] +name = "cliclack" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c827ccada848b64fba073b64518a7416d605ad70c594b5450b5ed1d97e3b5d4" +dependencies = [ + "console", + "indicatif", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "colorchoice" version = "1.0.2" @@ -275,6 +352,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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 = "console_error_panic_hook" version = "0.1.7" @@ -298,11 +388,17 @@ dependencies = [ "serde", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" dependencies = [ "libc", ] @@ -351,6 +447,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -362,6 +493,27 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -402,6 +554,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "equivalent" version = "1.0.1" @@ -564,9 +722,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" dependencies = [ "atomic-waker", "bytes", @@ -599,6 +757,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "hex" version = "0.4.3" @@ -683,9 +847,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.2" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", "http", @@ -719,6 +883,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -747,20 +917,53 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" 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 = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -775,9 +978,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] @@ -790,9 +993,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "libm" @@ -868,7 +1071,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", @@ -898,6 +1101,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -908,11 +1120,17 @@ dependencies = [ "libm", ] +[[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.36.3" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" dependencies = [ "memchr", ] @@ -1041,6 +1259,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "portable-atomic" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1086,9 +1310,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b22d8e7369034b9a7132bc2008cac12f2013c8132b45e0554e6e20e2617f2156" +checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" dependencies = [ "bytes", "pin-project-lite", @@ -1104,9 +1328,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba92fb39ec7ad06ca2582c0ca834dfeadcaf06ddfc8e635c80aa7e1c05315fdd" +checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" dependencies = [ "bytes", "rand", @@ -1121,22 +1345,22 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285" +checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" dependencies = [ "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -1220,13 +1444,12 @@ checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "reqwest" -version = "0.12.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", "futures-util", "http", @@ -1261,7 +1484,7 @@ dependencies = [ "wasm-streams", "web-sys", "webpki-roots", - "winreg", + "windows-registry", ] [[package]] @@ -1279,27 +1502,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rpassword" -version = "7.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.48.0", -] - -[[package]] -name = "rtoolbox" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1344,15 +1546,21 @@ checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.102.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "84678086bd54edf2b415183ed7a94d0efb049f1b646a33e22a36f3794be6ae56" dependencies = [ "ring", "rustls-pki-types", "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.18" @@ -1378,15 +1586,6 @@ dependencies = [ "regex", ] -[[package]] -name = "scc" -version = "2.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a744401cf50c4fe0c428808d76f6fffd75ff6b041c8226210397522b4dde7da" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1394,25 +1593,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sdd" -version = "3.0.2" +name = "semver" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0495e4577c672de8254beb68d01a9b62d0e8a13c099edecdbedccce3223cd29f" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" -version = "1.0.206" +version = "1.0.209" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3e4cd94123dd520a128bcd11e34d9e9e423e7e3e50425cb1b4b1e3549d0284" +checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.206" +version = "1.0.209" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabfb6138d2383ea8208cf98ccf69cdfb1aff4088460681d84189aa259762f97" +checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", @@ -1421,9 +1623,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.124" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -1453,39 +1655,29 @@ dependencies = [ ] [[package]] -name = "serial_test" -version = "3.1.1" +name = "sha2" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] -name = "serial_test_derive" -version = "3.1.1" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "sha2" -version = "0.10.8" +name = "signal-hook-registry" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "libc", ] [[package]] @@ -1515,6 +1707,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.7" @@ -1527,32 +1725,50 @@ dependencies = [ [[package]] name = "soldeer" -version = "0.3.4" +version = "0.4.0" +dependencies = [ + "soldeer-commands", + "tokio", + "yansi", +] + +[[package]] +name = "soldeer-commands" +version = "0.4.0" dependencies = [ - "chrono", "clap", + "cliclack", + "email-address-parser", + "soldeer-core", +] + +[[package]] +name = "soldeer-core" +version = "0.4.0" +dependencies = [ + "bon", + "chrono", + "cliclack", "const-hex", + "derive_more", "dunce", - "email-address-parser", - "futures", "home", "ignore", "mockito", "path-slash", - "rand", "regex", "reqwest", - "rpassword", "sanitize-filename", + "semver", "serde", "serde_json", - "serial_test", "sha2", + "temp-env", + "testdir", "thiserror", "tokio", "toml_edit", "uuid", - "yansi", "zip", "zip-extract", ] @@ -1577,9 +1793,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.74" +version = "2.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" dependencies = [ "proc-macro2", "quote", @@ -1591,6 +1807,58 @@ name = "sync_wrapper" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +dependencies = [ + "futures-core", +] + +[[package]] +name = "sysinfo" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c18a6156d1f27a9592ee18c1a846ca8dd5c258b7179fc193ae87c74ebb666f5" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "winapi", +] + +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "futures", + "parking_lot", +] + +[[package]] +name = "testdir" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee79e927b64d193f5abb60d20a0eb56be0ee5a242fdeb8ce3bf054177006de52" +dependencies = [ + "anyhow", + "backtrace", + "cargo_metadata", + "once_cell", + "sysinfo", + "whoami", +] + +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] [[package]] name = "thiserror" @@ -1629,9 +1897,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.2" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", @@ -1639,6 +1907,7 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -1668,9 +1937,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", @@ -1718,15 +1987,15 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -1792,6 +2061,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[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.23" @@ -1801,6 +2076,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" + +[[package]] +name = "unicode-xid" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" + [[package]] name = "untrusted" version = "0.9.0" @@ -1865,21 +2152,28 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", @@ -1892,9 +2186,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" dependencies = [ "cfg-if", "js-sys", @@ -1904,9 +2198,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1914,9 +2208,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", @@ -1927,9 +2221,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "wasm-streams" @@ -1946,9 +2240,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" dependencies = [ "js-sys", "wasm-bindgen", @@ -1956,13 +2250,40 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.3" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +checksum = "0bd24728e5af82c6c4ec1b66ac4844bdf8156257fccda846ec58b42cd0cdbe6a" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", + "web-sys", +] + +[[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.9" @@ -1972,6 +2293,42 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2129,21 +2486,14 @@ dependencies = [ "memchr", ] -[[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 = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] [[package]] name = "zerocopy" @@ -2171,12 +2521,26 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zip" -version = "2.1.6" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40dd8c92efc296286ce1fbd16657c5dbefff44f1b4ca01cc5f517d8b7b3d3e2e" +checksum = "dc5e4288ea4057ae23afc69a4472434a87a2495cafce6632fd1c4ec9f5cf3494" dependencies = [ "arbitrary", "bzip2", diff --git a/Cargo.toml b/Cargo.toml index 00f1922..a91b3c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,90 +1,49 @@ -[package] +[workspace] +members = ["crates/cli", "crates/core", "crates/commands"] +resolver = "2" + +[workspace.package] authors = ["m4rio"] -categories = ["development-tools", "development-tools"] -description = "A solidity package manager written in rust. It's minimal and easy within your solidity project. Works best with foundry." +categories = ["development-tools"] +description = "A minimal Solidity package manager written in Rust, best used with Foundry" edition = "2021" -exclude = [".github/*", ".vscode/*"] -homepage = "https://github.com/mario-eth/soldeer" -keywords = ["solidity", "package-manager"] +exclude = ["tests/"] +homepage = "https://soldeer.xyz" +keywords = ["solidity", "package-manager", "foundry"] license = "MIT" -name = "soldeer" readme = "./README.md" repository = "https://github.com/mario-eth/soldeer" -version = "0.3.4" rust-version = "1.80" - -[lints] -workspace = true +version = "0.4.0" [workspace.lints.clippy] dbg-macro = "warn" manual-string-new = "warn" -# uninlined-format-args = "warn" -# use-self = "warn" +uninlined-format-args = "warn" +use-self = "warn" redundant-clone = "warn" [workspace.lints.rust] rust-2018-idioms = "warn" -# unreachable-pub = "warn" +unreachable-pub = "warn" unused-must-use = "warn" redundant-lifetimes = "warn" [workspace.lints.rustdoc] all = "warn" -[dependencies] -chrono = { version = "0.4.38", default-features = false, features = [ - "std", - "serde", -] } +[workspace.dependencies] clap = { version = "4.5.9", features = ["derive"] } -const-hex = "1.12.0" -email-address-parser = "2.0.0" -futures = "0.3.30" -ignore = { version = "0.4.22", features = ["simd-accel"] } -regex = "1.10.5" -reqwest = { version = "0.12.5", features = [ - "blocking", - "json", - "multipart", - "stream", -], default-features = false } -rpassword = "7.3.1" -sanitize-filename = "0.5.0" -serde = { version = "1.0.204", features = ["derive"] } -serde_json = "1.0.120" -sha2 = "0.10.8" -home = "0.5.9" +cliclack = "0.3.4" +mockito = "1.5.0" +path-slash = "0.2.1" +reqwest = { version = "0.12.5", default-features = false } +temp-env = { version = "0.3.6", features = ["async_closure"] } +testdir = "0.9.1" thiserror = "1.0.63" tokio = { version = "1.38.0", features = [ - "rt-multi-thread", - "macros", "io-util", + "macros", + "process", + "rt-multi-thread", ] } -toml_edit = { version = "0.22.15", features = ["serde"] } -uuid = { version = "1.10.0", features = ["serde", "v4"] } -yansi = "1.0.1" -zip = { version = "2", default-features = false, features = ["deflate"] } -zip-extract = { version = "0.2.1", default-features = false, features = [ - "deflate", - "bzip2", -] } -dunce = "1.0.5" -path-slash = "0.2.1" - -[dev-dependencies] -mockito = "1.4.0" -rand = "0.8.5" -serial_test = "3.1.1" - -[lib] -name = "soldeer" -path = "src/lib.rs" - -[[bin]] -name = "soldeer" -path = "src/main.rs" - -[features] -default = ["rustls"] -rustls = ["reqwest/rustls-tls"] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..e1f2fc5 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "soldeer" +description.workspace = true +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "soldeer" +path = "src/main.rs" + +[dependencies] +soldeer-commands = { path = "../commands" } +tokio.workspace = true +yansi = { version = "1.0.1", features = ["detect-tty", "detect-env"] } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..eb05b5e --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,11 @@ +//! Soldeer is a package manager for Solidity projects +use soldeer_commands::{commands::Parser as _, run, Args}; +use yansi::Paint as _; + +#[tokio::main] +async fn main() { + let args = Args::parse(); + if let Err(err) = run(args.command).await { + eprintln!("{}", err.to_string().red()) + } +} diff --git a/crates/commands/Cargo.toml b/crates/commands/Cargo.toml new file mode 100644 index 0000000..8079093 --- /dev/null +++ b/crates/commands/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "soldeer-commands" +description = "High-level commands for the Soldeer CLI" +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +clap.workspace = true +cliclack.workspace = true +email-address-parser = "2.0.0" +soldeer-core = { path = "../core", features = ["cli"] } + +[features] +default = ["rustls"] +rustls = ["soldeer-core/rustls"] diff --git a/crates/commands/src/commands/init.rs b/crates/commands/src/commands/init.rs new file mode 100644 index 0000000..13a6939 --- /dev/null +++ b/crates/commands/src/commands/init.rs @@ -0,0 +1,65 @@ +use clap::Parser; +use cliclack::{ + log::{remark, success}, + multi_progress, +}; +use soldeer_core::{ + config::{add_to_config, read_soldeer_config, Paths}, + install::{ensure_dependencies_dir, install_dependency, Progress}, + lock::add_to_lockfile, + registry::get_latest_forge_std, + remappings::{edit_remappings, RemappingsAction}, + utils::remove_forge_lib, + Result, +}; +use std::fs; + +/// Convert a Foundry project to use Soldeer +#[derive(Debug, Clone, Default, Parser)] +#[clap(after_help = "For more information, read the README.md")] +pub struct Init { + /// Clean the Foundry project by removing .gitmodules and the lib directory + #[arg(long, default_value_t = false)] + pub clean: bool, +} + +pub(crate) async fn init_command(paths: &Paths, cmd: Init) -> Result<()> { + if cmd.clean { + remark("Flag `--clean` was set, removing `lib` dir and submodules")?; + remove_forge_lib(&paths.root).await?; + } + + let config = read_soldeer_config(&paths.config)?; + success("Done reading config")?; + ensure_dependencies_dir(&paths.dependencies)?; + let dependency = get_latest_forge_std().await?; + let multi = multi_progress(format!("Installing {dependency}")); + let progress = Progress::new(&multi, 1); + progress.start_all(); + let lock = + install_dependency(&dependency, None, &paths.dependencies, None, false, progress.clone()) + .await + .inspect_err(|e| { + multi.error(e); + })?; + progress.stop_all(); + multi.stop(); + add_to_config(&dependency, &paths.config)?; + success("Dependency added to config")?; + add_to_lockfile(lock, &paths.lock)?; + success("Dependency added to lockfile")?; + edit_remappings(&RemappingsAction::Add(dependency), &config, paths)?; + success("Dependency added to remappings")?; + + let gitignore_path = paths.root.join(".gitignore"); + if gitignore_path.exists() { + let mut gitignore = fs::read_to_string(&gitignore_path)?; + if !gitignore.contains("dependencies") { + gitignore.push_str("\n\n# Soldeer\n/dependencies\n"); + fs::write(&gitignore_path, gitignore)?; + } + } + success("Added `dependencies` to .gitignore")?; + + Ok(()) +} diff --git a/crates/commands/src/commands/install.rs b/crates/commands/src/commands/install.rs new file mode 100644 index 0000000..7a57cb1 --- /dev/null +++ b/crates/commands/src/commands/install.rs @@ -0,0 +1,170 @@ +use super::validate_dependency; +use clap::Parser; +use cliclack::{ + log::{remark, success, warning}, + multi_progress, outro, +}; +use soldeer_core::{ + config::{ + add_to_config, read_config_deps, read_soldeer_config, Dependency, GitIdentifier, Paths, + }, + errors::{InstallError, LockError}, + install::{ensure_dependencies_dir, install_dependencies, install_dependency, Progress}, + lock::{add_to_lockfile, generate_lockfile_contents, read_lockfile}, + remappings::{edit_remappings, RemappingsAction}, + Result, +}; +use std::fs; + +/// Install a dependency +#[derive(Debug, Clone, Default, Parser)] +#[clap( + long_about = "Install a dependency + +If used with arguments, a dependency will be added to the configuration. When used without argument, installs all dependencies that are missing. + +Examples: +- Install all: soldeer install +- Add from registry: soldeer install lib_name~2.3.0 +- Add with custom URL: soldeer install lib_name~2.3.0 https://foo.bar/lib.zip +- Add with git: soldeer install lib_name~2.3.0 git@github.com:foo/bar.git +- Add with git (commit): soldeer install lib_name~2.3.0 git@github.com:foo/bar.git --rev 05f218fb6617932e56bf5388c3b389c3028a7b73 +- Add with git (tag): soldeer install lib_name~2.3.0 git@github.com:foo/bar.git --tag v2.3.0 +- Add with git (branch): soldeer install lib_name~2.3.0 git@github.com:foo/bar.git --branch feature/baz", + after_help = "For more information, read the README.md" +)] +pub struct Install { + /// The dependency name and version, separated by a tilde. The version is always required. + /// + /// If not present, this command will install all dependencies which are missing. + #[arg(value_parser = validate_dependency, value_name = "DEPENDENCY~VERSION")] + pub dependency: Option, + + /// The URL to the dependency zip file. + /// + /// If not present, the package will be installed from the Soldeer repository. + /// + /// Example: https://my-domain/dep.zip + #[arg(value_name = "URL", requires = "dependency")] + pub remote_url: Option, + + /// A Git commit hash + #[arg(long, group = "identifier", requires = "remote_url")] + pub rev: Option, + + /// A Git tag + #[arg(long, group = "identifier", requires = "remote_url")] + pub tag: Option, + + /// A Git branch + #[arg(long, group = "identifier", requires = "remote_url")] + pub branch: Option, + + /// If set, this command will delete the existing remappings and re-create them + #[arg(short = 'g', long, default_value_t = false)] + pub regenerate_remappings: bool, + + /// If set, this command will install dependencies recursively (via git submodules or via + /// soldeer) + #[arg(short = 'd', long, default_value_t = false)] + pub recursive_deps: bool, + + /// Perform a clean install by re-installing all dependencies + #[arg(long, default_value_t = false)] + pub clean: bool, +} + +pub(crate) async fn install_command(paths: &Paths, cmd: Install) -> Result<()> { + let mut config = read_soldeer_config(&paths.config)?; + if cmd.regenerate_remappings { + config.remappings_regenerate = true; + } + if cmd.recursive_deps { + config.recursive_deps = true; + } + success("Done reading config")?; + ensure_dependencies_dir(&paths.dependencies)?; + let dependencies: Vec = read_config_deps(&paths.config)?; + match cmd.dependency { + None => { + let lockfile = read_lockfile(&paths.lock)?; + success("Done reading lockfile")?; + if cmd.clean { + remark("Flag `--clean` was set, re-installing all dependencies")?; + fs::remove_dir_all(&paths.dependencies).map_err(|e| InstallError::IOError { + path: paths.dependencies.clone(), + source: e, + })?; + ensure_dependencies_dir(&paths.dependencies)?; + } + let multi = multi_progress("Installing dependencies"); + let progress = Progress::new(&multi, dependencies.len() as u64); + progress.start_all(); + let new_locks = install_dependencies( + &dependencies, + &lockfile.entries, + &paths.dependencies, + config.recursive_deps, + progress.clone(), + ) + .await?; + progress.stop_all(); + multi.stop(); + let new_lockfile_content = generate_lockfile_contents(new_locks); + if !lockfile.raw.is_empty() && new_lockfile_content != lockfile.raw { + warning("Warning: the lock file is out of sync with the dependencies. Consider running `soldeer update` to re-generate the lockfile.")?; + } else if lockfile.raw.is_empty() { + fs::write(&paths.lock, new_lockfile_content).map_err(LockError::IOError)?; + } + edit_remappings(&RemappingsAction::Update, &config, paths)?; + success("Updated remappings")?; + } + Some(dependency) => { + let identifier = match (cmd.rev, cmd.branch, cmd.tag) { + (Some(rev), None, None) => Some(GitIdentifier::from_rev(&rev)), + (None, Some(branch), None) => Some(GitIdentifier::from_branch(&branch)), + (None, None, Some(tag)) => Some(GitIdentifier::from_tag(&tag)), + (None, None, None) => None, + _ => unreachable!("clap should prevent this"), + }; + let mut dep = Dependency::from_name_version(&dependency, cmd.remote_url, identifier)?; + if dependencies + .iter() + .any(|d| d.name() == dep.name() && d.version_req() == dep.version_req()) + { + outro(format!("{dep} is already installed"))?; + return Ok(()); + } + let multi = multi_progress(format!("Installing {dep}")); + let progress = Progress::new(&multi, 1); + progress.start_all(); + let lock = install_dependency( + &dep, + None, + &paths.dependencies, + None, + config.recursive_deps, + progress.clone(), + ) + .await?; + progress.stop_all(); + multi.stop(); + // for git deps, we need to add the commit hash before adding them to the + // config, unless a branch/tag was specified + if let Some(git_dep) = dep.as_git_mut() { + if git_dep.identifier.is_none() { + git_dep.identifier = Some(GitIdentifier::from_rev( + &lock.as_git().expect("lock entry should be of type git").rev, + )); + } + } + add_to_config(&dep, &paths.config)?; + success("Dependency added to config")?; + add_to_lockfile(lock, &paths.lock)?; + success("Dependency added to lockfile")?; + edit_remappings(&RemappingsAction::Add(dep), &config, paths)?; + success("Dependency added to remappings")?; + } + } + Ok(()) +} diff --git a/crates/commands/src/commands/login.rs b/crates/commands/src/commands/login.rs new file mode 100644 index 0000000..3d6cdd0 --- /dev/null +++ b/crates/commands/src/commands/login.rs @@ -0,0 +1,34 @@ +use clap::Parser; +use cliclack::{input, log::remark}; +use email_address_parser::{EmailAddress, ParsingOptions}; +use soldeer_core::{ + auth::{execute_login, Credentials}, + Result, +}; + +/// Log into the central repository to push packages +#[derive(Debug, Clone, Default, Parser)] +#[clap(after_help = "For more information, read the README.md")] +pub struct Login {} + +pub(crate) async fn login_command() -> Result<()> { + remark("If you do not have an account, please visit soldeer.xyz to create one.")?; + + let email: String = input("Email address") + .validate(|input: &String| { + if input.is_empty() { + Err("Email is required") + } else { + match EmailAddress::parse(input, Some(ParsingOptions::default())) { + None => Err("Invalid email address"), + Some(_) => Ok(()), + } + } + }) + .interact()?; + + let password = cliclack::password("Password").mask('▪').interact()?; + + execute_login(&Credentials { email, password }).await?; + Ok(()) +} diff --git a/crates/commands/src/commands/mod.rs b/crates/commands/src/commands/mod.rs new file mode 100644 index 0000000..7a14ab8 --- /dev/null +++ b/crates/commands/src/commands/mod.rs @@ -0,0 +1,38 @@ +pub use clap::{Parser, Subcommand}; + +pub mod init; +pub mod install; +pub mod login; +pub mod push; +pub mod uninstall; +pub mod update; + +/// A minimal Solidity dependency manager +#[derive(Parser, Debug)] +#[clap(name = "soldeer", author = "m4rio.eth", version)] +pub struct Args { + #[clap(subcommand)] + pub command: Subcommands, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum Subcommands { + Init(init::Init), + Install(install::Install), + Update(update::Update), + Login(login::Login), + Push(push::Push), + Uninstall(uninstall::Uninstall), + Version(Version), +} + +fn validate_dependency(dep: &str) -> std::result::Result { + if dep.split('~').count() != 2 { + return Err("The dependency should be in the format ~".to_string()); + } + Ok(dep.to_string()) +} + +/// Display the version of Soldeer +#[derive(Debug, Clone, Default, Parser)] +pub struct Version {} diff --git a/crates/commands/src/commands/push.rs b/crates/commands/src/commands/push.rs new file mode 100644 index 0000000..68f864c --- /dev/null +++ b/crates/commands/src/commands/push.rs @@ -0,0 +1,86 @@ +use super::validate_dependency; +use clap::Parser; +use cliclack::log::{info, remark, warning}; +use soldeer_core::{ + errors::PublishError, + push::{filter_files_to_copy, push_version, validate_name}, + utils::check_dotfiles, + Result, +}; +use std::{env, path::PathBuf}; + +/// Push a dependency to the repository +#[derive(Debug, Clone, Parser)] +#[clap( + long_about = "Push a Dependency to the Repository + +Examples: +- Current directory: soldeer push mypkg~0.1.0 +- Custom directory: soldeer push mypkg~0.1.0 /path/to/dep +- Dry run: soldeer push mypkg~0.1.0 --dry-run + +To ignore certain files, create a `.soldeerignore` file in the root of the project and add the files you want to ignore. The `.soldeerignore` uses the same syntax as `.gitignore`.", + after_help = "For more information, read the README.md" +)] +pub struct Push { + /// The dependency name and version, separated by a tilde. + /// + /// This should always be used when you want to push a dependency to the central repository: ``. + #[arg(value_parser = validate_dependency, value_name = "DEPENDENCY>~, + + /// If set, does not publish the package but generates a zip file that can be inspected. + #[arg(short, long, default_value_t = false)] + pub dry_run: bool, + + /// Use this if you want to skip the warnings that can be triggered when trying to push + /// dotfiles like .env. + #[arg(long, default_value_t = false)] + pub skip_warnings: bool, +} + +pub(crate) async fn push_command(cmd: Push) -> Result<()> { + let path = cmd.path.unwrap_or(env::current_dir()?); + + let files_to_copy: Vec = filter_files_to_copy(&path); + + // Check for sensitive files or directories + if !cmd.dry_run && + !cmd.skip_warnings && + check_dotfiles(&files_to_copy) && + !prompt_user_for_confirmation()? + { + return Err(PublishError::UserAborted.into()); + } + + if cmd.dry_run { + remark("Running in dry-run mode, a zip file will be created for inspection")?; + } + + if cmd.skip_warnings { + warning("Sensitive file warnings are being ignored as requested")?; + } + + let (dependency_name, dependency_version) = + cmd.dependency.split_once('~').expect("dependency string should have name and version"); + + validate_name(dependency_name)?; + + push_version(dependency_name, dependency_version, path, &files_to_copy, cmd.dry_run).await?; + Ok(()) +} + +// Function to prompt the user for confirmation +fn prompt_user_for_confirmation() -> Result { + remark("You are about to include some sensitive files in this version").ok(); + info("If you are not sure which files will be included, you can run the command with `--dry-run`and inspect the generated zip file.").ok(); + + cliclack::confirm("Do you want to continue?") + .interact() + .map_err(|e| PublishError::IOError { path: PathBuf::new(), source: e }.into()) +} diff --git a/crates/commands/src/commands/uninstall.rs b/crates/commands/src/commands/uninstall.rs new file mode 100644 index 0000000..8300a26 --- /dev/null +++ b/crates/commands/src/commands/uninstall.rs @@ -0,0 +1,38 @@ +use clap::Parser; +use cliclack::log::success; +use soldeer_core::{ + config::{delete_from_config, read_soldeer_config, Paths}, + download::delete_dependency_files_sync, + lock::remove_lock, + remappings::{edit_remappings, RemappingsAction}, + Result, SoldeerError, +}; + +/// Uninstall a dependency +#[derive(Debug, Clone, Parser)] +#[clap(after_help = "For more information, read the README.md")] +pub struct Uninstall { + /// The dependency name. Specifying a version is not necessary. + pub dependency: String, +} + +pub(crate) fn uninstall_command(paths: &Paths, cmd: &Uninstall) -> Result<()> { + let config = read_soldeer_config(&paths.config)?; + success("Done reading config")?; + + // delete from the config file and return the dependency + let dependency = delete_from_config(&cmd.dependency, &paths.config)?; + success("Dependency removed from config file")?; + + edit_remappings(&RemappingsAction::Remove(dependency.clone()), &config, paths)?; + success("Dependency removed from remappings")?; + + // deleting the files + delete_dependency_files_sync(&dependency, &paths.dependencies) + .map_err(|e| SoldeerError::DownloadError { dep: dependency.to_string(), source: e })?; + success("Dependency removed from disk")?; + + remove_lock(&dependency, &paths.lock)?; + success("Dependency removed from lockfile")?; + Ok(()) +} diff --git a/crates/commands/src/commands/update.rs b/crates/commands/src/commands/update.rs new file mode 100644 index 0000000..cfd393a --- /dev/null +++ b/crates/commands/src/commands/update.rs @@ -0,0 +1,65 @@ +use clap::Parser; +use cliclack::{log::success, multi_progress}; +use soldeer_core::{ + config::{read_config_deps, read_soldeer_config, Dependency, Paths}, + errors::LockError, + install::{ensure_dependencies_dir, Progress}, + lock::{generate_lockfile_contents, read_lockfile}, + remappings::{edit_remappings, RemappingsAction}, + update::update_dependencies, + Result, +}; +use std::fs; + +/// Update dependencies by reading the config file +#[derive(Debug, Clone, Default, Parser)] +#[clap(after_help = "For more information, read the README.md")] +pub struct Update { + /// If set, this command will delete the existing remappings and re-create them + #[arg(short = 'g', long, default_value_t = false)] + pub regenerate_remappings: bool, + + /// If set, this command will install the dependencies recursively (via submodules or via + /// soldeer) + #[arg(short = 'd', long, default_value_t = false)] + pub recursive_deps: bool, +} + +// TODO: add a parameter for a dependency name, where we would only update that particular +// dependency + +pub(crate) async fn update_command(paths: &Paths, cmd: Update) -> Result<()> { + let mut config = read_soldeer_config(&paths.config)?; + if cmd.regenerate_remappings { + config.remappings_regenerate = true; + } + if cmd.recursive_deps { + config.recursive_deps = true; + } + success("Done reading config")?; + ensure_dependencies_dir(&paths.dependencies)?; + let dependencies: Vec = read_config_deps(&paths.config)?; + let lockfile = read_lockfile(&paths.lock)?; + success("Done reading lockfile")?; + let multi = multi_progress("Updating dependencies"); + let progress = Progress::new(&multi, dependencies.len() as u64); + progress.start_all(); + let new_locks = update_dependencies( + &dependencies, + &lockfile.entries, + &paths.dependencies, + config.recursive_deps, + progress.clone(), + ) + .await?; + progress.stop_all(); + multi.stop(); + + let new_lockfile_content = generate_lockfile_contents(new_locks); + fs::write(&paths.lock, new_lockfile_content).map_err(LockError::IOError)?; + success("Updated lockfile")?; + + edit_remappings(&RemappingsAction::Update, &config, paths)?; + success("Updated remappings")?; + Ok(()) +} diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs new file mode 100644 index 0000000..082df6b --- /dev/null +++ b/crates/commands/src/lib.rs @@ -0,0 +1,62 @@ +//! High-level commands for the Soldeer CLI +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +pub use crate::commands::{Args, Subcommands}; +use cliclack::{intro, log::step, outro, outro_cancel}; +use soldeer_core::{config::Paths, Result}; +use std::env; + +pub mod commands; + +pub async fn run(command: Subcommands) -> Result<()> { + let paths = Paths::new()?; + match command { + Subcommands::Init(init) => { + intro("🦌 Soldeer Init 🦌")?; + step("Initialize Foundry project to use Soldeer")?; + commands::init::init_command(&paths, init).await.inspect_err(|_| { + outro_cancel("An error occurred during initialization").ok(); + })?; + outro("Done initializing!")?; + } + Subcommands::Install(cmd) => { + intro("🦌 Soldeer Install 🦌")?; + commands::install::install_command(&paths, cmd).await.inspect_err(|_| { + outro_cancel("An error occurred during install").ok(); + })?; + outro("Done installing!")?; + } + Subcommands::Update(cmd) => { + intro("🦌 Soldeer Update 🦌")?; + commands::update::update_command(&paths, cmd).await.inspect_err(|_| { + outro_cancel("An error occurred during the update").ok(); + })?; + outro("Done updating!")?; + } + Subcommands::Uninstall(cmd) => { + intro("🦌 Soldeer Uninstall 🦌")?; + commands::uninstall::uninstall_command(&paths, &cmd).inspect_err(|_| { + outro_cancel("An error occurred during uninstall").ok(); + })?; + outro("Done uninstalling!")?; + } + Subcommands::Login(_) => { + intro("🦌 Soldeer Login 🦌")?; + commands::login::login_command().await.inspect_err(|_| { + outro_cancel("An error occurred during login").ok(); + })?; + outro("Done logging in!")?; + } + Subcommands::Push(cmd) => { + intro("🦌 Soldeer Push 🦌")?; + commands::push::push_command(cmd).await.inspect_err(|_| { + outro_cancel("An error occurred during push").ok(); + })?; + outro("Done!")?; + } + Subcommands::Version(_) => { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + println!("soldeer {VERSION}"); + } + } + Ok(()) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..1649c6a --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "soldeer-core" +description = "Core functionality for Soldeer" +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + + +[lints] +workspace = true + +[dependencies] +bon = "2.0.0" +chrono = { version = "0.4.38", default-features = false, features = [ + "serde", + "std", +] } +cliclack = { workspace = true, optional = true } +const-hex = "1.12.0" +derive_more = { version = "1.0.0", features = ["from", "display", "from_str"] } +dunce = "1.0.5" +home = "0.5.9" +ignore = { version = "0.4.22", features = ["simd-accel"] } +path-slash.workspace = true +regex = "1.10.5" +reqwest = { workspace = true, features = ["json", "multipart", "stream"] } +sanitize-filename = "0.5.0" +semver = "1.0.23" +serde = { version = "1.0.204", features = ["derive"] } +serde_json = "1.0.120" +sha2 = "0.10.8" +thiserror.workspace = true +tokio.workspace = true +toml_edit = { version = "0.22.15", features = ["serde"] } +uuid = { version = "1.10.0", features = ["serde", "v4"] } +zip = { version = "2.1.3", default-features = false, features = ["deflate"] } +zip-extract = { version = "0.2.0", default-features = false, features = [ + "bzip2", + "deflate", +] } + +[dev-dependencies] +mockito.workspace = true +temp-env.workspace = true +testdir.workspace = true + +[features] +default = ["rustls"] +rustls = ["reqwest/rustls-tls"] +cli = ["cliclack"] +serde = [] diff --git a/crates/core/src/auth.rs b/crates/core/src/auth.rs new file mode 100644 index 0000000..d797a11 --- /dev/null +++ b/crates/core/src/auth.rs @@ -0,0 +1,147 @@ +use crate::{errors::AuthError, registry::api_url, utils::login_file_path}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::fs; + +#[cfg(feature = "cli")] +use cliclack::log::{info, success}; +#[cfg(feature = "cli")] +use path_slash::PathBufExt as _; +#[cfg(feature = "cli")] +use std::path::PathBuf; + +pub type Result = std::result::Result; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct Credentials { + pub email: String, + pub password: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct LoginResponse { + pub status: String, + pub token: String, +} + +pub fn get_token() -> Result { + let security_file = login_file_path()?; + let jwt = + fs::read_to_string(&security_file).map_err(|_| AuthError::MissingToken)?.trim().to_string(); + if jwt.is_empty() { + return Err(AuthError::MissingToken); + } + Ok(jwt) +} + +pub async fn execute_login(login: &Credentials) -> std::result::Result<(), AuthError> { + let security_file = login_file_path()?; + let url = api_url("auth/login", &[]); + let client = Client::new(); + let res = client.post(url).json(login).send().await?; + match res.status() { + s if s.is_success() => { + #[cfg(feature = "cli")] + success("Login successful")?; + + let response: LoginResponse = res.json().await?; + fs::write(&security_file, response.token)?; + + #[cfg(feature = "cli")] + info(format!( + "Login details saved in: {}", + PathBuf::from_slash_lossy(&security_file).to_string_lossy() /* normalize separators */ + ))?; + + Ok(()) + } + StatusCode::UNAUTHORIZED => Err(AuthError::InvalidCredentials), + _ => Err(AuthError::HttpError(res.error_for_status().unwrap_err())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use temp_env::async_with_vars; + use testdir::testdir; + + #[tokio::test] + async fn test_login_success() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/api/v1/auth/login") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"200","token":"jwt_token_example"}"#) + .create_async() + .await; + + let test_file = testdir!().join("test_save_jwt"); + let res = async_with_vars( + [ + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())), + ], + execute_login(&Credentials { + email: "test@test.com".to_string(), + password: "1234".to_string(), + }), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(fs::read_to_string(test_file).unwrap(), "jwt_token_example"); + } + + #[tokio::test] + async fn test_login_401() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/api/v1/auth/login") + .with_status(401) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"401"}"#) + .create_async() + .await; + + let test_file = testdir!().join("test_save_jwt"); + let res = async_with_vars( + [ + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())), + ], + execute_login(&Credentials { + email: "test@test.com".to_string(), + password: "1234".to_string(), + }), + ) + .await; + assert!(matches!(res, Err(AuthError::InvalidCredentials)), "{res:?}"); + } + + #[tokio::test] + async fn test_login_500() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/api/v1/auth/login") + .with_status(500) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"500"}"#) + .create_async() + .await; + + let test_file = testdir!().join("test_save_jwt"); + let res = async_with_vars( + [ + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(test_file.to_string_lossy().to_string())), + ], + execute_login(&Credentials { + email: "test@test.com".to_string(), + password: "1234".to_string(), + }), + ) + .await; + assert!(matches!(res, Err(AuthError::HttpError(_))), "{res:?}"); + } +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..6283149 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,1223 @@ +use crate::{ + download::{find_install_path, find_install_path_sync}, + errors::ConfigError, + remappings::RemappingsLocation, + utils::{get_url_type, UrlType}, +}; +use derive_more::derive::{Display, From, FromStr}; +use serde::Deserialize; +use std::{ + env, fmt, fs, + path::{Path, PathBuf}, +}; +use toml_edit::{value, DocumentMut, InlineTable, Item, Table}; + +#[cfg(feature = "cli")] +use cliclack::{log::warning, select}; + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +// make sure the struct is not constructible from the outside without using the new/from methods +#[non_exhaustive] +pub struct Paths { + pub root: PathBuf, + pub config: PathBuf, + pub dependencies: PathBuf, + pub lock: PathBuf, + pub remappings: PathBuf, +} + +impl Paths { + /// Instantiate all the paths needed for Soldeer + /// + /// The root path defaults to the current directory but can be overridden with the + /// `SOLDEER_PROJECT_ROOT` environment variable. + /// + /// The paths are canonicalized. + pub fn new() -> Result { + let root = dunce::canonicalize(Self::get_root_path())?; + let config = Self::get_config_path(&root)?; + let dependencies = root.join("dependencies"); + let lock = root.join("soldeer.lock"); + let remappings = root.join("remappings.txt"); + + Ok(Self { root, config, dependencies, lock, remappings }) + } + + /// Generate the paths object from a known root directory + /// + /// The `SOLDEER_PROJECT_ROOT` environment variable is ignored. + /// + /// The paths are canonicalized. + pub fn from_root(root: impl AsRef) -> Result { + let root = dunce::canonicalize(root.as_ref())?; + let config = Self::get_config_path(&root)?; + let dependencies = root.join("dependencies"); + let lock = root.join("soldeer.lock"); + let remappings = root.join("remappings.txt"); + + Ok(Self { root, config, dependencies, lock, remappings }) + } + + /// TODO: find the project's root directory and use that as the root instead of the current dir + fn get_root_path() -> PathBuf { + env::var("SOLDEER_PROJECT_ROOT") + .map(|p| { + if p.is_empty() { + env::current_dir().expect("could not get current dir") + } else { + PathBuf::from(p) + } + }) + .unwrap_or(env::current_dir().expect("could not get current dir")) + } + + /// Get the path to the config file or prompt the user to create one + fn get_config_path(root: impl AsRef) -> Result { + let foundry_path = root.as_ref().join("foundry.toml"); + if let Ok(contents) = fs::read_to_string(&foundry_path) { + let doc: DocumentMut = contents.parse::()?; + if doc.contains_table("dependencies") { + return Ok(foundry_path); + } + } + + let soldeer_path = root.as_ref().join("soldeer.toml"); + if soldeer_path.exists() { + return Ok(soldeer_path); + } + + #[cfg(feature = "cli")] + warning("No soldeer config found")?; + #[cfg(feature = "cli")] + let config_option: ConfigLocation = select("Select how you want to configure Soldeer") + .initial_value("foundry") + .item("foundry", "Using foundry.toml", "recommended") + .item("soldeer", "Using soldeer.toml", "for non-foundry projects") + .interact()? + .parse() + .map_err(|_| ConfigError::InvalidPromptOption)?; + + #[cfg(not(feature = "cli"))] + let config_option = ConfigLocation::Foundry; + + create_example_config(config_option, &foundry_path, &soldeer_path) + } +} + +/// For clap +fn default_true() -> bool { + true +} + +/// The Soldeer config options +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct SoldeerConfig { + #[serde(default = "default_true")] + pub remappings_generate: bool, + + #[serde(default)] + pub remappings_regenerate: bool, + + #[serde(default = "default_true")] + pub remappings_version: bool, + + #[serde(default)] + pub remappings_prefix: String, + + #[serde(default)] + pub remappings_location: RemappingsLocation, + + #[serde(default)] + pub recursive_deps: bool, +} + +impl Default for SoldeerConfig { + fn default() -> Self { + Self { + remappings_generate: true, + remappings_regenerate: false, + remappings_version: true, + remappings_prefix: String::new(), + remappings_location: RemappingsLocation::default(), + recursive_deps: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub enum GitIdentifier { + Rev(String), + Branch(String), + Tag(String), +} + +impl GitIdentifier { + pub fn from_rev(rev: impl Into) -> Self { + let rev: String = rev.into(); + Self::Rev(rev) + } + + pub fn from_branch(branch: impl Into) -> Self { + let branch: String = branch.into(); + Self::Branch(branch) + } + + pub fn from_tag(tag: impl Into) -> Self { + let tag: String = tag.into(); + Self::Tag(tag) + } +} + +#[bon::builder(on(String, into))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub struct GitDependency { + pub name: String, + #[cfg_attr(feature = "serde", serde(rename = "version"))] + pub version_req: String, + pub git: String, + pub identifier: Option, +} + +impl fmt::Display for GitDependency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}~{}", self.name, self.version_req) + } +} + +#[bon::builder(on(String, into))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub struct HttpDependency { + pub name: String, + #[cfg_attr(feature = "serde", serde(rename = "version"))] + pub version_req: String, + pub url: Option, +} + +impl fmt::Display for HttpDependency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}~{}", self.name, self.version_req) + } +} + +// Dependency object used to store a dependency data +#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, From)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub enum Dependency { + #[from(HttpDependency)] + Http(HttpDependency), + + #[from(GitDependency)] + Git(GitDependency), +} + +impl Dependency { + pub fn from_name_version( + name_version: &str, + custom_url: Option>, + identifier: Option, + ) -> Result { + let (dependency_name, dependency_version_req) = name_version + .split_once('~') + .expect("dependency string should have name and version requirement"); + if dependency_version_req.is_empty() { + return Err(ConfigError::EmptyVersion(dependency_name.to_string())); + } + Ok(match custom_url { + Some(url) => { + let url: String = url.into(); + // in this case (custom url or git dependency), the version requirement string is + // going to be used as part of the folder name inside the + // dependencies folder. As such, it's not allowed to contain the "=" + // character, because that would break the remappings. + if dependency_version_req.contains('=') { + return Err(ConfigError::InvalidVersionReq(dependency_name.to_string())); + } + match get_url_type(&url)? { + UrlType::Git => GitDependency { + name: dependency_name.to_string(), + version_req: dependency_version_req.to_string(), + git: url, + identifier, + } + .into(), + UrlType::Http => HttpDependency { + name: dependency_name.to_string(), + version_req: dependency_version_req.to_string(), + url: Some(url), + } + .into(), + } + } + None => HttpDependency { + name: dependency_name.to_string(), + version_req: dependency_version_req.to_string(), + url: None, + } + .into(), + }) + } + + pub fn name(&self) -> &str { + match self { + Self::Http(dep) => &dep.name, + Self::Git(dep) => &dep.name, + } + } + + pub fn version_req(&self) -> &str { + match self { + Self::Http(dep) => &dep.version_req, + Self::Git(dep) => &dep.version_req, + } + } + + pub fn url(&self) -> Option<&String> { + match self { + Self::Http(dep) => dep.url.as_ref(), + Self::Git(dep) => Some(&dep.git), + } + } + + pub fn install_path_sync(&self, deps: impl AsRef) -> Option { + find_install_path_sync(self, deps) + } + + pub async fn install_path(&self, deps: impl AsRef) -> Option { + find_install_path(self, deps).await + } + + pub fn to_toml_value(&self) -> (String, Item) { + match self { + Self::Http(dep) => ( + dep.name.clone(), + match &dep.url { + Some(url) => { + let mut table = InlineTable::new(); + table.insert( + "version", + value(&dep.version_req) + .into_value() + .expect("version should be a valid toml value"), + ); + table.insert( + "url", + value(url).into_value().expect("url should be a valid toml value"), + ); + value(table) + } + None => value(&dep.version_req), + }, + ), + Self::Git(dep) => { + let mut table = InlineTable::new(); + table.insert( + "version", + value(&dep.version_req) + .into_value() + .expect("version should be a valid toml value"), + ); + table.insert( + "git", + value(&dep.git).into_value().expect("git URL should be a valid toml value"), + ); + match &dep.identifier { + Some(GitIdentifier::Rev(rev)) => { + table.insert( + "rev", + value(rev).into_value().expect("rev should be a valid toml value"), + ); + } + Some(GitIdentifier::Branch(branch)) => { + table.insert( + "branch", + value(branch) + .into_value() + .expect("branch should be a valid toml value"), + ); + } + Some(GitIdentifier::Tag(tag)) => { + table.insert( + "tag", + value(tag).into_value().expect("tag should be a valid toml value"), + ); + } + None => {} + } + (dep.name.clone(), value(table)) + } + } + } + + pub fn is_http(&self) -> bool { + matches!(self, Self::Http(_)) + } + + pub fn as_http(&self) -> Option<&HttpDependency> { + if let Self::Http(v) = self { + Some(v) + } else { + None + } + } + + pub fn as_http_mut(&mut self) -> Option<&mut HttpDependency> { + if let Self::Http(v) = self { + Some(v) + } else { + None + } + } + + pub fn is_git(&self) -> bool { + matches!(self, Self::Git(_)) + } + + pub fn as_git(&self) -> Option<&GitDependency> { + if let Self::Git(v) = self { + Some(v) + } else { + None + } + } + + pub fn as_git_mut(&mut self) -> Option<&mut GitDependency> { + if let Self::Git(v) = self { + Some(v) + } else { + None + } + } +} + +impl From<&HttpDependency> for Dependency { + fn from(dep: &HttpDependency) -> Self { + Self::Http(dep.clone()) + } +} + +impl From<&GitDependency> for Dependency { + fn from(dep: &GitDependency) -> Self { + Self::Git(dep.clone()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromStr)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub enum ConfigLocation { + Foundry, + Soldeer, +} + +/// Read the list of dependencies from the config file. +/// +/// If no config file path is provided, then the path is inferred automatically. +pub fn read_config_deps(path: impl AsRef) -> Result> { + let contents = fs::read_to_string(path)?; + let doc: DocumentMut = contents.parse::()?; + let Some(Some(data)) = doc.get("dependencies").map(|v| v.as_table()) else { + return Err(ConfigError::MissingDependencies); + }; + + let mut dependencies: Vec = Vec::new(); + for (name, v) in data { + dependencies.push(parse_dependency(name, v)?); + } + + Ok(dependencies) +} + +pub fn read_soldeer_config(path: impl AsRef) -> Result { + #[derive(Deserialize)] + struct SoldeerConfigParsed { + #[serde(default)] + soldeer: SoldeerConfig, + } + + let contents = fs::read_to_string(path)?; + + let config: SoldeerConfigParsed = toml_edit::de::from_str(&contents)?; + + Ok(config.soldeer) +} + +pub fn add_to_config(dependency: &Dependency, config_path: impl AsRef) -> Result<()> { + let contents = fs::read_to_string(&config_path)?; + let mut doc: DocumentMut = contents.parse::()?; + + // in case we don't have the dependencies section defined in the config file, we add it + if !doc.contains_table("dependencies") { + doc.insert("dependencies", Item::Table(Table::default())); + } + + let (name, value) = dependency.to_toml_value(); + doc["dependencies"] + .as_table_mut() + .expect("dependencies should be a table") + .insert(&name, value); + + fs::write(config_path, doc.to_string())?; + + Ok(()) +} + +pub fn delete_from_config(dependency_name: &str, path: impl AsRef) -> Result { + let contents = fs::read_to_string(&path)?; + let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); + + let Some(item_removed) = doc["dependencies"].as_table_mut().unwrap().remove(dependency_name) + else { + return Err(ConfigError::MissingDependency(dependency_name.to_string())); + }; + + let dependency = parse_dependency(dependency_name, &item_removed)?; + + fs::write(path, doc.to_string())?; + Ok(dependency) +} + +fn parse_dependency(name: impl Into, value: &Item) -> Result { + let name: String = name.into(); + if let Some(version_req) = value.as_str() { + if version_req.is_empty() { + return Err(ConfigError::EmptyVersion(name)); + } + if version_req.contains('=') { + return Err(ConfigError::InvalidVersionReq(name)); + } + // this function does not retrieve the url + return Ok(HttpDependency { name, version_req: version_req.to_string(), url: None }.into()); + } + + // we should have a table or inline table + let table = { + match value.as_inline_table() { + Some(table) => table, + None => match value.as_table() { + // we normalize to inline table + Some(table) => &table.clone().into_inline_table(), + None => { + return Err(ConfigError::InvalidDependency(name)); + } + }, + } + }; + + // version is needed in both cases + let version_req = match table.get("version").map(|v| v.as_str()) { + Some(None) => { + return Err(ConfigError::InvalidField { field: "version".to_string(), dep: name }); + } + None => { + return Err(ConfigError::MissingField { field: "version".to_string(), dep: name }); + } + Some(Some(version_req)) => version_req.to_string(), + }; + if version_req.is_empty() { + return Err(ConfigError::EmptyVersion(name)); + } + if version_req.contains('=') { + return Err(ConfigError::InvalidVersionReq(name)); + } + + // check if it's a git dependency + match table.get("git").map(|v| v.as_str()) { + Some(None) => { + return Err(ConfigError::InvalidField { field: "git".to_string(), dep: name }); + } + Some(Some(git)) => { + // rev/branch/tag fields are optional but need to be a string if present + let rev = match table.get("rev").map(|v| v.as_str()) { + Some(Some(rev)) => Some(rev.to_string()), + Some(None) => { + return Err(ConfigError::InvalidField { field: "rev".to_string(), dep: name }); + } + None => None, + }; + let branch = match table.get("branch").map(|v| v.as_str()) { + Some(Some(tag)) => Some(tag.to_string()), + Some(None) => { + return Err(ConfigError::InvalidField { + field: "branch".to_string(), + dep: name, + }); + } + None => None, + }; + let tag = match table.get("tag").map(|v| v.as_str()) { + Some(Some(tag)) => Some(tag.to_string()), + Some(None) => { + return Err(ConfigError::InvalidField { field: "tag".to_string(), dep: name }); + } + None => None, + }; + let identifier = match (rev, branch, tag) { + (Some(rev), None, None) => Some(GitIdentifier::from_rev(rev)), + (None, Some(branch), None) => Some(GitIdentifier::from_branch(branch)), + (None, None, Some(tag)) => Some(GitIdentifier::from_tag(tag)), + (None, None, None) => None, + _ => { + return Err(ConfigError::GitIdentifierConflict(name)); + } + }; + return Ok(Dependency::Git(GitDependency { + name, + git: git.to_string(), + version_req, + identifier, + })); + } + None => {} + } + + // we should have a HTTP dependency + match table.get("url").map(|v| v.as_str()) { + Some(None) => Err(ConfigError::InvalidField { field: "url".to_string(), dep: name }), + None => Ok(HttpDependency { name, version_req, url: None }.into()), + Some(Some(url)) => { + Ok(HttpDependency { name, version_req, url: Some(url.to_string()) }.into()) + } + } +} + +fn create_example_config( + location: ConfigLocation, + foundry_path: impl AsRef, + soldeer_path: impl AsRef, +) -> Result { + match location { + ConfigLocation::Foundry => { + let foundry_path = foundry_path.as_ref(); + if foundry_path.exists() { + return Ok(foundry_path.to_path_buf()); + } + let contents = r#"[profile.default] +src = "src" +out = "out" +libs = ["dependencies"] + +[dependencies] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +"#; + + fs::write(foundry_path, contents)?; + Ok(foundry_path.to_path_buf()) + } + ConfigLocation::Soldeer => { + let soldeer_path = soldeer_path.as_ref(); + if soldeer_path.exists() { + return Ok(soldeer_path.to_path_buf()); + } + + fs::write(soldeer_path, "[dependencies]\n")?; + Ok(soldeer_path.to_path_buf()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::ConfigError; + use path_slash::PathBufExt; + use std::{fs, path::PathBuf}; + use temp_env::with_var; + use testdir::testdir; + + fn write_to_config(content: &str, filename: &str) -> PathBuf { + let path = testdir!().join(filename); + fs::write(&path, content).unwrap(); + path + } + + #[test] + fn test_paths_config_soldeer() { + let config_path = write_to_config("[dependencies]\n", "soldeer.toml"); + with_var( + "SOLDEER_PROJECT_ROOT", + Some(config_path.parent().unwrap().to_string_lossy().to_string()), + || { + let res = Paths::new(); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().config.to_slash_lossy(), config_path.to_slash_lossy()); + }, + ); + } + + #[test] + fn test_paths_config_foundry() { + let config_contents = r#"[profile.default] +libs = ["dependencies"] + +[dependencies] +"#; + let config_path = write_to_config(config_contents, "foundry.toml"); + with_var( + "SOLDEER_PROJECT_ROOT", + Some(config_path.parent().unwrap().to_string_lossy().to_string()), + || { + let res = Paths::new(); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().config, config_path); + }, + ); + } + + #[test] + fn test_paths_from_root() { + let config_path = write_to_config("[dependencies]\n", "soldeer.toml"); + let root = config_path.parent().unwrap(); + let res = Paths::from_root(root); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().root, root); + } + + #[test] + fn test_from_name_version_no_url() { + let res = Dependency::from_name_version("dependency~1.0.0", None::<&str>, None); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + HttpDependency::builder().name("dependency").version_req("1.0.0").build().into() + ); + } + + #[test] + fn test_from_name_version_with_http_url() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("https://github.com/user/repo/archive/123.zip"), + None, + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + HttpDependency::builder() + .name("dependency") + .version_req("1.0.0") + .url("https://github.com/user/repo/archive/123.zip") + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_with_git_url() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("https://github.com/user/repo.git"), + None, + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("https://github.com/user/repo.git") + .build() + .into() + ); + + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("https://test:test@gitlab.com/user/repo.git"), + None, + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("https://test:test@gitlab.com/user/repo.git") + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_with_git_url_rev() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("https://github.com/user/repo.git"), + Some(GitIdentifier::from_rev("123456")), + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("https://github.com/user/repo.git") + .identifier(GitIdentifier::from_rev("123456")) + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_with_git_url_branch() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("https://github.com/user/repo.git"), + Some(GitIdentifier::from_branch("dev")), + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("https://github.com/user/repo.git") + .identifier(GitIdentifier::from_branch("dev")) + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_with_git_url_tag() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("https://github.com/user/repo.git"), + Some(GitIdentifier::from_tag("v1.0.0")), + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("https://github.com/user/repo.git") + .identifier(GitIdentifier::from_tag("v1.0.0")) + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_with_git_ssh() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("git@github.com:user/repo.git"), + None, + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("git@github.com:user/repo.git") + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_with_git_ssh_rev() { + let res = Dependency::from_name_version( + "dependency~1.0.0", + Some("git@github.com:user/repo.git"), + Some(GitIdentifier::from_rev("123456")), + ); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + GitDependency::builder() + .name("dependency") + .version_req("1.0.0") + .git("git@github.com:user/repo.git") + .identifier(GitIdentifier::from_rev("123456")) + .build() + .into() + ); + } + + #[test] + fn test_from_name_version_empty_version() { + let res = Dependency::from_name_version("dependency~", None::<&str>, None); + assert!(matches!(res, Err(ConfigError::EmptyVersion(_))), "{res:?}"); + } + + #[test] + fn test_from_name_version_invalid_version() { + // for http deps, having the "=" character in the version requirement is ok + let res = Dependency::from_name_version("dependency~asdf=", None::<&str>, None); + assert!(res.is_ok(), "{res:?}"); + + let res = + Dependency::from_name_version("dependency~asdf=", Some("https://example.com"), None); + assert!(matches!(res, Err(ConfigError::InvalidVersionReq(_))), "{res:?}"); + + let res = Dependency::from_name_version( + "dependency~asdf=", + Some("git@github.com:user/repo.git"), + None, + ); + assert!(matches!(res, Err(ConfigError::InvalidVersionReq(_))), "{res:?}"); + } + + #[test] + fn test_read_soldeer_config_default() { + let config_contents = r#"[profile.default] +libs = ["dependencies"] +"#; + let config_path = write_to_config(config_contents, "foundry.toml"); + let res = read_soldeer_config(config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), SoldeerConfig::default()); + } + + #[test] + fn test_read_soldeer_config() { + let config_contents = r#"[soldeer] +remappings_generate = false +remappings_regenerate = true +remappings_version = false +remappings_prefix = "@" +remappings_location = "config" +recursive_deps = true +"#; + let expected = SoldeerConfig { + remappings_generate: false, + remappings_regenerate: true, + remappings_version: false, + remappings_prefix: "@".to_string(), + remappings_location: RemappingsLocation::Config, + recursive_deps: true, + }; + + let config_path = write_to_config(config_contents, "soldeer.toml"); + let res = read_soldeer_config(config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), expected); + + let config_path = write_to_config(config_contents, "foundry.toml"); + let res = read_soldeer_config(config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), expected); + } + + #[test] + fn test_read_foundry_config_deps() { + let config_contents = r#"[profile.default] +libs = ["dependencies"] + +[dependencies] +"lib1" = "1.0.0" +"lib2" = { version = "2.0.0" } +"lib3" = { version = "3.0.0", url = "https://example.com" } +"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" } +"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" } +"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" } +"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" } +"#; + let config_path = write_to_config(config_contents, "foundry.toml"); + let res = read_config_deps(config_path); + assert!(res.is_ok(), "{res:?}"); + let result = res.unwrap(); + + assert_eq!( + result[0], + HttpDependency::builder().name("lib1").version_req("1.0.0").build().into() + ); + assert_eq!( + result[1], + HttpDependency::builder().name("lib2").version_req("2.0.0").build().into() + ); + assert_eq!( + result[2], + HttpDependency::builder() + .name("lib3") + .version_req("3.0.0") + .url("https://example.com") + .build() + .into() + ); + assert_eq!( + result[3], + GitDependency::builder() + .name("lib4") + .version_req("4.0.0") + .git("https://example.com/repo.git") + .build() + .into() + ); + assert_eq!( + result[4], + GitDependency::builder() + .name("lib5") + .version_req("5.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_rev("123456")) + .build() + .into() + ); + assert_eq!( + result[5], + GitDependency::builder() + .name("lib6") + .version_req("6.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_branch("dev")) + .build() + .into() + ); + assert_eq!( + result[6], + GitDependency::builder() + .name("lib7") + .version_req("7.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_tag("v7.0.0")) + .build() + .into() + ); + } + + #[test] + fn test_read_soldeer_config_deps() { + let config_contents = r#"[dependencies] +"lib1" = "1.0.0" +"lib2" = { version = "2.0.0" } +"lib3" = { version = "3.0.0", url = "https://example.com" } +"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" } +"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" } +"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" } +"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" } +"#; + let config_path = write_to_config(config_contents, "soldeer.toml"); + let res = read_config_deps(config_path); + assert!(res.is_ok(), "{res:?}"); + let result = res.unwrap(); + + assert_eq!( + result[0], + HttpDependency::builder().name("lib1").version_req("1.0.0").build().into() + ); + assert_eq!( + result[1], + HttpDependency::builder().name("lib2").version_req("2.0.0").build().into() + ); + assert_eq!( + result[2], + HttpDependency::builder() + .name("lib3") + .version_req("3.0.0") + .url("https://example.com") + .build() + .into() + ); + assert_eq!( + result[3], + GitDependency::builder() + .name("lib4") + .version_req("4.0.0") + .git("https://example.com/repo.git") + .build() + .into() + ); + assert_eq!( + result[4], + GitDependency::builder() + .name("lib5") + .version_req("5.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_rev("123456")) + .build() + .into() + ); + assert_eq!( + result[5], + GitDependency::builder() + .name("lib6") + .version_req("6.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_branch("dev")) + .build() + .into() + ); + assert_eq!( + result[6], + GitDependency::builder() + .name("lib7") + .version_req("7.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_tag("v7.0.0")) + .build() + .into() + ); + } + + #[test] + fn test_read_soldeer_config_deps_bad_version() { + for dep in [ + r#""lib1" = """#, + r#""lib1" = { version = "" }"#, + r#""lib1" = { version = "", url = "https://example.com" }"#, + r#""lib1" = { version = "", git = "https://example.com/repo.git" }"#, + r#""lib1" = { version = "", git = "https://example.com/repo.git", rev = "123456" }"#, + ] { + let config_contents = format!("[dependencies]\n{dep}"); + let config_path = write_to_config(&config_contents, "soldeer.toml"); + let res = read_config_deps(config_path); + assert!(matches!(res, Err(ConfigError::EmptyVersion(_))), "{res:?}"); + } + + for dep in [ + r#""lib1" = "asdf=""#, + r#""lib1" = { version = "asdf=" }"#, + r#""lib1" = { version = "asdf=", url = "https://example.com" }"#, + r#""lib1" = { version = "asdf=", git = "https://example.com/repo.git" }"#, + r#""lib1" = { version = "asdf=", git = "https://example.com/repo.git", rev = "123456" }"#, + ] { + let config_contents = format!("[dependencies]\n{dep}"); + let config_path = write_to_config(&config_contents, "soldeer.toml"); + let res = read_config_deps(config_path); + assert!(matches!(res, Err(ConfigError::InvalidVersionReq(_))), "{res:?}"); + } + } + + #[test] + fn test_read_soldeer_config_deps_bad_git() { + for dep in [ + r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", rev = "123456", branch = "dev" }"#, + r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", rev = "123456", tag = "v1.0.0" }"#, + r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", branch = "dev", tag = "v1.0.0" }"#, + r#""lib1" = { version = "1.0.0", git = "https://example.com/repo.git", rev = "123456", branch = "dev", tag = "v1.0.0" }"#, + ] { + let config_contents = format!("[dependencies]\n{dep}"); + let config_path = write_to_config(&config_contents, "soldeer.toml"); + let res = read_config_deps(config_path); + assert!(matches!(res, Err(ConfigError::GitIdentifierConflict(_))), "{res:?}"); + } + } + + #[test] + fn test_add_to_config() { + let config_path = write_to_config("[dependencies]\n", "soldeer.toml"); + + let deps: &[Dependency] = &[ + HttpDependency::builder().name("lib1").version_req("1.0.0").build().into(), + HttpDependency::builder() + .name("lib2") + .version_req("1.0.0") + .url("https://test.com/test.zip") + .build() + .into(), + GitDependency::builder() + .name("lib3") + .version_req("1.0.0") + .git("https://example.com/repo.git") + .build() + .into(), + GitDependency::builder() + .name("lib4") + .version_req("1.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_rev("123456")) + .build() + .into(), + GitDependency::builder() + .name("lib5") + .version_req("1.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_branch("dev")) + .build() + .into(), + GitDependency::builder() + .name("lib6") + .version_req("1.0.0") + .git("https://example.com/repo.git") + .identifier(GitIdentifier::from_tag("v1.0.0")) + .build() + .into(), + ]; + for dep in deps { + let res = add_to_config(dep, &config_path); + assert!(res.is_ok(), "{dep}: {res:?}"); + } + + let parsed = read_config_deps(&config_path).unwrap(); + for (dep, parsed) in deps.iter().zip(parsed.iter()) { + assert_eq!(dep, parsed); + } + } + + #[test] + fn test_add_to_config_no_section() { + let config_path = write_to_config("", "soldeer.toml"); + let dep = Dependency::from_name_version("lib1~1.0.0", None::<&str>, None).unwrap(); + let res = add_to_config(&dep, &config_path); + assert!(res.is_ok(), "{res:?}"); + let parsed = read_config_deps(&config_path).unwrap(); + assert_eq!(parsed[0], dep); + } + + #[test] + fn test_delete_from_config() { + let config_contents = r#"[dependencies] +"lib1" = "1.0.0" +"lib2" = { version = "2.0.0" } +"lib3" = { version = "3.0.0", url = "https://example.com" } +"lib4" = { version = "4.0.0", git = "https://example.com/repo.git" } +"lib5" = { version = "5.0.0", git = "https://example.com/repo.git", rev = "123456" } +"lib6" = { version = "6.0.0", git = "https://example.com/repo.git", branch = "dev" } +"lib7" = { version = "7.0.0", git = "https://example.com/repo.git", tag = "v7.0.0" } + "#; + let config_path = write_to_config(config_contents, "soldeer.toml"); + let res = delete_from_config("lib1", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib1"); + assert_eq!(read_config_deps(&config_path).unwrap().len(), 6); + + let res = delete_from_config("lib2", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib2"); + assert_eq!(read_config_deps(&config_path).unwrap().len(), 5); + + let res = delete_from_config("lib3", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib3"); + assert_eq!(read_config_deps(&config_path).unwrap().len(), 4); + + let res = delete_from_config("lib4", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib4"); + assert_eq!(read_config_deps(&config_path).unwrap().len(), 3); + + let res = delete_from_config("lib5", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib5"); + assert_eq!(read_config_deps(&config_path).unwrap().len(), 2); + + let res = delete_from_config("lib6", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib6"); + assert_eq!(read_config_deps(&config_path).unwrap().len(), 1); + + let res = delete_from_config("lib7", &config_path); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap().name(), "lib7"); + assert!(read_config_deps(&config_path).unwrap().is_empty()); + } + + #[test] + fn test_delete_from_config_missing() { + let config_contents = r#"[dependencies] +"lib1" = "1.0.0" + "#; + let config_path = write_to_config(config_contents, "soldeer.toml"); + let res = delete_from_config("libfoo", &config_path); + assert!(matches!(res, Err(ConfigError::MissingDependency(_))), "{res:?}"); + } +} diff --git a/crates/core/src/download.rs b/crates/core/src/download.rs new file mode 100644 index 0000000..8c263cf --- /dev/null +++ b/crates/core/src/download.rs @@ -0,0 +1,305 @@ +use crate::{ + config::{Dependency, GitIdentifier}, + errors::DownloadError, + registry::parse_version_req, + utils::{run_git_command, sanitize_filename}, +}; +use derive_more::{Display, From}; +use reqwest::IntoUrl; +use semver::Version; +use std::{ + borrow::Cow, + fs, + io::Cursor, + path::{Path, PathBuf}, + str, +}; +use tokio::io::AsyncWriteExt as _; + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, From, Display)] +#[from(Cow<'static, str>, String, &'static str)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct IntegrityChecksum(pub String); + +pub async fn download_file(url: impl IntoUrl, folder_path: impl AsRef) -> Result { + let resp = reqwest::get(url).await?; + let mut resp = resp.error_for_status()?; + + let path = folder_path.as_ref().to_path_buf(); + let mut zip_filename = path + .file_name() + .expect("folder path should have a folder name") + .to_string_lossy() + .to_string(); + zip_filename.push_str(".zip"); + let path = path.parent().expect("dep folder should have a parent").join(zip_filename); + let mut file = tokio::fs::File::create(&path) + .await + .map_err(|e| DownloadError::IOError { path: path.clone(), source: e })?; + while let Some(mut chunk) = resp.chunk().await? { + file.write_all_buf(&mut chunk) + .await + .map_err(|e| DownloadError::IOError { path: path.clone(), source: e })?; + } + file.flush().await.map_err(|e| DownloadError::IOError { path: path.clone(), source: e })?; + Ok(path) +} + +pub async fn unzip_file(path: impl AsRef, into: impl AsRef) -> Result<()> { + let path = path.as_ref().to_path_buf(); + let out_dir = into.as_ref(); + let zip_contents = tokio::fs::read(&path) + .await + .map_err(|e| DownloadError::IOError { path: path.clone(), source: e })?; + + zip_extract::extract(Cursor::new(zip_contents), out_dir, true)?; + + tokio::fs::remove_file(&path) + .await + .map_err(|e| DownloadError::IOError { path: path.clone(), source: e }) +} + +pub async fn clone_repo( + url: &str, + identifier: Option<&GitIdentifier>, + path: impl AsRef, +) -> Result { + let path = path.as_ref().to_path_buf(); + run_git_command( + &["clone", "--tags", "--filter=tree:0", url, path.to_string_lossy().as_ref()], + None, + ) + .await?; + if let Some(identifier) = identifier { + run_git_command(&["checkout", &identifier.to_string()], Some(&path)).await?; + } + let commit = + run_git_command(&["rev-parse", "--verify", "HEAD"], Some(&path)).await?.trim().to_string(); + Ok(commit) +} + +pub fn delete_dependency_files_sync(dependency: &Dependency, deps: impl AsRef) -> Result<()> { + let Some(path) = find_install_path_sync(dependency, deps) else { + return Err(DownloadError::DependencyNotFound(dependency.to_string())); + }; + fs::remove_dir_all(&path).map_err(|e| DownloadError::IOError { path, source: e })?; + Ok(()) +} + +pub fn find_install_path_sync(dependency: &Dependency, deps: impl AsRef) -> Option { + let Ok(read_dir) = fs::read_dir(deps.as_ref()) else { + return None; + }; + for entry in read_dir { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if install_path_matches(dependency, &path) { + return Some(path); + } + } + None +} + +pub async fn find_install_path(dependency: &Dependency, deps: impl AsRef) -> Option { + let Ok(mut read_dir) = tokio::fs::read_dir(deps.as_ref()).await else { + return None; + }; + while let Ok(Some(entry)) = read_dir.next_entry().await { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if install_path_matches(dependency, &path) { + return Some(path); + } + } + None +} + +pub async fn delete_dependency_files( + dependency: &Dependency, + deps: impl AsRef, +) -> Result<()> { + let Some(path) = find_install_path(dependency, deps).await else { + return Err(DownloadError::DependencyNotFound(dependency.to_string())); + }; + tokio::fs::remove_dir_all(&path) + .await + .map_err(|e| DownloadError::IOError { path, source: e })?; + Ok(()) +} + +fn install_path_matches(dependency: &Dependency, path: &Path) -> bool { + if !path.is_dir() { + return false; + } + let Some(dir_name) = path.file_name() else { + return false; + }; + let dir_name = dir_name.to_string_lossy(); + let dep_name = sanitize_filename(dependency.name()); + if !dir_name.starts_with(&format!("{dep_name}-")) { + return false; + } + if let Some(version_req) = parse_version_req(dependency.version_req()) { + if let Ok(version) = Version::parse( + dir_name.strip_prefix(&format!("{dep_name}-")).expect("prefix should be present"), + ) { + if version_req.matches(&version) { + return true; + } + } + } else { + // not semver compliant + if dir_name == format!("{dep_name}-{}", dependency.version_req()) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::HttpDependency, push::zip_file}; + use std::fs; + use testdir::testdir; + + #[tokio::test] + async fn test_download_file() { + let path = testdir!().join("my-dependency"); + fs::create_dir(&path).unwrap(); + let res = download_file( + "https://raw.githubusercontent.com/mario-eth/soldeer/main/README.md", + &path, + ) + .await; + assert!(res.is_ok(), "{res:?}"); + let zip_path = path.with_file_name("my-dependency.zip"); + assert!(zip_path.exists()); + } + + #[tokio::test] + async fn test_unzip_file() { + let dir = testdir!(); + // create dummy zip + let file_path = dir.join("file.txt"); + fs::write(&file_path, "foobar").unwrap(); + let zip_path = dir.join("my-dependency.zip"); + zip_file(&dir, &[file_path], &zip_path).unwrap(); + + let out_dir = dir.join("out"); + let res = unzip_file(&zip_path, &out_dir).await; + assert!(res.is_ok(), "{res:?}"); + let file_path = out_dir.join("file.txt"); + assert!(file_path.exists()); + assert!(!zip_path.exists()); + } + + #[tokio::test] + async fn test_clone_repo() { + let dir = testdir!(); + let res = clone_repo("https://github.com/beeb/test-repo.git", None, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(&res.unwrap(), "d5d72fa135d28b2e8307650b3ea79115183f2406"); + } + + #[tokio::test] + async fn test_clone_repo_rev() { + let dir = testdir!(); + let res = clone_repo( + "https://github.com/beeb/test-repo.git", + Some(&GitIdentifier::from_rev("d230f5c588c0ed00821a4eb3ef38e300e4a519dc")), + &dir, + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(&res.unwrap(), "d230f5c588c0ed00821a4eb3ef38e300e4a519dc"); + } + + #[tokio::test] + async fn test_clone_repo_branch() { + let dir = testdir!(); + let res = clone_repo( + "https://github.com/beeb/test-repo.git", + Some(&GitIdentifier::from_branch("dev")), + &dir, + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(&res.unwrap(), "8d903e557e8f1b6e62bde768aa456d4ddfca72c4"); + } + + #[tokio::test] + async fn test_clone_repo_tag() { + let dir = testdir!(); + let res = clone_repo( + "https://github.com/beeb/test-repo.git", + Some(&GitIdentifier::from_tag("v0.1.0")), + &dir, + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(&res.unwrap(), "78c2f6a1a54db26bab6c3f501854a1564eb3707f"); + } + + #[test] + fn test_install_path_matches() { + let dependency: Dependency = + HttpDependency::builder().name("lib1").version_req("^1.0.0").build().into(); + let dir = testdir!(); + let path = dir.join("lib1-1.1.1"); + fs::create_dir(&path).unwrap(); + assert!(install_path_matches(&dependency, &path)); + + let path = dir.join("lib1-2.0.0"); + fs::create_dir(&path).unwrap(); + assert!(!install_path_matches(&dependency, &path)); + + let path = dir.join("lib2-1.0.0"); + fs::create_dir(&path).unwrap(); + assert!(!install_path_matches(&dependency, &path)); + } + + #[test] + fn test_install_path_matches_nosemver() { + let dependency: Dependency = + HttpDependency::builder().name("lib1").version_req("foobar").build().into(); + let dir = testdir!(); + let path = dir.join("lib1-foobar"); + fs::create_dir(&path).unwrap(); + assert!(install_path_matches(&dependency, &path)); + + let path = dir.join("lib1-somethingelse"); + fs::create_dir(&path).unwrap(); + assert!(!install_path_matches(&dependency, &path)); + } + + #[test] + fn test_find_install_path_sync() { + let dependency: Dependency = + HttpDependency::builder().name("lib1").version_req("^1.0.0").build().into(); + let dir = testdir!(); + let path = dir.join("lib1-1.1.1"); + fs::create_dir(&path).unwrap(); + let res = find_install_path_sync(&dependency, &dir); + assert!(res.is_some()); + assert_eq!(res.unwrap(), path); + } + + #[tokio::test] + async fn test_find_install_path() { + let dependency: Dependency = + HttpDependency::builder().name("lib1").version_req("^1.0.0").build().into(); + let dir = testdir!(); + let path = dir.join("lib1-1.2.5"); + fs::create_dir(&path).unwrap(); + let res = find_install_path(&dependency, &dir).await; + assert!(res.is_some()); + assert_eq!(res.unwrap(), path); + } +} diff --git a/src/errors.rs b/crates/core/src/errors.rs similarity index 52% rename from src/errors.rs rename to crates/core/src/errors.rs index 8d906ae..d585ffa 100644 --- a/src/errors.rs +++ b/crates/core/src/errors.rs @@ -5,6 +5,7 @@ use std::{ use thiserror::Error; #[derive(Error, Debug)] +#[non_exhaustive] pub enum SoldeerError { #[error("error during login: {0}")] AuthError(#[from] AuthError), @@ -15,25 +16,35 @@ pub enum SoldeerError { #[error("error during downloading ({dep}): {source}")] DownloadError { dep: String, source: DownloadError }, - #[error("error during janitor operation: {0}")] - JanitorError(#[from] JanitorError), + #[error("error during install operation: {0}")] + InstallError(#[from] InstallError), #[error("error during lockfile operation: {0}")] LockError(#[from] LockError), #[error("error during publishing: {0}")] PublishError(#[from] PublishError), + + #[error("error during remappings operation: {0}")] + RemappingsError(#[from] RemappingsError), + + #[error("error during registry operation: {0}")] + RegistryError(#[from] RegistryError), + + #[error("error during update operation: {0}")] + UpdateError(#[from] UpdateError), + + #[error("error during IO operation: {0}")] + IOError(#[from] io::Error), } #[derive(Error, Debug)] +#[non_exhaustive] pub enum AuthError { - #[error("login error: invalid email")] - InvalidEmail, - #[error("login error: invalid email or password")] InvalidCredentials, - #[error("missing token, you are not connected")] + #[error("missing token, run `soldeer login`")] MissingToken, #[error("error during IO operation for the security file: {0}")] @@ -44,6 +55,7 @@ pub enum AuthError { } #[derive(Error, Debug)] +#[non_exhaustive] pub enum ConfigError { #[error("config file is not valid: {0}")] Parsing(#[from] toml_edit::TomlError), @@ -60,9 +72,6 @@ pub enum ConfigError { #[error("error writing to config file: {0}")] FileWriteError(#[from] io::Error), - #[error("error writing to remappings file: {0}")] - RemappingsError(io::Error), - #[error("empty `version` field in {0}")] EmptyVersion(String), @@ -72,7 +81,7 @@ pub enum ConfigError { #[error("invalid `{field}` field in {dep}")] InvalidField { field: String, dep: String }, - #[error("only one of `git`, `branch` and `rev` can be specified for dependency {0}")] + #[error("only one of `git`, `branch` or `rev` can be specified for dependency {0}")] GitIdentifierConflict(String), #[error("dependency {0} is not valid")] @@ -83,9 +92,19 @@ pub enum ConfigError { #[error("error parsing config file: {0}")] DeserializeError(#[from] toml_edit::de::Error), + + #[error("error generating config file: {0}")] + SerializeError(#[from] toml_edit::ser::Error), + + #[error("error during config operation: {0}")] + DownloadError(#[from] DownloadError), + + #[error("the version requirement string for {0} cannot contain the equal symbol for git dependencies and http dependencies with a custom URL")] + InvalidVersionReq(String), } #[derive(Error, Debug)] +#[non_exhaustive] pub enum DownloadError { #[error("error downloading dependency: {0}")] HttpError(#[from] reqwest::Error), @@ -99,35 +118,55 @@ pub enum DownloadError { #[error("error during IO operation for {path:?}: {source}")] IOError { path: PathBuf, source: io::Error }, - #[error("Project {0} not found, please check the dependency name (project name) or create a new project on https://soldeer.xyz")] - ProjectNotFound(String), - - #[error("Could not get the dependency URL for {0}")] - URLNotFound(String), - - #[error("Could not get the last forge dependency")] - ForgeStdError, - #[error("error during async operation: {0}")] AsyncError(#[from] tokio::task::JoinError), - #[error("Could download the dependencies of this dependency {0}")] + #[error("could download the dependencies of this dependency {0}")] SubdependencyError(String), + + #[error("the provided URL is invalid: {0}")] + InvalidUrl(String), + + #[error("error during registry operation: {0}")] + RegistryError(#[from] RegistryError), + + #[error("dependency not found: {0}")] + DependencyNotFound(String), } #[derive(Error, Debug)] -pub enum JanitorError { - #[error("missing dependency {0}")] - MissingDependency(String), +#[non_exhaustive] +pub enum InstallError { + #[error("zip checksum for {path} does not match lock file: expected {expected}, got {actual}")] + ZipIntegrityError { path: PathBuf, expected: String, actual: String }, #[error("error during IO operation for {path:?}: {source}")] IOError { path: PathBuf, source: io::Error }, - #[error("error during lockfile operation: {0}")] - LockError(LockError), // TODO: derive from LockError + #[error("error during git command: {0}")] + GitError(String), + + #[error("error during dependency installation: {0}")] + DownloadError(#[from] DownloadError), + + #[error("error during dependency installation: {0}")] + ConfigError(#[from] ConfigError), + + #[error("error during async operation: {0}")] + AsyncError(#[from] tokio::task::JoinError), + + #[error("error during forge command: {0}")] + ForgeError(String), + + #[error("error during registry operation: {0}")] + RegistryError(#[from] RegistryError), + + #[error("error with lockfile: {0}")] + LockError(#[from] LockError), } #[derive(Error, Debug)] +#[non_exhaustive] pub enum LockError { #[error("soldeer.lock is missing")] Missing, @@ -140,9 +179,16 @@ pub enum LockError { #[error("error generating soldeer.lock contents: {0}")] SerializeError(#[from] toml_edit::ser::Error), + + #[error("lock entry does not match expected type")] + TypeMismatch, + + #[error("missing `{field}` field in lock entry for {dep}")] + MissingField { field: String, dep: String }, } #[derive(Error, Debug)] +#[non_exhaustive] pub enum PublishError { #[error("no files to publish")] NoFiles, @@ -159,8 +205,8 @@ pub enum PublishError { #[error("auth error: {0}")] AuthError(#[from] AuthError), - #[error("error during publishing: {0}")] - DownloadError(#[from] DownloadError), + #[error("registry error during publishing: {0}")] + DownloadError(#[from] RegistryError), #[error("Project not found. Make sure you send the right dependency name. The dependency name is the project name you created on https://soldeer.xyz")] ProjectNotFound, @@ -174,9 +220,60 @@ pub enum PublishError { #[error("http error during publishing: {0}")] HttpError(#[from] reqwest::Error), - #[error("invalid package name, only alphanumeric characters, `-` and `@` are allowed")] + #[error("invalid package name, only alphanumeric characters, `-` and `@` are allowed. Length must be between 3 and 100 characters")] InvalidName, + #[error("user cancelled operation")] + UserAborted, + #[error("unknown http error")] UnknownError, } + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum RegistryError { + #[error("error with registry request: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("could not get the dependency URL for {0}")] + URLNotFound(String), + + #[error("project {0} not found, please check the dependency name (project name) or create a new project on https://soldeer.xyz")] + ProjectNotFound(String), + + #[error("package {0} has no version")] + NoVersion(String), + + #[error("no matching version found for {dependency} with version requirement {version_req}")] + NoMatchingVersion { dependency: String, version_req: String }, +} + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum RemappingsError { + #[error("error writing to remappings file: {0}")] + FileWriteError(#[from] io::Error), + + #[error("error while interacting with the config file: {0}")] + ConfigError(#[from] ConfigError), + + #[error("dependency not found: {0}")] + DependencyNotFound(String), +} + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum UpdateError { + #[error("registry error: {0}")] + RegistryError(#[from] RegistryError), + + #[error("download error: {0}")] + DownloadError(#[from] DownloadError), + + #[error("error during install operation: {0}")] + InstallError(#[from] InstallError), + + #[error("error during async operation: {0}")] + AsyncError(#[from] tokio::task::JoinError), +} diff --git a/crates/core/src/install.rs b/crates/core/src/install.rs new file mode 100644 index 0000000..e14475d --- /dev/null +++ b/crates/core/src/install.rs @@ -0,0 +1,833 @@ +use crate::{ + config::{Dependency, GitIdentifier}, + download::{clone_repo, delete_dependency_files, download_file, unzip_file}, + errors::InstallError, + lock::{format_install_path, GitLockEntry, HttpLockEntry, LockEntry}, + registry::{get_dependency_url_remote, get_latest_supported_version}, + utils::{canonicalize, hash_file, hash_folder, run_forge_command, run_git_command}, +}; +use path_slash::PathBufExt as _; +use std::path::{Path, PathBuf}; +use tokio::{fs, task::JoinSet}; +use toml_edit::DocumentMut; + +#[cfg(feature = "cli")] +use cliclack::{progress_bar, MultiProgress, ProgressBar}; +#[cfg(feature = "cli")] +use std::fmt; + +pub const PROGRESS_TEMPLATE: &str = "[{elapsed_precise}] {bar:30.magenta} ({pos}/{len}) {msg}"; + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DependencyStatus { + Missing, + FailedIntegrity, + Installed, +} + +#[cfg(feature = "cli")] +#[derive(Clone)] +pub struct Progress { + pub multi: MultiProgress, + pub versions: ProgressBar, + pub downloads: ProgressBar, + pub unzip: ProgressBar, + pub subdependencies: ProgressBar, + pub integrity: ProgressBar, +} + +#[cfg(feature = "cli")] +impl Progress { + pub fn new(multi: &MultiProgress, deps: u64) -> Self { + let versions = multi.add(progress_bar(deps).with_template(PROGRESS_TEMPLATE)); + let downloads = multi.add(progress_bar(deps).with_template(PROGRESS_TEMPLATE)); + let unzip = multi.add(progress_bar(deps).with_template(PROGRESS_TEMPLATE)); + let subdependencies = multi.add(progress_bar(deps).with_template(PROGRESS_TEMPLATE)); + let integrity = multi.add(progress_bar(deps).with_template(PROGRESS_TEMPLATE)); + Self { multi: multi.clone(), versions, downloads, unzip, subdependencies, integrity } + } + + pub fn start_all(&self) { + self.versions.start("Retrieving versions..."); + self.downloads.start("Downloading dependencies..."); + self.unzip.start("Unzipping dependencies..."); + self.subdependencies.start("Installing subdependencies..."); + self.integrity.start("Checking integrity..."); + } + + pub fn increment_all(&self) { + self.versions.inc(1); + self.downloads.inc(1); + self.unzip.inc(1); + self.subdependencies.inc(1); + self.integrity.inc(1); + } + + pub fn stop_all(&self) { + self.versions.stop("Done retrieving versions"); + self.downloads.stop("Done downloading dependencies"); + self.unzip.stop("Done unzipping dependencies"); + self.subdependencies.stop("Done installing subdependencies"); + self.integrity.stop("Done checking integrity"); + } + + pub fn log(&self, msg: impl fmt::Display) { + self.multi.println(msg); + } +} + +#[bon::builder(on(String, into))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct HttpInstallInfo { + name: String, + version: String, + url: String, + checksum: Option, +} + +#[bon::builder(on(String, into))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct GitInstallInfo { + name: String, + version: String, + git: String, + identifier: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum InstallInfo { + Http(HttpInstallInfo), + Git(GitInstallInfo), +} + +impl From for InstallInfo { + fn from(value: HttpInstallInfo) -> Self { + Self::Http(value) + } +} + +impl From for InstallInfo { + fn from(value: GitInstallInfo) -> Self { + Self::Git(value) + } +} + +impl From for InstallInfo { + fn from(lock: LockEntry) -> Self { + match lock { + LockEntry::Http(lock) => HttpInstallInfo { + name: lock.name, + version: lock.version, + url: lock.url, + checksum: Some(lock.checksum), + } + .into(), + LockEntry::Git(lock) => GitInstallInfo { + name: lock.name, + version: lock.version, + git: lock.git, + identifier: Some(GitIdentifier::from_rev(lock.rev)), + } + .into(), + } + } +} + +pub async fn install_dependencies( + dependencies: &[Dependency], + locks: &[LockEntry], + deps: impl AsRef, + recursive_deps: bool, + #[cfg(feature = "cli")] progress: Progress, +) -> Result> { + let mut set = JoinSet::new(); + for dep in dependencies { + set.spawn({ + let d = dep.clone(); + #[cfg(feature = "cli")] + let p = progress.clone(); + + let lock = locks.iter().find(|l| l.name() == dep.name()).cloned(); + let deps = deps.as_ref().to_path_buf(); + async move { + install_dependency( + &d, + lock.as_ref(), + deps, + None, + recursive_deps, + #[cfg(feature = "cli")] + p, + ) + .await + } + }); + } + + let mut results = Vec::new(); + while let Some(res) = set.join_next().await { + results.push(res??); + } + Ok(results) +} + +/// Install a single dependency +/// +/// It's important that all file operations are done via the `tokio::fs` module because we are +/// highly concurrent here. +pub async fn install_dependency( + dependency: &Dependency, + lock: Option<&LockEntry>, + deps: impl AsRef, + force_version: Option, + recursive_deps: bool, + #[cfg(feature = "cli")] progress: Progress, +) -> Result { + if let Some(lock) = lock { + match check_dependency_integrity(lock, &deps).await? { + DependencyStatus::Installed => { + // no action needed, dependency is already installed and matches the lockfile + // entry + #[cfg(feature = "cli")] + progress.increment_all(); + + return Ok(lock.clone()); + } + DependencyStatus::FailedIntegrity => match dependency { + Dependency::Http(_) => { + // we know the folder exists because otherwise we would have gotten + // `Missing` + #[cfg(feature = "cli")] + progress.log(format!( + "Dependency {dependency} failed integrity check, reinstalling" + )); + + delete_dependency_files(dependency, &deps).await?; + // we won't need to retrieve the version number so we mark it as done + #[cfg(feature = "cli")] + progress.versions.inc(1); + } + Dependency::Git(_) => { + #[cfg(feature = "cli")] + progress.log(format!( + "Dependency {dependency} failed integrity check, resetting to commit {}", + lock.as_git().expect("lock entry should be of type git").rev + )); + + reset_git_dependency( + lock.as_git().expect("lock entry should be of type git"), + &deps, + ) + .await?; + // dependency should now be at the correct commit, we can exit + #[cfg(feature = "cli")] + progress.increment_all(); + + return Ok(lock.clone()); + } + }, + DependencyStatus::Missing => { + // make sure there is no existing directory for the dependency + if let Some(path) = dependency.install_path(&deps).await { + fs::remove_dir_all(&path) + .await + .map_err(|e| InstallError::IOError { path, source: e })?; + } + // we won't need to retrieve the version number so we mark it as done + #[cfg(feature = "cli")] + progress.versions.inc(1); + } + } + install_dependency_inner( + &lock.clone().into(), + lock.install_path(&deps), + recursive_deps, + #[cfg(feature = "cli")] + progress, + ) + .await + } else { + // no lockfile entry, install from config object + // make sure there is no existing directory for the dependency + if let Some(path) = dependency.install_path(&deps).await { + fs::remove_dir_all(&path) + .await + .map_err(|e| InstallError::IOError { path, source: e })?; + } + + let (url, version) = match dependency.url() { + // for git dependencies and http dependencies which have a custom url, we use the + // version requirement string as version, because in that case a version requirement has + // little sense (we can't automatically bump the version) + Some(url) => (url.clone(), dependency.version_req().to_string()), + None => { + let version = match force_version { + Some(v) => v, + None => get_latest_supported_version(dependency).await?, + }; + (get_dependency_url_remote(dependency, &version).await?, version) + } + }; + // indicate that we have retrieved the version number + #[cfg(feature = "cli")] + progress.versions.inc(1); + + let info = match &dependency { + Dependency::Http(dep) => { + HttpInstallInfo::builder().name(&dep.name).version(&version).url(url).build().into() + } + Dependency::Git(dep) => GitInstallInfo::builder() + .name(&dep.name) + .version(&version) + .git(url) + .maybe_identifier(dep.identifier.clone()) + .build() + .into(), + }; + install_dependency_inner( + &info, + format_install_path(dependency.name(), &version, &deps), + recursive_deps, + #[cfg(feature = "cli")] + progress, + ) + .await + } +} + +pub async fn check_dependency_integrity( + lock: &LockEntry, + deps: impl AsRef, +) -> Result { + match lock { + LockEntry::Http(lock) => check_http_dependency(lock, deps).await, + LockEntry::Git(lock) => check_git_dependency(lock, deps).await, + } +} + +pub fn ensure_dependencies_dir(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + if !path.exists() { + std::fs::create_dir(path) + .map_err(|e| InstallError::IOError { path: path.to_path_buf(), source: e })?; + } + Ok(()) +} + +async fn install_dependency_inner( + dep: &InstallInfo, + path: impl AsRef, + subdependencies: bool, + #[cfg(feature = "cli")] progress: Progress, +) -> Result { + match dep { + InstallInfo::Http(dep) => { + let zip_path = download_file(&dep.url, &path).await?; + #[cfg(feature = "cli")] + progress.downloads.inc(1); + + let zip_integrity = tokio::task::spawn_blocking({ + let zip_path = zip_path.clone(); + move || hash_file(zip_path) + }) + .await? + .map_err(|e| InstallError::IOError { path: zip_path.clone(), source: e })?; + if let Some(checksum) = &dep.checksum { + if checksum != &zip_integrity.to_string() { + return Err(InstallError::ZipIntegrityError { + path: zip_path.clone(), + expected: checksum.to_string(), + actual: zip_integrity.to_string(), + }); + } + } + unzip_file(&zip_path, &path).await?; + #[cfg(feature = "cli")] + progress.unzip.inc(1); + + if subdependencies { + install_subdependencies(&path).await?; + } + #[cfg(feature = "cli")] + progress.subdependencies.inc(1); + + let integrity = hash_folder(&path).map_err(|e| InstallError::IOError { + path: path.as_ref().to_path_buf(), + source: e, + })?; + #[cfg(feature = "cli")] + progress.integrity.inc(1); + + Ok(HttpLockEntry::builder() + .name(&dep.name) + .version(&dep.version) + .url(&dep.url) + .checksum(zip_integrity.to_string()) + .integrity(integrity.to_string()) + .build() + .into()) + } + InstallInfo::Git(dep) => { + // if the dependency was specified without a commit hash and we didn't have a lockfile, + // clone the default branch + let commit = clone_repo(&dep.git, dep.identifier.as_ref(), &path).await?; + #[cfg(feature = "cli")] + progress.downloads.inc(1); + + if subdependencies { + install_subdependencies(&path).await?; + } + #[cfg(feature = "cli")] + { + progress.unzip.inc(1); + progress.subdependencies.inc(1); + progress.integrity.inc(1); + } + Ok(GitLockEntry::builder() + .name(&dep.name) + .version(&dep.version) + .git(&dep.git) + .rev(commit) + .build() + .into()) + } + } +} + +async fn install_subdependencies(path: impl AsRef) -> Result<()> { + let path = path.as_ref().to_path_buf(); + let gitmodules_path = path.join(".gitmodules"); + if fs::metadata(&gitmodules_path).await.is_ok() { + // clone submodules + run_git_command(&["submodule", "update", "--init", "--recursive"], Some(&path)).await?; + } + // if there is a soldeer.toml file, install the soldeer deps + let soldeer_config_path = path.join("soldeer.toml"); + if fs::metadata(&soldeer_config_path).await.is_ok() { + // install subdependencies + run_forge_command(&["soldeer", "install"], Some(&path)).await?; + return Ok(()); + } + // if soldeer deps are defined in the foundry.toml file, install them + let foundry_path = path.join("foundry.toml"); + if let Ok(contents) = fs::read_to_string(&foundry_path).await { + if let Ok(doc) = contents.parse::() { + if doc.contains_table("dependencies") { + run_forge_command(&["soldeer", "install"], Some(&path)).await?; + } + } + } + Ok(()) +} + +async fn check_http_dependency( + lock: &HttpLockEntry, + deps: impl AsRef, +) -> Result { + let path = lock.install_path(deps); + if fs::metadata(&path).await.is_err() { + return Ok(DependencyStatus::Missing); + } + let current_hash = tokio::task::spawn_blocking({ + let path = path.clone(); + move || hash_folder(path) + }) + .await? + .map_err(|e| InstallError::IOError { path, source: e })?; + if current_hash.to_string() != lock.integrity { + return Ok(DependencyStatus::FailedIntegrity); + } + Ok(DependencyStatus::Installed) +} + +async fn check_git_dependency( + lock: &GitLockEntry, + deps: impl AsRef, +) -> Result { + let path = lock.install_path(deps); + if fs::metadata(&path).await.is_err() { + return Ok(DependencyStatus::Missing); + } + // check that the location is a git repository + let top_level = match run_git_command( + &["rev-parse", "--show-toplevel", path.to_string_lossy().as_ref()], + Some(&path), + ) + .await + { + Ok(top_level) => { + // stdout contains the path twice, we only keep the first item + PathBuf::from(top_level.split_whitespace().next().unwrap_or_default()) + } + Err(_) => { + // error getting the top level directory, assume the directory is not a git repository + return Ok(DependencyStatus::Missing); + } + }; + let top_level = top_level.to_slash_lossy(); + // compare the top level directory to the install path + + let absolute_path = canonicalize(&path) + .await + .map_err(|e| InstallError::IOError { path: path.clone(), source: e })?; + if top_level.trim() != absolute_path.to_slash_lossy() { + // the top level directory is not the install path, assume the directory is not a git + // repository + return Ok(DependencyStatus::Missing); + } + // for git dependencies, the `rev` field holds the commit hash + match run_git_command(&["diff", "--exit-code", &lock.rev], Some(&path)).await { + Ok(_) => Ok(DependencyStatus::Installed), + Err(_) => Ok(DependencyStatus::FailedIntegrity), + } +} + +/// Reset a git dependency to the commit specified in the lockfile entry +/// +/// This function runs `git reset --hard ` and `git clean -fd` in the git dependency's +/// directory +async fn reset_git_dependency(lock: &GitLockEntry, deps: impl AsRef) -> Result<()> { + let path = lock.install_path(deps); + run_git_command(&["reset", "--hard", &lock.rev], Some(&path)).await?; + run_git_command(&["clean", "-fd"], Some(&path)).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{GitDependency, HttpDependency}; + use cliclack::multi_progress; + use mockito::{Matcher, Server, ServerGuard}; + use temp_env::async_with_vars; + use testdir::testdir; + + async fn mock_api_server() -> ServerGuard { + let mut server = Server::new_async().await; + let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + let data2 = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision-cli") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data2) + .create_async() + .await; + server + } + + #[tokio::test] + async fn test_check_http_dependency() { + let lock = HttpLockEntry::builder() + .name("lib1") + .version("1.0.0") + .url("https://example.com/zip.zip") + .checksum("") + .integrity("beef") + .build(); + let dir = testdir!(); + let path = dir.join("lib1-1.0.0"); + fs::create_dir(&path).await.unwrap(); + fs::write(path.join("test.txt"), "foobar").await.unwrap(); + let res = check_http_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::FailedIntegrity); + + let lock = HttpLockEntry::builder() + .name("lib2") + .version("1.0.0") + .url("https://example.com/zip.zip") + .checksum("") + .integrity("") + .build(); + let res = check_http_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::Missing); + + let hash = hash_folder(&path).unwrap(); + let lock = HttpLockEntry::builder() + .name("lib1") + .version("1.0.0") + .url("https://example.com/zip.zip") + .checksum("") + .integrity(hash.to_string()) + .build(); + let res = check_http_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::Installed); + } + + #[tokio::test] + async fn test_check_git_dependency() { + // happy path + let dir = testdir!(); + let path = &dir.join("test-repo-1.0.0"); + let rev = clone_repo("https://github.com/beeb/test-repo.git", None, &path).await.unwrap(); + let lock = + GitLockEntry::builder().name("test-repo").version("1.0.0").git("").rev(rev).build(); + let res = check_git_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::Installed); + + // replace contents of existing file, diff is not empty + fs::write(path.join("foo.txt"), "foo").await.unwrap(); + let res = check_git_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::FailedIntegrity); + + // wrong commit is checked out + let lock = GitLockEntry::builder() + .name("test-repo") + .version("1.0.0") + .git("") + .rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f") + .build(); + let res = check_git_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::FailedIntegrity); + + // missing folder + let lock = GitLockEntry::builder().name("lib1").version("1.0.0").git("").rev("").build(); + let res = check_git_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::Missing); + + // remove .git folder -> not a git repo + let lock = + GitLockEntry::builder().name("test-repo").version("1.0.0").git("").rev("").build(); + fs::remove_dir_all(path.join(".git")).await.unwrap(); + let res = check_git_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), DependencyStatus::Missing); + } + + #[tokio::test] + async fn test_reset_git_dependency() { + let dir = testdir!(); + let path = &dir.join("test-repo-1.0.0"); + clone_repo("https://github.com/beeb/test-repo.git", None, &path).await.unwrap(); + let lock = GitLockEntry::builder() + .name("test-repo") + .version("1.0.0") + .git("") + .rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f") + .build(); + let test = path.join("test.txt"); + fs::write(&test, "foobar").await.unwrap(); + let res = reset_git_dependency(&lock, &dir).await; + assert!(res.is_ok(), "{res:?}"); + // non checked-in file + assert!(fs::metadata(test).await.is_err()); + // file that is in `main` but not in `78c2f6a` + assert!(fs::metadata(path.join("foo.txt")).await.is_err()); + let commit = run_git_command(&["rev-parse", "--verify", "HEAD"], Some(path)) + .await + .unwrap() + .trim() + .to_string(); + assert_eq!(commit, "78c2f6a1a54db26bab6c3f501854a1564eb3707f"); + } + + #[tokio::test] + async fn test_install_dependency_inner_http() { + let dir = testdir!(); + let install: InstallInfo = HttpInstallInfo::builder().name("test").version("1.0.0").url("https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip").checksum("94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468").build().into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency_inner(&install, &dir, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), "test"); + assert_eq!(lock.version(), "1.0.0"); + let lock = lock.as_http().unwrap(); + assert_eq!(lock.url, "https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip"); + assert_eq!( + lock.checksum, + "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468" + ); + let hash = hash_folder(&dir).unwrap(); + assert_eq!(lock.integrity, hash.to_string()); + } + + #[tokio::test] + async fn test_install_dependency_inner_git() { + let dir = testdir!(); + let install: InstallInfo = GitInstallInfo::builder() + .name("test") + .version("1.0.0") + .git("https://github.com/beeb/test-repo.git") + .build() + .into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency_inner(&install, &dir, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), "test"); + assert_eq!(lock.version(), "1.0.0"); + let lock = lock.as_git().unwrap(); + assert_eq!(lock.git, "https://github.com/beeb/test-repo.git"); + assert_eq!(lock.rev, "d5d72fa135d28b2e8307650b3ea79115183f2406"); + assert!(dir.join(".git").exists()); + } + + #[tokio::test] + async fn test_install_dependency_inner_git_rev() { + let dir = testdir!(); + let install: InstallInfo = GitInstallInfo::builder() + .name("test") + .version("1.0.0") + .git("https://github.com/beeb/test-repo.git") + .identifier(GitIdentifier::from_rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f")) + .build() + .into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency_inner(&install, &dir, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), "test"); + assert_eq!(lock.version(), "1.0.0"); + let lock = lock.as_git().unwrap(); + assert_eq!(lock.git, "https://github.com/beeb/test-repo.git"); + assert_eq!(lock.rev, "78c2f6a1a54db26bab6c3f501854a1564eb3707f"); + assert!(dir.join(".git").exists()); + } + + #[tokio::test] + async fn test_install_dependency_inner_git_branch() { + let dir = testdir!(); + let install: InstallInfo = GitInstallInfo::builder() + .name("test") + .version("1.0.0") + .git("https://github.com/beeb/test-repo.git") + .identifier(GitIdentifier::from_branch("dev")) + .build() + .into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency_inner(&install, &dir, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), "test"); + assert_eq!(lock.version(), "1.0.0"); + let lock = lock.as_git().unwrap(); + assert_eq!(lock.git, "https://github.com/beeb/test-repo.git"); + assert_eq!(lock.rev, "8d903e557e8f1b6e62bde768aa456d4ddfca72c4"); + assert!(dir.join(".git").exists()); + } + + #[tokio::test] + async fn test_install_dependency_inner_git_tag() { + let dir = testdir!(); + let install: InstallInfo = GitInstallInfo::builder() + .name("test") + .version("1.0.0") + .git("https://github.com/beeb/test-repo.git") + .identifier(GitIdentifier::from_tag("v0.1.0")) + .build() + .into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency_inner(&install, &dir, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), "test"); + assert_eq!(lock.version(), "1.0.0"); + let lock = lock.as_git().unwrap(); + assert_eq!(lock.git, "https://github.com/beeb/test-repo.git"); + assert_eq!(lock.rev, "78c2f6a1a54db26bab6c3f501854a1564eb3707f"); + assert!(dir.join(".git").exists()); + } + + #[tokio::test] + async fn test_install_dependency_registry() { + let server = mock_api_server().await; + let dir = testdir!(); + let dep = HttpDependency::builder().name("forge-std").version_req("1.9.2").build().into(); + let multi = multi_progress("Installing dependencies"); + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + install_dependency(&dep, None, &dir, None, false, Progress::new(&multi, 1)), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), dep.name()); + assert_eq!(lock.version(), dep.version_req()); + let lock = lock.as_http().unwrap(); + assert_eq!(&lock.url, "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"); + assert_eq!( + lock.checksum, + "20fd008c7c69b6c737cc0284469d1c76497107bc3e004d8381f6d8781cb27980" + ); + let hash = hash_folder(lock.install_path(&dir)).unwrap(); + assert_eq!(lock.integrity, hash.to_string()); + } + + #[tokio::test] + async fn test_install_dependency_registry_compatible() { + let server = mock_api_server().await; + let dir = testdir!(); + let dep = HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into(); + let multi = multi_progress("Installing dependencies"); + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + install_dependency(&dep, None, &dir, None, false, Progress::new(&multi, 1)), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), dep.name()); + assert_eq!(lock.version(), "1.9.2"); + let lock = lock.as_http().unwrap(); + assert_eq!(&lock.url, "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"); + let hash = hash_folder(lock.install_path(&dir)).unwrap(); + assert_eq!(lock.integrity, hash.to_string()); + } + + #[tokio::test] + async fn test_install_dependency_http() { + let dir = testdir!(); + let dep = HttpDependency::builder().name("test").version_req("1.0.0").url("https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip").build().into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency(&dep, None, &dir, None, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), dep.name()); + assert_eq!(lock.version(), dep.version_req()); + let lock = lock.as_http().unwrap(); + assert_eq!(&lock.url, dep.url().unwrap()); + assert_eq!( + lock.checksum, + "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468" + ); + let hash = hash_folder(lock.install_path(&dir)).unwrap(); + assert_eq!(lock.integrity, hash.to_string()); + } + + #[tokio::test] + async fn test_install_dependency_git() { + let dir = testdir!(); + let dep = GitDependency::builder() + .name("test") + .version_req("1.0.0") + .git("https://github.com/beeb/test-repo.git") + .build() + .into(); + let multi = multi_progress("Installing dependencies"); + let res = install_dependency(&dep, None, &dir, None, false, Progress::new(&multi, 1)).await; + assert!(res.is_ok(), "{res:?}"); + let lock = res.unwrap(); + assert_eq!(lock.name(), dep.name()); + assert_eq!(lock.version(), dep.version_req()); + let lock = lock.as_git().unwrap(); + assert_eq!(&lock.git, dep.url().unwrap()); + assert_eq!(lock.rev, "d5d72fa135d28b2e8307650b3ea79115183f2406"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..f90e592 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,17 @@ +//! Low-level library for interacting with Soldeer registries and files +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +pub use errors::SoldeerError; + +pub type Result = std::result::Result; + +pub mod auth; +pub mod config; +pub mod download; +pub mod errors; +pub mod install; +pub mod lock; +pub mod push; +pub mod registry; +pub mod remappings; +pub mod update; +pub mod utils; diff --git a/crates/core/src/lock.rs b/crates/core/src/lock.rs new file mode 100644 index 0000000..c7352d0 --- /dev/null +++ b/crates/core/src/lock.rs @@ -0,0 +1,522 @@ +use crate::{config::Dependency, errors::LockError, utils::sanitize_filename}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub type Result = std::result::Result; + +#[bon::builder(on(String, into))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub struct GitLockEntry { + pub name: String, + pub version: String, + pub git: String, + pub rev: String, +} + +impl GitLockEntry { + pub fn install_path(&self, deps: impl AsRef) -> PathBuf { + format_install_path(&self.name, &self.version, deps) + } +} + +#[bon::builder(on(String, into))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub struct HttpLockEntry { + pub name: String, + pub version: String, + pub url: String, + pub checksum: String, + pub integrity: String, +} + +impl HttpLockEntry { + pub fn install_path(&self, deps: impl AsRef) -> PathBuf { + format_install_path(&self.name, &self.version, deps) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] +#[non_exhaustive] +pub enum LockEntry { + Http(HttpLockEntry), + Git(GitLockEntry), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct TomlLockEntry { + pub name: String, + pub version: String, + pub git: Option, + pub url: Option, + pub rev: Option, + pub checksum: Option, + pub integrity: Option, +} + +impl From for TomlLockEntry { + fn from(value: LockEntry) -> Self { + match value { + LockEntry::Http(lock) => Self { + name: lock.name, + version: lock.version, + git: None, + url: Some(lock.url), + rev: None, + checksum: Some(lock.checksum), + integrity: Some(lock.integrity), + }, + LockEntry::Git(lock) => Self { + name: lock.name, + version: lock.version, + git: Some(lock.git), + url: None, + rev: Some(lock.rev), + checksum: None, + integrity: None, + }, + } + } +} + +impl TryFrom for LockEntry { + type Error = LockError; + + fn try_from(value: TomlLockEntry) -> std::result::Result { + if let Some(url) = value.url { + Ok(HttpLockEntry::builder() + .name(&value.name) + .version(value.version) + .url(url) + .checksum(value.checksum.ok_or(LockError::MissingField { + field: "checksum".to_string(), + dep: value.name.clone(), + })?) + .integrity(value.integrity.ok_or(LockError::MissingField { + field: "integrity".to_string(), + dep: value.name.clone(), + })?) + .build() + .into()) + } else { + Ok(GitLockEntry::builder() + .name(&value.name) + .version(value.version) + .git(value.git.ok_or(LockError::MissingField { + field: "git".to_string(), + dep: value.name.clone(), + })?) + .rev(value.rev.ok_or(LockError::MissingField { + field: "rev".to_string(), + dep: value.name.clone(), + })?) + .build() + .into()) + } + } +} + +impl LockEntry { + pub fn name(&self) -> &str { + match self { + Self::Git(lock) => &lock.name, + Self::Http(lock) => &lock.name, + } + } + + pub fn version(&self) -> &str { + match self { + Self::Git(lock) => &lock.version, + Self::Http(lock) => &lock.version, + } + } + + pub fn install_path(&self, deps: impl AsRef) -> PathBuf { + match self { + Self::Git(lock) => lock.install_path(deps), + Self::Http(lock) => lock.install_path(deps), + } + } + + pub fn as_http(&self) -> Option<&HttpLockEntry> { + if let Self::Http(l) = self { + Some(l) + } else { + None + } + } + + pub fn as_git(&self) -> Option<&GitLockEntry> { + if let Self::Git(l) = self { + Some(l) + } else { + None + } + } +} + +impl From for LockEntry { + fn from(value: HttpLockEntry) -> Self { + Self::Http(value) + } +} + +impl From for LockEntry { + fn from(value: GitLockEntry) -> Self { + Self::Git(value) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] +struct LockFileParsed { + dependencies: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct LockFile { + pub entries: Vec, + pub raw: String, +} + +pub fn read_lockfile(path: impl AsRef) -> Result { + if !path.as_ref().exists() { + return Ok(LockFile::default()); + } + let contents = fs::read_to_string(&path)?; + + let data: LockFileParsed = toml_edit::de::from_str(&contents).unwrap_or_default(); + Ok(LockFile { + entries: data.dependencies.into_iter().filter_map(|d| d.try_into().ok()).collect(), + raw: contents, + }) +} + +pub fn generate_lockfile_contents(mut entries: Vec) -> String { + entries.sort_unstable_by(|a, b| a.name().cmp(b.name())); + let data = LockFileParsed { dependencies: entries.into_iter().map(Into::into).collect() }; + toml_edit::ser::to_string_pretty(&data).expect("Lock entries should be serializable") +} + +pub fn add_to_lockfile(entry: LockEntry, path: impl AsRef) -> Result<()> { + let mut lockfile = read_lockfile(&path)?; + if let Some(index) = lockfile.entries.iter().position(|e| e.name() == entry.name()) { + let _ = std::mem::replace(&mut lockfile.entries[index], entry); + } else { + lockfile.entries.push(entry); + } + let new_contents = generate_lockfile_contents(lockfile.entries); + fs::write(&path, new_contents)?; + Ok(()) +} + +pub fn remove_lock(dependency: &Dependency, path: impl AsRef) -> Result<()> { + let lockfile = read_lockfile(&path)?; + + let entries: Vec<_> = lockfile + .entries + .into_iter() + .filter_map(|e| if e.name() != dependency.name() { Some(e.into()) } else { None }) + .collect(); + + if entries.is_empty() { + // remove lock file if there are no deps left + let _ = fs::remove_file(&path); + return Ok(()); + } + + let file_contents = + toml_edit::ser::to_string_pretty(&LockFileParsed { dependencies: entries })?; + + // replace contents of lockfile with new contents + fs::write(&path, file_contents)?; + + Ok(()) +} + +pub fn format_install_path(name: &str, version: &str, deps: impl AsRef) -> PathBuf { + deps.as_ref().join(sanitize_filename(&format!("{name}-{version}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use testdir::testdir; + + #[test] + fn test_toml_to_lock_entry_conversion_http() { + let toml_entry = TomlLockEntry { + name: "test".to_string(), + version: "1.0.0".to_string(), + git: None, + url: Some("https://example.com/zip.zip".to_string()), + rev: None, + checksum: Some("123456".to_string()), + integrity: Some("beef".to_string()), + }; + let entry: Result = toml_entry.try_into(); + assert!(entry.is_ok(), "{entry:?}"); + let entry = entry.unwrap(); + assert_eq!(entry.name(), "test"); + assert_eq!(entry.version(), "1.0.0"); + let http = entry.as_http().unwrap(); + assert_eq!(http.url, "https://example.com/zip.zip"); + assert_eq!(http.checksum, "123456"); + assert_eq!(http.integrity, "beef"); + } + + #[test] + fn test_toml_to_lock_entry_conversion_git() { + let toml_entry = TomlLockEntry { + name: "test".to_string(), + version: "1.0.0".to_string(), + git: Some("git@github.com:test/test.git".to_string()), + url: None, + rev: Some("123456".to_string()), + checksum: None, + integrity: None, + }; + let entry: Result = toml_entry.try_into(); + assert!(entry.is_ok(), "{entry:?}"); + let entry = entry.unwrap(); + assert_eq!(entry.name(), "test"); + assert_eq!(entry.version(), "1.0.0"); + let git = entry.as_git().unwrap(); + assert_eq!(git.git, "git@github.com:test/test.git"); + assert_eq!(git.rev, "123456"); + } + + #[test] + fn test_toml_lock_entry_bad_http() { + let toml_entry = TomlLockEntry { + name: "test".to_string(), + version: "1.0.0".to_string(), + git: None, + url: Some("https://example.com/zip.zip".to_string()), + rev: None, + checksum: None, + integrity: None, + }; + let entry: Result = toml_entry.try_into(); + assert!( + matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "checksum"), + "{entry:?}" + ); + + let toml_entry = TomlLockEntry { + name: "test".to_string(), + version: "1.0.0".to_string(), + git: None, + url: Some("https://example.com/zip.zip".to_string()), + rev: None, + checksum: Some("123456".to_string()), + integrity: None, + }; + let entry: Result = toml_entry.try_into(); + assert!( + matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "integrity"), + "{entry:?}" + ); + } + + #[test] + fn test_toml_lock_entry_bad_git() { + let toml_entry = TomlLockEntry { + name: "test".to_string(), + version: "1.0.0".to_string(), + git: None, + url: None, + rev: None, + checksum: None, + integrity: None, + }; + let entry: Result = toml_entry.try_into(); + assert!( + matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "git"), + "{entry:?}" + ); + + let toml_entry = TomlLockEntry { + name: "test".to_string(), + version: "1.0.0".to_string(), + git: Some("git@github.com:test/test.git".to_string()), + url: None, + rev: None, + checksum: None, + integrity: None, + }; + let entry: Result = toml_entry.try_into(); + assert!( + matches!(entry, Err(LockError::MissingField { ref field, dep: _ }) if field == "rev"), + "{entry:?}" + ); + } + + #[test] + fn test_read_lockfile() { + let dir = testdir!(); + let file_path = dir.join("soldeer.lock"); + // last entry is invalid and should be skipped + let content = r#"[[dependencies]] +name = "test" +version = "1.0.0" +git = "git@github.com:test/test.git" +rev = "123456" + +[[dependencies]] +name = "test2" +version = "1.0.0" +url = "https://example.com/zip.zip" +checksum = "123456" +integrity = "beef" + +[[dependencies]] +name = "test3" +version = "1.0.0" +"#; + fs::write(&file_path, content).unwrap(); + let res = read_lockfile(&file_path); + assert!(res.is_ok(), "{res:?}"); + let lockfile = res.unwrap(); + assert_eq!(lockfile.entries.len(), 2); + assert_eq!(lockfile.entries[0].name(), "test"); + assert_eq!(lockfile.entries[0].version(), "1.0.0"); + let git = lockfile.entries[0].as_git().unwrap(); + assert_eq!(git.git, "git@github.com:test/test.git"); + assert_eq!(git.rev, "123456"); + assert_eq!(lockfile.entries[1].name(), "test2"); + assert_eq!(lockfile.entries[1].version(), "1.0.0"); + let http = lockfile.entries[1].as_http().unwrap(); + assert_eq!(http.url, "https://example.com/zip.zip"); + assert_eq!(http.checksum, "123456"); + assert_eq!(http.integrity, "beef"); + assert_eq!(lockfile.raw, content); + } + + #[test] + fn test_generate_lockfile_content() { + let dir = testdir!(); + let file_path = dir.join("soldeer.lock"); + let content = r#"[[dependencies]] +name = "test" +version = "1.0.0" +git = "git@github.com:test/test.git" +rev = "123456" + +[[dependencies]] +name = "test2" +version = "1.0.0" +url = "https://example.com/zip.zip" +checksum = "123456" +integrity = "beef" +"#; + fs::write(&file_path, content).unwrap(); + let lockfile = read_lockfile(&file_path).unwrap(); + let new_content = generate_lockfile_contents(lockfile.entries); + assert_eq!(new_content, content); + } + + #[test] + fn test_add_to_lockfile() { + let dir = testdir!(); + let file_path = dir.join("soldeer.lock"); + let content = r#"[[dependencies]] +name = "test" +version = "1.0.0" +git = "git@github.com:test/test.git" +rev = "123456" +"#; + fs::write(&file_path, content).unwrap(); + let entry: LockEntry = HttpLockEntry::builder() + .name("test2") + .version("1.0.0") + .url("https://example.com/zip.zip") + .checksum("123456") + .integrity("beef") + .build() + .into(); + let res = add_to_lockfile(entry.clone(), &file_path); + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(&file_path).unwrap(); + assert_eq!(lockfile.entries.len(), 2); + assert_eq!(lockfile.entries[1], entry); + } + + #[test] + fn test_replace_in_lockfile() { + let dir = testdir!(); + let file_path = dir.join("soldeer.lock"); + let content = r#"[[dependencies]] +name = "test" +version = "1.0.0" +git = "git@github.com:test/test.git" +rev = "123456" +"#; + fs::write(&file_path, content).unwrap(); + let entry: LockEntry = HttpLockEntry::builder() + .name("test") + .version("2.0.0") + .url("https://example.com/zip.zip") + .checksum("123456") + .integrity("beef") + .build() + .into(); + let res = add_to_lockfile(entry.clone(), &file_path); + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(&file_path).unwrap(); + assert_eq!(lockfile.entries.len(), 1); + assert_eq!(lockfile.entries[0], entry); + } + + #[test] + fn test_remove_lock() { + let dir = testdir!(); + let file_path = dir.join("soldeer.lock"); + let content = r#"[[dependencies]] +name = "test" +version = "1.0.0" +git = "git@github.com:test/test.git" +rev = "123456" + +[[dependencies]] +name = "test2" +version = "1.0.0" +url = "https://example.com/zip.zip" +checksum = "123456" +integrity = "beef" +"#; + fs::write(&file_path, content).unwrap(); + let dep = Dependency::from_name_version("test2~2.0.0", None::<&str>, None).unwrap(); + let res = remove_lock(&dep, &file_path); + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(&file_path).unwrap(); + assert_eq!(lockfile.entries.len(), 1); + assert_eq!(lockfile.entries[0].name(), "test"); + } + + #[test] + fn test_remove_lock_empty() { + let dir = testdir!(); + let file_path = dir.join("soldeer.lock"); + let content = r#"[[dependencies]] +name = "test" +version = "1.0.0" +git = "git@github.com:test/test.git" +rev = "123456" +"#; + fs::write(&file_path, content).unwrap(); + let dep = Dependency::from_name_version("test~1.0.0", None::<&str>, None).unwrap(); + let res = remove_lock(&dep, &file_path); + assert!(res.is_ok(), "{res:?}"); + assert!(!file_path.exists()); + } +} diff --git a/crates/core/src/push.rs b/crates/core/src/push.rs new file mode 100644 index 0000000..1f4c82a --- /dev/null +++ b/crates/core/src/push.rs @@ -0,0 +1,342 @@ +use crate::{ + auth::get_token, + errors::{AuthError, PublishError}, + registry::{api_url, get_project_id}, + utils::read_file, +}; +use ignore::{WalkBuilder, WalkState}; +use path_slash::PathExt as _; +use regex::Regex; +use reqwest::{ + header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}, + multipart::{Form, Part}, + Client, StatusCode, +}; +use std::{ + fs::{remove_file, File}, + io::{Read as _, Write as _}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; +use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; + +#[cfg(feature = "cli")] +use cliclack::log::{info, success}; +#[cfg(feature = "cli")] +use path_slash::PathBufExt as _; + +pub type Result = std::result::Result; + +pub async fn push_version( + dependency_name: &str, + dependency_version: &str, + root_directory_path: impl AsRef, + files_to_copy: &[PathBuf], + dry_run: bool, +) -> Result<()> { + let file_name = + root_directory_path.as_ref().file_name().expect("path should have a last component"); + + let zip_archive = match zip_file(&root_directory_path, files_to_copy, file_name) { + Ok(zip) => zip, + Err(err) => { + return Err(err); + } + }; + + if dry_run { + #[cfg(feature = "cli")] + info(format!( + "Zip file created at {}", + PathBuf::from_slash_lossy(&zip_archive).to_string_lossy() + )) + .ok(); + + return Ok(()); + } + + if let Err(error) = push_to_repo(&zip_archive, dependency_name, dependency_version).await { + remove_file(zip_archive.to_str().unwrap()).unwrap(); + return Err(error); + } + + // deleting zip archive + let _ = remove_file(zip_archive); + + Ok(()) +} + +pub fn validate_name(name: &str) -> Result<()> { + let regex = Regex::new(r"^[@|a-z0-9][a-z0-9-]*[a-z0-9]$").expect("regex should compile"); + if !regex.is_match(name) { + return Err(PublishError::InvalidName); + } + if !(3..=100).contains(&name.len()) { + return Err(PublishError::InvalidName); + } + Ok(()) +} + +pub fn zip_file( + root_directory_path: impl AsRef, + files_to_copy: &[PathBuf], + file_name: impl Into, +) -> Result { + let mut file_name: PathBuf = file_name.into(); + file_name.set_extension("zip"); + let zip_file_path = root_directory_path.as_ref().join(file_name); + let file = File::create(&zip_file_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + if files_to_copy.is_empty() { + return Err(PublishError::NoFiles); + } + let mut added_dirs = Vec::new(); + + for file_path in files_to_copy { + let path = file_path.as_path(); + if !path.is_file() { + continue; + } + + // This is the relative path, we basically get the relative path to the target folder + // that we want to push and zip that as a name so we won't screw up the + // file/dir hierarchy in the zip file. + let relative_file_path = file_path.strip_prefix(root_directory_path.as_ref())?; + + // we add folders explicitly to the zip file, some tools might not handle this properly + // otherwise + if let Some(parent) = relative_file_path.parent() { + if !parent.as_os_str().is_empty() && !added_dirs.contains(&parent) { + zip.add_directory(parent.to_slash_lossy(), options)?; + added_dirs.push(parent); + } + } + + let mut f = File::open(file_path.clone()) + .map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?; + let mut buffer = Vec::new(); + zip.start_file(relative_file_path.to_slash_lossy(), options)?; + f.read_to_end(&mut buffer) + .map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?; + zip.write_all(&buffer) + .map_err(|e| PublishError::IOError { path: zip_file_path.clone(), source: e })?; + } + zip.finish()?; + Ok(zip_file_path) +} + +pub fn filter_files_to_copy(root_directory_path: impl AsRef) -> Vec { + let files_to_copy = Arc::new(Mutex::new(Vec::with_capacity(100))); + let walker = WalkBuilder::new(root_directory_path) + .add_custom_ignore_filename(".soldeerignore") + .hidden(false) + .filter_entry(|entry| { + !(entry.path().is_dir() && entry.path().file_name().unwrap_or_default() == ".git") + }) + .build_parallel(); + walker.run(|| { + let files_to_copy = Arc::clone(&files_to_copy); + // function executed for each DirEntry + Box::new(move |result| { + let Ok(entry) = result else { + return WalkState::Continue; + }; + let path = entry.path(); + if path.is_dir() { + return WalkState::Continue; + } + let mut files_to_copy = files_to_copy.lock().expect("mutex should not be poisoned"); + files_to_copy.push(path.to_path_buf()); + WalkState::Continue + }) + }); + + Arc::into_inner(files_to_copy) + .expect("Arc should have no other strong references") + .into_inner() + .expect("mutex should not be poisoned") +} + +async fn push_to_repo( + zip_file: &Path, + dependency_name: &str, + dependency_version: &str, +) -> Result<()> { + let token = get_token()?; + let client = Client::new(); + + let url = api_url("revision/upload", &[]); + + let mut headers: HeaderMap = HeaderMap::new(); + + let header_string = format!("Bearer {token}"); + let header_value = HeaderValue::from_str(&header_string); + + headers.insert(AUTHORIZATION, header_value.expect("Could not set auth header")); + + let file_fs = read_file(zip_file).unwrap(); + let mut part = Part::bytes(file_fs).file_name( + zip_file + .file_name() + .expect("path should have a last component") + .to_string_lossy() + .into_owned(), + ); + + // set the mime as app zip + part = part.mime_str("application/zip").expect("Could not set mime type"); + + let project_id = get_project_id(dependency_name).await?; + + let form = Form::new() + .text("project_id", project_id) + .text("revision", dependency_version.to_string()) + .part("zip_name", part); + + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(&("multipart/form-data; boundary=".to_owned() + form.boundary())) + .expect("Could not set content type"), + ); + let response = client.post(url).headers(headers.clone()).multipart(form).send().await?; + match response.status() { + StatusCode::OK => { + #[cfg(feature = "cli")] + success("Pushed to repository!").ok(); + + Ok(()) + } + StatusCode::NO_CONTENT => Err(PublishError::ProjectNotFound), + StatusCode::ALREADY_REPORTED => Err(PublishError::AlreadyExists), + StatusCode::UNAUTHORIZED => Err(PublishError::AuthError(AuthError::InvalidCredentials)), + StatusCode::PAYLOAD_TOO_LARGE => Err(PublishError::PayloadTooLarge), + s if s.is_server_error() || s.is_client_error() => { + Err(PublishError::HttpError(response.error_for_status().unwrap_err())) + } + _ => Err(PublishError::UnknownError), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::download::unzip_file; + use std::fs; + use testdir::testdir; + + #[test] + fn test_validate_name() { + assert!(validate_name("foo").is_ok()); + assert!(validate_name("test").is_ok()); + assert!(validate_name("test-123").is_ok()); + assert!(validate_name("@test-123").is_ok()); + + assert!(validate_name("t").is_err()); + assert!(validate_name("te").is_err()); + assert!(validate_name("@t").is_err()); + assert!(validate_name("test@123").is_err()); + assert!(validate_name("test-123-").is_err()); + assert!(validate_name("foo.bar").is_err()); + assert!(validate_name("mypäckage").is_err()); + assert!(validate_name(&"a".repeat(101)).is_err()); + } + + #[test] + fn test_filter_files_to_copy() { + let dir = testdir!(); + // ignore file + // *.toml + // !/broadcast + // /broadcast/31337/ + // /broadcast/*/dry_run/ + fs::write( + dir.join(".soldeerignore"), + "*.toml\n!/broadcast\n/broadcast/31337/\n/broadcast/*/dry_run/\n", + ) + .unwrap(); + + let mut ignored = Vec::new(); + let mut included = vec![dir.join(".soldeerignore")]; + + // test structure + // - testdir/ + // --- .soldeerignore <= not ignored + // --- random_dir/ + // --- --- random.toml <= ignored + // --- --- random.zip <= not ignored + // --- broadcast/ + // --- --- random.toml <= ignored + // --- --- random.zip <= not ignored + // --- --- 31337/ + // --- --- --- random.toml <= ignored + // --- --- --- random.zip <= ignored + // --- --- random_dir_in_broadcast/ + // --- --- --- random.zip <= not ignored + // --- --- --- random.toml <= ignored + // --- --- --- dry_run/ + // --- --- --- --- zip <= ignored + // --- --- --- --- toml <= ignored + fs::create_dir(dir.join("random_dir")).unwrap(); + fs::create_dir(dir.join("broadcast")).unwrap(); + fs::create_dir(dir.join("broadcast/31337")).unwrap(); + fs::create_dir(dir.join("broadcast/random_dir_in_broadcast")).unwrap(); + fs::create_dir(dir.join("broadcast/random_dir_in_broadcast/dry_run")).unwrap(); + + ignored.push(dir.join("random_dir/random.toml")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + included.push(dir.join("random_dir/random.zip")); + fs::write(included.last().unwrap(), "included").unwrap(); + ignored.push(dir.join("broadcast/random.toml")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + included.push(dir.join("broadcast/random.zip")); + fs::write(included.last().unwrap(), "included").unwrap(); + ignored.push(dir.join("broadcast/31337/random.toml")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + ignored.push(dir.join("broadcast/31337/random.zip")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + included.push(dir.join("broadcast/random_dir_in_broadcast/random.zip")); + fs::write(included.last().unwrap(), "included").unwrap(); + ignored.push(dir.join("broadcast/random_dir_in_broadcast/random.toml")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + ignored.push(dir.join("broadcast/random_dir_in_broadcast/dry_run/zip")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + ignored.push(dir.join("broadcast/random_dir_in_broadcast/dry_run/toml")); + fs::write(ignored.last().unwrap(), "ignored").unwrap(); + + let res = filter_files_to_copy(&dir); + assert_eq!(res.len(), included.len()); + for r in res { + assert!(included.contains(&r)); + } + } + + #[tokio::test] + async fn test_zip_file() { + let dir = testdir!().join("test_zip"); + fs::create_dir(&dir).unwrap(); + let mut files = Vec::new(); + files.push(dir.join("a.txt")); + fs::write(files.last().unwrap(), "test").unwrap(); + files.push(dir.join("b.txt")); + fs::write(files.last().unwrap(), "test").unwrap(); + fs::create_dir(dir.join("sub")).unwrap(); + files.push(dir.join("sub/c.txt")); + fs::write(files.last().unwrap(), "test").unwrap(); + fs::create_dir(dir.join("sub/sub")).unwrap(); + files.push(dir.join("sub/sub/d.txt")); + fs::write(files.last().unwrap(), "test").unwrap(); + fs::create_dir(dir.join("empty")).unwrap(); + + let res = zip_file(&dir, &files, "test"); + assert!(res.is_ok(), "{res:?}"); + + fs::copy(dir.join("test.zip"), testdir!().join("test.zip")).unwrap(); + fs::remove_dir_all(&dir).unwrap(); + fs::create_dir(&dir).unwrap(); + unzip_file(testdir!().join("test.zip"), &dir).await.unwrap(); + for f in files { + assert!(f.exists()); + } + } +} diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs new file mode 100644 index 0000000..b0f98f1 --- /dev/null +++ b/crates/core/src/registry.rs @@ -0,0 +1,401 @@ +use crate::{ + config::{Dependency, HttpDependency}, + errors::RegistryError, +}; +use chrono::{DateTime, Utc}; +use reqwest::Url; +use semver::{Version, VersionReq}; +use serde::Deserialize; +use std::env; + +pub type Result = std::result::Result; + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct Revision { + pub id: uuid::Uuid, + pub version: String, + pub internal_name: String, + pub url: String, + pub project_id: uuid::Uuid, + pub deleted: bool, + pub created_at: Option>, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct Project { + pub id: uuid::Uuid, + pub name: String, + pub description: String, + pub github_url: String, + pub user_id: uuid::Uuid, + pub deleted: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct RevisionResponse { + data: Vec, + status: String, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct ProjectResponse { + data: Vec, + status: String, +} + +pub fn api_url(path: &str, params: &[(&str, &str)]) -> Url { + let url = env::var("SOLDEER_API_URL").unwrap_or("https://api.soldeer.xyz".to_string()); + let mut url = Url::parse(&url).expect("SOLDEER_API_URL is invalid"); + url.set_path(&format!("api/v1/{path}")); + if params.is_empty() { + return url; + } + url.query_pairs_mut().extend_pairs(params.iter()); + url +} + +pub async fn get_dependency_url_remote(dependency: &Dependency, version: &str) -> Result { + let url = + api_url("revision-cli", &[("project_name", dependency.name()), ("revision", version)]); + + let res = reqwest::get(url).await?; + let res = res.error_for_status()?; + let revision: RevisionResponse = res.json().await?; + let Some(r) = revision.data.first() else { + return Err(RegistryError::URLNotFound(dependency.to_string())); + }; + Ok(r.url.clone()) +} + +pub async fn get_project_id(dependency_name: &str) -> Result { + let url = api_url("project", &[("project_name", dependency_name)]); + let res = reqwest::get(url).await?; + let res = res.error_for_status()?; + let project: ProjectResponse = res.json().await?; + let Some(p) = project.data.first() else { + return Err(RegistryError::ProjectNotFound(dependency_name.to_string())); + }; + Ok(p.id.to_string()) +} + +pub async fn get_latest_forge_std() -> Result { + let dependency_name = "forge-std"; + let url = + api_url("revision", &[("project_name", dependency_name), ("offset", "0"), ("limit", "1")]); + let res = reqwest::get(url).await?; + let res = res.error_for_status()?; + let revision: RevisionResponse = res.json().await?; + let Some(data) = revision.data.first() else { + return Err(RegistryError::URLNotFound(dependency_name.to_string())); + }; + Ok(HttpDependency { + name: dependency_name.to_string(), + version_req: data.clone().version, + url: None, + } + .into()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Versions { + Semver(Vec), + NonSemver(Vec), +} + +/// Get all versions of a dependency sorted in descending order +pub async fn get_all_versions_descending(dependency_name: &str) -> Result { + // TODO: provide a more efficient endpoint which already sorts by descending semver if possible + // and only returns the version strings + let url = api_url( + "revision", + &[("project_name", dependency_name), ("offset", "0"), ("limit", "10000")], + ); + let res = reqwest::get(url).await?; + let res = res.error_for_status()?; + let revision: RevisionResponse = res.json().await?; + if revision.data.is_empty() { + return Err(RegistryError::NoVersion(dependency_name.to_string())); + } + + match revision + .data + .iter() + .map(|r| Version::parse(&r.version)) + .collect::, _>>() + { + Ok(mut versions) => { + // all versions are semver compliant + versions.sort_unstable_by(|a, b| b.cmp(a)); // sort in descending order + Ok(Versions::Semver(versions)) + } + Err(_) => { + // not all versions are semver compliant, do not sort (use API sort order) + Ok(Versions::NonSemver(revision.data.iter().map(|r| r.version.to_string()).collect())) + } + } +} + +pub async fn get_latest_supported_version(dependency: &Dependency) -> Result { + match get_all_versions_descending(dependency.name()).await? { + Versions::Semver(all_versions) => { + match parse_version_req(dependency.version_req()) { + Some(req) => { + let new_version = all_versions + .iter() + .find(|version| req.matches(version)) + .ok_or(RegistryError::NoMatchingVersion { + dependency: dependency.name().to_string(), + version_req: dependency.version_req().to_string(), + })?; + Ok(new_version.to_string()) + } + None => { + // we can't check which version is newer, so we just take the latest one + Ok(all_versions + .into_iter() + .next() + .map(|v| v.to_string()) + .expect("there should be at least 1 version")) + } + } + } + Versions::NonSemver(all_versions) => { + // we can't check which version is newer, so we just take the latest one + Ok(all_versions.into_iter().next().expect("there should be at least 1 version")) + } + } +} + +/// Parse a version requirement string into a `VersionReq`. +/// +/// Adds the "equal" operator to the req if it doesn't have an operator. +/// This is necessary because the semver crate considers no operator to be equivalent to the +/// "compatible" operator, but we want to treat it as the "equal" operator. +pub fn parse_version_req(version_req: &str) -> Option { + let Ok(mut req) = version_req.parse::() else { + return None; + }; + if req.comparators.is_empty() { + return None; + } + let orig_items: Vec<_> = version_req.split(',').collect(); + // we only perform the operator conversion if we can reference the original string, i.e. if the + // parsed result has the same number of comparators as the original string + if orig_items.len() == req.comparators.len() { + for (comparator, orig) in req.comparators.iter_mut().zip(orig_items.into_iter()) { + if comparator.op == semver::Op::Caret && !orig.trim_start_matches(' ').starts_with('^') + { + comparator.op = semver::Op::Exact; + } + } + } + Some(req) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::{Matcher, Server}; + use temp_env::async_with_vars; + + #[tokio::test] + async fn test_get_dependency_url() { + let mut server = Server::new_async().await; + let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision-cli") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let dependency = + HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into(); + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + get_dependency_url_remote(&dependency, "1.9.2"), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"); + } + + #[tokio::test] + async fn test_get_dependency_url_nomatch() { + let mut server = Server::new_async().await; + let data = r#"{"data":[],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision-cli") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let dependency = + HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into(); + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + get_dependency_url_remote(&dependency, "1.9.2"), + ) + .await; + assert!(matches!(res, Err(RegistryError::URLNotFound(_)))); + } + + #[tokio::test] + async fn test_get_project_id() { + let mut server = Server::new_async().await; + let data = r#"{"data":[{"created_at":"2024-02-27T19:19:23.938837Z","deleted":false,"description":"Forge Standard Library is a collection of helpful contracts and libraries for use with Forge and Foundry.","downloads":67634,"github_url":"https://github.com/foundry-rs/forge-std","id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","image":"https://soldeer-resources.s3.amazonaws.com/default_icon.png","long_description":"Forge Standard Library is a collection of helpful contracts and libraries for use with Forge and Foundry. It leverages Forge's cheatcodes to make writing tests easier and faster, while improving the UX of cheatcodes.","name":"forge-std","updated_at":"2024-02-27T19:19:23.938837Z","user_id":"96228bb5-f777-4c19-ba72-363d14b8beed"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/project") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + let res = + async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_project_id("forge-std")) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), "37adefe5-9bc6-4777-aaf2-e56277d1f30b"); + } + + #[tokio::test] + async fn test_get_project_id_nomatch() { + let mut server = Server::new_async().await; + let data = r#"{"data":[],"status":"success"}"#; + server + .mock("GET", "/api/v1/project") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let res = + async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_project_id("forge-std")) + .await; + assert!(matches!(res, Err(RegistryError::ProjectNotFound(_)))); + } + + #[tokio::test] + async fn test_get_latest_forge_std() { + let mut server = Server::new_async().await; + let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let dependency = + HttpDependency::builder().name("forge-std").version_req("1.9.2").build().into(); + let res = + async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_latest_forge_std()) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), dependency); + } + + #[tokio::test] + async fn test_get_all_versions_descending() { + let mut server = Server::new_async().await; + // data is not sorted in reverse semver order + let data = r#"{"data":[{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"},{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + get_all_versions_descending("forge-std"), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + Versions::Semver(vec![ + "1.9.2".parse().unwrap(), + "1.9.1".parse().unwrap(), + "1.9.0".parse().unwrap() + ]) + ); + } + + #[tokio::test] + async fn test_get_latest_supported_version_semver() { + let mut server = Server::new_async().await; + let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let dependency: Dependency = + HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into(); + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + get_latest_supported_version(&dependency), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), "1.9.2"); + } + + #[tokio::test] + async fn test_get_latest_supported_version_no_semver() { + let mut server = Server::new_async().await; + let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"2024-08"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"2024-07"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"2024-06"}],"status":"success"}"#; + server + .mock("GET", "/api/v1/revision") + .match_query(Matcher::Any) + .with_header("content-type", "application/json") + .with_body(data) + .create_async() + .await; + + let dependency: Dependency = + HttpDependency::builder().name("forge-std").version_req("foobar").build().into(); + let res = async_with_vars( + [("SOLDEER_API_URL", Some(server.url()))], + get_latest_supported_version(&dependency), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), "2024-08"); + } + + #[test] + fn test_parse_version_req() { + assert_eq!(parse_version_req("1.9.0"), Some(VersionReq::parse("=1.9.0").unwrap())); + assert_eq!(parse_version_req("=1.9.0"), Some(VersionReq::parse("=1.9.0").unwrap())); + assert_eq!(parse_version_req("^1.9.0"), Some(VersionReq::parse("^1.9.0").unwrap())); + assert_eq!( + parse_version_req("^1.9.0,^1.10.0"), + Some(VersionReq::parse("^1.9.0, ^1.10.0").unwrap()) + ); + assert_eq!( + parse_version_req("1.9.0,1.10.0"), + Some(VersionReq::parse("=1.9.0,=1.10.0").unwrap()) + ); + assert_eq!(parse_version_req(">=1.9.0"), Some(VersionReq::parse(">=1.9.0").unwrap())); + assert_eq!(parse_version_req(""), None); + assert_eq!(parse_version_req("foobar"), None); + } +} diff --git a/crates/core/src/remappings.rs b/crates/core/src/remappings.rs new file mode 100644 index 0000000..038e18d --- /dev/null +++ b/crates/core/src/remappings.rs @@ -0,0 +1,684 @@ +use crate::{ + config::{read_config_deps, Dependency, Paths, SoldeerConfig}, + errors::RemappingsError, +}; +use path_slash::PathExt as _; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::Write as _, +}; +use toml_edit::{value, Array, DocumentMut}; + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum RemappingsAction { + Add(Dependency), + Remove(Dependency), + Update, +} + +/// Location where to store the remappings, either in `remappings.txt` or the config file +/// (foundry/soldeer) +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum RemappingsLocation { + #[default] + Txt, + Config, +} + +pub fn remappings_txt( + action: &RemappingsAction, + paths: &Paths, + soldeer_config: &SoldeerConfig, +) -> Result<()> { + if soldeer_config.remappings_regenerate && paths.remappings.exists() { + fs::remove_file(&paths.remappings)?; + } + let contents = if paths.remappings.exists() { + fs::read_to_string(&paths.remappings)? + } else { + String::new() + }; + let existing_remappings: Vec<_> = contents.lines().filter_map(|r| r.split_once('=')).collect(); + + let new_remappings = generate_remappings(action, paths, soldeer_config, &existing_remappings)?; + + let mut file = File::create(&paths.remappings)?; + for remapping in new_remappings { + writeln!(file, "{remapping}")?; + } + Ok(()) +} + +pub fn remappings_foundry( + action: &RemappingsAction, + paths: &Paths, + soldeer_config: &SoldeerConfig, +) -> Result<()> { + let contents = fs::read_to_string(&paths.config)?; + let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); + + let Some(profiles) = doc["profile"].as_table_mut() else { + // we don't add remappings if there are no profiles + return Ok(()); + }; + + for (name, profile) in profiles.iter_mut() { + // we normally only edit remappings of profiles which already have a remappings key + let Some(Some(remappings)) = profile.get_mut("remappings").map(|v| v.as_array_mut()) else { + // except the default profile, where we always add the remappings + if name == "default" { + let new_remappings = generate_remappings(action, paths, soldeer_config, &[])?; + let array = new_remappings.into_iter().collect::(); + profile["remappings"] = value(array); + } + continue; + }; + let existing_remappings: Vec<_> = remappings + .iter() + .filter_map(|r| r.as_str()) + .filter_map(|r| r.split_once('=')) + .collect(); + let new_remappings = + generate_remappings(action, paths, soldeer_config, &existing_remappings)?; + remappings.clear(); + for remapping in new_remappings { + remappings.push(remapping); + } + } + + fs::write(&paths.config, doc.to_string())?; + Ok(()) +} + +pub fn edit_remappings( + action: &RemappingsAction, + config: &SoldeerConfig, + paths: &Paths, +) -> Result<()> { + if config.remappings_generate { + if paths.config.to_string_lossy().contains("foundry.toml") { + match config.remappings_location { + RemappingsLocation::Txt => { + remappings_txt(action, paths, config)?; + } + RemappingsLocation::Config => { + remappings_foundry(action, paths, config)?; + } + } + } else { + remappings_txt(action, paths, config)?; + } + } + Ok(()) +} + +pub fn format_remap_name(soldeer_config: &SoldeerConfig, dependency: &Dependency) -> String { + let version_suffix = if soldeer_config.remappings_version { + &format!("-{}", dependency.version_req().replace('=', "")) + } else { + "" + }; + format!("{}{}{}/", soldeer_config.remappings_prefix, dependency.name(), version_suffix) +} + +fn generate_remappings( + action: &RemappingsAction, + paths: &Paths, + soldeer_config: &SoldeerConfig, + existing_remappings: &[(&str, &str)], +) -> Result> { + let mut new_remappings = Vec::new(); + if soldeer_config.remappings_regenerate { + new_remappings = remappings_from_deps(paths, soldeer_config)?; + } else { + match &action { + RemappingsAction::Remove(remove_dep) => { + // only keep items not matching the dependency to remove + if let Ok(remove_og) = get_install_dir_relative(remove_dep, paths) { + for (existing_remapped, existing_og) in existing_remappings { + // TODO: make the detection smarter, and match on any path where the version + // is semver-compatible too. + if !existing_og.trim_end_matches('/').starts_with(&remove_og) { + new_remappings.push(format!("{existing_remapped}={existing_og}")); + } + } + } else { + for (remapped, og) in existing_remappings { + new_remappings.push(format!("{remapped}={og}")); + } + } + } + RemappingsAction::Add(add_dep) => { + // we only add the remapping if it's not already existing, otherwise we keep the old + // remapping + let add_dep_remapped = format_remap_name(soldeer_config, add_dep); + let add_dep_og = get_install_dir_relative(add_dep, paths)?; + let mut found = false; // whether a remapping existed for that dep already + for (existing_remapped, existing_og) in existing_remappings { + new_remappings.push(format!("{existing_remapped}={existing_og}")); + if existing_og.trim_end_matches('/').starts_with(&add_dep_og) { + found = true; + } + } + if !found { + new_remappings.push(format!("{add_dep_remapped}={add_dep_og}/")); + } + } + RemappingsAction::Update => { + // This is where we end up in the `update` command if we don't want to re-generate + // all remappings. We need to merge existing remappings with the full list of deps. + // We generate all remappings from the dependencies, then replace existing items. + new_remappings = remappings_from_deps(paths, soldeer_config)?; + if !existing_remappings.is_empty() { + for item in &mut new_remappings { + let (_, item_og) = + item.split_once('=').expect("remappings should have two parts"); + // Try to find an existing item with the same path. + // TODO: make the detection smarter, and match on any path where the version + // is semver-compatible too. + // For this we need a reference to the dependency object so we can parse the + // version req string. + // If we found an existing remapping with a matching version, then we do a + // search and replace in the right-side (og) part to + // update the path to point to the new version + // folder. It's important to trim the trailing slash in case the existing + // remapping doesn't contain one. + if let Some((existing_remapped, existing_og)) = + existing_remappings.iter().find(|(_, og)| { + // if the existing remapping path starts with the dependency folder, + // we found a match + og.trim_end_matches('/').starts_with(item_og.trim_end_matches('/')) + }) + { + // if found, we restore it + *item = format!("{existing_remapped}={existing_og}"); + } + } + } + } + } + } + + // sort the remappings + new_remappings.sort_unstable(); + Ok(new_remappings) +} + +fn remappings_from_deps(paths: &Paths, soldeer_config: &SoldeerConfig) -> Result> { + let dependencies = read_config_deps(&paths.config)?; + dependencies + .iter() + .map(|dependency| { + let dependency_name_formatted = format_remap_name(soldeer_config, dependency); + let relative_path = get_install_dir_relative(dependency, paths)?; + Ok(format!("{dependency_name_formatted}={relative_path}/")) + }) + .collect::>>() +} + +/// Find the install path (relative to project root) for a dependency that was already installed +/// +/// # Errors +/// If the there is no folder in the dependencies folder corresponding to the dependency +fn get_install_dir_relative(dependency: &Dependency, paths: &Paths) -> Result { + let path = dunce::canonicalize( + dependency + .install_path_sync(&paths.dependencies) + .ok_or(RemappingsError::DependencyNotFound(dependency.to_string()))?, + )?; + Ok(path + .strip_prefix(&paths.root) // already canonicalized + .map_err(|_| RemappingsError::DependencyNotFound(dependency.to_string()))? + .to_slash_lossy() + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{GitDependency, HttpDependency}; + use testdir::testdir; + + #[test] + fn test_get_install_dir_relative() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let dependencies_dir = dir.join("dependencies"); + fs::create_dir_all(&dependencies_dir).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + + fs::create_dir_all(dependencies_dir.join("dep1-1.1.1")).unwrap(); + let dependency = + HttpDependency::builder().name("dep1").version_req("^1.0.0").build().into(); + let res = get_install_dir_relative(&dependency, &paths); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), "dependencies/dep1-1.1.1"); + + fs::create_dir_all(dependencies_dir.join("dep2-2.0.0")).unwrap(); + let dependency = GitDependency::builder() + .name("dep2") + .version_req("2.0.0") + .git("git@github.com:test/test.git") + .build() + .into(); + let res = get_install_dir_relative(&dependency, &paths); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), "dependencies/dep2-2.0.0"); + + let dependency = HttpDependency::builder().name("dep3").version_req("3.0.0").build().into(); + let res = get_install_dir_relative(&dependency, &paths); + assert!(res.is_err(), "{res:?}"); + } + + #[test] + fn test_format_remap_name() { + let dependency = + HttpDependency::builder().name("dep1").version_req("^1.0.0").build().into(); + let res = format_remap_name( + &SoldeerConfig { + remappings_version: false, + remappings_prefix: String::new(), + ..Default::default() + }, + &dependency, + ); + assert_eq!(res, "dep1/"); + let res = format_remap_name( + &SoldeerConfig { + remappings_version: true, + remappings_prefix: String::new(), + ..Default::default() + }, + &dependency, + ); + assert_eq!(res, "dep1-^1.0.0/"); + let res = format_remap_name( + &SoldeerConfig { + remappings_version: false, + remappings_prefix: "@".to_string(), + ..Default::default() + }, + &dependency, + ); + assert_eq!(res, "@dep1/"); + let res = format_remap_name( + &SoldeerConfig { + remappings_version: true, + remappings_prefix: "@".to_string(), + ..Default::default() + }, + &dependency, + ); + assert_eq!(res, "@dep1-^1.0.0/"); + + let dependency = + HttpDependency::builder().name("dep1").version_req("=1.0.0").build().into(); + let res = format_remap_name( + &SoldeerConfig { + remappings_version: true, + remappings_prefix: String::new(), + ..Default::default() + }, + &dependency, + ); + assert_eq!(res, "dep1-1.0.0/"); + } + + #[test] + fn test_remappings_from_deps() { + let dir = testdir!(); + let config = r#"[dependencies] +dep1 = "^1.0.0" +dep2 = "2.0.0" +dep3 = { version = "foobar", git = "git@github.com:test/test.git", branch = "foobar" } +"#; + fs::write(dir.join("soldeer.toml"), config).unwrap(); + let dependencies_dir = dir.join("dependencies"); + fs::create_dir_all(&dependencies_dir).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + + fs::create_dir_all(dependencies_dir.join("dep1-1.1.1")).unwrap(); + fs::create_dir_all(dependencies_dir.join("dep2-2.0.0")).unwrap(); + fs::create_dir_all(dependencies_dir.join("dep3-foobar")).unwrap(); + + let res = remappings_from_deps(&paths, &SoldeerConfig::default()); + assert!(res.is_ok(), "{res:?}"); + let res = res.unwrap(); + assert_eq!(res.len(), 3); + assert_eq!(res[0], "dep1-^1.0.0/=dependencies/dep1-1.1.1/"); + assert_eq!(res[1], "dep2-2.0.0/=dependencies/dep2-2.0.0/"); + assert_eq!(res[2], "dep3-foobar/=dependencies/dep3-foobar/"); + } + + #[test] + fn test_generate_remappings_add() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let config = SoldeerConfig::default(); + // empty existing remappings + let existing_deps = vec![]; + let dep = HttpDependency::builder().name("lib1").version_req("1.0.0").build().into(); + let res = generate_remappings(&RemappingsAction::Add(dep), &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/"]); + + // existing remappings not matching new one + let existing_deps = vec![("lib1-1.0.0/", "dependencies/lib1-1.0.0/")]; + fs::create_dir_all(paths.dependencies.join("lib2-1.1.1")).unwrap(); + let dep = HttpDependency::builder().name("lib2").version_req("^1.0.0").build().into(); + let res = generate_remappings(&RemappingsAction::Add(dep), &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/", "lib2-^1.0.0/=dependencies/lib2-1.1.1/"] + ); + + // existing remappings matching the new one + let existing_deps = vec![("@lib1-1.0.0/foo", "dependencies/lib1-1.0.0/src")]; + let dep = HttpDependency::builder().name("lib1").version_req("1.0.0").build().into(); + let res = generate_remappings(&RemappingsAction::Add(dep), &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), vec!["@lib1-1.0.0/foo=dependencies/lib1-1.0.0/src"]); + } + + #[test] + fn test_generate_remappings_remove() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib2-2.0.0")).unwrap(); + let config = SoldeerConfig::default(); + let existing_deps = vec![ + ("lib1-1.0.0/", "dependencies/lib1-1.0.0/"), + ("lib2-2.0.0/", "dependencies/lib2-2.0.0/"), + ]; + let dep = HttpDependency::builder().name("lib1").version_req("1.0.0").build().into(); + let res = + generate_remappings(&RemappingsAction::Remove(dep), &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(res.unwrap(), vec!["lib2-2.0.0/=dependencies/lib2-2.0.0/"]); + + // dep does not exist, no error + let dep = HttpDependency::builder().name("lib3").version_req("1.0.0").build().into(); + let res = + generate_remappings(&RemappingsAction::Remove(dep), &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/", "lib2-2.0.0/=dependencies/lib2-2.0.0/"] + ); + } + + #[test] + fn test_generate_remappings_update() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1.0.0" +lib2 = "2.0.0" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib2-2.0.0")).unwrap(); + let config = SoldeerConfig::default(); + // all entries are customized + let existing_deps = vec![ + ("lib1-1.0.0/", "dependencies/lib1-1.0.0/src/"), + ("lib2/", "dependencies/lib2-2.0.0/"), + ]; + let res = generate_remappings(&RemappingsAction::Update, &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/src/", "lib2/=dependencies/lib2-2.0.0/"] + ); + + // one entry is missing + let existing_deps = vec![("lib1-1.0.0/", "dependencies/lib1-1.0.0/")]; + let res = generate_remappings(&RemappingsAction::Update, &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/", "lib2-2.0.0/=dependencies/lib2-2.0.0/"] + ); + + // extra entries are removed + let existing_deps = vec![ + ("lib1-1.0.0/", "dependencies/lib1-1.0.0/"), + ("lib2-2.0.0/", "dependencies/lib2-2.0.0/"), + ("lib3/", "dependencies/lib3/"), + ]; + let res = generate_remappings(&RemappingsAction::Update, &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/", "lib2-2.0.0/=dependencies/lib2-2.0.0/"] + ); + } + + #[test] + fn test_remappings_foundry_noprofile() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + let config = SoldeerConfig::default(); + // no profile: no remappings are added + let res = remappings_foundry(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + assert_eq!(fs::read_to_string(&paths.config).unwrap(), contents); + } + + #[test] + fn test_remappings_foundry_default_profile_empty() { + let dir = testdir!(); + let contents = r#"[profile.default] + +[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let config = SoldeerConfig::default(); + let res = remappings_foundry(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.config).unwrap(); + let doc: DocumentMut = contents.parse::().unwrap(); + assert_eq!( + doc["profile"]["default"]["remappings"] + .as_array() + .unwrap() + .into_iter() + .map(|i| i.as_str().unwrap()) + .collect::>(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/"] + ); + } + + #[test] + fn test_remappings_foundry_second_profile_empty() { + let dir = testdir!(); + let contents = r#"[profile.default] + +[profile.local] + +[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let config = SoldeerConfig::default(); + // should only add remappings to the default profile + let res = remappings_foundry(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.config).unwrap(); + let doc: DocumentMut = contents.parse::().unwrap(); + assert_eq!( + doc["profile"]["default"]["remappings"] + .as_array() + .unwrap() + .into_iter() + .map(|i| i.as_str().unwrap()) + .collect::>(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/"] + ); + assert!(!doc["profile"]["local"].as_table().unwrap().contains_key("remappings")); + } + + #[test] + fn test_remappings_foundry_two_profiles() { + let dir = testdir!(); + let contents = r#"[profile.default] +remappings = [] + +[profile.local] +remappings = [] + +[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let config = SoldeerConfig::default(); + let res = remappings_foundry(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.config).unwrap(); + let doc: DocumentMut = contents.parse::().unwrap(); + assert_eq!( + doc["profile"]["default"]["remappings"] + .as_array() + .unwrap() + .into_iter() + .map(|i| i.as_str().unwrap()) + .collect::>(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/"] + ); + assert_eq!( + doc["profile"]["local"]["remappings"] + .as_array() + .unwrap() + .into_iter() + .map(|i| i.as_str().unwrap()) + .collect::>(), + vec!["lib1-1.0.0/=dependencies/lib1-1.0.0/"] + ); + } + + #[test] + fn test_remappings_foundry_keep_existing() { + let dir = testdir!(); + let contents = r#"[profile.default] +remappings = ["lib1/=dependencies/lib1-1.0.0/src/"] + +[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let config = SoldeerConfig::default(); + let res = remappings_foundry(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.config).unwrap(); + let doc: DocumentMut = contents.parse::().unwrap(); + assert_eq!( + doc["profile"]["default"]["remappings"] + .as_array() + .unwrap() + .into_iter() + .map(|i| i.as_str().unwrap()) + .collect::>(), + vec!["lib1/=dependencies/lib1-1.0.0/src/"] + ); + } + + #[test] + fn test_remappings_txt_keep() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let remappings = "lib1/=dependencies/lib1-1.0.0/src/\n"; + fs::write(dir.join("remappings.txt"), remappings).unwrap(); + let config = SoldeerConfig::default(); + let res = remappings_txt(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.remappings).unwrap(); + assert_eq!(contents, remappings); + } + + #[test] + fn test_remappings_txt_regenerate() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + let remappings = "lib1/=dependencies/lib1-1.0.0/src/\n"; + fs::write(dir.join("remappings.txt"), remappings).unwrap(); + let config = SoldeerConfig { remappings_regenerate: true, ..Default::default() }; + let res = remappings_txt(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.remappings).unwrap(); + assert_eq!(contents, "lib1-1.0.0/=dependencies/lib1-1.0.0/\n"); + } + + #[test] + fn test_remappings_txt_missing() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1.0.0" +lib2 = "2.0.0" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib2-2.0.0")).unwrap(); + let remappings = "lib1/=dependencies/lib1-1.0.0/src/\n"; + fs::write(dir.join("remappings.txt"), remappings).unwrap(); + let config = SoldeerConfig::default(); + let res = remappings_txt(&RemappingsAction::Update, &paths, &config); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.remappings).unwrap(); + assert_eq!( + contents, + "lib1/=dependencies/lib1-1.0.0/src/\nlib2-2.0.0/=dependencies/lib2-2.0.0/\n" + ); + } + + #[test] + fn test_edit_remappings_soldeer_config() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1.0.0" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib1-1.0.0")).unwrap(); + // the config gets ignored in this case + let config = + SoldeerConfig { remappings_location: RemappingsLocation::Config, ..Default::default() }; + let res = edit_remappings(&RemappingsAction::Update, &config, &paths); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&paths.remappings).unwrap(); + assert_eq!(contents, "lib1-1.0.0/=dependencies/lib1-1.0.0/\n"); + } +} diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs new file mode 100644 index 0000000..1da30ce --- /dev/null +++ b/crates/core/src/update.rs @@ -0,0 +1,156 @@ +use crate::{ + config::Dependency, + errors::UpdateError, + install::install_dependency, + lock::{format_install_path, GitLockEntry, LockEntry}, + registry::get_latest_supported_version, + utils::run_git_command, +}; +use std::path::Path; +use tokio::task::JoinSet; + +#[cfg(feature = "cli")] +use crate::install::Progress; + +pub type Result = std::result::Result; + +pub async fn update_dependencies( + dependencies: &[Dependency], + locks: &[LockEntry], + deps_path: impl AsRef, + recursive_deps: bool, + #[cfg(feature = "cli")] progress: Progress, +) -> Result> { + let mut set = JoinSet::new(); + for dep in dependencies { + set.spawn({ + let d = dep.clone(); + #[cfg(feature = "cli")] + let p = progress.clone(); + + let lock = locks.iter().find(|l| l.name() == dep.name()).cloned(); + let paths = deps_path.as_ref().to_path_buf(); + async move { + update_dependency( + &d, + lock.as_ref(), + &paths, + recursive_deps, + #[cfg(feature = "cli")] + p, + ) + .await + } + }); + } + + let mut results = Vec::new(); + while let Some(res) = set.join_next().await { + results.push(res??); + } + Ok(results) +} + +pub async fn update_dependency( + dependency: &Dependency, + lock: Option<&LockEntry>, + deps: impl AsRef, + recursive_deps: bool, + #[cfg(feature = "cli")] progress: Progress, +) -> Result { + match dependency { + Dependency::Git(ref dep) if dep.identifier.is_none() => { + // we handle the git case in a special way because we don't need to re-clone the repo + // update to the latest commit (git pull) + let path = match lock { + Some(lock) => lock.install_path(&deps), + None => dependency.install_path(&deps).await.unwrap_or_else(|| { + format_install_path(dependency.name(), dependency.version_req(), &deps) + }), + }; + run_git_command(&["reset", "--hard", "HEAD"], Some(&path)).await?; + run_git_command(&["clean", "-fd"], Some(&path)).await?; + let old_commit = run_git_command(&["rev-parse", "--verify", "HEAD"], Some(&path)) + .await? + .trim() + .to_string(); + run_git_command(&["pull"], Some(&path)).await?; + let commit = run_git_command(&["rev-parse", "--verify", "HEAD"], Some(&path)) + .await? + .trim() + .to_string(); + if commit != old_commit { + #[cfg(feature = "cli")] + progress.log(format!("Updating {dependency} from {old_commit:.7} to {commit:.7}")); + } + let new_lock = GitLockEntry::builder() + .name(&dep.name) + .version(&dep.version_req) + .git(&dep.git) + .rev(commit) + .build() + .into(); + #[cfg(feature = "cli")] + progress.increment_all(); + + Ok(new_lock) + } + Dependency::Git(ref dep) if dep.identifier.is_some() => { + // check integrity against the existing version since we can't update to a new rev + let lock = match lock { + Some(lock) => lock, + None => &GitLockEntry::builder() + .name(&dep.name) + .version(&dep.version_req) + .git(&dep.git) + .rev(dep.identifier.as_ref().expect("identifier should be present").to_string()) + .build() + .into(), + }; + let new_lock = install_dependency( + dependency, + Some(lock), + &deps, + None, + recursive_deps, + #[cfg(feature = "cli")] + progress, + ) + .await?; + Ok(new_lock) + } + _ => { + // for http dependencies, we simply install them as if there was no lock entry + + // to show which version we update to, we already need to know the new version, so we + // can pass it to `install_dependency` to spare us from another call to the + // registry + let force_version = match (dependency.url(), lock) { + (None, Some(lock)) => { + let new_version = get_latest_supported_version(dependency).await?; + if lock.version() != new_version { + #[cfg(feature = "cli")] + progress.log(format!( + "Updating {} from {} to {new_version}", + dependency.name(), + lock.version(), + )); + } + Some(new_version) + } + _ => None, + }; + let new_lock = install_dependency( + dependency, + None, + &deps, + force_version, + recursive_deps, + #[cfg(feature = "cli")] + progress, + ) + .await?; + Ok(new_lock) + } + } +} diff --git a/crates/core/src/utils.rs b/crates/core/src/utils.rs new file mode 100644 index 0000000..5c81bdd --- /dev/null +++ b/crates/core/src/utils.rs @@ -0,0 +1,356 @@ +use crate::{ + download::IntegrityChecksum, + errors::{DownloadError, InstallError}, +}; +use ignore::{WalkBuilder, WalkState}; +use path_slash::PathExt as _; +use regex::Regex; +use sha2::{Digest as _, Sha256}; +use std::{ + env, + ffi::OsStr, + fs, + io::Read, + path::{Path, PathBuf}, + sync::{Arc, LazyLock, Mutex}, +}; +use tokio::process::Command; + +static GIT_SSH_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:git@github\.com|git@gitlab)").expect("git ssh regex should compile") +}); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum UrlType { + Git, + Http, +} + +/// Read a file contents into a vector of bytes +pub fn read_file(path: impl AsRef) -> Result, std::io::Error> { + let f = fs::File::open(path)?; + let mut reader = std::io::BufReader::new(f); + let mut buffer = Vec::new(); + + // Read file into vector. + reader.read_to_end(&mut buffer)?; + + Ok(buffer) +} + +/// Get the location where the token file is stored or read from +/// +/// The token file is stored in the home directory of the user, or in the current directory +/// if the home cannot be found, in a hidden folder called `.soldeer`. The token file is called +/// `.soldeer_login`. +/// +/// The path can be overridden by setting the `SOLDEER_LOGIN_FILE` environment variable. +pub fn login_file_path() -> Result { + if let Ok(file_path) = env::var("SOLDEER_LOGIN_FILE") { + if !file_path.is_empty() { + return Ok(file_path.into()); + } + } + + // if home dir cannot be found, use the current dir + let dir = home::home_dir().unwrap_or(env::current_dir()?); + let security_directory = dir.join(".soldeer"); + if !security_directory.exists() { + fs::create_dir(&security_directory)?; + } + let security_file = security_directory.join(".soldeer_login"); + Ok(security_file) +} + +/// Check if any file starts with a period +pub fn check_dotfiles(files: &[PathBuf]) -> bool { + files.iter().any(|file| file.file_name().unwrap_or_default().to_string_lossy().starts_with('.')) +} + +pub fn get_url_type(dependency_url: &str) -> Result { + if GIT_SSH_REGEX.is_match(dependency_url) { + return Ok(UrlType::Git); + } else if let Ok(url) = reqwest::Url::parse(dependency_url) { + return Ok(match url.domain() { + Some("github.com" | "gitlab.com") => { + if url.path().ends_with(".git") { + UrlType::Git + } else { + UrlType::Http + } + } + _ => UrlType::Http, + }); + } + Err(DownloadError::InvalidUrl(dependency_url.to_string())) +} + +pub fn sanitize_filename(dependency_name: &str) -> String { + let options = + sanitize_filename::Options { truncate: true, windows: cfg!(windows), replacement: "-" }; + + sanitize_filename::sanitize_with_options(dependency_name, options) +} + +/// Hash the contents of a Reader with SHA256 +pub fn hash_content(content: &mut R) -> [u8; 32] { + let mut hasher = Sha256::new(); + let mut buf = [0; 1024]; + while let Ok(size) = content.read(&mut buf) { + if size == 0 { + break; + } + hasher.update(&buf[0..size]); + } + hasher.finalize().into() +} + +/// Walk a folder and compute the SHA256 hash of all non-hidden and non-gitignored files inside the +/// dir, combining them into a single hash. +/// +/// We hash the name of the folders and files too, so we can check the integrity of their names. +pub fn hash_folder(folder_path: impl AsRef) -> Result { + // a list of hashes, one for each DirEntry + let all_hashes = Arc::new(Mutex::new(Vec::with_capacity(100))); + let root_path = Arc::new(dunce::canonicalize(folder_path.as_ref())?); + // we use a parallel walker to speed things up + let walker = WalkBuilder::new(folder_path) + .filter_entry(|entry| { + !(entry.path().is_dir() && entry.path().file_name().unwrap_or_default() == ".git") + }) + .hidden(false) + .build_parallel(); + walker.run(|| { + let all_hashes = Arc::clone(&all_hashes); + let root_path = Arc::clone(&root_path); + // function executed for each DirEntry + Box::new(move |result| { + let Ok(entry) = result else { + return WalkState::Continue; + }; + let path = entry.path(); + // first hash the filename/dirname to make sure it can't be renamed or removed + let mut hasher = Sha256::new(); + hasher.update( + path.strip_prefix(root_path.as_ref()) + .expect("path should be a child of root") + .to_slash_lossy() + .as_bytes(), + ); + // for files, also hash the contents + if let Some(true) = entry.file_type().map(|t| t.is_file()) { + if let Ok(file) = fs::File::open(path) { + let mut reader = std::io::BufReader::new(file); + let hash = hash_content(&mut reader); + hasher.update(hash); + } + } + // record the hash for that file/folder in the list + let hash: [u8; 32] = hasher.finalize().into(); + let mut hashes_lock = all_hashes.lock().expect("mutex should not be poisoned"); + hashes_lock.push(hash); + WalkState::Continue + }) + }); + + // sort hashes + let mut hasher = Sha256::new(); + let mut all_hashes = all_hashes.lock().expect("mutex should not be poisoned"); + all_hashes.sort_unstable(); + // hash the hashes (yo dawg...) + for hash in all_hashes.iter() { + hasher.update(hash); + } + let hash: [u8; 32] = hasher.finalize().into(); + Ok(const_hex::encode(hash).into()) +} + +/// Compute the SHA256 hash of the contents of a file +pub fn hash_file(path: impl AsRef) -> Result { + let file = fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + let bytes = hash_content(&mut reader); + Ok(const_hex::encode(bytes).into()) +} + +pub async fn run_git_command( + args: I, + current_dir: Option<&PathBuf>, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut git = Command::new("git"); + git.args(args).env("GIT_TERMINAL_PROMPT", "0"); + if let Some(current_dir) = current_dir { + git.current_dir( + canonicalize(current_dir) + .await + .map_err(|e| DownloadError::IOError { path: current_dir.clone(), source: e })?, + ); + } + let git = git.output().await.map_err(|e| DownloadError::GitError(e.to_string()))?; + if !git.status.success() { + return Err(DownloadError::GitError(String::from_utf8(git.stderr).unwrap_or_default())) + } + Ok(String::from_utf8(git.stdout).expect("git command output should be valid utf-8")) +} + +pub async fn run_forge_command( + args: I, + current_dir: Option<&PathBuf>, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut forge = Command::new("forge"); + forge.args(args); + if let Some(current_dir) = current_dir { + forge.current_dir( + canonicalize(current_dir) + .await + .map_err(|e| InstallError::IOError { path: current_dir.clone(), source: e })?, + ); + } + let forge = forge.output().await.map_err(|e| InstallError::ForgeError(e.to_string()))?; + if !forge.status.success() { + return Err(InstallError::ForgeError(String::from_utf8(forge.stderr).unwrap_or_default())) + } + Ok(String::from_utf8(forge.stdout).expect("forge command output should be valid utf-8")) +} + +pub async fn remove_forge_lib(root: impl AsRef) -> Result<(), InstallError> { + let gitmodules_path = root.as_ref().join(".gitmodules"); + let lib_dir = root.as_ref().join("lib"); + let forge_std_dir = lib_dir.join("forge-std"); + run_git_command(&["rm", &forge_std_dir.to_string_lossy()], None).await?; + if lib_dir.exists() { + fs::remove_dir_all(&lib_dir) + .map_err(|e| InstallError::IOError { path: lib_dir.clone(), source: e })?; + } + if gitmodules_path.exists() { + fs::remove_file(&gitmodules_path) + .map_err(|e| InstallError::IOError { path: lib_dir, source: e })?; + } + Ok(()) +} + +pub async fn canonicalize(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + tokio::task::spawn_blocking(move || dunce::canonicalize(&path)).await? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use testdir::testdir; + + fn create_test_folder(name: Option<&str>) -> PathBuf { + let dir = testdir!(); + let named_dir = match name { + None => dir, + Some(name) => { + let d = dir.join(name); + fs::create_dir(&d).unwrap(); + d + } + }; + fs::write(named_dir.join("a.txt"), "this is a test file").unwrap(); + fs::write(named_dir.join("b.txt"), "this is a second test file").unwrap(); + dunce::canonicalize(named_dir).unwrap() + } + + #[test] + fn test_hash_content() { + let mut content = "this is a test file".as_bytes(); + let hash = hash_content(&mut content); + assert_eq!( + const_hex::encode(hash), + "5881707e54b0112f901bc83a1ffbacac8fab74ea46a6f706a3efc5f7d4c1c625".to_string() + ); + } + + #[test] + fn test_hash_content_content_sensitive() { + let mut content = "foobar".as_bytes(); + let hash = hash_content(&mut content); + let mut content2 = "baz".as_bytes(); + let hash2 = hash_content(&mut content2); + assert_ne!(hash, hash2); + } + + #[test] + fn test_hash_file() { + let path = testdir!().join("test.txt"); + fs::write(&path, "this is a test file").unwrap(); + let hash = hash_file(&path).unwrap(); + assert_eq!(hash, "5881707e54b0112f901bc83a1ffbacac8fab74ea46a6f706a3efc5f7d4c1c625".into()); + } + + #[test] + fn test_hash_folder_abs_path_insensitive() { + let folder1 = create_test_folder(Some("dir1")); + let folder2 = create_test_folder(Some("dir2")); + let hash1 = hash_folder(&folder1).unwrap(); + let hash2 = hash_folder(&folder2).unwrap(); + assert_eq!( + hash1.to_string(), + "4671014a36f223796de8760df8125ca6e5a749e162dd5690e815132621dd8bfb" + ); + assert_eq!(hash1, hash2); + } + + #[test] + fn test_hash_folder_rel_path_sensitive() { + let folder = create_test_folder(Some("dir")); + let hash1 = hash_folder(&folder).unwrap(); + fs::rename(folder.join("a.txt"), folder.join("c.txt")).unwrap(); + let hash2 = hash_folder(&folder).unwrap(); + assert_ne!(hash1, hash2); + } + + #[test] + fn test_hash_folder_content_sensitive() { + let folder = create_test_folder(Some("dir")); + let hash1 = hash_folder(&folder).unwrap(); + fs::create_dir(folder.join("test")).unwrap(); + let hash2 = hash_folder(&folder).unwrap(); + assert_ne!(hash1, hash2); + fs::write(folder.join("test/c.txt"), "this is a third test file").unwrap(); + let hash3 = hash_folder(&folder).unwrap(); + assert_ne!(hash2, hash3); + assert_ne!(hash1, hash3); + } + + #[test] + fn test_url_type_http() { + assert_eq!( + get_url_type("https://github.com/foundry-rs/forge-std/archive/refs/tags/v1.9.1.zip") + .unwrap(), + UrlType::Http + ); + } + + #[test] + fn test_get_url_git_ssh() { + assert_eq!(get_url_type("git@github.com:foundry-rs/forge-std.git").unwrap(), UrlType::Git); + assert_eq!(get_url_type("git@gitlab.com:foo/bar.git").unwrap(), UrlType::Git); + } + + #[test] + fn test_get_url_git_https() { + assert_eq!( + get_url_type("https://github.com/foundry-rs/forge-std.git").unwrap(), + UrlType::Git + ); + assert_eq!( + get_url_type("https://user:pass@github.com/foundry-rs/forge-std.git").unwrap(), + UrlType::Git + ); + assert_eq!(get_url_type("https://gitlab.com/foo/bar.git").unwrap(), UrlType::Git); + } +} diff --git a/foundry.toml b/foundry.toml deleted file mode 100644 index b9d13b5..0000000 --- a/foundry.toml +++ /dev/null @@ -1,13 +0,0 @@ - -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies","libs"] - -[dependencies] -forge-std = "1.9.1" - diff --git a/soldeer.toml b/soldeer.toml deleted file mode 100644 index a1a2186..0000000 --- a/soldeer.toml +++ /dev/null @@ -1,5 +0,0 @@ - -[remappings] -enabled = true - -[dependencies] diff --git a/src/.soldeerignore b/src/.soldeerignore deleted file mode 100644 index 496ee2c..0000000 --- a/src/.soldeerignore +++ /dev/null @@ -1 +0,0 @@ -.DS_Store \ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index 21f20de..0000000 --- a/src/auth.rs +++ /dev/null @@ -1,225 +0,0 @@ -use crate::{ - errors::AuthError, - utils::{define_security_file_location, get_base_url, read_file}, -}; -use email_address_parser::{EmailAddress, ParsingOptions}; -use reqwest::{Client, StatusCode}; -use rpassword::read_password; -use serde::{Deserialize, Serialize}; -use std::{ - fs::OpenOptions, - io::{self, Write}, -}; -use yansi::Paint as _; - -pub type Result = std::result::Result; - -#[derive(Debug, Serialize, Deserialize)] -pub struct Login { - pub email: String, - pub password: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginResponse { - pub status: String, - pub token: String, -} - -pub async fn login() -> Result<()> { - print!("ℹ️ If you do not have an account, please go to soldeer.xyz to create one.\n📧 Please enter your email: "); - std::io::stdout().flush().unwrap(); - let mut email = String::new(); - if io::stdin().read_line(&mut email).is_err() { - return Err(AuthError::InvalidEmail); - } - email = match check_email(email) { - Ok(e) => e, - Err(err) => return Err(err), - }; - - print!("🔓 Please enter your password: "); - std::io::stdout().flush().unwrap(); - let password = read_password().unwrap(); - - let login: Login = Login { email, password }; - - execute_login(login).await.unwrap(); - Ok(()) -} - -pub fn get_token() -> Result { - let security_file = define_security_file_location()?; - let jwt = read_file(security_file); - match jwt { - Ok(token) => Ok(String::from_utf8(token) - .expect("You are not logged in. Please login using the 'soldeer login' command")), - Err(_) => Err(AuthError::MissingToken), - } -} - -fn check_email(email_str: String) -> Result { - let email_str = email_str.trim().to_string().to_ascii_lowercase(); - - let email: Option = - EmailAddress::parse(&email_str, Some(ParsingOptions::default())); - if email.is_none() { - Err(AuthError::InvalidEmail) - } else { - Ok(email_str) - } -} - -async fn execute_login(login: Login) -> Result<()> { - let url = format!("{}/api/v1/auth/login", get_base_url()); - let req = Client::new().post(url).json(&login); - - let login_response = req.send().await; - - let security_file = define_security_file_location()?; - let response = login_response?; - - match response.status() { - s if s.is_success() => { - println!("{}", "Login successful".green()); - let jwt = serde_json::from_str::(&response.text().await.unwrap()) - .unwrap() - .token; - let mut file: std::fs::File = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .append(false) - .open(&security_file) - .unwrap(); - write!(file, "{}", &jwt)?; - println!("{}", format!("Login details saved in: {:?}", &security_file).green()); - Ok(()) - } - StatusCode::UNAUTHORIZED => Err(AuthError::InvalidCredentials), - _ => Err(AuthError::HttpError(response.error_for_status().unwrap_err())), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::utils::read_file_to_string; - use serial_test::serial; - use std::{env, fs::remove_file}; - - #[test] - #[serial] - fn email_validation() { - let valid_email = String::from("test@test.com"); - let invalid_email = String::from("test"); - - assert_eq!(check_email(valid_email.clone()).unwrap(), valid_email); - - assert!(matches!(check_email(invalid_email), Err(AuthError::InvalidEmail))); - } - - #[tokio::test] - #[serial] - async fn login_success() { - let data = r#" - { - "status": "200", - "token": "jwt_token_example" - }"#; - - // Request a new server from the pool - let mut server = mockito::Server::new_async().await; - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", format!("http://{}", server.host_with_port())); - } - - // Create a mock - let _ = server - .mock("POST", "/api/v1/auth/login") - .with_status(201) - .with_header("content-type", "application/json") - .with_body(data) - .create(); - - match execute_login(Login { - email: "test@test.com".to_string(), - password: "1234".to_string(), - }) - .await - { - Ok(_) => { - let results = read_file_to_string("./test_save_jwt"); - assert_eq!(results, "jwt_token_example"); - let _ = remove_file("./test_save_jwt"); - } - Err(_) => { - assert_eq!("Invalid State", ""); - } - }; - } - - #[tokio::test] - #[serial] - async fn login_401() { - let mut server = mockito::Server::new_async().await; - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", format!("http://{}", server.host_with_port())); - } - - let data = r#" - { - "status": "401", - }"#; - - let _ = server - .mock("POST", "/api/v1/auth/login") - .with_status(401) - .with_header("content-type", "application/json") - .with_body(data) - .create(); - - assert!(matches!( - execute_login(Login { - email: "test@test.com".to_string(), - password: "1234".to_string(), - }) - .await, - Err(AuthError::InvalidCredentials) - )); - } - - #[tokio::test] - #[serial] - async fn login_500() { - let mut server = mockito::Server::new_async().await; - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", format!("http://{}", server.host_with_port())); - } - - let data = r#" - { - "status": "401", - }"#; - - let _ = server - .mock("POST", "/api/v1/auth/login") - .with_status(500) - .with_header("content-type", "application/json") - .with_body(data) - .create(); - - assert!(matches!( - execute_login(Login { - email: "test@test.com".to_string(), - password: "1234".to_string(), - }) - .await, - Err(AuthError::HttpError(_)) - )); - } -} diff --git a/src/commands.rs b/src/commands.rs deleted file mode 100644 index 2034cab..0000000 --- a/src/commands.rs +++ /dev/null @@ -1,159 +0,0 @@ -use clap::{Parser, Subcommand}; -use std::path::PathBuf; - -/// A minimal solidity dependency manager. -#[derive(Parser, Debug)] -#[clap(name = "soldeer", author = "m4rio.eth", version)] -pub struct Args { - #[clap(subcommand)] - pub command: Subcommands, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum Subcommands { - Init(Init), - Install(Install), - Update(Update), - Login(Login), - Push(Push), - Uninstall(Uninstall), - Version(Version), -} - -/// Initialize a new Soldeer project for use with Foundry -#[derive(Debug, Clone, Parser)] -#[clap(after_help = "For more information, read the README.md")] -pub struct Init { - /// Clean the Foundry project by removing .gitmodules and the lib directory - #[arg(long, default_value_t = false)] - pub clean: bool, -} - -fn validate_dependency(dep: &str) -> Result { - if dep.split('~').count() != 2 { - return Err("The dependency should be in the format ~".to_string()); - } - Ok(dep.to_string()) -} - -/// Install a dependency -#[derive(Debug, Clone, Parser)] -#[clap( - long_about = "Install a dependency - -You can install a dependency from the Soldeer repository, a custom URL pointing to a zip file, or from Git using a Git link. -**Important:** The `~` symbol when specifying the dependency is crucial to differentiate between the name and the version that needs to be installed. -- **Example from Soldeer repository:** - soldeer install @openzeppelin-contracts~2.3.0 -- **Example from a custom URL:** - soldeer install @openzeppelin-contracts~2.3.0 https://github.com/OpenZeppelin/openzeppelin-contracts/archive/refs/tags/v5.0.2.zip -- **Example from Git:** - soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git -- **Example from Git with a specified commit:** - soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git --rev 05f218fb6617932e56bf5388c3b389c3028a7b73 -- **Example from Git with a specified tag:** - soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git --tag my-tag -- **Example from Git with a specified branch:** - soldeer install @openzeppelin-contracts~2.3.0 git@github.com:OpenZeppelin/openzeppelin-contracts.git --branch my-branch", - after_help = "For more information, read the README.md" -)] -pub struct Install { - /// The dependency name and version, separated by a tilde. - /// - /// If not present, this command will perform `soldeer update` - #[arg(value_parser = validate_dependency, value_name = "DEPENDENCY~VERSION")] - pub dependency: Option, - - /// The URL to the dependency zip file, if not from the Soldeer repository - /// - /// Example: https://my-domain/dep.zip - #[arg(value_name = "URL", requires = "dependency")] - pub remote_url: Option, - - /// A Git revision - #[arg(long, group = "identifier", requires = "remote_url")] - pub rev: Option, - - /// A Git tag - #[arg(long, group = "identifier", requires = "remote_url")] - pub tag: Option, - - /// A Git branch - #[arg(long, group = "identifier", requires = "remote_url")] - pub branch: Option, - - /// If set, this command will delete the existing remappings and re-create them - #[arg(short = 'g', long, default_value_t = false)] - pub regenerate_remappings: bool, - - /// If set, this command will install the recursive dependencies (via submodules or via - /// soldeer) - #[arg(short = 'd', long, default_value_t = false)] - pub recursive_deps: bool, -} - -/// Update dependencies by reading the config file -#[derive(Debug, Clone, Parser)] -#[clap(after_help = "For more information, read the README.md")] -pub struct Update { - /// If set, this command will delete the existing remappings and re-create them - #[arg(short = 'g', long, default_value_t = false)] - pub regenerate_remappings: bool, - - /// If set, this command will install the recursive dependencies (via submodules or via - /// soldeer) - #[arg(short = 'd', long, default_value_t = false)] - pub recursive_deps: bool, -} - -/// Display the version of Soldeer -#[derive(Debug, Clone, Parser)] -pub struct Version {} - -/// Log into the central repository to push the dependencies -#[derive(Debug, Clone, Parser)] -#[clap(after_help = "For more information, read the README.md")] -pub struct Login {} - -/// Push a dependency to the repository -#[derive(Debug, Clone, Parser)] -#[clap( - long_about = "Push a Dependency to the Repository -The `PATH_TO_DEPENDENCY` is optional. If not provided, the current directory will be used. -**Example:** -- If the current directory is `/home/soldeer/my_project` and you do not specify the `PATH_TO_DEPENDENCY`, the files inside `/home/soldeer/my_project` will be pushed to the repository. -- If you specify the `PATH_TO_DEPENDENCY`, the files inside the specified directory will be pushed to the repository. -To ignore certain files, create a `.soldeerignore` file in the root of the project and add the files you want to ignore. The `.soldeerignore` works like a `.gitignore`. -For a dry run, use the `--dry-run` argument set to `true`: `soldeer push ... --dry-run true`. This will create a zip file that you can inspect to see what will be pushed to the central repository.", - after_help = "For more information, read the README.md" -)] -pub struct Push { - /// The dependency name and version, separated by a tilde. - /// - /// This should always be used when you want to push a dependency to the central repository: ``. - #[arg(value_parser = validate_dependency, value_name = "DEPENDENCY>~, - - /// Use this if you want to run a dry run. If set, this will generate a zip file that you can - /// inspect to see what will be pushed. - #[arg(short, long, default_value_t = false)] - pub dry_run: bool, - - /// Use this if you want to skip the warnings that can be triggered when trying to push - /// dotfiles like .env. - #[arg(long, default_value_t = false)] - pub skip_warnings: bool, -} - -/// Uninstall a dependency -#[derive(Debug, Clone, Parser)] -#[clap(after_help = "For more information, read the README.md")] -pub struct Uninstall { - /// The dependency name. Specifying a version is not necessary. - pub dependency: String, -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 1509d59..0000000 --- a/src/config.rs +++ /dev/null @@ -1,2473 +0,0 @@ -use crate::{ - errors::ConfigError, - utils::{get_current_working_dir, read_file_to_string, sanitize_dependency_name}, - FOUNDRY_CONFIG_FILE, SOLDEER_CONFIG_FILE, -}; -use serde::{Deserialize, Serialize}; -use std::{ - env, fmt, - fs::{self, remove_dir_all, remove_file, File}, - io::{self, Write}, - path::{Path, PathBuf}, -}; -use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table}; -use yansi::Paint as _; - -pub type Result = std::result::Result; - -/// Location where to store the remappings, either in `remappings.txt` or the config file -/// (foundry/soldeer) -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Default)] -#[serde(rename_all = "lowercase")] -pub enum RemappingsLocation { - #[default] - Txt, - Config, -} - -fn default_true() -> bool { - true -} - -/// The Soldeer config options -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct SoldeerConfig { - #[serde(default = "default_true")] - pub remappings_generate: bool, - - #[serde(default)] - pub remappings_regenerate: bool, - - #[serde(default = "default_true")] - pub remappings_version: bool, - - #[serde(default)] - pub remappings_prefix: String, - - #[serde(default)] - pub remappings_location: RemappingsLocation, - - #[serde(default)] - pub recursive_deps: bool, -} - -impl Default for SoldeerConfig { - fn default() -> Self { - SoldeerConfig { - remappings_generate: true, - remappings_regenerate: false, - remappings_version: true, - remappings_prefix: String::new(), - remappings_location: Default::default(), - recursive_deps: false, - } - } -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub enum GitIdentifier { - Rev(String), - Branch(String), - Tag(String), -} - -impl GitIdentifier { - pub fn from_rev(rev: impl Into) -> Self { - let rev: String = rev.into(); - GitIdentifier::Rev(rev) - } - - pub fn from_branch(branch: impl Into) -> Self { - let branch: String = branch.into(); - GitIdentifier::Branch(branch) - } - - pub fn from_tag(tag: impl Into) -> Self { - let tag: String = tag.into(); - GitIdentifier::Tag(tag) - } -} - -impl fmt::Display for GitIdentifier { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let val = match self { - GitIdentifier::Rev(rev) => rev, - GitIdentifier::Branch(branch) => branch, - GitIdentifier::Tag(tag) => tag, - }; - write!(f, "{val}") - } -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct GitDependency { - pub name: String, - pub version: String, - pub git: String, - pub identifier: Option, -} - -impl fmt::Display for GitDependency { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}~{}", self.name, self.version) - } -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct HttpDependency { - pub name: String, - pub version: String, - pub url: Option, - pub checksum: Option, -} - -impl fmt::Display for HttpDependency { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}~{}", self.name, self.version) - } -} - -// Dependency object used to store a dependency data -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub enum Dependency { - Http(HttpDependency), - Git(GitDependency), -} - -impl Dependency { - pub fn name(&self) -> &str { - match self { - Dependency::Http(dep) => &dep.name, - Dependency::Git(dep) => &dep.name, - } - } - - pub fn version(&self) -> &str { - match self { - Dependency::Http(dep) => &dep.version, - Dependency::Git(dep) => &dep.version, - } - } - - #[allow(dead_code)] - pub fn url(&self) -> Option<&String> { - match self { - Dependency::Http(dep) => dep.url.as_ref(), - Dependency::Git(dep) => Some(&dep.git), - } - } - - pub fn to_toml_value(&self) -> (String, Item) { - match self { - Dependency::Http(dep) => ( - dep.name.clone(), - match &dep.url { - Some(url) => { - let mut table = InlineTable::new(); - table.insert( - "version", - value(&dep.version) - .into_value() - .expect("version should be a valid toml value"), - ); - table.insert( - "url", - value(url).into_value().expect("url should be a valid toml value"), - ); - value(table) - } - None => value(&dep.version), - }, - ), - Dependency::Git(dep) => { - let mut table = InlineTable::new(); - table.insert( - "version", - value(&dep.version).into_value().expect("version should be a valid toml value"), - ); - table.insert( - "git", - value(&dep.git).into_value().expect("git URL should be a valid toml value"), - ); - - match &dep.identifier { - Some(GitIdentifier::Rev(rev)) => { - table.insert( - "rev", - value(rev).into_value().expect("rev should be a valid toml value"), - ); - } - Some(GitIdentifier::Branch(branch)) => { - table.insert( - "branch", - value(branch) - .into_value() - .expect("branch should be a valid toml value"), - ); - } - Some(GitIdentifier::Tag(tag)) => { - table.insert( - "tag", - value(tag).into_value().expect("tag should be a valid toml value"), - ); - } - None => {} - } - - (dep.name.clone(), value(table)) - } - } - } - - #[allow(dead_code)] - pub fn as_http(&self) -> Option<&HttpDependency> { - if let Self::Http(v) = self { - Some(v) - } else { - None - } - } - - #[allow(dead_code)] - pub fn as_git(&self) -> Option<&GitDependency> { - if let Self::Git(v) = self { - Some(v) - } else { - None - } - } -} - -impl fmt::Display for Dependency { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Dependency::Http(dep) => write!(f, "{}", dep), - Dependency::Git(dep) => write!(f, "{}", dep), - } - } -} - -impl From for Dependency { - fn from(dep: HttpDependency) -> Self { - Dependency::Http(dep) - } -} - -impl From for Dependency { - fn from(dep: GitDependency) -> Self { - Dependency::Git(dep) - } -} - -pub fn get_config_path() -> Result { - let foundry_path: PathBuf = if cfg!(test) { - env::var("config_file").map(|s| s.into()).unwrap_or(FOUNDRY_CONFIG_FILE.clone()) - } else { - FOUNDRY_CONFIG_FILE.clone() - }; - - if let Ok(contents) = fs::read_to_string(&foundry_path) { - let doc: DocumentMut = contents.parse::()?; - if doc.contains_table("dependencies") { - return Ok(foundry_path); - } - } - - let soldeer_path = SOLDEER_CONFIG_FILE.clone(); - match fs::metadata(&soldeer_path) { - Ok(_) => Ok(soldeer_path), - Err(_) => { - println!("{}", "No config file found. If you wish to proceed, please select how you want Soldeer to be configured:\n1. Using foundry.toml\n2. Using soldeer.toml\n(Press 1 or 2), default is foundry.toml".blue()); - std::io::stdout().flush().unwrap(); - let mut option = String::new(); - io::stdin() - .read_line(&mut option) - .map_err(|e| ConfigError::PromptError { source: e })?; - - if option.is_empty() { - option = "1".to_string(); - } - create_example_config(&option) - } - } -} - -/// Read the list of dependencies from the config file -/// -/// If no config file path is provided, then the path is inferred automatically -/// The returned list is sorted by name and version -pub fn read_config_deps(path: Option) -> Result> { - let path: PathBuf = match path { - Some(p) => p, - None => get_config_path()?, - }; - let contents = read_file_to_string(&path); - let doc: DocumentMut = contents.parse::()?; - let Some(Some(data)) = doc.get("dependencies").map(|v| v.as_table()) else { - return Err(ConfigError::MissingDependencies); - }; - - let mut dependencies: Vec = Vec::new(); - for (name, v) in data { - dependencies.push(parse_dependency(name, v)?); - } - dependencies - .sort_unstable_by(|a, b| a.name().cmp(b.name()).then_with(|| a.version().cmp(b.version()))); - - Ok(dependencies) -} - -pub fn read_soldeer_config(path: Option) -> Result { - let path: PathBuf = match path { - Some(p) => p, - None => get_config_path()?, - }; - let contents = read_file_to_string(&path); - - #[derive(Deserialize)] - struct SoldeerConfigParsed { - #[serde(default)] - soldeer: SoldeerConfig, - } - let config: SoldeerConfigParsed = toml_edit::de::from_str(&contents)?; - - Ok(config.soldeer) -} - -pub fn add_to_config(dependency: &Dependency, config_path: impl AsRef) -> Result<()> { - println!( - "{}", - format!( - "Adding dependency {}-{} to the config file", - dependency.name(), - dependency.version() - ) - .green() - ); - - let contents = read_file_to_string(&config_path); - let mut doc: DocumentMut = contents.parse::()?; - - // in case we don't have the dependencies section defined in the config file, we add it - if !doc.contains_table("dependencies") { - doc.insert("dependencies", Item::Table(Table::default())); - } - - let (name, value) = dependency.to_toml_value(); - doc["dependencies"] - .as_table_mut() - .expect("dependencies should be a table") - .insert(&name, value); - - fs::write(config_path, doc.to_string())?; - - Ok(()) -} - -#[derive(Debug, Clone, PartialEq)] -pub enum RemappingsAction { - Add(Dependency), - Remove(Dependency), - None, -} - -pub async fn remappings_txt( - action: &RemappingsAction, - config_path: impl AsRef, - soldeer_config: &SoldeerConfig, -) -> Result<()> { - let remappings_path = get_current_working_dir().join("remappings.txt"); - if soldeer_config.remappings_regenerate && remappings_path.exists() { - remove_file(&remappings_path).map_err(ConfigError::RemappingsError)?; - } - let contents = match remappings_path.exists() { - true => read_file_to_string(&remappings_path), - false => String::new(), - }; - let existing_remappings = contents.lines().filter_map(|r| r.split_once('=')).collect(); - - if !remappings_path.exists() { - File::create(remappings_path.clone()).unwrap(); - } - - let new_remappings = - generate_remappings(action, config_path, soldeer_config, existing_remappings)?; - - let mut file = File::create(remappings_path)?; - for remapping in new_remappings { - writeln!(file, "{}", remapping)?; - } - Ok(()) -} - -pub async fn remappings_foundry( - action: &RemappingsAction, - config_path: impl AsRef, - soldeer_config: &SoldeerConfig, -) -> Result<()> { - let contents = read_file_to_string(&config_path); - let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); - - let Some(profiles) = doc["profile"].as_table_mut() else { - // we don't add remappings if there are no profiles - return Ok(()); - }; - - for (name, profile) in profiles.iter_mut() { - // we normally only edit remappings of profiles which already have a remappings key - let Some(Some(remappings)) = profile.get_mut("remappings").map(|v| v.as_array_mut()) else { - // except the default profile, where we always add the remappings - if name == "default" { - let new_remappings = - generate_remappings(action, &config_path, soldeer_config, vec![])?; - let array = Array::from_iter(new_remappings.into_iter()); - profile["remappings"] = value(array); - } - continue; - }; - let existing_remappings: Vec<_> = remappings - .iter() - .filter_map(|r| r.as_str()) - .filter_map(|r| r.split_once('=')) - .collect(); - let new_remappings = - generate_remappings(action, &config_path, soldeer_config, existing_remappings)?; - remappings.clear(); - for remapping in new_remappings { - remappings.push(remapping); - } - } - - fs::write(config_path, doc.to_string())?; - Ok(()) -} - -pub fn delete_config(dependency_name: &str, path: impl AsRef) -> Result { - println!( - "{}", - format!("Removing the dependency {dependency_name} from the config file").green() - ); - - let contents = read_file_to_string(&path); - let mut doc: DocumentMut = contents.parse::().expect("invalid doc"); - - let Some(item_removed) = doc["dependencies"].as_table_mut().unwrap().remove(dependency_name) - else { - return Err(ConfigError::MissingDependency(dependency_name.to_string())); - }; - - let dependency = parse_dependency(dependency_name, &item_removed)?; - - fs::write(path, doc.to_string())?; - - Ok(dependency) -} - -pub fn remove_forge_lib() -> Result<()> { - let lib_dir = get_current_working_dir().join("lib/"); - let gitmodules_file = get_current_working_dir().join(".gitmodules"); - - let _ = remove_file(gitmodules_file); - let _ = remove_dir_all(lib_dir); - Ok(()) -} - -fn parse_dependency(name: impl Into, value: &Item) -> Result { - let name: String = name.into(); - if let Some(version) = value.as_str() { - if version.is_empty() { - return Err(ConfigError::EmptyVersion(name)); - } - // this function does not retrieve the url - return Ok( - HttpDependency { name, version: version.to_string(), url: None, checksum: None }.into() - ); - } - - // we should have a table or inline table - let table = { - match value.as_inline_table() { - Some(table) => table, - None => match value.as_table() { - // we normalize to inline table - Some(table) => &table.clone().into_inline_table(), - None => { - return Err(ConfigError::InvalidDependency(name)); - } - }, - } - }; - - // version is needed in both cases - let version = match table.get("version").map(|v| v.as_str()) { - Some(None) => { - return Err(ConfigError::InvalidField { field: "version".to_string(), dep: name }); - } - None => { - return Err(ConfigError::MissingField { field: "version".to_string(), dep: name }); - } - Some(Some(version)) => version.to_string(), - }; - - // check if it's a git dependency - match table.get("git").map(|v| v.as_str()) { - Some(None) => { - return Err(ConfigError::InvalidField { field: "git".to_string(), dep: name }); - } - Some(Some(git)) => { - // rev/branch/tag fields are optional but need to be a string if present - let rev = match table.get("rev").map(|v| v.as_str()) { - Some(Some(rev)) => Some(rev.to_string()), - Some(None) => { - return Err(ConfigError::InvalidField { field: "rev".to_string(), dep: name }); - } - None => None, - }; - let branch = match table.get("branch").map(|v| v.as_str()) { - Some(Some(tag)) => Some(tag.to_string()), - Some(None) => { - return Err(ConfigError::InvalidField { - field: "branch".to_string(), - dep: name, - }); - } - None => None, - }; - let tag = match table.get("tag").map(|v| v.as_str()) { - Some(Some(tag)) => Some(tag.to_string()), - Some(None) => { - return Err(ConfigError::InvalidField { field: "tag".to_string(), dep: name }); - } - None => None, - }; - let identifier = match (rev, branch, tag) { - (Some(rev), None, None) => Some(GitIdentifier::from_rev(rev)), - (None, Some(branch), None) => Some(GitIdentifier::from_branch(branch)), - (None, None, Some(tag)) => Some(GitIdentifier::from_tag(tag)), - (None, None, None) => None, - _ => { - return Err(ConfigError::GitIdentifierConflict(name)); - } - }; - return Ok(Dependency::Git(GitDependency { - name, - git: git.to_string(), - version, - identifier, - })); - } - None => {} - } - - // we should have a HTTP dependency - match table.get("url").map(|v| v.as_str()) { - Some(None) => Err(ConfigError::InvalidField { field: "url".to_string(), dep: name }), - None => Ok(Dependency::Http(HttpDependency { name, version, url: None, checksum: None })), - Some(Some(url)) => Ok(Dependency::Http(HttpDependency { - name, - version, - url: Some(url.to_string()), - checksum: None, - })), - } -} - -fn remappings_from_deps( - config_path: impl AsRef, - soldeer_config: &SoldeerConfig, -) -> Result> { - let config_path = config_path.as_ref().to_path_buf(); - let dependencies = read_config_deps(Some(config_path))?; - Ok(dependencies - .iter() - .map(|dependency| { - let dependency_name_formatted = format_remap_name(soldeer_config, dependency); - format!( - "{dependency_name_formatted}=dependencies/{}-{}/", - dependency.name(), - dependency.version() - ) - }) - .collect()) -} - -fn generate_remappings( - action: &RemappingsAction, - config_path: impl AsRef, - soldeer_config: &SoldeerConfig, - existing_remappings: Vec<(&str, &str)>, -) -> Result> { - let mut new_remappings = Vec::new(); - if soldeer_config.remappings_regenerate { - new_remappings = remappings_from_deps(config_path, soldeer_config)?; - println!("{}", "Added all dependencies to remapppings".green()); - } else { - match &action { - RemappingsAction::Remove(remove_dep) => { - // only keep items not matching the dependency to remove - let sanitized_name = sanitize_dependency_name(&format!( - "{}-{}", - remove_dep.name(), - remove_dep.version() - )); - let remove_dep_orig = format!("dependencies/{sanitized_name}/"); - for (existing_remapped, existing_og) in existing_remappings { - if !existing_og - .trim_end_matches('/') - .starts_with(remove_dep_orig.trim_end_matches('/')) - { - new_remappings.push(format!("{existing_remapped}={existing_og}")); - } else { - println!("{}", format!("Removed {remove_dep} from remappings").green()); - } - } - } - RemappingsAction::Add(add_dep) => { - // we only add the remapping if it's not already existing, otherwise we keep the old - // remapping - let add_dep_remapped = format_remap_name(soldeer_config, add_dep); - let sanitized_name = - sanitize_dependency_name(&format!("{}-{}", add_dep.name(), add_dep.version())); - let add_dep_og = format!("dependencies/{}/", sanitized_name); - let mut found = false; // whether a remapping existed for that dep already - for (existing_remapped, existing_og) in existing_remappings { - new_remappings.push(format!("{existing_remapped}={existing_og}")); - if existing_og - .trim_end_matches('/') - .starts_with(add_dep_og.trim_end_matches('/')) - { - found = true; - } - } - if !found { - new_remappings.push(format!("{add_dep_remapped}={add_dep_og}")); - println!("{}", format!("Added {add_dep} to remappings").green()); - } - } - RemappingsAction::None => { - // This is where we end up in the `update` command if we don't want to re-generate - // all remappings. We need to merge existing remappings with the full list of deps. - // We generate all remappings from the dependencies, then replace existing items. - new_remappings = remappings_from_deps(config_path, soldeer_config)?; - if !existing_remappings.is_empty() { - for item in new_remappings.iter_mut() { - let (item_remapped, item_og) = - item.split_once('=').expect("remappings should have two parts"); - // try to find an existing item with the same path - if let Some((existing_remapped, existing_og)) = - existing_remappings.iter().find(|(_, og)| { - // if the existing remapping path starts with the dependency folder, - // we found a match - og.trim_end_matches('/').starts_with(item_og.trim_end_matches('/')) - }) - { - *item = format!("{existing_remapped}={existing_og}"); - } else { - println!( - "{}", - format!( - "Added {} to remappings", - item_remapped.trim_end_matches('/') - ) - .green() - ); - } - } - } - } - } - } - - // sort the remappings - new_remappings.sort_unstable(); - Ok(new_remappings) -} - -fn format_remap_name(soldeer_config: &SoldeerConfig, dependency: &Dependency) -> String { - let version_suffix = - if soldeer_config.remappings_version { &format!("-{}", dependency.version()) } else { "" }; - format!("{}{}{}/", soldeer_config.remappings_prefix, dependency.name(), version_suffix) -} - -fn create_example_config(option: &str) -> Result { - if option.trim() == "1" && FOUNDRY_CONFIG_FILE.exists() { - return Ok(FOUNDRY_CONFIG_FILE.clone()); - } - let (config_path, contents) = match option.trim() { - "1" => ( - FOUNDRY_CONFIG_FILE.clone(), - r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#, - ), - "2" => ( - SOLDEER_CONFIG_FILE.clone(), - r#" -[remappings] -enabled = true - -[dependencies] -"#, - ), - _ => { - return Err(ConfigError::InvalidPromptOption); - } - }; - - fs::write(&config_path, contents)?; - Ok(config_path) -} - -////////////// TESTS ////////////// - -#[cfg(test)] -mod tests { - use super::*; - use crate::{config::Dependency, errors::ConfigError, utils::get_current_working_dir}; - use rand::{distributions::Alphanumeric, Rng}; - use serial_test::serial; - use std::{ - fs::{self, remove_file}, - io::Write, - path::PathBuf, - }; - - #[tokio::test] // check dependencies as {version = "1.1.1"} - #[serial] - async fn read_foundry_config_version_v1_ok() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.6.1" -"@openzeppelin-contracts" = "5.0.2" -"#; - let target_config = define_config(true); - - write_to_config(&target_config, config_contents); - - let result = read_config_deps(Some(target_config.clone()))?; - - assert_eq!( - result[0], - Dependency::Http(HttpDependency { - name: "@gearbox-protocol-periphery-v3".to_string(), - version: "1.6.1".to_string(), - url: None, - checksum: None - }) - ); - - assert_eq!( - result[1], - Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "5.0.2".to_string(), - url: None, - checksum: None - }) - ); - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] // check dependencies as "1.1.1" - #[serial] - async fn read_foundry_config_version_v2_ok() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.6.1" -"@openzeppelin-contracts" = "5.0.2" -"#; - let target_config = define_config(true); - - write_to_config(&target_config, config_contents); - - let result = read_config_deps(Some(target_config.clone()))?; - - assert_eq!( - result[0], - Dependency::Http(HttpDependency { - name: "@gearbox-protocol-periphery-v3".to_string(), - version: "1.6.1".to_string(), - url: None, - checksum: None - }) - ); - - assert_eq!( - result[1], - Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "5.0.2".to_string(), - url: None, - checksum: None - }) - ); - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] // check dependencies as "1.1.1" - #[serial] - async fn read_soldeer_config_version_v1_ok() -> Result<()> { - let config_contents = r#" -[remappings] -enabled = true - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.6.1" -"@openzeppelin-contracts" = "5.0.2" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let result = read_config_deps(Some(target_config.clone()))?; - - assert_eq!( - result[0], - Dependency::Http(HttpDependency { - name: "@gearbox-protocol-periphery-v3".to_string(), - version: "1.6.1".to_string(), - url: None, - checksum: None - }) - ); - - assert_eq!( - result[1], - Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "5.0.2".to_string(), - url: None, - checksum: None - }) - ); - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] // check dependencies as "1.1.1" - #[serial] - async fn read_soldeer_config_version_v2_ok() -> Result<()> { - let config_contents = r#" -[remappings] -enabled = true - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.6.1" -"@openzeppelin-contracts" = "5.0.2" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let result = read_config_deps(Some(target_config.clone()))?; - - assert_eq!( - result[0], - Dependency::Http(HttpDependency { - name: "@gearbox-protocol-periphery-v3".to_string(), - version: "1.6.1".to_string(), - url: None, - checksum: None - }) - ); - - assert_eq!( - result[1], - Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "5.0.2".to_string(), - url: None, - checksum: None - }) - ); - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn read_malformed_config_incorrect_version_string_fails() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = 1.6.1" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - assert!(matches!( - read_config_deps(Some(target_config.clone())), - Err(ConfigError::Parsing(_)) - )); - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn read_malformed_config_empty_version_string_fails() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = "" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - assert!(matches!( - read_config_deps(Some(target_config.clone())), - Err(ConfigError::EmptyVersion(_)) - )); - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn define_config_file_choses_foundry() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -libs = ["dependencies"] - -[dependencies] -"#; - let target_config = define_config(true); - - write_to_config(&target_config, config_contents); - - assert!(target_config.file_name().unwrap().to_string_lossy().contains("foundry")); - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn define_config_file_choses_soldeer() -> Result<()> { - let config_contents = r#" -[dependencies] -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - assert!(target_config.file_name().unwrap().to_string_lossy().contains("soldeer")); - let _ = remove_file(target_config); - Ok(()) - } - - // #[test] // TODO check how to do this properly - #[allow(dead_code)] - fn create_new_file_if_not_defined_but_foundry_exists() -> Result<()> { - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies", "libs"] - -[dependencies] -forge-std = "1.9.1" -"#; - - let result = create_example_config("1").unwrap(); - - assert!(PathBuf::from(&result).file_name().unwrap().to_string_lossy().contains("foundry")); - assert_eq!(read_file_to_string(&result), content); - Ok(()) - } - - // #[test]// TODO check how to do this properly - #[allow(dead_code)] - fn create_new_file_if_not_defined_but_foundry_does_not_exists() -> Result<()> { - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies", "libs"] - -[dependencies] -forge-std = "1.9.1" -"#; - - let result = create_example_config("1").unwrap(); - - assert!(PathBuf::from(&result).file_name().unwrap().to_string_lossy().contains("foundry")); - assert_eq!(read_file_to_string(&result), content); - Ok(()) - } - - #[test] - fn create_new_file_if_not_defined_soldeer() -> Result<()> { - let content = r#" -[remappings] -enabled = true - -[dependencies] -"#; - - let result = create_example_config("2").unwrap(); - - assert!(PathBuf::from(&result).file_name().unwrap().to_string_lossy().contains("soldeer")); - assert_eq!(read_file_to_string(&result), content); - Ok(()) - } - - #[test] - fn add_to_config_foundry_no_custom_url_first_dependency() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep1 = "1.0.0" -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_with_custom_url_first_dependency() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: Some("http://custom_url.com/custom.zip".to_string()), - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_no_custom_url_second_dependency() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = "5.1.0-my-version-is-cool" -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = "5.1.0-my-version-is-cool" -dep1 = "1.0.0" -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_with_custom_url_second_dependency() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/cool-cool-cool.zip" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: Some("http://custom_url.com/custom.zip".to_string()), - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/cool-cool-cool.zip" } -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_update_dependency_version() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/cool-cool-cool.zip" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "old_dep".to_string(), - version: "1.0.0".to_string(), - url: Some("http://custom_url.com/custom.zip".to_string()), - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_update_dependency_version_no_custom_url() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = { version = "5.1.0-my-version-is-cool", url = "http://custom_url.com/cool-cool-cool.zip" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "old_dep".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -old_dep = "1.0.0" -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_not_altering_the_existing_contents() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -[dependencies] -dep1 = "1.0.0" - -# we don't have [dependencies] declared -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_soldeer_no_custom_url_first_dependency() -> Result<()> { - let mut content = r#" -[remappings] -enabled = true - -[dependencies] -"#; - - let target_config = define_config(false); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -[remappings] -enabled = true - -[dependencies] -dep1 = "1.0.0" -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_soldeer_with_custom_url_first_dependency() -> Result<()> { - let mut content = r#" -[remappings] -enabled = true - -[dependencies] -"#; - - let target_config = define_config(false); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: Some("http://custom_url.com/custom.zip".to_string()), - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -[remappings] -enabled = true - -[dependencies] -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_github_with_commit() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Git(GitDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - git: "git@github.com:foundry-rs/forge-std.git".to_string(), - identifier: Some(GitIdentifier::from_rev("07263d193d621c4b2b0ce8b4d54af58f6957d97d")), - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -[dependencies] -dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev = "07263d193d621c4b2b0ce8b4d54af58f6957d97d" } - -# we don't have [dependencies] declared -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_github_with_tag() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Git(GitDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - git: "https://gitlab.com/mario4582928/Mario.git".to_string(), - identifier: Some(GitIdentifier::from_tag("custom-tag")), - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -[dependencies] -dep1 = { version = "1.0.0", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-tag" } - -# we don't have [dependencies] declared -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_github_with_branch() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Git(GitDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - git: "https://gitlab.com/mario4582928/Mario.git".to_string(), - identifier: Some(GitIdentifier::from_branch("custom-branch")), - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -[dependencies] -dep1 = { version = "1.0.0", git = "https://gitlab.com/mario4582928/Mario.git", branch = "custom-branch" } - -# we don't have [dependencies] declared -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_github_previous_no_commit_then_with_commit() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared - -[dependencies] -dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Git(GitDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - git: "git@github.com:foundry-rs/forge-std.git".to_string(), - identifier: Some(GitIdentifier::from_rev("07263d193d621c4b2b0ce8b4d54af58f6957d97d")), - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared - -[dependencies] -dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev = "07263d193d621c4b2b0ce8b4d54af58f6957d97d" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn add_to_config_foundry_github_previous_commit_then_no_commit() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared - -[dependencies] -dep1 = { version = "1.0.0", git = "git@github.com:foundry-rs/forge-std.git", rev = "07263d193d621c4b2b0ce8b4d54af58f6957d97d" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: Some("http://custom_url.com/custom.zip".to_string()), - checksum: None, - }); - - add_to_config(&dependency, &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] -gas_reports = ['*'] - -# we don't have [dependencies] declared - -[dependencies] -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn remove_from_the_config_single() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - delete_config("dep1", &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn remove_from_the_config_multiple() -> Result<()> { - let mut content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep3 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -dep2 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - delete_config("dep1", &target_config).unwrap(); - content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep3 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -dep2 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[test] - fn remove_config_nonexistent_fails() -> Result<()> { - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep1 = { version = "1.0.0", url = "http://custom_url.com/custom.zip" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - assert!(matches!( - delete_config("dep2", &target_config), - Err(ConfigError::MissingDependency(_)) - )); - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn read_soldeer_configs_all_set() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config -[profile.default] -libs = ["dependencies"] -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -[soldeer] -remappings_generate = true -remappings_prefix = "@" -remappings_regenerate = true -remappings_version = true -remappings_location = "config" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let sc = match read_soldeer_config(Some(target_config.clone())) { - Ok(sc) => sc, - Err(_) => { - assert_eq!("False state", ""); - SoldeerConfig::default() - } - }; - let _ = remove_file(target_config); - assert!(sc.remappings_prefix == *"@"); - assert!(sc.remappings_generate); - assert!(sc.remappings_regenerate); - assert!(sc.remappings_version); - assert_eq!(sc.remappings_location, RemappingsLocation::Config); - Ok(()) - } - - #[tokio::test] - async fn read_soldeer_configs_generate_remappings() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config -[profile.default] -libs = ["dependencies"] -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -[soldeer] -remappings_generate = true -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let sc = match read_soldeer_config(Some(target_config.clone())) { - Ok(sc) => sc, - Err(_) => { - assert_eq!("False state", ""); - SoldeerConfig::default() - } - }; - let _ = remove_file(target_config); - assert!(sc.remappings_generate); - assert!(sc.remappings_prefix.is_empty()); - Ok(()) - } - - #[tokio::test] - async fn read_soldeer_configs_append_at_in_remappings() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config -[profile.default] -libs = ["dependencies"] -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -[soldeer] -remappings_prefix = "@" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let sc = match read_soldeer_config(Some(target_config.clone())) { - Ok(sc) => sc, - Err(_) => { - assert_eq!("False state", ""); - SoldeerConfig::default() - } - }; - let _ = remove_file(target_config); - assert!(sc.remappings_prefix == *"@"); - assert!(sc.remappings_generate); - Ok(()) - } - - #[tokio::test] - async fn read_soldeer_configs_reg_remappings() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config -[profile.default] -libs = ["dependencies"] -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -[soldeer] -remappings_regenerate = true -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let sc = match read_soldeer_config(Some(target_config.clone())) { - Ok(sc) => sc, - Err(_) => { - assert_eq!("False state", ""); - SoldeerConfig::default() - } - }; - let _ = remove_file(target_config); - assert!(sc.remappings_regenerate); - assert!(sc.remappings_generate); - Ok(()) - } - - #[tokio::test] - async fn read_soldeer_configs_remappings_version() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config -[profile.default] -libs = ["dependencies"] -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -[soldeer] -remappings_version = true -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let sc = match read_soldeer_config(Some(target_config.clone())) { - Ok(sc) => sc, - Err(_) => { - assert_eq!("False state", ""); - SoldeerConfig::default() - } - }; - let _ = remove_file(target_config); - assert!(sc.remappings_version); - assert!(sc.remappings_generate); - Ok(()) - } - - #[tokio::test] - async fn read_soldeer_configs_remappings_location() -> Result<()> { - let config_contents = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config -[profile.default] -libs = ["dependencies"] -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.1.1" -[soldeer] -remappings_location = "config" -"#; - let target_config = define_config(false); - - write_to_config(&target_config, config_contents); - - let sc = match read_soldeer_config(Some(target_config.clone())) { - Ok(sc) => sc, - Err(_) => { - assert_eq!("False state", ""); - SoldeerConfig::default() - } - }; - let _ = remove_file(target_config); - assert_eq!(sc.remappings_location, RemappingsLocation::Config); - assert!(sc.remappings_generate); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_with_prefix_and_version_in_config() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -[dependencies] -[soldeer] -remappings_prefix = "@" -remappings_version = true -remappings_location = "config" -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = - remappings_foundry(&RemappingsAction::Add(dependency), &target_config, &soldeer_config) - .await; - - content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["@dep1-1.0.0/=dependencies/dep1-1.0.0/"] -[dependencies] -[soldeer] -remappings_prefix = "@" -remappings_version = true -remappings_location = "config" -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_no_prefix_and_no_version_in_config() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -[dependencies] -[soldeer] -remappings_generate = true -remappings_version = false -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::Add(dependency), - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep1/=dependencies/dep1-1.0.0/"] -[dependencies] -[soldeer] -remappings_generate = true -remappings_version = false -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn generate_remappings_prefix_and_version_in_txt() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -[dependencies] -[soldeer] -remappings_generate = true -remappings_prefix = "@" -remappings_version = true -"#; - - let target_config = define_config(true); - let txt = get_current_working_dir().join("remappings.txt"); - let _ = remove_file(&txt); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_txt(&RemappingsAction::Add(dependency), &target_config, &soldeer_config) - .await; - - content = "@dep1-1.0.0/=dependencies/dep1-1.0.0/\n"; - - assert_eq!(read_file_to_string(&txt), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn generate_remappings_no_prefix_and_no_version_in_txt() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -[dependencies] -[soldeer] -remappings_generate = true -remappings_version = false -"#; - - let target_config = define_config(true); - let txt = get_current_working_dir().join("remappings.txt"); - let _ = remove_file(&txt); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_txt(&RemappingsAction::Add(dependency), &target_config, &soldeer_config) - .await; - - content = "dep1/=dependencies/dep1-1.0.0/\n"; - - assert_eq!(read_file_to_string(&txt), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_in_config_only_default_profile() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -[profile.local.testing] -ffi = true -[dependencies] -[soldeer] -remappings_generate = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::Add(dependency), - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/"] -[profile.local.testing] -ffi = true -[dependencies] -[soldeer] -remappings_generate = true -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_in_config_all_profiles() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -[profile.local] -remappings = [] -[profile.local.testing] -ffi = true -[dependencies] -[soldeer] -remappings_generate = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::Add(dependency), - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/"] -[profile.local] -remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/"] -[profile.local.testing] -ffi = true -[dependencies] -[soldeer] -remappings_generate = true -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_in_config_existing() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep2-1.0.0/=dependencies/dep2-1.0.0/"] -[dependencies] -[soldeer] -remappings_generate = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - let dependency = Dependency::Http(HttpDependency { - name: "dep1".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - }); - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::Add(dependency), - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep1-1.0.0/=dependencies/dep1-1.0.0/", "dep2-1.0.0/=dependencies/dep2-1.0.0/"] -[dependencies] -[soldeer] -remappings_generate = true -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn generate_remappings_regenerate() -> Result<()> { - let mut content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["@dep2-custom/=dependencies/dep2-1.0.0/"] -[dependencies] -dep2 = "1.0.0" -[soldeer] -remappings_generate = true -remappings_regenerate = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::None, - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep2-1.0.0/=dependencies/dep2-1.0.0/"] -[dependencies] -dep2 = "1.0.0" -[soldeer] -remappings_generate = true -remappings_regenerate = true -"#; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_keep_custom() -> Result<()> { - let content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["@dep2-custom/=dependencies/dep2-1.0.0/"] -[dependencies] -dep2 = "1.0.0" -[soldeer] -remappings_generate = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::None, - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - #[tokio::test] - async fn generate_remappings_keep_custom_path() -> Result<()> { - let content = r#" -[profile.default] -solc = "0.8.26" -libs = ["dependencies"] -remappings = ["dep2-1.0.0/=dependencies/dep2-1.0.0/src/"] -[dependencies] -dep2 = "1.0.0" -[soldeer] -remappings_generate = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - let soldeer_config = read_soldeer_config(Some(target_config.clone())).unwrap(); - let _ = remappings_foundry( - &RemappingsAction::None, - target_config.to_str().unwrap(), - &soldeer_config, - ) - .await; - - assert_eq!(read_file_to_string(&target_config), content); - - let _ = remove_file(target_config); - Ok(()) - } - - ////////////// UTILS ////////////// - - fn write_to_config(target_file: &PathBuf, content: &str) { - if target_file.exists() { - let _ = remove_file(target_file); - } - let mut file: std::fs::File = - fs::OpenOptions::new().create_new(true).write(true).open(target_file).unwrap(); - if let Err(e) = write!(file, "{}", content) { - eprintln!("Couldn't write to the config file: {}", e); - } - } - - fn define_config(foundry: bool) -> PathBuf { - let s: String = - rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); - let mut target = format!("foundry{}.toml", s); - if !foundry { - target = format!("soldeer{}.toml", s); - } - - get_current_working_dir().join("test").join(target) - } - - #[allow(unused)] - fn get_return_data() -> String { - r#" - { - "data": [ - { - "created_at": "2024-03-14T06:11:59.838552Z", - "deleted": false, - "downloads": 100, - "id": "c10d3ec8-7968-468f-bc12-8188bcafce2b", - "internal_name": "example_url.zip", - "project_id": "bbf2a8e4-2572-4787-bff9-216db013691b", - "url": "https://example_url.com/example_url.zip", - "version": "5.0.2" - } - ], - "status": "success" - } - "# - .to_string() - } -} diff --git a/src/dependency_downloader.rs b/src/dependency_downloader.rs deleted file mode 100644 index ebf8352..0000000 --- a/src/dependency_downloader.rs +++ /dev/null @@ -1,752 +0,0 @@ -use crate::{ - config::{Dependency, GitDependency, HttpDependency}, - errors::DownloadError, - remote::get_dependency_url_remote, - utils::{hash_folder, read_file, sanitize_dependency_name, zipfile_hash}, - DEPENDENCY_DIR, -}; -use reqwest::IntoUrl; -use std::{ - fs, - io::Cursor, - path::{Path, PathBuf}, - process::{Command, Stdio}, - str, -}; -use tokio::{fs as tokio_fs, io::AsyncWriteExt, task::JoinSet}; -use yansi::Paint as _; - -pub type Result = std::result::Result; - -#[derive(Debug, Clone, Default, PartialEq)] -pub struct IntegrityChecksum(pub String); - -impl From for IntegrityChecksum -where - T: Into, -{ - fn from(value: T) -> Self { - let v: String = value.into(); - IntegrityChecksum(v) - } -} - -impl core::fmt::Display for IntegrityChecksum { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -/// Download the dependencies from the list in parallel -/// -/// Note: the dependencies list should be sorted by name and version -pub async fn download_dependencies( - dependencies: &[Dependency], - clean: bool, -) -> Result> { - // clean dependencies folder if flag is true - if clean { - // creates the directory - clean_dependency_directory(); - } - - // create the dependency directory if it doesn't exist - let dir = DEPENDENCY_DIR.clone(); - if tokio_fs::metadata(&dir).await.is_err() { - tokio_fs::create_dir(&dir) - .await - .map_err(|e| DownloadError::IOError { path: dir, source: e })?; - } - - let mut set = JoinSet::new(); - for dep in dependencies { - set.spawn({ - let d = dep.clone(); - async move { download_dependency(&d, true).await } - }); - } - - let mut results = Vec::new(); - while let Some(res) = set.join_next().await { - results.push(res??); - } - // sort to make the order consistent with the input dependencies list (which should be sorted) - results.sort_unstable_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); - - Ok(results) -} - -// un-zip-ing dependencies to dependencies folder -pub fn unzip_dependencies(dependencies: &[Dependency]) -> Result>> { - let res: Vec<_> = dependencies - .iter() - .map(|d| match d { - Dependency::Http(dep) => unzip_dependency(dep).map(Some), - _ => Ok(None), - }) - .collect::>>()?; - Ok(res) -} - -#[derive(Debug, Clone)] -pub struct DownloadResult { - pub name: String, - pub version: String, - pub hash: String, - pub url: String, -} - -pub async fn download_dependency( - dependency: &Dependency, - skip_folder_check: bool, -) -> Result { - let dependency_directory: PathBuf = DEPENDENCY_DIR.clone(); - // if we called this method from `download_dependencies` we don't need to check if the folder - // exists, as it was created by the caller - if !skip_folder_check && tokio_fs::metadata(&dependency_directory).await.is_err() { - if let Err(e) = tokio_fs::create_dir(&dependency_directory).await { - // temp fix for race condition until we use tokio fs everywhere - if tokio_fs::metadata(&dependency_directory).await.is_err() { - return Err(DownloadError::IOError { path: dependency_directory, source: e }); - } - } - } - - let res = match dependency { - Dependency::Http(dep) => { - let url = match &dep.url { - Some(url) => url.clone(), - None => get_dependency_url_remote(dependency).await?, - }; - download_via_http(&url, dep, &dependency_directory).await?; - DownloadResult { - name: dep.name.clone(), - version: dep.version.clone(), - hash: zipfile_hash(dep)?.to_string(), - url, - } - } - Dependency::Git(dep) => { - let hash = download_via_git(dep, &dependency_directory).await?; - DownloadResult { - name: dep.name.clone(), - version: dep.version.clone(), - hash, - url: dep.git.clone(), - } - } - }; - - println!("{}", format!("Dependency {dependency} downloaded!").green()); - - Ok(res) -} - -pub fn unzip_dependency(dependency: &HttpDependency) -> Result { - let file_name = - sanitize_dependency_name(&format!("{}-{}", dependency.name, dependency.version)); - let target_name = format!("{}/", file_name); - let zip_path = DEPENDENCY_DIR.join(format!("{file_name}.zip")); - let target_dir = DEPENDENCY_DIR.join(target_name); - let zip_contents = read_file(&zip_path).unwrap(); - - zip_extract::extract(Cursor::new(zip_contents), &target_dir, true)?; - println!("{}", format!("The dependency {dependency} was unzipped!").green()); - - hash_folder(&target_dir, Some(zip_path)) - .map_err(|e| DownloadError::IOError { path: target_dir, source: e }) -} - -pub fn clean_dependency_directory() { - if fs::metadata(DEPENDENCY_DIR.clone()).is_ok() { - fs::remove_dir_all(DEPENDENCY_DIR.clone()).unwrap(); - fs::create_dir(DEPENDENCY_DIR.clone()).unwrap(); - } -} - -async fn download_via_git( - dependency: &GitDependency, - dependency_directory: &Path, -) -> Result { - println!("{}", format!("Started GIT download of {dependency}").green()); - let target_dir = - sanitize_dependency_name(&format!("{}-{}", dependency.name, dependency.version)); - let path = dependency_directory.join(target_dir); - let path_str = path.to_string_lossy().to_string(); - if path.exists() { - let _ = fs::remove_dir_all(&path); - } - - let mut git_clone = Command::new("git"); - - let result = git_clone - .args(["clone", &dependency.git, &path_str]) - .env("GIT_TERMINAL_PROMPT", "0") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let status = result.status().expect("Getting clone status failed"); - let out = result.output().expect("Getting clone output failed"); - - if !status.success() { - let _ = fs::remove_dir_all(&path); - return Err(DownloadError::GitError( - str::from_utf8(&out.stderr).unwrap().trim().to_string(), - )); - } - - if let Some(target) = &dependency.identifier { - let mut git_checkout = Command::new("git"); - let result = git_checkout - .args(["checkout".to_string(), target.to_string()]) - .env("GIT_TERMINAL_PROMPT", "0") - .current_dir(&path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let out = result.output().expect("Checkout status failed"); - let status = result.status().expect("Checkout getting output failed"); - - if !status.success() { - let _ = fs::remove_dir_all(&path); - return Err(DownloadError::GitError( - str::from_utf8(&out.stderr).unwrap().trim().to_string(), - )); - } - }; - - let mut git_checkout = Command::new("git"); - - let result = git_checkout - .args(["rev-parse".to_string(), "--verify".to_string(), "HEAD".to_string()]) - .env("GIT_TERMINAL_PROMPT", "0") - .current_dir(&path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let out = result.output().expect("Getting revision status failed"); - let status = result.status().expect("Getting revision output failed"); - if !status.success() { - let _ = fs::remove_dir_all(&path); - return Err(DownloadError::GitError( - str::from_utf8(&out.stderr).unwrap().trim().to_string(), - )); - } - - let hash = str::from_utf8(&out.stdout).unwrap().trim().to_string(); - // check the commit hash - if !hash.is_empty() && hash.len() != 40 { - let _ = fs::remove_dir_all(&path); - return Err(DownloadError::GitError(format!("invalid revision hash: {hash}"))); - } - - println!( - "{}", - format!("Successfully downloaded {} the dependency via git", dependency,).green() - ); - Ok(hash) -} - -async fn download_via_http( - url: impl IntoUrl, - dependency: &HttpDependency, - dependency_directory: &Path, -) -> Result<()> { - println!("{}", format!("Started HTTP download of {dependency}").green()); - let zip_to_download = - sanitize_dependency_name(&format!("{}-{}.zip", dependency.name, dependency.version)); - - let resp = reqwest::get(url).await?; - let mut resp = resp.error_for_status()?; - - let file_path = dependency_directory.join(&zip_to_download); - let mut file = tokio_fs::File::create(&file_path) - .await - .map_err(|e| DownloadError::IOError { path: file_path.clone(), source: e })?; - - while let Some(mut chunk) = resp.chunk().await? { - file.write_all_buf(&mut chunk) - .await - .map_err(|e| DownloadError::IOError { path: file_path.clone(), source: e })?; - } - // make sure we finished writing the file - file.flush().await.map_err(|e| DownloadError::IOError { path: file_path, source: e })?; - Ok(()) -} - -pub fn delete_dependency_files(dependency: &Dependency) -> Result<()> { - let path = DEPENDENCY_DIR.join(sanitize_dependency_name(&format!( - "{}-{}", - dependency.name(), - dependency.version() - ))); - fs::remove_dir_all(&path).map_err(|e| DownloadError::IOError { path, source: e })?; - Ok(()) -} - -pub fn install_subdependencies(dependency: &Dependency) -> Result<()> { - let dep_name = - sanitize_dependency_name(&format!("{}-{}", dependency.name(), dependency.version())); - - let dep_dir = DEPENDENCY_DIR.join(dep_name); - if !dep_dir.exists() { - return Err(DownloadError::SubdependencyError( - "Dependency directory does not exists".to_string(), - )); - } - - let mut git = Command::new("git"); - - let result = git - .args(["submodule", "update", "--init", "--recursive"]) - .env("GIT_TERMINAL_PROMPT", "0") - .current_dir(&dep_dir) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let status = result.status().expect("Subdependency via GIT failed"); - - if !status.success() { - println!("{}", "Dependency has no submodule dependency.".yellow()); - } - - let mut soldeer = Command::new("forge"); - - let result = soldeer - .args(["soldeer", "install"]) - .current_dir(&dep_dir) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let status = result.status().expect("Subdependency via Soldeer failed"); - - if !status.success() { - println!("{}", "Dependency has no Soldeer dependency.".yellow()); - } - - Ok(()) -} - -#[cfg(test)] -#[allow(clippy::vec_init_then_push)] -mod tests { - use super::*; - use crate::{ - config::GitIdentifier, - janitor::healthcheck_dependency, - utils::{get_url_type, UrlType}, - }; - use serial_test::serial; - use std::{fs::metadata, path::Path}; - - #[tokio::test] - #[serial] - async fn download_dependencies_http_one_success() { - let mut dependencies: Vec = Vec::new(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - dependencies.push(dependency.clone()); - let results = download_dependencies(&dependencies, false).await.unwrap(); - let path_zip = - DEPENDENCY_DIR.join(format!("{}-{}.zip", &dependency.name(), &dependency.version())); - assert!(path_zip.exists()); - assert!(results.len() == 1); - assert!(!results[0].hash.is_empty()); - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependency_gitlab_httpurl_with_a_specific_revision() { - clean_dependency_directory(); - let mut dependencies: Vec = Vec::new(); - let dependency = Dependency::Git(GitDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - git: "https://gitlab.com/mario4582928/Mario.git".to_string(), - identifier: Some(GitIdentifier::from_rev("7a0663eaf7488732f39550be655bad6694974cb3")), - }); - dependencies.push(dependency.clone()); - let results = download_dependencies(&dependencies, false).await.unwrap(); - let path_dir = - DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); - assert!(path_dir.exists()); - assert!(path_dir.join("README.md").exists()); - assert!(results.len() == 1); - assert_eq!(results[0].hash, "7a0663eaf7488732f39550be655bad6694974cb3"); // this is the last commit, hash == commit - - // at this revision, this file should exists - let test_right_revision = DEPENDENCY_DIR - .join(format!("{}-{}", &dependency.name(), &dependency.version())) - .join("JustATest2.md"); - assert!(test_right_revision.exists()); - - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependencies_gitlab_httpurl_one_success() { - clean_dependency_directory(); - let mut dependencies: Vec = Vec::new(); - let dependency = Dependency::Git(GitDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - git: "https://gitlab.com/mario4582928/Mario.git".to_string(), - identifier: None, - }); - dependencies.push(dependency.clone()); - let results = download_dependencies(&dependencies, false).await.unwrap(); - let path_dir = - DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); - assert!(path_dir.exists()); - assert!(path_dir.join("README.md").exists()); - assert!(results.len() == 1); - assert_eq!(results[0].hash, "22868f426bd4dd0e682b5ec5f9bd55507664240c"); // this is the last commit, hash == commit - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependencies_http_two_success() { - let mut dependencies: Vec = Vec::new(); - let dependency_one = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - dependencies.push(dependency_one.clone()); - - let dependency_two = Dependency::Http(HttpDependency { - name: "@uniswap-v2-core".to_string(), - version: "1.0.0-beta.4".to_string(), - url: Some("https://soldeer-revisions.s3.amazonaws.com/@uniswap-v2-core/1_0_0-beta_4_22-01-2024_13:18:27_v2-core.zip".to_string()), - checksum: None - }); - - dependencies.push(dependency_two.clone()); - let results = download_dependencies(&dependencies, false).await.unwrap(); - let mut path_zip = DEPENDENCY_DIR.join(format!( - "{}-{}.zip", - &dependency_one.name(), - &dependency_one.version() - )); - assert!(path_zip.exists()); - - path_zip = DEPENDENCY_DIR.join(format!( - "{}-{}.zip", - &dependency_two.name(), - &dependency_two.version() - )); - assert!(path_zip.exists()); - assert!(results.len() == 2); - assert!(!results[0].hash.is_empty()); - assert!(!results[1].hash.is_empty()); - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependencies_git_http_two_success() { - let mut dependencies: Vec = Vec::new(); - let dependency_one = Dependency::Git(GitDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - git: "https://github.com/transmissions11/solmate.git".to_string(), - identifier: None, - }); - dependencies.push(dependency_one.clone()); - - let dependency_two = Dependency::Git(GitDependency { - name: "@uniswap-v2-core".to_string(), - version: "1.0.0-beta.4".to_string(), - git: "https://gitlab.com/mario4582928/Mario.git".to_string(), - identifier: None, - }); - - dependencies.push(dependency_two.clone()); - let results = download_dependencies(&dependencies, false).await.unwrap(); - let mut path_dir = DEPENDENCY_DIR.join(format!( - "{}-{}", - &dependency_one.name(), - &dependency_one.version() - )); - let mut path_dir_two = DEPENDENCY_DIR.join(format!( - "{}-{}", - &dependency_two.name(), - &dependency_two.version() - )); - assert!(path_dir.exists()); - assert!(path_dir_two.exists()); - - path_dir = DEPENDENCY_DIR.join(format!( - "{}-{}", - &dependency_one.name(), - &dependency_one.version() - )); - path_dir_two = DEPENDENCY_DIR.join(format!( - "{}-{}", - &dependency_two.name(), - &dependency_two.version() - )); - assert!(path_dir.exists()); - assert!(path_dir_two.exists()); - assert!(results.len() == 2); - assert!(!results[0].hash.is_empty()); - assert!(!results[1].hash.is_empty()); - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependency_should_replace_existing_zip() { - let mut dependencies: Vec = Vec::new(); - let dependency_one = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "download-dep-v1".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - dependencies.push(dependency_one.clone()); - - download_dependencies(&dependencies, false).await.unwrap(); - let path_zip = DEPENDENCY_DIR.join(format!( - "{}-{}.zip", - &dependency_one.name(), - &dependency_one.version() - )); - let size_of_one = fs::metadata(Path::new(&path_zip)).unwrap().len(); - - let dependency_two = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "download-dep-v1".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string()), - checksum: None - }); - - dependencies = Vec::new(); - dependencies.push(dependency_two.clone()); - - let results = download_dependencies(&dependencies, false).await.unwrap(); - let size_of_two = fs::metadata(Path::new(&path_zip)).unwrap().len(); - - assert!(size_of_two > size_of_one); - assert!(results.len() == 1); - assert!(!results[0].hash.is_empty()); - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependencies_one_with_clean_success() { - let mut dependencies: Vec = Vec::new(); - let dependency_old = Dependency::Http(HttpDependency { - name: "@uniswap-v2-core".to_string(), - version: "1.0.0-beta.4".to_string(), - url: Some("https://soldeer-revisions.s3.amazonaws.com/@uniswap-v2-core/1_0_0-beta_4_22-01-2024_13:18:27_v2-core.zip".to_string()), - checksum: None - }); - - dependencies.push(dependency_old.clone()); - download_dependencies(&dependencies, false).await.unwrap(); - - // making sure the dependency exists so we can check the deletion - let path_zip_old = DEPENDENCY_DIR.join(format!( - "{}-{}.zip", - &dependency_old.name(), - &dependency_old.version() - )); - assert!(path_zip_old.exists()); - - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - dependencies = Vec::new(); - dependencies.push(dependency.clone()); - - let results = download_dependencies(&dependencies, true).await.unwrap(); - let path_zip = - DEPENDENCY_DIR.join(format!("{}-{}.zip", &dependency.name(), &dependency.version())); - assert!(!path_zip_old.exists()); - assert!(path_zip.exists()); - assert!(results.len() == 1); - assert!(!results[0].hash.is_empty()); - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependencies_http_one_fail() { - let mut dependencies: Vec = Vec::new(); - - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~.zip".to_string()), - checksum: None - }); - dependencies.push(dependency.clone()); - - match download_dependencies(&dependencies, false).await { - Ok(_) => { - assert_eq!("Invalid state", ""); - } - Err(err) => { - assert_eq!(err.to_string(), "error downloading dependency: HTTP status client error (404 Not Found) for url (https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~.zip)"); - } - } - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_dependencies_git_one_fail() { - let mut dependencies: Vec = Vec::new(); - - let dependency = Dependency::Git(GitDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - git: "git@github.com:transmissions11/solmate-wrong.git".to_string(), - identifier: None, - }); - dependencies.push(dependency.clone()); - - match download_dependencies(&dependencies, false).await { - Ok(_) => { - assert_eq!("Invalid state", ""); - } - Err(err) => { - // we assert this as the message contains various absolute paths that can not be - // hardcoded here - assert!(err.to_string().contains("Cloning into")); - } - } - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn unzip_dependency_success() { - let mut dependencies: Vec = Vec::new(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - dependencies.push(dependency.clone()); - download_dependencies(&dependencies, false).await.unwrap(); - let path = DEPENDENCY_DIR.join(format!("{}-{}", &dependency.name(), &dependency.version())); - match unzip_dependencies(&dependencies) { - Ok(_) => { - assert!(path.exists()); - assert!(metadata(&path).unwrap().len() > 0); - } - Err(_) => { - clean_dependency_directory(); - assert_eq!("Error", ""); - } - } - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn unzip_non_zip_file_error() { - let mut dependencies: Vec = Vec::new(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some( - "https://freetestdata.com/wp-content/uploads/2022/02/Free_Test_Data_117KB_JPG.jpg" - .to_string(), - ), - checksum: None, - }); - dependencies.push(dependency.clone()); - download_dependencies(&dependencies, false).await.unwrap(); - match unzip_dependencies(&dependencies) { - Ok(_) => { - clean_dependency_directory(); - assert_eq!("Wrong State", ""); - } - Err(err) => { - assert!(matches!(err, DownloadError::UnzipError(_))); - } - } - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn download_unzip_check_integrity() { - let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "3.3.0-custom-test".to_string(), - url: Some("https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip".to_string()), - checksum: None, - })); - download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); - healthcheck_dependency(&dependencies[0]).unwrap(); - assert!(DEPENDENCY_DIR - .join("@openzeppelin-contracts-3.3.0-custom-test") - .join("token") - .join("ERC20") - .join("ERC20.sol") - .exists()); - clean_dependency_directory(); - } - - #[test] - fn get_download_tunnel_http() { - assert_eq!( - get_url_type("https://github.com/foundry-rs/forge-std/archive/refs/tags/v1.9.1.zip"), - UrlType::Http - ); - } - - #[test] - fn get_download_tunnel_git_giturl() { - assert_eq!(get_url_type("git@github.com:foundry-rs/forge-std.git"), UrlType::Git); - } - - #[test] - fn get_download_tunnel_git_githttp() { - assert_eq!(get_url_type("https://github.com/foundry-rs/forge-std.git"), UrlType::Git); - } - - #[tokio::test] - #[serial] - async fn remove_one_dependency() { - let mut dependencies: Vec = Vec::new(); - - let dependency = Dependency::Git(GitDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - git: "https://github.com/transmissions11/solmate.git".to_string(), - identifier: None, - }); - dependencies.push(dependency.clone()); - - match download_dependencies(&dependencies, false).await { - Ok(_) => {} - Err(_) => { - assert_eq!("Invalid state", ""); - } - } - let _ = delete_dependency_files(&dependency); - assert!(!DEPENDENCY_DIR - .join(format!("{}~{}", dependency.name(), dependency.version())) - .exists()); - } -} diff --git a/src/janitor.rs b/src/janitor.rs deleted file mode 100644 index e6bee11..0000000 --- a/src/janitor.rs +++ /dev/null @@ -1,183 +0,0 @@ -use crate::{ - config::Dependency, errors::JanitorError, lock::remove_lock, utils::sanitize_dependency_name, - DEPENDENCY_DIR, -}; -use std::fs; - -pub type Result = std::result::Result; - -// Health-check dependencies before we clean them, this one checks if they were unzipped -pub fn healthcheck_dependencies(dependencies: &[Dependency]) -> Result<()> { - dependencies.iter().try_for_each(healthcheck_dependency)?; - Ok(()) -} - -// Cleanup zips after the download -pub fn cleanup_after(dependencies: &[Dependency]) -> Result<()> { - dependencies.iter().try_for_each(|d| cleanup_dependency(d, false))?; - Ok(()) -} - -pub fn healthcheck_dependency(dependency: &Dependency) -> Result<()> { - let file_name = - sanitize_dependency_name(&format!("{}-{}", dependency.name(), dependency.version())); - let new_path = DEPENDENCY_DIR.join(file_name); - match fs::metadata(new_path) { - Ok(_) => Ok(()), - Err(_) => Err(JanitorError::MissingDependency(dependency.to_string())), - } -} - -pub fn cleanup_dependency(dependency: &Dependency, full: bool) -> Result<()> { - let sanitized_name = - sanitize_dependency_name(&format!("{}-{}", dependency.name(), dependency.version())); - - let new_path = DEPENDENCY_DIR.clone().join(format!("{sanitized_name}.zip")); - if new_path.exists() { - fs::remove_file(&new_path) - .map_err(|e| JanitorError::IOError { path: new_path, source: e })?; - } - if full { - let dir = DEPENDENCY_DIR.join(sanitized_name); - if dir.exists() { - fs::remove_dir_all(&dir).map_err(|e| JanitorError::IOError { path: dir, source: e })?; - } - remove_lock(dependency).map_err(JanitorError::LockError)?; - } - Ok(()) -} - -#[cfg(test)] -#[allow(clippy::vec_init_then_push)] -mod tests { - use super::*; - use crate::{ - config::HttpDependency, - dependency_downloader::{ - clean_dependency_directory, download_dependencies, unzip_dependency, - }, - }; - use serial_test::serial; - - struct CleanupDependency; - impl Drop for CleanupDependency { - fn drop(&mut self) { - clean_dependency_directory(); - } - } - - #[tokio::test] - async fn healthcheck_dependency_not_found() { - let _ = healthcheck_dependency(&Dependency::Http(HttpDependency { - name: "test".to_string(), - version: "1.0.0".to_string(), - url: None, - checksum: None, - })) - .unwrap_err(); - } - - #[tokio::test] - #[serial] - async fn healthcheck_dependency_found() { - let _cleanup = CleanupDependency; - - let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None})); - download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); - healthcheck_dependency(&dependencies[0]).unwrap(); - } - - #[tokio::test] - #[serial] - async fn cleanup_existing_dependency() { - let _cleanup = CleanupDependency; - - let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None })); - download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); - cleanup_dependency(&dependencies[0], false).unwrap(); - } - - #[test] - #[serial] - fn cleanup_nonexisting_dependency() { - let _cleanup = CleanupDependency; - - let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "v-cleanup-nonexisting".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None})); - match cleanup_dependency(&dependencies[0], false) { - Ok(_) => {} - Err(error) => { - println!("Error {:?}", error); - assert_eq!("Invalid State", ""); - } - }; - } - - #[tokio::test] - #[serial] - async fn cleanup_after_existing_dependency() { - let _cleanup = CleanupDependency; - - let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None})); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.4.0".to_string(), - url:Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string()), - checksum: None })); - - download_dependencies(&dependencies, false).await.unwrap(); - let _ = unzip_dependency(dependencies[0].as_http().unwrap()); - let result: Result<()> = cleanup_after(&dependencies); - assert!(result.is_ok()); - clean_dependency_directory(); - } - - #[tokio::test] - #[serial] - async fn cleanup_after_one_existing_one_not_existing_dependency() { - let _cleanup = CleanupDependency; - - let mut dependencies: Vec = Vec::new(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "cleanup-after-one-existing".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None})); - - download_dependencies(&dependencies, false).await.unwrap(); - unzip_dependency(dependencies[0].as_http().unwrap()).unwrap(); - dependencies.push(Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "cleanup-after-one-existing-2".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.4.0.zip".to_string()), - checksum: None})); - match cleanup_after(&dependencies) { - Ok(_) => {} - Err(error) => { - println!("Error {:?}", error); - assert_eq!("Invalid State", ""); - } - } - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index fcd59d6..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,1820 +0,0 @@ -//! # soldeer -//! -//! Solidity package manager. - -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] - -use crate::{ - auth::login, - config::{delete_config, read_config_deps, remappings_txt, Dependency}, - dependency_downloader::{ - delete_dependency_files, download_dependencies, install_subdependencies, - unzip_dependencies, unzip_dependency, - }, - janitor::{cleanup_after, healthcheck_dependencies}, - lock::{lock_check, remove_lock, write_lock}, - utils::{check_dotfiles_recursive, get_current_working_dir, prompt_user_for_confirmation}, - versioning::push_version, -}; -pub use crate::{commands::Subcommands, errors::SoldeerError}; -use config::{ - add_to_config, get_config_path, read_soldeer_config, remappings_foundry, GitDependency, - GitIdentifier, HttpDependency, RemappingsAction, RemappingsLocation, -}; -use dependency_downloader::download_dependency; -use janitor::cleanup_dependency; -use lock::LockWriteMode; -use remote::get_latest_forge_std_dependency; -use std::{env, path::PathBuf, sync::LazyLock}; -use utils::{get_url_type, UrlType}; -use versioning::validate_name; -use yansi::Paint as _; - -mod auth; -pub mod commands; -mod config; -mod dependency_downloader; -pub mod errors; -mod janitor; -mod lock; -mod remote; -mod utils; -mod versioning; - -pub static DEPENDENCY_DIR: LazyLock = - LazyLock::new(|| get_current_working_dir().join("dependencies/")); -pub static LOCK_FILE: LazyLock = - LazyLock::new(|| get_current_working_dir().join("soldeer.lock")); -pub static SOLDEER_CONFIG_FILE: LazyLock = - LazyLock::new(|| get_current_working_dir().join("soldeer.toml")); -pub static FOUNDRY_CONFIG_FILE: LazyLock = - LazyLock::new(|| get_current_working_dir().join("foundry.toml")); - -#[tokio::main] -pub async fn run(command: Subcommands) -> Result<(), SoldeerError> { - match command { - Subcommands::Init(init) => { - println!("{}", "🦌 Running Soldeer init 🦌".green()); - println!("{}", "Initializes a new Soldeer project in foundry".green()); - - if init.clean { - config::remove_forge_lib()?; - } - - let dependency: Dependency = get_latest_forge_std_dependency().await.map_err(|e| { - SoldeerError::DownloadError { dep: "forge-std".to_string(), source: e } - })?; - install_dependency(dependency, true, false).await?; - } - Subcommands::Install(install) => { - let regenerate_remappings = install.regenerate_remappings; - let Some(dependency) = install.dependency else { - return update(regenerate_remappings, install.recursive_deps).await; - // TODO: instead, check which - // dependencies - // do - // not match the - // integrity checksum and install those - }; - - println!("{}", "🦌 Running Soldeer install 🦌".green()); - let (dependency_name, dependency_version) = - dependency.split_once('~').expect("dependency string should have name and version"); - - let dep = match install.remote_url { - Some(url) => match get_url_type(&url) { - UrlType::Git => { - let identifier = match (install.rev, install.branch, install.tag) { - (Some(rev), None, None) => Some(GitIdentifier::from_rev(rev)), - (None, Some(branch), None) => Some(GitIdentifier::from_branch(branch)), - (None, None, Some(tag)) => Some(GitIdentifier::from_tag(tag)), - (None, None, None) => None, - _ => { - unreachable!("clap validation should prevent this from happening") - } - }; - Dependency::Git(GitDependency { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - git: url, - identifier, - }) - } - UrlType::Http => Dependency::Http(HttpDependency { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - url: Some(url), - checksum: None, - }), - }, - None => Dependency::Http(HttpDependency { - name: dependency_name.to_string(), - version: dependency_version.to_string(), - url: None, - checksum: None, - }), - }; - - install_dependency(dep, regenerate_remappings, install.recursive_deps).await?; - } - Subcommands::Update(update_args) => { - return update(update_args.regenerate_remappings, update_args.recursive_deps).await; - } - Subcommands::Login(_) => { - println!("{}", "🦌 Running Soldeer login 🦌".green()); - login().await?; - } - Subcommands::Push(push) => { - let path = push.path.unwrap_or(get_current_working_dir()); - let dry_run = push.dry_run; - let skip_warnings = push.skip_warnings; - - // Check for sensitive files or directories - if !dry_run && - !skip_warnings && - check_dotfiles_recursive(&path) && - !prompt_user_for_confirmation() - { - println!("{}", "Push operation aborted by the user.".yellow()); - return Ok(()); - } - - if dry_run { - println!( - "{}", - "🦌 Running Soldeer push with dry-run, a zip file will be available for inspection 🦌".green() - ); - } else { - println!("{}", "🦌 Running Soldeer push 🦌".green()); - } - - if skip_warnings { - println!("{}", "Warning: Skipping sensitive file checks as requested.".yellow()); - } - - let (dependency_name, dependency_version) = push - .dependency - .split_once('~') - .expect("dependency string should have name and version"); - - validate_name(dependency_name)?; - - push_version(dependency_name, dependency_version, path, dry_run).await?; - } - - Subcommands::Uninstall(uninstall) => { - // define the config file - let config_path = get_config_path()?; - - // delete from the config file and return the dependency - let dependency = delete_config(&uninstall.dependency, &config_path)?; - - // deleting the files - delete_dependency_files(&dependency).map_err(|e| SoldeerError::DownloadError { - dep: dependency.to_string(), - source: e, - })?; - - // removing the dependency from the lock file - remove_lock(&dependency)?; - - let config = read_soldeer_config(Some(config_path.clone()))?; - - if config.remappings_generate { - if config_path.to_string_lossy().contains("foundry.toml") { - match config.remappings_location { - RemappingsLocation::Txt => { - remappings_txt( - &RemappingsAction::Remove(dependency), - &config_path, - &config, - ) - .await? - } - RemappingsLocation::Config => { - remappings_foundry( - &RemappingsAction::Remove(dependency), - &config_path, - &config, - ) - .await? - } - } - } else { - remappings_txt(&RemappingsAction::Remove(dependency), &config_path, &config) - .await?; - } - } - } - - Subcommands::Version(_) => { - const VERSION: &str = env!("CARGO_PKG_VERSION"); - println!("{}", format!("Current Soldeer {}", VERSION).cyan()); - } - } - Ok(()) -} - -async fn install_dependency( - mut dependency: Dependency, - regenerate_remappings: bool, - recursive_deps: bool, -) -> Result<(), SoldeerError> { - lock_check(&dependency, true)?; - - let config_path = match get_config_path() { - Ok(file) => file, - Err(e) => { - cleanup_dependency(&dependency, true)?; - return Err(e.into()); - } - }; - - let mut config = read_soldeer_config(Some(config_path.clone()))?; - if regenerate_remappings { - config.remappings_regenerate = regenerate_remappings; - } - - if recursive_deps { - config.recursive_deps = recursive_deps; - } - - let result = download_dependency(&dependency, false) - .await - .map_err(|e| SoldeerError::DownloadError { dep: dependency.to_string(), source: e })?; - match dependency { - Dependency::Http(ref mut dep) => { - add_to_config(&dep.clone().into(), &config_path)?; - dep.checksum = Some(result.hash); - dep.url = Some(result.url); - } - Dependency::Git(ref mut dep) => { - if dep.identifier.is_none() { - dep.identifier = Some(GitIdentifier::from_rev(result.hash)); - } - add_to_config(&dependency, &config_path)?; - } - } - - let integrity = match &dependency { - Dependency::Http(dep) => match unzip_dependency(dep) { - Ok(i) => Some(i), - Err(e) => { - cleanup_dependency(&dependency, true)?; - return Err(SoldeerError::DownloadError { dep: dependency.to_string(), source: e }); - } - }, - Dependency::Git(_) => None, - }; - - write_lock(&[dependency.clone()], &[integrity], LockWriteMode::Append)?; - - janitor::healthcheck_dependency(&dependency)?; - - janitor::cleanup_dependency(&dependency, false)?; - - if config.recursive_deps { - if let Err(e) = install_subdependencies(&dependency) { - return Err(SoldeerError::DownloadError { dep: dependency.to_string(), source: e }); - }; - } - - if config.remappings_generate { - if config_path.to_string_lossy().contains("foundry.toml") { - match config.remappings_location { - RemappingsLocation::Txt => { - remappings_txt(&RemappingsAction::Add(dependency), &config_path, &config) - .await? - } - RemappingsLocation::Config => { - remappings_foundry(&RemappingsAction::Add(dependency), &config_path, &config) - .await? - } - } - } else { - remappings_txt(&RemappingsAction::Add(dependency), &config_path, &config).await?; - } - } - - Ok(()) -} - -async fn update(regenerate_remappings: bool, recursive_deps: bool) -> Result<(), SoldeerError> { - println!("{}", "🦌 Running Soldeer update 🦌".green()); - - let config_path = get_config_path()?; - let mut config = read_soldeer_config(Some(config_path.clone()))?; - if regenerate_remappings { - config.remappings_regenerate = regenerate_remappings; - } - - if recursive_deps { - config.recursive_deps = recursive_deps; - } - - let mut dependencies: Vec = read_config_deps(None)?; - - let results = download_dependencies(&dependencies, true) - .await - .map_err(|e| SoldeerError::DownloadError { dep: String::new(), source: e })?; - - dependencies.iter_mut().zip(results.into_iter()).for_each(|(dependency, result)| { - match dependency { - Dependency::Http(ref mut dep) => { - dep.checksum = Some(result.hash); - dep.url = Some(result.url); - } - Dependency::Git(ref mut dep) => { - dep.identifier = Some(GitIdentifier::from_rev(result.hash)) - } - } - }); - - let integrities = unzip_dependencies(&dependencies) - .map_err(|e| SoldeerError::DownloadError { dep: String::new(), source: e })?; - - healthcheck_dependencies(&dependencies)?; - - cleanup_after(&dependencies)?; - - write_lock(&dependencies, &integrities, LockWriteMode::Replace)?; - - if config.remappings_generate { - if config_path.to_string_lossy().contains("foundry.toml") { - match config.remappings_location { - RemappingsLocation::Txt => { - remappings_txt(&RemappingsAction::None, &config_path, &config).await? - } - RemappingsLocation::Config => { - remappings_foundry(&RemappingsAction::None, &config_path, &config).await? - } - } - } else { - remappings_txt(&RemappingsAction::None, &config_path, &config).await?; - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use commands::{Init, Install, Push, Update}; - use rand::{distributions::Alphanumeric, Rng}; - use serial_test::serial; - use std::{ - env::{self}, - fs::{self, create_dir_all, remove_dir, remove_dir_all, remove_file, File}, - io::Write, - path::{Path, PathBuf}, - }; - use utils::read_file_to_string; - use zip::ZipArchive; // 0.8 - - #[test] - #[serial] - fn soldeer_install_moves_to_update_no_custom_link() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.6.1" -"@openzeppelin-contracts" = "5.0.2" -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: None, - remote_url: None, - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let mut path_dependency = DEPENDENCY_DIR.join("@gearbox-protocol-periphery-v3-1.6.1"); - - assert!(path_dependency.exists()); - path_dependency = DEPENDENCY_DIR.join("@openzeppelin-contracts-5.0.2"); - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_install_from_git_no_rev_adds_rev_to_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("test~1".to_string()), - remote_url: Some("https://gitlab.com/mario4582928/Mario.git".to_string()), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("test-1"); - - assert!(path_dependency.exists()); - - let expected_content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -test = { version = "1", git = "https://gitlab.com/mario4582928/Mario.git", rev = "22868f426bd4dd0e682b5ec5f9bd55507664240c" } -"#; - assert_eq!(expected_content, read_file_to_string(&target_config)); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_install_from_git_with_rev_adds_rev_to_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("test~1".to_string()), - remote_url: Some("https://gitlab.com/mario4582928/Mario.git".to_string()), - rev: Some("2fd642069600f0b8da3e1897fad42b2c53c6e927".to_string()), - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("test-1"); - - assert!(path_dependency.exists()); - - let expected_content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -test = { version = "1", git = "https://gitlab.com/mario4582928/Mario.git", rev = "2fd642069600f0b8da3e1897fad42b2c53c6e927" } -"#; - assert_eq!(expected_content, read_file_to_string(&target_config)); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_install_moves_to_update_custom_link() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"@tt" = {version = "1.6.1", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip"} -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: None, - remote_url: None, - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("@tt-1.6.1"); - - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_update_success() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"@tt" = {version = "1.6.1", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip"} -forge-std = { version = "1.8.1" } -solmate = "6.7.0" -mario = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", rev = "22868f426bd4dd0e682b5ec5f9bd55507664240c" } -mario-custom-tag = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-tag" } -mario-custom-branch = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-branch" } - -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = - Subcommands::Update(Update { regenerate_remappings: false, recursive_deps: false }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("@tt-1.6.1"); - - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_update_success_with_soldeer_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let path_remappings = get_current_working_dir().join("remappings.txt"); - - let _ = remove_file(&path_remappings); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"@tt" = {version = "1.6.1", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip"} -forge-std = { version = "1.8.1" } -solmate = "6.7.0" -mario = { version = "1.0", git = "https://gitlab.com/mario4582928/mario-soldeer-dependency.git", rev = "9800b422749c438fb59f289f3c2d5b1a173707ea" } - -[soldeer] -remappings_generate = true -remappings_regenerate = true -remappings_location = "config" -remappings_prefix = "@custom@" -recursive_deps = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = - Subcommands::Update(Update { regenerate_remappings: false, recursive_deps: false }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("@tt-1.6.1"); - assert!(path_dependency.exists()); - - let path_dependency = DEPENDENCY_DIR.join("forge-std-1.8.1"); - assert!(path_dependency.exists()); - - let path_dependency = DEPENDENCY_DIR.join("solmate-6.7.0"); - assert!(path_dependency.exists()); - - let path_dependency = DEPENDENCY_DIR.join("mario-1.0"); - assert!(path_dependency.exists()); - - let expected_remappings = r#"@custom@@tt-1.6.1/=dependencies/@tt-1.6.1/ -@custom@forge-std-1.8.1/=dependencies/forge-std-1.8.1/ -@custom@mario-1.0/=dependencies/mario-1.0/ -@custom@solmate-6.7.0/=dependencies/solmate-6.7.0/ -"#; - assert_eq!(expected_remappings, read_file_to_string(path_remappings)); - - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_update_with_git_and_http_success() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"@dep1" = {version = "1", url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/3_3_0-rc_2_22-01-2024_13:12:57_contracts.zip"} -"@dep2" = {version = "2", git = "https://gitlab.com/mario4582928/Mario.git", rev="22868f426bd4dd0e682b5ec5f9bd55507664240c" } -"@dep3" = {version = "3.3", git = "https://gitlab.com/mario4582928/Mario.git", rev="7a0663eaf7488732f39550be655bad6694974cb3" } -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = - Subcommands::Update(Update { regenerate_remappings: false, recursive_deps: false }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - // http dependency should be there - let path_dependency = - DEPENDENCY_DIR.join("@dep1-1").join("token").join("ERC20").join("ERC20.sol"); - assert!(path_dependency.exists()); - - // git dependency should be there without specified revision - let path_dependency = DEPENDENCY_DIR.join("@dep2-2").join("JustATest3.md"); - assert!(path_dependency.exists()); - - // git dependency should be there with specified revision - let path_dependency = DEPENDENCY_DIR.join("@dep3-3.3").join("JustATest2.md"); - assert!(path_dependency.exists()); - - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_update_dependencies_fails_when_one_dependency_fails() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"@gearbox-protocol-periphery-v3" = "1.6.1" -"@openzeppelin-contracts" = "5.0.2" -"will-not-fail" = {version = "1", url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip"} -"will-fail" = {version = "1", url="https://will-not-work"} -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: None, - remote_url: None, - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - clean_test_env(target_config.clone()); - // can not generalize as diff systems return various dns errors - assert!(err.to_string().contains("error sending request for url")) - } - } - - let mut path_dependency = DEPENDENCY_DIR.join("@gearbox-protocol-periphery-v3-1.6.1"); - - assert!(!path_dependency.exists()); - path_dependency = DEPENDENCY_DIR.join("@openzeppelin-contracts-5.0.2"); - assert!(!path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_push_dry_run() { - // in case this exists we clean it before setting up the tests - let path_dependency = env::current_dir().unwrap().join("test").join("custom_dry_run"); - - if path_dependency.exists() { - let _ = remove_dir_all(&path_dependency); - } - - let _ = create_dir_all(&path_dependency); - - create_random_file(path_dependency.as_path(), ".txt".to_string()); - create_random_file(path_dependency.as_path(), ".txt".to_string()); - - let command = Subcommands::Push(Push { - dependency: "@test~1.1".to_string(), - path: Some(path_dependency.clone()), - dry_run: true, - skip_warnings: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(PathBuf::default()); - assert_eq!("Invalid State", "") - } - } - - let archive = File::open(path_dependency.join("custom_dry_run.zip")); - let archive = ZipArchive::new(archive.unwrap()); - - assert!(path_dependency.exists()); - assert_eq!(archive.unwrap().len(), 2); - - let _ = remove_dir_all(path_dependency); - } - - #[test] - #[serial] - fn push_prompts_user_on_sensitive_files() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test_push_sensitive"); - let _ = remove_dir(&test_dir); - let _ = create_dir_all(&test_dir); - - // Create a .env file in the test directory - let env_file_path = test_dir.join(".env"); - let mut env_file = File::create(&env_file_path).unwrap(); - writeln!(env_file, "SENSITIVE_DATA=secret").unwrap(); - - let command = Subcommands::Push(Push { - dependency: "@test~1.1".to_string(), - path: Some(test_dir.clone()), - dry_run: false, - skip_warnings: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(PathBuf::default()); - assert_eq!("Invalid State", "") - } - } - - // Check if the .env file exists - assert!(env_file_path.exists()); - - // Clean up - let _ = remove_file(&env_file_path); - let _ = remove_dir_all(&test_dir); - } - - #[test] - #[serial] - fn push_skips_warning_on_sensitive_files() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("test_push_skip_sensitive"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - // Create a .env file in the test directory - let env_file_path = test_dir.join(".env"); - let mut env_file = File::create(&env_file_path).unwrap(); - writeln!(env_file, "SENSITIVE_DATA=secret").unwrap(); - - let command = Subcommands::Push(Push { - dependency: "@test~1.1".to_string(), - path: Some(test_dir.clone()), - dry_run: false, - skip_warnings: true, - }); - - match run(command) { - Ok(_) => { - println!("Push command succeeded as expected"); - } - Err(e) => { - clean_test_env(PathBuf::default()); - - // Check if the error is due to not being logged in - if e.to_string().contains("you are not connected") { - println!( - "Test skipped: User not logged in. This test requires a logged-in state." - ); - return; - } - - // If it's a different error, fail the test - panic!("Push command failed unexpectedly: {:?}", e); - } - } - - // Check if the .env file still exists (it should) - assert!( - env_file_path.exists(), - "The .env file should still exist after the push operation" - ); - - // Clean up - let _ = remove_file(&env_file_path); - let _ = remove_dir_all(&test_dir); - } - - #[test] - #[serial] - fn install_dependency_remote_url() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("forge-std~1.9.1".to_string()), - remote_url: Option::None, - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("forge-std-1.9.1").join("src").join("Test.sol"); - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn install_dependency_custom_url_chooses_http() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("forge-std~1.9.1".to_string()), - remote_url: Some("https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip".to_string()), - rev: None, - tag: None, -branch: None, - regenerate_remappings: false, - recursive_deps: false - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("forge-std-1.9.1").join("src").join("Test.sol"); - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn install_dependency_custom_git_httpurl_chooses_git() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("forge-std~1.9.1".to_string()), - remote_url: Some("https://github.com/foundry-rs/forge-std.git".to_string()), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("forge-std-1.9.1").join("src").join("Test.sol"); - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn install_dependency_custom_git_giturl_chooses_git() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("forge-std~1.9.1".to_string()), - remote_url: Some("https://github.com/foundry-rs/forge-std.git".to_string()), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("forge-std-1.9.1").join("src").join("Test.sol"); - assert!(path_dependency.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn install_dependency_custom_git_giturl_custom_commit() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("forge-std~1.9.1".to_string()), - remote_url: Some("https://github.com/foundry-rs/forge-std.git".to_string()), - rev: Some("3778c3cb8e4244cb5a1c3ef3ce1c71a3683e324a".to_string()), - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let mut path_dependency = - DEPENDENCY_DIR.join("forge-std-1.9.1").join("src").join("mocks").join("MockERC721.sol"); - assert!(!path_dependency.exists()); // this should not exists at that commit - path_dependency = DEPENDENCY_DIR.join("forge-std-1.9.1").join("src").join("Test.sol"); - assert!(path_dependency.exists()); // this should exists at that commit - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -forge-std = { version = "1.9.1", git = "https://github.com/foundry-rs/forge-std.git", rev = "3778c3cb8e4244cb5a1c3ef3ce1c71a3683e324a" } -"#; - assert_eq!(read_file_to_string(&target_config), content); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn install_dependency_custom_git_giturl_custom_tag() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("dep~1".to_string()), - remote_url: Some("https://gitlab.com/mario4582928/Mario.git".to_string()), - rev: None, - tag: Some("custom-tag".to_string()), - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("dep-1").join("CustomTagFileBranch"); - assert!(path_dependency.exists()); // this should exists at that tag - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep = { version = "1", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-tag" } -"#; - assert_eq!(read_file_to_string(&target_config), content); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn install_dependency_custom_git_giturl_custom_branch() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let test_dir = env::current_dir().unwrap().join("test").join("install_http"); - - // Create test directory - if !test_dir.exists() { - std::fs::create_dir(&test_dir).unwrap(); - } - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("dep~1".to_string()), - remote_url: Some("https://gitlab.com/mario4582928/Mario.git".to_string()), - rev: None, - tag: None, - branch: Some("custom-branch".to_string()), - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("dep-1").join("CustomFileBranch"); - assert!(path_dependency.exists()); // this should exists at that branch - - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -dep = { version = "1", git = "https://gitlab.com/mario4582928/Mario.git", branch = "custom-branch" } -"#; - assert_eq!(read_file_to_string(&target_config), content); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_init_should_install_forge() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - - let target_config = define_config(true); - let content = String::new(); - write_to_config(&target_config, &content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Init(Init { clean: false }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let lock_test = get_current_working_dir().join("test").join("soldeer.lock"); - assert!(find_forge_std_path().exists()); - assert!(lock_test.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn soldeer_init_clean_should_delete_git_submodules() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - - let submodules_path = get_current_working_dir().join(".gitmodules"); - let lib_path = get_current_working_dir().join("lib"); - let lock_test = get_current_working_dir().join("test").join("soldeer.lock"); - - //remove it just in case - let _ = remove_file(&submodules_path); - let _ = remove_dir_all(&lib_path); - let _ = remove_file(&lock_test); - - fs::write(&submodules_path, "this is a test file").unwrap(); - let _ = create_dir_all(&lib_path); - - let target_config = define_config(true); - let content = String::new(); - write_to_config(&target_config, &content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Init(Init { clean: true }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - assert!(find_forge_std_path().exists()); - assert!(lock_test.exists()); - assert!(!submodules_path.exists()); - assert!(!lib_path.exists()); - clean_test_env(target_config); - let _ = remove_file(submodules_path); - let _ = remove_dir_all(lib_path); - } - - #[test] - #[serial] - fn download_dependency_with_subdependencies_on_soldeer_success_arg_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("dep1~1.0".to_string()), - remote_url: Some( - "https://gitlab.com/mario4582928/mario-soldeer-dependency.git".to_string(), - ), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: true, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dir = DEPENDENCY_DIR.join("dep1-1.0"); - assert!(path_dir.exists()); - let path_dir = DEPENDENCY_DIR - .join("dep1-1.0") - .join("dependencies") - .join("@openzeppelin-contracts-5.0.2") - .join("token"); - assert!(path_dir.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn download_dependency_with_subdependencies_on_soldeer_success_soldeer_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] - -[soldeer] -recursive_deps = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("dep1~1.0".to_string()), - remote_url: Some( - "https://gitlab.com/mario4582928/mario-soldeer-dependency.git".to_string(), - ), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dir = DEPENDENCY_DIR.join("dep1-1.0"); - assert!(path_dir.exists()); - let path_dir = DEPENDENCY_DIR - .join("dep1-1.0") - .join("dependencies") - .join("@openzeppelin-contracts-5.0.2") - .join("token"); - assert!(path_dir.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn download_dependency_with_subdependencies_on_git_success_arg_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("dep1~1.0".to_string()), - remote_url: Some("https://gitlab.com/mario4582928/mario-git-submodule.git".to_string()), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: true, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dir = DEPENDENCY_DIR.join("dep1-1.0"); - assert!(path_dir.exists()); - let path_dir = - DEPENDENCY_DIR.join("dep1-1.0").join("lib").join("mario").join("foundry.toml"); - assert!(path_dir.exists()); - clean_test_env(target_config); - } - - #[test] - #[serial] - fn download_dependency_with_subdependencies_on_git_success_soldeer_config() { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let content = r#" -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] - -[soldeer] -recursive_deps = true -"#; - - let target_config = define_config(true); - - write_to_config(&target_config, content); - - unsafe { - // became unsafe in Rust 1.80 - env::set_var("base_url", "https://api.soldeer.xyz"); - } - - let command = Subcommands::Install(Install { - dependency: Some("dep1~1.0".to_string()), - remote_url: Some("https://gitlab.com/mario4582928/mario-git-submodule.git".to_string()), - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match run(command) { - Ok(_) => {} - Err(err) => { - println!("Error occurred {:?}", err); - clean_test_env(target_config.clone()); - assert_eq!("Invalid State", "") - } - } - - let path_dir = DEPENDENCY_DIR.join("dep1-1.0"); - assert!(path_dir.exists()); - let path_dir = - DEPENDENCY_DIR.join("dep1-1.0").join("lib").join("mario").join("foundry.toml"); - assert!(path_dir.exists()); - clean_test_env(target_config); - } - - fn clean_test_env(target_config: PathBuf) { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - if target_config != PathBuf::default() { - let _ = remove_file(&target_config); - let parent = target_config.parent(); - let lock = parent.unwrap().join("soldeer.lock"); - let _ = remove_file(lock); - } - } - - fn write_to_config(target_file: &PathBuf, content: &str) { - if target_file.exists() { - let _ = remove_file(target_file); - } - let mut file: File = - fs::OpenOptions::new().create_new(true).write(true).open(target_file).unwrap(); - if let Err(e) = write!(file, "{}", content) { - eprintln!("Couldn't write to the config file: {}", e); - } - } - - fn define_config(foundry: bool) -> PathBuf { - let s: String = - rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); - let mut target = format!("foundry{}.toml", s); - if !foundry { - target = format!("Soldeer{}.toml", s); - } - - let path = env::current_dir().unwrap().join("test").join(target); - unsafe { - // became unsafe in Rust 1.80 - env::set_var("config_file", path.to_string_lossy().to_string()); - } - path - } - - fn create_random_file(target_dir: &Path, extension: String) -> String { - let s: String = - rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); - let target = target_dir.join(format!("random{}.{}", s, extension)); - let mut file: std::fs::File = - fs::OpenOptions::new().create_new(true).write(true).open(&target).unwrap(); - if let Err(e) = write!(file, "this is a test file") { - eprintln!("Couldn't write to the config file: {}", e); - } - String::from(target.to_str().unwrap()) - } - - fn find_forge_std_path() -> PathBuf { - for entry in fs::read_dir(DEPENDENCY_DIR.clone()).unwrap().filter_map(Result::ok) { - let path = entry.path(); - if path.is_dir() && - path.file_name().unwrap().to_string_lossy().starts_with("forge-std-") - { - return path; - } - } - panic!("could not find forge-std folder in dependency dir"); - } -} diff --git a/src/lock.rs b/src/lock.rs deleted file mode 100644 index 8dada49..0000000 --- a/src/lock.rs +++ /dev/null @@ -1,439 +0,0 @@ -use crate::{ - config::Dependency, - dependency_downloader::IntegrityChecksum, - errors::LockError, - utils::{get_current_working_dir, read_file_to_string}, - LOCK_FILE, -}; -use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf}; -use yansi::Paint as _; - -pub type Result = std::result::Result; - -// Top level struct to hold the TOML data. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub struct LockEntry { - name: String, - version: String, - source: String, - checksum: String, - integrity: Option, -} - -impl LockEntry { - #[must_use] - pub fn new( - name: impl Into, - version: impl Into, - source: impl Into, - checksum: impl Into, - integrity: Option, - ) -> Self { - LockEntry { - name: name.into(), - version: version.into(), - source: source.into(), - checksum: checksum.into(), - integrity, - } - } -} - -pub fn lock_check(dependency: &Dependency, allow_missing_lockfile: bool) -> Result<()> { - let lock_entries = match read_lock() { - Ok(entries) => entries, - Err(e) => { - if allow_missing_lockfile { - return Ok(()); - } - return Err(e); - } - }; - - let is_locked = lock_entries.iter().any(|lock_entry| { - lock_entry.name == dependency.name() && lock_entry.version == dependency.version() - }); - - if is_locked { - return Err(LockError::DependencyInstalled(dependency.to_string())); - } - Ok(()) -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum LockWriteMode { - Replace, - Append, -} - -pub fn write_lock( - dependencies: &[Dependency], - integrity_checksums: &[Option], - mode: LockWriteMode, -) -> Result<()> { - let lock_file: PathBuf = if cfg!(test) { - get_current_working_dir().join("test").join("soldeer.lock") - } else { - LOCK_FILE.clone() - }; - - if mode == LockWriteMode::Replace && lock_file.exists() { - fs::remove_file(&lock_file)?; - } - - if !lock_file.exists() { - fs::File::create(&lock_file)?; - } - - let mut entries = read_lock()?; - for (dep, integrity) in dependencies.iter().zip(integrity_checksums.iter()) { - let entry = match dep { - Dependency::Http(dep) => LockEntry::new( - &dep.name, - &dep.version, - dep.url.as_ref().unwrap(), - dep.checksum.as_ref().unwrap(), - integrity.clone().map(|c| c.to_string()), - ), - Dependency::Git(dep) => LockEntry::new( - &dep.name, - &dep.version, - &dep.git, - dep.identifier.as_ref().unwrap().to_string(), - None, - ), - }; - // check for entry already existing - match entries.iter().position(|e| e.name == entry.name && e.version == entry.version) { - Some(pos) => { - println!("{}", format!("Updating {dep} in the lock file.").green()); - // replace the entry with the new data - entries[pos] = entry; - } - None => { - println!( - "{}", - format!("Writing {}~{} to the lock file.", entry.name, entry.version).green() - ); - entries.push(entry); - } - } - } - // make sure the ordering is consistent - entries.sort_unstable_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); - - if entries.is_empty() { - // remove lock file if there are no deps left - let _ = fs::remove_file(&lock_file); - return Ok(()); - } - - let file_contents = toml_edit::ser::to_string_pretty(&LockType { dependencies: entries })?; - - // replace contents of lockfile with new contents - fs::write(lock_file, file_contents)?; - Ok(()) -} - -pub fn remove_lock(dependency: &Dependency) -> Result<()> { - let lock_file: PathBuf = if cfg!(test) { - get_current_working_dir().join("test").join("soldeer.lock") - } else { - LOCK_FILE.clone() - }; - // check if the lock exists, if does not we don't have what to delete - if !lock_file.exists() { - return Ok(()); - } - - let entries: Vec<_> = read_lock()? - .into_iter() - .filter(|e| e.name != dependency.name() || e.version != dependency.version()) - .collect(); - - if entries.is_empty() { - // remove lock file if there are no deps left - let _ = fs::remove_file(&lock_file); - return Ok(()); - } - - let file_contents = toml_edit::ser::to_string_pretty(&LockType { dependencies: entries })?; - - // replace contents of lockfile with new contents - fs::write(lock_file, file_contents)?; - - Ok(()) -} - -// Top level struct to hold the TOML data. -#[derive(Serialize, Deserialize, Debug, Default)] -struct LockType { - dependencies: Vec, -} - -fn read_lock() -> Result> { - let lock_file: PathBuf = if cfg!(test) { - get_current_working_dir().join("test").join("soldeer.lock") - } else { - LOCK_FILE.clone() - }; - - if !lock_file.exists() { - return Err(LockError::Missing); - } - let contents = read_file_to_string(lock_file); - - // parse file contents - let data: LockType = toml_edit::de::from_str(&contents).unwrap_or_default(); - Ok(data.dependencies) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - config::{Dependency, HttpDependency}, - utils::read_file_to_string, - }; - use serial_test::serial; - use std::{fs::File, io::Write}; - - fn check_lock_file() -> PathBuf { - let lock_file: PathBuf = get_current_working_dir().join("test").join("soldeer.lock"); - if lock_file.exists() { - fs::remove_file(&lock_file).unwrap(); - } - lock_file - } - - pub fn initialize() { - let lock_file = check_lock_file(); - let lock_contents = r#" -[[dependencies]] -name = "@openzeppelin-contracts" -version = "2.3.0" -source = "registry+https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip" -checksum = "a2d469062adeb62f7a4aada78237acae4ad3c168ba65c3ac9c76e290332c11ec" -integrity = "deadbeef" - -[[dependencies]] -name = "@prb-test" -version = "0.6.5" -source = "registry+https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@prb-test~0.6.5.zip" -checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" -integrity = "deadbeef" -"#; - File::create(lock_file).unwrap().write_all(lock_contents.as_bytes()).unwrap(); - } - - #[test] - #[serial] - fn lock_file_not_present_test() { - let lock_file = check_lock_file(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - - assert!(matches!(lock_check(&dependency, false), Err(LockError::Missing))); - - assert!(!lock_file.exists()); - } - - #[test] - #[serial] - fn check_lock_all_locked_test() { - initialize(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.3.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip".to_string()), - checksum: None - }); - - assert!(matches!(lock_check(&dependency, false), Err(LockError::DependencyInstalled(_)))); - } - - #[test] - #[serial] - fn write_clean_lock_test() { - let lock_file = check_lock_file(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.5.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), - checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) - }); - let dependencies = vec![dependency.clone()]; - write_lock(&dependencies, &[Some("deadbeef".into())], LockWriteMode::Append).unwrap(); - assert!(matches!(lock_check(&dependency, true), Err(LockError::DependencyInstalled(_)))); - - let contents = read_file_to_string(lock_file); - - assert_eq!( - contents, - r#"[[dependencies]] -name = "@openzeppelin-contracts" -version = "2.5.0" -source = "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip" -checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" -integrity = "deadbeef" -"# - ); - assert!(matches!(lock_check(&dependency, true), Err(LockError::DependencyInstalled(_)))); - } - - #[test] - #[serial] - fn write_append_lock_test() { - let lock_file = check_lock_file(); - initialize(); - let mut dependencies: Vec = Vec::new(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts-2".to_string(), - version: "2.6.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.6.0.zip".to_string()), - checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) - }); - dependencies.push(dependency.clone()); - write_lock(&dependencies, &[Some("deadbeef".into())], LockWriteMode::Append).unwrap(); - let contents = read_file_to_string(lock_file); - - assert_eq!( - contents, - r#"[[dependencies]] -name = "@openzeppelin-contracts" -version = "2.3.0" -source = "registry+https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.3.0.zip" -checksum = "a2d469062adeb62f7a4aada78237acae4ad3c168ba65c3ac9c76e290332c11ec" -integrity = "deadbeef" - -[[dependencies]] -name = "@openzeppelin-contracts-2" -version = "2.6.0" -source = "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.6.0.zip" -checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" -integrity = "deadbeef" - -[[dependencies]] -name = "@prb-test" -version = "0.6.5" -source = "registry+https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@prb-test~0.6.5.zip" -checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" -integrity = "deadbeef" -"# - ); - - assert!(matches!(lock_check(&dependency, true), Err(LockError::DependencyInstalled(_)))); - } - - #[test] - #[serial] - fn remove_lock_single_success() { - let lock_file = check_lock_file(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.5.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), - checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) - }); - let dependencies = vec![dependency.clone()]; - write_lock(&dependencies, &[Some(IntegrityChecksum::default())], LockWriteMode::Append) - .unwrap(); - - match remove_lock(&dependency) { - Ok(_) => {} - Err(_) => { - assert_eq!("Invalid State", ""); - } - } - assert!(!lock_file.exists()); - } - - #[test] - #[serial] - fn remove_lock_multiple_success() { - let lock_file = check_lock_file(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.5.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), - checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) - }); - let dependency2 = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts2".to_string(), - version: "2.5.0".to_string(), - url: Some( "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), - checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) - }); - let dependencies = vec![dependency.clone(), dependency2]; - write_lock( - &dependencies, - &[Some("deadbeef".into()), Some("deadbeef".into())], - LockWriteMode::Append, - ) - .unwrap(); - - match remove_lock(&dependency) { - Ok(_) => {} - Err(_) => { - assert_eq!("Invalid State", ""); - } - } - let contents = read_file_to_string(lock_file); - - assert_eq!( - contents, - r#"[[dependencies]] -name = "@openzeppelin-contracts2" -version = "2.5.0" -source = "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip" -checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" -integrity = "deadbeef" -"# - ); - } - - #[test] - #[serial] - fn remove_lock_one_fails() { - let lock_file = check_lock_file(); - let dependency = Dependency::Http(HttpDependency { - name: "@openzeppelin-contracts".to_string(), - version: "2.5.0".to_string(), - url: Some("https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip".to_string()), - checksum: Some("5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016".to_string()) - }); - - let dependencies = vec![dependency.clone()]; - write_lock(&dependencies, &[Some("deadbeef".into())], LockWriteMode::Append).unwrap(); - - match remove_lock(&Dependency::Http(HttpDependency { - name: "non-existent".to_string(), - version: dependency.version().to_string(), - url: None, - checksum: None, - })) { - Ok(_) => {} - Err(_) => { - assert_eq!("Invalid State", ""); - } - } - let contents = read_file_to_string(lock_file); - - assert_eq!( - contents, - r#"[[dependencies]] -name = "@openzeppelin-contracts" -version = "2.5.0" -source = "https://github.com/mario-eth/soldeer-versions/raw/main/all_versions/@openzeppelin-contracts~2.5.0.zip" -checksum = "5019418b1e9128185398870f77a42e51d624c44315bb1572e7545be51d707016" -integrity = "deadbeef" -"# - ); - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 1171ded..0000000 --- a/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -use clap::Parser; -use soldeer::commands::Args; -use yansi::Paint as _; - -fn main() { - let args = Args::parse(); - match soldeer::run(args.command) { - Ok(_) => {} - Err(err) => { - eprintln!("{}", err.to_string().red()) - } - } -} diff --git a/src/remote.rs b/src/remote.rs deleted file mode 100644 index d20a411..0000000 --- a/src/remote.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::{ - config::{Dependency, HttpDependency}, - dependency_downloader::Result, - errors::DownloadError, - utils::get_base_url, -}; -use chrono::{DateTime, Utc}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; - -pub async fn get_dependency_url_remote(dependency: &Dependency) -> Result { - let url = format!( - "{}/api/v1/revision-cli?project_name={}&revision={}", - get_base_url(), - dependency.name(), - dependency.version() - ); - let req = Client::new().get(url); - if let Ok(response) = req.send().await { - if response.status().is_success() { - let response_text = response.text().await.unwrap(); - let revision = serde_json::from_str::(&response_text); - if let Ok(revision) = revision { - if revision.data.is_empty() { - return Err(DownloadError::URLNotFound(dependency.to_string())); - } - return Ok(revision.data[0].clone().url); - } - } - } - Err(DownloadError::URLNotFound(dependency.to_string())) -} - -//TODO clean this up and do error handling -pub async fn get_project_id(dependency_name: &str) -> Result { - let url = format!("{}/api/v1/project?project_name={}", get_base_url(), dependency_name); - let req = Client::new().get(url); - let get_project_response = req.send().await; - - if let Ok(response) = get_project_response { - if response.status().is_success() { - let response_text = response.text().await.unwrap(); - let project = serde_json::from_str::(&response_text); - match project { - Ok(project) => { - if !project.data.is_empty() { - return Ok(project.data[0].id.to_string()); - } - } - Err(_) => { - return Err(DownloadError::ProjectNotFound(dependency_name.to_string())); - } - } - } - } - Err(DownloadError::ProjectNotFound(dependency_name.to_string())) -} - -pub async fn get_latest_forge_std_dependency() -> Result { - let dependency_name = "forge-std"; - let url = format!( - "{}/api/v1/revision?project_name={}&offset=0&limit=1", - get_base_url(), - dependency_name - ); - let req = Client::new().get(url); - if let Ok(response) = req.send().await { - if response.status().is_success() { - let response_text = response.text().await.unwrap(); - let revision = serde_json::from_str::(&response_text); - if let Ok(revision) = revision { - if revision.data.is_empty() { - return Err(DownloadError::ForgeStdError); - } - return Ok(Dependency::Http(HttpDependency { - name: dependency_name.to_string(), - version: revision.data[0].clone().version, - url: Some(revision.data[0].clone().url), - checksum: None, - })); - } - } - } - Err(DownloadError::ForgeStdError) -} - -#[allow(non_snake_case)] -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Revision { - pub id: uuid::Uuid, - pub version: String, - pub internal_name: String, - pub url: String, - pub project_id: uuid::Uuid, - pub deleted: bool, - pub created_at: Option>, -} - -#[allow(non_snake_case)] -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct Project { - pub id: uuid::Uuid, - pub name: String, - pub description: String, - pub github_url: String, - pub user_id: uuid::Uuid, - pub deleted: Option, - pub created_at: Option>, - pub updated_at: Option>, -} - -#[allow(non_snake_case)] -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct RevisionResponse { - data: Vec, - status: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct ProjectResponse { - data: Vec, - status: String, -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index ddba652..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,369 +0,0 @@ -use crate::{ - config::HttpDependency, dependency_downloader::IntegrityChecksum, errors::DownloadError, -}; -use ignore::{WalkBuilder, WalkState}; -use path_slash::PathExt; -use regex::Regex; -use sha2::{Digest, Sha256}; -use std::{ - env, - fs::{self, File}, - io::{BufReader, Read, Write}, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, LazyLock, Mutex, - }, -}; -use yansi::Paint as _; - -static GIT_SSH_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"^(?:git@github\.com|git@gitlab)").expect("git ssh regex should compile") -}); -static GIT_HTTPS_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"^(?:https://github\.com|https://gitlab\.com).*\.git$") - .expect("git https regex should compile") -}); - -// get the current working directory -pub fn get_current_working_dir() -> PathBuf { - env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Read contents of file at path into a string, or panic -/// -/// # Panics -/// If the file cannot be read, due to it being non-existent, not a valid UTF-8 string, etc. -pub fn read_file_to_string(path: impl AsRef) -> String { - fs::read_to_string(path.as_ref()).unwrap_or_else(|_| { - panic!("Could not read file `{:?}`", path.as_ref()); - }) -} - -// read a file contents into a vector of bytes so we can unzip it -pub fn read_file(path: impl AsRef) -> Result, std::io::Error> { - fs::read(path) -} - -/// Get the location where the token file is stored or read from -/// -/// The token file is stored in the home directory of the user, or in the current working directory -/// if the home cannot be found, in a hidden folder called `.soldeer`. The token file is called -/// `.soldeer_login`. -/// -/// For reading (e.g. when pushing to the registry), the path can be overridden by -/// setting the `SOLDEER_LOGIN_FILE` environment variable. -/// For login, the custom path will only be used if the file already exists. -pub fn define_security_file_location() -> Result { - if cfg!(test) { - return Ok(PathBuf::from("./test_save_jwt")); - } - - if let Some(path) = env::var_os("SOLDEER_LOGIN_FILE") { - if !path.is_empty() && Path::new(&path).exists() { - return Ok(path.into()); - } - } - - // if home dir cannot be found, use the current working directory - let dir = home::home_dir().unwrap_or_else(get_current_working_dir); - let security_directory = dir.join(".soldeer"); - if !security_directory.exists() { - fs::create_dir(&security_directory)?; - } - let security_file = security_directory.join(".soldeer_login"); - Ok(security_file) -} - -pub fn get_base_url() -> String { - if cfg!(test) { - env::var("base_url").unwrap_or_else(|_| "http://0.0.0.0".to_string()) - } else { - "https://api.soldeer.xyz".to_string() - } -} - -// Function to check for the presence of sensitive files or directories -pub fn check_dotfiles(path: impl AsRef) -> bool { - if !path.as_ref().is_dir() { - return false; - } - fs::read_dir(path) - .unwrap() - .map_while(Result::ok) - .any(|entry| entry.file_name().to_string_lossy().starts_with('.')) -} - -// Function to recursively check for sensitive files or directories in a given path -pub fn check_dotfiles_recursive(path: impl AsRef) -> bool { - if check_dotfiles(&path) { - return true; - } - - if path.as_ref().is_dir() { - return fs::read_dir(path) - .unwrap() - .map_while(Result::ok) - .any(|entry| check_dotfiles(entry.path())); - } - false -} - -// Function to prompt the user for confirmation -pub fn prompt_user_for_confirmation() -> bool { - println!( - "{}", - "You are about to include some sensitive files in this version. Are you sure you want to continue?".yellow() - ); - println!( - "{}", - "If you are not sure what sensitive files, you can run the dry-run command to check what will be pushed.".cyan() - ); - - print!("{}", "Do you want to continue? (y/n): ".green()); - std::io::stdout().flush().unwrap(); - - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap(); - let input = input.trim().to_lowercase(); - input == "y" || input == "yes" -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum UrlType { - Git, - Http, -} - -pub fn get_url_type(dependency_url: &str) -> UrlType { - if GIT_SSH_REGEX.is_match(dependency_url) || GIT_HTTPS_REGEX.is_match(dependency_url) { - return UrlType::Git; - } - UrlType::Http -} - -pub fn sanitize_dependency_name(dependency_name: &str) -> String { - let options = - sanitize_filename::Options { truncate: true, windows: cfg!(windows), replacement: "-" }; - - sanitize_filename::sanitize_with_options(dependency_name, options) -} - -pub fn zipfile_hash(dependency: &HttpDependency) -> Result { - use crate::DEPENDENCY_DIR; - - let file_name = - sanitize_dependency_name(&format!("{}-{}.zip", dependency.name, dependency.version)); - let path = DEPENDENCY_DIR.join(&file_name); - hash_file(&path).map_err(|e| DownloadError::IOError { path, source: e }) -} - -/// Hash the contents of a Reader with SHA256 -pub fn hash_content(content: &mut R) -> [u8; 32] { - let mut hasher = ::new(); - let mut buf = [0; 1024]; - while let Ok(size) = content.read(&mut buf) { - if size == 0 { - break; - } - hasher.update(&buf[0..size]); - } - hasher.finalize().into() -} - -/// Walk a folder and compute the SHA256 hash of all non-hidden and non-gitignored files inside the -/// dir, combining them into a single hash. -/// -/// We hash the name of the folders and files too, so we can check the integrity of their names. -/// -/// Since the folder contains the zip file still, we need to skip it. TODO: can we remove the zip -/// file right after unzipping so this is not necessary? -pub fn hash_folder( - folder_path: impl AsRef, - ignore_path: Option, -) -> Result { - // perf: it's easier to check a boolean than to compare paths, so when we find the zip we skip - // the check afterwards - let seen_ignore_path = Arc::new(AtomicBool::new(ignore_path.is_none())); - // a list of hashes, one for each DirEntry - let hashes = Arc::new(Mutex::new(Vec::with_capacity(100))); - // we use a parallel walker to speed things up - let walker = WalkBuilder::new(&folder_path).hidden(false).build_parallel(); - let root_path = Arc::new(dunce::canonicalize(folder_path.as_ref())?); - walker.run(|| { - let root_path = Arc::clone(&root_path); - let ignore_path = ignore_path.clone(); - let seen_ignore_path = Arc::clone(&seen_ignore_path); - let hashes = Arc::clone(&hashes); - // function executed for each DirEntry - Box::new(move |result| { - let Ok(entry) = result else { - return WalkState::Continue; - }; - let path = entry.path(); - // check if that file is `ignore_path`, unless we've seen it already - if !seen_ignore_path.load(Ordering::SeqCst) { - let ignore_path = ignore_path - .as_ref() - .expect("ignore_path should always be Some when seen_ignore_path is false"); - if path == ignore_path { - // record that we've seen the zip file - seen_ignore_path.swap(true, Ordering::SeqCst); - return WalkState::Continue; - } - } - // first hash the filename/dirname to make sure it can't be renamed or removed - let mut hasher = ::new(); - hasher.update( - path.strip_prefix(root_path.as_ref()) - .expect("path should be a child of root") - .to_slash_lossy() - .as_bytes(), - ); - // for files, also hash the contents - if let Some(true) = entry.file_type().map(|t| t.is_file()) { - if let Ok(file) = File::open(path) { - let mut reader = BufReader::new(file); - let hash = hash_content(&mut reader); - hasher.update(hash); - } - } - // record the hash for that file/folder in the list - let hash: [u8; 32] = hasher.finalize().into(); - let mut hashes_lock = hashes.lock().expect("mutex should not be poisoned"); - hashes_lock.push(hash); - WalkState::Continue - }) - }); - - // sort hashes - let mut hasher = ::new(); - let mut hashes = hashes.lock().expect("mutex should not be poisoned"); - hashes.sort_unstable(); - // hash the hashes (yo dawg...) - for hash in hashes.iter() { - hasher.update(hash); - } - let hash: [u8; 32] = hasher.finalize().into(); - Ok(const_hex::encode(hash).into()) -} - -/// Compute the SHA256 hash of the contents of a file -pub fn hash_file(path: impl AsRef) -> Result { - let file = File::open(path)?; - let mut reader = BufReader::new(file); - let bytes = hash_content(&mut reader); - Ok(const_hex::encode(bytes).into()) -} - -#[cfg(test)] -mod tests { - use rand::{distributions::Alphanumeric, Rng as _}; - - use super::*; - use std::fs; - - #[test] - fn filename_sanitization() { - let filenames = vec![ - "valid|filename.txt", - "valid:filename.txt", - "valid\"filename.txt", - "valid\\filename.txt", - "validfilename.txt", - "valid*filename.txt", - "valid?filename.txt", - "valid/filename.txt", - ]; - - for filename in filenames { - assert_eq!(sanitize_dependency_name(filename), "valid-filename.txt"); - } - assert_eq!(sanitize_dependency_name("valid~1.0.0"), "valid~1.0.0"); - assert_eq!(sanitize_dependency_name("valid~1*0.0"), "valid~1-0.0"); - } - - #[test] - fn test_hash_content() { - let mut content = "this is a test file".as_bytes(); - let hash = hash_content(&mut content); - assert_eq!( - const_hex::encode(hash), - "5881707e54b0112f901bc83a1ffbacac8fab74ea46a6f706a3efc5f7d4c1c625".to_string() - ); - } - - #[test] - fn test_hash_content_content_sensitive() { - let mut content = "foobar".as_bytes(); - let hash = hash_content(&mut content); - let mut content2 = "baz".as_bytes(); - let hash2 = hash_content(&mut content2); - assert_ne!(hash, hash2); - } - - #[test] - fn test_hash_file() { - let file = create_random_file("test", "txt"); - let hash = hash_file(&file).unwrap(); - fs::remove_file(&file).unwrap(); - assert_eq!(hash, "5881707e54b0112f901bc83a1ffbacac8fab74ea46a6f706a3efc5f7d4c1c625".into()); - } - - #[test] - fn test_hash_folder() { - let folder = create_test_folder("test", "test_hash_folder"); - let hash = hash_folder(&folder, None).unwrap(); - fs::remove_dir_all(&folder).unwrap(); - assert_eq!(hash, "4671014a36f223796de8760df8125ca6e5a749e162dd5690e815132621dd8bfb".into()); - } - - #[test] - fn test_hash_folder_abs_path_unsensitive() { - let folder1 = create_test_folder("test", "test_hash_folder1"); - let folder2 = create_test_folder("test", "test_hash_folder2"); - let hash1 = hash_folder(&folder1, None).unwrap(); - let hash2 = hash_folder(&folder2, None).unwrap(); - fs::remove_dir_all(&folder1).unwrap(); - fs::remove_dir_all(&folder2).unwrap(); - assert_eq!(hash1, hash2); - } - - #[test] - fn test_hash_folder_rel_path_sensitive() { - let folder = create_test_folder("test", "test_hash_folder_rel_path_sensitive"); - let hash1 = hash_folder(&folder, None).unwrap(); - fs::rename(folder.join("a.txt"), folder.join("c.txt")).unwrap(); - let hash2 = hash_folder(&folder, None).unwrap(); - fs::remove_dir_all(&folder).unwrap(); - assert_ne!(hash1, hash2); - } - - #[test] - fn test_hash_folder_ignore_path() { - let folder = create_test_folder("test", "test_hash_folder_ignore_path"); - let hash1 = hash_folder(&folder, None).unwrap(); - let hash2 = hash_folder(&folder, Some(folder.join("a.txt"))).unwrap(); - fs::remove_dir_all(&folder).unwrap(); - assert_ne!(hash1, hash2); - } - - fn create_random_file(target_dir: impl AsRef, extension: &str) -> PathBuf { - let s: String = - rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); - let random_file = target_dir.as_ref().join(format!("random{}.{}", s, extension)); - fs::write(&random_file, "this is a test file").expect("could not write to test file"); - random_file - } - - fn create_test_folder(target_dir: impl AsRef, dirname: &str) -> PathBuf { - let test_folder = target_dir.as_ref().canonicalize().unwrap().join(dirname); - fs::create_dir(&test_folder).expect("could not create test folder"); - fs::write(test_folder.join("a.txt"), "this is a test file") - .expect("could not write to test file a"); - fs::write(test_folder.join("b.txt"), "this is a second test file") - .expect("could not write to test file b"); - test_folder - } -} diff --git a/src/versioning.rs b/src/versioning.rs deleted file mode 100644 index 9126de1..0000000 --- a/src/versioning.rs +++ /dev/null @@ -1,420 +0,0 @@ -use crate::{ - auth::get_token, - errors::{AuthError, PublishError}, - remote::get_project_id, - utils::{get_base_url, read_file}, -}; -use ignore::{WalkBuilder, WalkState}; -use regex::Regex; -use reqwest::{ - header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}, - multipart::{Form, Part}, - Client, StatusCode, -}; -use std::{ - fs::{remove_file, File}, - io::{self, Read, Write}, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, -}; -use yansi::Paint as _; -use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; - -pub type Result = std::result::Result; - -pub async fn push_version( - dependency_name: &str, - dependency_version: &str, - root_directory_path: PathBuf, - dry_run: bool, -) -> Result<()> { - let file_name = root_directory_path.file_name().expect("path should have a last component"); - println!( - "{}", - format!("Pushing a dependency {}-{}:", dependency_name, dependency_version).green() - ); - - let files_to_copy: Vec = filter_files_to_copy(&root_directory_path); - - let zip_archive = match zip_file(&root_directory_path, &files_to_copy, file_name) { - Ok(zip) => zip, - Err(err) => { - return Err(err); - } - }; - - if dry_run { - return Ok(()); - } - - match push_to_repo(&zip_archive, dependency_name, dependency_version).await { - Ok(_) => {} - Err(error) => { - remove_file(zip_archive.to_str().unwrap()).unwrap(); - return Err(error); - } - } - // deleting zip archive - let _ = remove_file(zip_archive); - - Ok(()) -} - -pub fn validate_name(name: &str) -> Result<()> { - let regex = Regex::new(r"^[@|a-z0-9][a-z0-9-]*[a-z0-9]$").unwrap(); - if !regex.is_match(name) { - return Err(PublishError::InvalidName); - } - Ok(()) -} - -fn zip_file( - root_directory_path: &Path, - files_to_copy: &Vec, - file_name: impl Into, -) -> Result { - let mut file_name: PathBuf = file_name.into(); - file_name.set_extension("zip"); - let zip_file_path = root_directory_path.join(file_name); - let file = File::create(&zip_file_path).unwrap(); - let mut zip = ZipWriter::new(file); - let options = SimpleFileOptions::default().compression_method(CompressionMethod::DEFLATE); - if files_to_copy.is_empty() { - return Err(PublishError::NoFiles); - } - - for file_path in files_to_copy { - let file_to_copy = File::open(file_path.clone()) - .map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?; - let path = Path::new(&file_path); - let mut buffer = Vec::new(); - - // This is the relative path, we basically get the relative path to the target folder that - // we want to push and zip that as a name so we won't screw up the file/dir - // hierarchy in the zip file. - let relative_file_path = file_path.strip_prefix(root_directory_path)?; - - // Write file or directory explicitly - // Some unzip tools unzip files with directory paths correctly, some do not! - if path.is_file() { - zip.start_file(relative_file_path.to_string_lossy(), options)?; - io::copy(&mut file_to_copy.take(u64::MAX), &mut buffer) - .map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?; - zip.write_all(&buffer) - .map_err(|e| PublishError::IOError { path: zip_file_path.clone(), source: e })?; - } else if path.is_dir() { - let _ = zip.add_directory(file_path.to_string_lossy(), options); - } - } - let _ = zip.finish(); - Ok(zip_file_path) -} - -fn filter_files_to_copy(root_directory_path: impl AsRef) -> Vec { - let files_to_copy = Arc::new(Mutex::new(Vec::with_capacity(100))); - let walker = WalkBuilder::new(root_directory_path) - .add_custom_ignore_filename(".soldeerignore") - .hidden(false) - .build_parallel(); - walker.run(|| { - let files_to_copy = Arc::clone(&files_to_copy); - // function executed for each DirEntry - Box::new(move |result| { - let Ok(entry) = result else { - return WalkState::Continue; - }; - let path = entry.path(); - if path.is_dir() { - return WalkState::Continue; - } - let mut files_to_copy = files_to_copy.lock().expect("mutex should not be poisoned"); - files_to_copy.push(path.to_path_buf()); - WalkState::Continue - }) - }); - - Arc::into_inner(files_to_copy) - .expect("Arc should have no other strong references") - .into_inner() - .expect("mutex should not be poisoned") -} - -async fn push_to_repo( - zip_file: &Path, - dependency_name: &str, - dependency_version: &str, -) -> Result<()> { - let token = get_token()?; - let client = Client::new(); - - let url = format!("{}/api/v1/revision/upload", get_base_url()); - - let mut headers: HeaderMap = HeaderMap::new(); - - let header_string = format!("Bearer {}", token); - let header_value = HeaderValue::from_str(&header_string); - - headers.insert(AUTHORIZATION, header_value.expect("Could not set auth header")); - - let file_fs = read_file(zip_file).unwrap(); - let mut part = Part::bytes(file_fs).file_name( - zip_file - .file_name() - .expect("path should have a last component") - .to_string_lossy() - .into_owned(), - ); - - // set the mime as app zip - part = part.mime_str("application/zip").expect("Could not set mime type"); - - let project_id = get_project_id(dependency_name).await?; - - let form = Form::new() - .text("project_id", project_id) - .text("revision", dependency_version.to_string()) - .part("zip_name", part); - - headers.insert( - CONTENT_TYPE, - HeaderValue::from_str(&("multipart/form-data; boundary=".to_owned() + form.boundary())) - .expect("Could not set content type"), - ); - let res = client.post(url).headers(headers.clone()).multipart(form).send(); - - let response = res.await.unwrap(); - match response.status() { - StatusCode::OK => { - println!("{}", "Success!".green()); - Ok(()) - } - StatusCode::NO_CONTENT => Err(PublishError::ProjectNotFound), - StatusCode::ALREADY_REPORTED => Err(PublishError::AlreadyExists), - StatusCode::UNAUTHORIZED => Err(PublishError::AuthError(AuthError::InvalidCredentials)), - StatusCode::PAYLOAD_TOO_LARGE => Err(PublishError::PayloadTooLarge), - s if s.is_server_error() || s.is_client_error() => { - Err(PublishError::HttpError(response.error_for_status().unwrap_err())) - } - _ => Err(PublishError::UnknownError), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::utils::get_current_working_dir; - use io::Cursor; - use rand::{distributions::Alphanumeric, Rng}; - use serial_test::serial; - use std::fs::{self, create_dir_all, remove_dir_all, remove_file}; - - #[test] - #[serial] - fn filter_only_files_success() { - let target_dir = get_current_working_dir().join("test").join("test_push"); - let _ = remove_dir_all(&target_dir); - let _ = create_dir_all(&target_dir); - - let soldeerignore = define_ignore_file(&target_dir, false); - let gitignore = define_ignore_file(&target_dir, true); - let _ = remove_file(soldeerignore); - - let mut ignored_files = vec![]; - let mut filtered_files = vec![gitignore.clone()]; - ignored_files.push(create_random_file(&target_dir, "toml")); - ignored_files.push(create_random_file(&target_dir, "zip")); - ignored_files.push(create_random_file(&target_dir, "toml")); - filtered_files.push(create_random_file(&target_dir, "txt")); - - let ignore_contents_git = r#" -*.toml -*.zip - "#; - write_to_ignore(&gitignore, ignore_contents_git); - - let result = filter_files_to_copy(&target_dir); - assert_eq!(filtered_files.len(), result.len()); - for res in result { - assert!(filtered_files.contains(&res), "File {:?} not found in filtered files", res); - } - - let _ = remove_file(gitignore); - let _ = remove_dir_all(target_dir); - } - - #[test] - #[serial] - fn filter_files_and_dir_success() { - let target_dir = get_current_working_dir().join("test").join("test_push"); - let _ = remove_dir_all(&target_dir); - let _ = create_dir_all(&target_dir); - - let soldeerignore = define_ignore_file(&target_dir, false); - let gitignore = define_ignore_file(&target_dir, true); - let _ = remove_file(soldeerignore); - - // divide ignored vs filtered files to check them later - let mut ignored_files = vec![]; - let mut filtered_files = vec![gitignore.clone()]; - - // initial dir to test the ignore - let target_dir = get_current_working_dir().join("test").join("test_push"); - - // we create various test files structure - // - test_push/ - // --- random_dir/ <= not ignored - // --- --- random.toml <= ignored - // --- --- random.zip <= not ignored - // --- broadcast/ <= not ignored - // --- --- random.toml <= ignored - // --- --- random.zip <= not ignored - // --- --- 31337/ <= ignored - // --- --- --- random.toml <= ignored - // --- --- --- random.zip <= ignored - // --- --- random_dir_in_broadcast/ <= not ignored - // --- --- --- random.zip <= not ignored - // --- --- --- random.toml <= ignored - // --- --- --- dry_run/ <= ignored - // --- --- --- --- zip <= ignored - // --- --- --- --- toml <= ignored - - let random_dir = create_random_directory(&target_dir, None); - let broadcast_dir = create_random_directory(&target_dir, Some("broadcast")); - - let the_31337_dir = create_random_directory(&broadcast_dir, Some("31337")); - let random_dir_in_broadcast = create_random_directory(&broadcast_dir, None); - let dry_run_dir = create_random_directory(&random_dir_in_broadcast, Some("dry_run")); - - ignored_files.push(create_random_file(&random_dir, "toml")); - filtered_files.push(create_random_file(&random_dir, "zip")); - - ignored_files.push(create_random_file(&broadcast_dir, "toml")); - filtered_files.push(create_random_file(&broadcast_dir, "zip")); - - ignored_files.push(create_random_file(&the_31337_dir, "toml")); - ignored_files.push(create_random_file(&the_31337_dir, "zip")); - - filtered_files.push(create_random_file(&random_dir_in_broadcast, "zip")); - filtered_files.push(create_random_file(&random_dir_in_broadcast, "toml")); - - ignored_files.push(create_random_file(&dry_run_dir, "zip")); - ignored_files.push(create_random_file(&dry_run_dir, "toml")); - - let ignore_contents_git = r#" -*.toml -!/broadcast -/broadcast/31337/ -/broadcast/*/dry_run/ - "#; - write_to_ignore(&gitignore, ignore_contents_git); - - let result = filter_files_to_copy(&target_dir); - - // for each result we just just to see if a file (not a dir) is in the filtered results - for res in result { - if PathBuf::from(&res).is_dir() { - continue; - } - - assert!(filtered_files.contains(&res), "File {:?} not found in filtered files", res); - } - - let _ = remove_file(gitignore); - let _ = remove_dir_all(target_dir); - } - - #[test] - #[serial] - fn zipping_file_structure_check() { - let target_dir = get_current_working_dir().join("test").join("test_zip"); - let target_dir_unzip = get_current_working_dir().join("test").join("test_unzip"); - let _ = remove_dir_all(&target_dir); - let _ = remove_dir_all(&target_dir_unzip); - let _ = create_dir_all(&target_dir); - let _ = create_dir_all(&target_dir_unzip); - - // File structure that should be preserved - // - target_dir/ - // --- random_dir_1/ - // --- --- random_dir_2/ - // --- --- --- random_file_3.txt - // --- --- random_file_2.txt - // --- random_file_1.txt - let random_dir_1 = create_random_directory(&target_dir, None); - let random_dir_2 = create_random_directory(Path::new(&random_dir_1), None); - let random_file_1 = create_random_file(&target_dir, "txt"); - let random_file_2 = create_random_file(Path::new(&random_dir_1), "txt"); - let random_file_3 = create_random_file(Path::new(&random_dir_2), "txt"); - - let files_to_copy: Vec = - vec![random_file_1.clone(), random_file_3.clone(), random_file_2.clone()]; - let result = match zip_file(&target_dir, &files_to_copy, "test_zip") { - Ok(r) => r, - Err(_) => { - assert_eq!("Invalid State", ""); - return; - } - }; - - // unzipping for checks - let archive = read_file(result).unwrap(); - match zip_extract::extract(Cursor::new(archive), &target_dir_unzip, true) { - Ok(_) => {} - Err(_) => { - assert_eq!("Invalid State", ""); - } - } - - let mut random_file_1_unzipped = target_dir_unzip.clone(); - random_file_1_unzipped.push(random_file_1.strip_prefix(&target_dir).unwrap()); - let mut random_file_2_unzipped = target_dir_unzip.clone(); - random_file_2_unzipped.push(random_file_2.strip_prefix(&target_dir).unwrap()); - let mut random_file_3_unzipped = target_dir_unzip.clone(); - random_file_3_unzipped.push(random_file_3.strip_prefix(&target_dir).unwrap()); - println!("{random_file_3_unzipped:?}"); - - assert!(Path::new(&random_file_1_unzipped).exists()); - assert!(Path::new(&random_file_2_unzipped).exists()); - assert!(Path::new(&random_file_3_unzipped).exists()); - - //cleaning up - let _ = remove_dir_all(&target_dir); - let _ = remove_dir_all(&target_dir_unzip); - } - - fn define_ignore_file(target_dir: impl AsRef, git: bool) -> PathBuf { - let mut target = ".soldeerignore"; - if git { - target = ".gitignore"; - } - target_dir.as_ref().to_path_buf().join(target) - } - - fn write_to_ignore(target_file: impl AsRef, contents: &str) { - if target_file.as_ref().exists() { - let _ = remove_file(&target_file); - } - fs::write(&target_file, contents).expect("Could not write to ignore file"); - } - - fn create_random_file(target_dir: impl AsRef, extension: &str) -> PathBuf { - let s: String = - rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); - let target = target_dir.as_ref().join(format!("random{}.{}", s, extension)); - fs::write(&target, "this is a test file").expect("Could not write to test file"); - target - } - - fn create_random_directory(target_dir: impl AsRef, name: Option<&str>) -> PathBuf { - let target = match name { - Some(name) => target_dir.as_ref().join(name), - None => { - let s: String = - rand::thread_rng().sample_iter(&Alphanumeric).take(7).map(char::from).collect(); - target_dir.as_ref().join(format!("random{}", s)) - } - }; - let _ = create_dir_all(&target); - target - } -} diff --git a/test/emptyfile b/test/emptyfile deleted file mode 100644 index 5337cd5..0000000 --- a/test/emptyfile +++ /dev/null @@ -1 +0,0 @@ -File kept just to have the test directory present on git. This directory is used for the tests to write artifacts \ No newline at end of file diff --git a/tests/ci/foundry.rs b/tests/ci/foundry.rs deleted file mode 100644 index 5c31c49..0000000 --- a/tests/ci/foundry.rs +++ /dev/null @@ -1,165 +0,0 @@ -use clap::Parser as _; -use serial_test::serial; -use soldeer::{ - commands::{Args, Install, Subcommands}, - DEPENDENCY_DIR, LOCK_FILE, -}; -use std::{ - env, - fs::{self, create_dir_all, remove_dir_all, remove_file}, - io, - io::Write, - path::{Path, PathBuf}, - process::Command, -}; - -#[test] -#[serial] -fn soldeer_install_valid_dependency() { - let test_project = env::current_dir().unwrap().join("test_project"); - clean_test_env(&test_project); - let command = Subcommands::Install(Install { - dependency: Some("forge-std~1.8.2".to_string()), - remote_url: None, - rev: None, - tag: None, - branch: None, - regenerate_remappings: false, - recursive_deps: false, - }); - - match soldeer::run(command) { - Ok(_) => {} - Err(_) => { - assert_eq!("Invalid State", "") - } - } - - let path_dependency = DEPENDENCY_DIR.join("forge-std-1.8.2"); - assert!(path_dependency.exists()); - let test_contract = r#" -// SPDX-License-Identifier: MIT -pragma solidity >= 0.8.20; - -contract Increment { - uint256 i; - - function increment() external { - i++; - } -} - "#; - - let test = r#" -// SPDX-License-Identifier: MIT -pragma solidity >= 0.8.20; -import "../src/Increment.sol"; -import "@forge-std-1.8.2/src/Test.sol"; - -contract TestSoldeer is Test { - Increment t = new Increment(); - - function testIncrement() external { - t.increment(); - } -} - "#; - - let _ = fs::create_dir(&test_project); - let _ = fs::create_dir(test_project.join("src")); - let _ = fs::create_dir(test_project.join("test")); - let mut file: std::fs::File = fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(test_project.join("src").join("Increment.sol")) - .unwrap(); - if write!(file, "{}", test_contract).is_err() { - println!("Error on writing test file"); - assert_eq!("Invalid state", ""); - } - - let mut file: std::fs::File = fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(test_project.join("test").join("TestIncrement.sol")) - .unwrap(); - if write!(file, "{}", test).is_err() { - println!("Error on writing test file"); - assert_eq!("Invalid state", ""); - } - - let _ = create_dir_all(test_project.join("dependencies").join("forge-std-1.8.2")); - - let _ = copy_dir_all( - env::current_dir().unwrap().join("dependencies").join("forge-std-1.8.2"), - test_project.join("dependencies").join("forge-std-1.8.2"), - ); - let foundry_content = r#" - -# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config - -[profile.default] -script = "script" -solc = "0.8.26" -src = "src" -test = "test" -libs = ["dependencies"] - -[dependencies] -forge-std = "1.8.2" - -"#; - - let _ = fs::write(test_project.join("foundry.toml"), foundry_content); - - let _ = fs::write( - test_project.join("remappings.txt"), - "@forge-std-1.8.2=dependencies/forge-std-1.8.2", - ); - - let output = Command::new("forge") - .arg("test") - .arg("--root") - .arg(&test_project) - .output() - .expect("failed to execute process"); - - let passed = String::from_utf8(output.stdout).unwrap().contains("[PASS]"); - if !passed { - eprintln!("This failed with: {:?}", String::from_utf8(output.stderr).unwrap()); - } - assert!(passed); - clean_test_env(&test_project); -} - -#[test] -#[serial] -fn soldeer_install_invalid_dependency() { - assert!(Args::try_parse_from(["soldeer", "install", "forge-std"]).is_err()); - - let path_dependency = DEPENDENCY_DIR.join("forge-std"); - let path_zip = DEPENDENCY_DIR.join("forge-std.zip"); - - assert!(!path_zip.exists()); - assert!(!path_dependency.exists()); -} - -fn clean_test_env(test_project: &PathBuf) { - let _ = remove_dir_all(DEPENDENCY_DIR.clone()); - let _ = remove_file(LOCK_FILE.clone()); - let _ = remove_dir_all(test_project); -} - -fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> io::Result<()> { - fs::create_dir_all(&dst)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - let ty = entry.file_type()?; - if ty.is_dir() { - copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; - } else { - fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; - } - } - Ok(()) -} diff --git a/tests/ci/main.rs b/tests/ci/main.rs deleted file mode 100644 index 1f996da..0000000 --- a/tests/ci/main.rs +++ /dev/null @@ -1 +0,0 @@ -mod foundry;