diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..6d27d05 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,4 @@ +[profile.default] +retries = { backoff = "exponential", count = 2, delay = "2s", jitter = true } +slow-timeout = { period = "1m", terminate-after = 3 } +fail-fast = false diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0c0d613..8276a67 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,23 +2,37 @@ 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, macos-latest] + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@nextest - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - name: Run tests - run: cargo test + run: cargo nextest run + + doctests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - run: cargo test --workspace --doc feature-checks: runs-on: ubuntu-latest @@ -59,7 +73,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..2a6ec04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,19 +4,13 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" dependencies = [ "gimli", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "adler2" version = "2.0.0" @@ -81,6 +75,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" + [[package]] name = "arbitrary" version = "1.3.2" @@ -108,23 +108,23 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", - "miniz_oxide 0.7.4", + "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] @@ -148,6 +148,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97493a391b4b18ee918675fb8663e53646fd09321c58b46afa04e8ce2499c869" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2af3eac944c12cdf4423eab70d310da0a8e5851a18ffb192c0a5e3f7ae1663" +dependencies = [ + "darling", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bstr" version = "1.10.0" @@ -172,9 +195,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "bzip2" @@ -197,11 +220,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.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" +dependencies = [ + "shlex", +] [[package]] name = "cfg-if" @@ -221,9 +278,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.15" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" dependencies = [ "clap_builder", "clap_derive", @@ -231,9 +288,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.15" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" +checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" dependencies = [ "anstream", "anstyle", @@ -243,9 +300,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -259,6 +316,20 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +[[package]] +name = "cliclack" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a80570d35684e725e9d2d4aaaf32bc0cbfcfb8539898f9afea3da0d2e5189e4" +dependencies = [ + "console", + "indicatif", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "colorchoice" version = "1.0.2" @@ -275,6 +346,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 +382,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.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] @@ -351,6 +441,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 +487,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" @@ -389,6 +535,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "email-address-parser" version = "2.0.0" @@ -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" @@ -410,12 +568,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", - "miniz_oxide 0.8.0", + "miniz_oxide", ] [[package]] @@ -545,15 +703,15 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" [[package]] name = "globset" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" dependencies = [ "aho-corasick", "bstr", @@ -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", @@ -701,9 +865,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ "bytes", "futures-channel", @@ -714,11 +878,16 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", - "tower", "tower-service", "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" @@ -731,9 +900,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.22" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" dependencies = [ "crossbeam-deque", "globset", @@ -747,19 +916,52 @@ 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" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" + +[[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" @@ -775,9 +977,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 +992,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libm" @@ -844,15 +1046,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "miniz_oxide" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", -] - [[package]] name = "miniz_oxide" version = "0.8.0" @@ -868,7 +1061,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 +1091,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 +1110,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", ] @@ -960,9 +1168,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.7.11" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" +checksum = "fdbef9d1d47087a895abd220ed25eb4ad973a5e26f6a4367b038c25e28dfc2d9" dependencies = [ "memchr", "thiserror", @@ -971,9 +1179,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.11" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" +checksum = "4d3a6e3394ec80feb3b6393c725571754c6188490265c61aaf260810d6b95aa0" dependencies = [ "pest", "pest_generator", @@ -981,9 +1189,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.11" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" +checksum = "94429506bde1ca69d1b5601962c73f4172ab4726571a59ea95931218cb0e930e" dependencies = [ "pest", "pest_meta", @@ -994,35 +1202,15 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.11" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" +checksum = "ac8a071862e93690b6e34e9a5fb8e33ff3734473ac0245b27232222c4906a33f" dependencies = [ "once_cell", "pest", "sha2", ] -[[package]] -name = "pin-project" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.14" @@ -1037,9 +1225,15 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "ppv-lite86" @@ -1086,9 +1280,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 +1298,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 +1315,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", ] @@ -1180,11 +1374,31 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags", ] @@ -1214,19 +1428,18 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[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 +1474,7 @@ dependencies = [ "wasm-streams", "web-sys", "webpki-roots", - "winreg", + "windows-registry", ] [[package]] @@ -1279,27 +1492,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" @@ -1314,9 +1506,9 @@ checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] name = "rustls" -version = "0.23.12" +version = "0.23.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" dependencies = [ "once_cell", "ring", @@ -1338,21 +1530,27 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" 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 +1576,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 +1583,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.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3e4cd94123dd520a128bcd11e34d9e9e423e7e3e50425cb1b4b1e3549d0284" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.206" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabfb6138d2383ea8208cf98ccf69cdfb1aff4088460681d84189aa259762f97" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -1421,9 +1613,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", @@ -1433,9 +1625,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] @@ -1453,39 +1645,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 +1697,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 +1715,58 @@ 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", + "derive_more", + "email-address-parser", + "mockito", + "rayon", + "reqwest", + "soldeer-core", + "temp-env", + "testdir", + "tokio", +] + +[[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", + "rayon", "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 +1791,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -1591,21 +1805,73 @@ 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" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -1629,9 +1895,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 +1905,7 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -1668,9 +1935,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", @@ -1690,9 +1957,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.20" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "serde", @@ -1701,32 +1968,11 @@ dependencies = [ "winnow", ] -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" - [[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" @@ -1761,9 +2007,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unarray" @@ -1788,19 +2034,37 @@ checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1865,21 +2129,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 +2163,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 +2175,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 +2185,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,15 +2198,15 @@ 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" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "4e072d4e72f700fb3443d8fe94a39315df013eef1104903cdb0a2abd322bbecd" dependencies = [ "futures-util", "js-sys", @@ -1946,9 +2217,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 +2227,40 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.3" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" 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 +2270,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" @@ -2122,28 +2456,21 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" 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 +2498,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..3f7e7f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,90 +1,52 @@ -[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" +unwrap_used = "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" +derive_more = { version = "1.0.0", features = ["from", "display", "from_str"] } +mockito = "1.5.0" +path-slash = "0.2.1" +rayon = "1.10.0" +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/clippy.toml b/clippy.toml index 8bfc90f..ee17597 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1,2 @@ msrv = "1.80" +allow-unwrap-in-tests = true 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..39061e3 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,39 @@ +//! Soldeer is a package manager for Solidity projects +use soldeer_commands::{commands::Parser as _, run, Args}; +use yansi::{Condition, Paint as _}; + +const HAVE_COLOR: Condition = Condition(|| { + std::env::var_os("NO_COLOR").is_none() && + (Condition::CLICOLOR_LIVE)() && + Condition::stdouterr_are_tty_live() +}); + +#[tokio::main] +async fn main() { + // disable colors if unsupported + yansi::whenever(HAVE_COLOR); + banner(); + let args = Args::parse(); + if let Err(err) = run(args.command).await { + eprintln!("{}", err.to_string().red()) + } +} + +/// Generate and print a banner +fn banner() { + println!( + "{}", + format!( + " ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + ╔═╗╔═╗╦ ╔╦╗╔═╗╔═╗╦═╗ Solidity Package Manager + ╚═╗║ ║║ ║║║╣ ║╣ ╠╦╝ + ╚═╝╚═╝╩═╝═╩╝╚═╝╚═╝╩╚═ github.com/mario-eth/soldeer + v{} soldeer.xyz ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +", + env!("CARGO_PKG_VERSION") + ) + .bright_cyan() + ); +} diff --git a/crates/commands/Cargo.toml b/crates/commands/Cargo.toml new file mode 100644 index 0000000..36509c5 --- /dev/null +++ b/crates/commands/Cargo.toml @@ -0,0 +1,36 @@ +[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 +derive_more.workspace = true +email-address-parser = "2.0.0" +soldeer-core = { path = "../core", features = ["cli"] } +rayon.workspace = true + +[dev-dependencies] +mockito.workspace = true +reqwest.workspace = true +temp-env.workspace = true +testdir.workspace = true +tokio.workspace = true + +[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..ea73dfc --- /dev/null +++ b/crates/commands/src/commands/init.rs @@ -0,0 +1,69 @@ +use clap::Parser; +use cliclack::{ + log::{remark, success}, + multi_progress, +}; +use soldeer_core::{ + config::{add_to_config, read_soldeer_config, update_config_libs, Paths}, + install::{ensure_dependencies_dir, install_dependency, Progress}, + lock::add_to_lockfile, + registry::get_latest_version, + 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_version("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)?; + let foundry_config = paths.root.join("foundry.toml"); + if foundry_config.exists() { + update_config_libs(foundry_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..d76ba60 --- /dev/null +++ b/crates/commands/src/commands/mod.rs @@ -0,0 +1,40 @@ +pub use clap::{Parser, Subcommand}; +use derive_more::derive::From; + +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: Command, +} + +/// The available commands for Soldeer +#[derive(Debug, Clone, Subcommand, From)] +pub enum Command { + Init(init::Init), + Install(install::Install), + Update(update::Update), + Login(login::Login), + Push(push::Push), + Uninstall(uninstall::Uninstall), + Version(Version), +} + +/// Display the version of Soldeer +#[derive(Debug, Clone, Default, Parser)] +pub struct 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()) +} diff --git a/crates/commands/src/commands/push.rs b/crates/commands/src/commands/push.rs new file mode 100644 index 0000000..f7bf2ed --- /dev/null +++ b/crates/commands/src/commands/push.rs @@ -0,0 +1,90 @@ +use super::validate_dependency; +use clap::Parser; +use cliclack::log::{info, remark, warning}; +use soldeer_core::{ + errors::PublishError, + push::{filter_ignored_files, 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_ignored_files(&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)?; + + if let Some(zip_path) = + push_version(dependency_name, dependency_version, path, &files_to_copy, cmd.dry_run).await? + { + info(format!("Zip file created at {}", zip_path.to_string_lossy()))?; + } + 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..3744ba4 --- /dev/null +++ b/crates/commands/src/lib.rs @@ -0,0 +1,65 @@ +//! High-level commands for the Soldeer CLI +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +pub use crate::commands::{Args, Command}; +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: Command) -> Result<()> { + match command { + Command::Init(init) => { + intro("🦌 Soldeer Init 🦌")?; + step("Initialize Foundry project to use Soldeer")?; + let paths = Paths::new()?; + commands::init::init_command(&paths, init).await.inspect_err(|_| { + outro_cancel("An error occurred during initialization").ok(); + })?; + outro("Done initializing!")?; + } + Command::Install(cmd) => { + intro("🦌 Soldeer Install 🦌")?; + let paths = Paths::new()?; + commands::install::install_command(&paths, cmd).await.inspect_err(|_| { + outro_cancel("An error occurred during install").ok(); + })?; + outro("Done installing!")?; + } + Command::Update(cmd) => { + intro("🦌 Soldeer Update 🦌")?; + let paths = Paths::new()?; + commands::update::update_command(&paths, cmd).await.inspect_err(|_| { + outro_cancel("An error occurred during the update").ok(); + })?; + outro("Done updating!")?; + } + Command::Uninstall(cmd) => { + intro("🦌 Soldeer Uninstall 🦌")?; + let paths = Paths::new()?; + commands::uninstall::uninstall_command(&paths, &cmd).inspect_err(|_| { + outro_cancel("An error occurred during uninstall").ok(); + })?; + outro("Done uninstalling!")?; + } + Command::Login(_) => { + intro("🦌 Soldeer Login 🦌")?; + commands::login::login_command().await.inspect_err(|_| { + outro_cancel("An error occurred during login").ok(); + })?; + outro("Done logging in!")?; + } + Command::Push(cmd) => { + intro("🦌 Soldeer Push 🦌")?; + commands::push::push_command(cmd).await.inspect_err(|_| { + outro_cancel("An error occurred during push").ok(); + })?; + outro("Done!")?; + } + Command::Version(_) => { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + println!("soldeer {VERSION}"); + } + } + Ok(()) +} diff --git a/crates/commands/tests/tests-init.rs b/crates/commands/tests/tests-init.rs new file mode 100644 index 0000000..83e2d06 --- /dev/null +++ b/crates/commands/tests/tests-init.rs @@ -0,0 +1,107 @@ +use soldeer_commands::{commands::init::Init, run, Command}; +use soldeer_core::{config::read_config_deps, lock::read_lockfile, utils::run_git_command}; +use std::fs; +use temp_env::async_with_vars; +use testdir::testdir; + +#[tokio::test] +async fn test_init_clean() { + let dir = testdir!(); + run_git_command( + ["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."], + Some(&dir), + ) + .await + .unwrap(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Init { clean: true }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + assert!(!dir.join("lib").exists()); + assert!(!dir.join(".gitmodules").exists()); + assert!(dir.join("dependencies").exists()); + let deps = read_config_deps(dir.join("soldeer.toml")).unwrap(); + assert_eq!(deps.first().unwrap().name(), "forge-std"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!(lock.entries.first().unwrap().name(), "forge-std"); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert!(remappings.contains("forge-std")); + let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap(); + assert!(gitignore.contains("/dependencies")); + let foundry_config = fs::read_to_string(dir.join("foundry.toml")).unwrap(); + assert!(foundry_config.contains("libs = [\"dependencies\"]")); +} + +#[tokio::test] +async fn test_init_no_clean() { + let dir = testdir!(); + run_git_command( + ["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."], + Some(&dir), + ) + .await + .unwrap(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Init { clean: false }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + assert!(dir.join("lib").exists()); + assert!(dir.join(".gitmodules").exists()); + assert!(dir.join("dependencies").exists()); + let deps = read_config_deps(dir.join("soldeer.toml")).unwrap(); + assert_eq!(deps.first().unwrap().name(), "forge-std"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!(lock.entries.first().unwrap().name(), "forge-std"); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert!(remappings.contains("forge-std")); + let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap(); + assert!(gitignore.contains("/dependencies")); + let foundry_config = fs::read_to_string(dir.join("foundry.toml")).unwrap(); + assert!(foundry_config.contains("libs = [\"dependencies\"]")); +} + +#[tokio::test] +async fn test_init_no_remappings() { + let dir = testdir!(); + run_git_command( + ["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."], + Some(&dir), + ) + .await + .unwrap(); + let contents = r"[soldeer] +remappings_generate = false + +[dependencies] +"; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let cmd: Command = Init { clean: true }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + assert!(!dir.join("remappings.txt").exists()); +} + +#[tokio::test] +async fn test_init_no_gitignore() { + let dir = testdir!(); + run_git_command( + ["clone", "--recursive", "https://github.com/foundry-rs/forge-template.git", "."], + Some(&dir), + ) + .await + .unwrap(); + fs::remove_file(dir.join(".gitignore")).unwrap(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Init { clean: true }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + assert!(!dir.join(".gitignore").exists()); +} diff --git a/crates/commands/tests/tests-install.rs b/crates/commands/tests/tests-install.rs new file mode 100644 index 0000000..24c487e --- /dev/null +++ b/crates/commands/tests/tests-install.rs @@ -0,0 +1,513 @@ +use soldeer_commands::{commands::install::Install, run, Command}; +use soldeer_core::{config::read_config_deps, download::download_file, lock::read_lockfile}; +use std::{fs, path::Path}; +use temp_env::async_with_vars; +use testdir::testdir; + +#[allow(clippy::unwrap_used)] +fn check_install(dir: &Path, name: &str, version_req: &str) { + assert!(dir.join("dependencies").exists()); + let mut config_path = dir.join("soldeer.toml"); + if !config_path.exists() { + config_path = dir.join("foundry.toml"); + } + let deps = read_config_deps(config_path).unwrap(); + assert_eq!(deps.first().unwrap().name(), name); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert!(remappings.contains(name)); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!(lock.entries.first().unwrap().name(), name); + let version = lock.entries.first().unwrap().version(); + assert!(version.starts_with(version_req)); + assert!(dir.join("dependencies").join(format!("{name}-{version}")).exists()); +} + +#[tokio::test] +async fn test_install_registry_any_version() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("@openzeppelin-contracts~5".to_string()), + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "@openzeppelin-contracts", "5"); +} + +#[tokio::test] +async fn test_install_registry_specific_version() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("@openzeppelin-contracts~4.9.5".to_string()), + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "@openzeppelin-contracts", "4.9.5"); +} + +#[tokio::test] +async fn test_install_custom_http() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("mylib~1.0.0".to_string()), + remote_url: Some("https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip".to_string()), + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "mylib", "1.0.0"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lock.entries.first().unwrap().as_http().unwrap().url, + "https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip" + ); +} + +#[tokio::test] +async fn test_install_git_main() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("mylib~0.1.0".to_string()), + remote_url: Some("https://github.com/beeb/test-repo.git".to_string()), + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "mylib", "0.1.0"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lock.entries.first().unwrap().as_git().unwrap().rev, + "d5d72fa135d28b2e8307650b3ea79115183f2406" + ); +} + +#[tokio::test] +async fn test_install_git_commit() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("mylib~0.1.0".to_string()), + remote_url: Some("https://github.com/beeb/test-repo.git".to_string()), + rev: Some("78c2f6a1a54db26bab6c3f501854a1564eb3707f".to_string()), + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "mylib", "0.1.0"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lock.entries.first().unwrap().as_git().unwrap().rev, + "78c2f6a1a54db26bab6c3f501854a1564eb3707f" + ); +} + +#[tokio::test] +async fn test_install_git_tag() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("mylib~0.1.0".to_string()), + remote_url: Some("https://github.com/beeb/test-repo.git".to_string()), + rev: None, + tag: Some("v0.1.0".to_string()), + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "mylib", "0.1.0"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lock.entries.first().unwrap().as_git().unwrap().rev, + "78c2f6a1a54db26bab6c3f501854a1564eb3707f" + ); +} + +#[tokio::test] +async fn test_install_git_branch() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("mylib~dev".to_string()), + remote_url: Some("https://github.com/beeb/test-repo.git".to_string()), + rev: None, + tag: None, + branch: Some("dev".to_string()), + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "mylib", "dev"); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lock.entries.first().unwrap().as_git().unwrap().rev, + "8d903e557e8f1b6e62bde768aa456d4ddfca72c4" + ); +} + +#[tokio::test] +async fn test_install_foundry_config() { + let dir = testdir!(); + fs::write(dir.join("foundry.toml"), "[dependencies]\n").unwrap(); + let cmd: Command = Install { + dependency: Some("@openzeppelin-contracts~5".to_string()), + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "@openzeppelin-contracts", "5"); +} + +#[tokio::test] +async fn test_install_foundry_remappings() { + let dir = testdir!(); + let contents = r#"[profile.default] + +[soldeer] +remappings_location = "config" + +[dependencies] +"@openzeppelin-contracts" = "5" +"#; + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let cmd: Command = Install { + dependency: None, + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let config = fs::read_to_string(dir.join("foundry.toml")).unwrap(); + assert!(config.contains( + "remappings = [\"@openzeppelin-contracts-5/=dependencies/@openzeppelin-contracts-5.0.2/\"]" + )); +} + +#[tokio::test] +async fn test_install_missing_no_lock() { + let dir = testdir!(); + let contents = r#"[dependencies] +"@openzeppelin-contracts" = "5.0.2" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let cmd: Command = Install { + dependency: None, + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "@openzeppelin-contracts", "5.0.2"); +} + +#[tokio::test] +async fn test_install_missing_with_lock() { + let dir = testdir!(); + let contents = r#"[dependencies] +mylib = "1.1" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let lock = r#"[[dependencies]] +name = "mylib" +version = "1.1.0" +url = "https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip" +checksum = "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468" +integrity = "f3c628f3e9eae4db14fe14f9ab29e49a0107c47b8ee956e4cee57b616b493fc2" +"#; + fs::write(dir.join("soldeer.lock"), lock).unwrap(); + let cmd: Command = Install { + dependency: None, + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + check_install(&dir, "mylib", "1.1"); +} + +#[tokio::test] +async fn test_install_second_time() { + let dir = testdir!(); + let contents = r#"[dependencies] +mylib = "1.1" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + + // get zip file locally for mock + let zip_file = download_file( + "https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip", + &dir, + "tmp", + ) + .await + .unwrap(); + + // serve the file with mock server + let mut server = mockito::Server::new_async().await; + let mock = server.mock("GET", "/file.zip").with_body_from_file(zip_file).create_async().await; + let mock = mock.expect(1); // download link should be called exactly once + + let lock = format!( + r#"[[dependencies]] +name = "mylib" +version = "1.1.0" +url = "{}/file.zip" +checksum = "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468" +integrity = "f3c628f3e9eae4db14fe14f9ab29e49a0107c47b8ee956e4cee57b616b493fc2" +"#, + server.url() + ); + fs::write(dir.join("soldeer.lock"), lock).unwrap(); + let cmd: Command = Install { + dependency: None, + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = async_with_vars( + [("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], + run(cmd.clone()), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + mock.assert(); // download link was called + + // second install + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + mock.assert(); // download link was not called a second time +} + +#[tokio::test] +async fn test_install_clean() { + let dir = testdir!(); + let contents = r#"[dependencies] +"@openzeppelin-contracts" = "5.0.2" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let test_path = dir.join("dependencies").join("foo"); + fs::create_dir_all(&test_path).unwrap(); + fs::write(test_path.join("foo.txt"), "test").unwrap(); + let cmd: Command = Install { + dependency: None, + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: true, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + assert!(!test_path.exists()); +} + +#[tokio::test] +async fn test_install_recursive_deps() { + let dir = testdir!(); + let contents = r#"[dependencies] +foo = { version = "0.1.0", git = "https://github.com/foundry-rs/forge-template.git" } +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let cmd: Command = Install { + dependency: None, + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: true, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let dep_path = dir.join("dependencies").join("foo-0.1.0"); + assert!(dep_path.exists()); + let sub_dirs_path = dep_path.join("lib"); + assert!(sub_dirs_path.exists()); + assert!(sub_dirs_path.join("forge-std").join("src").exists()); +} + +#[tokio::test] +async fn test_install_regenerate_remappings() { + let dir = testdir!(); + fs::write(dir.join("soldeer.toml"), "[dependencies]\n").unwrap(); + fs::write(dir.join("remappings.txt"), "foo=bar").unwrap(); + let cmd: Command = Install { + dependency: Some("@openzeppelin-contracts~5".to_string()), + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: true, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert!(!remappings.contains("foo=bar")); +} + +#[tokio::test] +async fn test_add_remappings() { + let dir = testdir!(); + + let contents = r#"[profile.default] +src = "src" +out = "out" +libs = ["dependencies"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options + +[soldeer] +remappings_generate = true +remappings_prefix = "@custom-f@" +remappings_location = "config" +remappings_regenerate = true + +[dependencies] +"#; + + fs::write(dir.join("foundry.toml"), contents).unwrap(); + let cmd: Command = Install { + dependency: Some("forge-std~1.8.1".to_string()), + remote_url: None, + rev: None, + tag: None, + branch: None, + regenerate_remappings: false, + recursive_deps: false, + clean: false, + } + .into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + + let updated_contents = r#"[profile.default] +src = "src" +out = "out" +libs = ["dependencies"] +remappings = ["@custom-f@forge-std-1.8.1/=dependencies/forge-std-1.8.1/"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options + +[soldeer] +remappings_generate = true +remappings_prefix = "@custom-f@" +remappings_location = "config" +remappings_regenerate = true + +[dependencies] +forge-std = "1.8.1" +"#; + assert_eq!(updated_contents, fs::read_to_string(dir.join("foundry.toml")).unwrap()); +} diff --git a/crates/commands/tests/tests-push.rs b/crates/commands/tests/tests-push.rs new file mode 100644 index 0000000..8692884 --- /dev/null +++ b/crates/commands/tests/tests-push.rs @@ -0,0 +1,254 @@ +use mockito::{Matcher, Mock, ServerGuard}; +use reqwest::StatusCode; +use soldeer_commands::{commands::push::Push, run, Command}; +use soldeer_core::{errors::PublishError, SoldeerError}; +use std::{env, fs, path::PathBuf}; +use temp_env::async_with_vars; +use testdir::testdir; + +#[allow(clippy::unwrap_used)] +fn setup_project(dotfile: bool) -> (PathBuf, PathBuf) { + let dir = testdir!(); + let login_file = dir.join("test_save_jwt"); + fs::write(&login_file, "jwt_token_example").unwrap(); + let project_path = dir.join("mypkg"); + fs::create_dir(&project_path).unwrap(); + fs::write(project_path.join("foundry.toml"), "[dependencies]\n").unwrap(); + if dotfile { + fs::write(project_path.join(".env"), "super-secret-stuff").unwrap(); + } + (login_file, project_path) +} + +async fn mock_api_server(status_code: Option) -> (ServerGuard, Mock) { + let mut server = mockito::Server::new_async().await; + let body = r#"{"data":[{"created_at":"2024-02-27T19:19:23.938837Z","deleted":false,"description":"","downloads":67634,"github_url":"","id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","image":"","long_description":"","name":"mock","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(body) + .create_async() + .await; + let mock = match status_code { + Some(status_code) => { + server + .mock("POST", "/api/v1/revision/upload") + .with_header("content-type", "application/json") + .with_status(status_code.as_u16() as usize) + .with_body(r#"{"status":"fail","message": "failure"}"#) + .create_async() + .await + } + None => { + server + .mock("POST", "/api/v1/revision/upload") + .with_header("content-type", "application/json") + .with_body(r#"{"status":"success","data":{"data":{"project_id":"mock"}}}"#) + .create_async() + .await + } + }; + + (server, mock) +} + +#[tokio::test] +async fn test_push_success() { + let (login_file, project_path) = setup_project(false); + + let (server, mock) = mock_api_server(None).await; + + env::set_current_dir(&project_path).unwrap(); + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: None, + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_other_dir_success() { + let dir = testdir!(); + fs::write(dir.join("foundry.toml"), "[dependencies]\n").unwrap(); + let login_file = dir.join("test_save_jwt"); + fs::write(&login_file, "jwt_token_example").unwrap(); + let project_path = dir.join("mypkg"); + fs::create_dir(&project_path).unwrap(); + fs::write(project_path.join("test.sol"), "contract Foo {}\n").unwrap(); + + let (server, mock) = mock_api_server(None).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path), + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_not_found() { + let (login_file, project_path) = setup_project(false); + + let (server, mock) = mock_api_server(Some(StatusCode::NO_CONTENT)).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path), + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::ProjectNotFound)))); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_already_exists() { + let (login_file, project_path) = setup_project(false); + + let (server, mock) = mock_api_server(Some(StatusCode::ALREADY_REPORTED)).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path), + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::AlreadyExists)))); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_unauthorized() { + let (login_file, project_path) = setup_project(false); + + let (server, mock) = mock_api_server(Some(StatusCode::UNAUTHORIZED)).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path), + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::AuthError(_))))); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_payload_too_large() { + let (login_file, project_path) = setup_project(false); + + let (server, mock) = mock_api_server(Some(StatusCode::PAYLOAD_TOO_LARGE)).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path), + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::PayloadTooLarge)))); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_other_error() { + let (login_file, project_path) = setup_project(false); + + let (server, mock) = mock_api_server(Some(StatusCode::INTERNAL_SERVER_ERROR)).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path), + dry_run: false, + skip_warnings: false, + })), + ) + .await; + assert!(matches!(res, Err(SoldeerError::PublishError(PublishError::HttpError(_))))); + mock.expect(1); +} + +#[tokio::test] +async fn test_push_dry_run() { + let (login_file, project_path) = setup_project(true); // insert a .env file + + let (server, mock) = mock_api_server(None).await; + + let res = async_with_vars( + [ + ("SOLDEER_PROJECT_ROOT", Some(project_path.to_string_lossy().to_string())), + ("SOLDEER_API_URL", Some(server.url())), + ("SOLDEER_LOGIN_FILE", Some(login_file.to_string_lossy().to_string())), + ], + run(Command::Push(Push { + dependency: "mypkg~0.1.0".to_string(), + path: Some(project_path.clone()), + dry_run: true, + skip_warnings: false, + })), + ) + .await; + assert!(res.is_ok(), "{res:?}"); + mock.expect(0); + assert!(project_path.join("mypkg.zip").exists()); +} diff --git a/crates/commands/tests/tests-uninstall.rs b/crates/commands/tests/tests-uninstall.rs new file mode 100644 index 0000000..147255d --- /dev/null +++ b/crates/commands/tests/tests-uninstall.rs @@ -0,0 +1,87 @@ +use soldeer_commands::{ + commands::{install::Install, uninstall::Uninstall}, + run, Command, +}; +use soldeer_core::{config::read_config_deps, lock::read_lockfile}; +use std::{fs, path::PathBuf}; +use temp_env::async_with_vars; +use testdir::testdir; + +#[allow(clippy::unwrap_used)] +async fn setup(config_filename: &str) -> PathBuf { + let dir = testdir!(); + let mut contents = r#"[dependencies] +"@openzeppelin-contracts" = "5.0.2" +solady = "0.0.238" +"# + .to_string(); + if config_filename == "foundry.toml" { + contents = format!( + r#"[profile.default] + +[soldeer] +remappings_location = "config" + +{contents}"# + ); + } + fs::write(dir.join(config_filename), contents).unwrap(); + let cmd: Command = Install::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + dir +} + +#[tokio::test] +async fn test_uninstall_one() { + let dir = setup("soldeer.toml").await; + let cmd: Command = Uninstall { dependency: "solady".to_string() }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let deps = read_config_deps(dir.join("soldeer.toml")).unwrap(); + assert!(!deps.iter().any(|d| d.name() == "solady")); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert!(!remappings.contains("solady")); + let lock = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert!(!lock.entries.iter().any(|d| d.name() == "solady")); + assert!(!dir.join("dependencies").join("solady-0.0.238").exists()); +} + +#[tokio::test] +async fn test_uninstall_all() { + let dir = setup("soldeer.toml").await; + let cmd: Command = Uninstall { dependency: "solady".to_string() }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let cmd: Command = Uninstall { dependency: "@openzeppelin-contracts".to_string() }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + + let deps = read_config_deps(dir.join("soldeer.toml")).unwrap(); + assert!(deps.is_empty()); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert_eq!(remappings, ""); + assert!(!dir.join("soldeer.lock").exists()); +} + +#[tokio::test] +async fn test_uninstall_foundry_config() { + let dir = setup("foundry.toml").await; + let cmd: Command = Uninstall { dependency: "solady".to_string() }.into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let deps = read_config_deps(dir.join("foundry.toml")).unwrap(); + assert!(!deps.iter().any(|d| d.name() == "solady")); + let config = fs::read_to_string(dir.join("foundry.toml")).unwrap(); + assert!(!config.contains("solady")); +} diff --git a/crates/commands/tests/tests-update.rs b/crates/commands/tests/tests-update.rs new file mode 100644 index 0000000..2da4230 --- /dev/null +++ b/crates/commands/tests/tests-update.rs @@ -0,0 +1,206 @@ +use soldeer_commands::{ + commands::{install::Install, update::Update}, + run, Command, +}; +use soldeer_core::lock::read_lockfile; +use std::{fs, path::PathBuf}; +use temp_env::async_with_vars; +use testdir::testdir; + +#[allow(clippy::unwrap_used)] +async fn setup(config_filename: &str) -> PathBuf { + // install v1.9.0 of forge-std (faking an old install) + let dir = testdir!(); + let mut contents = r#"[dependencies] +forge-std = "1.9.0" +"# + .to_string(); + if config_filename == "foundry.toml" { + contents = format!( + r#"[profile.default] + +[soldeer] +remappings_location = "config" + +{contents}"# + ); + } + fs::write(dir.join(config_filename), &contents).unwrap(); + let cmd: Command = Install::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + // change install requirement to forge-std ^1.0.0 (making the current install outdated) + contents = contents.replace("1.9.0", "1"); + fs::write(dir.join(config_filename), &contents).unwrap(); + // update remappings accordingly + fs::write(dir.join("remappings.txt"), "forge-std-1/=dependencies/forge-std-1.9.0/\n").unwrap(); + dir +} + +#[tokio::test] +async fn test_update_existing() { + let dir = setup("soldeer.toml").await; + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(dir.join("soldeer.lock")).unwrap(); + let version = lockfile.entries.first().unwrap().version(); + assert_ne!(version, "1.9.0"); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert_eq!(remappings, format!("forge-std-1/=dependencies/forge-std-{version}/\n")); + assert!(dir.join("dependencies").join(format!("forge-std-{version}")).exists()); +} + +#[tokio::test] +async fn test_update_foundry_config() { + let dir = setup("foundry.toml").await; + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(dir.join("soldeer.lock")).unwrap(); + let version = lockfile.entries.first().unwrap().version(); + assert_ne!(version, "1.9.0"); + assert!(dir.join("dependencies").join(format!("forge-std-{version}")).exists()); +} + +#[tokio::test] +async fn test_update_missing() { + let dir = testdir!(); + let contents = r#"[dependencies] +forge-std = "1" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(dir.join("soldeer.lock")).unwrap(); + let version = lockfile.entries.first().unwrap().version(); + assert!(dir.join("dependencies").join(format!("forge-std-{version}")).exists()); +} + +#[tokio::test] +async fn test_update_custom_remappings() { + let dir = setup("soldeer.toml").await; + // customize remappings before update + fs::write(dir.join("remappings.txt"), "forge-std/=dependencies/forge-std-1.9.0/src/\n") + .unwrap(); + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(dir.join("soldeer.lock")).unwrap(); + let version = lockfile.entries.first().unwrap().version(); + let remappings = fs::read_to_string(dir.join("remappings.txt")).unwrap(); + assert_eq!(remappings, format!("forge-std/=dependencies/forge-std-{version}/src/\n")); +} + +#[tokio::test] +async fn test_update_git_main() { + let dir = testdir!(); + // install older commit in "main" branch + let contents = r#"[dependencies] +my-lib = { version = "branch-main", git = "https://github.com/beeb/test-repo.git" } +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let lockfile = r#"[[dependencies]] +name = "my-lib" +version = "branch-main" +git = "https://github.com/beeb/test-repo.git" +rev = "78c2f6a1a54db26bab6c3f501854a1564eb3707f" +"#; + fs::write(dir.join("soldeer.lock"), lockfile).unwrap(); + let cmd: Command = Install::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + + // update to latest commit in "main" branch + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lockfile.entries.first().unwrap().as_git().unwrap().rev, + "d5d72fa135d28b2e8307650b3ea79115183f2406" + ); +} + +#[tokio::test] +async fn test_update_git_branch() { + let dir = testdir!(); + // install older commit in "dev" branch + let contents = r#"[dependencies] +my-lib = { version = "branch-dev", git = "https://github.com/beeb/test-repo.git", branch = "dev" } +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let lockfile = r#"[[dependencies]] +name = "my-lib" +version = "branch-dev" +git = "https://github.com/beeb/test-repo.git" +rev = "78c2f6a1a54db26bab6c3f501854a1564eb3707f" +"#; + fs::write(dir.join("soldeer.lock"), lockfile).unwrap(); + let cmd: Command = Install::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + + // update to latest commit in "dev" branch + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let lockfile = read_lockfile(dir.join("soldeer.lock")).unwrap(); + assert_eq!( + lockfile.entries.first().unwrap().as_git().unwrap().rev, + "8d903e557e8f1b6e62bde768aa456d4ddfca72c4" + ); +} + +#[tokio::test] +async fn test_update_foundry_config_multi_dep() { + let dir = testdir!(); + + let contents = r#"[profile.default] + +[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" } + +[soldeer] +remappings_location = "config" +"#; + + fs::write(dir.join("foundry.toml"), contents).unwrap(); + + let cmd: Command = Update::default().into(); + let res = + async_with_vars([("SOLDEER_PROJECT_ROOT", Some(dir.to_string_lossy().as_ref()))], run(cmd)) + .await; + assert!(res.is_ok(), "{res:?}"); + let deps = dir.join("dependencies"); + assert!(deps.join("@tt-1.6.1").exists()); + assert!(deps.join("forge-std-1.8.1").exists()); + assert!(deps.join("solmate-6.7.0").exists()); + assert!(deps.join("mario-1.0").exists()); + assert!(deps.join("mario-custom-tag-1.0").exists()); + assert!(deps.join("mario-custom-branch-1.0").exists()); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..fc687dd --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,60 @@ +[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.workspace = true +dunce = "1.0.5" +home = "0.5.9" +ignore = { version = "0.4.22", features = ["simd-accel"] } +path-slash.workspace = true +rayon.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..1fc4b90 --- /dev/null +++ b/crates/core/src/auth.rs @@ -0,0 +1,155 @@ +//! Registry authentication +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; + +/// Credentials to be used for login +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct Credentials { + pub email: String, + pub password: String, +} + +/// Response from the login endpoint +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct LoginResponse { + pub status: String, + /// JWT token + pub token: String, +} + +/// Get the JWT token from the login file +pub fn get_token() -> Result { + let login_file = login_file_path()?; + let jwt = + fs::read_to_string(&login_file).map_err(|_| AuthError::MissingToken)?.trim().to_string(); + if jwt.is_empty() { + return Err(AuthError::MissingToken); + } + Ok(jwt) +} + +/// Execute the login request and store the JWT token in the login file +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().expect_err("result should be an error"), + )), + } +} + +#[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..c20a432 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,1562 @@ +//! Manage the Soldeer configuration and dependencies list. +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, Array, DocumentMut, InlineTable, Item, Table}; + +#[cfg(feature = "cli")] +use cliclack::{log::warning, select}; + +pub type Result = std::result::Result; + +/// The paths used by Soldeer. +/// +/// The paths are canonicalized on creation of the object. +/// +/// To create this object, the [`Paths::new`] and [`Paths::from_root`] methods can be used. +/// +/// # Examples +/// +/// ``` +/// # use soldeer_core::config::Paths; +/// # let dir = testdir::testdir!(); +/// # std::env::set_current_dir(&dir).unwrap(); +/// # std::fs::write("foundry.toml", "[dependencies]\n").unwrap(); +/// let paths = Paths::new().unwrap(); // foundry.toml exists in the current path +/// assert_eq!(paths.root, std::env::current_dir().unwrap()); +/// assert_eq!(paths.config, std::env::current_dir().unwrap().join("foundry.toml")); +/// +/// let paths = Paths::from_root(&dir).unwrap(); // root is the given path +/// assert_eq!(paths.root, dir); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +// making sure the struct is not constructible from the outside without using the new/from methods +#[non_exhaustive] +pub struct Paths { + /// The root directory of the project. + /// + /// At the moment, the current directory or the path given by the `SOLDEER_PROJECT_ROOT` + /// environment variable. + pub root: PathBuf, + + /// The path to the config file. + /// + /// `foundry.toml` if it contains a `[dependencies]` table, otherwise `soldeer.toml` if it + /// exists. If neither file exists, the user is prompted to create one when the `cli` + /// feature is enabled. If the `cli` feature is not enabled, the function will return the + /// path to the `foundry.toml` by default. When the config file does not exist, a new one + /// is created with default contents. + pub config: PathBuf, + + /// The path to the dependencies folder (does not need to exist). + /// + /// This is `/dependencies` inside the root directory. + pub dependencies: PathBuf, + + /// The path to the lockfile (does not need to exist). + /// + /// This is `/soldeer.lock` inside the root directory. + pub lock: PathBuf, + + /// The path to the remappings file (does not need to exist). + /// + /// This path gets ignored if the remappings should be generated in the `foundry.toml` file. + /// This is `/remappings.txt` inside the root directory. + 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 }) + } + + /// Get the root directory path. + /// + /// At the moment, this is the current directory, unless overridden by the + /// `SOLDEER_PROJECT_ROOT` environment variable. + fn get_root_path() -> PathBuf { + // TODO: find the project's root directory and use that as the root instead of the current + // dir + 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 choose one (only with `cli` feature + /// flag). + 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 { + /// Whether to generate remappings or completely leave them untouched. + /// + /// Defaults to `true`. + #[serde(default = "default_true")] + pub remappings_generate: bool, + + /// Whether to regenerate the remappings every time and ignore existing content. + /// + /// Defaults to `false`. + #[serde(default)] + pub remappings_regenerate: bool, + + /// Whether to include the version requirement string in the left part of the remappings. + /// + /// Defaults to `true`. + #[serde(default = "default_true")] + pub remappings_version: bool, + + /// A prefix to add to each dependency name in the left part of the remappings. + /// + /// None by default. + #[serde(default)] + pub remappings_prefix: String, + + /// The location where the remappings file should be generated. + /// + /// Either inside the `foundry.toml` config file or as a separate `remappings.txt` file. + /// This gets ignored if the config file is `soldeer.toml`, in which case the remappings + /// are always generated in a separate file. + /// + /// Defaults to [`RemappingsLocation::Txt`]. + #[serde(default)] + pub remappings_location: RemappingsLocation, + + /// Whether to include dependencies from dependencies. + /// + /// For dependencies which use soldeer, the `soldeer install` command will be invoked. + /// Git dependencies which have submodules will see their submodules cloned as well. + /// + /// Defaults to `false`. + #[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, + } + } +} + +/// A git identifier used to specify a revision, branch or tag. +/// +/// # Examples +/// +/// ``` +/// # use soldeer_core::config::GitIdentifier; +/// let rev = GitIdentifier::from_rev("082692fcb6b5b1ab8f856914897f7f2b46b84fd2"); +/// let branch = GitIdentifier::from_branch("feature/foo"); +/// let tag = GitIdentifier::from_tag("v1.0.0"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub enum GitIdentifier { + /// A commit hash + Rev(String), + + /// A branch name + Branch(String), + + /// A tag name + Tag(String), +} + +impl GitIdentifier { + /// Create a new git identifier from a revision hash. + pub fn from_rev(rev: impl Into) -> Self { + let rev: String = rev.into(); + Self::Rev(rev) + } + + /// Create a new git identifier from a branch name. + pub fn from_branch(branch: impl Into) -> Self { + let branch: String = branch.into(); + Self::Branch(branch) + } + + /// Create a new git identifier from a tag name. + pub fn from_tag(tag: impl Into) -> Self { + let tag: String = tag.into(); + Self::Tag(tag) + } +} + +/// A git dependency config item. +/// +/// This struct is used to represent a git dependency from the config file. +#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)] +#[builder(on(String, into))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub struct GitDependency { + /// The name of the dependency (user-defined). + pub name: String, + + /// The version requirement string (semver). + /// + /// Example: `>=1.9.3 || ^2.0.0` + /// + /// When no operator is used before the version number, it defaults to `=` which pins the + /// version. + #[cfg_attr(feature = "serde", serde(rename = "version"))] + pub version_req: String, + + /// The git URL, must end with `.git`. + pub git: String, + + /// The git identifier (revision, branch or tag). + /// + /// If omitted, the main branch is used. + 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) + } +} + +/// An HTTP dependency config item. +/// +/// This struct is used to represent an HTTP dependency from the config file. +#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)] +#[builder(on(String, into))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub struct HttpDependency { + /// The name of the dependency (user-defined). + pub name: String, + + /// The version requirement string (semver). + /// + /// Example: `>=1.9.3 || ^2.0.0` + /// + /// When no operator is used before the version number, it defaults to `=` which pins the + /// version. + #[cfg_attr(feature = "serde", serde(rename = "version"))] + pub version_req: String, + + /// The URL to the dependency. + /// + /// If omitted, the registry will be contacted to get the download URL for that dependency (by + /// name). + 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) + } +} + +/// A git or HTTP dependency config item. +/// +/// A builder can be used to create the underlying [`HttpDependency`] or [`GitDependency`] and then +/// converted into this type with `.into()`. +/// +/// # Examples +/// +/// ``` +/// # use soldeer_core::config::{Dependency, HttpDependency}; +/// let dep: Dependency = HttpDependency::builder() +/// .name("my-dep") +/// .version_req("^1.0.0") +/// .url("https://...") +/// .build() +/// .into(); +/// ``` +#[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 { + /// Create a new dependency from a name and version requirement string. + /// + /// The string should be in the format `name~version_req`. + /// + /// The version requirement string can use the semver format. + /// + /// Example: `dependency~^1.0.0` + /// + /// If a custom URL is provided, then the version requirement string + /// cannot contain the `=` character, as it would break the remappings. + /// + /// The type of dependency ([`HttpDependency`] or [`GitDependency`]) is inferred from the URL + /// format, which can be of the form `https://...`, `git@github.com:` or `git@gitlab.com:`. + /// + /// # Examples + /// + /// ``` + /// # use soldeer_core::config::{Dependency, HttpDependency, GitDependency, GitIdentifier}; + /// assert_eq!( + /// Dependency::from_name_version("my-lib~^1.0.0", Some("https://foo.bar/zip.zip"), None) + /// .unwrap(), + /// HttpDependency::builder() + /// .name("my-lib") + /// .version_req("^1.0.0") + /// .url("https://foo.bar/zip.zip") + /// .build() + /// .into() + /// ); + /// assert_eq!( + /// Dependency::from_name_version( + /// "my-lib~^1.0.0", + /// Some("git@github.com:foo/bar.git"), + /// Some(GitIdentifier::from_tag("v1.0.0")) + /// ) + /// .unwrap(), + /// GitDependency::builder() + /// .name("my-lib") + /// .version_req("^1.0.0") + /// .git("git@github.com:foo/bar.git") + /// .identifier(GitIdentifier::from_tag("v1.0.0")) + /// .build() + /// .into() + /// ); + /// ``` + 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(), + }) + } + + /// Get the name of the dependency. + pub fn name(&self) -> &str { + match self { + Self::Http(dep) => &dep.name, + Self::Git(dep) => &dep.name, + } + } + + /// Get the version requirement string of the dependency. + pub fn version_req(&self) -> &str { + match self { + Self::Http(dep) => &dep.version_req, + Self::Git(dep) => &dep.version_req, + } + } + + /// Get the URL of the dependency. + pub fn url(&self) -> Option<&String> { + match self { + Self::Http(dep) => dep.url.as_ref(), + Self::Git(dep) => Some(&dep.git), + } + } + + /// Get the install path of the dependency (must exist already). + pub fn install_path_sync(&self, deps: impl AsRef) -> Option { + find_install_path_sync(self, deps) + } + + /// Get the install path of the dependency in an async way (must exist already). + pub async fn install_path(&self, deps: impl AsRef) -> Option { + find_install_path(self, deps).await + } + + /// Convert the dependency to a TOML value for saving to the config file. + 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)) + } + } + } + + /// Check if the dependency is an HTTP dependency. + pub fn is_http(&self) -> bool { + matches!(self, Self::Http(_)) + } + + /// Cast to a HTTP dependency if it is one. + pub fn as_http(&self) -> Option<&HttpDependency> { + if let Self::Http(v) = self { + Some(v) + } else { + None + } + } + + /// Cast to a mutable HTTP dependency if it is one. + pub fn as_http_mut(&mut self) -> Option<&mut HttpDependency> { + if let Self::Http(v) = self { + Some(v) + } else { + None + } + } + + /// Check if the dependency is a git dependency. + pub fn is_git(&self) -> bool { + matches!(self, Self::Git(_)) + } + + /// Cast to a git dependency if it is one. + pub fn as_git(&self) -> Option<&GitDependency> { + if let Self::Git(v) = self { + Some(v) + } else { + None + } + } + + /// Cast to a mutable git dependency if it is one. + 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()) + } +} + +/// The location where the Soldeer config should be stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromStr)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, Deserialize))] +pub enum ConfigLocation { + /// The `foundry.toml` file. + Foundry, + + /// The `soldeer.toml` file. + Soldeer, +} + +/// Read the list of dependencies from the config file. +/// +/// Dependencies are stored in a TOML table under the `dependencies` key. +/// Each key inside of the table is the name of the dependency and the value can be: +/// - a string representing the version requirement +/// - a table with the following fields: +/// - `version` (required): the version requirement string +/// - `url` (optional): the URL to the dependency's zip file +/// - `git` (optional): the git URL for git dependencies +/// - `rev` (optional): the revision hash for git dependencies +/// - `branch` (optional): the branch name for git dependencies +/// - `tag` (optional): the tag name for git dependencies +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) +} + +/// Read the Soldeer config from the config file. +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) +} + +/// Add a dependency to the config file. +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(()) +} + +/// Delete a dependency from the config file. +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(dependencies) = doc["dependencies"].as_table_mut() else { + return Err(ConfigError::MissingDependency(dependency_name.to_string())); + }; + let Some(item_removed) = dependencies.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) +} + +/// Update the config file to add the `dependencies` folder as a source for libraries. +pub fn update_config_libs(foundry_config: impl AsRef) -> Result<()> { + let contents = fs::read_to_string(&foundry_config)?; + let mut doc: DocumentMut = contents.parse::()?; + + if !doc.contains_key("profile") { + let mut profile = Table::default(); + profile["default"] = Item::Table(Table::default()); + profile.set_implicit(true); + doc["profile"] = Item::Table(profile); + } + + let profile = doc["profile"].as_table_mut().expect("profile should be a table"); + if !profile.contains_key("default") { + profile["default"] = Item::Table(Table::default()); + } + + let default_profile = + profile["default"].as_table_mut().expect("default profile should be a table"); + if !default_profile.contains_key("libs") { + default_profile["libs"] = value(Array::from_iter(&["dependencies".to_string()])); + } + + let libs = default_profile["libs"].as_array_mut().expect("libs should be an array"); + if !libs.iter().any(|v| v.as_str() == Some("dependencies")) { + libs.push("dependencies"); + } + + fs::write(foundry_config, doc.to_string())?; + Ok(()) +} + +/// Parse a dependency from a TOML value. +/// +/// The value can be a string (version requirement) or a table. +/// The table can have the following fields: +/// - `version` (required): the version requirement string +/// - `url` (optional): the URL to the dependency's zip file +/// - `git` (optional): the git URL for git dependencies +/// - `rev` (optional): the revision hash for git dependencies +/// - `branch` (optional): the branch name for git dependencies +/// - `tag` (optional): the tag name for git dependencies +/// +/// Note that the version requirement string cannot contain the `=` symbol for git dependencies +/// and HTTP dependencies with a custom URL. +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)); + } + // 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)); + } + + // 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)) => { + // for git dependencies, 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 version_req.contains('=') { + return Err(ConfigError::InvalidVersionReq(name)); + } + // 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)) => { + // for HTTP dependencies with custom URL, 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 version_req.contains('=') { + return Err(ConfigError::InvalidVersionReq(name)); + } + Ok(HttpDependency { name, version_req, url: Some(url.to_string()) }.into()) + } + } +} + +/// Create a basic config file with default contents. +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" = { 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:?}"); + } + + // it's ok to have the "=" character in the version requirement for HTTP dependencies + // without a custom URL + let config_contents = r#"[dependencies] +"lib1" = "asdf=" +"lib2" = { version = "asdf=" } +"#; + let config_path = write_to_config(config_contents, "soldeer.toml"); + let res = read_config_deps(config_path); + assert!(res.is_ok(), "{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:?}"); + } + + #[test] + fn test_update_config_libs() { + let config_contents = r#"[profile.default] +libs = ["lib"] + +[dependencies] +"#; + let config_path = write_to_config(config_contents, "foundry.toml"); + let res = update_config_libs(&config_path); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + contents, + r#"[profile.default] +libs = ["lib", "dependencies"] + +[dependencies] +"# + ); + } + + #[test] + fn test_update_config_profile_empty() { + let config_contents = r#"[dependencies] +"#; + let config_path = write_to_config(config_contents, "foundry.toml"); + let res = update_config_libs(&config_path); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + contents, + r#"[dependencies] + +[profile.default] +libs = ["dependencies"] +"# + ); + } + + #[test] + fn test_update_config_libs_empty() { + let config_contents = r#"[profile.default] +src = "src" + +[dependencies] +"#; + let config_path = write_to_config(config_contents, "foundry.toml"); + let res = update_config_libs(&config_path); + assert!(res.is_ok(), "{res:?}"); + let contents = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + contents, + r#"[profile.default] +src = "src" +libs = ["dependencies"] + +[dependencies] +"# + ); + } +} diff --git a/crates/core/src/download.rs b/crates/core/src/download.rs new file mode 100644 index 0000000..f27802d --- /dev/null +++ b/crates/core/src/download.rs @@ -0,0 +1,309 @@ +//! Download and/or extract dependencies +use crate::{ + config::{Dependency, GitIdentifier}, + errors::DownloadError, + utils::{path_matches, run_git_command, sanitize_filename}, +}; +use reqwest::IntoUrl; +use std::{ + fs, + io::Cursor, + path::{Path, PathBuf}, + str, +}; +use tokio::io::AsyncWriteExt as _; + +pub type Result = std::result::Result; + +/// Download a zip file into the provided folder. +/// +/// Depending on the platform, the folder path must exist prior to calling this function. +/// The filename for the zip file will be the provided base name with the ".zip" extension +pub async fn download_file( + url: impl IntoUrl, + folder_path: impl AsRef, + base_name: &str, +) -> Result { + let resp = reqwest::get(url).await?; + let mut resp = resp.error_for_status()?; + + let zip_path = folder_path.as_ref().join(sanitize_filename(&format!("{base_name}.zip"))); + let mut file = tokio::fs::File::create(&zip_path) + .await + .map_err(|e| DownloadError::IOError { path: zip_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: zip_path.clone(), source: e })?; + } + file.flush().await.map_err(|e| DownloadError::IOError { path: zip_path.clone(), source: e })?; + Ok(zip_path) +} + +/// Unzip a file into a directory and then delete it. +pub async fn unzip_file(path: impl AsRef, into: impl AsRef) -> Result<()> { + let path = path.as_ref().to_path_buf(); + let zip_contents = tokio::fs::read(&path) + .await + .map_err(|e| DownloadError::IOError { path: path.clone(), source: e })?; + + tokio::task::spawn_blocking({ + let out_dir = into.as_ref().to_path_buf(); + move || zip_extract::extract(Cursor::new(zip_contents), &out_dir, true) + }) + .await??; + + tokio::fs::remove_file(&path) + .await + .map_err(|e| DownloadError::IOError { path: path.clone(), source: e }) +} + +/// Clone a git repo into the given path, optionally checking out a reference. +/// +/// The repository is cloned without trees, which can speed up cloning when the full history is not +/// needed. Contrary to a shallow clone, it's possible to checkout any ref and the missing trees +/// will be retrieved as they are needed. +/// +/// This function returns the commit hash corresponding to the checked out reference (branch, tag, +/// commit). +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) +} + +/// Remove the files for a dependency (synchronous). +/// +/// This function should only be called in sync contexts. For a version that is safe to run in +/// multithreaded async contexts, see [`delete_dependency_files`]. +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(()) +} + +/// Find the install path of a dependency by reading the dependencies directory and matching on the +/// folder name. +/// +/// If a dependency version requirement string is a semver requirement, any folder which version +/// matches the requirements is returned. +pub fn find_install_path_sync(dependency: &Dependency, deps: impl AsRef) -> Option { + fs::read_dir(deps.as_ref()) + .map(|read_dir| { + read_dir.into_iter().find_map(|e| { + e.ok().filter(|e| install_path_matches(dependency, e.path())).map(|e| e.path()) + }) + }) + .ok() + .flatten() +} + +/// Find the install path of a dependency by reading the dependencies directory and matching on the +/// folder name (async version). +/// +/// If a dependency version requirement string is a semver requirement, any folder which version +/// matches the requirements is returned. +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 +} + +/// Remove the files for a dependency from the dependencies folder. +/// +/// A folder must exist for the dependency. +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(()) +} + +/// Check if a path corresponds to the provided dependency. +/// +/// The path must exist and be a folder, and the folder name must start with the dependency name +/// (sanitized). For dependencies with a semver-compliant version requirement, any folder with a +/// version that matches will give a result of `true`. Otherwise, the folder name must contain the +/// version requirement string after the dependency name. +fn install_path_matches(dependency: &Dependency, path: impl AsRef) -> bool { + let path = path.as_ref(); + if !path.is_dir() { + return false; + } + path_matches(dependency, path) +} + +#[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, + "my-dependency", + ) + .await; + assert!(res.is_ok(), "{res:?}"); + let zip_path = path.join("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..48df46b --- /dev/null +++ b/crates/core/src/install.rs @@ -0,0 +1,927 @@ +//! Install dependencies. +//! +//! This module contains functions to install dependencies from the config object or from the +//! lockfile. Dependencies can be installed in parallel. +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; + +/// Template for the progress bars. +pub const PROGRESS_TEMPLATE: &str = "[{elapsed_precise}] {bar:30.magenta} ({pos}/{len}) {msg}"; + +pub type Result = std::result::Result; + +/// Status of a dependency, which can either be missing, installed and untouched, or installed but +/// failing the integrity check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DependencyStatus { + /// The dependency is missing. + Missing, + + /// The dependency is installed but the integrity check failed. + FailedIntegrity, + + /// The dependency is installed and the integrity check passed. + Installed, +} + +/// Progress bars for the installation process. +#[cfg(feature = "cli")] +#[derive(Clone)] +pub struct Progress { + /// The multi progress bar object. + pub multi: MultiProgress, + + /// Progress bar for calls to the API to retrieve the packages versions. + pub versions: ProgressBar, + + /// Progress bar for downloading the dependencies. + pub downloads: ProgressBar, + + /// Progress bar for unzipping the downloaded files. + pub unzip: ProgressBar, + + /// Progress bar for installing subdependencies. + pub subdependencies: ProgressBar, + + /// Progress bar for checking the integrity of the installed dependencies. + pub integrity: ProgressBar, +} + +#[cfg(feature = "cli")] +impl Progress { + /// Create a new progress bar object. + /// + /// The total number of dependencies to install must be passed as an argument. + 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 } + } + + /// Start all progress bars. + 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..."); + } + + /// Increment all progress bars by one. + 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); + } + + /// Stop all progress bars. + 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"); + } + + /// Log a message above the multiprogress bar. + pub fn log(&self, msg: impl fmt::Display) { + self.multi.println(msg); + } +} + +/// HTTP dependency installation information. +#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)] +#[builder(on(String, into))] +struct HttpInstallInfo { + /// The name of the dependency. + name: String, + + /// The version of the dependency. This is not a version requirement string but a specific. + /// version. + version: String, + + /// THe URL from which the zip file will be downloaded. + url: String, + + /// The checksum of the downloaded zip file, if available (e.g. from the lockfile) + checksum: Option, +} + +/// Git dependency installation information. +#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)] +#[builder(on(String, into))] +struct GitInstallInfo { + /// The name of the dependency. + name: String, + + /// The version of the dependency. + version: String, + + /// The URL of the git repository. + git: String, + + /// The identifier of the git dependency (e.g. a commit hash, branch name, or tag name). If + /// `None` is provided, the default branch is used. + identifier: Option, +} + +/// Installation information for a dependency. +/// +/// A builder can be used to create the underlying [`HttpInstallInfo`] or [`GitInstallInfo`] and +/// then converted into this type with `.into()`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum InstallInfo { + /// Installation information for an HTTP dependency. + Http(HttpInstallInfo), + + /// Installation information for a git dependency. + 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(), + } + } +} + +/// Install a list of dependencies in parallel. +/// +/// This function spawns a task for each dependency and waits for all of them to finish. Each task +/// checks the integrity of the dependency if found on disk, downloads the dependency (zip file or +/// cloning repo) if not already present, unzips the zip file if necessary, installs +/// sub-dependencies and generates the lockfile entry. +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. +/// +/// This function checks the integrity of the dependency if found on disk, downloads the dependency +/// (zip file or cloning repo) if not already present, unzips the zip file if necessary, installs +/// sub-dependencies and generates the lockfile entry. +/// +/// If no lockfile entry is provided, the dependency is installed from the config object and +/// integrity checks are skipped. +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 + } +} + +/// Check the integrity of a dependency that was installed. +/// +/// If any file has changed in the dependency directory (except ignored files and any `.git` +/// directory), the integrity check will fail. +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, + } +} + +/// Ensure that the dependencies directory exists. +/// +/// If the directory does not exist, it will be created. +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(()) +} + +/// Install a single dependency. +async fn install_dependency_inner( + dep: &InstallInfo, + path: impl AsRef, + subdependencies: bool, + #[cfg(feature = "cli")] progress: Progress, +) -> Result { + match dep { + InstallInfo::Http(dep) => { + // download zip file into the dependencies directory, naming it after the dependency + let path = path.as_ref(); + let zip_path = download_file( + &dep.url, + path.parent().expect("dependency install path should have a parent"), + &format!("{}-{}", dep.name, dep.version), + ) + .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.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()) + } + } +} + +/// Install subdependencies of a dependency. +/// +/// This function checks for a `.gitmodules` file in the dependency directory and clones the +/// submodules if it exists. If a `soldeer.toml` file is found, the soldeer dependencies are +/// installed with a call to `forge soldeer install`. If the dependency has a `foundry.toml` file +/// with a `dependencies` table, the soldeer dependencies are installed as well. +/// +/// TODO: this function should install soldeer deps without calling to forge or the soldeer binary. +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(()) +} + +/// Check the integrity of an HTTP dependency. +/// +/// THis function hashes the contents of the dependency directory and compares it with the lockfile +/// entry. +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) +} + +/// Check the integrity of a git dependency. +/// +/// This function checks that the dependency is a git repository and that the current commit is the +/// one specified in the lockfile entry. +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..37be800 --- /dev/null +++ b/crates/core/src/lock.rs @@ -0,0 +1,619 @@ +//! Lockfile handling. +//! +//! The lockfile contains the resolved dependencies of a project. It is a TOML file with an array of +//! dependencies, each containing the name, version, and other information about the dependency. +//! +//! The lockfile is used to ensure that the same versions of dependencies are installed across +//! different machines. It is also used to skip the installation of dependencies that are already +//! installed. +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; + +/// A lock entry for a git dependency. +#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)] +#[builder(on(String, into))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub struct GitLockEntry { + /// The name of the dependency. + pub name: String, + + /// The version (this corresponds to the version requirement of the dependency). + pub version: String, + + /// The git url of the dependency. + pub git: String, + + /// The resolved git commit hash. + pub rev: String, +} + +impl GitLockEntry { + /// Returns the install path of the dependency. + /// + /// The directory does not need to exist. Since the lock entry contains the version, + /// the install path can be calculated without needing to check the actual directory. + pub fn install_path(&self, deps: impl AsRef) -> PathBuf { + format_install_path(&self.name, &self.version, deps) + } +} + +/// A lock entry for an HTTP dependency. +#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)] +#[builder(on(String, into))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub struct HttpLockEntry { + /// The name of the dependency. + pub name: String, + + /// The resolved version of the dependency (not necessarily matches the version requirement of + /// the dependency). + /// + /// If the version req is a semver range, then this will be the exact version that was + /// resolved. + pub version: String, + + /// The URL from where the dependency was downloaded. + pub url: String, + + /// The checksum of the downloaded zip file. + pub checksum: String, + + /// The integrity hash of the downloaded zip file after extraction. + pub integrity: String, +} + +impl HttpLockEntry { + /// Returns the install path of the dependency. + /// + /// The directory does not need to exist. Since the lock entry contains the version, + /// the install path can be calculated without needing to check the actual directory. + pub fn install_path(&self, deps: impl AsRef) -> PathBuf { + format_install_path(&self.name, &self.version, deps) + } +} + +/// A lock entry for a dependency. +/// +/// A builder should be used to create the underlying [`HttpLockEntry`] or [`GitLockEntry`] and then +/// converted into this type with `.into()`. +/// +/// # Examples +/// +/// ``` +/// # use soldeer_core::lock::{LockEntry, HttpLockEntry}; +/// let dep: LockEntry = HttpLockEntry::builder() +/// .name("my-dep") +/// .version("1.2.3") +/// .url("https://...") +/// .checksum("dead") +/// .integrity("beef") +/// .build() +/// .into(); +/// ``` +#[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 { + /// A lock entry for an HTTP dependency. + Http(HttpLockEntry), + + /// A lock entry for a git dependency. + Git(GitLockEntry), +} + +/// A TOML representation of a lock entry, which merges all fields from the two variants of +/// [`LockEntry`]. +/// +/// This is used to serialize and deserialize lock entries to and from TOML. All fields which are +/// not present in both variants are optional. +#[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 { + /// Convert a [`LockEntry`] into a [`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; + + /// Convert a [`TomlLockEntry`] into a [`LockEntry`] if possible. + 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 { + /// The name of the dependency. + pub fn name(&self) -> &str { + match self { + Self::Git(lock) => &lock.name, + Self::Http(lock) => &lock.name, + } + } + + /// The version of the dependency. + pub fn version(&self) -> &str { + match self { + Self::Git(lock) => &lock.version, + Self::Http(lock) => &lock.version, + } + } + + /// The install path of the dependency. + 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), + } + } + + /// Get the underlying [`HttpLockEntry`] if this is an HTTP lock entry. + pub fn as_http(&self) -> Option<&HttpLockEntry> { + if let Self::Http(l) = self { + Some(l) + } else { + None + } + } + + /// Get the underlying [`GitLockEntry`] if this is a git lock entry. + pub fn as_git(&self) -> Option<&GitLockEntry> { + if let Self::Git(l) = self { + Some(l) + } else { + None + } + } +} + +impl From for LockEntry { + /// Wrap an [`HttpLockEntry`] in a [`LockEntry`]. + fn from(value: HttpLockEntry) -> Self { + Self::Http(value) + } +} + +impl From for LockEntry { + /// Wrap a [`GitLockEntry`] in a [`LockEntry`]. + fn from(value: GitLockEntry) -> Self { + Self::Git(value) + } +} + +/// A parsed TOML lock file. +/// +/// The lockfile is a table with one entry `dependencies` containing an array of [`TomlLockEntry`]s. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] +struct LockFileParsed { + dependencies: Vec, +} + +/// The result of reading and parsing a lock file. +/// +/// The [`TomlLockEntry`]s are converted into [`LockEntry`]s. A copy of the text contents of +/// the lockfile is provided for diffing purposes. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct LockFile { + /// The parsed lock entries. + pub entries: Vec, + + /// The raw contents of the lockfile. + pub raw: String, +} + +/// Read a lockfile from disk. +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, + }) +} + +/// Generate the contents of a lockfile from a list of lock entries. +/// +/// The entries do not need to be sorted, they will be sorted by name. +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") +} + +/// Add a lock entry to a lockfile. +/// +/// If an entry with the same name already exists, it will be replaced. +/// The entries are sorted by name before being written back to the file. +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(()) +} + +/// Remove a lock entry from a lockfile, matching on the name. +/// +/// If the entry is the last entry in the lockfile, the lockfile will be removed. +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(()) +} + +/// Format the install path of a dependency. +/// +/// The folder name is sanitized to remove disallowed characters. +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..a992c70 --- /dev/null +++ b/crates/core/src/push.rs @@ -0,0 +1,369 @@ +//! Handle publishing of a dependency to the registry. +use crate::{ + auth::get_token, + errors::{AuthError, PublishError}, + registry::{api_url, get_project_id}, + utils::read_file, +}; +use ignore::{WalkBuilder, WalkState}; +use path_slash::{PathBufExt as _, PathExt as _}; +use regex::Regex; +use reqwest::{ + header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}, + multipart::{Form, Part}, + Client, StatusCode, +}; +use std::{ + fs, + io::{Read as _, Write as _}, + path::{Path, PathBuf}, + sync::mpsc, +}; +use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; + +#[cfg(feature = "cli")] +use cliclack::log::success; + +pub type Result = std::result::Result; + +/// Push a new version of a dependency to the registry. +/// +/// The provided root folder will be zipped and uploaded to the registry, then deleted, unless the +/// `dry_run` argument is set to `true`. In that case, the function will only create the zip file +/// and return its path. +/// +/// An authentication token is required to push a zip file to the registry. The token is retrieved +/// from the login file (see [`login_file_path`][crate::utils::login_file_path] and +/// [`execute_login`][crate::auth::execute_login]). +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 { + return Ok(Some(PathBuf::from_slash_lossy(&zip_archive))); + } + + if let Err(error) = push_to_repo(&zip_archive, dependency_name, dependency_version).await { + let _ = fs::remove_file(zip_archive); + return Err(error); + } + + let _ = fs::remove_file(zip_archive); + + Ok(None) +} + +/// Validate the name of a dependency. +/// +/// The name must be between 3 and 100 characters long, and can only contain lowercase letters, +/// numbers, hyphens and the `@` symbol. It cannot start or end with a hyphen. +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(()) +} + +/// Create a zip file from a list of files. +/// +/// The zip file will be created in the root directory, with the provided name and the `.zip` +/// extension. The function returns the path to the created zip file. +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 = fs::File::create(&zip_file_path) + .map_err(|e| PublishError::IOError { path: zip_file_path.clone(), source: e })?; + 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 = fs::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) +} + +/// Filter the files in a directory according to ignore rules. +/// +/// The following ignore files are supported: +/// - `.ignore` +/// - `.gitignore` (including any global one) +/// - `.git/info/exclude` +/// - `.soldeerignore` +/// +/// The `.git` folders are always skipped. +pub fn filter_ignored_files(root_directory_path: impl AsRef) -> Vec { + let (tx, rx) = mpsc::channel::(); + 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 tx = tx.clone(); + // 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; + } + tx.send(path.to_path_buf()) + .expect("Channel receiver should never be dropped before end of function scope"); + WalkState::Continue + }) + }); + + drop(tx); + // this cannot happen before tx is dropped safely + let mut files = Vec::new(); + while let Ok(path) = rx.recv() { + files.push(path); + } + files +} + +/// Push a zip file to the registry. +/// +/// An authentication token is required to push a zip file to the registry. The token is retrieved +/// from the login file (see [`login_file_path`][crate::utils::login_file_path] and +/// [`execute_login`][crate::auth::execute_login]). +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) + .map_err(|e| PublishError::IOError { path: zip_file.to_path_buf(), source: e })?; + 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().expect_err("result should be an error"), + )), + _ => 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_ignored_files(&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..e376f9d --- /dev/null +++ b/crates/core/src/registry.rs @@ -0,0 +1,481 @@ +//! Soldeer registry client. +//! +//! The registry client is responsible for fetching information about packages from the Soldeer +//! registry at . +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; + +/// A revision (version) for a project (package). +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct Revision { + /// The unique ID for the revision. + pub id: uuid::Uuid, + + /// The version of the revision. + pub version: String, + + /// The internal name (path of zip file) for the revision. + pub internal_name: String, + + /// The zip file download URL. + pub url: String, + + /// The project unique ID. + pub project_id: uuid::Uuid, + + /// Whether this revision has been deleted. + pub deleted: bool, + + /// Creation date for the revision. + pub created_at: Option>, +} + +/// A project (package) in the registry. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct Project { + /// The unique ID for the project. + pub id: uuid::Uuid, + + /// The name of the project. + pub name: String, + + /// The description of the project. + pub description: String, + + /// The URL of the repository on GitHub. + pub github_url: String, + + /// The unique ID for the owner of the project. + pub user_id: uuid::Uuid, + + /// Whether this project has been deleted. + pub deleted: Option, + + /// The project's creation datetime. + pub created_at: Option>, + + /// The project's last update datetime. + pub updated_at: Option>, +} + +/// The response from the revision endpoint. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct RevisionResponse { + /// The revisions. + data: Vec, + + /// The status of the response. + status: String, +} + +/// The response from the project endpoint. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct ProjectResponse { + /// The projects. + data: Vec, + + /// The status of the response. + status: String, +} + +/// Construct a URL for the Soldeer API. +/// +/// The URL is constructed from the `SOLDEER_API_URL` environment variable, or defaults to +/// . The API version prefix and path are appended to the base URL, +/// and any query parameters are URL-encoded and appended to the URL. +/// +/// # Examples +/// +/// ``` +/// # use soldeer_core::registry::api_url; +/// let url = +/// api_url("revision", &[("project_name", "forge-std"), ("offset", "0"), ("limit", "1")]); +/// assert_eq!( +/// url.as_str(), +/// "https://api.soldeer.xyz/api/v1/revision?project_name=forge-std&offset=0&limit=1" +/// ); +/// ``` +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 +} + +/// Get the download URL for a dependency at a specific version. +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()) +} + +/// Get the unique ID for a project by name. +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()) +} + +/// Get the latest version of a dependency. +pub async fn get_latest_version(dependency_name: &str) -> Result { + 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()) +} + +/// The versions of a dependency. +/// +/// If all versions can be parsed as semver, then the versions are sorted in descending order +/// according to semver. If not all versions can be parsed as semver, then the versions are returned +/// in the order they were received from the API (descending creation date). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Versions { + /// All versions are semver compliant. + Semver(Vec), + + /// Not all versions are semver compliant. + NonSemver(Vec), +} + +/// Get all versions of a dependency sorted in descending order +/// +/// If all versions can be parsed as semver, then the versions are sorted in descending order +/// according to semver. If not all versions can be parsed as semver, then the versions are returned +/// in the order they were received from the API (descending creation date). +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())) + } + } +} + +/// Get the latest version of a dependency that satisfies the version requirement. +/// +/// If the version requirement is not semver-compliant, then the latest version is the one with the +/// latest creation date. +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_version("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..b064f82 --- /dev/null +++ b/crates/core/src/remappings.rs @@ -0,0 +1,840 @@ +//! Remappings management. +use crate::{ + config::{read_config_deps, Dependency, Paths, SoldeerConfig}, + errors::RemappingsError, + utils::path_matches, +}; +use derive_more::derive::From; +use path_slash::PathExt as _; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::Write as _, + path::PathBuf, +}; +use toml_edit::{value, Array, DocumentMut}; + +pub type Result = std::result::Result; + +/// Action to perform on the remappings. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum RemappingsAction { + /// Add a dependency to the remappings. + Add(Dependency), + + /// Remove a dependency from the remappings. + Remove(Dependency), + + /// Update the remappings according to the config file. + 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 { + /// Store the remappings in a dedicated `remappings.txt` file. + #[default] + Txt, + + /// Store the remappings in the `foundry.toml` config file. + /// + /// Note that remappings are never stored in the `soldeer.toml` file because foundry wouldn't + /// be able to read them from there. + Config, +} + +/// Generate the remappings for storing into the `remappings.txt` file. +/// +/// If the `remappings_regenerate` option is set to `true`, then any existing remappings are +/// discarded and the remappings are generated from the dependencies in the config file. +/// +/// Otherwise, existing remappings are kept, and depending on the action, a remapping entry is added +/// or removed. For the [`RemappingsAction::Update`] action, the existing remappings are merged with +/// the dependencies in the config file. +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(()) +} + +/// Generate the remappings for storing into the `foundry.toml` config file. +/// +/// If the `remappings_regenerate` option is set to `true`, then any existing remappings are +/// discarded and the remappings are generated from the dependencies in the config file. +/// +/// Otherwise, existing remappings are kept, and depending on the action, a remapping entry is added +/// or removed. For the [`RemappingsAction::Update`] action, the existing remappings are merged with +/// the dependencies in the config file. +/// +/// The remappings are added to the default profile in all cases, and to any other profile that +/// already has a `remappings key`. If the profile doesn't have a remappings key, it is left +/// untouched. +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 + match profile.get_mut("remappings").map(|v| v.as_array_mut()) { + Some(Some(remappings)) => { + 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); + } + format_array(remappings); + } + _ => { + if name == "default" { + // except the default profile, where we always add the remappings + let new_remappings = generate_remappings(action, paths, soldeer_config, &[])?; + let mut array = new_remappings.into_iter().collect::(); + format_array(&mut array); + profile["remappings"] = value(array); + } + } + } + } + + fs::write(&paths.config, doc.to_string())?; + + Ok(()) +} + +/// Edit the remappings according to the action and the configuration. +/// +/// Depending on the configuration, the remappings are either stored in a `remappings.txt` file or +/// in the `foundry.toml` config file. +/// +/// Note that if the config is stored in a dedicated `soldeer.toml` file, then the +/// `remappings_location` setting is ignored and the remappings are always stored in a +/// `remappings.txt` file. +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(()) +} + +/// Format the default left part (alias) for a remappings entry. +/// +/// The optional `remappings_prefix` setting is prepended to the dependency name, and the +/// version requirement string is appended (after a hyphen) if the `remappings_version` setting is +/// set to `true`. Finally, a trailing slash is added to the alias. +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) +} + +/// Generate the remappings for a given action. +/// +/// If the `remappings_regenerate` option is set to `true`, then any existing remappings are +/// discarded and the remappings are generated from the dependencies in the config file. +/// +/// Otherwise, existing remappings are kept, and depending on the action, a remapping entry is added +/// or removed. For the [`RemappingsAction::Update`] action, the existing remappings are merged with +/// the dependencies in the config file. +/// +/// Dependencies are sorted alphabetically for consistency. +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 { + let dependencies = read_config_deps(&paths.config)?; + new_remappings = remappings_from_deps(&dependencies, paths, soldeer_config)? + .into_iter() + .map(|i| i.remapping_string) + .collect(); + } 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. + let dependencies = read_config_deps(&paths.config)?; + let new_remappings_info = + remappings_from_deps(&dependencies, paths, soldeer_config)?; + if existing_remappings.is_empty() { + new_remappings = + new_remappings_info.into_iter().map(|i| i.remapping_string).collect(); + } else { + for RemappingInfo { remapping_string: item, dependency: dep } in + new_remappings_info + { + let (_, item_og) = + item.split_once('=').expect("remappings should have two parts"); + // Try to find an existing item pointing to a matching dependency folder + if let Some((existing_remapped, existing_og)) = + existing_remappings.iter().find(|(_, og)| { + // only keep the first two components of the path (dependencies + // folder and the dependency folder) + let path: PathBuf = + PathBuf::from(og).components().take(2).collect(); + path_matches(&dep, path) + }) + { + // If found, we restore it, replacing the old version with the new one + let path: PathBuf = + PathBuf::from(existing_og).components().take(2).collect(); + let existing_og_updated = existing_og.replace( + path.to_slash_lossy().as_ref(), + item_og.trim_end_matches('/'), + ); + new_remappings + .push(format!("{existing_remapped}={existing_og_updated}")); + } else { + new_remappings.push(item); + } + } + } + } + } + } + + // sort the remappings + new_remappings.sort_unstable(); + Ok(new_remappings) +} + +#[derive(Debug, Clone, From)] +struct RemappingInfo { + remapping_string: String, + dependency: Dependency, +} + +/// Generate remappings from the dependencies list. +/// +/// The remappings are generated in the form `alias/=path/`, where `alias` is the dependency name +/// with an optional prefix and version requirement suffix, and `path` is the relative path to the +/// dependency folder. +fn remappings_from_deps( + dependencies: &[Dependency], + paths: &Paths, + soldeer_config: &SoldeerConfig, +) -> Result> { + dependencies + .par_iter() + .map(|dependency| { + let dependency_name_formatted = format_remap_name(soldeer_config, dependency); // contains trailing slash + let relative_path = get_install_dir_relative(dependency, paths)?; + Ok((format!("{dependency_name_formatted}={relative_path}/"), dependency.clone()).into()) + }) + .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()) +} + +/// Format a TOML array as a multi-line array with indentation in case there is more than one +/// element. +/// +/// # Examples +/// +/// ```toml +/// [profile.default] +/// remappings = [] +/// ``` +/// +/// ```toml +/// [profile.default] +/// remappings = ["lib1-1.0.0/=dependencies/lib1-1.0.0/"] +/// ``` +/// +/// ```toml +/// [profile.default] +/// remappings = [ +/// "lib1-1.0.0/=dependencies/lib1-1.0.0/", +/// "lib2-2.0.0/=dependencies/lib2-2.0.0/", +/// ] +/// ``` +fn format_array(array: &mut Array) { + array.fmt(); + if (0..=1).contains(&array.len()) { + array.set_trailing(""); + array.set_trailing_comma(false); + } else { + for item in array.iter_mut() { + item.decor_mut().set_prefix("\n "); + } + array.set_trailing("\n"); + array.set_trailing_comma(true); + } +} + +#[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 dependencies = read_config_deps(&paths.config).unwrap(); + let res = remappings_from_deps(&dependencies, &paths, &SoldeerConfig::default()); + assert!(res.is_ok(), "{res:?}"); + let res = res.unwrap(); + assert_eq!(res.len(), 3); + assert_eq!(res[0].remapping_string, "dep1-^1.0.0/=dependencies/dep1-1.1.1/"); + assert_eq!(res[1].remapping_string, "dep2-2.0.0/=dependencies/dep2-2.0.0/"); + assert_eq!(res[2].remapping_string, "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"); + } + + #[test] + fn test_generate_remappings_update_semver_custom() { + let dir = testdir!(); + let contents = r#"[dependencies] +lib1 = "1" +lib2 = "2" +"#; + fs::write(dir.join("soldeer.toml"), contents).unwrap(); + let paths = Paths::from_root(&dir).unwrap(); + // libs have been updated to newer versions + fs::create_dir_all(paths.dependencies.join("lib1-1.2.0")).unwrap(); + fs::create_dir_all(paths.dependencies.join("lib2-2.1.0")).unwrap(); + let config = SoldeerConfig::default(); + // all entries are customized, using an old version of the libs + let existing_deps = vec![ + ("lib1-1/", "dependencies/lib1-1.1.1/src/"), // customize right part + ("lib2/", "dependencies/lib2-2.0.1/src/"), // customize both sides + ]; + let res = generate_remappings(&RemappingsAction::Update, &paths, &config, &existing_deps); + assert!(res.is_ok(), "{res:?}"); + assert_eq!( + res.unwrap(), + vec!["lib1-1/=dependencies/lib1-1.2.0/src/", "lib2/=dependencies/lib2-2.1.0/src/"] + ); + } +} diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs new file mode 100644 index 0000000..a30dbb3 --- /dev/null +++ b/crates/core/src/update.rs @@ -0,0 +1,204 @@ +//! Update dependencies to the latest version. +#[cfg(feature = "cli")] +use crate::install::Progress; +use crate::{ + config::{Dependency, GitIdentifier}, + 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; + +pub type Result = std::result::Result; + +/// Update the dependencies to a new version. +/// +/// This function spawns a task for each dependency and waits for all of them to finish. +/// +/// For Git dependencies without a ref or with a +/// [`GitIdentifier::Branch`] ref, the function will update +/// the dependency to the latest commit with `git pull`. +/// +/// For Git dependencies with a [`GitIdentifier::Rev`] or [`GitIdentifier::Tag`] ref, the function +/// will reset the repo to the ref if the integrity check fails. An update is not really possible in +/// this case. +/// +/// For HTTP dependencies, the function will install the latest version of the dependency according +/// to the version requirement in the config file. If the version requirement is not a semver range, +/// the function will install the latest version from the registry. +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) +} + +/// Update a single dependency to a new version. +/// +/// For Git dependencies without a ref or with a +/// [`GitIdentifier::Branch`] ref, the function will update +/// the dependency to the latest commit with `git pull`. +/// +/// For Git dependencies with a [`GitIdentifier::Rev`] or [`GitIdentifier::Tag`] ref, the function +/// will reset the repo to the ref if the integrity check fails. An update is not really possible in +/// this case. +/// +/// For HTTP dependencies, the function will install the latest version of the dependency according +/// to the version requirement in the config file. If the version requirement is not a semver range, +/// the function will install the latest version from the registry. +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 matches!(dep.identifier, None | Some(GitIdentifier::Branch(_))) => + { + // 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(); + + if let Some(GitIdentifier::Branch(ref branch)) = dep.identifier { + // checkout the desired branch + run_git_command(&["checkout", branch], Some(&path)).await?; + } else { + // necessarily `None` because of the match above + // checkout the default branch + let branch = run_git_command( + &["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], + Some(&path), + ) + .await? + .trim_start_matches("origin/") + .trim() + .to_string(); + run_git_command(&["checkout", &branch], Some(&path)).await?; + } + // pull the latest commits + 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..d845270 --- /dev/null +++ b/crates/core/src/utils.rs @@ -0,0 +1,431 @@ +//! Utility functions used throughout the codebase. +use crate::{ + config::Dependency, + errors::{DownloadError, InstallError}, + registry::parse_version_req, +}; +use derive_more::derive::{Display, From}; +use ignore::{WalkBuilder, WalkState}; +use path_slash::PathExt as _; +use rayon::prelude::*; +use regex::Regex; +use semver::Version; +use sha2::{Digest as _, Sha256}; +use std::{ + borrow::Cow, + env, + ffi::OsStr, + fs, + io::Read, + path::{Path, PathBuf}, + sync::{mpsc, Arc, LazyLock}, +}; +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, +} + +/// Newtype for the string representation of an integrity checksum (SHA256). +#[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); + +/// 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 filename in the list of paths starts with a period. +pub fn check_dotfiles(files: &[PathBuf]) -> bool { + files + .par_iter() + .any(|file| file.file_name().unwrap_or_default().to_string_lossy().starts_with('.')) +} + +/// Get the type of URL from a dependency URL. +/// +/// Git URLs are identified by the presence of the `git@github.com` or `git@gitlab.com` prefix, as +/// well as HTTPS URLs which have the `github.com` or `gitlab.com` domain and a trailing `.git` in +/// their path. +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())) +} + +/// Sanitize a filename by replacing invalid characters with a dash. +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-ignored files inside the +/// dir, combining them into a single hash. +/// +/// The paths of the folders and files are hashes too, so we can the integrity of their names and +/// location can be checked. +pub fn hash_folder(folder_path: impl AsRef) -> Result { + // a list of hashes, one for each DirEntry + let root_path = Arc::new(dunce::canonicalize(folder_path.as_ref())?); + + let (tx, rx) = mpsc::channel::<[u8; 32]>(); + + // 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 tx = tx.clone(); + 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(); + tx.send(hash) + .expect("Channel receiver should never be dropped before end of function scope"); + WalkState::Continue + }) + }); + drop(tx); + let mut hasher = Sha256::new(); + // this cannot happen before tx is dropped safely + let mut hashes = Vec::new(); + while let Ok(msg) = rx.recv() { + hashes.push(msg); + } + // sort hashes + hashes.par_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 = fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + let bytes = hash_content(&mut reader); + Ok(const_hex::encode(bytes).into()) +} + +/// Run a `git` command with the given arguments in the given directory. +/// +/// The function output is parsed as a UTF-8 string and returned. +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")) +} + +/// Run a `forge` command with the given arguments in the given directory. +/// +/// The function output is parsed as a UTF-8 string and returned. +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")) +} + +/// Remove/uninstall the `forge-std` library installed as a git submodule in a foundry project. +/// +/// This function removes the `forge-std` submodule, the `.gitmodules` file and the `lib` directory +/// from the project. +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"); + if forge_std_dir.exists() { + run_git_command( + &["rm", &forge_std_dir.to_string_lossy()], + Some(&root.as_ref().to_path_buf()), + ) + .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(()) +} + +/// Canonicalize a path, resolving symlinks and relative paths. +/// +/// This function also normalizes paths on Windows to use the MS-DOS format (as opposed to UNC) +/// whenever possible. +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? +} + +/// Check if a path corresponds to the provided dependency. +/// +/// The folder does not need to exist. The folder name must start with the dependency name +/// (sanitized). For dependencies with a semver-compliant version requirement, any folder with a +/// version that matches will give a result of `true`. Otherwise, the folder name must contain the +/// version requirement string after the dependency name. +pub fn path_matches(dependency: &Dependency, path: impl AsRef) -> bool { + let path = path.as_ref(); + let Some(dir_name) = path.file_name() else { + return false; + }; + let dir_name = dir_name.to_string_lossy(); + let prefix = format!("{}-", sanitize_filename(dependency.name())); + if !dir_name.starts_with(&prefix) { + return false; + } + match ( + parse_version_req(dependency.version_req()), + Version::parse(dir_name.strip_prefix(&prefix).expect("prefix should be present")), + ) { + (None, _) | (Some(_), Err(_)) => { + // not semver compliant + dir_name == format!("{prefix}{}", sanitize_filename(dependency.version_req())) + } + (Some(version_req), Ok(version)) => version_req.matches(&version), + } +} + +#[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;