fix: resolve Windows command wrapper execution (#269)

- add PATHEXT-aware command resolution via which\n- migrate affected command runners to resolved_command\n- add regression coverage for wrapper/fallback behavior
This commit is contained in:
Artiom Tofan
2026-03-12 15:36:19 +01:00
committed by GitHub
parent 064cb3ea65
commit 2e194efb3a
35 changed files with 732 additions and 282 deletions
+1
View File
@@ -38,6 +38,7 @@ benchmark-report.md
*.sqlite3
rtk_tracking.db
claudedocs
.omc
# Vitals provenance data
.vitals/
Generated
+272 -72
View File
@@ -90,9 +90,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autocfg"
@@ -108,9 +108,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.10.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
@@ -133,15 +133,15 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.1"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cc"
version = "1.2.54"
version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -155,9 +155,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.43"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -168,9 +168,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.54"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@@ -178,9 +178,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.54"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
@@ -190,9 +190,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.49"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
@@ -202,9 +202,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
@@ -323,6 +323,12 @@ dependencies = [
"syn",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -359,9 +365,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.8"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
@@ -373,6 +379,12 @@ dependencies = [
"miniz_oxide",
]
[[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"
@@ -405,14 +417,15 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.3.4"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
@@ -437,6 +450,15 @@ dependencies = [
"ahash",
]
[[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.16.1"
@@ -471,9 +493,9 @@ dependencies = [
[[package]]
name = "iana-time-zone"
version = "0.1.64"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
@@ -574,6 +596,12 @@ 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"
@@ -619,6 +647,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
@@ -635,9 +665,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.85"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -650,18 +680,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.180"
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libredox"
version = "0.1.12"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags",
"libc",
]
@@ -678,9 +713,9 @@ dependencies = [
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
@@ -696,9 +731,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miniz_oxide"
@@ -758,6 +793,16 @@ dependencies = [
"zerovec",
]
[[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"
@@ -778,18 +823,18 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.43"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
@@ -804,9 +849,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.2"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -816,9 +861,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.13"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@@ -827,9 +872,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.8"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
@@ -869,6 +914,7 @@ dependencies = [
"toml",
"ureq",
"walkdir",
"which",
]
[[package]]
@@ -887,9 +933,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
@@ -948,6 +994,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1050,9 +1102,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -1072,12 +1124,12 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.24.0"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -1162,9 +1214,15 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
@@ -1250,10 +1308,19 @@ dependencies = [
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
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 = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
@@ -1264,9 +1331,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1274,9 +1341,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -1287,13 +1354,47 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
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 = "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 = "webpki-roots"
version = "0.26.11"
@@ -1312,6 +1413,17 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "which"
version = "8.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a824aeba0fbb27264f815ada4cff43d65b1741b7a4ed7629ff9089148c4a4e0"
dependencies = [
"env_home",
"rustix",
"winsafe",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -1539,18 +1651,106 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.14"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[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-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"
@@ -1583,18 +1783,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.33"
version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.33"
version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
dependencies = [
"proc-macro2",
"quote",
@@ -1663,6 +1863,6 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.16"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+1
View File
@@ -32,6 +32,7 @@ ureq = "2"
hostname = "0.4"
flate2 = "1.0"
quick-xml = "0.37"
which = "8"
[build-dependencies]
toml = "0.8"
+28
View File
@@ -200,6 +200,34 @@ rtk init --show # Should show "OpenCode: plugin installed"
---
## Problem: RTK commands fail on Windows ("program not found" or "No such file")
### Symptom
```
rtk vitest --run
# Error: program not found
# Or: The system cannot find the file specified
rtk lint .
# Error: No such file or directory
```
### Root Cause
On Windows, Node.js tools (vitest, eslint, tsc, etc.) are installed as `.CMD` or `.BAT` wrapper scripts, not as native `.exe` binaries. Rust's `std::process::Command::new("vitest")` does not honor the Windows `PATHEXT` environment variable, so it cannot find `vitest.CMD` even when it's on PATH.
### Solution
Update to rtk v0.23.1+ which resolves this via the `which` crate for proper PATH+PATHEXT resolution. All 16+ command modules now use `resolved_command()` instead of `Command::new()`.
```bash
cargo install --git https://github.com/rtk-ai/rtk
rtk --version # Should be 0.23.1+
```
### Affected Commands
All commands that spawn external tools: `rtk vitest`, `rtk lint`, `rtk tsc`, `rtk pnpm`, `rtk playwright`, `rtk prisma`, `rtk next`, `rtk prettier`, `rtk ruff`, `rtk pytest`, `rtk pip`, `rtk mypy`, `rtk golangci-lint`, and others.
---
## Problem: "command not found: rtk" after installation
### Symptom
+4 -5
View File
@@ -5,10 +5,9 @@
use crate::json_cmd;
use crate::tracking;
use crate::utils::{join_with_overflow, truncate_iso_date};
use crate::utils::{join_with_overflow, resolved_command, truncate_iso_date};
use anyhow::{Context, Result};
use serde_json::Value;
use std::process::Command;
const MAX_ITEMS: usize = 20;
const JSON_COMPRESS_DEPTH: usize = 4;
@@ -62,7 +61,7 @@ fn is_structured_operation(args: &[String]) -> bool {
fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("aws");
let mut cmd = resolved_command("aws");
cmd.arg(subcommand);
let mut has_output_flag = false;
@@ -126,7 +125,7 @@ fn run_aws_json(
extra_args: &[String],
verbose: u8,
) -> Result<(String, String, std::process::ExitStatus)> {
let mut cmd = Command::new("aws");
let mut cmd = resolved_command("aws");
for arg in sub_args {
cmd.arg(arg);
}
@@ -197,7 +196,7 @@ fn run_s3_ls(extra_args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// s3 ls doesn't support --output json, run as-is and filter text
let mut cmd = Command::new("aws");
let mut cmd = resolved_command("aws");
cmd.args(["s3", "ls"]);
for arg in extra_args {
cmd.arg(arg);
+3 -4
View File
@@ -1,9 +1,8 @@
use crate::tracking;
use crate::utils::truncate;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::ffi::OsString;
use std::process::Command;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
@@ -68,7 +67,7 @@ where
{
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("cargo");
let mut cmd = resolved_command("cargo");
cmd.arg(subcommand);
let restored_args = restore_double_dash(args);
@@ -965,7 +964,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
if verbose > 0 {
eprintln!("cargo passthrough: {:?}", args);
}
let status = Command::new("cargo")
let status = resolved_command("cargo")
.args(args)
.status()
.context("Failed to run cargo")?;
+5 -8
View File
@@ -4,6 +4,7 @@
//! Claude Code API usage metrics. Handles subprocess execution, JSON parsing,
//! and graceful degradation when ccusage is unavailable.
use crate::utils::{resolved_command, tool_exists};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::process::Command;
@@ -84,21 +85,17 @@ struct MonthlyEntry {
/// Check if ccusage binary exists in PATH
fn binary_exists() -> bool {
Command::new("which")
.arg("ccusage")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
tool_exists("ccusage")
}
/// Build the ccusage command, falling back to npx if binary not in PATH
fn build_command() -> Option<Command> {
if binary_exists() {
return Some(Command::new("ccusage"));
return Some(resolved_command("ccusage"));
}
// Fallback: try npx
let npx_check = Command::new("npx")
let npx_check = resolved_command("npx")
.arg("ccusage")
.arg("--help")
.stdout(std::process::Stdio::null())
@@ -106,7 +103,7 @@ fn build_command() -> Option<Command> {
.status();
if npx_check.map(|s| s.success()).unwrap_or(false) {
let mut cmd = Command::new("npx");
let mut cmd = resolved_command("npx");
cmd.arg("ccusage");
return Some(cmd);
}
+16 -16
View File
@@ -1,7 +1,7 @@
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::process::Command;
#[derive(Debug, Clone, Copy)]
pub enum ContainerCmd {
@@ -27,13 +27,13 @@ pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result<()> {
fn docker_ps(_verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let raw = Command::new("docker")
let raw = resolved_command("docker")
.args(["ps"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let output = Command::new("docker")
let output = resolved_command("docker")
.args([
"ps",
"--format",
@@ -84,13 +84,13 @@ fn docker_ps(_verbose: u8) -> Result<()> {
fn docker_images(_verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let raw = Command::new("docker")
let raw = resolved_command("docker")
.args(["images"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let output = Command::new("docker")
let output = resolved_command("docker")
.args(["images", "--format", "{{.Repository}}:{{.Tag}}\t{{.Size}}"])
.output()
.context("Failed to run docker images")?;
@@ -160,7 +160,7 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result<()> {
return Ok(());
}
let output = Command::new("docker")
let output = resolved_command("docker")
.args(["logs", "--tail", "100", container])
.output()
.context("Failed to run docker logs")?;
@@ -184,7 +184,7 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result<()> {
fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("kubectl");
let mut cmd = resolved_command("kubectl");
cmd.args(["get", "pods", "-o", "json"]);
for arg in args {
cmd.arg(arg);
@@ -285,7 +285,7 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> {
fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("kubectl");
let mut cmd = resolved_command("kubectl");
cmd.args(["get", "services", "-o", "json"]);
for arg in args {
cmd.arg(arg);
@@ -365,7 +365,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result<()> {
return Ok(());
}
let mut cmd = Command::new("kubectl");
let mut cmd = resolved_command("kubectl");
cmd.args(["logs", "--tail", "100", pod]);
for arg in args.iter().skip(1) {
cmd.arg(arg);
@@ -529,7 +529,7 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
if verbose > 0 {
eprintln!("docker passthrough: {:?}", args);
}
let status = Command::new("docker")
let status = resolved_command("docker")
.args(args)
.status()
.context("Failed to run docker")?;
@@ -551,7 +551,7 @@ pub fn run_compose_ps(verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Raw output for token tracking
let raw_output = Command::new("docker")
let raw_output = resolved_command("docker")
.args(["compose", "ps"])
.output()
.context("Failed to run docker compose ps")?;
@@ -564,7 +564,7 @@ pub fn run_compose_ps(verbose: u8) -> Result<()> {
let raw = String::from_utf8_lossy(&raw_output.stdout).to_string();
// Structured output for parsing (same pattern as docker_ps)
let output = Command::new("docker")
let output = resolved_command("docker")
.args([
"compose",
"ps",
@@ -595,7 +595,7 @@ pub fn run_compose_ps(verbose: u8) -> Result<()> {
pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("docker");
let mut cmd = resolved_command("docker");
cmd.args(["compose", "logs", "--tail", "100"]);
if let Some(svc) = service {
cmd.arg(svc);
@@ -633,7 +633,7 @@ pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result<()> {
pub fn run_compose_build(service: Option<&str>, verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("docker");
let mut cmd = resolved_command("docker");
cmd.args(["compose", "build"]);
if let Some(svc) = service {
cmd.arg(svc);
@@ -674,7 +674,7 @@ pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
if verbose > 0 {
eprintln!("docker compose passthrough: {:?}", args);
}
let status = Command::new("docker")
let status = resolved_command("docker")
.arg("compose")
.args(args)
.status()
@@ -699,7 +699,7 @@ pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
if verbose > 0 {
eprintln!("kubectl passthrough: {:?}", args);
}
let status = Command::new("kubectl")
let status = resolved_command("kubectl")
.args(args)
.status()
.context("Failed to run kubectl")?;
+2 -3
View File
@@ -1,12 +1,11 @@
use crate::json_cmd;
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use std::process::Command;
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("curl");
let mut cmd = resolved_command("curl");
cmd.arg("-s"); // Silent mode (no progress bar)
for arg in args {
+4 -5
View File
@@ -2,11 +2,10 @@ use crate::binlog;
use crate::dotnet_format_report;
use crate::dotnet_trx;
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -29,7 +28,7 @@ pub fn run_restore(args: &[String], verbose: u8) -> Result<()> {
pub fn run_format(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let (report_path, cleanup_report_path) = resolve_format_report_path(args);
let mut cmd = Command::new("dotnet");
let mut cmd = resolved_command("dotnet");
cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE);
cmd.arg("format");
@@ -80,7 +79,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let subcommand = args[0].to_string_lossy().to_string();
let mut cmd = Command::new("dotnet");
let mut cmd = resolved_command("dotnet");
cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE);
cmd.arg(&subcommand);
for arg in &args[1..] {
@@ -124,7 +123,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res
// For test commands, prefer user-provided results directory; otherwise create isolated one.
let (trx_results_dir, cleanup_trx_results_dir) = resolve_trx_results_dir(subcommand, args);
let mut cmd = Command::new("dotnet");
let mut cmd = resolved_command("dotnet");
cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE);
cmd.arg(subcommand);
+3 -4
View File
@@ -1,10 +1,9 @@
use crate::prettier_cmd;
use crate::ruff_cmd;
use crate::tracking;
use crate::utils::package_manager_exec;
use crate::utils::{package_manager_exec, resolved_command};
use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
/// Detect formatter from project files or explicit argument
fn detect_formatter(args: &[String]) -> String {
@@ -72,9 +71,9 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
// Build command based on formatter
let mut cmd = match formatter.as_str() {
"prettier" => package_manager_exec("prettier"),
"black" | "ruff" => Command::new(formatter.as_str()),
"black" | "ruff" => resolved_command(formatter.as_str()),
"biome" => package_manager_exec("biome"),
_ => Command::new(formatter.as_str()),
_ => resolved_command(formatter.as_str()),
};
// Add formatter-specific flags
+16 -17
View File
@@ -5,12 +5,11 @@
use crate::git;
use crate::tracking;
use crate::utils::{ok_confirmation, truncate};
use crate::utils::{ok_confirmation, resolved_command, truncate};
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use regex::Regex;
use serde_json::Value;
use std::process::Command;
lazy_static! {
static ref HTML_COMMENT_RE: Regex = Regex::new(r"(?s)<!--.*?-->").unwrap();
@@ -203,7 +202,7 @@ fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> {
fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args([
"pr",
"list",
@@ -304,7 +303,7 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
return run_passthrough_with_extra("gh", &["pr", "view", &pr_number], &extra_args);
}
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args([
"pr",
"view",
@@ -475,7 +474,7 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()>
None => return Err(anyhow::anyhow!("PR number required")),
};
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args(["pr", "checks", &pr_number]);
for arg in &extra_args {
cmd.arg(arg);
@@ -558,7 +557,7 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()>
fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args([
"pr",
"status",
@@ -614,7 +613,7 @@ fn run_issue(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> {
fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args(["issue", "list", "--json", "number,title,state,author"]);
for arg in args {
@@ -686,7 +685,7 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> {
None => return Err(anyhow::anyhow!("Issue number required")),
};
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args([
"issue",
"view",
@@ -782,7 +781,7 @@ fn run_workflow(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()>
fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args([
"run",
"list",
@@ -884,7 +883,7 @@ fn view_run(args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args(["run", "view", &run_id]);
for arg in &extra_args {
cmd.arg(arg);
@@ -960,7 +959,7 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.arg("repo").arg("view");
for arg in rest_args {
@@ -1030,7 +1029,7 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> {
fn pr_create(args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args(["pr", "create"]);
for arg in args {
cmd.arg(arg);
@@ -1068,7 +1067,7 @@ fn pr_create(args: &[String], _verbose: u8) -> Result<()> {
fn pr_merge(args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args(["pr", "merge"]);
for arg in args {
cmd.arg(arg);
@@ -1126,7 +1125,7 @@ fn pr_diff(args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.args(["pr", "diff"]);
for arg in gh_args.iter() {
cmd.arg(arg);
@@ -1161,7 +1160,7 @@ fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let subcmd = &args[0];
let mut cmd = Command::new("gh");
let mut cmd = resolved_command("gh");
cmd.arg("pr");
for arg in args {
cmd.arg(arg);
@@ -1221,7 +1220,7 @@ fn run_api(args: &[String], _verbose: u8) -> Result<()> {
fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[String]) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut command = Command::new(cmd);
let mut command = resolved_command(cmd);
for arg in base_args {
command.arg(arg);
}
@@ -1252,7 +1251,7 @@ fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[Strin
fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut command = Command::new(cmd);
let mut command = resolved_command(cmd);
command.arg(subcommand);
for arg in args {
command.arg(arg);
+2 -1
View File
@@ -1,4 +1,5 @@
use crate::tracking;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::process::Command;
@@ -22,7 +23,7 @@ pub enum GitCommand {
/// Create a git Command with global options (e.g. -C, -c, --git-dir, --work-tree)
/// prepended before any subcommand arguments.
fn git_cmd(global_args: &[String]) -> Command {
let mut cmd = Command::new("git");
let mut cmd = resolved_command("git");
for arg in global_args {
cmd.arg(arg);
}
+5 -6
View File
@@ -1,10 +1,9 @@
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::ffi::OsString;
use std::process::Command;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
@@ -40,7 +39,7 @@ struct PackageResult {
pub fn run_test(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("go");
let mut cmd = resolved_command("go");
cmd.arg("test");
// Force JSON output if not already specified
@@ -99,7 +98,7 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> {
pub fn run_build(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("go");
let mut cmd = resolved_command("go");
cmd.arg("build");
for arg in args {
@@ -152,7 +151,7 @@ pub fn run_build(args: &[String], verbose: u8) -> Result<()> {
pub fn run_vet(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("go");
let mut cmd = resolved_command("go");
cmd.arg("vet");
for arg in args {
@@ -210,7 +209,7 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let subcommand = args[0].to_string_lossy();
let mut cmd = Command::new("go");
let mut cmd = resolved_command("go");
cmd.arg(&*subcommand);
for arg in &args[1..] {
+2 -3
View File
@@ -1,9 +1,8 @@
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize)]
struct Position {
@@ -34,7 +33,7 @@ struct GolangciOutput {
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("golangci-lint");
let mut cmd = resolved_command("golangci-lint");
// Force JSON output
let has_format = args
+8 -4
View File
@@ -1,8 +1,8 @@
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use regex::Regex;
use std::collections::HashMap;
use std::process::Command;
pub fn run(
pattern: &str,
@@ -23,7 +23,7 @@ pub fn run(
// Fix: convert BRE alternation \| → | for rg (which uses PCRE-style regex)
let rg_pattern = pattern.replace(r"\|", "|");
let mut rg_cmd = Command::new("rg");
let mut rg_cmd = resolved_command("rg");
rg_cmd.args(["-n", "--no-heading", &rg_pattern, path]);
if let Some(ft) = file_type {
@@ -40,7 +40,11 @@ pub fn run(
let output = rg_cmd
.output()
.or_else(|_| Command::new("grep").args(["-rn", pattern, path]).output())
.or_else(|_| {
resolved_command("grep")
.args(["-rn", pattern, path])
.output()
})
.context("grep/rg failed")?;
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -274,7 +278,7 @@ mod tests {
fn test_rg_always_has_line_numbers() {
// grep_cmd::run() always passes "-n" to rg (line 24).
// This test documents that -n is built-in, so the clap flag is safe to ignore.
let mut cmd = std::process::Command::new("rg");
let mut cmd = resolved_command("rg");
cmd.args(["-n", "--no-heading", "NONEXISTENT_PATTERN_12345", "."]);
// If rg is available, it should accept -n without error (exit 1 = no match, not error)
if let Ok(output) = cmd.output() {
+3 -4
View File
@@ -1,10 +1,9 @@
use crate::tracking;
use crate::utils::{ok_confirmation, strip_ansi, truncate};
use crate::utils::{ok_confirmation, resolved_command, strip_ansi, truncate};
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use regex::Regex;
use std::ffi::OsString;
use std::process::Command;
lazy_static! {
static ref EMAIL_RE: Regex =
@@ -27,7 +26,7 @@ fn run_gt_filtered(
) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gt");
let mut cmd = resolved_command("gt");
for part in subcmd {
cmd.arg(part);
}
@@ -171,7 +170,7 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> {
fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("gt");
let mut cmd = resolved_command("gt");
cmd.arg(subcommand);
for arg in args {
cmd.arg(arg);
+3 -5
View File
@@ -1,12 +1,10 @@
use crate::mypy_cmd;
use crate::ruff_cmd;
use crate::tracking;
use crate::utils::{package_manager_exec, truncate};
use crate::utils::{package_manager_exec, resolved_command, truncate};
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize, Serialize)]
struct EslintMessage {
@@ -90,10 +88,10 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
let (linter, explicit) = detect_linter(effective_args);
// Python linters use Command::new() directly (they're on PATH via pip/pipx)
// Python linters use resolved_command() directly (they're on PATH via pip/pipx)
// JS linters use package_manager_exec (npx/pnpm exec)
let mut cmd = if is_python_linter(linter) {
Command::new(linter)
resolved_command(linter)
} else {
package_manager_exec(linter)
};
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use std::process::Command;
/// Noise directories commonly excluded from LLM context
const NOISE_DIRS: &[&str] = &[
@@ -51,7 +51,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
// Build ls -la + any extra flags the user passed (e.g. -R)
// Strip -l, -a, -h (we handle all of these ourselves)
let mut cmd = Command::new("ls");
let mut cmd = resolved_command("ls");
cmd.arg("-la");
for flag in &flags {
if flag.starts_with("--") {
+6 -6
View File
@@ -1044,7 +1044,7 @@ fn run_fallback(parse_error: clap::Error) -> Result<()> {
if let Some(filter) = toml_match {
// TOML match: capture stdout for filtering
let result = std::process::Command::new(&args[0])
let result = utils::resolved_command(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped()) // capture
@@ -1089,7 +1089,7 @@ fn run_fallback(parse_error: clap::Error) -> Result<()> {
}
} else {
// No TOML match: original passthrough behaviour (Stdio::inherit, streaming)
let status = std::process::Command::new(&args[0])
let status = utils::resolved_command(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
@@ -1848,7 +1848,7 @@ fn main() -> Result<()> {
_ => {
// Passthrough other prisma subcommands
let timer = tracking::TimedExecution::start();
let mut cmd = std::process::Command::new("npx");
let mut cmd = utils::resolved_command("npx");
for arg in &args {
cmd.arg(arg);
}
@@ -1865,7 +1865,7 @@ fn main() -> Result<()> {
}
} else {
let timer = tracking::TimedExecution::start();
let status = std::process::Command::new("npx")
let status = utils::resolved_command("npx")
.arg("prisma")
.status()
.context("Failed to run npx prisma")?;
@@ -1961,7 +1961,7 @@ fn main() -> Result<()> {
Commands::Proxy { args } => {
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::process::Stdio;
use std::thread;
if args.is_empty() {
@@ -1997,7 +1997,7 @@ fn main() -> Result<()> {
eprintln!("Proxy mode: {} {}", cmd_name, cmd_args.join(" "));
}
let mut child = Command::new(&cmd_name)
let mut child = utils::resolved_command(cmd_name.as_ref())
.args(&cmd_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
+4 -16
View File
@@ -1,17 +1,16 @@
use crate::tracking;
use crate::utils::{strip_ansi, truncate};
use crate::utils::{resolved_command, strip_ansi, tool_exists, truncate};
use anyhow::{Context, Result};
use regex::Regex;
use std::collections::HashMap;
use std::process::Command;
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = if which_command("mypy").is_some() {
Command::new("mypy")
let mut cmd = if tool_exists("mypy") {
resolved_command("mypy")
} else {
let mut c = Command::new("python3");
let mut c = resolved_command("python3");
c.arg("-m").arg("mypy");
c
};
@@ -47,17 +46,6 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
std::process::exit(output.status.code().unwrap_or(1));
}
fn which_command(cmd: &str) -> Option<String> {
Command::new("which")
.arg(cmd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
struct MypyError {
file: String,
line: usize,
+4 -9
View File
@@ -1,23 +1,18 @@
use crate::tracking;
use crate::utils::{strip_ansi, truncate};
use crate::utils::{resolved_command, strip_ansi, tool_exists, truncate};
use anyhow::{Context, Result};
use regex::Regex;
use std::process::Command;
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Try next directly first, fallback to npx if not found
let next_exists = Command::new("which")
.arg("next")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let next_exists = tool_exists("next");
let mut cmd = if next_exists {
Command::new("next")
resolved_command("next")
} else {
let mut c = Command::new("npx");
let mut c = resolved_command("npx");
c.arg("next");
c
};
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use std::process::Command;
/// Known npm subcommands that should NOT get "run" injected.
/// Shared between production code and tests to avoid drift.
@@ -74,7 +74,7 @@ const NPM_SUBCOMMANDS: &[&str] = &[
pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("npm");
let mut cmd = resolved_command("npm");
// Determine if this is "npm run <script>" or another npm subcommand (install, list, etc.)
// Only inject "run" when args look like a script name, not a known npm subcommand.
+5 -17
View File
@@ -1,7 +1,7 @@
use crate::tracking;
use crate::utils::{resolved_command, tool_exists};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::process::Command;
#[derive(Debug, Deserialize)]
struct Package {
@@ -15,7 +15,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Auto-detect uv vs pip
let use_uv = which_command("uv").is_some();
let use_uv = tool_exists("uv");
let base_cmd = if use_uv { "uv" } else { "pip" };
if verbose > 0 && use_uv {
@@ -51,7 +51,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
}
fn run_list(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, String)> {
let mut cmd = Command::new(base_cmd);
let mut cmd = resolved_command(base_cmd);
if base_cmd == "uv" {
cmd.arg("pip");
@@ -86,7 +86,7 @@ fn run_list(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, Str
}
fn run_outdated(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, String)> {
let mut cmd = Command::new(base_cmd);
let mut cmd = resolved_command(base_cmd);
if base_cmd == "uv" {
cmd.arg("pip");
@@ -121,7 +121,7 @@ fn run_outdated(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String,
}
fn run_passthrough(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, String)> {
let mut cmd = Command::new(base_cmd);
let mut cmd = resolved_command(base_cmd);
if base_cmd == "uv" {
cmd.arg("pip");
@@ -153,18 +153,6 @@ fn run_passthrough(base_cmd: &str, args: &[String], verbose: u8) -> Result<(Stri
Ok((raw.clone(), raw))
}
/// Check if a command exists in PATH
fn which_command(cmd: &str) -> Option<String> {
Command::new("which")
.arg(cmd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Filter pip list JSON output
fn filter_pip_list(output: &str) -> String {
let packages: Vec<Package> = match serde_json::from_str(output) {
+4 -4
View File
@@ -1,5 +1,5 @@
use crate::tracking;
use crate::utils::{detect_package_manager, strip_ansi};
use crate::utils::{detect_package_manager, resolved_command, strip_ansi};
use anyhow::{Context, Result};
use regex::Regex;
use serde::Deserialize;
@@ -246,17 +246,17 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
let pm = detect_package_manager();
let mut cmd = match pm {
"pnpm" => {
let mut c = std::process::Command::new("pnpm");
let mut c = resolved_command("pnpm");
c.arg("exec").arg("--").arg("playwright");
c
}
"yarn" => {
let mut c = std::process::Command::new("yarn");
let mut c = resolved_command("yarn");
c.arg("exec").arg("--").arg("playwright");
c
}
_ => {
let mut c = std::process::Command::new("npx");
let mut c = resolved_command("npx");
c.arg("--no-install").arg("--").arg("playwright");
c
}
+5 -5
View File
@@ -1,9 +1,9 @@
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::ffi::OsString;
use std::process::Command;
use crate::parser::{
emit_degradation_warning, emit_passthrough_warning, truncate_output, Dependency,
@@ -294,7 +294,7 @@ pub fn run(cmd: PnpmCommand, args: &[String], verbose: u8) -> Result<()> {
fn run_list(depth: usize, args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("pnpm");
let mut cmd = resolved_command("pnpm");
cmd.arg("list");
cmd.arg(format!("--depth={}", depth));
cmd.arg("--json");
@@ -350,7 +350,7 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result<()> {
fn run_outdated(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("pnpm");
let mut cmd = resolved_command("pnpm");
cmd.arg("outdated");
cmd.arg("--format");
cmd.arg("json");
@@ -411,7 +411,7 @@ fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result<()>
}
}
let mut cmd = Command::new("pnpm");
let mut cmd = resolved_command("pnpm");
cmd.arg("install");
for pkg in packages {
@@ -495,7 +495,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
if verbose > 0 {
eprintln!("pnpm passthrough: {:?}", args);
}
let status = Command::new("pnpm")
let status = resolved_command("pnpm")
.args(args)
.status()
.context("Failed to run pnpm")?;
+4 -9
View File
@@ -1,4 +1,5 @@
use crate::tracking;
use crate::utils::{resolved_command, tool_exists};
use anyhow::{Context, Result};
use std::process::Command;
@@ -26,16 +27,10 @@ pub fn run(cmd: PrismaCommand, args: &[String], verbose: u8) -> Result<()> {
/// Create a Command that will run prisma (tries global first, then npx)
fn create_prisma_command() -> Command {
let prisma_exists = Command::new("which")
.arg("prisma")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if prisma_exists {
Command::new("prisma")
if tool_exists("prisma") {
resolved_command("prisma")
} else {
let mut c = Command::new("npx");
let mut c = resolved_command("npx");
c.arg("prisma");
c
}
+2 -1
View File
@@ -4,6 +4,7 @@
//! and produces compact tab-separated or key=value output.
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use regex::Regex;
@@ -21,7 +22,7 @@ lazy_static! {
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = std::process::Command::new("psql");
let mut cmd = resolved_command("psql");
for arg in args {
cmd.arg(arg);
}
+4 -17
View File
@@ -1,7 +1,6 @@
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, tool_exists, truncate};
use anyhow::{Context, Result};
use std::process::Command;
#[derive(Debug, PartialEq)]
enum ParseState {
@@ -15,11 +14,11 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Try to detect pytest command (could be "pytest", "python -m pytest", etc.)
let mut cmd = if which_command("pytest").is_some() {
Command::new("pytest")
let mut cmd = if tool_exists("pytest") {
resolved_command("pytest")
} else {
// Fallback to python -m pytest
let mut c = Command::new("python");
let mut c = resolved_command("python");
c.arg("-m").arg("pytest");
c
};
@@ -83,18 +82,6 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
Ok(())
}
/// Check if a command exists in PATH
fn which_command(cmd: &str) -> Option<String> {
Command::new("which")
.arg(cmd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Parse pytest output using state machine
fn filter_pytest_output(output: &str) -> String {
let mut state = ParseState::Header;
+2 -3
View File
@@ -1,9 +1,8 @@
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize)]
struct RuffLocation {
@@ -38,7 +37,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
let is_format = args.iter().any(|a| a == "format");
let mut cmd = Command::new("ruff");
let mut cmd = resolved_command("ruff");
if is_check {
// Force JSON output for check command
+3 -4
View File
@@ -7,8 +7,8 @@
//! unless -a flag is present (respecting user intent).
use crate::tracking;
use crate::utils::{resolved_command, tool_exists};
use anyhow::{Context, Result};
use std::process::Command;
/// Noise directories commonly excluded from LLM context
const NOISE_DIRS: &[&str] = &[
@@ -44,8 +44,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Check if tree is installed
let tree_check = Command::new("which").arg("tree").output();
if tree_check.is_err() || !tree_check.unwrap().status.success() {
if !tool_exists("tree") {
anyhow::bail!(
"tree command not found. Install it first:\n\
- macOS: brew install tree\n\
@@ -55,7 +54,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> {
);
}
let mut cmd = Command::new("tree");
let mut cmd = resolved_command("tree");
// Determine if user wants all files or default behavior
let show_all = args.iter().any(|a| a == "-a" || a == "--all");
+4 -9
View File
@@ -1,24 +1,19 @@
use crate::tracking;
use crate::utils::truncate;
use crate::utils::{resolved_command, tool_exists, truncate};
use anyhow::{Context, Result};
use regex::Regex;
use std::collections::HashMap;
use std::process::Command;
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Try tsc directly first, fallback to npx if not found
let tsc_exists = Command::new("which")
.arg("tsc")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let tsc_exists = tool_exists("tsc");
let mut cmd = if tsc_exists {
Command::new("tsc")
resolved_command("tsc")
} else {
let mut c = Command::new("npx");
let mut c = resolved_command("npx");
c.arg("tsc");
c
};
+298 -16
View File
@@ -7,6 +7,7 @@
use anyhow::{Context, Result};
use regex::Regex;
use std::path::PathBuf;
use std::process::Command;
/// Truncates a string to `max_len` characters, appending `...` if needed.
@@ -33,7 +34,7 @@ pub fn truncate(s: &str, max_len: usize) -> String {
}
}
/// Strips ANSI escape codes (colors, styles) from a string.
/// Strip ANSI escape codes (colors, styles) from a string.
///
/// # Arguments
/// * `text` - Text potentially containing ANSI escape codes
@@ -54,7 +55,7 @@ pub fn strip_ansi(text: &str) -> String {
/// Executes a command and returns cleaned stdout/stderr.
///
/// # Arguments
/// * `cmd` - Command to execute (e.g. "eslint")
/// * `cmd` - Command to execute (e.g., "eslint")
/// * `args` - Command arguments
///
/// # Returns
@@ -68,7 +69,7 @@ pub fn strip_ansi(text: &str) -> String {
/// ```
#[allow(dead_code)]
pub fn execute_command(cmd: &str, args: &[&str]) -> Result<(String, String, i32)> {
let output = Command::new(cmd)
let output = resolved_command(cmd)
.args(args)
.output()
.context(format!("Failed to execute {}", cmd))?;
@@ -83,10 +84,10 @@ pub fn execute_command(cmd: &str, args: &[&str]) -> Result<(String, String, i32)
/// Formats a token count with K/M suffixes for readability.
///
/// # Arguments
/// * `n` - Token count
/// * `n` - Number of tokens
///
/// # Returns
/// Formatted string (e.g. "1.2M", "59.2K", "694")
/// Formatted string (e.g., "1.2M", "59.2K", "694")
///
/// # Examples
/// ```
@@ -229,29 +230,23 @@ pub fn detect_package_manager() -> &'static str {
/// Build a Command using the detected package manager's exec mechanism.
/// Returns a Command ready to have tool-specific args appended.
pub fn package_manager_exec(tool: &str) -> Command {
let tool_exists = Command::new("which")
.arg(tool)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if tool_exists {
Command::new(tool)
if tool_exists(tool) {
resolved_command(tool)
} else {
let pm = detect_package_manager();
match pm {
"pnpm" => {
let mut c = Command::new("pnpm");
let mut c = resolved_command("pnpm");
c.arg("exec").arg("--").arg(tool);
c
}
"yarn" => {
let mut c = Command::new("yarn");
let mut c = resolved_command("yarn");
c.arg("exec").arg("--").arg(tool);
c
}
_ => {
let mut c = Command::new("npx");
let mut c = resolved_command("npx");
c.arg("--no-install").arg("--").arg(tool);
c
}
@@ -259,6 +254,68 @@ pub fn package_manager_exec(tool: &str) -> Command {
}
}
/// Resolve a binary name to its full path, honoring PATHEXT on Windows.
///
/// On Windows, Node.js tools are installed as `.CMD`/`.BAT`/`.PS1` shims.
/// Rust's `std::process::Command::new()` does NOT honor PATHEXT, so
/// `Command::new("vitest")` fails even when `vitest.CMD` is on PATH.
///
/// This function uses the `which` crate to perform proper PATH+PATHEXT resolution.
///
/// # Arguments
/// * `name` - Binary name (e.g., "vitest", "eslint", "tsc")
///
/// # Returns
/// Full path to the resolved binary, or error if not found.
pub fn resolve_binary(name: &str) -> Result<PathBuf> {
which::which(name).context(format!("Binary '{}' not found on PATH", name))
}
/// Create a `Command` with PATHEXT-aware binary resolution.
///
/// Drop-in replacement for `Command::new(name)` that works on Windows
/// with `.CMD`/`.BAT`/`.PS1` wrappers.
///
/// Falls back to `Command::new(name)` if resolution fails, so native
/// commands (git, cargo) still work even if `which` can't find them.
///
/// # Arguments
/// * `name` - Binary name (e.g., "vitest", "eslint")
///
/// # Returns
/// A `Command` configured with the resolved binary path.
pub fn resolved_command(name: &str) -> Command {
match resolve_binary(name) {
Ok(path) => Command::new(path),
Err(e) => {
// On Windows, resolution failure likely means a .CMD/.BAT wrapper
// wasn't found — always warn so users have a signal.
// On Unix, this is less common; only log in debug builds.
#[cfg(target_os = "windows")]
eprintln!(
"rtk: Failed to resolve '{}' via PATH, falling back to direct exec: {}",
name, e
);
#[cfg(not(target_os = "windows"))]
{
#[cfg(debug_assertions)]
eprintln!(
"rtk: Failed to resolve '{}' via PATH, falling back to direct exec: {}",
name, e
);
}
Command::new(name)
}
}
}
/// Check if a tool exists on PATH (PATHEXT-aware on Windows).
///
/// Replaces manual `Command::new("which").arg(tool)` checks that fail on Windows.
pub fn tool_exists(name: &str) -> bool {
which::which(name).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -429,4 +486,229 @@ mod tests {
let result = truncate(cjk, 6);
assert!(result.ends_with("..."));
}
// ===== resolve_binary tests (issue #212) =====
#[test]
fn test_resolve_binary_finds_known_command() {
// "cargo" must be on PATH in any Rust dev environment
let result = resolve_binary("cargo");
assert!(
result.is_ok(),
"resolve_binary('cargo') should succeed, got: {:?}",
result.err()
);
}
#[test]
fn test_resolve_binary_returns_absolute_path() {
let path = resolve_binary("cargo").expect("cargo should be resolvable");
assert!(
path.is_absolute(),
"resolve_binary should return absolute path, got: {:?}",
path
);
}
#[test]
fn test_resolve_binary_fails_for_unknown() {
let result = resolve_binary("nonexistent_binary_xyz_99999");
assert!(
result.is_err(),
"resolve_binary should fail for nonexistent binary"
);
}
#[test]
fn test_resolve_binary_path_contains_binary_name() {
let path = resolve_binary("cargo").expect("cargo should be resolvable");
let filename = path
.file_name()
.expect("should have filename")
.to_string_lossy();
// On Windows this could be "cargo.exe", on Unix just "cargo"
assert!(
filename.starts_with("cargo"),
"resolved path filename should start with 'cargo', got: {}",
filename
);
}
// ===== resolved_command tests (issue #212) =====
#[test]
fn test_resolved_command_executes_known_command() {
let output = resolved_command("cargo")
.arg("--version")
.output()
.expect("resolved_command('cargo') should execute");
assert!(
output.status.success(),
"cargo --version should succeed via resolved_command"
);
}
// ===== tool_exists tests (issue #212) =====
#[test]
fn test_tool_exists_finds_cargo() {
assert!(
tool_exists("cargo"),
"tool_exists('cargo') should return true"
);
}
#[test]
fn test_tool_exists_rejects_unknown() {
assert!(
!tool_exists("nonexistent_binary_xyz_99999"),
"tool_exists should return false for nonexistent binary"
);
}
#[test]
fn test_tool_exists_finds_git() {
assert!(tool_exists("git"), "tool_exists('git') should return true");
}
// ===== Windows-specific PATHEXT resolution tests (issue #212) =====
#[cfg(target_os = "windows")]
mod windows_tests {
use super::super::*;
use std::fs;
/// Create a temporary .cmd wrapper to simulate Node.js tool installation
fn create_temp_cmd_wrapper(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
let cmd_path = dir.join(format!("{}.cmd", name));
fs::write(&cmd_path, "@echo off\r\necho fake-tool-output\r\n")
.expect("failed to create .cmd wrapper");
cmd_path
}
/// Build a PATH string that includes the temp dir
fn path_with_dir(dir: &std::path::Path) -> std::ffi::OsString {
let original = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(dir.as_os_str());
new_path.push(";");
new_path.push(&original);
new_path
}
#[test]
fn test_resolve_binary_finds_cmd_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
create_temp_cmd_wrapper(temp_dir.path(), "fake-tool-test");
// Use which::which_in to avoid mutating global PATH (thread-safe)
let search_path = path_with_dir(temp_dir.path());
let result = which::which_in(
"fake-tool-test",
Some(search_path),
std::env::current_dir().unwrap(),
);
assert!(
result.is_ok(),
"which_in should find .cmd wrapper on Windows, got: {:?}",
result.err()
);
let path = result.unwrap();
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
assert!(
ext == "cmd" || ext == "bat",
"resolved path should have .cmd/.bat extension, got: {:?}",
path
);
}
#[test]
fn test_resolve_binary_finds_bat_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let bat_path = temp_dir.path().join("fake-bat-tool.bat");
fs::write(&bat_path, "@echo off\r\necho bat-output\r\n")
.expect("failed to create .bat wrapper");
let search_path = path_with_dir(temp_dir.path());
let result = which::which_in(
"fake-bat-tool",
Some(search_path),
std::env::current_dir().unwrap(),
);
assert!(
result.is_ok(),
"which_in should find .bat wrapper on Windows, got: {:?}",
result.err()
);
}
#[test]
fn test_resolved_command_executes_cmd_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
create_temp_cmd_wrapper(temp_dir.path(), "fake-exec-test");
// Resolve the full path, then execute it directly (no PATH mutation)
let search_path = path_with_dir(temp_dir.path());
let resolved = which::which_in(
"fake-exec-test",
Some(search_path),
std::env::current_dir().unwrap(),
)
.expect("should resolve fake-exec-test");
let output = Command::new(&resolved).output();
assert!(
output.is_ok(),
"Command with resolved path should execute .cmd wrapper on Windows"
);
let output = output.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("fake-tool-output"),
"should get output from .cmd wrapper, got: {}",
stdout
);
}
#[test]
fn test_resolved_command_fallback_on_unknown_binary() {
// When resolve_binary fails, resolved_command should fall back to
// Command::new(name) instead of panicking. On Windows this also
// prints a warning to stderr.
let mut cmd = resolved_command("nonexistent_binary_xyz_99999");
// The Command should be created (not panic). Attempting to run it
// will fail, but that's expected — we just verify the fallback path
// produces a usable Command.
let result = cmd.output();
assert!(
result.is_err() || !result.unwrap().status.success(),
"nonexistent binary should fail to execute, but resolved_command must not panic"
);
}
#[test]
fn test_tool_exists_finds_cmd_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
create_temp_cmd_wrapper(temp_dir.path(), "fake-exists-test");
let search_path = path_with_dir(temp_dir.path());
let result = which::which_in(
"fake-exists-test",
Some(search_path),
std::env::current_dir().unwrap(),
);
assert!(
result.is_ok(),
"which_in should find .cmd wrapper on Windows"
);
}
}
}
+2 -2
View File
@@ -7,13 +7,13 @@
/// - `wc -c file.py` → `978`
/// - `wc -l *.py` → table with common path prefix stripped
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use std::process::Command;
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = Command::new("wc");
let mut cmd = resolved_command("wc");
for arg in args {
cmd.arg(arg);
}
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::tracking;
use crate::utils::resolved_command;
use anyhow::{Context, Result};
use std::process::Command;
/// Compact wget - strips progress bars, shows only result
pub fn run(url: &str, args: &[String], verbose: u8) -> Result<()> {
@@ -19,7 +19,7 @@ pub fn run(url: &str, args: &[String], verbose: u8) -> Result<()> {
}
cmd_args.push(url);
let output = Command::new("wget")
let output = resolved_command("wget")
.args(&cmd_args)
.output()
.context("Failed to run wget")?;
@@ -64,7 +64,7 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result<()> {
}
cmd_args.push(url);
let output = Command::new("wget")
let output = resolved_command("wget")
.args(&cmd_args)
.output()
.context("Failed to run wget")?;