diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..38a8f84 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: ci + +on: + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + with: + cache-targets: "false" + + - name: fmt + if: matrix.os == 'ubuntu-latest' + run: cargo fmt --all -- --check + + - name: check + if: matrix.os == 'ubuntu-latest' + run: cargo check --all-targets --all-features + + - name: clippy + if: matrix.os == 'ubuntu-latest' + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: test + run: cargo test + + - name: test (all features) + if: matrix.os == 'ubuntu-latest' + run: cargo test --all-features + + - name: doc + if: matrix.os == 'ubuntu-latest' + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --no-deps --all-features + + audit: + name: audit + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + cache-targets: "false" + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + + - name: audit + run: cargo audit diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml deleted file mode 100644 index 5b6b876..0000000 --- a/.github/workflows/pull_request.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Pull Requests - -on: - pull_request_target: - branches: - - master - push: - branches: - - master - -jobs: - build: - strategy: - matrix: - os: [ ubuntu-latest, windows-latest, macos-latest ] - - runs-on: ${{ matrix.os }} - - steps: - - name: Check out repository - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Set up Rust toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - components: rustfmt - override: true - profile: minimal - - name: Rust Cache - uses: Swatinem/rust-cache@v2.7.0 - with: - cache-targets: "false" - - name: Check formatting - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check - - name: Check compilation - uses: actions-rs/cargo@v1 - with: - command: check - - name: Test - uses: actions-rs/cargo@v1 - with: - command: test diff --git a/Cargo.lock b/Cargo.lock index ad07edf..27fa41c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "0.7.18" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -28,36 +28,37 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] @@ -77,39 +78,32 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "bitflags" -version = "1.3.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.4.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bstr" -version = "1.0.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca0852af221f458706eb0725c03e4ed6c46af9ac98e6a689d5e634215d594dd" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", - "once_cell", "regex-automata", "serde", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" @@ -142,20 +136,20 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.11", + "syn", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" @@ -186,32 +180,32 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "directories" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ "dirs-sys", ] [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -220,14 +214,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -238,9 +238,9 @@ checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" [[package]] name = "fastrand" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "float-cmp" @@ -259,9 +259,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -274,9 +274,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -284,15 +284,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -301,31 +301,49 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "futures-sink" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.21" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -336,33 +354,55 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.7" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.6", + "r-efi", + "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "insta" version = "1.45.0" @@ -377,46 +417,52 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "libc" -version = "0.2.170" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libredox" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +dependencies = [ + "bitflags", + "libc", +] [[package]] name = "linux-raw-sys" -version = "0.9.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "lock_api" -version = "0.4.7" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.17" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.4.1" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "normalize-line-endings" @@ -426,9 +472,9 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "num-traits" -version = "0.2.14" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -439,6 +485,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -447,9 +499,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -457,22 +509,22 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.3" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-sys 0.36.1", + "windows-link", ] [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -496,20 +548,29 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" [[package]] name = "predicates-tree" -version = "1.0.4" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338c7be2905b732ae3984a2f40032b5e94fd8f52505b186c7d4d68d193445df7" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" dependencies = [ "predicates-core", "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -521,92 +582,148 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.26" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" -version = "0.2.13" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", + "bitflags", ] [[package]] name = "redox_users" -version = "0.4.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.7", - "redox_syscall", + "getrandom 0.2.16", + "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.5.6" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", + "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] [[package]] name = "regex-syntax" -version = "0.6.26" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "relative-path" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" -version = "1.0.0" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f8dcd64f141950290e45c99f7710ede1b600297c91818bb30b3667c0f45dc0" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.4.1", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "scc" -version = "2.1.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96560eea317a9cc4e0bb1f6a2c93c09a19b8c4fc5cb3fcc0ec1c094cd783e2" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" dependencies = [ "sdd", ] [[package]] name = "scopeguard" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdd" -version = "0.2.0" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" [[package]] -name = "sdkman-cli-native" +name = "sdkman" version = "0.7.14" dependencies = [ "assert_cmd", @@ -619,17 +736,47 @@ dependencies = [ "insta", "predicates", "proc-macro2", + "rstest", "serial_test", "symlink", "tempfile", "textwrap", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" -version = "1.0.147" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "serial_test" @@ -653,7 +800,7 @@ checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", - "syn 2.0.11", + "syn", ] [[package]] @@ -664,15 +811,15 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "slab" -version = "0.4.6" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.8.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smawk" @@ -682,9 +829,9 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "symlink" @@ -694,20 +841,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" [[package]] name = "syn" -version = "1.0.98" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e3787bb71465627110e7d87ed4faaa36c1f61042ee67badb9e2ef173accc40" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -716,22 +852,22 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom 0.3.1", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "termtree" -version = "0.2.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a4ec180a2de59b57434704ccfad967f789b12737738798fa08798cd5824c16" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "textwrap" @@ -746,29 +882,59 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.31" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.31" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 1.0.98", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", ] [[package]] name = "unicode-ident" -version = "1.0.1" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-linebreak" @@ -778,61 +944,45 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "wait-timeout" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ "libc", ] [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "windows-sys" -version = "0.36.1" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "windows_aarch64_msvc 0.36.1", - "windows_i686_gnu 0.36.1", - "windows_i686_msvc 0.36.1", - "windows_x86_64_gnu 0.36.1", - "windows_x86_64_msvc 0.36.1", + "wit-bindgen", ] [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.0", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -840,22 +990,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] -name = "windows-targets" -version = "0.48.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", + "windows-link", ] [[package]] @@ -864,58 +1008,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -928,66 +1042,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -995,10 +1067,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "winnow" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ - "bitflags 2.4.1", + "memchr", ] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index 650da0a..fcf7a6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "sdkman-cli-native" +name = "sdkman" version = "0.7.14" edition = "2021" publish = false @@ -9,8 +9,8 @@ assert_cmd = "2.1.1" proc-macro2 = "1.0.103" clap = { version = "4.5.53", features = ["derive"] } colored = "3.0.0" -directories = "5.0.0" -dirs = "5.0.1" +directories = "6.0.0" +dirs = "6.0.0" exitcode = "1.1.2" fs_extra = "1.3.0" predicates = "3.1.3" @@ -21,3 +21,5 @@ textwrap = "0.16.2" [dev-dependencies] insta = "1.45.0" +rstest = "0.26.1" +tempfile = "3.23.0" diff --git a/src/bin/current/main.rs b/src/bin/current/main.rs deleted file mode 100644 index 4ac7a09..0000000 --- a/src/bin/current/main.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::process; - -use clap::Parser; -use colored::Colorize; - -use sdkman_cli_native::constants::{CANDIDATES_DIR, CURRENT_DIR}; -use sdkman_cli_native::helpers::{infer_sdkman_dir, known_candidates, validate_candidate}; - -#[derive(Parser, Debug)] -#[command( - bin_name = "sdk current", - about = "sdk subcommand to display the current version in use for one or all candidates" -)] -struct Args { - #[arg(required(false))] - candidate: Option, -} - -fn main() { - let args = Args::parse(); - let sdkman_dir = infer_sdkman_dir(); - let all_candidates = known_candidates(sdkman_dir.to_owned()); - - match args.candidate { - Some(candidate) => { - // Show current version for a specific candidate - let candidate = validate_candidate(all_candidates, &candidate); - let current_version = get_current_version(sdkman_dir.to_owned(), &candidate); - match current_version { - Some(version) => println!("Using {} version {}", candidate.bold(), version.bold()), - _ => { - eprintln!("No current version of {} configured.", candidate.bold()); - process::exit(1); - } - } - } - _ => { - // Show current version for all candidates - let mut found_any = false; - let mut candidates_with_versions = Vec::new(); - - // Collect all candidates with their versions first - for candidate in all_candidates { - let current_version = get_current_version(sdkman_dir.to_owned(), candidate); - if let Some(version) = current_version { - candidates_with_versions.push((candidate, version)); - found_any = true; - } - } - - if found_any { - // Print header - println!("{}", "Current versions in use:".bold()); - - // Print all candidate versions - for (candidate, version) in candidates_with_versions { - println!("{} {}", candidate, version); - } - } else { - eprintln!("No candidates are in use."); - process::exit(0); - } - } - } -} - -fn get_current_version(base_dir: PathBuf, candidate: &str) -> Option { - // First check if the candidate is installed - let candidate_dir = base_dir.join(CANDIDATES_DIR).join(candidate); - if !candidate_dir.exists() || !candidate_dir.is_dir() { - return None; - } - - // Check for current symlink - let current_link = candidate_dir.join(CURRENT_DIR); - if !current_link.exists() { - return None; - } - - // Get the symlink target (which should be the version) - if let Ok(target) = fs::read_link(¤t_link) { - // Extract the version from the path - return target - .file_name() - .and_then(|name| name.to_str()) - .map(|s| s.to_string()); - } - - // If this is not a symlink but a directory (fallback case) - if current_link.is_dir() { - return current_link - .file_name() - .and_then(|name| name.to_str()) - .map(|s| s.to_string()); - } - - None -} diff --git a/src/bin/default/main.rs b/src/bin/default/main.rs deleted file mode 100644 index 134d26d..0000000 --- a/src/bin/default/main.rs +++ /dev/null @@ -1,68 +0,0 @@ -use clap::Parser; -use colored::Colorize; -use fs_extra::copy_items; -use fs_extra::dir::CopyOptions; -use std::fs; -use std::fs::remove_dir_all; -use symlink::{remove_symlink_dir, symlink_dir}; - -use sdkman_cli_native::constants::{CANDIDATES_DIR, CURRENT_DIR, TMP_DIR}; -use sdkman_cli_native::helpers::{ - infer_sdkman_dir, known_candidates, validate_candidate, validate_version_path, -}; - -#[derive(Parser, Debug)] -#[command( - bin_name = "sdk default", - about = "sdk subcommand to set the local default version of the candidate" -)] -struct Args { - #[arg(required(true))] - candidate: String, - - #[arg(required(true))] - version: String, -} -fn main() { - let args = Args::parse(); - let candidate = args.candidate; - let version = args.version; - let sdkman_dir = infer_sdkman_dir(); - let tmp_dir = sdkman_dir.join(TMP_DIR); - let candidate = validate_candidate(known_candidates(sdkman_dir.to_owned()), &candidate); - let version_path = validate_version_path(sdkman_dir.to_owned(), &candidate, &version); - let current_link_path = sdkman_dir - .join(CANDIDATES_DIR) - .join(&candidate) - .join(CURRENT_DIR); - - if current_link_path.exists() { - remove_symlink_dir(¤t_link_path).unwrap_or_else(|_| { - remove_dir_all(¤t_link_path).expect(&format!( - "cannot remove current directory for {}.", - candidate - )) - }) - } - println!( - "setting {} {} as the {} version for all shells.", - &candidate.bold(), - &version.bold(), - "default".italic() - ); - symlink_dir(&version_path, ¤t_link_path).unwrap_or_else(|_| { - let options = CopyOptions::new(); - let mut version_paths = Vec::new(); - let version_path_string = version_path.into_os_string().into_string().unwrap(); - version_paths.push(version_path_string); - - copy_items(&version_paths, &tmp_dir, &options).expect("cannot copy to tmp folder."); - let tmp_version_path = tmp_dir.join(&version); - fs::rename(tmp_version_path, current_link_path).expect("cannot rename copied folder."); - let error_message = format!( - "cannot create {} symlink, fall back to copy!", - "current".italic() - ); - println!("{}", error_message.bold()) - }) -} diff --git a/src/bin/help/main.rs b/src/bin/help/main.rs deleted file mode 100644 index 2159691..0000000 --- a/src/bin/help/main.rs +++ /dev/null @@ -1,614 +0,0 @@ -extern crate clap; - -use clap::Command; -use colored::Colorize; -use textwrap::{fill, indent}; - -fn main() { - let default_error = format!( - "error: no subcommand specified (use {} for help)", - "sdk help".italic() - ); - let args = Command::new("help") - .override_help(default_error) - .subcommand(Command::new("config")) - .subcommand(Command::new("current").alias("c")) - .subcommand(Command::new("default").alias("d")) - .subcommand(Command::new("env").alias("e")) - .subcommand(Command::new("flush")) - .subcommand(Command::new("home").alias("h")) - .subcommand(Command::new("install").alias("i")) - .subcommand(Command::new("list").alias("ls")) - .subcommand(Command::new("selfupdate")) - .subcommand(Command::new("uninstall").alias("rm")) - .subcommand(Command::new("update")) - .subcommand(Command::new("upgrade")) - .subcommand(Command::new("use").alias("u")) - .subcommand(Command::new("version").alias("v")) - .get_matches(); - - let help = match args.subcommand_name() { - Some("config") => config_help(), - Some("current") => current_help(), - Some("default") => default_help(), - Some("env") => env_help(), - Some("flush") => flush_help(), - Some("home") => home_help(), - Some("install") => install_help(), - Some("list") => list_help(), - Some("selfupdate") => selfupdate_help(), - Some("uninstall") => uninstall_help(), - Some("update") => update_help(), - Some("upgrade") => upgrade_help(), - Some("use") => use_help(), - Some("version") => version_help(), - _ => main_help(), - }; - - println!("{}", &render(help)); -} - -struct Subcommand { - command: String, - description: String, -} - -struct Configuration { - content: String, - snippet: String, -} - -struct Mnemonic { - shorthand: String, - command: String, -} - -#[derive(Default)] -struct Help { - cmd: String, - tagline: String, - synopsis: String, - description: String, - subcommands: Option>, - configuration: Option, - mnemonic: Option, - exit_code: Option, - examples: String, -} - -const INDENTATION_WIDTH: usize = 4; -const TERMINAL_WIDTH: usize = 80; -const TEXT_WIDTH: usize = TERMINAL_WIDTH - INDENTATION_WIDTH; - -fn render(help: Help) -> String { - let spaced_tab = format!("{:width$}", " ", width = INDENTATION_WIDTH); - let indentation = spaced_tab.as_str(); - let nameline = format!("{} - {}", help.cmd.italic(), help.tagline); - let wrapped_nameline = fill(&nameline, TEXT_WIDTH); - let name = format!( - "\n{}\n{}\n\n", - "NAME".bold(), - indent(&wrapped_nameline, indentation) - ); - - let synopsis = format!( - "{}\n{}\n\n", - "SYNOPSIS".bold(), - indent(&format!("{}", help.synopsis.italic()), indentation) - ); - - let description = format!( - "{}\n{}\n\n", - "DESCRIPTION".bold(), - indent(&fill(help.description.as_str(), TEXT_WIDTH), indentation) - ); - - let subcommands: String = help - .subcommands - .iter() - .map(|subs| { - let lines: String = subs - .iter() - .map(|sub| { - let desc_depth = 17; - let desc_indent = format!("{:width$}", " ", width = desc_depth); - let command = indent(&fill(&sub.command, desc_depth), indentation); - let description = &indent( - &fill(&sub.description, TEXT_WIDTH - desc_depth), - &desc_indent, - )[command.len()..]; - format!("{}{}\n", command.to_string(), description) - }) - .collect(); - return format!("{}\n{}\n", "SUBCOMMANDS & QUALIFIERS".bold(), lines); - }) - .collect(); - - let configuration = help - .configuration - .map(|config| { - format!( - "{}\n{}\n\n{}\n\n", - "CONFIGURATION".bold(), - indent(&fill(&config.content, TEXT_WIDTH), indentation), - indent(&config.snippet, indentation) - ) - }) - .unwrap_or_else(|| String::new()); - - let mnemonic = help - .mnemonic - .map(|mnemonic| { - let text = format!( - "{} - may be used in place of the {} subcommand.", - &mnemonic.shorthand.bold(), - &mnemonic.command.bold() - ); - format!("{}\n{}\n\n", "MNEMONIC".bold(), indent(&text, indentation)) - }) - .unwrap_or_else(|| String::new()); - - let exit_code = help - .exit_code - .map(|m| { - format!( - "{}\n{}\n\n", - "EXIT CODE".bold(), - indent(&fill(&m, TEXT_WIDTH), indentation) - ) - }) - .unwrap_or_else(|| String::new()); - - let examples = format!( - "{}\n{}\n\n", - "EXAMPLES".bold(), - indent(&format!("{}", help.examples.italic()), indentation) - ); - - format!( - "{}{}{}{}{}{}{}{}", - name, synopsis, description, subcommands, configuration, exit_code, mnemonic, examples - ) -} - -fn main_help() -> Help { - Help { - cmd: "sdk".to_string(), - tagline: "The command line interface (CLI) for SDKMAN!".to_string(), - synopsis: "sdk [candidate] [version]".to_string(), - description: "SDKMAN! is a tool for managing parallel versions of multiple JVM related Software Development \ - Kits on most Unix based systems. It provides a convenient Command Line Interface (CLI) and API for installing, \ - switching, removing and listing Candidates.".to_string(), - subcommands: Some(vec![ - Subcommand { command: "help".to_string(), description: "[subcommand]".italic().to_string() }, - Subcommand { command: "install".to_string(), description: " [version] [path]".italic().to_string() }, - Subcommand { command: "uninstall".to_string(), description: " ".italic().to_string() }, - Subcommand { command: "list".to_string(), description: "[candidate]".italic().to_string() }, - Subcommand { command: "use".to_string(), description: " ".italic().to_string() }, - Subcommand { command: "config".to_string(), description: "no qualifier".to_string() }, - Subcommand { command: "default".to_string(), description: " [version]".italic().to_string() }, - Subcommand { command: "home".to_string(), description: " ".italic().to_string() }, - Subcommand { command: "env".to_string(), description: "[init|install|clear]".italic().to_string() }, - Subcommand { command: "current".to_string(), description: "[candidate]".italic().to_string() }, - Subcommand { command: "upgrade".to_string(), description: "[candidate]".italic().to_string() }, - Subcommand { command: "version".to_string(), description: "no qualifier".to_string() }, - Subcommand { command: "offline".to_string(), description: "[enable|disable]".italic().to_string() }, - Subcommand { command: "selfupdate".to_string(), description: "[force]".italic().to_string() }, - Subcommand { command: "update".to_string(), description: "no qualifier".to_string() }, - Subcommand { command: "flush".to_string(), description: "[tmp|metadata|version]".italic().to_string() }, - ]), - examples: "sdk install java 17.0.0-tem\nsdk help install".to_string(), - ..Default::default() - } -} - -fn config_help() -> Help { - let config_file = "${SDKMAN_DIR}/etc/config"; - let default_config = "\ ---- -sdkman_auto_answer=false -sdkman_auto_complete=true -sdkman_auto_env=false -sdkman_auto_update=true -sdkman_beta_channel=false -sdkman_checksum_enable=true -sdkman_colour_enable=true -sdkman_curl_connect_timeout=7 -sdkman_curl_max_time=10 -sdkman_debug_mode=false -sdkman_insecure_ssl=false -sdkman_selfupdate_feature=true ----"; - Help { - cmd: "sdk config".to_string(), - tagline: "sdk subcommand to edit the SDKMAN configuration file".to_string(), - synopsis: "sdk config".to_string(), - description: format!("This subcommand opens a text editor on the configuration file located at {}. \ - The subcommand will infer the text editor from the {} environment variable. If the system does \ - not set the {} environment variable, then vi is assumed as the default editor.", config_file.underline(), - "EDITOR".italic(), "EDITOR".italic()), - configuration: Some( - Configuration { - content: format!("The {} file contains the following default configuration. A new shell should be \ - opened for any configuration changes to take effect.", config_file.underline()), - snippet: default_config.italic().to_string(), - } - ), - examples: "sdk config".to_string(), - ..Default::default() - } -} - -fn current_help() -> Help { - Help { - cmd: "sdk current".to_string(), - tagline: "sdk subcommand to display the current default installed versions".to_string(), - synopsis: "sdk current [candidate]".to_string(), - description: "This subcommand will display a list of candidates with their default version installed on the \ - system. It is also possible to qualify the candidate when running the subcommand to display only that \ - candidate's default version.".to_string(), - mnemonic: Some(Mnemonic { shorthand: "c".to_string(), command: "current".to_string() }), - examples: "sdk current\nsdk current java".to_string(), - ..Default::default() - } -} - -fn default_help() -> Help { - Help { - cmd: "sdk default".to_string(), - tagline: "sdk subcommand to set the local default version of the candidate".to_string(), - synopsis: "sdk default [version]".to_string(), - description: "The mandatory candidate qualifier of the subcommand specifies the candidate to default for all \ - future shells.\n\nThe optional version qualifier sets that specific version as default for all subsequent \ - shells on the local environment. Omitting the version will set the global SDKMAN tracked version as the \ - default version for that candidate.".to_string(), - mnemonic: Some(Mnemonic { shorthand: "d".to_string(), command: "default".to_string() }), - exit_code: Some("The subcommand will return a non-zero return code if the candidate or version does not exist." - .to_string()), - examples: "sdk default java 17.0.0-tem\nsdk default java".to_string(), - ..Default::default() - } -} - -fn env_help() -> Help { - let config_file_content = "\ ---- -# Enable auto-env through the sdkman_auto_env config -# Add key=value pairs of SDKs to use below -java=11.0.13-tem ----" - .italic(); - Help { - cmd: "sdk env".to_string(), - tagline: "sdk subcommand to control SDKs on a project level, setting up specific versions for a directory" - .to_string(), - synopsis: "sdk env [init|install|clear]".to_string(), - description: format!("Allows the developer to manage the SDK versions used in a project directory. The \ - subcommand uses an {} file to install or switch specific SDK versions in a project directory.\n\nWhen \ - issuing the subcommand without a qualifier, it will switch to the versions specified in {} and emit \ - warnings for versions not present on the system. In addition, the subcommand has three optional qualifiers.", - ".sdkmanrc".underline(), - ".sdkmanrc".underline()), - subcommands: Some( - vec![ - Subcommand { - command: "install".to_string(), - description: format!("install and switch to the SDK versions specified in {}", ".sdkmanrc".underline()), - }, - Subcommand { - command: "init".to_string(), - description: format!("allows for the creation of a default {} file with a single entry for the {} \ - candidate, set to the current default value)", ".sdkmanrc".underline(), "java".italic()), - }, - Subcommand { - command: "clear".to_string(), - description: "reset all SDK versions to their system defaults".to_string(), - }, - ]), - configuration: Some( - Configuration - { - content: format!("The {} file contains key-value pairs for each configurable SDK for that project \ - environment. You may enable a configuration option for auto-env behaviour by setting {} in the {} \ - file. This setting will automatically switch versions when stepping into a directory on the presence \ - of a {} descriptor. When enabled, you no longer need to issue the {} qualifier explicitly. This \ - behaviour is disabled by default. An initial file will have content such as this:", - ".sdkmanrc".underline(), - "sdkman_auto_env=true".italic(), - "$SDKMAN_DIR/etc/config".underline(), - ".sdkmanrc".underline(), - "install".italic()), - snippet: config_file_content.italic().to_string(), - } - ), - examples: "sdk env\nsdk env install\nsdk env init\nsdk env clear".to_string(), - ..Default::default() - } -} - -fn flush_help() -> Help { - Help { - cmd: "sdk flush".to_string(), - tagline: "sdk subcommand used for flushing local temporal state of SDKMAN".to_string(), - synopsis: "sdk flush [tmp|metadata|version]".to_string(), - description: format!("This command cleans temporary storage under {} in the {} and {} directories, removing \ - metadata and version caches. It also removes any residual download artifacts. It is possible to \ - flush specific targets by providing a qualifier. Omission of the qualifier results in a full flush of all \ - targets.", "$SDKMAN_DIR".underline(), "var".underline(), "tmp".underline()) - .to_string(), - subcommands: Some(vec![ - Subcommand { - command: "tmp".to_string(), - description: format!("cleans out pre/post hooks and residual archives from {}", "$SDKMAN_DIR/tmp".underline()), - }, - Subcommand { - command: "metadata".to_string(), - description: format!("removes any header metadata"), - }, - Subcommand { - command: "version".to_string(), - description: format!( - "flushes the {} and {} files under {}", - "version".underline(), - "version_native".underline(), - "$SDKMAN_DIR/var".underline() - ), - }, - ]), - examples: "sdk flush\nsdk flush tmp\nsdk flush metadata\nsdk flush version".to_string(), - ..Default::default() - } -} - -fn home_help() -> Help { - Help { - cmd: "sdk home".to_string(), - tagline: "sdk subcommand to output the path of a specific candidate version".to_string(), - synopsis: "sdk home ".to_string(), - description: "Print the absolute home path of any candidate version installed by SDKMAN. The candidate and \ - version parameters are mandatory. This subcommand is usually used for scripting, so does not append a newline \ - character.".to_string(), - exit_code: Some("The subcommand will emit a non-zero exit code if a valid candidate version is not locally \ - installed.".to_string()), - examples: "sdk home java 17.0.0-tem".to_string(), - ..Default::default() - } -} - -fn install_help() -> Help { - Help { - cmd: "sdk install".to_string(), - tagline: "sdk subcommand to install a candidate version".to_string(), - synopsis: "sdk install [version] [path]".to_string(), - description: "Invoking this subcommand with only the candidate as parameter will install the currently \ - known default version for that candidate. Provide a second qualifier to install a specific non-default \ - version. Provide a third optional qualifier to add an already installed local version. This final qualifier is \ - the absolute local path to the base directory of the SDK to be added. The local version will appear as an \ - installed version of the candidate. The version may not conflict with an existing version, installed or not.".to_string(), - mnemonic: Some(Mnemonic { shorthand: "i".to_string(), command: "install".to_string() }), - exit_code: Some("The subcommand will return a non-zero exit code for versions not found or for an invalid path.".to_string()), - examples: "sdk install java\nsdk install java 17.0.0-tem\nsdk install java 11-local /usr/lib/jvm/java-11-openjdk".to_string(), - ..Default::default() - } -} - -fn list_help() -> Help { - let legend = "\ -+ - local version -* - installed -> - currently in use"; - Help { - cmd: "sdk list".to_string(), - tagline: "sdk subcommand to list all candidates or candidate versions".to_string(), - synopsis: "sdk list [candidate]".to_string(), - description: format!("Invoke the subcommand without a candidate to see a comprehensive list of all candidates \ - with name, URL, detailed description and an installation command.\nIf the candidate qualifier is specified, \ - the subcommand will display a list of all available and local versions for that candidate. In addition, the \ - version list view marks all versions that are local, installed or currently in use. They appear as follows:\n -{} - -Java has a custom list view with vendor-specific details.", legend.italic()), - mnemonic: Some(Mnemonic { shorthand: "ls".to_string(), command: "list".to_string() }), - examples: "sdk list\nsdk list java\nsdk list groovy".to_string(), - ..Default::default() - } -} - -fn selfupdate_help() -> Help { - Help { - cmd: "sdk selfupdate".to_string(), - tagline: "sdk subcommand to upgrade the SDKMAN core".to_string(), - synopsis: "sdk selfupdate [force]".to_string(), - description: "Invoke this command to upgrade the core script and native components of the SDKMAN command-line \ - interface. The command will only upgrade the native components if the detected platform is supported. The \ - command will refuse to upgrade the core if no new version is available. A qualifier may be added to the \ - selfupdate command to force an upgrade.".to_string(), - examples: "sdk selfupdate\nsdk selfupdate force".to_string(), - ..Default::default() - } -} - -fn uninstall_help() -> Help { - Help { - cmd: "sdk uninstall".to_string(), - tagline: "sdk subcommand to uninstall a candidate version".to_string(), - synopsis: "sdk uninstall ".to_string(), - description: format!("Always follow the subcommand with two qualifiers, the candidate and version to be \ - uninstalled.\n\nThe specified version will be removed from the corresponding candidate directory under {} and \ - will no longer be available for use on the system.", "$SDKMAN_DIR/candidates".underline()), - mnemonic: Some(Mnemonic { shorthand: "rm".to_string(), command: "uninstall".to_string() }), - exit_code: Some("An invalid candidate or version supplied to the subcommand will result in a non-zero \ - return code.".to_string()), - examples: "sdk uninstall java 17.0.0-tem".to_string(), - ..Default::default() - } -} - -fn update_help() -> Help { - Help { - cmd: "sdk update".to_string(), - tagline: "sdk subcommand to update the local state of SDKMAN".to_string(), - synopsis: "sdk update".to_string(), - description: "This command is used to download information about all candidates and versions. Other \ - commands operate on this data to perform version installations and upgrades or search and display details \ - about all packages available for installation. Run this command often to ensure that all candidates are \ - up to date and that the latest versions will be visible and installed.".to_string(), - examples: "sdk update".to_string(), - ..Default::default() - } -} - -fn upgrade_help() -> Help { - Help { - cmd: "sdk upgrade".to_string(), - tagline: "sdk subcommand to upgrade installed candidate versions".to_string(), - synopsis: "sdk upgrade [candidate]".to_string(), - description: "The optional candidate qualifier can be applied to specify the candidate you want to upgrade. \ - If the candidate qualifier is omitted from the command, it will attempt an upgrade of all outdated \ - candidates.\nCandidates that do not require an upgrade will be omitted, and a notification will be displayed \ - that these candidates are up to date.".to_string(), - mnemonic: Some(Mnemonic { shorthand: "ug".to_string(), command: "upgrade".to_string() }), - exit_code: Some("The subcommand will return a non-zero return code if the candidate does not exist.".to_string()), - examples: "sdk upgrade\nsdk upgrade java".to_string(), - ..Default::default() - } -} - -fn use_help() -> Help { - Help { - cmd: "sdk use".to_string(), - tagline: "sdk subcommand to use a specific version only in the current shell".to_string(), - synopsis: "sdk use ".to_string(), - description: "The mandatory candidate and version follow the subcommand to specify what to use in the \ - shell. This subcommand only operates on the current shell. It does not affect other shells \ - running different versions of the same candidate. It also does not change the default version set for \ - all subsequent shells.".to_string(), - mnemonic: Some(Mnemonic { shorthand: "u".to_string(), command: "use".to_string() }), - exit_code: Some("The subcommand will return a non-zero return code if the candidate or version does not exist." - .to_string()), - examples: "sdk use java 17.0.0-tem".to_string(), - ..Default::default() - } -} - -fn version_help() -> Help { - Help { - cmd: "sdk version".to_string(), - tagline: "sdk subcommand to display the installed SDKMAN version".to_string(), - synopsis: "sdk version".to_string(), - description: "This subcommand displays the version of the bash and native components of SDKMAN on this \ - system. The versions of the bash and native libraries evolve independently from each other and so will not \ - be in sync.".to_string(), - mnemonic: Some(Mnemonic { shorthand: "v".to_string(), command: "version".to_string() }), - examples: "sdk version".to_string(), - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// # Working with Snapshots - /// - /// Snapshots are stored in src/bin/help/snapshots/ and preserve ANSI formatting. - /// - /// ```bash - /// # Review changes - /// cargo insta review - /// - /// # View formatted snapshots - /// bat src/bin/help/snapshots/ - /// ``` - /// - /// See https://insta.rs/ for more details. - - fn setup() { - colored::control::set_override(true); - colored::control::SHOULD_COLORIZE.set_override(true); - } - - #[test] - fn should_render_main_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(main_help())); - } - - #[test] - fn should_render_config_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(config_help())); - } - - #[test] - fn should_render_current_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(current_help())); - } - - #[test] - fn should_render_default_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(default_help())); - } - - #[test] - fn should_render_env_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(env_help())); - } - - #[test] - fn should_render_flush_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(flush_help())); - } - - #[test] - fn should_render_home_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(home_help())); - } - - #[test] - fn should_render_install_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(install_help())); - } - - #[test] - fn should_render_list_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(list_help())); - } - - #[test] - fn should_render_selfupdate_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(selfupdate_help())); - } - - #[test] - fn should_render_uninstall_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(uninstall_help())); - } - - #[test] - fn should_render_update_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(update_help())); - } - - #[test] - fn should_render_upgrade_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(upgrade_help())); - } - - #[test] - fn should_render_use_help_with_formatting() { - setup(); - insta::assert_snapshot!(render(use_help())); - } -} diff --git a/src/bin/home/main.rs b/src/bin/home/main.rs deleted file mode 100644 index 7835091..0000000 --- a/src/bin/home/main.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::process; - -use clap::Parser; -use colored::Colorize; - -use sdkman_cli_native::constants::CANDIDATES_DIR; -use sdkman_cli_native::helpers::{infer_sdkman_dir, known_candidates, validate_candidate}; - -#[derive(Parser, Debug)] -#[command( - bin_name = "sdk home", - about = "sdk subcommand to output the path of a specific candidate version" -)] -struct Args { - #[arg(required(true))] - candidate: String, - - #[arg(required(true))] - version: String, -} - -fn main() { - let args = Args::parse(); - let candidate = args.candidate; - let version = args.version; - let sdkman_dir = infer_sdkman_dir(); - - let candidate = validate_candidate(known_candidates(sdkman_dir.to_owned()), &candidate); - - let candidate_path = sdkman_dir - .join(CANDIDATES_DIR) - .join(&candidate) - .join(&version); - if candidate_path.exists() && candidate_path.is_dir() { - println!( - "{}/{}/{}/{}", - sdkman_dir.to_str().unwrap(), - CANDIDATES_DIR, - &candidate, - &version - ); - } else { - eprintln!( - "{} {} is not installed on your system.", - candidate.bold(), - version.bold() - ); - process::exit(1); - } -} diff --git a/src/bin/uninstall/main.rs b/src/bin/uninstall/main.rs deleted file mode 100644 index 6962de1..0000000 --- a/src/bin/uninstall/main.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::fs; -use std::fs::remove_dir_all; -use std::process; - -use clap::Parser; -use colored::Colorize; -use symlink::remove_symlink_dir; - -use sdkman_cli_native::constants::{CANDIDATES_DIR, CURRENT_DIR}; -use sdkman_cli_native::helpers::{ - infer_sdkman_dir, known_candidates, validate_candidate, validate_version_path, -}; - -#[derive(Parser, Debug)] -#[command( - bin_name = "sdk uninstall", - about = "sdk subcommand to remove a specific candidate version" -)] -struct Args { - #[arg(short = 'f', long = "force")] - force: bool, - - #[arg(required(true))] - candidate: String, - - #[arg(required(true))] - version: String, -} - -fn main() { - let args = Args::parse(); - let candidate = args.candidate; - let version = args.version; - let force = args.force; - let sdkman_dir = infer_sdkman_dir(); - - let candidate = validate_candidate(known_candidates(sdkman_dir.to_owned()), &candidate); - - let candidate_path = sdkman_dir.join(CANDIDATES_DIR).join(&candidate); - let version_path = validate_version_path(sdkman_dir, &candidate, &version); - let current_link_path = candidate_path.join(CURRENT_DIR); - if current_link_path.is_dir() { - match fs::read_link(current_link_path.to_owned()) { - Ok(relative_resolved_dir) => { - let resolved_link_path = candidate_path.join(relative_resolved_dir); - if (version_path == resolved_link_path) && force { - remove_symlink_dir(¤t_link_path).unwrap_or_else(|_| { - remove_dir_all(current_link_path.to_owned()).expect(&format!( - "cannot remove current directory for {}.", - candidate - )) - }); - } else if (version_path == resolved_link_path) && !force { - eprintln!( - "\n{} {} is the {} version and should not be removed.", - candidate.bold(), - version.bold(), - "current".italic(), - ); - println!( - "\n\nOverride with {}, but leaves the candidate unusable!", - "--force".italic() - ); - process::exit(1); - } - } - Err(e) => { - eprintln!("current link broken, stepping over: {}", e.to_string()); - } - } - } - - remove_dir_all(version_path) - .map(|_| { - println!("removed {} {}.", candidate.bold(), version.bold()); - }) - .expect("panic! could not delete directory."); -} diff --git a/src/bin/version/main.rs b/src/bin/version/main.rs deleted file mode 100644 index 7778db5..0000000 --- a/src/bin/version/main.rs +++ /dev/null @@ -1,23 +0,0 @@ -use colored::Colorize; - -use sdkman_cli_native::{ - constants::VAR_DIR, - helpers::{check_file_exists, infer_sdkman_dir, read_file_content}, -}; -const CLI_VERSION_FILE: &str = "version"; -const NATIVE_VERSION: &str = env!("CARGO_PKG_VERSION"); - -fn main() { - let sdkman_dir = infer_sdkman_dir(); - let cli_version_file = sdkman_dir.join(VAR_DIR).join(CLI_VERSION_FILE); - let cli_version = read_file_content(check_file_exists(cli_version_file)); - - println!( - "\n{}\nscript: {}\nnative: {} ({} {})\n", - "SDKMAN!".bold().yellow(), - cli_version.expect("Failed to read CLI version file"), - NATIVE_VERSION, - std::env::consts::OS, - std::env::consts::ARCH - ); -} diff --git a/src/commands/current.rs b/src/commands/current.rs new file mode 100644 index 0000000..72bc55d --- /dev/null +++ b/src/commands/current.rs @@ -0,0 +1,128 @@ +//! `sdk current` command. +//! +//! Shows the version currently in use for a candidate, based on +//! `${SDKMAN_DIR}/candidates//current`. +//! +//! If a candidate is provided, prints a single line in the form: +//! `Using version `. +//! +//! If no candidate is provided, prints all candidates that have a resolvable +//! current version (sorted by candidate name) as ` `. +//! +//! ## Exit codes +//! - `0` on success (including the “no candidates are in use” case) +//! - `1` if a specific candidate is requested but has no configured current version, +//! or on invalid candidate / filesystem errors +//! +//! ## Examples +//! ```no_run +//! # use std::process::Command; +//! // Show current version for all candidates +//! Command::new("sdk").arg("current").status().unwrap(); +//! +//! // Show current version for a specific candidate +//! Command::new("sdk").args(["current", "java"]).status().unwrap(); +//! ``` + +use crate::utils::{ + constants::{CANDIDATES_DIR, CURRENT_DIR}, + directory_utils::infer_sdkman_dir, + helpers::{known_candidates, validate_candidate}, +}; +use colored::Colorize; +use std::{fs, path::Path}; + +/// Arguments for `sdk current`. +#[derive(clap::Args, Debug)] +#[command(about = "Display the current version in use for one or all candidates")] +pub struct Args { + /// Optional candidate name to query (otherwise prints all current versions). + pub candidate: Option, +} + +/// Run `sdk current`. +/// +/// Behavior: +/// - With a candidate: prints `Using version `. +/// - Without a candidate: prints a sorted list of candidates with a resolvable `current`. +pub fn run(args: Args) -> Result<(), i32> { + let sdkman_dir = infer_sdkman_dir().map_err(|e| { + eprintln!("failed to infer SDKMAN_DIR: {e}"); + 1 + })?; + + let all_candidates = known_candidates(&sdkman_dir); + + match args.candidate { + Some(candidate) => { + let candidate = validate_candidate(&all_candidates, &candidate); + match get_current_version(&sdkman_dir, &candidate) { + Some(version) => { + println!("Using {} version {}", candidate.bold(), version.bold()); + Ok(()) + } + None => { + eprintln!("No current version of {} configured.", candidate.bold()); + Err(1) + } + } + } + None => { + let mut rows: Vec<(String, String)> = all_candidates + .into_iter() + .filter_map(|cand| get_current_version(&sdkman_dir, &cand).map(|v| (cand, v))) + .collect(); + + if rows.is_empty() { + eprintln!("No candidates are in use."); + return Ok(()); + } + + // Stable output. + rows.sort_by(|a, b| a.0.cmp(&b.0)); + + println!("{}", "Current versions in use:".bold()); + for (candidate, version) in rows { + println!("{} {}", candidate, version); + } + Ok(()) + } + } +} + +/// Resolve the "current" version for `candidate`. +/// +/// This prefers reading the `current` symlink target (the normal SDKMAN! layout). +/// If `current` is a real directory, it falls back to using the directory name. +/// +/// Returns `None` if the candidate directory or `current` entry does not exist, or +/// if the current version cannot be determined. +fn get_current_version(base_dir: &Path, candidate: &str) -> Option { + let candidate_dir = base_dir.join(CANDIDATES_DIR).join(candidate); + if !candidate_dir.is_dir() { + return None; + } + + let current_path = candidate_dir.join(CURRENT_DIR); + if !current_path.exists() { + return None; + } + + // Primary: read symlink target. + if let Ok(target) = fs::read_link(¤t_path) { + return target + .file_name() + .and_then(|name| name.to_str()) + .map(|s| s.to_string()); + } + + // Fallback: if `current` is a directory, attempt to treat its name as version. + if current_path.is_dir() { + return current_path + .file_name() + .and_then(|name| name.to_str()) + .map(|s| s.to_string()); + } + + None +} diff --git a/src/commands/default.rs b/src/commands/default.rs new file mode 100644 index 0000000..90fc23d --- /dev/null +++ b/src/commands/default.rs @@ -0,0 +1,121 @@ +//! `sdk default` command. +//! +//! Sets the “current” version for a candidate by updating +//! `${SDKMAN_DIR}/candidates//current`. +//! +//! The preferred implementation is a directory symlink to the requested version. +//! On platforms or filesystems where symlinks are unavailable, this falls back to +//! copying the version directory into place (via `${SDKMAN_DIR}/tmp`). +//! +//! ## Exit codes +//! - `0` on success +//! - `1` on invalid candidate/version, missing files, or filesystem errors +//! +//! ## Examples +//! ```no_run +//! # use std::process::Command; +//! // Set scala 3.3.1 as the default for all new shells +//! Command::new("sdk") +//! .args(["default", "scala", "3.3.1"]) +//! .status() +//! .unwrap(); +//! ``` + +use crate::utils::{ + constants::{CANDIDATES_DIR, CURRENT_DIR, TMP_DIR}, + directory_utils::infer_sdkman_dir, + helpers::{known_candidates, validate_candidate, validate_version_path}, +}; +use colored::Colorize; +use fs_extra::{copy_items, dir::CopyOptions}; +use std::{ + fs::{self, remove_dir_all}, + process::exit, + slice, +}; +use symlink::{remove_symlink_dir, symlink_dir}; + +/// Arguments for `sdk default`. +#[derive(clap::Args, Debug)] +#[command(about = "Set the local default version of a candidate")] +pub struct Args { + /// Candidate name (e.g. `java`, `scala`). + #[arg(required = true)] + pub candidate: String, + + /// Candidate version to set as `current`. + #[arg(required = true)] + pub version: String, +} + +/// Run `sdk default`. +/// +/// Returns `Ok(())` on success, or an exit code (`Err(code)`) on failure. +/// +/// Implementation notes: +/// - Removes any existing `current` path (symlink or directory). +/// - Attempts to create a symlink `current -> `. +/// - If symlinking fails, copies `` into `${SDKMAN_DIR}/tmp` then renames into place. +pub fn run(args: Args) -> Result<(), i32> { + let sdkman_dir = infer_sdkman_dir().map_err(|e| { + eprintln!("failed to infer SDKMAN_DIR: {e}"); + 1 + })?; + + let tmp_dir = sdkman_dir.join(TMP_DIR); + let candidate = validate_candidate(&known_candidates(&sdkman_dir), &args.candidate); + let version_path = validate_version_path(&sdkman_dir, &candidate, &args.version); + + let current_link_path = sdkman_dir + .join(CANDIDATES_DIR) + .join(&candidate) + .join(CURRENT_DIR); + + // Remove existing "current" (symlink or dir). + if current_link_path.exists() { + remove_symlink_dir(¤t_link_path).unwrap_or_else(|_| { + remove_dir_all(¤t_link_path).unwrap_or_else(|e| { + eprintln!( + "cannot remove current directory for {}: {}", + candidate.bold(), + e + ); + exit(1); + }) + }); + } + + println!( + "setting {} {} as the {} version for all shells.", + candidate.bold(), + args.version.bold(), + "default".italic() + ); + + // Prefer symlink; fallback to copying into place if symlinks fail. + symlink_dir(&version_path, ¤t_link_path).unwrap_or_else(|_| { + let options = CopyOptions::new(); + + copy_items(slice::from_ref(&version_path), &tmp_dir, &options).unwrap_or_else(|e| { + eprintln!("cannot copy to tmp folder: {e}"); + exit(1); + }); + + let tmp_version_path = tmp_dir.join(&args.version); + fs::rename(&tmp_version_path, ¤t_link_path).unwrap_or_else(|e| { + eprintln!("cannot rename copied folder into place: {e}"); + exit(1); + }); + + println!( + "{}", + format!( + "cannot create {} symlink, falling back to copy!", + "current".italic() + ) + .bold() + ); + }); + + Ok(()) +} diff --git a/src/commands/help.rs b/src/commands/help.rs new file mode 100644 index 0000000..6b5b1d2 --- /dev/null +++ b/src/commands/help.rs @@ -0,0 +1,802 @@ +//! `sdk help` command. +//! +//! Renders human-friendly help text for `sdk` and its subcommands, matching the +//! look/feel of SDKMAN!'s bash help (headings, indentation, wrapping, and ANSI +//! styling). +//! +//! Notes: +//! - `sdk` (main help) renders the NAME line as `sdk - …` +//! - Subcommand help renders as `sdk …` +//! - Aliases are supported (e.g. `sdk help ls`, `sdk help rm`, `sdk help v`) +//! - Snapshot tests live in `src/commands/snapshots/` and preserve ANSI output. +//! +//! # Examples +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk").args(["help"]).status().unwrap(); +//! Command::new("sdk").args(["help", "install"]).status().unwrap(); +//! Command::new("sdk").args(["help", "ls"]).status().unwrap(); +//! ``` +use colored::Colorize; +use textwrap::{fill, indent}; + +/// CLI arguments for `sdk help`. +#[derive(clap::Args, Debug)] +#[command(about = "Show detailed help for a subcommand")] +pub struct Args { + /// Optional subcommand name (e.g. `install`). Aliases like `i`, `ls`, etc. also work. + pub subcommand: Option, +} + +/// Entry point for `sdk help`. +/// +/// Resolves an optional help topic (including aliases) and prints the rendered help text. +pub fn run(args: Args) -> Result<(), i32> { + let help = match args + .subcommand + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + None => main_help(), + + // qualifiers + aliases + Some("config") => config_help(), + Some("current") | Some("c") => current_help(), + Some("default") | Some("d") => default_help(), + Some("env") | Some("e") => env_help(), + Some("flush") => flush_help(), + Some("home") | Some("h") => home_help(), + Some("install") | Some("i") => install_help(), + Some("list") | Some("ls") => list_help(), + Some("selfupdate") => selfupdate_help(), + Some("uninstall") | Some("rm") => uninstall_help(), + Some("update") => update_help(), + Some("upgrade") | Some("ug") => upgrade_help(), + Some("use") | Some("u") => use_help(), + Some("version") | Some("v") => version_help(), + + Some(other) => { + eprintln!( + "error: unknown help topic '{}'\n(use {} to see available subcommands)", + other, + "sdk help".italic() + ); + return Err(1); + } + }; + + println!("{}", render(help)); + Ok(()) +} + +/// A single row in the "SUBCOMMANDS & QUALIFIERS" section. +struct Subcommand { + /// Subcommand name. + command: String, + /// Subcommand qualifier summary (rendered in italic). + description: String, +} + +/// Optional "CONFIGURATION" section content. +struct Configuration { + /// Explanatory text. + content: String, + /// Example snippet (typically multi-line). + snippet: String, +} + +/// Optional mnemonic shorthand for a subcommand (e.g. `v` for `version`). +struct Mnemonic { + /// Shorthand alias. + shorthand: String, + /// Canonical subcommand name. + command: String, +} + +/// In-memory representation of a help page. +#[derive(Default)] +struct Help { + /// Command label used in the NAME line (e.g. `sdk`, `sdk install`). + cmd: String, + /// Short tagline shown in NAME. + tagline: String, + /// One-line usage form. + synopsis: String, + /// Paragraph describing the command. + description: String, + /// Optional list of subcommands or qualifiers. + subcommands: Option>, + /// Optional configuration details and example snippet. + configuration: Option, + /// Optional mnemonic shorthand. + mnemonic: Option, + /// Optional exit code note. + exit_code: Option, + /// Example invocations. + examples: String, +} + +/// Visual indentation used for help body blocks. +const INDENTATION_WIDTH: usize = 4; + +/// Target terminal width for wrapping. +const TERMINAL_WIDTH: usize = 80; + +/// Effective text width after indentation. +const TEXT_WIDTH: usize = TERMINAL_WIDTH - INDENTATION_WIDTH; + +/// Render a [`Help`] page into a styled string. +/// +/// Output includes section headings and wraps text to [`TERMINAL_WIDTH`]. +fn render(help: Help) -> String { + let spaced_tab = format!("{:width$}", " ", width = INDENTATION_WIDTH); + let indentation = spaced_tab.as_str(); + + let sep = if help.cmd.trim() == "sdk" { " - " } else { " " }; + let nameline = format!("{}{}{}", help.cmd.italic(), sep, help.tagline); + + let wrapped_nameline = fill(&nameline, TEXT_WIDTH); + let name = format!( + "\n{}\n{}\n\n", + "NAME".bold(), + indent(&wrapped_nameline, indentation) + ); + + let synopsis = format!( + "{}\n{}\n\n", + "SYNOPSIS".bold(), + indent(&format!("{}", help.synopsis.italic()), indentation) + ); + + let description = format!( + "{}\n{}\n\n", + "DESCRIPTION".bold(), + indent(&fill(help.description.as_str(), TEXT_WIDTH), indentation) + ); + + let subcommands: String = help + .subcommands + .iter() + .map(|subs| { + let lines: String = subs + .iter() + .map(|sub| { + let desc_depth = 17; + let desc_indent = format!("{:width$}", " ", width = desc_depth); + + let command = indent(&fill(&sub.command, desc_depth), indentation); + let description = &indent( + &fill(&sub.description, TEXT_WIDTH - desc_depth), + &desc_indent, + )[command.len()..]; + + format!("{}{}\n", command, description) + }) + .collect(); + + format!("{}\n{}\n", "SUBCOMMANDS & QUALIFIERS".bold(), lines) + }) + .collect(); + + let configuration = help + .configuration + .map(|config| { + format!( + "{}\n{}\n\n{}\n\n", + "CONFIGURATION".bold(), + indent(&fill(&config.content, TEXT_WIDTH), indentation), + indent(&config.snippet, indentation) + ) + }) + .unwrap_or_default(); + + let mnemonic = help + .mnemonic + .map(|mnemonic| { + let text = format!( + "{} - may be used in place of the {} subcommand.", + &mnemonic.shorthand.bold(), + &mnemonic.command.bold() + ); + format!("{}\n{}\n\n", "MNEMONIC".bold(), indent(&text, indentation)) + }) + .unwrap_or_default(); + + let exit_code = help + .exit_code + .map(|m| { + format!( + "{}\n{}\n\n", + "EXIT CODE".bold(), + indent(&fill(&m, TEXT_WIDTH), indentation) + ) + }) + .unwrap_or_default(); + + let examples = format!( + "{}\n{}\n\n", + "EXAMPLES".bold(), + indent(&format!("{}", help.examples.italic()), indentation) + ); + + format!( + "{}{}{}{}{}{}{}{}", + name, synopsis, description, subcommands, configuration, exit_code, mnemonic, examples + ) +} + +/// Help text for `sdk` (top-level). +fn main_help() -> Help { + Help { + cmd: "sdk".to_string(), + tagline: "The command line interface (CLI) for SDKMAN!".to_string(), + synopsis: "sdk [candidate] [version]".to_string(), + description: "SDKMAN! is a tool for managing parallel versions of multiple JVM related Software Development \ + Kits on most Unix based systems. It provides a convenient Command Line Interface (CLI) and API for installing, \ + switching, removing and listing Candidates." + .to_string(), + subcommands: Some(vec![ + Subcommand { + command: "help".to_string(), + description: "[subcommand]".italic().to_string(), + }, + Subcommand { + command: "install".to_string(), + description: " [version] [path]".italic().to_string(), + }, + Subcommand { + command: "uninstall".to_string(), + description: " ".italic().to_string(), + }, + Subcommand { + command: "list".to_string(), + description: "[candidate]".italic().to_string(), + }, + Subcommand { + command: "use".to_string(), + description: " ".italic().to_string(), + }, + Subcommand { + command: "config".to_string(), + description: "no qualifier".to_string(), + }, + Subcommand { + command: "default".to_string(), + description: " [version]".italic().to_string(), + }, + Subcommand { + command: "home".to_string(), + description: " ".italic().to_string(), + }, + Subcommand { + command: "env".to_string(), + description: "[init|install|clear]".italic().to_string(), + }, + Subcommand { + command: "current".to_string(), + description: "[candidate]".italic().to_string(), + }, + Subcommand { + command: "upgrade".to_string(), + description: "[candidate]".italic().to_string(), + }, + Subcommand { + command: "version".to_string(), + description: "no qualifier".to_string(), + }, + Subcommand { + command: "selfupdate".to_string(), + description: "[force]".italic().to_string(), + }, + Subcommand { + command: "update".to_string(), + description: "no qualifier".to_string(), + }, + Subcommand { + command: "flush".to_string(), + description: "[tmp|metadata|version]".italic().to_string(), + }, + ]), + examples: "sdk install java 17.0.0-tem\nsdk help install".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk config`. +fn config_help() -> Help { + let config_file = "${SDKMAN_DIR}/etc/config"; + let default_config = "\ +--- +sdkman_auto_answer=false +sdkman_auto_complete=true +sdkman_auto_env=false +sdkman_auto_update=true +sdkman_beta_channel=false +sdkman_checksum_enable=true +sdkman_colour_enable=true +sdkman_curl_connect_timeout=7 +sdkman_curl_max_time=10 +sdkman_debug_mode=false +sdkman_insecure_ssl=false +sdkman_selfupdate_feature=true +---"; + + Help { + cmd: "sdk config".to_string(), + tagline: "sdk subcommand to edit the SDKMAN configuration file".to_string(), + synopsis: "sdk config".to_string(), + description: format!( + "This subcommand opens a text editor on the configuration file located at {}. \ + The subcommand will infer the text editor from the {} environment variable. If the system does \ + not set the {} environment variable, then vi is assumed as the default editor.", + config_file.underline(), + "EDITOR".italic(), + "EDITOR".italic() + ), + configuration: Some(Configuration { + content: format!( + "The {} file contains the following default configuration. A new shell should be \ + opened for any configuration changes to take effect.", + config_file.underline() + ), + snippet: default_config.italic().to_string(), + }), + examples: "sdk config".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk current` (and alias `c`). +fn current_help() -> Help { + Help { + cmd: "sdk current".to_string(), + tagline: "sdk subcommand to display the current default installed versions".to_string(), + synopsis: "sdk current [candidate]".to_string(), + description: "This subcommand will display a list of candidates with their default version installed on the \ + system. It is also possible to qualify the candidate when running the subcommand to display only that \ + candidate's default version." + .to_string(), + mnemonic: Some(Mnemonic { + shorthand: "c".to_string(), + command: "current".to_string(), + }), + examples: "sdk current\nsdk current java".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk default` (and alias `d`). +fn default_help() -> Help { + Help { + cmd: "sdk default".to_string(), + tagline: "sdk subcommand to set the local default version of the candidate".to_string(), + synopsis: "sdk default [version]".to_string(), + description: "The mandatory candidate qualifier of the subcommand specifies the candidate to default for all \ + future shells.\n\nThe optional version qualifier sets that specific version as default for all subsequent \ + shells on the local environment. Omitting the version will set the global SDKMAN tracked version as the \ + default version for that candidate." + .to_string(), + mnemonic: Some(Mnemonic { + shorthand: "d".to_string(), + command: "default".to_string(), + }), + exit_code: Some( + "The subcommand will return a non-zero return code if the candidate or version does not exist." + .to_string(), + ), + examples: "sdk default java 17.0.0-tem\nsdk default java".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk env` (and alias `e`). +fn env_help() -> Help { + let config_file_content = "\ +--- +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=11.0.13-tem +---" + .italic(); + + Help { + cmd: "sdk env".to_string(), + tagline: + "sdk subcommand to control SDKs on a project level, setting up specific versions for a directory" + .to_string(), + synopsis: "sdk env [init|install|clear]".to_string(), + description: format!( + "Allows the developer to manage the SDK versions used in a project directory. The \ + subcommand uses an {} file to install or switch specific SDK versions in a project directory.\n\nWhen \ + issuing the subcommand without a qualifier, it will switch to the versions specified in {} and emit \ + warnings for versions not present on the system. In addition, the subcommand has three optional qualifiers.", + ".sdkmanrc".underline(), + ".sdkmanrc".underline() + ), + subcommands: Some(vec![ + Subcommand { + command: "install".to_string(), + description: format!( + "install and switch to the SDK versions specified in {}", + ".sdkmanrc".underline() + ), + }, + Subcommand { + command: "init".to_string(), + description: format!( + "allows for the creation of a default {} file with a single entry for the {} \ + candidate, set to the current default value)", + ".sdkmanrc".underline(), + "java".italic() + ), + }, + Subcommand { + command: "clear".to_string(), + description: "reset all SDK versions to their system defaults".to_string(), + }, + ]), + configuration: Some(Configuration { + content: format!( + "The {} file contains key-value pairs for each configurable SDK for that project \ + environment. You may enable a configuration option for auto-env behaviour by setting {} in the {} \ + file. This setting will automatically switch versions when stepping into a directory on the presence \ + of a {} descriptor. When enabled, you no longer need to issue the {} qualifier explicitly. This \ + behaviour is disabled by default. An initial file will have content such as this:", + ".sdkmanrc".underline(), + "sdkman_auto_env=true".italic(), + "$SDKMAN_DIR/etc/config".underline(), + ".sdkmanrc".underline(), + "install".italic() + ), + snippet: config_file_content.to_string(), + }), + examples: "sdk env\nsdk env install\nsdk env init\nsdk env clear".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk flush`. +fn flush_help() -> Help { + Help { + cmd: "sdk flush".to_string(), + tagline: "sdk subcommand used for flushing local temporal state of SDKMAN".to_string(), + synopsis: "sdk flush [tmp|metadata|version]".to_string(), + description: format!( + "This command cleans temporary storage under {} in the {} and {} directories, removing \ + metadata and version caches. It also removes any residual download artifacts. It is possible to \ + flush specific targets by providing a qualifier. Omission of the qualifier results in a full flush of all \ + targets.", + "$SDKMAN_DIR".underline(), + "var".underline(), + "tmp".underline() + ), + subcommands: Some(vec![ + Subcommand { + command: "tmp".to_string(), + description: format!( + "cleans out pre/post hooks and residual archives from {}", + "$SDKMAN_DIR/tmp".underline() + ), + }, + Subcommand { + command: "metadata".to_string(), + description: "removes any header metadata".to_string(), + }, + Subcommand { + command: "version".to_string(), + description: format!( + "flushes the {} and {} files under {}", + "version".underline(), + "version_native".underline(), + "$SDKMAN_DIR/var".underline() + ), + }, + ]), + examples: "sdk flush\nsdk flush tmp\nsdk flush metadata\nsdk flush version".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk home` (and alias `h`). +fn home_help() -> Help { + Help { + cmd: "sdk home".to_string(), + tagline: "sdk subcommand to output the path of a specific candidate version".to_string(), + synopsis: "sdk home ".to_string(), + description: "Print the absolute home path of any candidate version installed by SDKMAN. The candidate and \ + version parameters are mandatory. This subcommand is usually used for scripting, so does not append a newline \ + character." + .to_string(), + exit_code: Some( + "The subcommand will emit a non-zero exit code if a valid candidate version is not locally installed." + .to_string(), + ), + examples: "sdk home java 17.0.0-tem".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk install` (and alias `i`). +fn install_help() -> Help { + Help { + cmd: "sdk install".to_string(), + tagline: "sdk subcommand to install a candidate version".to_string(), + synopsis: "sdk install [version] [path]".to_string(), + description: "Invoking this subcommand with only the candidate as parameter will install the currently \ + known default version for that candidate. Provide a second qualifier to install a specific non-default \ + version. Provide a third optional qualifier to add an already installed local version. This final qualifier is \ + the absolute local path to the base directory of the SDK to be added. The local version will appear as an \ + installed version of the candidate. The version may not conflict with an existing version, installed or not." + .to_string(), + mnemonic: Some(Mnemonic { + shorthand: "i".to_string(), + command: "install".to_string(), + }), + exit_code: Some( + "The subcommand will return a non-zero exit code for versions not found or for an invalid path." + .to_string(), + ), + examples: "sdk install java\nsdk install java 17.0.0-tem\nsdk install java 11-local /usr/lib/jvm/java-11-openjdk" + .to_string(), + ..Default::default() + } +} + +/// Help text for `sdk list` (and alias `ls`). +fn list_help() -> Help { + let legend = "\ ++ - local version +* - installed +> - currently in use"; + + Help { + cmd: "sdk list".to_string(), + tagline: "sdk subcommand to list all candidates or candidate versions".to_string(), + synopsis: "sdk list [candidate]".to_string(), + description: format!( + "Invoke the subcommand without a candidate to see a comprehensive list of all candidates \ + with name, URL, detailed description and an installation command.\nIf the candidate qualifier is specified, \ + the subcommand will display a list of all available and local versions for that candidate. In addition, the \ + version list view marks all versions that are local, installed or currently in use. They appear as follows:\n +{} + +Java has a custom list view with vendor-specific details.", + legend.italic() + ), + mnemonic: Some(Mnemonic { + shorthand: "ls".to_string(), + command: "list".to_string(), + }), + examples: "sdk list\nsdk list java\nsdk list groovy".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk selfupdate`. +fn selfupdate_help() -> Help { + Help { + cmd: "sdk selfupdate".to_string(), + tagline: "sdk subcommand to upgrade the SDKMAN core".to_string(), + synopsis: "sdk selfupdate [force]".to_string(), + description: "Invoke this command to upgrade the core script and native components of the SDKMAN command-line \ + interface. The command will only upgrade the native components if the detected platform is supported. The \ + command will refuse to upgrade the core if no new version is available. A qualifier may be added to the \ + selfupdate command to force an upgrade." + .to_string(), + examples: "sdk selfupdate\nsdk selfupdate force".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk uninstall` (and alias `rm`). +fn uninstall_help() -> Help { + Help { + cmd: "sdk uninstall".to_string(), + tagline: "sdk subcommand to uninstall a candidate version".to_string(), + synopsis: "sdk uninstall ".to_string(), + description: format!( + "Always follow the subcommand with two qualifiers, the candidate and version to be \ + uninstalled.\n\nThe specified version will be removed from the corresponding candidate directory under {} and \ + will no longer be available for use on the system.", + "$SDKMAN_DIR/candidates".underline() + ), + mnemonic: Some(Mnemonic { + shorthand: "rm".to_string(), + command: "uninstall".to_string(), + }), + exit_code: Some( + "An invalid candidate or version supplied to the subcommand will result in a non-zero return code." + .to_string(), + ), + examples: "sdk uninstall java 17.0.0-tem".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk update`. +fn update_help() -> Help { + Help { + cmd: "sdk update".to_string(), + tagline: "sdk subcommand to update the local state of SDKMAN".to_string(), + synopsis: "sdk update".to_string(), + description: "This command is used to download information about all candidates and versions. Other \ + commands operate on this data to perform version installations and upgrades or search and display details \ + about all packages available for installation. Run this command often to ensure that all candidates are \ + up to date and that the latest versions will be visible and installed." + .to_string(), + examples: "sdk update".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk upgrade` (and alias `ug`). +fn upgrade_help() -> Help { + Help { + cmd: "sdk upgrade".to_string(), + tagline: "sdk subcommand to upgrade installed candidate versions".to_string(), + synopsis: "sdk upgrade [candidate]".to_string(), + description: "The optional candidate qualifier can be applied to specify the candidate you want to upgrade. \ + If the candidate qualifier is omitted from the command, it will attempt an upgrade of all outdated \ + candidates.\nCandidates that do not require an upgrade will be omitted, and a notification will be displayed \ + that these candidates are up to date." + .to_string(), + mnemonic: Some(Mnemonic { + shorthand: "ug".to_string(), + command: "upgrade".to_string(), + }), + exit_code: Some( + "The subcommand will return a non-zero return code if the candidate does not exist.".to_string(), + ), + examples: "sdk upgrade\nsdk upgrade java".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk use` (and alias `u`). +fn use_help() -> Help { + Help { + cmd: "sdk use".to_string(), + tagline: "sdk subcommand to use a specific version only in the current shell".to_string(), + synopsis: "sdk use ".to_string(), + description: "The mandatory candidate and version follow the subcommand to specify what to use in the \ + shell. This subcommand only operates on the current shell. It does not affect other shells \ + running different versions of the same candidate. It also does not change the default version set for \ + all subsequent shells." + .to_string(), + mnemonic: Some(Mnemonic { + shorthand: "u".to_string(), + command: "use".to_string(), + }), + exit_code: Some( + "The subcommand will return a non-zero return code if the candidate or version does not exist." + .to_string(), + ), + examples: "sdk use java 17.0.0-tem".to_string(), + ..Default::default() + } +} + +/// Help text for `sdk version` (and alias `v`). +fn version_help() -> Help { + Help { + cmd: "sdk version".to_string(), + tagline: "sdk subcommand to display the installed SDKMAN version".to_string(), + synopsis: "sdk version".to_string(), + description: "This subcommand displays the version of the bash and native components of SDKMAN on this \ + system. The versions of the bash and native libraries evolve independently from each other and so will not \ + be in sync." + .to_string(), + mnemonic: Some(Mnemonic { + shorthand: "v".to_string(), + command: "version".to_string(), + }), + examples: "sdk version".to_string(), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use colored::control; + + fn setup() { + control::set_override(true); + control::SHOULD_COLORIZE.set_override(true); + } + + fn snapshot_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("commands") + .join("snapshots") + } + + fn assert_help_snapshot(name: &str, render_fn: impl FnOnce() -> String) { + setup(); + let rendered = render_fn(); + + insta::with_settings!({ + snapshot_path => snapshot_dir(), + prepend_module_to_snapshot => false, + }, { + insta::assert_snapshot!(name, rendered); + }); + } + + #[test] + fn renders_main_help() { + assert_help_snapshot("main_help", || render(main_help())); + } + + #[test] + fn renders_config_help() { + assert_help_snapshot("config_help", || render(config_help())); + } + + #[test] + fn renders_current_help() { + assert_help_snapshot("current_help", || render(current_help())); + } + + #[test] + fn renders_default_help() { + assert_help_snapshot("default_help", || render(default_help())); + } + + #[test] + fn renders_env_help() { + assert_help_snapshot("env_help", || render(env_help())); + } + + #[test] + fn renders_flush_help() { + assert_help_snapshot("flush_help", || render(flush_help())); + } + + #[test] + fn renders_home_help() { + assert_help_snapshot("home_help", || render(home_help())); + } + + #[test] + fn renders_install_help() { + assert_help_snapshot("install_help", || render(install_help())); + } + + #[test] + fn renders_list_help() { + assert_help_snapshot("list_help", || render(list_help())); + } + + #[test] + fn renders_selfupdate_help() { + assert_help_snapshot("selfupdate_help", || render(selfupdate_help())); + } + + #[test] + fn renders_uninstall_help() { + assert_help_snapshot("uninstall_help", || render(uninstall_help())); + } + + #[test] + fn renders_update_help() { + assert_help_snapshot("update_help", || render(update_help())); + } + + #[test] + fn renders_upgrade_help() { + assert_help_snapshot("upgrade_help", || render(upgrade_help())); + } + + #[test] + fn renders_use_help() { + assert_help_snapshot("use_help", || render(use_help())); + } + + #[test] + fn renders_version_help() { + assert_help_snapshot("version_help", || render(version_help())); + } +} diff --git a/src/commands/home.rs b/src/commands/home.rs new file mode 100644 index 0000000..14bb67e --- /dev/null +++ b/src/commands/home.rs @@ -0,0 +1,65 @@ +//! `sdk home` command. +//! +//! Prints the absolute path to an installed candidate version under +//! `$SDKMAN_DIR/candidates//`. +//! +//! Exits with a non-zero code if the candidate/version directory does not exist. +//! +//! ## Examples +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["home", "java", "17.0.0-tem"]) +//! .status() +//! .unwrap(); +//! ``` + +use crate::utils::{ + constants::CANDIDATES_DIR, + directory_utils::infer_sdkman_dir, + helpers::{known_candidates, validate_candidate}, +}; +use colored::Colorize; + +/// Arguments for `sdk home`. +#[derive(clap::Args, Debug)] +#[command(about = "Output the path of a specific candidate version")] +pub struct Args { + /// Candidate name (e.g. `java`). + #[arg(required = true)] + pub candidate: String, + + /// Candidate version (e.g. `17.0.0-tem`). + #[arg(required = true)] + pub version: String, +} + +/// Run `sdk home`. +/// +/// Returns `Ok(())` on success, or an exit code (`Err(code)`) on failure. +pub fn run(args: Args) -> Result<(), i32> { + let sdkman_dir = infer_sdkman_dir().map_err(|e| { + eprintln!("failed to infer SDKMAN_DIR: {e}"); + 1 + })?; + + let candidate = validate_candidate(&known_candidates(&sdkman_dir), &args.candidate); + + let candidate_path = sdkman_dir + .join(CANDIDATES_DIR) + .join(&candidate) + .join(&args.version); + + if candidate_path.is_dir() { + // print absolute path to the version directory + println!("{}", candidate_path.display()); + Ok(()) + } else { + eprintln!( + "{} {} is not installed on your system.", + candidate.bold(), + args.version.bold() + ); + Err(1) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..d2500e8 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod current; +pub mod default; +pub mod help; +pub mod home; +pub mod uninstall; +pub mod version; diff --git a/src/bin/help/snapshots/help__tests__should_render_config_help_with_formatting.snap b/src/commands/snapshots/config_help.snap similarity index 87% rename from src/bin/help/snapshots/help__tests__should_render_config_help_with_formatting.snap rename to src/commands/snapshots/config_help.snap index 4981eb1..4501a9e 100644 --- a/src/bin/help/snapshots/help__tests__should_render_config_help_with_formatting.snap +++ b/src/commands/snapshots/config_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(config_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk config - sdk subcommand to edit the SDKMAN configuration file + sdk config sdk subcommand to edit the SDKMAN configuration file SYNOPSIS sdk config diff --git a/src/bin/help/snapshots/help__tests__should_render_current_help_with_formatting.snap b/src/commands/snapshots/current_help.snap similarity index 75% rename from src/bin/help/snapshots/help__tests__should_render_current_help_with_formatting.snap rename to src/commands/snapshots/current_help.snap index d08e42f..6d6d51d 100644 --- a/src/bin/help/snapshots/help__tests__should_render_current_help_with_formatting.snap +++ b/src/commands/snapshots/current_help.snap @@ -1,10 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(current_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk current - sdk subcommand to display the current default installed - versions + sdk current sdk subcommand to display the current default installed versions SYNOPSIS sdk current [candidate] diff --git a/src/bin/help/snapshots/help__tests__should_render_default_help_with_formatting.snap b/src/commands/snapshots/default_help.snap similarity index 82% rename from src/bin/help/snapshots/help__tests__should_render_default_help_with_formatting.snap rename to src/commands/snapshots/default_help.snap index 700258e..6f5be18 100644 --- a/src/bin/help/snapshots/help__tests__should_render_default_help_with_formatting.snap +++ b/src/commands/snapshots/default_help.snap @@ -1,10 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(default_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk default - sdk subcommand to set the local default version of the - candidate + sdk default sdk subcommand to set the local default version of the candidate SYNOPSIS sdk default [version] diff --git a/src/bin/help/snapshots/help__tests__should_render_env_help_with_formatting.snap b/src/commands/snapshots/env_help.snap similarity index 92% rename from src/bin/help/snapshots/help__tests__should_render_env_help_with_formatting.snap rename to src/commands/snapshots/env_help.snap index 9e74ff9..5f74d98 100644 --- a/src/bin/help/snapshots/help__tests__should_render_env_help_with_formatting.snap +++ b/src/commands/snapshots/env_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(env_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk env - sdk subcommand to control SDKs on a project level, setting up + sdk env sdk subcommand to control SDKs on a project level, setting up specific versions for a directory SYNOPSIS diff --git a/src/bin/help/snapshots/help__tests__should_render_flush_help_with_formatting.snap b/src/commands/snapshots/flush_help.snap similarity index 85% rename from src/bin/help/snapshots/help__tests__should_render_flush_help_with_formatting.snap rename to src/commands/snapshots/flush_help.snap index 64ef40e..2309655 100644 --- a/src/bin/help/snapshots/help__tests__should_render_flush_help_with_formatting.snap +++ b/src/commands/snapshots/flush_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(flush_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk flush - sdk subcommand used for flushing local temporal state of SDKMAN + sdk flush sdk subcommand used for flushing local temporal state of SDKMAN SYNOPSIS sdk flush [tmp|metadata|version] diff --git a/src/bin/help/snapshots/help__tests__should_render_home_help_with_formatting.snap b/src/commands/snapshots/home_help.snap similarity index 77% rename from src/bin/help/snapshots/help__tests__should_render_home_help_with_formatting.snap rename to src/commands/snapshots/home_help.snap index 17035f6..931c1ef 100644 --- a/src/bin/help/snapshots/help__tests__should_render_home_help_with_formatting.snap +++ b/src/commands/snapshots/home_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(home_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk home - sdk subcommand to output the path of a specific candidate version + sdk home sdk subcommand to output the path of a specific candidate version SYNOPSIS sdk home  diff --git a/src/bin/help/snapshots/help__tests__should_render_install_help_with_formatting.snap b/src/commands/snapshots/install_help.snap similarity index 88% rename from src/bin/help/snapshots/help__tests__should_render_install_help_with_formatting.snap rename to src/commands/snapshots/install_help.snap index cd02db0..0b5e26c 100644 --- a/src/bin/help/snapshots/help__tests__should_render_install_help_with_formatting.snap +++ b/src/commands/snapshots/install_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(install_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk install - sdk subcommand to install a candidate version + sdk install sdk subcommand to install a candidate version SYNOPSIS sdk install [version] [path] diff --git a/src/bin/help/snapshots/help__tests__should_render_list_help_with_formatting.snap b/src/commands/snapshots/list_help.snap similarity index 85% rename from src/bin/help/snapshots/help__tests__should_render_list_help_with_formatting.snap rename to src/commands/snapshots/list_help.snap index 840b411..0d230e0 100644 --- a/src/bin/help/snapshots/help__tests__should_render_list_help_with_formatting.snap +++ b/src/commands/snapshots/list_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(list_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk list - sdk subcommand to list all candidates or candidate versions + sdk list sdk subcommand to list all candidates or candidate versions SYNOPSIS sdk list [candidate] diff --git a/src/bin/help/snapshots/help__tests__should_render_main_help_with_formatting.snap b/src/commands/snapshots/main_help.snap similarity index 91% rename from src/bin/help/snapshots/help__tests__should_render_main_help_with_formatting.snap rename to src/commands/snapshots/main_help.snap index b0a1451..c1b2703 100644 --- a/src/bin/help/snapshots/help__tests__should_render_main_help_with_formatting.snap +++ b/src/commands/snapshots/main_help.snap @@ -1,7 +1,8 @@ --- -source: src/bin/help/main.rs -expression: render(main_help()) +source: src/commands/help.rs +expression: rendered --- + NAME sdk - The command line interface (CLI) for SDKMAN! @@ -27,7 +28,6 @@ expression: render(main_help()) current [candidate] upgrade [candidate] version no qualifier - offline [enable|disable] selfupdate [force] update no qualifier flush [tmp|metadata|version] diff --git a/src/bin/help/snapshots/help__tests__should_render_selfupdate_help_with_formatting.snap b/src/commands/snapshots/selfupdate_help.snap similarity index 79% rename from src/bin/help/snapshots/help__tests__should_render_selfupdate_help_with_formatting.snap rename to src/commands/snapshots/selfupdate_help.snap index 65adbd4..07d5ba7 100644 --- a/src/bin/help/snapshots/help__tests__should_render_selfupdate_help_with_formatting.snap +++ b/src/commands/snapshots/selfupdate_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(selfupdate_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk selfupdate - sdk subcommand to upgrade the SDKMAN core + sdk selfupdate sdk subcommand to upgrade the SDKMAN core SYNOPSIS sdk selfupdate [force] diff --git a/src/bin/help/snapshots/help__tests__should_render_uninstall_help_with_formatting.snap b/src/commands/snapshots/uninstall_help.snap similarity index 82% rename from src/bin/help/snapshots/help__tests__should_render_uninstall_help_with_formatting.snap rename to src/commands/snapshots/uninstall_help.snap index ca5b541..6095491 100644 --- a/src/bin/help/snapshots/help__tests__should_render_uninstall_help_with_formatting.snap +++ b/src/commands/snapshots/uninstall_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(uninstall_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk uninstall - sdk subcommand to uninstall a candidate version + sdk uninstall sdk subcommand to uninstall a candidate version SYNOPSIS sdk uninstall  diff --git a/src/bin/help/snapshots/help__tests__should_render_update_help_with_formatting.snap b/src/commands/snapshots/update_help.snap similarity index 78% rename from src/bin/help/snapshots/help__tests__should_render_update_help_with_formatting.snap rename to src/commands/snapshots/update_help.snap index 078127a..14bd23f 100644 --- a/src/bin/help/snapshots/help__tests__should_render_update_help_with_formatting.snap +++ b/src/commands/snapshots/update_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(update_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk update - sdk subcommand to update the local state of SDKMAN + sdk update sdk subcommand to update the local state of SDKMAN SYNOPSIS sdk update diff --git a/src/bin/help/snapshots/help__tests__should_render_upgrade_help_with_formatting.snap b/src/commands/snapshots/upgrade_help.snap similarity index 83% rename from src/bin/help/snapshots/help__tests__should_render_upgrade_help_with_formatting.snap rename to src/commands/snapshots/upgrade_help.snap index 1315017..6258872 100644 --- a/src/bin/help/snapshots/help__tests__should_render_upgrade_help_with_formatting.snap +++ b/src/commands/snapshots/upgrade_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(upgrade_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk upgrade - sdk subcommand to upgrade installed candidate versions + sdk upgrade sdk subcommand to upgrade installed candidate versions SYNOPSIS sdk upgrade [candidate] diff --git a/src/bin/help/snapshots/help__tests__should_render_use_help_with_formatting.snap b/src/commands/snapshots/use_help.snap similarity index 82% rename from src/bin/help/snapshots/help__tests__should_render_use_help_with_formatting.snap rename to src/commands/snapshots/use_help.snap index 9235ed0..9c337bb 100644 --- a/src/bin/help/snapshots/help__tests__should_render_use_help_with_formatting.snap +++ b/src/commands/snapshots/use_help.snap @@ -1,9 +1,10 @@ --- -source: src/bin/help/main.rs -expression: render(use_help()) +source: src/commands/help.rs +expression: rendered --- + NAME - sdk use - sdk subcommand to use a specific version only in the current shell + sdk use sdk subcommand to use a specific version only in the current shell SYNOPSIS sdk use  diff --git a/src/commands/snapshots/version_help.snap b/src/commands/snapshots/version_help.snap new file mode 100644 index 0000000..797e4b6 --- /dev/null +++ b/src/commands/snapshots/version_help.snap @@ -0,0 +1,21 @@ +--- +source: src/commands/help.rs +expression: rendered +--- + +NAME + sdk version sdk subcommand to display the installed SDKMAN version + +SYNOPSIS + sdk version + +DESCRIPTION + This subcommand displays the version of the bash and native components of + SDKMAN on this system. The versions of the bash and native libraries evolve + independently from each other and so will not be in sync. + +MNEMONIC + v - may be used in place of the version subcommand. + +EXAMPLES + sdk version diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs new file mode 100644 index 0000000..98c0445 --- /dev/null +++ b/src/commands/uninstall.rs @@ -0,0 +1,124 @@ +//! `sdk uninstall` command. +//! +//! Removes an installed candidate version from `$SDKMAN_DIR/candidates//`. +//! If the target version is currently selected via the `current` link, removal is blocked unless +//! `--force` is provided. +//! +//! ## Flags +//! - `-f, --force`: remove even if the target is the current version (may leave the candidate unusable). +//! +//! ## Examples +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["uninstall", "java", "17.0.0-tem"]) +//! .status() +//! .unwrap(); +//! ``` +//! +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["uninstall", "java", "17.0.0-tem", "--force"]) +//! .status() +//! .unwrap(); +//! ``` + +use crate::utils::{ + constants::{CANDIDATES_DIR, CURRENT_DIR}, + directory_utils::infer_sdkman_dir, + helpers::{known_candidates, validate_candidate, validate_version_path}, +}; +use colored::Colorize; +use std::{fs, fs::remove_dir_all}; +use symlink::remove_symlink_dir; + +/// Arguments for `sdk uninstall`. +#[derive(clap::Args, Debug)] +#[command(about = "Remove a specific candidate version")] +pub struct Args { + /// Remove even if this version is currently selected (may leave the candidate unusable). + #[arg(short = 'f', long = "force")] + pub force: bool, + + /// Candidate name (e.g. `java`). + #[arg(required = true)] + pub candidate: String, + + /// Candidate version to remove (e.g. `17.0.0-tem`). + #[arg(required = true)] + pub version: String, +} + +/// Run `sdk uninstall`. +/// +/// Returns `Ok(())` on success, or an exit code (`Err(code)`) on failure. +/// +/// Behavior notes: +/// - Validates the candidate against the local candidates list. +/// - Refuses to remove the target if it is the `current` version unless `--force` is set. +/// - Attempts to remove the `current` symlink first (when forced), falling back to removing a +/// real directory if `current` is not a symlink. +pub fn run(args: Args) -> Result<(), i32> { + let sdkman_dir = infer_sdkman_dir().map_err(|e| { + eprintln!("failed to infer SDKMAN_DIR: {e}"); + 1 + })?; + + let candidate = validate_candidate(&known_candidates(&sdkman_dir), &args.candidate); + + let candidate_path = sdkman_dir.join(CANDIDATES_DIR).join(&candidate); + let version_path = validate_version_path(&sdkman_dir, &candidate, &args.version); + let current_link_path = candidate_path.join(CURRENT_DIR); + + // if "current" points at the version we’re removing, enforce --force + if current_link_path.is_dir() { + match fs::read_link(¤t_link_path) { + Ok(relative_target) => { + let resolved_link_path = candidate_path.join(relative_target); + + if version_path == resolved_link_path && args.force { + // remove the current symlink; fall back to removing a directory if needed + remove_symlink_dir(¤t_link_path).unwrap_or_else(|_| { + remove_dir_all(¤t_link_path).unwrap_or_else(|e| { + eprintln!( + "cannot remove current directory for {}: {}", + candidate.bold(), + e + ); + std::process::exit(1); + }) + }); + } else if version_path == resolved_link_path && !args.force { + eprintln!( + "\n{} {} is the {} version and should not be removed.", + candidate.bold(), + args.version.bold(), + "current".italic(), + ); + eprintln!( + "\nOverride with {}, but leaves the candidate unusable!", + "--force".italic() + ); + return Err(1); + } + } + Err(e) => { + eprintln!("current link broken, stepping over: {}", e); + } + } + } + + remove_dir_all(&version_path) + .map(|_| println!("removed {} {}.", candidate.bold(), args.version.bold())) + .map_err(|e| { + eprintln!( + "could not delete directory {}: {}", + version_path.display(), + e + ); + 1 + })?; + + Ok(()) +} diff --git a/src/commands/version.rs b/src/commands/version.rs new file mode 100644 index 0000000..cb53cf9 --- /dev/null +++ b/src/commands/version.rs @@ -0,0 +1,80 @@ +//! `sdk version`. +//! +//! Shows the installed SDKMAN! script version (from `$SDKMAN_DIR/var/version`) and the +//! native CLI version (from `CARGO_PKG_VERSION`), plus the current platform. +//! +//! ## Flags +//! - `--native-only`: print only the native binary version. +//! +//! ## Examples +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk").arg("version").status().unwrap(); +//! ``` +//! +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["version", "--native-only"]) +//! .status() +//! .unwrap(); +//! ``` + +use crate::utils::{ + constants::VAR_DIR, + directory_utils::infer_sdkman_dir, + file_utils::{check_file_exists, read_file_content}, +}; +use colored::Colorize; +use std::env::consts::{ARCH, OS}; + +const CLI_VERSION_FILE: &str = "version"; +const NATIVE_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Arguments for `sdk version`. +#[derive(clap::Args, Debug)] +#[command(about = "Display the installed SDKMAN! version (script + native)")] +pub struct Args { + /// Print only the native binary version. + #[arg(long)] + pub native_only: bool, +} + +/// Run `sdk version`. +/// +/// Returns `Ok(())` on success, or an exit code (`Err(code)`) on failure. +pub fn run(args: Args) -> Result<(), i32> { + if args.native_only { + println!("{NATIVE_VERSION}"); + return Ok(()); + } + + let sdkman_dir = infer_sdkman_dir().map_err(|e| { + eprintln!("failed to infer SDKMAN_DIR: {e}"); + 1 + })?; + + let cli_version_path = sdkman_dir.join(VAR_DIR).join(CLI_VERSION_FILE); + + let cli_version = check_file_exists(&cli_version_path) + .and_then(read_file_content) + .map_err(|e| { + eprintln!( + "failed to read SDKMAN! script version at {}: {}", + cli_version_path.display(), + e + ); + 1 + })?; + + println!( + "\n{}\nscript: {}\nnative: {} ({} {})\n", + "SDKMAN!".bold().yellow(), + cli_version, + NATIVE_VERSION, + OS, + ARCH + ); + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index e4a5dca..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,140 +0,0 @@ -pub mod constants { - pub const CANDIDATES_DIR: &str = "candidates"; - pub const CANDIDATES_FILE: &str = "candidates"; - pub const CURRENT_DIR: &str = "current"; - pub const DEFAULT_SDKMAN_HOME: &str = ".sdkman"; - pub const SDKMAN_DIR_ENV_VAR: &str = "SDKMAN_DIR"; - pub const TMP_DIR: &str = "tmp"; - pub const VAR_DIR: &str = "var"; -} - -pub mod helpers { - use colored::Colorize; - use directories::UserDirs; - use std::path::PathBuf; - use std::{env, fs, process}; - - use crate::constants::{ - CANDIDATES_DIR, CANDIDATES_FILE, DEFAULT_SDKMAN_HOME, SDKMAN_DIR_ENV_VAR, VAR_DIR, - }; - - pub fn infer_sdkman_dir() -> PathBuf { - match env::var(SDKMAN_DIR_ENV_VAR) { - Ok(s) => PathBuf::from(s), - Err(_) => fallback_sdkman_dir(), - } - } - - fn fallback_sdkman_dir() -> PathBuf { - UserDirs::new() - .map(|dir| dir.home_dir().join(DEFAULT_SDKMAN_HOME)) - .unwrap() - } - - pub fn check_file_exists(path: PathBuf) -> PathBuf { - if path.exists() && path.is_file() { - path - } else { - panic!("not a valid path: {}", path.to_str().unwrap()) - } - } - - pub fn read_file_content(path: PathBuf) -> Option { - match fs::read_to_string(path) { - Ok(s) => Some(s), - Err(_) => None, - } - .filter(|s| !s.trim().is_empty()) - .map(|s| s.trim().to_string()) - } - - pub fn known_candidates<'a>(sdkman_dir: PathBuf) -> Vec<&'static str> { - let absolute_path = sdkman_dir.join(VAR_DIR).join(CANDIDATES_FILE); - let verified_path = check_file_exists(absolute_path); - let panic = format!( - "the candidates file is missing: {}", - verified_path.to_str().unwrap() - ); - let content = read_file_content(verified_path).expect(&panic); - let line_str: &'static str = Box::leak(content.into_boxed_str()); - let mut fields = Vec::new(); - for field in line_str.split(',') { - fields.push(field.trim()); - } - - fields - } - - pub fn validate_candidate(all_candidates: Vec<&str>, candidate: &str) -> String { - if !all_candidates.contains(&candidate) { - eprintln!("{} is not a valid candidate.", candidate.bold()); - process::exit(1); - } else { - candidate.to_string() - } - } - - pub fn validate_version_path(base_dir: PathBuf, candidate: &str, version: &str) -> PathBuf { - let version_path = base_dir.join(CANDIDATES_DIR).join(candidate).join(version); - if version_path.exists() && version_path.is_dir() { - version_path - } else { - eprintln!( - "{} {} is not installed on your system", - candidate.bold(), - version.bold() - ); - process::exit(1) - } - } -} - -#[cfg(test)] -mod tests { - use std::env; - use std::io::Write; - use std::path::PathBuf; - - use serial_test::serial; - use tempfile::NamedTempFile; - - use crate::constants::SDKMAN_DIR_ENV_VAR; - use crate::helpers::infer_sdkman_dir; - use crate::helpers::read_file_content; - - #[test] - #[serial] - fn should_infer_sdkman_dir_from_env_var() { - let sdkman_dir = PathBuf::from("/home/someone/.sdkman"); - env::set_var(SDKMAN_DIR_ENV_VAR, sdkman_dir.to_owned()); - assert_eq!(sdkman_dir, infer_sdkman_dir()); - } - - #[test] - #[serial] - fn should_infer_fallback_dir() { - env::remove_var(SDKMAN_DIR_ENV_VAR); - let actual_sdkman_dir = dirs::home_dir().unwrap().join(".sdkman"); - assert_eq!(actual_sdkman_dir, infer_sdkman_dir()); - } - - #[test] - #[serial] - fn should_read_content_from_file() { - let expected_version = "5.0.0"; - let mut file = NamedTempFile::new().unwrap(); - file.write(expected_version.as_bytes()).unwrap(); - let path = file.path().to_path_buf(); - let maybe_version = read_file_content(path); - assert_eq!(maybe_version, Some(expected_version.to_string())); - } - - #[test] - #[serial] - fn should_fail_reading_file_content_from_empty_file() { - let file = NamedTempFile::new().unwrap(); - let path = file.path().to_path_buf(); - let maybe_version = read_file_content(path); - assert_eq!(maybe_version, None); - } -} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..37c5e90 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,115 @@ +//! `sdk` — a native CLI companion for SDKMAN!. +//! +//! A small, fast command-line interface that reads the local SDKMAN! directory +//! layout (typically `$SDKMAN_DIR`) and implements a subset of `sdk` commands in Rust. +//! +//! ## Commands +//! - `sdk help [subcommand]` +//! - `sdk version [--native-only]` +//! - `sdk default ` +//! - `sdk current [candidate]` +//! - `sdk uninstall [--force]` +//! - `sdk home ` +//! +//! ## Doctest examples +//! +//! Print the script + native versions (reads `$SDKMAN_DIR/var/version`): +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .arg("version") +//! .status() +//! .expect("failed to run sdk"); +//! ``` +//! +//! Print only the native binary version: +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["version", "--native-only"]) +//! .status() +//! .expect("failed to run sdk"); +//! ``` +//! +//! Set a default version (updates `$SDKMAN_DIR/candidates//current`): +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["default", "java", "17.0.0-tem"]) +//! .status() +//! .expect("failed to run sdk"); +//! ``` +//! +//! Query current version for one candidate: +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .args(["current", "java"]) +//! .status() +//! .expect("failed to run sdk"); +//! ``` +//! +//! Use a specific SDKMAN directory for a single call: +//! ```no_run +//! # use std::process::Command; +//! Command::new("sdk") +//! .env("SDKMAN_DIR", "/home/me/.sdkman") +//! .args(["home", "kotlin", "1.9.24"]) +//! .status() +//! .expect("failed to run sdk"); +//! ``` +mod commands; +mod utils; + +use clap::{Parser, Subcommand}; +use std::process::exit; + +/// Top-level CLI parser for the `sdk` binary. +#[derive(Parser, Debug)] +#[command( + name = "sdk", + about = "The command line interface (CLI) for SDKMAN!", + version, + propagate_version = true, + disable_version_flag = true, + disable_help_subcommand = true +)] +struct Cli { + /// The subcommand to execute. + #[command(subcommand)] + command: Commands, +} + +/// Supported `sdk` subcommands. +#[derive(Subcommand, Debug)] +enum Commands { + /// Show detailed help for a subcommand. + Help(commands::help::Args), + /// Display the installed SDKMAN! version (script + native). + Version(commands::version::Args), + /// Set the local default version of a candidate. + Default(commands::default::Args), + /// Display the current version in use for one or all candidates. + Current(commands::current::Args), + /// Remove a specific candidate version. + Uninstall(commands::uninstall::Args), + /// Output the absolute path of a specific candidate version. + Home(commands::home::Args), +} + +fn main() { + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Version(args) => commands::version::run(args), + Commands::Help(args) => commands::help::run(args), + Commands::Default(args) => commands::default::run(args), + Commands::Current(args) => commands::current::run(args), + Commands::Uninstall(args) => commands::uninstall::run(args), + Commands::Home(args) => commands::home::run(args), + }; + + if let Err(code) = result { + exit(code); + } +} diff --git a/src/utils/constants.rs b/src/utils/constants.rs new file mode 100644 index 0000000..e48864c --- /dev/null +++ b/src/utils/constants.rs @@ -0,0 +1,19 @@ +/// Directory containing installed candidates. +pub const CANDIDATES_DIR: &str = "candidates"; +/// Alias kept for semantic clarity. +pub const CANDIDATES_FILE: &str = CANDIDATES_DIR; + +/// Directory name for "current" symlinks. +pub const CURRENT_DIR: &str = "current"; + +/// Default SDKMAN home directory (relative to home). +pub const DEFAULT_SDKMAN_HOME: &str = ".sdkman"; + +/// Environment variable that overrides SDKMAN home. +pub const SDKMAN_DIR: &str = "SDKMAN_DIR"; + +/// Temporary directory name. +pub const TMP_DIR: &str = "tmp"; + +/// Var directory name. +pub const VAR_DIR: &str = "var"; diff --git a/src/utils/directory_utils.rs b/src/utils/directory_utils.rs new file mode 100644 index 0000000..cbf543f --- /dev/null +++ b/src/utils/directory_utils.rs @@ -0,0 +1,258 @@ +//! sdkman — small, opinionated utilities for working with SDKMAN-style directories. +//! +//! This module provides helpers to: +//! - infer the SDKMAN home directory (via `SDKMAN_DIR` or a home-based fallback) +//! - resolve the default SDKMAN directory in a cross-platform way + +use super::{ + constants::{DEFAULT_SDKMAN_HOME, SDKMAN_DIR}, + PathBuf, +}; +use directories::UserDirs; +use std::env; + +/// Attempts to determine the SDKMAN directory. +/// +/// If the environment variable [`SDKMAN_DIR`] is set (and is valid Unicode), +/// its value is used. Otherwise, falls back to [`fallback_sdkman_dir`]. +/// +/// # Examples +/// +/// ```no_run +/// use std::env; +/// use sdkman::utils::constants::SDKMAN_DIR; +/// use sdkman::utils::directory_utils::infer_sdkman_dir; +/// +/// let tmp = tempfile::TempDir::new().unwrap(); +/// unsafe { env::set_var(SDKMAN_DIR, tmp.path()); } +/// +/// let dir = infer_sdkman_dir().unwrap(); +/// assert_eq!(dir, tmp.path().to_path_buf()); +/// +/// unsafe { env::remove_var(SDKMAN_DIR); } +/// ``` +pub fn infer_sdkman_dir() -> Result { + env::var(SDKMAN_DIR) + .map(PathBuf::from) + // NotPresent / NotUnicode => fallback + .or_else(|_| Ok(fallback_sdkman_dir())) +} + +/// Returns the default SDKMAN directory. +/// +/// Resolution order: +/// - Windows: `USERPROFILE`, then `HOMEDRIVE`+`HOMEPATH`, then `HOME` +/// - Unix: `HOME` +/// - Fallback: `directories::UserDirs` +/// +/// This is intentionally env-first so CI and tests that override home behave +/// predictably across platforms. +/// +/// # Examples +/// +/// ```no_run +/// use sdkman::utils::directory_utils::fallback_sdkman_dir; +/// let dir = fallback_sdkman_dir(); +/// assert!(dir.ends_with(".sdkman")); +/// ``` +#[doc(hidden)] +pub fn fallback_sdkman_dir() -> PathBuf { + if let Some(home) = env_home_dir() { + return home.join(DEFAULT_SDKMAN_HOME); + } + + UserDirs::new() + .map(|dir| dir.home_dir().join(DEFAULT_SDKMAN_HOME)) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SDKMAN_HOME)) +} + +fn env_home_dir() -> Option { + #[cfg(windows)] + { + if let Ok(p) = env::var("USERPROFILE") { + if !p.is_empty() { + return Some(PathBuf::from(p)); + } + } + + if let (Ok(drive), Ok(path)) = (env::var("HOMEDRIVE"), env::var("HOMEPATH")) { + if !drive.is_empty() && !path.is_empty() { + return Some(PathBuf::from(format!("{drive}{path}"))); + } + } + + // MSYS/Git-Bash sometimes sets HOME on Windows + if let Ok(p) = env::var("HOME") { + if !p.is_empty() { + return Some(PathBuf::from(p)); + } + } + + None + } + + #[cfg(not(windows))] + { + if let Ok(p) = env::var("HOME") { + if !p.is_empty() { + return Some(PathBuf::from(p)); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::{fallback_sdkman_dir, infer_sdkman_dir, DEFAULT_SDKMAN_HOME, SDKMAN_DIR}; + use rstest::rstest; + use std::{ + env::{remove_var, set_var, var_os}, + ffi::{OsStr, OsString}, + sync::{Mutex, MutexGuard, OnceLock}, + }; + use tempfile::TempDir; + + // --- global env lock --- + static ENV_LOCK: OnceLock> = OnceLock::new(); + + fn lock_env() -> MutexGuard<'static, ()> { + match ENV_LOCK.get_or_init(|| Mutex::new(())).lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), // recover after a panic + } + } + + // --- RAII guard to set/remove env vars --- + struct ScopedEnvVar { + key: &'static str, + old: Option, + } + + impl ScopedEnvVar { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old = var_os(key); + unsafe { set_var(key, value) }; + Self { key, old } + } + + fn remove(key: &'static str) -> Self { + let old = var_os(key); + unsafe { remove_var(key) }; + Self { key, old } + } + } + + impl Drop for ScopedEnvVar { + fn drop(&mut self) { + unsafe { + match self.old.take() { + Some(v) => set_var(self.key, v), + None => remove_var(self.key), + } + } + } + } + + // helper: what fallback expects as "home" given our precedence + fn expected_env_home() -> Option { + #[cfg(windows)] + { + if let Some(v) = var_os("USERPROFILE") { + if !v.is_empty() { + return Some(v.into()); + } + } + let drive = var_os("HOMEDRIVE"); + let path = var_os("HOMEPATH"); + if let (Some(d), Some(p)) = (drive, path) { + if !d.is_empty() && !p.is_empty() { + return Some(std::path::PathBuf::from(format!( + "{}{}", + d.to_string_lossy(), + p.to_string_lossy() + ))); + } + } + if let Some(v) = var_os("HOME") { + if !v.is_empty() { + return Some(v.into()); + } + } + None + } + + #[cfg(not(windows))] + { + var_os("HOME").filter(|v| !v.is_empty()).map(Into::into) + } + } + + #[test] + fn fallback_is_home_join_default() { + let _guard = lock_env(); + + // This test matches the implementation: env-home (if present) wins. + if let Some(home) = expected_env_home() { + assert_eq!(fallback_sdkman_dir(), home.join(DEFAULT_SDKMAN_HOME)); + } else { + // no env home: last component should still be DEFAULT_SDKMAN_HOME + assert!(fallback_sdkman_dir().ends_with(DEFAULT_SDKMAN_HOME)); + } + } + + #[test] + fn fallback_respects_overridden_home_dir() { + let _guard = lock_env(); + + #[cfg(windows)] + const HOME_KEY: &str = "USERPROFILE"; + #[cfg(not(windows))] + const HOME_KEY: &str = "HOME"; + + let temp_home = TempDir::new().unwrap(); + let _home = ScopedEnvVar::set(HOME_KEY, temp_home.path()); + + let expected = temp_home.path().join(DEFAULT_SDKMAN_HOME); + assert_eq!(fallback_sdkman_dir(), expected); + } + + #[rstest] + #[case::env_set(true)] + #[case::env_unset(false)] + fn infer_sdkman_dir_env_cases(#[case] env_set: bool) { + let _guard = lock_env(); + + if env_set { + let temp_dir = TempDir::new().unwrap(); + let expected = temp_dir.path().to_path_buf(); + let _sdkman = ScopedEnvVar::set(SDKMAN_DIR, temp_dir.path()); + + let got = infer_sdkman_dir().unwrap(); + assert_eq!(got, expected); + } else { + let _sdkman = ScopedEnvVar::remove(SDKMAN_DIR); + + let got = infer_sdkman_dir().unwrap(); + assert_eq!(got, fallback_sdkman_dir()); + } + } + + // Non-unicode env var is only meaningful on Unix. + #[cfg(unix)] + mod unix_only { + use super::*; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn infer_falls_back_when_env_is_not_unicode() { + let _guard = lock_env(); + + let invalid = OsString::from_vec(vec![0xFF, 0xFE, 0xFD]); + let _sdkman = ScopedEnvVar::set(SDKMAN_DIR, invalid); + + let got = infer_sdkman_dir().unwrap(); + assert_eq!(got, fallback_sdkman_dir()); + } + } +} diff --git a/src/utils/file_utils.rs b/src/utils/file_utils.rs new file mode 100644 index 0000000..387e1ae --- /dev/null +++ b/src/utils/file_utils.rs @@ -0,0 +1,310 @@ +//! File utilities for common path validation and file content helpers. +//! +//! This file provides small, opinionated helpers around common filesystem tasks. +//! +//! # Examples +//! ```rust +//! use std::io; +//! use tempfile::NamedTempFile; +//! use sdkman::utils::file_utils::{check_file_exists, read_file_content}; +//! +//! # fn main() -> io::Result<()> { +//! let mut file = NamedTempFile::new()?; +//! std::io::Write::write_all(&mut file, b"5.9.0\n")?; +//! +//! let path = check_file_exists(file.path())?; +//! let version = read_file_content(&path)?; +//! assert_eq!(version, "5.9.0"); +//! # Ok(()) +//! # } +//! ``` +use super::PathBuf; +use std::{ + fs, + io::{Error, ErrorKind}, + path::Path, +}; + +/// Checks whether the given path exists and is a regular file. +/// +/// # Examples +/// +/// ```rust +/// use std::io; +/// use std::path::Path; +/// use tempfile::NamedTempFile; +/// use sdkman::utils::file_utils::check_file_exists; +/// +/// # fn main() -> io::Result<()> { +/// let file = NamedTempFile::new()?; +/// let path = file.path(); +/// +/// // Should succeed +/// let validated = check_file_exists(path)?; +/// assert_eq!(validated, path.to_path_buf()); +/// +/// // Should fail for a non-existent file +/// let bad_path = Path::new("does_not_exist.txt"); +/// let err = check_file_exists(bad_path).unwrap_err(); +/// assert_eq!(err.kind(), io::ErrorKind::NotFound); +/// # Ok(()) +/// # } +/// ``` +pub fn check_file_exists>(path: P) -> Result { + let path_buf = path.as_ref().to_path_buf(); + if path_buf.exists() && path_buf.is_file() { + Ok(path_buf) + } else { + Err(Error::new( + ErrorKind::NotFound, + format!("Not a valid file path: {}", path_buf.display()), + )) + } +} + +/// Reads the contents of a file, trimming whitespace, and returns an error +/// if the file is empty. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{self, Write}; +/// use tempfile::NamedTempFile; +/// use sdkman::utils::file_utils::read_file_content; +/// +/// # fn main() -> io::Result<()> { +/// // Write some data to a temp file +/// let mut file = NamedTempFile::new()?; +/// writeln!(file, "5.9.0")?; +/// +/// // Read it back +/// let version = read_file_content(file.path())?; +/// assert_eq!(version, "5.9.0"); +/// +/// // Empty file should return InvalidData error +/// let empty_file = NamedTempFile::new()?; +/// let err = read_file_content(empty_file.path()).unwrap_err(); +/// assert_eq!(err.kind(), io::ErrorKind::InvalidData); +/// # Ok(()) +/// # } +/// ``` +pub fn read_file_content>(path: P) -> Result { + let content = fs::read_to_string(path)?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { + Err(Error::new(ErrorKind::InvalidData, "File is empty")) + } else { + Ok(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::{check_file_exists, read_file_content}; + use rstest::rstest; + use std::{ + io::{Error, ErrorKind, Result, Write}, + path::{Path, PathBuf}, + }; + use tempfile::{tempdir, NamedTempFile}; + + #[cfg(unix)] + use std::os::unix::fs as unix_fs; + + #[cfg(windows)] + use std::os::windows::fs as windows_fs; + + fn assert_kind_is_one_of(err: &Error, kinds: &[ErrorKind]) { + let got = err.kind(); + assert!( + kinds.contains(&got), + "expected error kind to be one of {:?}, got {:?} (err: {})", + kinds, + got, + err + ); + } + + // file existence checks + enum PathInput<'a> { + PathRef(&'a Path), + PathBuf(PathBuf), + Str(&'a str), + } + + type MakeInput = for<'a> fn(&'a Path) -> PathInput<'a>; + + fn as_path_ref<'a>(p: &'a Path) -> PathInput<'a> { + PathInput::PathRef(p) + } + fn as_path_buf<'a>(p: &'a Path) -> PathInput<'a> { + PathInput::PathBuf(p.to_path_buf()) + } + fn as_str<'a>(p: &'a Path) -> PathInput<'a> { + PathInput::Str(p.to_str().expect("temp path should be valid UTF-8")) + } + + #[rstest] + #[case::path_ref(as_path_ref)] + #[case::path_buf(as_path_buf)] + #[case::str_path(as_str)] + fn check_file_exists_ok_param(#[case] make_input: MakeInput) -> Result<()> { + let file = NamedTempFile::new()?; + let expected = file.path().to_path_buf(); + + let input = make_input(file.path()); + let validated = match input { + PathInput::PathRef(p) => check_file_exists(p)?, + PathInput::PathBuf(pb) => check_file_exists(pb)?, + PathInput::Str(s) => check_file_exists(s)?, + }; + + assert_eq!(validated, expected); + Ok(()) + } + + #[derive(Clone, Copy, Debug)] + enum InvalidCheckPath { + MissingFile, + Directory, + } + + #[rstest] + #[case::missing_file(InvalidCheckPath::MissingFile)] + #[case::directory(InvalidCheckPath::Directory)] + fn check_file_exists_err_cases(#[case] kind: InvalidCheckPath) -> Result<()> { + let dir = tempdir()?; + let path = match kind { + InvalidCheckPath::MissingFile => dir.path().join("nope_this_file_should_not_exist"), + InvalidCheckPath::Directory => dir.path().to_path_buf(), + }; + + let err = check_file_exists(&path).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::NotFound); + assert!(err.to_string().contains("Not a valid file path")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn check_file_exists_ok_with_symlink_to_file() -> Result<()> { + let file = NamedTempFile::new()?; + let dir = tempdir()?; + let link_path = dir.path().join("link_to_file"); + + unix_fs::symlink(file.path(), &link_path)?; + + let validated = check_file_exists(&link_path)?; + assert_eq!(validated, link_path); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn check_file_exists_err_with_broken_symlink() -> Result<()> { + let dir = tempdir()?; + let target = dir.path().join("target_that_does_not_exist"); + let link = dir.path().join("broken_link"); + + unix_fs::symlink(&target, &link)?; + + let err = check_file_exists(&link).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::NotFound); + Ok(()) + } + + // On Windows, creating symlinks can require admin privileges or Developer Mode. + // So: attempt to create the symlink, and if it fails, skip the test. + #[cfg(windows)] + #[test] + fn check_file_exists_ok_with_symlink_to_file() -> Result<()> { + let file = NamedTempFile::new()?; + let dir = tempdir()?; + let link_path = dir.path().join("link_to_file"); + + if windows_fs::symlink_file(file.path(), &link_path).is_err() { + eprintln!("skipping symlink test (no permission / developer mode not enabled)"); + return Ok(()); + } + + let validated = check_file_exists(&link_path)?; + assert_eq!(validated, link_path); + Ok(()) + } + + #[cfg(windows)] + #[test] + fn check_file_exists_err_with_broken_symlink() -> Result<()> { + let dir = tempdir()?; + let target = dir.path().join("target_that_does_not_exist"); + let link = dir.path().join("broken_link"); + + if windows_fs::symlink_file(&target, &link).is_err() { + eprintln!("skipping symlink test (no permission / developer mode not enabled)"); + return Ok(()); + } + + let err = check_file_exists(&link).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::NotFound); + Ok(()) + } + + // read_file_content tests + #[rstest] + #[case::trims_newline("5.9.0\n", "5.9.0")] + #[case::trims_surrounding_ws(" hello world \n\n\t", "hello world")] + #[case::preserves_internal_ws(" a b c ", "a b c")] + #[case::unicode(" こんにちは世界 \n", "こんにちは世界")] + fn read_file_content_ok_cases(#[case] input: &str, #[case] expected: &str) -> Result<()> { + let mut file = NamedTempFile::new()?; + write!(file, "{input}")?; + + let content = read_file_content(file.path())?; + assert_eq!(content, expected); + Ok(()) + } + + #[rstest] + #[case::empty(None)] + #[case::whitespace_only(Some(" \n\t \r\n"))] + fn read_file_content_err_emptyish(#[case] contents: Option<&str>) -> Result<()> { + let mut file = NamedTempFile::new()?; + if let Some(s) = contents { + write!(file, "{s}")?; + } + + let err = read_file_content(file.path()).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert_eq!(err.to_string(), "File is empty"); + Ok(()) + } + + #[test] + fn read_file_content_err_missing_file() -> Result<()> { + let dir = tempdir()?; + let missing = dir.path().join("definitely_missing.txt"); + + let err = read_file_content(&missing).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::NotFound); + Ok(()) + } + + #[test] + fn read_file_content_err_when_path_is_directory() -> Result<()> { + let dir = tempdir()?; + let err = read_file_content(dir.path()).unwrap_err(); + + // OS/filesystem dependent. + // Windows can report PermissionDenied/Other for "is a directory" attempts. + assert_kind_is_one_of( + &err, + &[ + ErrorKind::IsADirectory, + ErrorKind::PermissionDenied, + ErrorKind::Other, + ], + ); + + Ok(()) + } +} diff --git a/src/utils/helpers.rs b/src/utils/helpers.rs new file mode 100644 index 0000000..3f85c5d --- /dev/null +++ b/src/utils/helpers.rs @@ -0,0 +1,273 @@ +//! SDKMAN helper utilities. +//! +//! These helpers are primarily intended for CLI flows: +//! - read the known candidates list from `$SDKMAN_DIR/var/candidates` +//! - validate a candidate name and installed version paths +//! +//! ## Error handling +//! These functions print a user-friendly error message and terminate the process +//! with exit code `1` when validation fails. If you need library-style error +//! handling, prefer implementing `try_*` variants that return `Result`. +use super::{ + constants::{CANDIDATES_DIR, CANDIDATES_FILE, VAR_DIR}, + file_utils::{check_file_exists, read_file_content}, + PathBuf, +}; +use colored::Colorize; +use std::{path::Path, process}; + +/// Reads and parses the known SDKMAN candidates from `$SDKMAN_DIR/var/candidates`. +/// +/// The candidates file is expected to be a comma-separated list (e.g. `"java, maven, gradle"`). +/// Whitespace around each entry is trimmed and empty entries are ignored. +/// +/// ## Error handling +/// If the candidates file is missing or unreadable, this function prints a message to stderr +/// and terminates the process with exit code `1`. +/// +/// # Examples +/// +/// ```rust +/// use std::io::Write; +/// use tempfile::TempDir; +/// +/// use sdkman::utils::constants::{VAR_DIR, CANDIDATES_FILE}; +/// use sdkman::utils::helpers::known_candidates; +/// +/// let sdkman_dir = TempDir::new().unwrap(); +/// let var_dir = sdkman_dir.path().join(VAR_DIR); +/// std::fs::create_dir_all(&var_dir).unwrap(); +/// +/// let candidates_path = var_dir.join(CANDIDATES_FILE); +/// let mut f = std::fs::File::create(&candidates_path).unwrap(); +/// writeln!(f, " java, maven, ,gradle ").unwrap(); +/// +/// let candidates = known_candidates(sdkman_dir.path()); +/// assert_eq!(candidates, vec!["java", "maven", "gradle"]); +/// ``` +pub fn known_candidates(sdkman_dir: impl AsRef) -> Vec { + let candidates_path = sdkman_dir.as_ref().join(VAR_DIR).join(CANDIDATES_FILE); + + let verified_path = check_file_exists(&candidates_path).unwrap_or_else(|e| { + eprintln!( + "{} {} ({})", + "the candidates file is missing:".red(), + candidates_path.display(), + e + ); + process::exit(1); + }); + + let content = read_file_content(&verified_path).unwrap_or_else(|e| { + eprintln!( + "{} {} ({})", + "failed to read candidates file:".red(), + verified_path.display(), + e + ); + process::exit(1); + }); + + content + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Validates that `candidate` exists in `all_candidates`. +/// +/// Returns `candidate` as an owned `String` on success. +/// +/// ## Error handling +/// If `candidate` is not present in `all_candidates`, this function prints an error to stderr +/// and terminates the process with exit code `1`. +/// +/// # Examples +/// +/// ```rust +/// use sdkman::utils::helpers::validate_candidate; +/// +/// let all = vec!["java".to_string(), "maven".to_string()]; +/// +/// let ok = validate_candidate(&all, "java"); +/// assert_eq!(ok, "java"); +/// ``` +pub fn validate_candidate(all_candidates: &[String], candidate: &str) -> String { + let ok = all_candidates.iter().any(|c| c == candidate); + if !ok { + eprintln!("{} is not a valid candidate.", candidate.bold()); + process::exit(1); + } + candidate.to_string() +} + +/// Validates that a version directory exists under +/// `$SDKMAN_DIR/candidates//`. +/// +/// Returns the resolved version path on success. +/// +/// ## Error handling +/// If the version directory does not exist, this function prints an error to stderr +/// and terminates the process with exit code `1`. +/// +/// # Examples +/// +/// ```rust +/// use tempfile::TempDir; +/// +/// use sdkman::utils::constants::CANDIDATES_DIR; +/// use sdkman::utils::helpers::validate_version_path; +/// +/// let sdkman_dir = TempDir::new().unwrap(); +/// let version_dir = sdkman_dir +/// .path() +/// .join(CANDIDATES_DIR) +/// .join("java") +/// .join("17.0.9-tem"); +/// std::fs::create_dir_all(&version_dir).unwrap(); +/// +/// let got = validate_version_path(sdkman_dir.path(), "java", "17.0.9-tem"); +/// assert_eq!(got, version_dir); +/// ``` +pub fn validate_version_path( + base_dir: impl AsRef, + candidate: &str, + version: &str, +) -> PathBuf { + let version_path = base_dir + .as_ref() + .join(CANDIDATES_DIR) + .join(candidate) + .join(version); + + if version_path.is_dir() { + version_path + } else { + eprintln!( + "{} {} is not installed on your system", + candidate.bold(), + version.bold() + ); + process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::{ + super::constants::{CANDIDATES_DIR, CANDIDATES_FILE, VAR_DIR}, + known_candidates, validate_candidate, validate_version_path, + }; + use rstest::rstest; + use std::{io::Write, process::Command}; + use tempfile::TempDir; + + // --- success-path unit tests --- + #[rstest] + #[case("java,maven,gradle", vec!["java", "maven", "gradle"])] + #[case(" java, maven, ,gradle ", vec!["java", "maven", "gradle"])] + #[case("kotlin", vec!["kotlin"])] + #[case(",, ,", Vec::<&str>::new())] + fn known_candidates_parses_csv(#[case] input: &str, #[case] expected: Vec<&str>) { + let sdkman_dir = TempDir::new().unwrap(); + let var_dir = sdkman_dir.path().join(VAR_DIR); + std::fs::create_dir_all(&var_dir).unwrap(); + + let candidates_path = var_dir.join(CANDIDATES_FILE); + let mut f = std::fs::File::create(&candidates_path).unwrap(); + writeln!(f, "{input}").unwrap(); + + let got = known_candidates(sdkman_dir.path()); + let expected: Vec = expected.into_iter().map(str::to_string).collect(); + assert_eq!(got, expected); + } + + #[test] + fn validate_candidate_returns_owned_string_when_valid() { + let all = vec!["java".to_string(), "maven".to_string()]; + let got = validate_candidate(&all, "java"); + assert_eq!(got, "java"); + } + + #[test] + fn validate_version_path_returns_path_when_installed() { + let sdkman_dir = TempDir::new().unwrap(); + let expected = sdkman_dir + .path() + .join(CANDIDATES_DIR) + .join("java") + .join("17.0.9-tem"); + + std::fs::create_dir_all(&expected).unwrap(); + + let got = validate_version_path(sdkman_dir.path(), "java", "17.0.9-tem"); + assert_eq!(got, expected); + } + + // --- exit-path tests (exit cases) --- + // + // `exit(1)` paths are tested by spawning this test binary as a child process. + fn run_child(filter: &str) -> std::process::ExitStatus { + let exe = std::env::current_exe().unwrap(); + Command::new(exe) + .env("SDKRAN_HELPERS_EXIT_TESTS", "1") + // Run only tests matching this substring filter + .arg(filter) + // Stable output, avoid capturing + .arg("--nocapture") + .status() + .unwrap() + } + + // == parent assertions == + #[test] + fn exits_when_candidates_file_missing() { + let status = run_child("helpers_exit_child_candidates_file_missing"); + assert_eq!(status.code(), Some(1)); + } + + #[test] + fn exits_when_candidate_invalid() { + let status = run_child("helpers_exit_child_candidate_invalid"); + assert_eq!(status.code(), Some(1)); + } + + #[test] + fn exits_when_version_not_installed() { + let status = run_child("helpers_exit_child_version_not_installed"); + assert_eq!(status.code(), Some(1)); + } + + // == child tests == + #[test] + fn helpers_exit_child_candidates_file_missing() { + if std::env::var_os("SDKRAN_HELPERS_EXIT_TESTS").is_none() { + return; + } + let sdkman_dir = TempDir::new().unwrap(); + // no var/candidates file created -> should exit(1) + let _ = known_candidates(sdkman_dir.path()); + } + + #[test] + fn helpers_exit_child_candidate_invalid() { + if std::env::var_os("SDKRAN_HELPERS_EXIT_TESTS").is_none() { + return; + } + let all = vec!["java".to_string(), "maven".to_string()]; + // not in list -> should exit(1) + let _ = validate_candidate(&all, "not-a-candidate"); + } + + #[test] + fn helpers_exit_child_version_not_installed() { + if std::env::var_os("SDKRAN_HELPERS_EXIT_TESTS").is_none() { + return; + } + let sdkman_dir = TempDir::new().unwrap(); + // path doesn't exist -> should exit(1) + let _ = validate_version_path(sdkman_dir.path(), "java", "0.0.0-not-installed"); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..3268bd8 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,5 @@ +use std::path::PathBuf; +pub mod constants; +pub mod directory_utils; +pub mod file_utils; +pub mod helpers; diff --git a/tests/current.rs b/tests/current.rs index 9d680d4..08e8cab 100644 --- a/tests/current.rs +++ b/tests/current.rs @@ -1,103 +1,92 @@ -#[cfg(test)] -use std::env; +use assert_cmd::{assert::OutputAssertExt, cargo}; +use predicates::prelude::PredicateBooleanExt; +use rstest::rstest; use std::path::Path; use std::process::Command; -use assert_cmd::prelude::*; -use predicates::prelude::*; -use serial_test::serial; use support::{TestCandidate, VirtualEnv}; mod support; -#[test] -#[serial] -fn should_show_current_version_for_specific_candidate() -> Result<(), Box> { - let name = "java"; - let current_version = "11.0.15-tem"; - let versions = vec!["11.0.15-tem", "17.0.3-tem"]; +fn sdk_cmd(sdkman_dir: &Path) -> Result> { + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + cmd.env("SDKMAN_DIR", sdkman_dir); + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR", "0"); + Ok(cmd) +} +#[rstest] +#[case("java", "11.0.15-tem", vec!["11.0.15-tem", "17.0.3-tem"])] +#[case("kotlin", "1.7.22", vec!["1.6.21", "1.7.22"])] +fn should_show_current_version_for_specific_candidate( + #[case] name: &'static str, + #[case] current_version: &'static str, + #[case] versions: Vec<&'static str>, +) -> Result<(), Box> { let env = VirtualEnv { cli_version: "5.0.0".to_string(), native_version: "0.1.0".to_string(), candidates: vec![TestCandidate { name, - versions: versions.clone(), + versions, current_version, }], }; let sdkman_dir = support::virtual_env(env); - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); + let expected = format!("Using {} version {}", name, current_version); - let expected_output = format!("Using {} version {}", name, current_version); - let contains_expected = predicate::str::contains(expected_output); - - Command::cargo_bin("current")? + sdk_cmd(sdkman_dir.path())? + .arg("current") .arg(name) .assert() .success() - .stdout(contains_expected) - .code(0); + .stdout(predicates::str::contains(expected)); Ok(()) } #[test] -#[serial] fn should_show_current_versions_for_all_candidates() -> Result<(), Box> { - // Define multiple candidates with their versions - let java_name = "java"; - let java_current_version = "11.0.15-tem"; - let java_versions = vec!["11.0.15-tem", "17.0.3-tem"]; - - let kotlin_name = "kotlin"; - let kotlin_current_version = "1.7.22"; - let kotlin_versions = vec!["1.6.21", "1.7.22"]; - let env = VirtualEnv { cli_version: "5.0.0".to_string(), native_version: "0.1.0".to_string(), candidates: vec![ TestCandidate { - name: java_name, - versions: java_versions.clone(), - current_version: java_current_version, + name: "java", + versions: vec!["11.0.15-tem", "17.0.3-tem"], + current_version: "11.0.15-tem", }, TestCandidate { - name: kotlin_name, - versions: kotlin_versions.clone(), - current_version: kotlin_current_version, + name: "kotlin", + versions: vec!["1.6.21", "1.7.22"], + current_version: "1.7.22", }, ], }; let sdkman_dir = support::virtual_env(env); - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); - - // Expected output patterns for the simple format (candidate version) - let expected_java_output = format!("{} {}", java_name, java_current_version); - let expected_kotlin_output = format!("{} {}", kotlin_name, kotlin_current_version); - - // Check for both expected outputs - let contains_java_output = predicate::str::contains(expected_java_output); - let contains_kotlin_output = predicate::str::contains(expected_kotlin_output); - Command::cargo_bin("current")? + sdk_cmd(sdkman_dir.path())? + .arg("current") .assert() .success() - .stdout(contains_java_output.and(contains_kotlin_output)) - .code(0); + .stdout( + predicates::str::contains("Current versions in use:") + .and(predicates::str::contains("java 11.0.15-tem")) + .and(predicates::str::contains("kotlin 1.7.22")), + ); Ok(()) } -#[test] -#[serial] -fn should_show_error_for_non_existent_candidate() -> Result<(), Box> { - let invalid_name = "invalid"; - - // Create a simple environment with an empty candidates file +#[rstest] +#[case("invalid")] +#[case("not-a-candidate")] +fn should_error_for_non_existent_candidate( + #[case] invalid_name: &str, +) -> Result<(), Box> { let env = VirtualEnv { cli_version: "5.0.0".to_string(), native_version: "0.1.0".to_string(), @@ -106,7 +95,7 @@ fn should_show_error_for_non_existent_candidate() -> Result<(), Box Result<(), Box Result<(), Box> { - // Create a candidate entry in candidates file, but no directory structure +fn should_error_for_candidate_with_no_current_version() -> Result<(), Box> { let sdkman_dir = support::prepare_sdkman_dir(); - // Write candidates file with a candidate let candidate_name = "kotlin"; support::write_file( sdkman_dir.path(), @@ -144,31 +126,25 @@ fn should_show_error_for_candidate_with_no_current_version( candidate_name.to_string(), ); - // Create candidate directory but no current symlink - let candidate_dir = Path::new("candidates").join(candidate_name); - std::fs::create_dir_all(sdkman_dir.path().join(&candidate_dir)) - .expect("Failed to create candidate directory"); + // Candidate dir exists, but no `current` link/dir + std::fs::create_dir_all(sdkman_dir.path().join("candidates").join(candidate_name))?; - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); - - let contains_error = predicate::str::contains("No current version of"); - - Command::cargo_bin("current")? + sdk_cmd(sdkman_dir.path())? + .arg("current") .arg(candidate_name) .assert() .failure() - .stderr(contains_error) - .code(1); + .code(1) + .stderr(predicates::str::contains("No current version of")); Ok(()) } #[test] -#[serial] fn should_show_message_when_no_candidates_in_use() -> Result<(), Box> { - // Create empty candidates file, but ensure it has at least one character (e.g., "kotlin") - // to avoid causing a panic in the known_candidates function let sdkman_dir = support::prepare_sdkman_dir(); + + // known_candidates() needs at least one candidate entry support::write_file( sdkman_dir.path(), Path::new("var"), @@ -176,18 +152,35 @@ fn should_show_message_when_no_candidates_in_use() -> Result<(), Box Result<(), Box> { + let sdkman_dir = support::prepare_sdkman_dir(); - let contains_message = predicate::str::contains("No candidates are in use"); + // Ensure var exists but candidates file is missing + let var_dir = sdkman_dir.path().join("var"); + std::fs::create_dir_all(&var_dir)?; + let candidates_path = var_dir.join("candidates"); + let _ = std::fs::remove_file(candidates_path); - Command::cargo_bin("current")? + sdk_cmd(sdkman_dir.path())? + .arg("current") .assert() - .stderr(contains_message) - .code(0); + .failure() + .code(1) + .stderr(predicates::str::contains("candidates")); Ok(()) } diff --git a/tests/default.rs b/tests/default.rs index fbb144d..612a4f7 100644 --- a/tests/default.rs +++ b/tests/default.rs @@ -1,54 +1,107 @@ -#[cfg(test)] -use assert_cmd::Command; -use predicates::str::contains; -use serial_test::serial; -use std::{env, fs}; +use assert_cmd::{cargo, prelude::*}; +use predicates::prelude::*; +use rstest::rstest; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + use support::{TestCandidate, VirtualEnv}; mod support; -#[test] -#[serial] -fn should_set_an_installed_version_as_default() -> Result<(), Box> { +fn cmd_with_env(sdkman_dir: &Path) -> Command { + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + cmd.env("SDKMAN_DIR", sdkman_dir); + // deterministic output + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR", "0"); + cmd +} + +fn current_scala_bin(sdkman_dir: &Path) -> PathBuf { + sdkman_dir + .join("candidates") + .join("scala") + .join("current") + .join("bin") + .join("scala") +} + +fn current_scala_dir(sdkman_dir: &Path) -> PathBuf { + sdkman_dir.join("candidates").join("scala").join("current") +} + +fn remove_any_path(p: &Path) { + // Handles: file, dir, symlink-to-file, symlink-to-dir (cross-platform best effort) + if fs::remove_file(p).is_ok() { + return; + } + let _ = fs::remove_dir_all(p); +} + +#[rstest] +#[case( + vec!["0.0.1", "0.0.2"], + "0.0.1", + "0.0.2", + "Running scala 0.0.2" +)] +#[case( + vec!["0.0.1"], + "0.0.1", + "0.0.1", + "Running scala 0.0.1" +)] +fn should_set_an_installed_version_as_default( + #[case] versions: Vec<&'static str>, + #[case] initial_current: &'static str, + #[case] set_to: &'static str, + #[case] expected_script_contains: &'static str, +) -> Result<(), Box> { let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), candidates: vec![TestCandidate { name: "scala", - versions: vec!["0.0.1", "0.0.2"], - current_version: "0.0.1", + versions, + current_version: initial_current, }], }; let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = "setting scala 0.0.2 as the default version for all shells"; - Command::cargo_bin("default")? + let expected_output = format!( + "setting scala {} as the default version for all shells.", + set_to + ); + + cmd_with_env(sdkman_dir.path()) + .arg("default") .arg("scala") - .arg("0.0.2") + .arg(set_to) .assert() .success() - .stdout(contains(expected_output)) - .code(0); + .code(0) + .stdout(predicate::str::contains(expected_output)); - let file = sdkman_dir - .path() - .join("candidates") - .join("scala") - .join("current") - .join("bin") - .join("scala"); - let content = fs::read_to_string(file).unwrap(); - assert!(content.contains("Running scala 0.0.2")); + // current should exist (symlink-to-dir or real dir fallback) + let current_dir = current_scala_dir(sdkman_dir.path()); + assert!(current_dir.exists()); + assert!(current_dir.is_dir()); + + let content = fs::read_to_string(current_scala_bin(sdkman_dir.path()))?; + assert!( + content.contains(expected_script_contains), + "expected current scala script to contain '{expected_script_contains}', got:\n{content}" + ); Ok(()) } #[test] -#[serial] -fn should_reset_the_current_default_version_as_default() -> Result<(), Box> { +fn should_not_set_an_uninstalled_version_as_default() -> Result<(), Box> { let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), @@ -60,55 +113,104 @@ fn should_reset_the_current_default_version_as_default() -> Result<(), Box Result<(), Box> { + let env = VirtualEnv { + cli_version: "0.0.1".to_string(), + native_version: "0.0.1".to_string(), + candidates: vec![TestCandidate { + name: "scala", + versions: vec!["0.0.1"], + current_version: "0.0.1", + }], + }; + + let sdkman_dir = support::virtual_env(env); + + cmd_with_env(sdkman_dir.path()) + .arg("default") + .arg("notreal") .arg("0.0.1") .assert() - .success() - .stdout(contains(expected_output)) - .code(0); + .failure() + .code(1) + .stderr( + predicate::str::contains("notreal") + .and(predicate::str::contains("is not a valid candidate")), + ); - let file = sdkman_dir - .path() - .join("candidates") - .join("scala") - .join("current") - .join("bin") - .join("scala"); - let content = fs::read_to_string(file).unwrap(); - assert!(content.contains("Running scala 0.0.1")); + Ok(()) +} + +#[test] +fn should_fail_when_candidates_file_missing() -> Result<(), Box> { + let sdkman_dir = support::prepare_sdkman_dir(); + + // Remove var/candidates to force the known_candidates error path. + let candidates_file = sdkman_dir.path().join("var").join("candidates"); + remove_any_path(&candidates_file); + + cmd_with_env(sdkman_dir.path()) + .arg("default") + .arg("scala") + .arg("0.0.1") + .assert() + .failure() + .code(1) + .stderr(predicate::str::contains("the candidates file is missing")); Ok(()) } #[test] -#[serial] -fn should_not_set_an_uninstalled_version_as_default() -> Result<(), Box> { +fn should_replace_current_when_current_is_a_real_directory( +) -> Result<(), Box> { + // Start with a normal env, then force current/ to be a real directory. let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), candidates: vec![TestCandidate { name: "scala", - versions: vec!["0.0.1"], + versions: vec!["0.0.1", "0.0.2"], current_version: "0.0.1", }], }; let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); + let current_dir = current_scala_dir(sdkman_dir.path()); + + // Remove any symlink and replace with a real directory + dummy file. + remove_any_path(¤t_dir); + fs::create_dir_all(current_dir.join("bin"))?; + fs::write(current_dir.join("bin").join("scala"), "Running scala OLD\n")?; - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = "scala 0.0.2 is not installed on your system"; - Command::cargo_bin("default")? + cmd_with_env(sdkman_dir.path()) + .arg("default") .arg("scala") .arg("0.0.2") .assert() - .failure() - .stderr(contains(expected_output)) - .code(1); + .success() + .code(0); + + let content = fs::read_to_string(current_scala_bin(sdkman_dir.path()))?; + assert!(content.contains("Running scala 0.0.2")); + Ok(()) } diff --git a/tests/help.rs b/tests/help.rs index 1550af9..2cb9037 100644 --- a/tests/help.rs +++ b/tests/help.rs @@ -1,49 +1,91 @@ -#[cfg(test)] +use assert_cmd::{cargo, prelude::*}; +use predicates::prelude::*; +use rstest::rstest; use std::process::Command; -use assert_cmd::prelude::*; -use predicates::prelude::*; +fn sdk() -> Command { + Command::new(cargo::cargo_bin!("sdkman")) +} #[test] fn should_render_base_help() -> Result<(), Box> { - let header = "\nNAME\n sdk - The command line interface (CLI) for SDKMAN!"; - Command::cargo_bin("help")? + sdk() + .arg("help") .assert() .success() - .stdout(predicate::str::starts_with(header)) - .code(0); - println!("Tested: {}", header); + .code(0) + // Don't hardcode ANSI/wrapping; just validate structure. + .stdout( + predicate::str::contains("\nNAME\n") + .and(predicate::str::contains( + "sdk - The command line interface (CLI) for SDKMAN!", + )) + .and(predicate::str::contains("\nSYNOPSIS\n")) + .and(predicate::str::contains("\nDESCRIPTION\n")) + .and(predicate::str::contains("\nEXAMPLES\n")), + ); + Ok(()) } -#[test] -fn should_render_help_for_all_subcommands() -> Result<(), Box> { - let args = [ - "config", - "current", - "default", - "env", - "flush", - "home", - "install", - "list", - "selfupdate", - "uninstall", - "update", - "upgrade", - "use", - "version", - ]; - - for arg in &args { - let header = format!("\n{} {} - ", "NAME\n sdk", &arg); - Command::cargo_bin("help")? - .arg(arg) - .assert() - .success() - .stdout(predicate::str::starts_with(&header)) - .code(0); - println!("Success: sdk {}", arg); +#[rstest] +#[case("config")] +#[case("current")] +#[case("default")] +#[case("env")] +#[case("flush")] +#[case("home")] +#[case("install")] +#[case("list")] +#[case("selfupdate")] +#[case("uninstall")] +#[case("update")] +#[case("upgrade")] +#[case("use")] +#[case("version")] +fn should_render_help_for_subcommand( + #[case] subcommand: &str, +) -> Result<(), Box> { + let expected_name_line = format!("sdk {}", subcommand); + + sdk() + .arg("help") + .arg(subcommand) + .assert() + .success() + .code(0) + .stdout( + predicate::str::contains("\nNAME\n") + .and(predicate::str::contains(&expected_name_line)) + .and(predicate::str::contains("\nSYNOPSIS\n")) + .and(predicate::str::contains("\nDESCRIPTION\n")) + .and(predicate::str::contains("\nEXAMPLES\n")), + ); + + Ok(()) +} + +#[rstest] +#[case("help", None)] +#[case("help", Some("version"))] +#[case("help", Some("install"))] +fn should_not_panic_for_help_paths( + #[case] a: &str, + #[case] b: Option<&str>, +) -> Result<(), Box> { + let mut cmd = sdk(); + cmd.arg(a); + if let Some(b) = b { + cmd.arg(b); } + + cmd.assert().success(); + Ok(()) +} + +#[test] +fn should_not_panic_on_clap_help_flag() -> Result<(), Box> { + // This ensures your clap wiring (disable_help_subcommand etc) is not exploding. + sdk().arg("--help").assert().success().code(0); Ok(()) } diff --git a/tests/helpers.rs b/tests/helpers.rs deleted file mode 100644 index 99ef396..0000000 --- a/tests/helpers.rs +++ /dev/null @@ -1,37 +0,0 @@ -#[cfg(test)] -use crate::support::TestCandidate; -use sdkman_cli_native::helpers::known_candidates; -use serial_test::serial; -use support::{prepare_sdkman_dir, VirtualEnv}; - -mod support; - -#[test] -#[serial] -fn should_fail_if_candidate_is_unknown() -> Result<(), Box> { - let env = VirtualEnv { - cli_version: "0.0.1".to_string(), - native_version: "0.0.1".to_string(), - candidates: vec![TestCandidate { - name: "scala", - versions: vec!["0.0.1"], - current_version: "0.0.1", - }], - }; - - let sdkman_dir = support::virtual_env(env); - let candidates = known_candidates(sdkman_dir.into_path()); - let expected_candidate = vec!["scala"]; - - assert_eq!(candidates, expected_candidate); - - Ok(()) -} - -#[test] -#[serial] -#[should_panic] -fn should_fail_if_candidate_file_is_missing() { - let sdkman_dir = prepare_sdkman_dir(); - known_candidates(sdkman_dir.into_path()); -} diff --git a/tests/home.rs b/tests/home.rs index c972e02..1244010 100644 --- a/tests/home.rs +++ b/tests/home.rs @@ -1,44 +1,81 @@ -#[cfg(test)] -use assert_cmd::Command; -use predicates::str::contains; -use serial_test::serial; -use std::env; +use assert_cmd::{cargo, prelude::*}; +use predicates::prelude::*; +use rstest::rstest; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + use support::{TestCandidate, VirtualEnv}; mod support; -#[test] -#[serial] -fn should_successfully_display_current_candidate_home() -> Result<(), Box> { +fn sdk_with_sdkman_dir(sdkman_dir: &Path) -> Command { + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + cmd.env("SDKMAN_DIR", sdkman_dir); + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR", "0"); + cmd +} + +fn sdk_with_home_fallback(home_dir: &Path) -> Command { + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + // Ensure we test fallback path resolution, not an inherited SDKMAN_DIR. + cmd.env_remove("SDKMAN_DIR"); + + // directories::UserDirs uses HOME on unix and USERPROFILE on windows. + // Setting both makes this deterministic across platforms. + cmd.env("HOME", home_dir); + cmd.env("USERPROFILE", home_dir); + + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR", "0"); + cmd +} + +fn version_dir(sdkman_dir: &Path, candidate: &str, version: &str) -> PathBuf { + sdkman_dir.join("candidates").join(candidate).join(version) +} + +fn candidates_file(sdkman_dir: &Path) -> PathBuf { + sdkman_dir.join("var").join("candidates") +} + +#[rstest] +#[case(vec!["0.0.1"], "0.0.1")] +#[case(vec!["0.0.1", "0.0.2"], "0.0.2")] +fn home_prints_path_for_installed_version( + #[case] versions: Vec<&'static str>, + #[case] query_version: &'static str, +) -> Result<(), Box> { let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), candidates: vec![TestCandidate { name: "scala", - versions: vec!["0.0.1"], + versions, current_version: "0.0.1", }], }; - let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = format!("{}/candidates/scala/0.0.1", dir_string); - Command::cargo_bin("home")? + let expected = version_dir(sdkman_dir.path(), "scala", query_version); + + sdk_with_sdkman_dir(sdkman_dir.path()) + .arg("home") .arg("scala") - .arg("0.0.1") + .arg(query_version) .assert() .success() - .stdout(contains(expected_output)) - .code(0); + .code(0) + .stdout(predicate::str::contains(expected.display().to_string())); Ok(()) } #[test] -#[serial] -fn should_fail_if_candidate_home_is_not_found() -> Result<(), Box> { +fn home_fails_for_uninstalled_version() -> Result<(), Box> { let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), @@ -48,18 +85,188 @@ fn should_fail_if_candidate_home_is_not_found() -> Result<(), Box Result<(), Box> { + let env = VirtualEnv { + cli_version: "0.0.1".to_string(), + native_version: "0.0.1".to_string(), + candidates: vec![TestCandidate { + name: "scala", + versions: vec!["0.0.1"], + current_version: "0.0.1", + }], + }; + let sdkman_dir = support::virtual_env(env); + + // Create an on-disk directory for a candidate that is NOT in var/candidates. + fs::create_dir_all(version_dir(sdkman_dir.path(), "notreal", "1.0.0"))?; + + sdk_with_sdkman_dir(sdkman_dir.path()) + .arg("home") + .arg("notreal") + .arg("1.0.0") + .assert() + .failure() + .code(1) + .stderr( + predicate::str::contains("notreal") + .and(predicate::str::contains("is not a valid candidate")), + ); + + Ok(()) +} + +#[test] +fn home_fails_when_candidates_file_missing() -> Result<(), Box> { + let sdkman_dir = support::prepare_sdkman_dir(); + + // Ensure var exists, but remove var/candidates. + fs::create_dir_all(sdkman_dir.path().join("var"))?; + let _ = fs::remove_file(candidates_file(sdkman_dir.path())); + + sdk_with_sdkman_dir(sdkman_dir.path()) + .arg("home") + .arg("scala") + .arg("0.0.1") + .assert() + .failure() + .code(1) + .stderr(predicate::str::contains("the candidates file is missing")); + + Ok(()) +} + +#[test] +fn home_works_with_candidates_file_whitespace_and_commas() -> Result<(), Box> +{ + let sdkman_dir = support::prepare_sdkman_dir(); + + // Write a candidates file with extra commas/whitespace. + support::write_file( + sdkman_dir.path(), + Path::new("var"), + "candidates", + " , scala , java, , ".to_string(), + ); + + // Create scala version directory + fs::create_dir_all(version_dir(sdkman_dir.path(), "scala", "0.0.1"))?; + + let expected = version_dir(sdkman_dir.path(), "scala", "0.0.1"); + + sdk_with_sdkman_dir(sdkman_dir.path()) + .arg("home") + .arg("scala") + .arg("0.0.1") + .assert() + .success() + .code(0) + .stdout(predicate::str::contains(expected.display().to_string())); + + Ok(()) +} + +#[test] +fn home_treats_version_path_that_is_a_file_as_not_installed( +) -> Result<(), Box> { + let sdkman_dir = support::prepare_sdkman_dir(); + support::write_file( + sdkman_dir.path(), + Path::new("var"), + "candidates", + "scala".to_string(), + ); + + // Create a FILE at candidates/scala/0.0.1 (not a directory) + let version_path = version_dir(sdkman_dir.path(), "scala", "0.0.1"); + fs::create_dir_all(version_path.parent().unwrap())?; + fs::write(&version_path, "not a dir")?; + + sdk_with_sdkman_dir(sdkman_dir.path()) + .arg("home") + .arg("scala") + .arg("0.0.1") + .assert() + .failure() + .code(1) + .stderr( + predicate::str::contains("scala") + .and(predicate::str::contains("0.0.1")) + .and(predicate::str::contains("is not installed on your system")), + ); + + Ok(()) +} + +#[test] +fn home_works_via_fallback_sdkman_dir_when_sdkman_dir_env_not_set( +) -> Result<(), Box> { + // Build a fake HOME and put .sdkman under it, since fallback_sdkman_dir = HOME/.sdkman + let home = tempfile::TempDir::new()?; + let sdkman_root = home.path().join(".sdkman"); + + // Minimal structure used by known_candidates + home: + // .sdkman/var/candidates + // .sdkman/candidates/scala/0.0.1 + fs::create_dir_all(sdkman_root.join("var"))?; + fs::create_dir_all(sdkman_root.join("candidates").join("scala").join("0.0.1"))?; + fs::write(sdkman_root.join("var").join("candidates"), "scala")?; + + let expected = sdkman_root.join("candidates").join("scala").join("0.0.1"); + + sdk_with_home_fallback(home.path()) + .arg("home") + .arg("scala") + .arg("0.0.1") + .assert() + .success() + .code(0) + .stdout(predicate::str::contains(expected.display().to_string())); + + Ok(()) +} + +#[test] +fn home_fails_when_candidates_file_is_empty() -> Result<(), Box> { + let sdkman_dir = support::prepare_sdkman_dir(); + + // Empty candidates file => known_candidates() returns empty vec => validate_candidate exits(1) + support::write_file( + sdkman_dir.path(), + Path::new("var"), + "candidates", + " , , ,".to_string(), + ); + + sdk_with_sdkman_dir(sdkman_dir.path()) + .arg("home") + .arg("scala") + .arg("0.0.1") + .assert() + .failure() + .code(1) + .stderr( + predicate::str::contains("scala") + .and(predicate::str::contains("not a valid candidate")), + ); + Ok(()) } diff --git a/tests/support/mod.rs b/tests/support.rs similarity index 93% rename from tests/support/mod.rs rename to tests/support.rs index 88077da..155c179 100644 --- a/tests/support/mod.rs +++ b/tests/support.rs @@ -83,7 +83,7 @@ echo Running {} {} .expect("cannot create current symlink"); } - return sdkman_dir; + sdkman_dir } pub fn prepare_sdkman_dir() -> TempDir { @@ -100,11 +100,11 @@ pub fn write_file( content: String, ) -> PathBuf { let absolute_path = temp_dir.join(relative_path); - create_dir_all(absolute_path.to_owned()).expect("could not create nested dirs"); + create_dir_all(&absolute_path).expect("could not create nested dirs"); let file_path = absolute_path.join(file_name); let mut file = File::create(&file_path).expect("could not create file"); - write!(file, "{}", content.to_string()).expect("could not write to file"); + write!(file, "{}", content).expect("could not write to file"); file_path } diff --git a/tests/uninstall.rs b/tests/uninstall.rs index 428c5f6..5ee5ed9 100644 --- a/tests/uninstall.rs +++ b/tests/uninstall.rs @@ -1,52 +1,44 @@ -#[cfg(test)] -use assert_cmd::Command; -use predicates::str::contains; -use serial_test::serial; -use std::env; +use assert_cmd::{cargo, prelude::*}; +use predicates::prelude::*; +use rstest::rstest; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + use support::{TestCandidate, VirtualEnv}; mod support; -#[test] -#[serial] -fn should_successfully_remove_unused_candidate_version() -> Result<(), Box> { - let env = VirtualEnv { - cli_version: "0.0.1".to_string(), - native_version: "0.0.1".to_string(), - candidates: vec![TestCandidate { - name: "scala", - versions: vec!["0.0.1", "0.0.2"], - current_version: "0.0.2", - }], - }; - - let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); +fn sdk(sdkman_dir: &Path) -> Command { + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + cmd.env("SDKMAN_DIR", sdkman_dir); + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR", "0"); + cmd +} - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = "removed scala 0.0.1"; - Command::cargo_bin("uninstall")? - .arg("scala") - .arg("0.0.1") - .assert() - .success() - .stdout(contains(expected_output)) - .code(0); +fn version_path(sdkman_dir: &Path, candidate: &str, version: &str) -> PathBuf { + sdkman_dir.join("candidates").join(candidate).join(version) +} - let exists = sdkman_dir - .path() +fn current_path(sdkman_dir: &Path, candidate: &str) -> PathBuf { + sdkman_dir .join("candidates") - .join("scala") - .join("0.0.1") - .exists(); - assert!(!exists); - - Ok(()) + .join(candidate) + .join("current") } -#[test] -#[serial] -fn should_successfully_remove_current_candidate_version_when_forced( +#[rstest] +#[case("0.0.1", "0.0.2", false, 0, "removed scala 0.0.1.")] +#[case("0.0.2", "0.0.2", true, 0, "removed scala 0.0.2.")] +fn uninstall_removes_version_when_allowed( + #[case] target_version: &str, + #[case] current_version: &'static str, + #[case] force: bool, + #[case] expected_code: i32, + #[case] expected_stdout: &str, ) -> Result<(), Box> { let env = VirtualEnv { cli_version: "0.0.1".to_string(), @@ -54,39 +46,43 @@ fn should_successfully_remove_current_candidate_version_when_forced( candidates: vec![TestCandidate { name: "scala", versions: vec!["0.0.1", "0.0.2"], - current_version: "0.0.2", + current_version, }], }; let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = "removed scala 0.0.2"; - Command::cargo_bin("uninstall")? - .arg("scala") - .arg("0.0.2") - .arg("--force") - .assert() - .success() - .stdout(contains(expected_output)) - .code(0); + let mut cmd = sdk(sdkman_dir.path()); + cmd.arg("uninstall").arg("scala").arg(target_version); + if force { + cmd.arg("--force"); + } - let exists = sdkman_dir - .path() - .join("candidates") - .join("scala") - .join("0.0.2") - .exists(); - assert!(!exists); + cmd.assert() + .success() + .code(expected_code) + .stdout(predicate::str::contains(expected_stdout)); + + // Version directory should be gone. + assert!( + !version_path(sdkman_dir.path(), "scala", target_version).exists(), + "expected version dir to be removed" + ); + + // If we removed the current version with --force, current should be gone too. + if force && target_version == current_version { + assert!( + !current_path(sdkman_dir.path(), "scala").exists(), + "expected current link/dir to be removed when forcing uninstall of current" + ); + } Ok(()) } #[test] -#[serial] -fn should_fail_if_candidate_version_is_current_when_not_forced( -) -> Result<(), Box> { +fn uninstall_fails_when_target_is_current_without_force() -> Result<(), Box> +{ let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), @@ -98,23 +94,37 @@ fn should_fail_if_candidate_version_is_current_when_not_forced( }; let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = format!("scala 0.0.2 is the current version and should not be removed."); - Command::cargo_bin("uninstall")? + sdk(sdkman_dir.path()) + .arg("uninstall") .arg("scala") .arg("0.0.2") .assert() .failure() - .stderr(contains(expected_output)) - .code(1); + .code(1) + .stderr( + predicate::str::contains("scala") + .and(predicate::str::contains("0.0.2")) + .and(predicate::str::contains("is the")) + .and(predicate::str::contains("current")) + .and(predicate::str::contains("should not be removed")), + ); + + // Ensure version still exists. + assert!(version_path(sdkman_dir.path(), "scala", "0.0.2").exists()); + Ok(()) } -#[test] -#[serial] -fn should_fail_if_candidate_is_invalid() -> Result<(), Box> { +#[rstest] +#[case("zcala", "0.0.2", "is not a valid candidate", false)] +#[case("scala", "0.0.9", "is not installed on your system", true)] +fn uninstall_fails_for_invalid_inputs( + #[case] candidate: &str, + #[case] version: &str, + #[case] expected_msg: &str, + #[case] expect_version_in_stderr: bool, +) -> Result<(), Box> { let env = VirtualEnv { cli_version: "0.0.1".to_string(), native_version: "0.0.1".to_string(), @@ -126,44 +136,41 @@ fn should_fail_if_candidate_is_invalid() -> Result<(), Box Result<(), Box> { - let env = VirtualEnv { - cli_version: "0.0.1".to_string(), - native_version: "0.0.1".to_string(), - candidates: vec![TestCandidate { - name: "scala", - versions: vec!["0.0.1"], - current_version: "0.0.1", - }], - }; +fn uninstall_fails_when_candidates_file_missing() -> Result<(), Box> { + let sdkman_dir = support::prepare_sdkman_dir(); - let sdkman_dir = support::virtual_env(env); - let dir_string = sdkman_dir.path().to_str().unwrap(); + // Ensure var exists but candidates file is missing. + fs::create_dir_all(sdkman_dir.path().join("var"))?; + let _ = fs::remove_file(sdkman_dir.path().join("var").join("candidates")); - env::set_var("SDKMAN_DIR", dir_string); - let expected_output = format!("{} {} is not installed on your system", "scala", "0.0.2"); - Command::cargo_bin("uninstall")? + sdk(sdkman_dir.path()) + .arg("uninstall") .arg("scala") - .arg("0.0.2") + .arg("0.0.1") .assert() .failure() - .stderr(contains(expected_output)) - .code(1); + .code(1) + .stderr(predicate::str::contains("the candidates file is missing")); + Ok(()) } diff --git a/tests/version.rs b/tests/version.rs index cc6ce0d..10d125f 100644 --- a/tests/version.rs +++ b/tests/version.rs @@ -1,22 +1,26 @@ -#[cfg(test)] -use std::env; +use assert_cmd::{cargo, prelude::*}; +use predicates::prelude::*; +use rstest::rstest; use std::{path::Path, process::Command}; -use assert_cmd::prelude::*; -use predicates::prelude::*; -use serial_test::serial; use support::VirtualEnv; mod support; +fn sdk(sdkman_dir: &std::path::Path) -> Command { + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + cmd.env("SDKMAN_DIR", sdkman_dir); + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR", "0"); + cmd +} + #[test] -#[serial] fn should_successfully_render_version() -> Result<(), Box> { let prefix = "SDKMAN!"; let cli_version = "5.0.0"; let native_version = env!("CARGO_PKG_VERSION"); - let header = format!("\n{}", prefix); let env = VirtualEnv { cli_version: cli_version.to_string(), native_version: native_version.to_string(), @@ -25,40 +29,40 @@ fn should_successfully_render_version() -> Result<(), Box let sdkman_dir = support::virtual_env(env); - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); - - let contains_header = predicate::str::starts_with(header); - let contains_version = predicate::str::contains(format!("script: {}", cli_version)); - let contains_native_version = predicate::str::contains(format!("native: {}", native_version)); + let contains_header = predicate::str::contains(format!("\n{}", prefix)); + let contains_cli = predicate::str::contains(format!("script: {}", cli_version)); + let contains_native = predicate::str::contains(format!("native: {}", native_version)); - Command::cargo_bin("version")? + sdk(sdkman_dir.path()) + .arg("version") .assert() .success() - .stdout(contains_header.and(contains_version.and(contains_native_version))) - .code(0); + .code(0) + .stdout(contains_header.and(contains_cli).and(contains_native)); Ok(()) } -#[test] -#[serial] -fn should_panic_if_version_file_not_present() -> Result<(), Box> { - let sdkman_dir = support::prepare_sdkman_dir(); - - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); - - Command::cargo_bin("version")?.assert().failure().code(101); - Ok(()) -} - -#[test] -#[serial] -fn should_panic_if_version_file_empty() -> Result<(), Box> { +#[rstest] +#[case(true, false, "Not a valid file path")] // missing version file +#[case(false, true, "File is empty")] // empty version file +fn should_fail_if_version_file_missing_or_empty( + #[case] missing_version_file: bool, + #[case] empty_version_file: bool, + #[case] expected_reason: &str, +) -> Result<(), Box> { let sdkman_dir = support::prepare_sdkman_dir(); let var_path = Path::new("var"); - support::write_file(sdkman_dir.path(), var_path, "version", "".to_string()); + if missing_version_file { + let _ = std::fs::remove_file(sdkman_dir.path().join("var").join("version")); + } + + if empty_version_file { + support::write_file(sdkman_dir.path(), var_path, "version", "".to_string()); + } + // Keep this present so we isolate failures to the script version path. support::write_file( sdkman_dir.path(), var_path, @@ -66,14 +70,22 @@ fn should_panic_if_version_file_empty() -> Result<(), Box "0.1.0".to_string(), ); - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); + sdk(sdkman_dir.path()) + .arg("version") + .assert() + .failure() + .code(1) + .stderr( + predicate::str::contains("failed to read SDKMAN! script version") + // match "/var/version" on unix OR "\var\version" on windows + .and(predicate::str::is_match(r"[/\\]var[/\\]version").unwrap()) + .and(predicate::str::contains(expected_reason)), + ); - Command::cargo_bin("version")?.assert().failure().code(101); Ok(()) } #[test] -#[serial] fn should_include_os_and_arch_info() -> Result<(), Box> { let cli_version = "5.0.0"; let native_version = env!("CARGO_PKG_VERSION"); @@ -85,20 +97,30 @@ fn should_include_os_and_arch_info() -> Result<(), Box> { }; let sdkman_dir = support::virtual_env(env); - env::set_var("SDKMAN_DIR", sdkman_dir.path().as_os_str()); - // Get the expected OS and architecture strings let os = std::env::consts::OS; let arch = std::env::consts::ARCH; - let contains_os = predicate::str::contains(format!("{}", os)); - let contains_arch = predicate::str::contains(format!("{}", arch)); - - Command::cargo_bin("version")? + sdk(sdkman_dir.path()) + .arg("version") .assert() .success() - .stdout(contains_os.and(contains_arch)) - .code(0); + .code(0) + .stdout(predicate::str::contains(os).and(predicate::str::contains(arch))); + + Ok(()) +} + +#[test] +fn should_not_panic_when_sdkman_dir_missing_defaults_to_failure( +) -> Result<(), Box> { + // If SDKMAN_DIR points somewhere invalid, the command should fail. + // (Exact code depends on your impl; this asserts "non-zero".) + let mut cmd = Command::new(cargo::cargo_bin!("sdkman")); + cmd.env("SDKMAN_DIR", "__definitely_missing_sdkman_dir__"); + cmd.env("NO_COLOR", "1"); + + cmd.arg("version").assert().failure(); Ok(()) }