From 49b9ce8d9fc5c3286964dfd9199719f77256cbe5 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:19:59 +0200 Subject: [PATCH 01/37] chore(deps): refresh Cargo.lock --- Cargo.lock | 651 ++++++++++++++++------------------------------------- 1 file changed, 196 insertions(+), 455 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5c5328..4418dc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "assert_cmd" @@ -111,9 +111,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -121,14 +121,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -139,9 +140,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -154,13 +161,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -171,15 +178,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -195,9 +202,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] name = "chrono" @@ -214,9 +232,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -224,9 +242,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -288,9 +306,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -357,6 +375,37 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -441,9 +490,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -451,9 +500,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -525,12 +574,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -621,36 +664,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", - "wasip2", + "r-efi", + "rand_core", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -665,15 +695,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -698,9 +719,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -708,9 +729,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -727,9 +748,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -901,12 +922,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -935,16 +950,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -973,10 +986,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" dependencies = [ + "defmt", "jiff-static", "log", "portable-atomic", @@ -986,9 +1000,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ "proc-macro2", "quote", @@ -1046,31 +1060,25 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1079,9 +1087,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1109,9 +1117,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -1121,9 +1129,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1143,9 +1151,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1253,11 +1261,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -1277,15 +1291,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "predicates" version = "3.1.4" @@ -1316,16 +1321,6 @@ dependencies = [ "termtree", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1337,9 +1332,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -1357,15 +1352,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1379,33 +1375,27 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" 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 = "r-efi" version = "6.0.0" @@ -1414,31 +1404,28 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -1447,7 +1434,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -1463,9 +1450,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1475,9 +1462,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1549,9 +1536,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1568,7 +1555,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1577,9 +1564,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "once_cell", @@ -1603,9 +1590,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -1652,9 +1639,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1686,7 +1673,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1787,15 +1774,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -1821,9 +1808,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1849,9 +1836,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1884,7 +1871,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1917,7 +1904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1961,9 +1948,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1976,9 +1963,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -1993,9 +1980,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", @@ -2046,7 +2033,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -2113,12 +2100,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unit-prefix" version = "0.5.2" @@ -2189,29 +2170,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2222,9 +2185,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2232,9 +2195,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2242,9 +2205,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -2255,35 +2218,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -2297,23 +2238,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -2331,9 +2260,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -2423,16 +2352,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2450,31 +2370,14 @@ 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_i686_gnullvm 0.52.6", - "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", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2483,190 +2386,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -2706,26 +2467,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2788,6 +2529,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From e92e87db998b47f4970a2fc5e4e3def19f304d46 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:19:59 +0200 Subject: [PATCH 02/37] refactor(shell): deduplicate posix logic shared by bash and zsh Bash and Zsh emitted byte-identical path/set_env_var/wrapper_fn strings from two copies. Extract posix_path, posix_set_env_var and posix_wrapper_fn free functions; both impls now delegate. Only the use_on_cd hooks differ per shell. --- src/shell.rs | 111 ++++++++++++++++++++++----------------------------- 1 file changed, 47 insertions(+), 64 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index 9a269b7..480ab80 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -39,39 +39,20 @@ fn fish_single_quote(value: &str) -> String { out } -pub struct Bash; - -impl Shell for Bash { - fn path(&self, path: &Path) -> String { - format!( - "export PATH={}:\"$PATH\"", - posix_single_quote(&path.display().to_string()) - ) - } - - fn set_env_var(&self, name: &str, value: &str) -> String { - format!("export {}={}", name, posix_single_quote(value)) - } +fn posix_path(path: &Path) -> String { + format!( + "export PATH={}:\"$PATH\"", + posix_single_quote(&path.display().to_string()) + ) +} - fn use_on_cd(&self) -> String { - " -_pvm_cd_hook() { - if [[ -f .php-version ]]; then - pvm use --silent \"$(cat .php-version)\" || true - fi +fn posix_set_env_var(name: &str, value: &str) -> String { + format!("export {}={}", name, posix_single_quote(value)) } -if [[ -n \"$BASH_VERSION\" ]]; then - if [[ ! \"$PROMPT_COMMAND\" == *\"_pvm_cd_hook\"* ]]; then - PROMPT_COMMAND=\"_pvm_cd_hook; ${PROMPT_COMMAND:-}\" - fi -fi -" - .to_string() - } - fn wrapper_fn(&self) -> String { - format!( - " +fn posix_wrapper_fn() -> String { + format!( + " export PATH=\"${{{}}}/bin:$PATH\" pvm() {{ @@ -95,8 +76,39 @@ pvm() {{ fi }} ", - PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE - ) + PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE + ) +} + +pub struct Bash; + +impl Shell for Bash { + fn path(&self, path: &Path) -> String { + posix_path(path) + } + + fn set_env_var(&self, name: &str, value: &str) -> String { + posix_set_env_var(name, value) + } + + fn use_on_cd(&self) -> String { + " +_pvm_cd_hook() { + if [[ -f .php-version ]]; then + pvm use --silent \"$(cat .php-version)\" || true + fi +} +if [[ -n \"$BASH_VERSION\" ]]; then + if [[ ! \"$PROMPT_COMMAND\" == *\"_pvm_cd_hook\"* ]]; then + PROMPT_COMMAND=\"_pvm_cd_hook; ${PROMPT_COMMAND:-}\" + fi +fi +" + .to_string() + } + + fn wrapper_fn(&self) -> String { + posix_wrapper_fn() } } @@ -104,14 +116,11 @@ pub struct Zsh; impl Shell for Zsh { fn path(&self, path: &Path) -> String { - format!( - "export PATH={}:\"$PATH\"", - posix_single_quote(&path.display().to_string()) - ) + posix_path(path) } fn set_env_var(&self, name: &str, value: &str) -> String { - format!("export {}={}", name, posix_single_quote(value)) + posix_set_env_var(name, value) } fn use_on_cd(&self) -> String { @@ -128,33 +137,7 @@ add-zsh-hook chpwd _pvm_cd_hook } fn wrapper_fn(&self) -> String { - format!( - " -export PATH=\"${{{}}}/bin:$PATH\" - -pvm() {{ - local command=$1 - if [[ \"$command\" == \"env\" ]]; then - command pvm \"$@\" - else - if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then - local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\" - [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null - PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\" - local exit_code=$? - if [[ -f \"$env_file\" ]]; then - eval \"$(cat \"$env_file\")\" - command rm -f \"$env_file\" 2>/dev/null - fi - return $exit_code - else - command pvm \"$@\" - fi - fi -}} -", - PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE - ) + posix_wrapper_fn() } } From 9e068ae5eaf2a8e81629ae3b29a31b4845014165 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:20:17 +0200 Subject: [PATCH 03/37] refactor(fs): replace hand-rolled version comparator with semver sort utils::sort_versions carried a 3-way semver/non-semver fallback comparator, but its only caller sorts installed-version directory names, which are always full semver. One sort_by_cached_key on semver::Version::parse covers it; delete src/utils.rs. --- src/fs.rs | 3 ++- src/main.rs | 1 - src/utils.rs | 54 ---------------------------------------------------- 3 files changed, 2 insertions(+), 56 deletions(-) delete mode 100644 src/utils.rs diff --git a/src/fs.rs b/src/fs.rs index 499bfd7..7d1ed41 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -63,7 +63,8 @@ pub fn list_installed_versions() -> Result> { } } - crate::utils::sort_versions(&mut versions); + // Directory names are always full semver ("8.3.1"); unparsable ones sort first. + versions.sort_by_cached_key(|v| semver::Version::parse(v).ok()); Ok(versions) } diff --git a/src/main.rs b/src/main.rs index 6724f55..319dfd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ mod interactive; mod network; mod shell; mod update; -mod utils; use anyhow::Result; use clap::Parser; diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 3f31718..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,54 +0,0 @@ -use semver::Version; - -/// Sorts a list of version strings using semantic versioning. -/// If a version string is not valid semver, it falls back to a simple string-based numeric sort. -pub fn sort_versions(versions: &mut [String]) { - let mut parsed: Vec<(String, Result)> = versions - .iter() - .map(|v| (v.clone(), Version::parse(v))) - .collect(); - - parsed.sort_by(|a, b| { - match (&a.1, &b.1) { - (Ok(av), Ok(bv)) => av.cmp(bv), - (Ok(av), Err(_)) => { - let b_parts: Vec = b.0.split('.').filter_map(|s| s.parse().ok()).collect(); - let a_parts = vec![av.major, av.minor, av.patch]; - match a_parts.cmp(&b_parts) { - std::cmp::Ordering::Equal => { - if !av.pre.is_empty() { - std::cmp::Ordering::Less - } else { - std::cmp::Ordering::Equal - } - } - ord => ord, - } - } - (Err(_), Ok(bv)) => { - let a_parts: Vec = a.0.split('.').filter_map(|s| s.parse().ok()).collect(); - let b_parts = vec![bv.major, bv.minor, bv.patch]; - match a_parts.cmp(&b_parts) { - std::cmp::Ordering::Equal => { - if !bv.pre.is_empty() { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Equal - } - } - ord => ord, - } - } - _ => { - // Fallback for non-semver strings (e.g., "8.2") - let a_parts: Vec = a.0.split('.').filter_map(|s| s.parse().ok()).collect(); - let b_parts: Vec = b.0.split('.').filter_map(|s| s.parse().ok()).collect(); - a_parts.cmp(&b_parts) - } - } - }); - - for (i, (orig, _)) in parsed.into_iter().enumerate() { - versions[i] = orig; - } -} From cbc87514e8fca39ec3723e5599ba51dce4364403 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:20:34 +0200 Subject: [PATCH 04/37] refactor(fs): drop redundant re-sort in get_aliased_versions list_installed_versions already returns the list semver-sorted; the inline parts-based re-sort was a third comparator doing the same work again. --- src/fs.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/fs.rs b/src/fs.rs index 7d1ed41..3b17c9c 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -116,18 +116,11 @@ pub fn write_env_file_locked(path: &PathBuf, content: &str) -> Result<()> { } pub fn get_aliased_versions() -> Result> { - let mut installed = list_installed_versions()?; + let installed = list_installed_versions()?; if installed.is_empty() { return Ok(Vec::new()); } - // Sort semantic versions cleanly - installed.sort_by(|a, b| { - let a_parts: Vec = a.split('.').filter_map(|s| s.parse().ok()).collect(); - let b_parts: Vec = b.split('.').filter_map(|s| s.parse().ok()).collect(); - a_parts.cmp(&b_parts) - }); - let mut items = Vec::new(); // Latest alias From d71b4ecc13f34748861c8623e89c814629558447 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:21:11 +0200 Subject: [PATCH 05/37] refactor(network): drop futures-util, stream via reqwest chunk() futures-util existed only for StreamExt on bytes_stream(). reqwest::Response::chunk() does the same natively. One dependency fewer. --- Cargo.lock | 1 - Cargo.toml | 1 - src/network.rs | 11 ++++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4418dc5..a012dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,7 +1241,6 @@ dependencies = [ "dirs", "env_logger", "flate2", - "futures-util", "indicatif", "log", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 404766b..760fe6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ dialoguer = "0.12.0" dirs = "6.0.0" env_logger = "0.11.10" flate2 = "1.1.9" -futures-util = "0.3.32" indicatif = "0.18.4" log = "0.4.29" reqwest = { version = "0.13.3", features = ["stream", "rustls", "json"] } diff --git a/src/network.rs b/src/network.rs index d6f72db..351446b 100644 --- a/src/network.rs +++ b/src/network.rs @@ -2,7 +2,6 @@ use crate::constants::{BASE_URL, REMOTE_CACHE_FILE}; use anyhow::{Context, Result}; use flate2::read::GzDecoder; -use futures_util::StreamExt; use reqwest::Client; use serde::Deserialize; use std::path::Path; @@ -65,14 +64,16 @@ pub(crate) fn build_download_progress_bar(total_size: Option) -> Result Result { let mut tmp = tempfile::tempfile().context("Failed to create temporary archive file")?; let mut downloaded: u64 = 0; - let mut stream = response.bytes_stream(); - while let Some(item) = stream.next().await { - let chunk = item.context("Error while downloading chunk")?; + while let Some(chunk) = response + .chunk() + .await + .context("Error while downloading chunk")? + { tmp.write_all(&chunk) .context("Failed to write archive chunk to temp file")?; downloaded += chunk.len() as u64; From 4a21e7c25b123a3f186d8c15544da93eb88932e4 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:21:39 +0200 Subject: [PATCH 06/37] refactor(update): drop log and env_logger dependencies Two dependencies served a single log::debug! call on a path where the error is intentionally swallowed (best-effort update check). Delete the call and both crates. --- Cargo.lock | 106 +++----------------------------------------------- Cargo.toml | 2 - src/main.rs | 2 - src/update.rs | 21 +++------- 4 files changed, 11 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a012dc7..8d95c5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,12 +138,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" @@ -375,37 +369,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror", -] - [[package]] name = "dialoguer" version = "0.12.0" @@ -488,29 +451,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "env_filter" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -984,31 +924,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" -dependencies = [ - "defmt", - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "jni" version = "0.22.4" @@ -1239,10 +1154,8 @@ dependencies = [ "colored", "dialoguer", "dirs", - "env_logger", "flate2", "indicatif", - "log", "predicates", "reqwest", "semver", @@ -1272,15 +1185,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -1433,7 +1337,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags", ] [[package]] @@ -1554,7 +1458,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1672,7 +1576,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1870,7 +1774,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2032,7 +1936,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags", "bytes", "futures-util", "http", diff --git a/Cargo.toml b/Cargo.toml index 760fe6d..5dcd5a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,8 @@ clap = { version = "4.6.1", features = ["derive"] } colored = "3.1.1" dialoguer = "0.12.0" dirs = "6.0.0" -env_logger = "0.11.10" flate2 = "1.1.9" indicatif = "0.18.4" -log = "0.4.29" reqwest = { version = "0.13.3", features = ["stream", "rustls", "json"] } semver = "1.0.28" serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 319dfd2..a1c4e3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,6 @@ use colored::Colorize; #[tokio::main] async fn main() -> Result<()> { - env_logger::init(); - let _pvm_dir = fs::get_pvm_dir()?; let versions_dir = fs::get_versions_dir()?; std::fs::create_dir_all(&versions_dir)?; diff --git a/src/update.rs b/src/update.rs index d3f70bb..eb983bd 100644 --- a/src/update.rs +++ b/src/update.rs @@ -55,21 +55,12 @@ pub async fn check_for_updates(target_version: &str) -> Result> { } let minor_prefix = format!("{}.{}", parts[0], parts[1]); - // Fetch remotes and resolve the newest patch for that minor line - match network::resolve_version(&minor_prefix).await { - Ok(latest_matching) => { - if latest_matching != target_version { - return Ok(Some(latest_matching)); - } - } - Err(e) => { - log::debug!( - "Failed to resolve version for update check (minor_prefix: {}, target: {}): {}", - minor_prefix, - target_version, - e - ); - } + // Fetch remotes and resolve the newest patch for that minor line. + // Resolution failures are ignored: the update check is best-effort. + if let Ok(latest_matching) = network::resolve_version(&minor_prefix).await + && latest_matching != target_version + { + return Ok(Some(latest_matching)); } Ok(None) From b6e38084c6b64f62cafbff652d71434c84341f53 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:21:58 +0200 Subject: [PATCH 07/37] refactor(fs): remove dead PVM_SHELL_PID branch in get_env_update_path Nothing ever sets PVM_SHELL_PID: the shell wrappers pass per-session uniqueness via PVM_ENV_UPDATE_PATH. The suffix branch could never execute. --- src/fs.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/fs.rs b/src/fs.rs index 3b17c9c..3f3a6d1 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -87,14 +87,7 @@ pub fn get_env_update_path(override_path: Option) -> Result { if let Ok(env_path) = std::env::var("PVM_ENV_UPDATE_PATH") { return Ok(PathBuf::from(env_path)); } - let pvm_dir = get_pvm_dir()?; - let shell_pid = std::env::var("PVM_SHELL_PID").unwrap_or_default(); - let filename = if shell_pid.is_empty() { - crate::constants::ENV_UPDATE_FILE.to_string() - } else { - format!("{}_{}", crate::constants::ENV_UPDATE_FILE, shell_pid) - }; - Ok(pvm_dir.join(filename)) + Ok(get_pvm_dir()?.join(crate::constants::ENV_UPDATE_FILE)) } /// Safely writes content to the environment update file with an exclusive lock. From 793c29bd075217c0f12b376d4f92b2b316eb9222 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:22:19 +0200 Subject: [PATCH 08/37] refactor(fs): drop unused override_path param from get_env_update_path Every caller passed None; the PVM_ENV_UPDATE_PATH env var is the real override mechanism. --- src/commands/install.rs | 2 +- src/commands/use_cmd.rs | 2 +- src/fs.rs | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index dda3152..84629e1 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -135,7 +135,7 @@ pub async fn execute_install_with( let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); let export_str2 = s.path(&bin_dir); - let env_file = crate::fs::get_env_update_path(None)?; + let env_file = crate::fs::get_env_update_path()?; crate::fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; // Note: process-global env is intentionally NOT mutated here. std::env::set_var diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index 9247db4..58d1a8a 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -185,7 +185,7 @@ impl Use { let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); let export_str2 = s.path(&bin_dir); - let env_file = fs::get_env_update_path(None)?; + let env_file = fs::get_env_update_path()?; fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; // Note: process-global env is intentionally NOT mutated here. std::env::set_var diff --git a/src/fs.rs b/src/fs.rs index 3f3a6d1..a269a4a 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -80,10 +80,7 @@ pub fn get_current_version() -> String { "system".to_string() } -pub fn get_env_update_path(override_path: Option) -> Result { - if let Some(path) = override_path { - return Ok(path); - } +pub fn get_env_update_path() -> Result { if let Ok(env_path) = std::env::var("PVM_ENV_UPDATE_PATH") { return Ok(PathBuf::from(env_path)); } From 3894baacf2fc8e39f3937d0af0df81f645801619 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:23:43 +0200 Subject: [PATCH 09/37] refactor(deps): trim tokio features to macros and rt-multi-thread Only tokio::main is used directly; reqwest enables the tokio features it needs itself. 'full' pulled signal, net, io and process drivers into a size-optimized binary for nothing. --- Cargo.lock | 59 ------------------------------------------------------ Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d95c5a..d2a2c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1021,15 +1021,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.33" @@ -1114,29 +1105,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1331,15 +1299,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -1564,12 +1523,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "security-framework" version = "3.7.0" @@ -1665,16 +1618,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -1873,9 +1816,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 5dcd5a0..ca99f79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ sha2 = "0.11.0" serde_json = "1.0.149" tar = "0.4.45" tempfile = "3.27.0" -tokio = { version = "1.52.1", features = ["full"] } +tokio = { version = "1.52.1", features = ["macros", "rt-multi-thread"] } [dev-dependencies] assert_cmd = "2.2.1" From eb10947550490764c4032549cebe0a656ca226f1 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:24:01 +0200 Subject: [PATCH 10/37] refactor(install): inline execute_install delegation wrapper It only forwarded to execute_install_with(v, true); callers now call that directly. --- src/commands/install.rs | 6 +----- src/commands/ls_remote.rs | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index 84629e1..bc91d7f 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -11,10 +11,6 @@ pub struct Install { pub version: Option, } -pub async fn execute_install(version: &str) -> Result<()> { - execute_install_with(version, true).await.map(|_| ()) -} - /// `prompt_activation` controls the trailing "Do you want to use PHP X now?" prompt and the /// resulting env-file write. Callers like `pvm use` set it to `false` because they will fall /// through to their own activation path with the returned resolved version. @@ -162,7 +158,7 @@ pub async fn execute_install_with( impl Install { pub async fn call(self) -> Result<()> { match self.version { - Some(v) => execute_install(&v).await, + Some(v) => execute_install_with(&v, true).await.map(|_| ()), None => { let ls_cmd = crate::commands::ls_remote::LsRemote { version_prefix: None, diff --git a/src/commands/ls_remote.rs b/src/commands/ls_remote.rs index 6b0bc5e..f3166e0 100644 --- a/src/commands/ls_remote.rs +++ b/src/commands/ls_remote.rs @@ -102,7 +102,7 @@ impl LsRemote { }; if !installed.contains(selected) { - crate::commands::install::execute_install(selected).await?; + crate::commands::install::execute_install_with(selected, true).await?; } else { println!( "{} PHP {} is already installed.", From 000c0eaf2ca5e5c43dec1b4e0f16c9d2b78c83f7 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:24:15 +0200 Subject: [PATCH 11/37] refactor(main): drop unused _pvm_dir binding get_versions_dir derives from the same root and create_dir_all creates the full path. --- src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index a1c4e3e..c77879a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,6 @@ use colored::Colorize; #[tokio::main] async fn main() -> Result<()> { - let _pvm_dir = fs::get_pvm_dir()?; let versions_dir = fs::get_versions_dir()?; std::fs::create_dir_all(&versions_dir)?; From 8a7106e5615ab2d8b6746010779fd7ba415ea2ca Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:25:14 +0200 Subject: [PATCH 12/37] refactor(build): drop chrono build-dependency, get timestamp from date -u Only supported build hosts are Linux/macOS where date(1) exists; build.rs already shells out to git. Extract cmd_stdout helper for the three command-to-string call sites. --- Cargo.lock | 82 ------------------------------------------------------ Cargo.toml | 3 -- build.rs | 36 +++++++++++------------- 3 files changed, 17 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2a2c00..5d69351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -211,19 +202,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.2" @@ -756,30 +734,6 @@ dependencies = [ "windows-registry", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1117,7 +1071,6 @@ version = "1.3.2" dependencies = [ "anyhow", "assert_cmd", - "chrono", "clap", "colored", "dialoguer", @@ -2120,41 +2073,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index ca99f79..4bf149c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,6 @@ assert_cmd = "2.2.1" predicates = "3.1.4" tempfile = "3.27.0" -[build-dependencies] -chrono = "0.4.44" - # Optimize for binary size and performance [profile.release] opt-level = "z" # Optimize for size diff --git a/build.rs b/build.rs index 2e57a8b..e38aa33 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,16 @@ use std::process::Command; +fn cmd_stdout(cmd: &str, args: &[&str]) -> Option { + Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn main() { println!("cargo:rerun-if-changed=.git/HEAD"); if let Ok(head) = std::fs::read_to_string(".git/HEAD") @@ -12,32 +23,19 @@ fn main() { println!("cargo:rerun-if-env-changed=CI"); println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); - let commit_hash = Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()); - - let build_time = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + // Only Linux/macOS are supported targets, so `date -u` is always available. + let build_time = + cmd_stdout("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]).unwrap_or_else(|| "unknown".to_string()); let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok(); let version = if is_ci { - let tag = Command::new("git") - .args(["describe", "--tags", "--always"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + let tag = cmd_stdout("git", &["describe", "--tags", "--always"]) .unwrap_or_else(|| "unknown".to_string()); format!("{} (built at: {})", tag, build_time) } else { + let commit_hash = cmd_stdout("git", &["rev-parse", "--short", "HEAD"]) + .unwrap_or_else(|| "unknown".to_string()); let pkg_version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string()); format!( From 7fbcdd6d538a8be90f8bf3d4ef05188fb09722d5 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:44:14 +0200 Subject: [PATCH 13/37] test(cli): add offline helpers seeding remote cache and installed versions Integration tests can now exercise version resolution and install logic without network access by pre-filling remote_cache-.json and fake versions//bin layouts. --- Cargo.toml | 1 + tests/cli.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4bf149c..81b3665 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ tokio = { version = "1.52.1", features = ["macros", "rt-multi-thread"] } [dev-dependencies] assert_cmd = "2.2.1" predicates = "3.1.4" +serde_json = "1.0.149" tempfile = "3.27.0" # Optimize for binary size and performance diff --git a/tests/cli.rs b/tests/cli.rs index 4067cde..cce84ea 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,54 @@ use predicates::prelude::*; +/// Seed the remote version cache so commands that hit the network resolve +/// entirely offline. Mirrors the cache filename scheme in network.rs. +fn seed_remote_cache(pvm_dir: &std::path::Path, versions: &[(&str, &[&str])]) { + let data: Vec<(String, Vec)> = versions + .iter() + .map(|(v, pkgs)| { + ( + v.to_string(), + pkgs.iter().map(|p| p.to_string()).collect(), + ) + }) + .collect(); + let target = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + std::fs::create_dir_all(pvm_dir).unwrap(); + std::fs::write( + pvm_dir.join(format!("remote_cache-{}.json", target)), + serde_json::to_string(&data).unwrap(), + ) + .unwrap(); +} + +/// Create a fake installed version with the given package binaries. +fn seed_installed_version(pvm_dir: &std::path::Path, version: &str, packages: &[&str]) { + let bin_dir = pvm_dir.join("versions").join(version).join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + for pkg in packages { + let file = match *pkg { + "cli" => "php", + "fpm" => "php-fpm", + "micro" => "micro.sfx", + other => panic!("unknown package {}", other), + }; + std::fs::write(bin_dir.join(file), "").unwrap(); + } +} + +#[test] +fn test_install_resolve_failure_uses_cache_offline() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("7.0"); + cmd.assert().failure().stderr(predicate::str::contains( + "Could not resolve a remotely available version matching '7.0'", + )); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); From 9fd3506e3cccd26cf887e6e451cc194d598c345e Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:46:12 +0200 Subject: [PATCH 14/37] feat(ls-remote)!: make ls-remote a plain listing, move picker to install pvm ls-remote now prints the remote versions (with installed markers and package tags) instead of opening an install picker, so scripts and non-TTY callers can consume it. The interactive picker moved to 'pvm install' without arguments, which already delegated here; it now bails with a usage hint when stdin is not a terminal. BREAKING CHANGE: 'pvm ls-remote' no longer prompts to install a selected version. Use 'pvm install' without arguments for the interactive picker. --- src/commands/install.rs | 111 +++++++++++++++++++++++++++++++++++--- src/commands/ls_remote.rs | 79 ++------------------------- tests/cli.rs | 53 +++++++++++++++--- 3 files changed, 157 insertions(+), 86 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index bc91d7f..f55387a 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -3,6 +3,8 @@ use crate::{fs, network}; use anyhow::Result; use clap::Parser; use colored::Colorize; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::io::IsTerminal; /// Install a specific PHP version #[derive(Parser, Debug)] @@ -155,16 +157,113 @@ pub async fn execute_install_with( } } +/// Interactive remote-version picker used by `pvm install` without a version: +/// quick-select aliases (latest, newest patch per minor) above the full list. +async fn pick_and_install() -> Result<()> { + if !std::io::stdin().is_terminal() { + anyhow::bail!("No version given. Usage: pvm install "); + } + + let versions_info = network::get_available_versions().await?; + if versions_info.is_empty() { + println!("{} No remote versions found.", "💡".yellow()); + return Ok(()); + } + + let installed = fs::list_installed_versions().unwrap_or_default(); + + let mut display_items = Vec::new(); + let mut target_versions = Vec::new(); // Parallel array tying display index to actual version string + + // Build "Quick Select" aliases + let mut minors = std::collections::BTreeMap::new(); + let mut highest_overall = None; + + for (v, _) in &versions_info { + highest_overall = Some(v.clone()); + let parts: Vec<&str> = v.split('.').collect(); + if parts.len() >= 2 { + let minor = format!("{}.{}", parts[0], parts[1]); + minors.insert(minor, v.clone()); // BTreeMap iterates ascending, keeps latest + } + } + + if let Some(highest) = highest_overall { + display_items.push(format!("latest ({})", highest).bold().to_string()); + target_versions.push(highest); + } + + // Add them in reverse order (newest minor first) for the quick select + for (minor, highest_patch) in minors.iter().rev() { + display_items.push( + format!("{} ({})", minor, highest_patch) + .bold() + .cyan() + .to_string(), + ); + target_versions.push(highest_patch.clone()); + } + + display_items.push("---".dimmed().to_string()); + target_versions.push("".to_string()); // Unselectable divider + + // Build the rest of the flat list + for (v, pkgs) in versions_info.iter().rev() { + let pkgs_str = pkgs.join(", "); + if installed.contains(v) { + display_items.push(format!( + "{} {} {} [{}]", + "✓".green(), + v, + "(installed)".dimmed(), + pkgs_str.cyan() + )); + } else { + display_items.push(format!(" {} [{}]", v, pkgs_str.cyan())); + } + target_versions.push(v.clone()); + } + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select a PHP version to install") + .default(0) + .items(&display_items) + .interact_opt()?; + + let selected = match selection { + Some(idx) => { + let target = &target_versions[idx]; + if target.is_empty() { + // They clicked the divider + println!("{} Invalid selection.", "✗".red()); + return Ok(()); + } + target + } + None => { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + }; + + if !installed.contains(selected) { + execute_install_with(selected, true).await?; + } else { + println!( + "{} PHP {} is already installed.", + "✓".green(), + selected.bold() + ); + } + + Ok(()) +} + impl Install { pub async fn call(self) -> Result<()> { match self.version { Some(v) => execute_install_with(&v, true).await.map(|_| ()), - None => { - let ls_cmd = crate::commands::ls_remote::LsRemote { - version_prefix: None, - }; - ls_cmd.call().await - } + None => pick_and_install().await, } } } diff --git a/src/commands/ls_remote.rs b/src/commands/ls_remote.rs index f3166e0..f7d888d 100644 --- a/src/commands/ls_remote.rs +++ b/src/commands/ls_remote.rs @@ -2,7 +2,6 @@ use crate::{fs, network}; use anyhow::Result; use clap::Parser; use colored::Colorize; -use dialoguer::{Select, theme::ColorfulTheme}; /// List all remote available PHP versions from static-php-cli #[derive(Parser, Debug)] @@ -27,88 +26,20 @@ impl LsRemote { let installed = fs::list_installed_versions().unwrap_or_default(); - let mut display_items = Vec::new(); - let mut target_versions = Vec::new(); // Parallel array tying display index to actual version string - - // Build "Quick Select" aliases - let mut minors = std::collections::BTreeMap::new(); - let mut highest_overall = None; - - for (v, _) in &versions_info { - highest_overall = Some(v.clone()); - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let minor = format!("{}.{}", parts[0], parts[1]); - minors.insert(minor, v.clone()); // BTreeMap iterates ascending, keeps latest - } - } - - if let Some(highest) = highest_overall { - display_items.push(format!("latest ({})", highest).bold().to_string()); - target_versions.push(highest); - } - - // Add them in reverse order (newest minor first) for the quick select - for (minor, highest_patch) in minors.iter().rev() { - display_items.push( - format!("{} ({})", minor, highest_patch) - .bold() - .cyan() - .to_string(), - ); - target_versions.push(highest_patch.clone()); - } - - display_items.push("---".dimmed().to_string()); - target_versions.push("".to_string()); // Unselectable divider - - // Build the rest of the flat list - for (v, pkgs) in versions_info.iter().rev() { + // Ascending order: the newest versions print last, right above the prompt. + for (v, pkgs) in &versions_info { let pkgs_str = pkgs.join(", "); if installed.contains(v) { - display_items.push(format!( + println!( "{} {} {} [{}]", "✓".green(), v, "(installed)".dimmed(), pkgs_str.cyan() - )); + ); } else { - display_items.push(format!(" {} [{}]", v, pkgs_str.cyan())); - } - target_versions.push(v.clone()); - } - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select a PHP version to install") - .default(0) - .items(&display_items) - .interact_opt()?; - - let selected = match selection { - Some(idx) => { - let target = &target_versions[idx]; - if target.is_empty() { - // They clicked the divider - println!("{} Invalid selection.", "✗".red()); - return Ok(()); - } - target + println!(" {} [{}]", v, pkgs_str.cyan()); } - None => { - println!("{} Operation cancelled.", "✗".red()); - return Ok(()); - } - }; - - if !installed.contains(selected) { - crate::commands::install::execute_install_with(selected, true).await?; - } else { - println!( - "{} PHP {} is already installed.", - "✓".green(), - selected.bold() - ); } Ok(()) diff --git a/tests/cli.rs b/tests/cli.rs index cce84ea..11f1da3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -5,12 +5,7 @@ use predicates::prelude::*; fn seed_remote_cache(pvm_dir: &std::path::Path, versions: &[(&str, &[&str])]) { let data: Vec<(String, Vec)> = versions .iter() - .map(|(v, pkgs)| { - ( - v.to_string(), - pkgs.iter().map(|p| p.to_string()).collect(), - ) - }) + .map(|(v, pkgs)| (v.to_string(), pkgs.iter().map(|p| p.to_string()).collect())) .collect(); let target = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); std::fs::create_dir_all(pvm_dir).unwrap(); @@ -49,6 +44,52 @@ fn test_install_resolve_failure_uses_cache_offline() { )); } +#[test] +fn test_ls_remote_plain_listing() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache( + temp_dir.path(), + &[("8.9.6", &["cli", "fpm"]), ("8.9.7", &["cli"])], + ); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("ls-remote"); + cmd.assert() + .success() + .stdout(predicate::str::contains("8.9.7 [cli]")) + .stdout(predicate::str::contains("8.9.6")) + .stdout(predicate::str::contains("(installed)")); +} + +#[test] +fn test_ls_remote_prefix_filter() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.8.1", &["cli"]), ("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("ls-remote").arg("8.9"); + cmd.assert() + .success() + .stdout(predicate::str::contains("8.9.7")) + .stdout(predicate::str::contains("8.8.1").not()); +} + +#[test] +fn test_install_no_version_non_tty_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("No version given")); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); From df9859d997d79560e4f3a4aa620fbda14ab03d54 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:49:19 +0200 Subject: [PATCH 15/37] feat(install): non-interactive installs via --packages, -y and non-TTY defaults - pvm install gains --packages cli,fpm,micro (validated against the remote index) and -y/--yes; without a terminal the package selection defaults to cli instead of failing in dialoguer. - pvm use gains -y/--yes for the install-missing and patch-update prompts; patch-update offers are now skipped entirely without a terminal so scripts never trigger surprise downloads. - Patch updates reuse the package set of the version being replaced instead of re-prompting. - New prompt::confirm helper centralizes Confirm handling (assume-yes, non-TTY returns the default); uninstall now uses it too. --- src/commands/init.rs | 1 + src/commands/install.rs | 123 +++++++++++++++++++++++++++----------- src/commands/uninstall.rs | 19 ++---- src/commands/use_cmd.rs | 84 +++++++++++++------------- src/interactive.rs | 7 ++- src/main.rs | 1 + src/prompt.rs | 21 +++++++ tests/cli.rs | 26 ++++++++ 8 files changed, 189 insertions(+), 93 deletions(-) create mode 100644 src/prompt.rs diff --git a/src/commands/init.rs b/src/commands/init.rs index 984511a..051d2c3 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -60,6 +60,7 @@ impl Init { let use_cmd = crate::commands::use_cmd::Use { version: Some(selected.clone()), silent: false, + yes: false, }; use_cmd.call().await?; } diff --git a/src/commands/install.rs b/src/commands/install.rs index f55387a..09f5eb2 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -11,14 +11,25 @@ use std::io::IsTerminal; pub struct Install { /// The version to install, or "latest" pub version: Option, + + /// Packages to install without prompting (comma-separated: cli,fpm,micro) + #[arg(long, value_delimiter = ',')] + pub packages: Vec, + + /// Auto-approve prompts + #[arg(short = 'y', long = "yes")] + pub yes: bool, } /// `prompt_activation` controls the trailing "Do you want to use PHP X now?" prompt and the /// resulting env-file write. Callers like `pvm use` set it to `false` because they will fall /// through to their own activation path with the returned resolved version. +/// `packages` skips the interactive package selection; `assume_yes` auto-approves prompts. pub async fn execute_install_with( version: &str, prompt_activation: bool, + packages: &[String], + assume_yes: bool, ) -> Result> { let versions_dir = fs::get_versions_dir()?; std::fs::create_dir_all(&versions_dir)?; @@ -41,30 +52,11 @@ pub async fn execute_install_with( anyhow::bail!("No packages found for PHP {}", resolved_version); } - let theme = dialoguer::theme::ColorfulTheme::default(); - let selections = dialoguer::MultiSelect::with_theme(&theme) - .with_prompt(format!( - "Select packages to install for PHP {}", - resolved_version - )) - .items(&available_packages) - .defaults( - &available_packages - .iter() - .map(|p| p == "cli") - .collect::>(), - ) - .interact()?; - - if selections.is_empty() { + let selected_packages = select_packages(&resolved_version, &available_packages, packages)?; + let Some(selected_packages) = selected_packages else { println!("{} No packages selected. Operation cancelled.", "✗".red()); return Ok(None); - } - - let selected_packages: Vec = selections - .into_iter() - .map(|i| available_packages[i].clone()) - .collect(); + }; let dest = versions_dir.join(&resolved_version); let dest_existed = dest.exists(); @@ -115,16 +107,13 @@ pub async fn execute_install_with( } let use_now = cli_selected - && dialoguer::Confirm::with_theme(&theme) - .with_prompt( - format!("Do you want to use PHP {} now?", resolved_version) - .bold() - .to_string(), - ) - .default(true) - .interact_opt() - .unwrap_or(Some(false)) - .unwrap_or(false); + && crate::prompt::confirm( + &format!("Do you want to use PHP {} now?", resolved_version) + .bold() + .to_string(), + true, + assume_yes, + )?; if use_now { let v = crate::fs::resolve_local_version(&resolved_version)?; @@ -157,9 +146,69 @@ pub async fn execute_install_with( } } +/// Decide which packages to install: an explicit list is validated against +/// what the remote offers; without one, a non-interactive stdin defaults to +/// "cli" and a terminal gets the MultiSelect. Returns None on empty selection. +fn select_packages( + resolved_version: &str, + available_packages: &[String], + requested: &[String], +) -> Result>> { + if !requested.is_empty() { + for pkg in requested { + if !available_packages.contains(pkg) { + anyhow::bail!( + "Package '{}' is not available for PHP {} (available: {})", + pkg, + resolved_version, + available_packages.join(", ") + ); + } + } + return Ok(Some(requested.to_vec())); + } + + if !std::io::stdin().is_terminal() { + let default_pkg = "cli".to_string(); + if !available_packages.contains(&default_pkg) { + anyhow::bail!( + "No terminal to select packages and the default 'cli' package is not available for PHP {} (available: {}). Use --packages.", + resolved_version, + available_packages.join(", ") + ); + } + return Ok(Some(vec![default_pkg])); + } + + let theme = dialoguer::theme::ColorfulTheme::default(); + let selections = dialoguer::MultiSelect::with_theme(&theme) + .with_prompt(format!( + "Select packages to install for PHP {}", + resolved_version + )) + .items(available_packages) + .defaults( + &available_packages + .iter() + .map(|p| p == "cli") + .collect::>(), + ) + .interact()?; + + if selections.is_empty() { + return Ok(None); + } + Ok(Some( + selections + .into_iter() + .map(|i| available_packages[i].clone()) + .collect(), + )) +} + /// Interactive remote-version picker used by `pvm install` without a version: /// quick-select aliases (latest, newest patch per minor) above the full list. -async fn pick_and_install() -> Result<()> { +async fn pick_and_install(packages: &[String], assume_yes: bool) -> Result<()> { if !std::io::stdin().is_terminal() { anyhow::bail!("No version given. Usage: pvm install "); } @@ -247,7 +296,7 @@ async fn pick_and_install() -> Result<()> { }; if !installed.contains(selected) { - execute_install_with(selected, true).await?; + execute_install_with(selected, true, packages, assume_yes).await?; } else { println!( "{} PHP {} is already installed.", @@ -262,8 +311,10 @@ async fn pick_and_install() -> Result<()> { impl Install { pub async fn call(self) -> Result<()> { match self.version { - Some(v) => execute_install_with(&v, true).await.map(|_| ()), - None => pick_and_install().await, + Some(v) => execute_install_with(&v, true, &self.packages, self.yes) + .await + .map(|_| ()), + None => pick_and_install(&self.packages, self.yes).await, } } } diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs index 3a0095c..b953c73 100644 --- a/src/commands/uninstall.rs +++ b/src/commands/uninstall.rs @@ -2,8 +2,6 @@ use crate::fs; use anyhow::Result; use clap::Parser; use colored::Colorize; -use std::io::IsTerminal; - use dialoguer::{Select, theme::ColorfulTheme}; /// Uninstall a specific PHP version @@ -59,19 +57,10 @@ impl Uninstall { ); } - let is_tty = std::io::stdin().is_terminal(); - if !self.yes && is_tty { - let prompt = format!("Are you sure you want to uninstall PHP {}?", version); - let confirmed = dialoguer::Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt.bold().to_string()) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if !confirmed { - println!("{} Operation cancelled.", "✗".red()); - return Ok(()); - } + let question = format!("Are you sure you want to uninstall PHP {}?", version); + if !crate::prompt::confirm(&question.bold().to_string(), true, self.yes)? { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); } println!("{} Removing PHP {}...", "↻".blue(), version); diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index 58d1a8a..52936e2 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -1,9 +1,10 @@ use crate::constants::{MULTISHELL_PATH_VAR, PHP_VERSION_FILE}; -use crate::{fs, shell, update}; +use crate::{fs, prompt, shell, update}; use anyhow::{Context, Result}; use clap::Parser; use colored::Colorize; -use dialoguer::{Confirm, Select, theme::ColorfulTheme}; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::io::IsTerminal; use std::path::Path; /// Change PHP version @@ -15,6 +16,10 @@ pub struct Use { /// Skip interactive prompts when the requested version is missing (used by shell hooks). #[arg(long, hide = true)] pub silent: bool, + + /// Auto-approve prompts (install missing versions, patch updates) + #[arg(short = 'y', long = "yes")] + pub yes: bool, } impl Use { @@ -27,24 +32,20 @@ impl Use { return Ok(()); } - let prompt = format!( + let question = format!( "PHP {} is not installed locally. Do you want to install it now?", v.bold() ); - let install_now = Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if !install_now { + if !prompt::confirm(&question, true, self.yes)? { eprintln!("{} Operation cancelled.", "✗".red()); return Ok(()); } // Skip install's own "use now?" prompt — we fall through to // the activation path below with the freshly installed version. - match crate::commands::install::execute_install_with(v, false).await? { + match crate::commands::install::execute_install_with(v, false, &[], self.yes) + .await? + { Some(installed) => installed, None => return Ok(()), } @@ -61,22 +62,18 @@ impl Use { } None => { if !self.silent { - let prompt = format!( + let question = format!( "PHP {} (from {}) is not installed locally. Do you want to install it now?", trimmed.bold(), PHP_VERSION_FILE.bold() ); - let install_now = - Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if install_now { + if prompt::confirm(&question, true, self.yes)? { if let Some(installed) = crate::commands::install::execute_install_with( - &trimmed, false, + &trimmed, + false, + &[], + self.yes, ) .await? { @@ -121,25 +118,33 @@ impl Use { } }; - if let Ok(Some(newer_version)) = update::check_for_updates(&version).await { - let prompt = format!( + // Patch-update offers are an interactive-only feature: scripts must not + // trigger surprise downloads, so the check is skipped without a terminal. + if std::io::stdin().is_terminal() + && let Ok(Some(newer_version)) = update::check_for_updates(&version).await + { + let question = format!( "{} A new patch version is available: {} ➜ {}. Do you want to install and use it now?", "💡".yellow(), version.dimmed(), newer_version.green().bold() ); - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(true) - .interact_opt()? - .unwrap_or(false) - { - let install_cmd = crate::commands::install::Install { - version: Some(newer_version.clone()), - }; - install_cmd.call().await?; - version = newer_version; + if prompt::confirm(&question, true, self.yes)? { + // Carry over the packages of the version being replaced so the + // upgrade does not re-ask what is already known. + let installed_pkgs = fs::get_installed_packages(&version); + if crate::commands::install::execute_install_with( + &newer_version, + false, + &installed_pkgs, + self.yes, + ) + .await? + .is_some() + { + version = newer_version; + } } } @@ -156,17 +161,14 @@ impl Use { && let Ok(current_file_ver) = std::fs::read_to_string(PHP_VERSION_FILE) && current_file_ver.trim() != version { - let prompt = format!( + let question = format!( "A {} file is present ({}). Do you want to apply this change to the directory?", PHP_VERSION_FILE, current_file_ver.trim().yellow() ); - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(&prompt) - .default(false) - .interact_opt()? - .unwrap_or(false) - { + // Deliberately not covered by --yes: writing .php-version is a + // side effect the user should opt into explicitly. + if prompt::confirm(&question, false, false)? { std::fs::write(PHP_VERSION_FILE, &version) .with_context(|| format!("Failed to update {}", PHP_VERSION_FILE))?; eprintln!( diff --git a/src/interactive.rs b/src/interactive.rs index 1a0b7d6..62fd5a0 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -37,11 +37,16 @@ pub async fn run_root_menu() -> Result<()> { let cmd = commands::use_cmd::Use { version: None, silent: false, + yes: false, }; cmd.call().await } 1 => { - let cmd = commands::install::Install { version: None }; + let cmd = commands::install::Install { + version: None, + packages: vec![], + yes: false, + }; cmd.call().await } 2 => { diff --git a/src/main.rs b/src/main.rs index c77879a..380bc90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod constants; mod fs; mod interactive; mod network; +mod prompt; mod shell; mod update; diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..6ef172b --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,21 @@ +use anyhow::{Context, Result}; +use dialoguer::{Confirm, theme::ColorfulTheme}; +use std::io::IsTerminal; + +/// Ask a yes/no question. `assume_yes` short-circuits to true; a +/// non-interactive stdin returns `default` so scripts never hang on a +/// hidden prompt. Esc/q counts as "no". +pub fn confirm(prompt: &str, default: bool, assume_yes: bool) -> Result { + if assume_yes { + return Ok(true); + } + if !std::io::stdin().is_terminal() { + return Ok(default); + } + Ok(Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .default(default) + .interact_opt() + .context("Failed to read confirmation from terminal")? + .unwrap_or(false)) +} diff --git a/tests/cli.rs b/tests/cli.rs index 11f1da3..c0d8b16 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -90,6 +90,32 @@ fn test_install_no_version_non_tty_fails() { .stderr(predicate::str::contains("No version given")); } +#[test] +fn test_install_packages_flag_rejects_unavailable() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("8.9.7").arg("--packages").arg("fpm"); + cmd.assert().failure().stderr(predicate::str::contains( + "Package 'fpm' is not available for PHP 8.9.7", + )); +} + +#[test] +fn test_install_non_tty_without_cli_package_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["fpm"])]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("8.9.7"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Use --packages")); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); From 8e48f3282983d53277b6fd078fee46ce1ac63666 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:50:01 +0200 Subject: [PATCH 16/37] feat(install): detect installed packages and preselect missing ones Installing an already-present version now prints what is installed, preselects only the missing packages in the interactive selection, and becomes an idempotent no-op for non-interactive callers when cli is already there. Explicit --packages still reinstalls (repair). --- src/commands/install.rs | 40 ++++++++++++++++++++++++++++++++++++---- tests/cli.rs | 16 ++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index 09f5eb2..cc08c3b 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -52,7 +52,30 @@ pub async fn execute_install_with( anyhow::bail!("No packages found for PHP {}", resolved_version); } - let selected_packages = select_packages(&resolved_version, &available_packages, packages)?; + let already_installed = fs::get_installed_packages(&resolved_version); + if !already_installed.is_empty() { + println!( + "{} PHP {} is already installed [{}]", + "💡".yellow(), + resolved_version.bold(), + already_installed.join(", ") + ); + // Idempotent no-op for scripts: nothing was explicitly requested and + // the default cli package is already present. + if packages.is_empty() + && !std::io::stdin().is_terminal() + && already_installed.iter().any(|p| p == "cli") + { + return Ok(Some(resolved_version)); + } + } + + let selected_packages = select_packages( + &resolved_version, + &available_packages, + packages, + &already_installed, + )?; let Some(selected_packages) = selected_packages else { println!("{} No packages selected. Operation cancelled.", "✗".red()); return Ok(None); @@ -147,12 +170,14 @@ pub async fn execute_install_with( } /// Decide which packages to install: an explicit list is validated against -/// what the remote offers; without one, a non-interactive stdin defaults to -/// "cli" and a terminal gets the MultiSelect. Returns None on empty selection. +/// what the remote offers (and may reinstall); without one, a non-interactive +/// stdin defaults to "cli" and a terminal gets the MultiSelect, preselecting +/// whatever is not yet installed. Returns None on empty selection. fn select_packages( resolved_version: &str, available_packages: &[String], requested: &[String], + already_installed: &[String], ) -> Result>> { if !requested.is_empty() { for pkg in requested { @@ -190,7 +215,14 @@ fn select_packages( .defaults( &available_packages .iter() - .map(|p| p == "cli") + .map(|p| { + if already_installed.is_empty() { + p == "cli" + } else { + // Preselect what is missing so "add fpm later" is one Enter away. + !already_installed.contains(p) + } + }) .collect::>(), ) .interact()?; diff --git a/tests/cli.rs b/tests/cli.rs index c0d8b16..6c693e7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -116,6 +116,22 @@ fn test_install_non_tty_without_cli_package_fails() { .stderr(predicate::str::contains("Use --packages")); } +#[test] +fn test_install_already_installed_is_idempotent_non_tty() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli", "fpm"])]); + seed_installed_version(temp_dir.path(), "8.9.7", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("install").arg("8.9.7"); + // Succeeds without any download attempt (the fake version would 404). + cmd.assert() + .success() + .stdout(predicate::str::contains("already installed [cli]")); + assert!(temp_dir.path().join("versions/8.9.7/bin/php").exists()); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); From 61212eadc3be55f33adc3f53153d295655ef5a4a Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:51:22 +0200 Subject: [PATCH 17/37] feat(default): persistent default version for new shells pvm default stores the version in PVM_DIR/default; pvm env activates it on shell startup, so a chosen version finally survives opening a new terminal. pvm default without argument shows an interactive picker (or prints the current default when non-TTY), and pvm default system clears it. Also added to the interactive menu. --- src/cli.rs | 5 +++ src/commands/default_cmd.rs | 78 +++++++++++++++++++++++++++++++++++++ src/commands/env.rs | 15 ++++++- src/commands/mod.rs | 1 + src/constants.rs | 2 + src/fs.rs | 30 ++++++++++++++ src/interactive.rs | 7 +++- tests/cli.rs | 58 +++++++++++++++++++++++++++ 8 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/commands/default_cmd.rs diff --git a/src/cli.rs b/src/cli.rs index 77f84a8..38e451f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,6 +45,10 @@ pub enum Commands { #[clap(name = "current")] Current(commands::current::Current), + /// Set the default PHP version for new shells + #[clap(name = "default")] + Default(commands::default_cmd::DefaultCmd), + /// Uninstall a PHP version #[clap(name = "uninstall", visible_aliases = &["rm", "remove"])] Uninstall(commands::uninstall::Uninstall), @@ -67,6 +71,7 @@ impl Commands { Self::Ls(cmd) => cmd.call().await, Self::LsRemote(cmd) => cmd.call().await, Self::Current(cmd) => cmd.call().await, + Self::Default(cmd) => cmd.call().await, Self::Uninstall(cmd) => cmd.call().await, Self::Init(cmd) => cmd.call().await, Self::SelfUpdate(cmd) => cmd.call().await, diff --git a/src/commands/default_cmd.rs b/src/commands/default_cmd.rs new file mode 100644 index 0000000..dc3789d --- /dev/null +++ b/src/commands/default_cmd.rs @@ -0,0 +1,78 @@ +use crate::fs; +use anyhow::Result; +use clap::Parser; +use colored::Colorize; +use dialoguer::{Select, theme::ColorfulTheme}; +use std::io::IsTerminal; + +/// Set the default PHP version for new shells (evaluated by 'pvm env') +#[derive(Parser, Debug)] +pub struct DefaultCmd { + /// The version to use as default, "system" to clear (omit for interactive list) + pub version: Option, +} + +impl DefaultCmd { + pub async fn call(self) -> Result<()> { + let version = match self.version { + Some(ref v) if v == "system" => { + fs::clear_default_version()?; + println!( + "{} Default cleared. New shells start on the system PHP.", + "✓".green() + ); + return Ok(()); + } + Some(ref v) => fs::resolve_local_version(v)?, + None => { + if !std::io::stdin().is_terminal() { + match fs::get_default_version()? { + Some(v) => println!("{}", v), + None => println!("none"), + } + return Ok(()); + } + + let items = fs::get_aliased_versions()?; + if items.is_empty() { + eprintln!("{} No PHP versions are currently installed.", "💡".yellow()); + return Ok(()); + } + + let current_default = fs::get_default_version()?.unwrap_or_default(); + let displays: Vec = items + .iter() + .map(|i| { + if i.version == current_default { + format!("{} {}", i.display, "(default)".cyan()) + } else { + i.display.clone() + } + }) + .collect(); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select the default PHP version for new shells") + .default(0) + .items(&displays) + .interact_opt()?; + + match selection { + Some(idx) => items[idx].version.clone(), + None => { + eprintln!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + } + } + }; + + fs::set_default_version(&version)?; + println!( + "{} Default version set to {}. New shells pick it up via 'pvm env'.", + "✓".green(), + version.bold() + ); + Ok(()) + } +} diff --git a/src/commands/env.rs b/src/commands/env.rs index 913d72d..942fa34 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -1,4 +1,4 @@ -use crate::constants::PVM_DIR_VAR; +use crate::constants::{MULTISHELL_PATH_VAR, PVM_DIR_VAR}; use crate::shell; use anyhow::Result; use clap::Parser; @@ -31,6 +31,19 @@ impl Env { println!("{}", s.wrapper_fn()); println!("{}", s.use_on_cd()); + // Activate the persisted default version ('pvm default') so every new + // shell starts on it instead of the system PHP. + if let Some(version) = crate::fs::get_default_version()? + && crate::fs::is_version_installed(&version)? + { + let bin_dir = crate::fs::get_version_bin_dir(&version)?; + println!( + "{}", + s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()) + ); + println!("{}", s.path(&bin_dir)); + } + Ok(()) } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 317902b..007926a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod current; +pub mod default_cmd; pub mod env; pub mod init; pub mod install; diff --git a/src/constants.rs b/src/constants.rs index b178630..7ecb197 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -13,6 +13,8 @@ pub const REMOTE_CACHE_FILE: &str = "remote_cache.json"; /// The name of the file used as a guard for the update check. pub const UPDATE_CHECK_GUARD_FILE: &str = ".update_check_guard"; +pub const DEFAULT_VERSION_FILE: &str = "default"; + /// The name of the file used to store the PHP version for a directory. pub const PHP_VERSION_FILE: &str = ".php-version"; diff --git a/src/fs.rs b/src/fs.rs index a269a4a..5e6f66e 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -87,6 +87,36 @@ pub fn get_env_update_path() -> Result { Ok(get_pvm_dir()?.join(crate::constants::ENV_UPDATE_FILE)) } +/// The version new shells start on, persisted via 'pvm default'. +pub fn get_default_version() -> Result> { + let path = get_pvm_dir()?.join(crate::constants::DEFAULT_VERSION_FILE); + match std::fs::read_to_string(path) { + Ok(content) => { + let trimmed = content.trim().to_string(); + Ok((!trimmed.is_empty()).then_some(trimmed)) + } + Err(_) => Ok(None), + } +} + +pub fn set_default_version(version: &str) -> Result<()> { + let pvm_dir = get_pvm_dir()?; + std::fs::create_dir_all(&pvm_dir)?; + std::fs::write( + pvm_dir.join(crate::constants::DEFAULT_VERSION_FILE), + version, + ) + .context("Failed to write default version file") +} + +pub fn clear_default_version() -> Result<()> { + let path = get_pvm_dir()?.join(crate::constants::DEFAULT_VERSION_FILE); + if path.exists() { + std::fs::remove_file(path).context("Failed to remove default version file")?; + } + Ok(()) +} + /// Safely writes content to the environment update file with an exclusive lock. pub fn write_env_file_locked(path: &PathBuf, content: &str) -> Result<()> { use std::io::Write; diff --git a/src/interactive.rs b/src/interactive.rs index 62fd5a0..4758404 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -13,6 +13,7 @@ pub async fn run_root_menu() -> Result<()> { "List (View locally installed versions)", "List-Remote (View all available cloud versions)", "Current (Print the currently active PHP version)", + "Default (Set the default PHP version for new shells)", "Init (Initialize a .php-version file)", "Exit", ]; @@ -28,7 +29,7 @@ pub async fn run_root_menu() -> Result<()> { None => break, // Esc/Q exits the menu entirely }; - if choice == 7 { + if choice == options.len() - 1 { break; } @@ -71,6 +72,10 @@ pub async fn run_root_menu() -> Result<()> { cmd.call().await } 6 => { + let cmd = commands::default_cmd::DefaultCmd { version: None }; + cmd.call().await + } + 7 => { let cmd = commands::init::Init {}; cmd.call().await } diff --git a/tests/cli.rs b/tests/cli.rs index 6c693e7..9267b75 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -132,6 +132,64 @@ fn test_install_already_installed_is_idempotent_non_tty() { assert!(temp_dir.path().join("versions/8.9.7/bin/php").exists()); } +#[test] +fn test_default_set_show_and_env_activation() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + // Set the default + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default").arg("8.9.6"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Default version set to 8.9.6")); + + // Non-TTY 'pvm default' prints it + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default"); + cmd.assert() + .success() + .stdout(predicate::str::contains("8.9.6")); + + // 'pvm env' activates it for new shells + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("env").arg("--shell=bash"); + cmd.assert() + .success() + .stdout(predicate::str::contains("export PVM_MULTISHELL_PATH=")) + .stdout(predicate::str::contains("versions/8.9.6/bin")); + + // 'pvm default system' clears it again + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default").arg("system"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Default cleared")); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("env").arg("--shell=bash"); + cmd.assert() + .success() + .stdout(predicate::str::contains("PVM_MULTISHELL_PATH").not()); +} + +#[test] +fn test_default_rejects_missing_version() { + let temp_dir = tempfile::tempdir().unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("default").arg("9.9.9"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("not installed locally")); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); From 2d0a1cd99ec51e5dbe15c39849976766f447e7a3 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:52:59 +0200 Subject: [PATCH 18/37] feat(use): support switching back to system PHP pvm use system writes a deactivation snippet to the env-update file: it clears PVM_MULTISHELL_PATH and filters every PVM_DIR/versions entry out of PATH (tr/grep/paste for bash and zsh, string match for fish). Until now there was no way back to the system PHP without opening a new shell. --- src/commands/use_cmd.rs | 9 +++++++++ src/shell.rs | 42 +++++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 20 ++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index 52936e2..b33425c 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -24,6 +24,15 @@ pub struct Use { impl Use { pub async fn call(self) -> Result<()> { + // "system" is not a real version: deactivate pvm for this shell instead. + if self.version.as_deref() == Some("system") { + let s = shell::detect_shell(); + let env_file = fs::get_env_update_path()?; + fs::write_env_file_locked(&env_file, &s.deactivate(&fs::get_versions_dir()?))?; + eprintln!("{} Switched to system PHP", "✓".green()); + return Ok(()); + } + let mut version = match self.version { Some(ref v) => match fs::try_resolve_local_version(v)? { Some(resolved) => resolved, diff --git a/src/shell.rs b/src/shell.rs index 480ab80..5a05eaa 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -6,6 +6,9 @@ pub trait Shell { fn set_env_var(&self, name: &str, value: &str) -> String; fn use_on_cd(&self) -> String; fn wrapper_fn(&self) -> String; + /// Emit commands that drop every pvm-managed entry from PATH and clear + /// the multishell marker, returning the shell to the system PHP. + fn deactivate(&self, versions_dir: &Path) -> String; } /// Quote a string for POSIX shells (bash/zsh) by wrapping it in single quotes @@ -80,6 +83,13 @@ pvm() {{ ) } +fn posix_deactivate(versions_dir: &Path) -> String { + format!( + "export PVM_MULTISHELL_PATH=''\nexport PATH=\"$(printf '%s' \"$PATH\" | tr ':' '\\n' | grep -vF {} | paste -sd : -)\"", + posix_single_quote(&versions_dir.display().to_string()) + ) +} + pub struct Bash; impl Shell for Bash { @@ -110,6 +120,10 @@ fi fn wrapper_fn(&self) -> String { posix_wrapper_fn() } + + fn deactivate(&self, versions_dir: &Path) -> String { + posix_deactivate(versions_dir) + } } pub struct Zsh; @@ -139,6 +153,10 @@ add-zsh-hook chpwd _pvm_cd_hook fn wrapper_fn(&self) -> String { posix_wrapper_fn() } + + fn deactivate(&self, versions_dir: &Path) -> String { + posix_deactivate(versions_dir) + } } pub struct Fish; @@ -166,6 +184,13 @@ end .to_string() } + fn deactivate(&self, versions_dir: &Path) -> String { + format!( + "set -gx PVM_MULTISHELL_PATH ''\nset -gx PATH (string match -v -- {} $PATH)", + fish_single_quote(&format!("{}*", versions_dir.display())) + ) + } + fn wrapper_fn(&self) -> String { format!( " @@ -267,4 +292,21 @@ mod tests { let fish = Fish; assert_eq!(fish.set_env_var("X", "a'b\\c"), "set -gx X 'a\\'b\\\\c'"); } + + #[test] + fn test_bash_deactivate_filters_versions_dir() { + let bash = Bash; + let out = bash.deactivate(std::path::Path::new("/data/pvm/versions")); + assert!(out.contains("export PVM_MULTISHELL_PATH=''")); + assert!(out.contains("grep -vF '/data/pvm/versions'")); + assert!(out.contains("paste -sd : -")); + } + + #[test] + fn test_fish_deactivate_filters_versions_dir() { + let fish = Fish; + let out = fish.deactivate(std::path::Path::new("/data/pvm/versions")); + assert!(out.contains("set -gx PVM_MULTISHELL_PATH ''")); + assert!(out.contains("string match -v -- '/data/pvm/versions*' $PATH")); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 9267b75..bdb04b8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -190,6 +190,26 @@ fn test_default_rejects_missing_version() { .stderr(predicate::str::contains("not installed locally")); } +#[test] +fn test_use_system_writes_deactivation_env_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let env_file = temp_dir.path().join("custom_env_update"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_ENV_UPDATE_PATH", &env_file); + cmd.env("SHELL", "/bin/bash"); + cmd.arg("use").arg("system"); + cmd.assert() + .success() + .stderr(predicate::str::contains("Switched to system PHP")); + + let content = std::fs::read_to_string(env_file).unwrap(); + assert!(content.contains("export PVM_MULTISHELL_PATH=''")); + assert!(content.contains("grep -vF")); + assert!(content.contains("versions")); +} + #[test] fn test_help() { let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); From e9a32c6a7370bdc2e1e14696abc822af0d9e93a1 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 17 Jul 2026 23:55:05 +0200 Subject: [PATCH 19/37] feat(ls): interactive version switch from list with .php-version save offer pvm ls on a terminal now shows the installed versions as a picker: Enter switches the shell to the selection, Esc just exits. After a picker-driven switch (ls or pvm use without arguments) pvm offers to save the choice to .php-version. Scripts and pipes still get the plain list. The activation tail of pvm use moved into a shared activate() used by both flows, and a missing version without a terminal now fails with a usage hint instead of a dialoguer error. --- src/commands/ls.rs | 25 ++++- src/commands/use_cmd.rs | 235 +++++++++++++++++++++++++++------------- tests/cli.rs | 29 +++++ 3 files changed, 211 insertions(+), 78 deletions(-) diff --git a/src/commands/ls.rs b/src/commands/ls.rs index 0db9b20..8b90d3c 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,20 +1,26 @@ +use crate::commands::use_cmd::{ActivateOpts, activate, pick_installed_version}; use crate::fs; use anyhow::Result; use clap::Parser; use colored::Colorize; +use std::io::IsTerminal; -/// List all locally installed PHP versions +/// List all locally installed PHP versions (interactively switch on a terminal) #[derive(Parser, Debug)] pub struct Ls; impl Ls { pub async fn call(self) -> Result<()> { let items = fs::get_aliased_versions()?; - let current = fs::get_current_version(); - if items.is_empty() { println!("No PHP versions installed."); - } else { + return Ok(()); + } + + // Scripts and pipes get the plain list; a terminal gets a picker that + // switches the shell to the selected version (Esc just leaves). + if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) { + let current = fs::get_current_version(); for item in items { let pkgs_str = item.packages.join(", "); if item.version == current { @@ -28,8 +34,17 @@ impl Ls { println!(" {} [{}]", item.display, pkgs_str.cyan()); } } + return Ok(()); } - Ok(()) + let current = fs::get_current_version(); + match pick_installed_version("Installed PHP versions — Enter switches, Esc exits")? { + Some(version) if version == current => { + eprintln!("{} PHP {} is already active.", "✓".green(), version.bold()); + Ok(()) + } + Some(version) => activate(version, ActivateOpts::picker(false)).await, + None => Ok(()), + } } } diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index b33425c..90e937d 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -33,7 +33,7 @@ impl Use { return Ok(()); } - let mut version = match self.version { + let version = match self.version { Some(ref v) => match fs::try_resolve_local_version(v)? { Some(resolved) => resolved, None => { @@ -103,78 +103,142 @@ impl Use { if let Some(resolved) = resolved_version { resolved } else { - let items = fs::get_aliased_versions()?; - if items.is_empty() { - eprintln!("{} No PHP versions are currently installed.", "💡".yellow()); - return Ok(()); - } - - let displays: Vec = items.iter().map(|i| i.display.clone()).collect(); - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select a locally installed PHP version to use") - .default(0) - .items(&displays) - .interact_opt()?; - - match selection { - Some(idx) => items[idx].version.clone(), - None => { - eprintln!("{} Operation cancelled.", "✗".red()); - return Ok(()); - } + match pick_installed_version("Select a locally installed PHP version to use")? { + Some(v) => return activate(v, ActivateOpts::picker(self.yes)).await, + None => return Ok(()), } } } }; - // Patch-update offers are an interactive-only feature: scripts must not - // trigger surprise downloads, so the check is skipped without a terminal. - if std::io::stdin().is_terminal() - && let Ok(Some(newer_version)) = update::check_for_updates(&version).await - { - let question = format!( - "{} A new patch version is available: {} ➜ {}. Do you want to install and use it now?", - "💡".yellow(), - version.dimmed(), - newer_version.green().bold() - ); + activate( + version, + ActivateOpts { + offer_save: false, + assume_yes: self.yes, + quiet: self.silent, + }, + ) + .await + } +} - if prompt::confirm(&question, true, self.yes)? { - // Carry over the packages of the version being replaced so the - // upgrade does not re-ask what is already known. - let installed_pkgs = fs::get_installed_packages(&version); - if crate::commands::install::execute_install_with( - &newer_version, - false, - &installed_pkgs, - self.yes, - ) - .await? - .is_some() - { - version = newer_version; - } +/// Show the installed-version picker. Ok(None) means empty list or cancel +/// (both already reported to the user). +pub(crate) fn pick_installed_version(prompt_text: &str) -> Result> { + if !std::io::stdin().is_terminal() { + anyhow::bail!("No version given. Usage: pvm use "); + } + + let items = fs::get_aliased_versions()?; + if items.is_empty() { + eprintln!("{} No PHP versions are currently installed.", "💡".yellow()); + return Ok(None); + } + + let current = fs::get_current_version(); + let displays: Vec = items + .iter() + .map(|i| { + let pkgs = i.packages.join(", "); + if i.version == current { + format!("{} {} [{}]", i.display, "(current)".cyan(), pkgs.cyan()) + } else { + format!("{} [{}]", i.display, pkgs.cyan()) } + }) + .collect(); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt_text) + .default(0) + .items(&displays) + .interact_opt()?; + + match selection { + Some(idx) => Ok(Some(items[idx].version.clone())), + None => { + eprintln!("{} Operation cancelled.", "✗".red()); + Ok(None) } + } +} - if !fs::is_version_installed(&version)? { - anyhow::bail!( - "PHP {} is not installed. Run 'pvm install {}' first.", - version, - version - ); +pub(crate) struct ActivateOpts { + /// Version came from an interactive picker: offer to persist it in .php-version. + pub offer_save: bool, + pub assume_yes: bool, + /// Suppress the success message (shell cd-hook runs on every prompt). + pub quiet: bool, +} + +impl ActivateOpts { + pub(crate) fn picker(assume_yes: bool) -> Self { + Self { + offer_save: true, + assume_yes, + quiet: false, } + } +} - // Smart prompt logic - if Path::new(PHP_VERSION_FILE).exists() - && let Ok(current_file_ver) = std::fs::read_to_string(PHP_VERSION_FILE) - && current_file_ver.trim() != version - { - let question = format!( - "A {} file is present ({}). Do you want to apply this change to the directory?", - PHP_VERSION_FILE, - current_file_ver.trim().yellow() - ); +/// Shared activation tail used by 'pvm use' and the interactive 'pvm ls': +/// patch-update offer, .php-version handling and the env-file write. +pub(crate) async fn activate(mut version: String, opts: ActivateOpts) -> Result<()> { + // Patch-update offers are an interactive-only feature: scripts must not + // trigger surprise downloads, so the check is skipped without a terminal. + if std::io::stdin().is_terminal() + && let Ok(Some(newer_version)) = update::check_for_updates(&version).await + { + let question = format!( + "{} A new patch version is available: {} ➜ {}. Do you want to install and use it now?", + "💡".yellow(), + version.dimmed(), + newer_version.green().bold() + ); + + if prompt::confirm(&question, true, opts.assume_yes)? { + // Carry over the packages of the version being replaced so the + // upgrade does not re-ask what is already known. + let installed_pkgs = fs::get_installed_packages(&version); + if crate::commands::install::execute_install_with( + &newer_version, + false, + &installed_pkgs, + opts.assume_yes, + ) + .await? + .is_some() + { + version = newer_version; + } + } + } + + if !fs::is_version_installed(&version)? { + anyhow::bail!( + "PHP {} is not installed. Run 'pvm install {}' first.", + version, + version + ); + } + + let file_version = std::fs::read_to_string(PHP_VERSION_FILE) + .ok() + .map(|c| c.trim().to_string()); + + if opts.offer_save { + // Picker flows: offer to persist the choice for this directory. + if file_version.as_deref() != Some(version.as_str()) { + let question = match &file_version { + Some(old) => format!( + "Save {} to {} (currently {})?", + version.bold(), + PHP_VERSION_FILE, + old.yellow() + ), + None => format!("Save {} to {}?", version.bold(), PHP_VERSION_FILE), + }; // Deliberately not covered by --yes: writing .php-version is a // side effect the user should opt into explicitly. if prompt::confirm(&question, false, false)? { @@ -188,21 +252,46 @@ impl Use { ); } } + } else if Path::new(PHP_VERSION_FILE).exists() + && let Some(ref current_file_ver) = file_version + && current_file_ver != &version + { + let question = format!( + "A {} file is present ({}). Do you want to apply this change to the directory?", + PHP_VERSION_FILE, + current_file_ver.yellow() + ); + // Deliberately not covered by --yes: writing .php-version is a + // side effect the user should opt into explicitly. + if prompt::confirm(&question, false, false)? { + std::fs::write(PHP_VERSION_FILE, &version) + .with_context(|| format!("Failed to update {}", PHP_VERSION_FILE))?; + eprintln!( + "{} Updated {} to {}", + "✓".green(), + PHP_VERSION_FILE, + version.bold() + ); + } + } - let bin_dir = fs::get_version_bin_dir(&version)?; - let s = shell::detect_shell(); + let bin_dir = fs::get_version_bin_dir(&version)?; + let s = shell::detect_shell(); - // These evaluate in the user's shell hook via wrapper - let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); - let export_str2 = s.path(&bin_dir); + // These evaluate in the user's shell hook via wrapper + let export_str1 = s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()); + let export_str2 = s.path(&bin_dir); - let env_file = fs::get_env_update_path()?; - fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; + let env_file = fs::get_env_update_path()?; + fs::write_env_file_locked(&env_file, &format!("{}\n{}", export_str1, export_str2))?; - // Note: process-global env is intentionally NOT mutated here. std::env::set_var - // is unsound in a multi-threaded tokio runtime, and the wrapper sources env_file - // into the parent shell on exit, so subsequent pvm invocations see the new PATH. + // Note: process-global env is intentionally NOT mutated here. std::env::set_var + // is unsound in a multi-threaded tokio runtime, and the wrapper sources env_file + // into the parent shell on exit, so subsequent pvm invocations see the new PATH. - Ok(()) + if !opts.quiet { + eprintln!("{} Now using PHP {}", "✓".green(), version.bold()); } + + Ok(()) } diff --git a/tests/cli.rs b/tests/cli.rs index bdb04b8..b8174b0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -259,6 +259,35 @@ fn test_help_lists_self_update() { .stdout(predicate::str::contains("self-update")); } +#[test] +fn test_ls_non_tty_prints_plain_list() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli", "fpm"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("ls"); + cmd.assert() + .success() + .stdout(predicate::str::contains("latest (8.9.6)")) + .stdout(predicate::str::contains("cli, fpm")); +} + +#[test] +fn test_use_no_version_non_tty_fails_with_hint() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_UPDATE_MODE", "disabled"); + cmd.current_dir(temp_dir.path()); + cmd.arg("use"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("No version given")); +} + #[test] fn test_ls_empty() { let temp_dir = tempfile::tempdir().unwrap(); From d23716390a90ed8ca926d482a79c077c069f599a Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:04:42 +0200 Subject: [PATCH 20/37] feat(init): confirm .php-version overwrite and prefer installed versions pvm init no longer overwrites an existing .php-version silently: it shows the current content and asks first. The selection list now offers locally installed versions (marked) before the remote major.minor lines, and falls back to installed-only with a warning when the remote index is unreachable. Without a terminal init fails with a hint instead of a dialoguer error. --- src/commands/init.rs | 100 +++++++++++++++++++++++++++++++++---------- tests/cli.rs | 15 +++++++ 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/src/commands/init.rs b/src/commands/init.rs index 051d2c3..68ad1e5 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,9 +1,12 @@ -use crate::network; +use crate::constants::PHP_VERSION_FILE; +use crate::{fs, network, prompt}; use anyhow::Result; use clap::Parser; use colored::Colorize; -use dialoguer::{Confirm, Select, theme::ColorfulTheme}; +use dialoguer::{Select, theme::ColorfulTheme}; use std::collections::HashSet; +use std::io::IsTerminal; +use std::path::Path; /// Initialize a .php-version file in the current directory #[derive(Parser, Debug)] @@ -11,22 +14,63 @@ pub struct Init; impl Init { pub async fn call(self) -> Result<()> { - println!("{} Fetching remotely available PHP versions...", "↻".blue()); - let all_versions = network::get_available_versions().await?; + // The whole flow is interactive (overwrite confirm + picker), so + // fail early with a hint instead of a cryptic dialoguer error. + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "pvm init requires a terminal; write {} yourself instead.", + PHP_VERSION_FILE + ); + } + + if Path::new(PHP_VERSION_FILE).exists() { + let existing = std::fs::read_to_string(PHP_VERSION_FILE)?; + let question = format!( + "A {} file already exists ({}). Overwrite?", + PHP_VERSION_FILE, + existing.trim().yellow() + ); + if !prompt::confirm(&question, false, false)? { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + } - // Extract just the major.minor (e.g., "8.4" or "7.4") - let mut major_minors = HashSet::new(); - let mut options = Vec::new(); + // Distinct major.minor lines: locally installed first (newest first, + // marked), then the remote-only ones (newest first). + let mut seen = HashSet::new(); + let mut options = Vec::new(); // what gets written to .php-version + let mut displays = Vec::new(); // what the picker shows - for (v, _) in all_versions.iter().rev() { - // Start from newest - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let mm = format!("{}.{}", parts[0], parts[1]); - if major_minors.insert(mm.clone()) { - options.push(mm); + for v in fs::list_installed_versions()?.iter().rev() { + if let Some(mm) = major_minor(v) + && seen.insert(mm.clone()) + { + displays.push(format!("{} {}", mm, "(installed)".green())); + options.push(mm); + } + } + + println!("{} Fetching remotely available PHP versions...", "↻".blue()); + match network::get_available_versions().await { + Ok(remote) => { + for (v, _) in remote.iter().rev() { + // Start from newest + if let Some(mm) = major_minor(v) + && seen.insert(mm.clone()) + { + displays.push(mm.clone()); + options.push(mm); + } } } + Err(e) => { + println!( + "{} Could not fetch remote versions ({}). Showing installed versions only.", + "💡".yellow(), + e + ); + } } if options.is_empty() { @@ -36,7 +80,7 @@ impl Init { let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Select a PHP version for this directory") .default(0) - .items(&options) + .items(&displays) .interact_opt()?; let selected = match selection { @@ -47,15 +91,16 @@ impl Init { } }; - std::fs::write(".php-version", selected)?; - println!("{} Wrote {} to .php-version", "✓".green(), selected.bold()); + std::fs::write(PHP_VERSION_FILE, selected)?; + println!( + "{} Wrote {} to {}", + "✓".green(), + selected.bold(), + PHP_VERSION_FILE + ); - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Do you want to run `pvm use {}` now?", selected)) - .default(true) - .interact_opt()? - .unwrap_or(false) - { + let question = format!("Do you want to run 'pvm use {}' now?", selected); + if prompt::confirm(&question, true, false)? { // Call use programmatically let use_cmd = crate::commands::use_cmd::Use { version: Some(selected.clone()), @@ -68,3 +113,12 @@ impl Init { Ok(()) } } + +/// "8.4.18" -> "8.4"; None when there is no minor component. +fn major_minor(v: &str) -> Option { + let mut parts = v.split('.'); + match (parts.next(), parts.next()) { + (Some(major), Some(minor)) => Some(format!("{}.{}", major, minor)), + _ => None, + } +} diff --git a/tests/cli.rs b/tests/cli.rs index b8174b0..c8eb7a6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -288,6 +288,21 @@ fn test_use_no_version_non_tty_fails_with_hint() { .stderr(predicate::str::contains("No version given")); } +#[test] +fn test_init_non_tty_fails_with_hint() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.current_dir(temp_dir.path()); + cmd.arg("init"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("requires a terminal")); +} + #[test] fn test_ls_empty() { let temp_dir = tempfile::tempdir().unwrap(); From 847dab67e0819717fa14f2f6fdd992326429a5f7 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:04:40 +0200 Subject: [PATCH 21/37] feat(cache): add cache clear command for the remote version index pvm cache clear deletes the cached remote_cache-.json files so a freshly published upstream patch becomes visible before the 24h cache expiry, without hand-deleting files in PVM_DIR. --- src/cli.rs | 5 ++++ src/commands/cache.rs | 65 +++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + tests/cli.rs | 45 ++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 src/commands/cache.rs diff --git a/src/cli.rs b/src/cli.rs index 38e451f..e2a39bf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -60,6 +60,10 @@ pub enum Commands { /// Check for and apply updates to pvm itself #[clap(name = "self-update")] SelfUpdate(commands::self_update::SelfUpdate), + + /// Manage the remote version cache + #[clap(name = "cache")] + Cache(commands::cache::Cache), } impl Commands { @@ -75,6 +79,7 @@ impl Commands { Self::Uninstall(cmd) => cmd.call().await, Self::Init(cmd) => cmd.call().await, Self::SelfUpdate(cmd) => cmd.call().await, + Self::Cache(cmd) => cmd.call().await, } } } diff --git a/src/commands/cache.rs b/src/commands/cache.rs new file mode 100644 index 0000000..fd344ca --- /dev/null +++ b/src/commands/cache.rs @@ -0,0 +1,65 @@ +use crate::fs; +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use colored::Colorize; + +/// Manage the remote version cache +#[derive(Parser, Debug)] +pub struct Cache { + #[command(subcommand)] + action: CacheAction, +} + +#[derive(Subcommand, Debug)] +enum CacheAction { + /// Delete the cached remote version index + Clear, +} + +impl Cache { + pub async fn call(self) -> Result<()> { + match self.action { + CacheAction::Clear => clear(), + } + } +} + +fn clear() -> Result<()> { + let pvm_dir = fs::get_pvm_dir()?; + let mut removed: usize = 0; + + match std::fs::read_dir(&pvm_dir) { + Ok(entries) => { + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name.starts_with("remote_cache") + && name.ends_with(".json") + && entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) + { + std::fs::remove_file(entry.path()) + .with_context(|| format!("Failed to remove cache file '{}'", name))?; + removed += 1; + } + } + } + // No PVM dir yet means there is nothing cached — that is fine. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(e).context(format!( + "Failed to read PVM directory '{}'", + pvm_dir.display() + )); + } + } + + println!( + "{} Removed {} cached remote version index file{}", + "✓".green(), + removed, + if removed == 1 { "" } else { "s" } + ); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 007926a..d72f3af 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod cache; pub mod current; pub mod default_cmd; pub mod env; diff --git a/tests/cli.rs b/tests/cli.rs index c8eb7a6..2f2c2cf 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -423,6 +423,51 @@ fn test_uninstall_fpm_only_success() { assert!(!temp_dir.path().join("versions").join("8.3.1").exists()); } +#[test] +fn test_cache_clear_removes_cache_file_and_is_idempotent() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_remote_cache(temp_dir.path(), &[("8.9.7", &["cli"])]); + + let target = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let cache_file = temp_dir + .path() + .join(format!("remote_cache-{}.json", target)); + assert!(cache_file.exists()); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("cache").arg("clear"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed 1")); + + assert!( + !cache_file.exists(), + "cache file must be deleted from PVM_DIR" + ); + + // Second run: nothing left to remove, still succeeds. + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("cache").arg("clear"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed 0")); +} + +#[test] +fn test_cache_clear_tolerates_missing_pvm_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let missing_dir = temp_dir.path().join("does-not-exist"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", &missing_dir); + cmd.arg("cache").arg("clear"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed 0")); +} + #[test] fn test_use_php_version_file() { let temp_dir = tempfile::tempdir().unwrap(); From af20674e193f4686f44a766da3e66629518d1805 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:04:48 +0200 Subject: [PATCH 22/37] feat(which): print the path of the active or given PHP binary pvm which resolves the active version (or an explicit argument like 8.3) and prints the full path of its php binary - a debugging aid for "which php am I actually running". --- src/cli.rs | 5 +++ src/commands/mod.rs | 1 + src/commands/which_cmd.rs | 34 ++++++++++++++++++++ tests/cli.rs | 65 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 src/commands/which_cmd.rs diff --git a/src/cli.rs b/src/cli.rs index e2a39bf..7b841b4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -64,6 +64,10 @@ pub enum Commands { /// Manage the remote version cache #[clap(name = "cache")] Cache(commands::cache::Cache), + + /// Print the path of the active or given PHP binary + #[clap(name = "which")] + Which(commands::which_cmd::Which), } impl Commands { @@ -80,6 +84,7 @@ impl Commands { Self::Init(cmd) => cmd.call().await, Self::SelfUpdate(cmd) => cmd.call().await, Self::Cache(cmd) => cmd.call().await, + Self::Which(cmd) => cmd.call().await, } } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d72f3af..abb59ba 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,3 +9,4 @@ pub mod ls_remote; pub mod self_update; pub mod uninstall; pub mod use_cmd; +pub mod which_cmd; diff --git a/src/commands/which_cmd.rs b/src/commands/which_cmd.rs new file mode 100644 index 0000000..c0a8697 --- /dev/null +++ b/src/commands/which_cmd.rs @@ -0,0 +1,34 @@ +use crate::fs; +use anyhow::Result; +use clap::Parser; + +/// Print the path of the active or given PHP binary +#[derive(Parser, Debug)] +pub struct Which { + /// The version to inspect (defaults to the active one) + pub version: Option, +} + +impl Which { + pub async fn call(self) -> Result<()> { + let version = match self.version { + Some(ref v) => fs::resolve_local_version(v)?, + None => { + let current = fs::get_current_version(); + if current == "system" { + anyhow::bail!("no pvm-managed PHP is active (try: pvm which )"); + } + current + } + }; + + let php_path = fs::get_version_bin_dir(&version)?.join("php"); + if !php_path.exists() { + anyhow::bail!("PHP {} has no cli package installed", version); + } + + // Plain output (no icons) so scripts can consume the path directly. + println!("{}", php_path.display()); + Ok(()) + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 2f2c2cf..6ec7d50 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -468,6 +468,71 @@ fn test_cache_clear_tolerates_missing_pvm_dir() { .stdout(predicate::str::contains("Removed 0")); } +#[test] +fn test_which_prints_path_of_active_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let multishell_path = temp_dir.path().join("versions").join("8.9.6").join("bin"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_MULTISHELL_PATH", &multishell_path); + cmd.arg("which"); + cmd.assert().success().stdout(predicate::str::contains( + multishell_path.join("php").to_string_lossy().into_owned(), + )); +} + +#[test] +fn test_which_resolves_explicit_minor_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("which").arg("8.9"); + cmd.assert().success().stdout(predicate::str::contains( + temp_dir + .path() + .join("versions") + .join("8.9.6") + .join("bin") + .join("php") + .to_string_lossy() + .into_owned(), + )); +} + +#[test] +fn test_which_no_active_version_fails_with_hint() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("which"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("no pvm-managed PHP is active")); +} + +#[test] +fn test_which_fpm_only_version_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["fpm"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("which").arg("8.9.6"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("has no cli package")); +} + #[test] fn test_use_php_version_file() { let temp_dir = tempfile::tempdir().unwrap(); From 06a4f1829250a3d3f4411f74f5b2c09c3964f5f8 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:04:20 +0200 Subject: [PATCH 23/37] feat(exec): run a command under a specific PHP version pvm exec prepends the version bin directory to PATH for a single child process and propagates its exit code - testing across versions without switching the shell. --- src/cli.rs | 5 +++++ src/commands/exec_cmd.rs | 35 +++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + tests/cli.rs | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 src/commands/exec_cmd.rs diff --git a/src/cli.rs b/src/cli.rs index 7b841b4..bf02d79 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,6 +33,10 @@ pub enum Commands { #[clap(name = "env")] Env(commands::env::Env), + /// Run a command under a specific PHP version + #[clap(name = "exec")] + Exec(commands::exec_cmd::Exec), + /// List all locally installed PHP versions #[clap(name = "list", visible_aliases = &["ls"])] Ls(commands::ls::Ls), @@ -76,6 +80,7 @@ impl Commands { Self::Install(cmd) => cmd.call().await, Self::Use(cmd) => cmd.call().await, Self::Env(cmd) => cmd.call().await, + Self::Exec(cmd) => cmd.call().await, Self::Ls(cmd) => cmd.call().await, Self::LsRemote(cmd) => cmd.call().await, Self::Current(cmd) => cmd.call().await, diff --git a/src/commands/exec_cmd.rs b/src/commands/exec_cmd.rs new file mode 100644 index 0000000..8124f07 --- /dev/null +++ b/src/commands/exec_cmd.rs @@ -0,0 +1,35 @@ +use crate::fs; +use anyhow::{Context, Result}; +use clap::Parser; + +/// Run a command under a specific PHP version +#[derive(Parser, Debug)] +pub struct Exec { + /// The PHP version to run under + version: String, + + /// The command and arguments to execute + #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)] + command: Vec, +} + +impl Exec { + pub async fn call(self) -> Result<()> { + let version = fs::resolve_local_version(&self.version)?; + let bin_dir = fs::get_version_bin_dir(&version)?; + + // Prepend the version's bin dir so its binaries shadow any other PHP + // already on PATH, for this single child process only. + let existing = std::env::var_os("PATH").unwrap_or_default(); + let paths = std::iter::once(bin_dir).chain(std::env::split_paths(&existing)); + let new_path = std::env::join_paths(paths).context("Failed to construct PATH")?; + + let status = std::process::Command::new(&self.command[0]) + .args(&self.command[1..]) + .env("PATH", new_path) + .status() + .with_context(|| format!("Failed to execute '{}'", self.command[0]))?; + + std::process::exit(status.code().unwrap_or(1)); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index abb59ba..75fd0df 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod current; pub mod default_cmd; pub mod env; +pub mod exec_cmd; pub mod init; pub mod install; pub mod ls; diff --git a/tests/cli.rs b/tests/cli.rs index 6ec7d50..97ba3ab 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -423,6 +423,41 @@ fn test_uninstall_fpm_only_success() { assert!(!temp_dir.path().join("versions").join("8.3.1").exists()); } +#[cfg(unix)] +#[test] +fn test_exec_runs_command_and_propagates_exit_code() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + // Replace the empty fake php binary with a script that prints a marker + // and exits with a distinctive code. + let php_path = temp_dir.path().join("versions/8.9.6/bin/php"); + std::fs::write(&php_path, "#!/bin/sh\necho FAKE-PHP-8.9.6\nexit 7\n").unwrap(); + std::fs::set_permissions(&php_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("exec").arg("8.9.6").arg("php"); + cmd.assert() + .code(7) + .stdout(predicate::str::contains("FAKE-PHP-8.9.6")); +} + +#[cfg(unix)] +#[test] +fn test_exec_unknown_version_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("exec").arg("9.9.9").arg("php").arg("-v"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("not installed locally")); +} + #[test] fn test_cache_clear_removes_cache_file_and_is_idempotent() { let temp_dir = tempfile::tempdir().unwrap(); From 065fca9c16508eaaccc9114be8dea11ee19e478c Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:04:44 +0200 Subject: [PATCH 24/37] feat(prune): remove superseded patch versions pvm prune [-y] deletes every installed patch that is no longer the newest of its minor line, keeping the currently active version. If the persisted default version gets pruned, it is re-pointed to the kept patch of the same minor. --- src/cli.rs | 5 +++ src/commands/mod.rs | 1 + src/commands/prune.rs | 88 +++++++++++++++++++++++++++++++++++++++++++ src/interactive.rs | 13 +++++-- tests/cli.rs | 74 ++++++++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/commands/prune.rs diff --git a/src/cli.rs b/src/cli.rs index bf02d79..43a5529 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -57,6 +57,10 @@ pub enum Commands { #[clap(name = "uninstall", visible_aliases = &["rm", "remove"])] Uninstall(commands::uninstall::Uninstall), + /// Remove superseded patch versions + #[clap(name = "prune")] + Prune(commands::prune::Prune), + /// Initialize a .php-version file in the current directory #[clap(name = "init")] Init(commands::init::Init), @@ -86,6 +90,7 @@ impl Commands { Self::Current(cmd) => cmd.call().await, Self::Default(cmd) => cmd.call().await, Self::Uninstall(cmd) => cmd.call().await, + Self::Prune(cmd) => cmd.call().await, Self::Init(cmd) => cmd.call().await, Self::SelfUpdate(cmd) => cmd.call().await, Self::Cache(cmd) => cmd.call().await, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 75fd0df..6acde34 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod init; pub mod install; pub mod ls; pub mod ls_remote; +pub mod prune; pub mod self_update; pub mod uninstall; pub mod use_cmd; diff --git a/src/commands/prune.rs b/src/commands/prune.rs new file mode 100644 index 0000000..fa1a5d1 --- /dev/null +++ b/src/commands/prune.rs @@ -0,0 +1,88 @@ +use crate::fs; +use anyhow::Result; +use clap::Parser; +use colored::Colorize; +use std::collections::BTreeMap; + +/// Remove superseded patch versions +#[derive(Parser, Debug)] +pub struct Prune { + /// Auto-approve the removal without prompting + #[arg(short = 'y', long = "yes")] + pub yes: bool, +} + +impl Prune { + pub async fn call(self) -> Result<()> { + let installed = fs::list_installed_versions()?; + let current = fs::get_current_version(); + + // Keeper per major.minor line: the newest patch. The installed list + // is semver-sorted ascending, so the last insert per minor wins. + let mut keepers: BTreeMap = BTreeMap::new(); + for version in &installed { + if let Some(minor) = minor_of(version) { + keepers.insert(minor, version.clone()); + } + } + + // Candidates: every installed patch that is not its minor's keeper, + // except the currently active version. + let candidates: Vec<(String, String)> = installed + .iter() + .filter_map(|version| { + let keeper = keepers.get(&minor_of(version)?)?; + (version != keeper && *version != current) + .then(|| (version.clone(), keeper.clone())) + }) + .collect(); + + if candidates.is_empty() { + println!("{} Nothing to prune.", "✓".green()); + return Ok(()); + } + + println!("The following superseded version(s) will be removed:"); + for (version, keeper) in &candidates { + println!(" {} (superseded by {})", version, keeper.bold()); + } + + let question = format!("Remove {} superseded version(s)?", candidates.len()); + if !crate::prompt::confirm(&question.bold().to_string(), true, self.yes)? { + println!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + + let versions_dir = fs::get_versions_dir()?; + for (version, _) in &candidates { + let dest = versions_dir.join(version); + std::fs::remove_dir_all(&dest) + .map_err(|e| anyhow::anyhow!("Failed to remove PHP {}: {}", version, e))?; + println!("{} Removed PHP {}", "✓".green(), version); + } + + // If the persisted default version was pruned, re-point it to the + // keeper of the same minor line. + if let Some(default) = fs::get_default_version()? + && let Some((_, keeper)) = candidates.iter().find(|(v, _)| *v == default) + { + fs::set_default_version(keeper)?; + println!( + "{} Default version {} was pruned; re-pointed to {}.", + "💡".yellow(), + default, + keeper.bold() + ); + } + + Ok(()) + } +} + +/// "8.3.1" -> "8.3"; None for names without a major.minor prefix. +fn minor_of(version: &str) -> Option { + let mut parts = version.split('.'); + let major = parts.next()?; + let minor = parts.next()?; + Some(format!("{}.{}", major, minor)) +} diff --git a/src/interactive.rs b/src/interactive.rs index 4758404..dbce290 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -10,6 +10,7 @@ pub async fn run_root_menu() -> Result<()> { "Use (Switch active PHP version)", "Install (Install a PHP version)", "Uninstall (Remove a PHP version)", + "Prune (Remove superseded patch versions)", "List (View locally installed versions)", "List-Remote (View all available cloud versions)", "Current (Print the currently active PHP version)", @@ -58,24 +59,28 @@ pub async fn run_root_menu() -> Result<()> { cmd.call().await } 3 => { - let cmd = commands::ls::Ls; + let cmd = commands::prune::Prune { yes: false }; cmd.call().await } 4 => { + let cmd = commands::ls::Ls; + cmd.call().await + } + 5 => { let cmd = commands::ls_remote::LsRemote { version_prefix: None, }; cmd.call().await } - 5 => { + 6 => { let cmd = commands::current::Current {}; cmd.call().await } - 6 => { + 7 => { let cmd = commands::default_cmd::DefaultCmd { version: None }; cmd.call().await } - 7 => { + 8 => { let cmd = commands::init::Init {}; cmd.call().await } diff --git a/tests/cli.rs b/tests/cli.rs index 97ba3ab..cf640d2 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -568,6 +568,80 @@ fn test_which_fpm_only_version_fails() { .stderr(predicate::str::contains("has no cli package")); } +#[test] +fn test_prune_removes_superseded_versions() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("prune").arg("-y"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed PHP 8.3.1")); + + assert!(!temp_dir.path().join("versions/8.3.1").exists()); + assert!(temp_dir.path().join("versions/8.3.5").exists()); + assert!(temp_dir.path().join("versions/8.4.2").exists()); +} + +#[test] +fn test_prune_keeps_active_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env( + "PVM_MULTISHELL_PATH", + temp_dir.path().join("versions/8.3.1/bin"), + ); + cmd.arg("prune").arg("-y"); + cmd.assert().success(); + + assert!(temp_dir.path().join("versions/8.3.1").exists()); + assert!(temp_dir.path().join("versions/8.3.5").exists()); + assert!(temp_dir.path().join("versions/8.4.2").exists()); +} + +#[test] +fn test_prune_repoints_default_version() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + std::fs::write(temp_dir.path().join("default"), "8.3.1").unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("prune").arg("-y"); + cmd.assert().success(); + + let default = std::fs::read_to_string(temp_dir.path().join("default")).unwrap(); + assert_eq!(default.trim(), "8.3.5"); +} + +#[test] +fn test_prune_nothing_to_prune() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.3.5", &["cli"]); + seed_installed_version(temp_dir.path(), "8.4.2", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("prune").arg("-y"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Nothing to prune.")); + + assert!(temp_dir.path().join("versions/8.3.5").exists()); + assert!(temp_dir.path().join("versions/8.4.2").exists()); +} + #[test] fn test_use_php_version_file() { let temp_dir = tempfile::tempdir().unwrap(); From 6ed5aa878286baa85c691a45c42e19d2f1cf3172 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:08:22 +0200 Subject: [PATCH 25/37] test(e2e): cover default, use system, non-interactive install and prune New cases 15-18 exercise pvm default persistence + env activation, pvm use system PATH stripping, install --packages/-y idempotency and prune with default re-pointing. The uninstall case moves to slot 19 and removes LATEST, since prune already dropped PREVIOUS. Cases guard the single-upstream-patch situation where LATEST == PREVIOUS. --- tests/e2e/cases/15_default.sh | 29 +++++++++++++++++++++ tests/e2e/cases/15_uninstall.sh | 20 --------------- tests/e2e/cases/16_use_system.sh | 30 ++++++++++++++++++++++ tests/e2e/cases/17_install_packages.sh | 19 ++++++++++++++ tests/e2e/cases/18_prune.sh | 35 ++++++++++++++++++++++++++ tests/e2e/cases/19_uninstall.sh | 21 ++++++++++++++++ 6 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/cases/15_default.sh delete mode 100755 tests/e2e/cases/15_uninstall.sh create mode 100644 tests/e2e/cases/16_use_system.sh create mode 100644 tests/e2e/cases/17_install_packages.sh create mode 100644 tests/e2e/cases/18_prune.sh create mode 100755 tests/e2e/cases/19_uninstall.sh diff --git a/tests/e2e/cases/15_default.sh b/tests/e2e/cases/15_default.sh new file mode 100644 index 0000000..4e86d9d --- /dev/null +++ b/tests/e2e/cases/15_default.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# `pvm default ` persists the version and `pvm env` activates it in new shells. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +PVM_DIR="${PVM_DIR:-$HOME/.local/share/pvm}" + +step "pvm default $PREVIOUS — persist + env activation" + +"$PVM_BIN" default "$PREVIOUS" +[[ "$(cat "$PVM_DIR/default")" == "$PREVIOUS" ]] \ + || fail "default file does not contain $PREVIOUS" +ok "default file written" + +# Non-TTY `pvm default` prints the stored version. +OUT=$("$PVM_BIN" default) +echo "$OUT" | grep -q "$PREVIOUS" || fail "pvm default did not print $PREVIOUS" +ok "pvm default prints $PREVIOUS" + +# A fresh shell that evals `pvm env` starts on the default version. +OUT=$(bash --norc --noprofile -c "eval \"\$('$PVM_BIN' env)\"; php -v" 2>&1) +echo "$OUT" | grep -q "$PREVIOUS" \ + || fail "new shell did not start on PHP $PREVIOUS (got: $OUT)" +ok "new shell starts on PHP $PREVIOUS via pvm env" + +# `pvm default system` clears it again. +"$PVM_BIN" default system +[[ ! -f "$PVM_DIR/default" ]] || fail "default file still present after clearing" +ok "pvm default system cleared the default" diff --git a/tests/e2e/cases/15_uninstall.sh b/tests/e2e/cases/15_uninstall.sh deleted file mode 100755 index f66d90a..0000000 --- a/tests/e2e/cases/15_uninstall.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# `pvm uninstall ` removes the version dir and pvm ls drops the entry. -# Driver runs this AFTER fpm has been shut down. -set -euo pipefail -source "$(dirname "$0")/../_lib.sh" - -step "pvm uninstall $PREVIOUS" - -"$PVM_BIN" uninstall "$PREVIOUS" - -if [[ ! -d "${PVM_DIR:-$HOME/.local/share/pvm}/versions/$PREVIOUS" ]]; then - ok "versions/$PREVIOUS directory removed" -else - fail "uninstall left versions/$PREVIOUS in place" -fi - -if "$PVM_BIN" ls 2>&1 | grep -q "$PREVIOUS"; then - fail "pvm ls still shows $PREVIOUS after uninstall" -fi -ok "pvm ls no longer lists $PREVIOUS" diff --git a/tests/e2e/cases/16_use_system.sh b/tests/e2e/cases/16_use_system.sh new file mode 100644 index 0000000..b1c50b2 --- /dev/null +++ b/tests/e2e/cases/16_use_system.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# `pvm use system` strips pvm-managed entries from PATH via the wrapper. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm use system — back to system PHP" + +OUT=$(bash --norc --noprofile -c " + eval \"\$('$PVM_BIN' env)\" + pvm use $VFILTER >/dev/null 2>&1 + command -v php || true + pvm use system >/dev/null 2>&1 + echo '---AFTER---' + command -v php || true + pvm current +" 2>&1) + +BEFORE=$(echo "$OUT" | sed -n '1p') +echo "$BEFORE" | grep -q "/pvm/versions/" \ + || fail "pvm use $VFILTER did not put a pvm php on PATH (got: $BEFORE)" +ok "pvm use put php on PATH: $BEFORE" + +AFTER=$(echo "$OUT" | sed -n '/---AFTER---/,$p') +if echo "$AFTER" | grep -q "/pvm/versions/"; then + fail "pvm use system left a pvm versions entry on PATH: $AFTER" +fi +ok "no pvm versions entry on PATH after pvm use system" + +echo "$AFTER" | grep -q "system" || fail "pvm current is not 'system' after deactivation" +ok "pvm current reports system" diff --git a/tests/e2e/cases/17_install_packages.sh b/tests/e2e/cases/17_install_packages.sh new file mode 100644 index 0000000..2e48054 --- /dev/null +++ b/tests/e2e/cases/17_install_packages.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Non-interactive install: --packages skips the MultiSelect, rerun is idempotent. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +PVM_DIR="${PVM_DIR:-$HOME/.local/share/pvm}" + +step "pvm install $LATEST --packages cli — non-interactive" + +"$PVM_BIN" install "$LATEST" --packages cli --yes /dev/null))" +ok "default re-pointed to $LATEST" + +# Cleanup for the following cases. +"$PVM_BIN" default system diff --git a/tests/e2e/cases/19_uninstall.sh b/tests/e2e/cases/19_uninstall.sh new file mode 100755 index 0000000..ad339de --- /dev/null +++ b/tests/e2e/cases/19_uninstall.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# `pvm uninstall ` removes the version dir and pvm ls drops the entry. +# Runs last: 18_prune keeps only $LATEST, so that is what gets removed here. +# Driver runs this AFTER fpm has been shut down. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm uninstall $LATEST" + +"$PVM_BIN" uninstall "$LATEST" + +if [[ ! -d "${PVM_DIR:-$HOME/.local/share/pvm}/versions/$LATEST" ]]; then + ok "versions/$LATEST directory removed" +else + fail "uninstall left versions/$LATEST in place" +fi + +if "$PVM_BIN" ls 2>&1 | grep -q "$LATEST"; then + fail "pvm ls still shows $LATEST after uninstall" +fi +ok "pvm ls no longer lists $LATEST" From ff24549a7629ba2afa1a20b5ee1f33b4b1e8cea2 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:08:22 +0200 Subject: [PATCH 26/37] docs(readme): document new commands and flows Covers default/use system, which/exec/prune/cache clear, the non-interactive install flags, the interactive ls picker and plain output when piped, and replaces the stale fs4 reference with OS file locks. --- README.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 97614cb..5618165 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ PVM uses pre-compiled static PHP CLI binaries from [Static PHP CLI (SPC)](https: - 📦 **Static Binaries**: No compilation needed. The `pvm install` command instantly downloads self-contained executables with most common extensions pre-baked. - 🧩 **Multi-Package Selection**: Pick which packages to install per version — `cli`, `fpm`, and/or `micro` (`micro.sfx`) — via an interactive MultiSelect prompt. `cli` is the default. - 🐘 **Native Composer Support**: Works out of the box with your system's global Composer without any explicit proxy or configuration. -- 🖱️ **Interactive TUI Menus**: Run `pvm` without arguments to launch a master selection menu. Or run commands like `pvm use` / `pvm ls-remote` / `pvm uninstall` without parameters to select actions via a visual UI. +- 🖱️ **Interactive TUI Menus**: Run `pvm` without arguments to launch a master selection menu. Or run commands like `pvm use` / `pvm ls` / `pvm install` / `pvm uninstall` without parameters to select actions via a visual UI. +- 📌 **Persistent Default**: `pvm default 8.4` makes a version survive new terminals — every fresh shell starts on it via `pvm env`. `pvm use system` switches back to the system PHP anytime. - 🏷️ **Smart Aliasing**: Install and use patches cleanly by saying `pvm install 8.4`. PVM dynamically figures out the highest patch (`8.4.18`) underneath the hood. -- 🔄 **Patch Update Check**: `pvm use 8.4` notices when a newer patch (e.g. `8.4.19`) is available and offers to install and switch in one step. -- 📝 **`.php-version` Bootstrap**: `pvm init` interactively picks a major.minor and writes `.php-version` for the current directory. +- 🔄 **Patch Update Check**: `pvm use 8.4` notices when a newer patch (e.g. `8.4.19`) is available and offers to install and switch in one step (interactive shells only — scripts never trigger surprise downloads). `pvm prune` cleans up superseded patches afterwards. +- 🤖 **Script-Friendly**: every command works without a terminal — `pvm install 8.4 --packages cli,fpm -y` installs non-interactively, `pvm ls` / `pvm ls-remote` print plain lists when piped, prompts fall back to sane defaults. +- 📝 **`.php-version` Bootstrap**: `pvm init` interactively picks a major.minor and writes `.php-version` for the current directory; picker-driven switches offer to save your choice there too. - 🗑️ **Clean Uninstall**: `pvm uninstall` (alias `rm` / `remove`) removes a version's binaries; warns when removing the active one. -- 🐚 **Multi-Shell**: Bash, Zsh, and Fish wrappers generated by `pvm env`, with concurrency-safe per-PID env files locked via `fs4`. -- ⚡ **Cached Cloud Resolution**: Quickly check for new versions on `dl.static-php.dev` under lightning-fast 24-hour JSON caching. +- 🐚 **Multi-Shell**: Bash, Zsh, and Fish wrappers generated by `pvm env`, with concurrency-safe per-PID env files under OS file locks. +- ⚡ **Cached Cloud Resolution**: Quickly check for new versions on `dl.static-php.dev` under lightning-fast 24-hour JSON caching (`pvm cache clear` forces a refresh). ## Installation @@ -50,8 +52,12 @@ pvm # Install a specific PHP version (by minor alias or fully-qualified). # Opens a MultiSelect to pick packages: cli (default), fpm, micro. +# Without a version argument, opens an interactive remote-version picker. pvm install 8.4 # alias: pvm i 8.4 +# Non-interactive install for scripts/CI: pick packages up front, skip prompts. +pvm install 8.4 --packages cli,fpm -y + # Install the absolute latest version available pvm install latest @@ -59,21 +65,43 @@ pvm install latest # Auto-prompts to install + switch if a newer patch exists upstream. pvm use 8.4 -# List all local installed versions alongside their specific aliases +# Switch this shell back to the system PHP +pvm use system + +# Make a version the default for every new shell (cleared via: pvm default system) +pvm default 8.4 + +# List installed versions. On a terminal this is a picker: Enter switches +# (and offers to save the choice to .php-version), Esc just exits. +# When piped it prints a plain list. pvm ls # alias for: pvm list -# Interactively view and install available cloud versions +# List available cloud versions (plain list, script-friendly) pvm ls-remote # alias: pvm list-remote # Print the currently active PHP version pvm current +# Print the path of the active (or a given) php binary +pvm which +pvm which 8.3 + +# Run a single command under a specific version without switching the shell +pvm exec 8.3 php -v +pvm exec 8.3 composer install + # Remove an installed version (interactive picker if no arg). pvm uninstall 8.3 # aliases: pvm rm 8.3 / pvm remove 8.3 +# Remove every patch that is no longer the newest of its minor line +pvm prune # add -y to skip the confirmation + # Write a .php-version file for this directory (interactive picker) pvm init +# Drop the 24h remote-version cache (e.g. right after an upstream release) +pvm cache clear + # Check for and apply updates to pvm itself pvm self-update # optional: pvm self-update --apply to apply automatically ``` From 02f5b362aab1bad1c64df311ebadad32e322e4b8 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Sat, 18 Jul 2026 00:09:00 +0200 Subject: [PATCH 27/37] docs(claude): track CLAUDE.md with current architecture Previously untracked. Documents command-dispatch conventions incl. the non-interactive requirements (prompt::confirm, IsTerminal guards), the default-version and cache files in PVM_DIR, the shared activate() tail, Shell::deactivate, and the offline test helpers. --- CLAUDE.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..53a75cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Critical Safety Rule + +**Never use backticks (`` ` ``) inside `git commit -m` messages.** Backticks are evaluated as command substitution by the user's shell before git sees them, executing arbitrary code under their privileges. Use single or double quotes to wrap code/file references in commit bodies. This applies to all commits, PRs, and any string passed to a shell. + +## Commands + +Toolchain is pinned in `rust-toolchain.toml` (currently Rust 1.97.0, edition 2024) with `clippy` and `rustfmt`; Renovate bumps it, so check the file rather than assuming. + +- Build release binary: `cargo build --release` (size-optimized: `opt-level = "z"`, LTO, `panic = "abort"`, stripped) +- Run from source: `cargo run -- ` +- All tests: `cargo test` +- Single test: `cargo test test_use_silent_export` (or any function name in `tests/cli.rs` / `src/**/tests`) +- Lint (CI gate): `cargo clippy -- -D warnings` +- Format check (CI gate): `cargo fmt -- --check` +- Local install from source: `./build.sh` (compiles release, copies to `$PVM_DIR/bin/pvm`) + +CI lives in `.github/workflows/release.yml` as a chained pipeline: `tests` (fmt + clippy + `cargo test`) → `e2e tests - linux` (`tests/e2e/run.sh`) → `release`; failures block merge. New CI gates belong inside `release.yml` in that chain, not as standalone workflow files. Releases are driven by `semantic-release` from Conventional Commits on `main` — `Cargo.toml` version and `CHANGELOG.md` are bumped automatically, so do not hand-edit them. + +## Architecture + +PVM is a single-binary Rust CLI that downloads pre-built static PHP binaries from `dl.static-php.dev` and switches `PATH` per-shell. Three concerns drive the design: + +### 1. Command dispatch (`src/cli.rs` → `src/commands/*`) + +`Cli` is a `clap` `Parser` with an `Option` subcommand. If absent, `main.rs` falls through to `interactive::run_root_menu()` (a `dialoguer` TUI). Every subcommand is its own struct in `src/commands/.rs` with `#[derive(Parser, Debug)]` and an async `call(self) -> Result<()>` method. To add a subcommand: create the module, add it to `src/commands/mod.rs`, register the variant in the `Commands` enum in `src/cli.rs`, add the dispatch arm in `Commands::call`, and (if menu-worthy) extend `interactive.rs` — its match arms are index-based, so renumber everything after the insertion point. + +Non-interactive behavior is a hard requirement: every command must work without a terminal. Yes/no questions go through `prompt::confirm(prompt, default, assume_yes)` (`src/prompt.rs`), which auto-answers with `default` on a non-TTY stdin and short-circuits on `--yes`. Pickers (`dialoguer::Select`) are guarded by `std::io::IsTerminal` checks that bail with a usage hint or fall back to plain output (`ls`, `ls-remote`). The patch-update offer in `use` runs only on a TTY so scripts never trigger downloads. + +### 2. Filesystem layout (`src/fs.rs`, `src/constants.rs`) + +Rooted at `$PVM_DIR` (defaults to `dirs::data_local_dir()/pvm`, i.e. `~/.local/share/pvm` on Linux). Layout: + +- `$PVM_DIR/bin/pvm` — the binary itself +- `$PVM_DIR/versions//bin/{php,php-fpm,micro.sfx}` — installed PHP binaries; presence of each file determines which packages are "installed" (`fs::get_installed_packages`) +- `$PVM_DIR/remote_cache-.json` — 24h cache of the remote version index (per target triple), file-locked on read/write; `pvm cache clear` deletes it +- `$PVM_DIR/default` — version activated by `pvm env` in every new shell (`pvm default`); `prune` re-points it when it removes the referenced patch +- `$PVM_DIR/.update_check_guard` — 24h throttle for the "newer patch available" check (`src/update.rs`); its presence suppresses update prompts, which matters when testing update flows +- `$PVM_DIR/.env_update__` — short-lived file the shell wrapper evals to mutate the parent shell + +Version resolution is two-staged: `network::resolve_version` matches a user input like `8.4` against the remote list (latest patch wins), `fs::resolve_local_version` does the same against installed versions. `latest` is a special alias on both sides. + +### 3. Shell integration (`src/shell.rs`, `src/commands/env.rs`) + +The hard part: a child process cannot mutate its parent shell's environment. PVM solves this with `pvm env` + a wrapper function: + +1. The user evaluates `pvm env` in their rc file, which prints a `pvm()` shell function plus a `cd` hook. +2. When the user runs `pvm use 8.4`, the wrapper exports `PVM_ENV_UPDATE_PATH=` before invoking the real `pvm` binary. +3. The Rust binary writes `export PATH=...; export PVM_MULTISHELL_PATH=...` to that tmpfile (with an exclusive file lock — see `fs::write_env_file_locked`). +4. The wrapper `eval`s the tmpfile and removes it. + +The `Shell` trait (`Bash`, `Zsh`, `Fish`) emits the syntax for each shell; `detect_shell()` reads `$SHELL`. Bash and Zsh share the `posix_*` free functions; only `use_on_cd` differs. The `cd` hook reads `.php-version` and calls `pvm use --silent` automatically. `deactivate()` (used by `pvm use system`) writes an env-file snippet that clears `PVM_MULTISHELL_PATH` and filters `$PVM_DIR/versions` entries out of PATH. + +The activation tail (patch-update offer, `.php-version` prompts, env-file write) lives in `use_cmd::activate(version, ActivateOpts)`; both `pvm use` and the interactive `pvm ls` picker call it. `ActivateOpts.offer_save` distinguishes picker flows (offer to write `.php-version`) from explicit-argument flows (offer to update an existing differing file). + +Concurrency: every shell session uses its own tmpfile keyed on PID, and the cache + env files are taken under OS file locks (std `File::lock`/`lock_shared` — the `fs4` crate was dropped) so parallel `pvm` invocations don't corrupt state. + +### 4. Networking (`src/network.rs`) + +Single endpoint: `https://dl.static-php.dev/static-php-cli/bulk/?format=json` returns the file index. `get_target_triple()` filters by `{linux,macos}-{x86_64,aarch64}`; package suffixes `cli`/`fpm`/`micro` are parsed from filenames like `php-8.4.18-cli-linux-x86_64.tar.gz`. Downloads stream via `reqwest` with an `indicatif` progress bar, then untar into `$PVM_DIR/versions//bin/` and `chmod 0755` on Unix. Other platforms/architectures are unsupported and will bail. + +## Conventions + +- All fallible command-level functions return `anyhow::Result`. +- User-visible status uses `colored` icons: `✓` (green = success), `✗` (red = error), `↻` (blue = in-progress), `💡` (yellow = hint). +- Async runtime is `tokio` with `features = ["full"]`; all network I/O uses `reqwest`. +- Interactive prompts are `dialoguer` (`Select`, `MultiSelect`, `Confirm`) with `ColorfulTheme::default()`. +- File writes that other processes may read concurrently MUST take an OS file lock via std `File::lock` — follow the pattern in `fs::write_env_file_locked` (cache reads use `lock_shared`, see `src/network.rs`). +- Commits follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, …); the `feat:`/`fix:` prefixes determine version bumps via semantic-release. + +## Testing + +- Integration tests live in `tests/cli.rs` and use `assert_cmd` + `predicates` to invoke the compiled binary. +- Every test that touches the filesystem MUST call `tempfile::tempdir()` and pass its path via `cmd.env("PVM_DIR", ...)`. Never assume the host's real `~/.local/share/pvm` is empty or writable. +- Network-dependent paths are tested offline via the helpers at the top of `tests/cli.rs`: `seed_remote_cache()` pre-fills `remote_cache-.json` with fake versions, `seed_installed_version()` fakes `versions//bin` layouts. Tests that must not see a pvm-active shell use `cmd.env_remove("PVM_MULTISHELL_PATH")`. +- Unit tests inside `src/**` that mutate `std::env` use a `static Mutex<()>` guard (see `src/fs.rs::tests::ENV_LOCK`) — `cargo test` runs in parallel by default and concurrent `set_var` is unsound. +- The build script (`build.rs`) embeds version + git commit + build time into `PVM_VERSION`; it reruns when `.git/HEAD`, refs, or `Cargo.toml` change. Tests that assert on `--version` output should not hardcode the value. + +### E2E suite (`tests/e2e/`) + +Bash-based Linux end-to-end tests: `run.sh` is the driver, one case per file in `cases/NN_*.sh` (install → use → FPM config/run/FastCGI → uninstall), shared helpers in `_lib.sh`. Each case runs as a fresh bash subprocess so state can't mask bugs (e.g. `.update_check_guard` suppressing later prompts). Key points: + +- The driver mutates `$HOME/.local/share/pvm` and refuses to run outside a sandbox (container or `GITHUB_ACTIONS=true`); `PVM_E2E_FORCE=1` overrides — don't use it on a dev machine. +- Run locally via Docker: `docker build -t pvm-e2e tests/e2e`, then mount `tests/e2e` + `target/release/pvm` and set `PVM_BIN` (full commands in `tests/e2e/README.md`). +- `PVM_E2E_ONLY="07_patch_update.sh"` runs a single case; cases 09–13 depend on state from 08. +- `PVM_VERSION_MAJOR_MINOR` (default `8.5`) picks the PHP line; both latest and previous patches must exist upstream. From 209f5571522b23d31ccd45ec53364c32b173c54f Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 20:56:11 +0200 Subject: [PATCH 28/37] fix(default): keep default lifecycle consistent on uninstall Uninstalling the persisted default version now clears the default file with a hint (prune already re-points it); pvm env warns on stderr when the stored default is not installed instead of silently falling back to system PHP. Tests cover both paths plus the non-TTY prune default. --- src/commands/env.rs | 24 ++++++++------ src/commands/uninstall.rs | 11 +++++++ tests/cli.rs | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/commands/env.rs b/src/commands/env.rs index 942fa34..bad1104 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -33,15 +33,21 @@ impl Env { // Activate the persisted default version ('pvm default') so every new // shell starts on it instead of the system PHP. - if let Some(version) = crate::fs::get_default_version()? - && crate::fs::is_version_installed(&version)? - { - let bin_dir = crate::fs::get_version_bin_dir(&version)?; - println!( - "{}", - s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()) - ); - println!("{}", s.path(&bin_dir)); + if let Some(version) = crate::fs::get_default_version()? { + if crate::fs::is_version_installed(&version)? { + let bin_dir = crate::fs::get_version_bin_dir(&version)?; + println!( + "{}", + s.set_env_var(MULTISHELL_PATH_VAR, &bin_dir.to_string_lossy()) + ); + println!("{}", s.path(&bin_dir)); + } else { + // stderr so the eval'd stdout stays clean. + eprintln!( + "pvm: default version {} is not installed; run 'pvm default' to fix", + version + ); + } } Ok(()) diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs index b953c73..5d49c9e 100644 --- a/src/commands/uninstall.rs +++ b/src/commands/uninstall.rs @@ -73,6 +73,17 @@ impl Uninstall { } } + // Do not leave a default pointing at a removed version: 'pvm env' + // would silently skip it and new shells would fall back to system PHP. + if fs::get_default_version()?.as_deref() == Some(version.as_str()) { + fs::clear_default_version()?; + println!( + "{} PHP {} was the default version; default cleared. Set a new one with 'pvm default '.", + "💡".yellow(), + version + ); + } + Ok(()) } } diff --git a/tests/cli.rs b/tests/cli.rs index cf640d2..a334275 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -190,6 +190,72 @@ fn test_default_rejects_missing_version() { .stderr(predicate::str::contains("not installed locally")); } +#[test] +fn test_uninstall_clears_stale_default() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + std::fs::write(temp_dir.path().join("default"), "8.9.6").unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("uninstall").arg("8.9.6").arg("--yes"); + cmd.assert() + .success() + .stdout(predicate::str::contains("default cleared")); + + assert!( + !temp_dir.path().join("default").exists(), + "stale default file must be removed with its version" + ); +} + +#[test] +fn test_use_missing_version_non_tty_fails_without_yes() { + let temp_dir = tempfile::tempdir().unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_UPDATE_MODE", "disabled"); + cmd.current_dir(temp_dir.path()); + cmd.arg("use").arg("8.9"); + // No terminal, no --yes: must not silently download and install. + cmd.assert() + .failure() + .stderr(predicate::str::contains("or pass --yes")); +} + +#[test] +fn test_env_warns_about_stale_default() { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("default"), "8.9.6").unwrap(); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.arg("env").arg("--shell=bash"); + cmd.assert() + .success() + .stdout(predicate::str::contains("PVM_MULTISHELL_PATH").not()) + .stderr(predicate::str::contains("is not installed")); +} + +#[test] +fn test_prune_non_tty_defaults_to_removal() { + let temp_dir = tempfile::tempdir().unwrap(); + seed_installed_version(temp_dir.path(), "8.9.1", &["cli"]); + seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("prune"); + // Non-TTY confirm falls back to the default (yes), like uninstall. + cmd.assert() + .success() + .stdout(predicate::str::contains("Removed PHP 8.9.1")); + assert!(!temp_dir.path().join("versions/8.9.1").exists()); + assert!(temp_dir.path().join("versions/8.9.6").exists()); +} + #[test] fn test_use_system_writes_deactivation_env_file() { let temp_dir = tempfile::tempdir().unwrap(); From 9f588dec4c641e5d4eb0e7144015373ef6dedb83 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 20:56:11 +0200 Subject: [PATCH 29/37] fix(use): no surprise installs without a terminal Non-TTY 'pvm use ' without --yes now fails with a hint instead of auto-confirming a network install (both the argument and .php-version paths), matching the existing patch-update gate. A .php-version containing 'system' deactivates instead of offering to install 'PHP system', --silent suppresses the deactivation message for cd-hooks, and Esc in the version picker exits quietly as the ls prompt promises. --- src/commands/use_cmd.rs | 46 +++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index 90e937d..da23762 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -26,11 +26,7 @@ impl Use { pub async fn call(self) -> Result<()> { // "system" is not a real version: deactivate pvm for this shell instead. if self.version.as_deref() == Some("system") { - let s = shell::detect_shell(); - let env_file = fs::get_env_update_path()?; - fs::write_env_file_locked(&env_file, &s.deactivate(&fs::get_versions_dir()?))?; - eprintln!("{} Switched to system PHP", "✓".green()); - return Ok(()); + return switch_to_system(self.silent); } let version = match self.version { @@ -41,6 +37,16 @@ impl Use { return Ok(()); } + // Scripts must not trigger surprise network installs: without + // a terminal, installing needs an explicit --yes. + if !std::io::stdin().is_terminal() && !self.yes { + anyhow::bail!( + "PHP {} is not installed. Run 'pvm install {}' first (or pass --yes).", + v, + v + ); + } + let question = format!( "PHP {} is not installed locally. Do you want to install it now?", v.bold() @@ -64,6 +70,9 @@ impl Use { let mut resolved_version = None; if let Ok(content) = std::fs::read_to_string(PHP_VERSION_FILE) { let trimmed = content.trim().to_string(); + if trimmed == "system" { + return switch_to_system(self.silent); + } if !trimmed.is_empty() { match fs::try_resolve_local_version(&trimmed)? { Some(resolved) => { @@ -71,6 +80,14 @@ impl Use { } None => { if !self.silent { + if !std::io::stdin().is_terminal() && !self.yes { + anyhow::bail!( + "PHP {} (from {}) is not installed. Run 'pvm install {}' first (or pass --yes).", + trimmed, + PHP_VERSION_FILE, + trimmed + ); + } let question = format!( "PHP {} (from {}) is not installed locally. Do you want to install it now?", trimmed.bold(), @@ -155,13 +172,20 @@ pub(crate) fn pick_installed_version(prompt_text: &str) -> Result .items(&displays) .interact_opt()?; - match selection { - Some(idx) => Ok(Some(items[idx].version.clone())), - None => { - eprintln!("{} Operation cancelled.", "✗".red()); - Ok(None) - } + // Esc/cancel is a quiet no-op: the ls picker promises "Esc exits". + Ok(selection.map(|idx| items[idx].version.clone())) +} + +/// Write the deactivation snippet for this shell session ('pvm use system', +/// or a .php-version file containing "system"). +fn switch_to_system(quiet: bool) -> Result<()> { + let s = shell::detect_shell(); + let env_file = fs::get_env_update_path()?; + fs::write_env_file_locked(&env_file, &s.deactivate(&fs::get_versions_dir()?))?; + if !quiet { + eprintln!("{} Switched to system PHP", "✓".green()); } + Ok(()) } pub(crate) struct ActivateOpts { From 963a9dda9531e9b249e4ba010a19da204cdbe625 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 20:56:12 +0200 Subject: [PATCH 30/37] fix(install): make -y fully non-interactive and honest without a TTY -y now also skips the package MultiSelect (defaults to cli, like a missing terminal); without a terminal and without -y the trailing 'use now?' question no longer auto-answers yes, which printed a 'Switched to PHP' message although no shell evaluates the env file. --- src/commands/install.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index cc08c3b..75eae93 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -75,6 +75,7 @@ pub async fn execute_install_with( &available_packages, packages, &already_installed, + assume_yes, )?; let Some(selected_packages) = selected_packages else { println!("{} No packages selected. Operation cancelled.", "✗".red()); @@ -129,7 +130,10 @@ pub async fn execute_install_with( return Ok(Some(resolved_version)); } + // Without a terminal (and without -y) do not auto-activate: nothing evals + // the env file, so a "Switched" message would lie. let use_now = cli_selected + && (assume_yes || std::io::stdin().is_terminal()) && crate::prompt::confirm( &format!("Do you want to use PHP {} now?", resolved_version) .bold() @@ -178,6 +182,7 @@ fn select_packages( available_packages: &[String], requested: &[String], already_installed: &[String], + assume_yes: bool, ) -> Result>> { if !requested.is_empty() { for pkg in requested { @@ -193,11 +198,12 @@ fn select_packages( return Ok(Some(requested.to_vec())); } - if !std::io::stdin().is_terminal() { + // -y skips the MultiSelect exactly like a missing terminal does. + if assume_yes || !std::io::stdin().is_terminal() { let default_pkg = "cli".to_string(); if !available_packages.contains(&default_pkg) { anyhow::bail!( - "No terminal to select packages and the default 'cli' package is not available for PHP {} (available: {}). Use --packages.", + "Cannot auto-select packages: the default 'cli' package is not available for PHP {} (available: {}). Use --packages.", resolved_version, available_packages.join(", ") ); From 4673640eb20642e39371cde737795baa3c20c8ef Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 20:56:12 +0200 Subject: [PATCH 31/37] fix(exec): propagate signal terminations as 128 plus signal number Signal-killed children previously collapsed to exit code 1; now they follow the shell convention so callers can distinguish SIGTERM from a plain failure. --- src/commands/exec_cmd.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/commands/exec_cmd.rs b/src/commands/exec_cmd.rs index 8124f07..788bfa5 100644 --- a/src/commands/exec_cmd.rs +++ b/src/commands/exec_cmd.rs @@ -30,6 +30,16 @@ impl Exec { .status() .with_context(|| format!("Failed to execute '{}'", self.command[0]))?; - std::process::exit(status.code().unwrap_or(1)); + #[cfg(unix)] + let code = { + use std::os::unix::process::ExitStatusExt; + // Mirror the shell convention for signal-terminated children. + status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(1)) + }; + #[cfg(not(unix))] + let code = status.code().unwrap_or(1); + std::process::exit(code); } } From 626ff8321aedaf13dc0a476b0f92e1bb319e05d0 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 21:02:33 +0200 Subject: [PATCH 32/37] refactor(use): deduplicate the .php-version save blocks in activate Both the picker and explicit-argument flows carried the same confirm-write-report block; save_question() now picks the wording and one shared block does the writing. --- src/commands/use_cmd.rs | 60 +++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index da23762..a397536 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -5,7 +5,6 @@ use clap::Parser; use colored::Colorize; use dialoguer::{Select, theme::ColorfulTheme}; use std::io::IsTerminal; -use std::path::Path; /// Change PHP version #[derive(Parser, Debug)] @@ -176,6 +175,30 @@ pub(crate) fn pick_installed_version(prompt_text: &str) -> Result Ok(selection.map(|idx| items[idx].version.clone())) } +/// The .php-version question for this activation, or None when there is +/// nothing to ask: picker flows offer to save (or update) the choice, +/// explicit-argument flows only offer to update an existing differing file. +fn save_question(version: &str, file_version: &Option, offer_save: bool) -> Option { + if file_version.as_deref() == Some(version) { + return None; + } + match (offer_save, file_version) { + (true, Some(old)) => Some(format!( + "Save {} to {} (currently {})?", + version.bold(), + PHP_VERSION_FILE, + old.yellow() + )), + (true, None) => Some(format!("Save {} to {}?", version.bold(), PHP_VERSION_FILE)), + (false, Some(old)) => Some(format!( + "A {} file is present ({}). Do you want to apply this change to the directory?", + PHP_VERSION_FILE, + old.yellow() + )), + (false, None) => None, + } +} + /// Write the deactivation snippet for this shell session ('pvm use system', /// or a .php-version file containing "system"). fn switch_to_system(quiet: bool) -> Result<()> { @@ -251,40 +274,7 @@ pub(crate) async fn activate(mut version: String, opts: ActivateOpts) -> Result< .ok() .map(|c| c.trim().to_string()); - if opts.offer_save { - // Picker flows: offer to persist the choice for this directory. - if file_version.as_deref() != Some(version.as_str()) { - let question = match &file_version { - Some(old) => format!( - "Save {} to {} (currently {})?", - version.bold(), - PHP_VERSION_FILE, - old.yellow() - ), - None => format!("Save {} to {}?", version.bold(), PHP_VERSION_FILE), - }; - // Deliberately not covered by --yes: writing .php-version is a - // side effect the user should opt into explicitly. - if prompt::confirm(&question, false, false)? { - std::fs::write(PHP_VERSION_FILE, &version) - .with_context(|| format!("Failed to update {}", PHP_VERSION_FILE))?; - eprintln!( - "{} Updated {} to {}", - "✓".green(), - PHP_VERSION_FILE, - version.bold() - ); - } - } - } else if Path::new(PHP_VERSION_FILE).exists() - && let Some(ref current_file_ver) = file_version - && current_file_ver != &version - { - let question = format!( - "A {} file is present ({}). Do you want to apply this change to the directory?", - PHP_VERSION_FILE, - current_file_ver.yellow() - ); + if let Some(question) = save_question(&version, &file_version, opts.offer_save) { // Deliberately not covered by --yes: writing .php-version is a // side effect the user should opt into explicitly. if prompt::confirm(&question, false, false)? { From 9741347eff8b9c52ba35105bd7ea904bb8dc758e Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 21:03:27 +0200 Subject: [PATCH 33/37] refactor(fs): consolidate minor-version extraction into fs::minor_of init.rs and prune.rs carried identical private helpers; fs.rs, update.rs and the install picker had three more inline split variants of the same logic. --- src/commands/init.rs | 13 ++----------- src/commands/install.rs | 4 +--- src/commands/prune.rs | 12 ++---------- src/fs.rs | 10 +++++++--- src/update.rs | 6 ++---- 5 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/commands/init.rs b/src/commands/init.rs index 68ad1e5..ca43962 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -43,7 +43,7 @@ impl Init { let mut displays = Vec::new(); // what the picker shows for v in fs::list_installed_versions()?.iter().rev() { - if let Some(mm) = major_minor(v) + if let Some(mm) = fs::minor_of(v) && seen.insert(mm.clone()) { displays.push(format!("{} {}", mm, "(installed)".green())); @@ -56,7 +56,7 @@ impl Init { Ok(remote) => { for (v, _) in remote.iter().rev() { // Start from newest - if let Some(mm) = major_minor(v) + if let Some(mm) = fs::minor_of(v) && seen.insert(mm.clone()) { displays.push(mm.clone()); @@ -113,12 +113,3 @@ impl Init { Ok(()) } } - -/// "8.4.18" -> "8.4"; None when there is no minor component. -fn major_minor(v: &str) -> Option { - let mut parts = v.split('.'); - match (parts.next(), parts.next()) { - (Some(major), Some(minor)) => Some(format!("{}.{}", major, minor)), - _ => None, - } -} diff --git a/src/commands/install.rs b/src/commands/install.rs index 75eae93..6abe137 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -268,9 +268,7 @@ async fn pick_and_install(packages: &[String], assume_yes: bool) -> Result<()> { for (v, _) in &versions_info { highest_overall = Some(v.clone()); - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let minor = format!("{}.{}", parts[0], parts[1]); + if let Some(minor) = fs::minor_of(v) { minors.insert(minor, v.clone()); // BTreeMap iterates ascending, keeps latest } } diff --git a/src/commands/prune.rs b/src/commands/prune.rs index fa1a5d1..8e3ecda 100644 --- a/src/commands/prune.rs +++ b/src/commands/prune.rs @@ -21,7 +21,7 @@ impl Prune { // is semver-sorted ascending, so the last insert per minor wins. let mut keepers: BTreeMap = BTreeMap::new(); for version in &installed { - if let Some(minor) = minor_of(version) { + if let Some(minor) = fs::minor_of(version) { keepers.insert(minor, version.clone()); } } @@ -31,7 +31,7 @@ impl Prune { let candidates: Vec<(String, String)> = installed .iter() .filter_map(|version| { - let keeper = keepers.get(&minor_of(version)?)?; + let keeper = keepers.get(&fs::minor_of(version)?)?; (version != keeper && *version != current) .then(|| (version.clone(), keeper.clone())) }) @@ -78,11 +78,3 @@ impl Prune { Ok(()) } } - -/// "8.3.1" -> "8.3"; None for names without a major.minor prefix. -fn minor_of(version: &str) -> Option { - let mut parts = version.split('.'); - let major = parts.next()?; - let minor = parts.next()?; - Some(format!("{}.{}", major, minor)) -} diff --git a/src/fs.rs b/src/fs.rs index 5e6f66e..db3b00e 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -38,6 +38,12 @@ pub fn get_versions_dir() -> Result { Ok(get_pvm_dir()?.join("versions")) } +/// "8.3.1" -> "8.3"; None for names without a major.minor prefix. +pub fn minor_of(version: &str) -> Option { + let mut parts = version.split('.'); + Some(format!("{}.{}", parts.next()?, parts.next()?)) +} + pub fn get_version_bin_dir(version: &str) -> Result { Ok(get_versions_dir()?.join(version).join("bin")) } @@ -155,9 +161,7 @@ pub fn get_aliased_versions() -> Result> { // Minor version aliases let mut minors = std::collections::BTreeMap::new(); for v in &installed { - let parts: Vec<&str> = v.split('.').collect(); - if parts.len() >= 2 { - let minor = format!("{}.{}", parts[0], parts[1]); + if let Some(minor) = minor_of(v) { // BTreeMap keeps the latest because we iterate in ascending order, overriding previous values minors.insert(minor, v.clone()); } diff --git a/src/update.rs b/src/update.rs index eb983bd..8fb0e9f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -49,11 +49,9 @@ pub async fn check_for_updates(target_version: &str) -> Result> { } // Parse out the minor version (e.g., "8.4.1" -> "8.4") - let parts: Vec<&str> = target_version.split('.').collect(); - if parts.len() < 2 { + let Some(minor_prefix) = fs::minor_of(target_version) else { return Ok(None); - } - let minor_prefix = format!("{}.{}", parts[0], parts[1]); + }; // Fetch remotes and resolve the newest patch for that minor line. // Resolution failures are ignored: the update check is best-effort. From 2e89427ab47e19336d2489ef2f2e11d73985ba40 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 21:03:45 +0200 Subject: [PATCH 34/37] refactor(fs): drop dead .exe checks in get_installed_packages Windows is an unsupported target (get_target_triple bails before any install), so the .exe fallbacks could never match. --- src/fs.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fs.rs b/src/fs.rs index db3b00e..7ca7408 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -13,10 +13,11 @@ pub struct VersionItem { pub fn get_installed_packages(version: &str) -> Vec { let mut pkgs = Vec::new(); if let Ok(bin_dir) = get_version_bin_dir(version) { - if bin_dir.join("php").exists() || bin_dir.join("php.exe").exists() { + // No .exe variants: Windows is unsupported (get_target_triple bails). + if bin_dir.join("php").exists() { pkgs.push("cli".to_string()); } - if bin_dir.join("php-fpm").exists() || bin_dir.join("php-fpm.exe").exists() { + if bin_dir.join("php-fpm").exists() { pkgs.push("fpm".to_string()); } if bin_dir.join("micro.sfx").exists() { From d434cb8e5f0ba2d4938228dc10a0dd2eccaaee39 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 21:04:24 +0200 Subject: [PATCH 35/37] refactor(interactive): construct and call commands inline in menu arms Every arm bound the command to a temporary before calling it; call directly on the struct literal instead. --- src/interactive.rs | 53 ++++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/src/interactive.rs b/src/interactive.rs index dbce290..b7859c0 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -36,54 +36,47 @@ pub async fn run_root_menu() -> Result<()> { let res = match choice { 0 => { - let cmd = commands::use_cmd::Use { + commands::use_cmd::Use { version: None, silent: false, yes: false, - }; - cmd.call().await + } + .call() + .await } 1 => { - let cmd = commands::install::Install { + commands::install::Install { version: None, packages: vec![], yes: false, - }; - cmd.call().await + } + .call() + .await } 2 => { - let cmd = commands::uninstall::Uninstall { + commands::uninstall::Uninstall { version: None, yes: false, - }; - cmd.call().await - } - 3 => { - let cmd = commands::prune::Prune { yes: false }; - cmd.call().await - } - 4 => { - let cmd = commands::ls::Ls; - cmd.call().await + } + .call() + .await } + 3 => commands::prune::Prune { yes: false }.call().await, + 4 => commands::ls::Ls.call().await, 5 => { - let cmd = commands::ls_remote::LsRemote { + commands::ls_remote::LsRemote { version_prefix: None, - }; - cmd.call().await - } - 6 => { - let cmd = commands::current::Current {}; - cmd.call().await + } + .call() + .await } + 6 => commands::current::Current {}.call().await, 7 => { - let cmd = commands::default_cmd::DefaultCmd { version: None }; - cmd.call().await - } - 8 => { - let cmd = commands::init::Init {}; - cmd.call().await + commands::default_cmd::DefaultCmd { version: None } + .call() + .await } + 8 => commands::init::Init {}.call().await, _ => break, }; From 43e0d47b282bdb8a5219dc35f4b8006659e20795 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 21:08:02 +0200 Subject: [PATCH 36/37] chore(gitignore): ignore local .claude directory Claude Code drops runtime files (locks, worktrees) there; they are machine-local and must not land in commits. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1578d8a..e288043 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ rust-project.json # End of https://www.toptal.com/developers/gitignore/api/linux,rust,rust-analyzer,jetbrains+all .php-version +.claude/ From 17994e25a1bb5f9cc59865cd6f91d323e3c9ec7f Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Fri, 24 Jul 2026 21:33:43 +0200 Subject: [PATCH 37/37] fix(review): address CodeRabbit findings on prune, install picker and cd-hook - pvm prune without a terminal now requires --yes before mass deletion, matching the non-TTY guards on use and install. - Explicit --packages survives the already-installed short-circuit in the install picker, so packages can be added to an existing version. - The silent cd-hook never evaluates the .php-version save prompt; a file holding a partial version like 8.3 would otherwise prompt on every cd. --- src/commands/install.rs | 4 +++- src/commands/prune.rs | 10 ++++++++++ src/commands/use_cmd.rs | 6 +++++- tests/cli.rs | 14 ++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index 6abe137..aef3c37 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -331,7 +331,9 @@ async fn pick_and_install(packages: &[String], assume_yes: bool) -> Result<()> { } }; - if !installed.contains(selected) { + // Explicit --packages must not be swallowed by the short-circuit: it is + // the way to add packages to an already-installed version. + if !installed.contains(selected) || !packages.is_empty() { execute_install_with(selected, true, packages, assume_yes).await?; } else { println!( diff --git a/src/commands/prune.rs b/src/commands/prune.rs index 8e3ecda..efe448e 100644 --- a/src/commands/prune.rs +++ b/src/commands/prune.rs @@ -3,6 +3,7 @@ use anyhow::Result; use clap::Parser; use colored::Colorize; use std::collections::BTreeMap; +use std::io::IsTerminal; /// Remove superseded patch versions #[derive(Parser, Debug)] @@ -47,6 +48,15 @@ impl Prune { println!(" {} (superseded by {})", version, keeper.bold()); } + // Mass deletion needs explicit consent in scripts, like the other + // non-TTY guards in this codebase (use/install). + if !std::io::stdin().is_terminal() && !self.yes { + anyhow::bail!( + "Refusing to prune {} version(s) without a terminal; pass --yes to confirm.", + candidates.len() + ); + } + let question = format!("Remove {} superseded version(s)?", candidates.len()); if !crate::prompt::confirm(&question.bold().to_string(), true, self.yes)? { println!("{} Operation cancelled.", "✗".red()); diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index a397536..e132d0c 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -274,7 +274,11 @@ pub(crate) async fn activate(mut version: String, opts: ActivateOpts) -> Result< .ok() .map(|c| c.trim().to_string()); - if let Some(question) = save_question(&version, &file_version, opts.offer_save) { + // quiet = shell cd-hook: never prompt there, or a .php-version holding a + // partial version like "8.3" would ask on every single cd. + if !opts.quiet + && let Some(question) = save_question(&version, &file_version, opts.offer_save) + { // Deliberately not covered by --yes: writing .php-version is a // side effect the user should opt into explicitly. if prompt::confirm(&question, false, false)? { diff --git a/tests/cli.rs b/tests/cli.rs index a334275..9705d32 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -239,16 +239,26 @@ fn test_env_warns_about_stale_default() { } #[test] -fn test_prune_non_tty_defaults_to_removal() { +fn test_prune_non_tty_requires_yes() { let temp_dir = tempfile::tempdir().unwrap(); seed_installed_version(temp_dir.path(), "8.9.1", &["cli"]); seed_installed_version(temp_dir.path(), "8.9.6", &["cli"]); + // Without a terminal and without -y, mass deletion must refuse. let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); cmd.env("PVM_DIR", temp_dir.path()); cmd.env_remove("PVM_MULTISHELL_PATH"); cmd.arg("prune"); - // Non-TTY confirm falls back to the default (yes), like uninstall. + cmd.assert() + .failure() + .stderr(predicate::str::contains("pass --yes")); + assert!(temp_dir.path().join("versions/8.9.1").exists()); + + // With -y it removes the superseded patch and keeps the keeper. + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env_remove("PVM_MULTISHELL_PATH"); + cmd.arg("prune").arg("-y"); cmd.assert() .success() .stdout(predicate::str::contains("Removed PHP 8.9.1"));