diff --git a/Cargo.lock b/Cargo.lock index 29a678cc..bc2fc985 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,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" @@ -96,9 +96,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[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 = "bstr" @@ -112,15 +112,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", @@ -147,9 +147,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", @@ -157,9 +157,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", @@ -169,9 +169,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", @@ -181,9 +181,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" @@ -253,6 +253,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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" @@ -289,9 +301,15 @@ 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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "getrandom" @@ -306,14 +324,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", + "wasip3", ] [[package]] @@ -338,6 +357,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" @@ -361,9 +389,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[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", @@ -383,6 +411,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ignore" version = "0.4.25" @@ -407,6 +441,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -423,9 +459,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" dependencies = [ "once_cell", "wasm-bindgen", @@ -437,11 +473,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[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.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" @@ -466,9 +508,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 = "log" @@ -478,9 +520,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 = "num-traits" @@ -515,6 +557,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[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" @@ -526,9 +578,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -552,9 +604,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", @@ -564,9 +616,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", @@ -575,9 +627,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rtk" @@ -598,6 +650,7 @@ dependencies = [ "thiserror", "toml", "walkdir", + "which", ] [[package]] @@ -616,9 +669,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", @@ -642,6 +695,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" @@ -715,9 +774,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[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", @@ -726,12 +785,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -800,9 +859,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "utf8parse" @@ -847,11 +912,20 @@ dependencies = [ "wit-bindgen", ] +[[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", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" dependencies = [ "cfg-if", "once_cell", @@ -862,9 +936,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -872,9 +946,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" dependencies = [ "bumpalo", "proc-macro2", @@ -885,13 +959,59 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" 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 = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1117,26 +1237,114 @@ 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 = "zerocopy" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", @@ -1145,6 +1353,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" diff --git a/Cargo.toml b/Cargo.toml index 244aa793..5d3fa5e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } toml = "0.8" chrono = "0.4" thiserror = "1.0" +which = "7" tempfile = "3" [dev-dependencies] diff --git a/INSTALL.md b/INSTALL.md index 002e7147..54291294 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -148,6 +148,37 @@ rtk init # Creates ./CLAUDE.md with full RTK instructions (137 lines) **Token savings**: Instructions loaded only for this project +### Gemini CLI Setup + +**Best for: Gemini CLI users wanting the same token optimization** + +```bash +rtk init --gemini +# → Registers "rtk hook gemini" in ~/.gemini/settings.json +# → Prompts: "Patch settings.json? [y/N]" +# → If yes: patches + creates backup (~/.gemini/settings.json.bak) + +# Automated alternatives: +rtk init --gemini --auto-patch # Patch without prompting +rtk init --gemini --no-patch # Print manual instructions instead + +# Verify installation +rtk init --show # Shows both Claude and Gemini hook status +``` + +**Manual setup** (if `rtk init --gemini` isn't available): +```json +// Add to ~/.gemini/settings.json +{ + "hooks": { + "BeforeTool": [{ + "matcher": "run_shell_command", + "hooks": [{ "type": "command", "command": "rtk hook gemini" }] + }] + } +} +``` + ### Upgrading from Previous Version If you previously used `rtk init -g` with the old system (137-line injection): diff --git a/README.md b/README.md index b6537eab..878cd6bb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [Website](https://www.rtk-ai.app) | [GitHub](https://github.com/rtk-ai/rtk) | [Install](INSTALL.md) -rtk filters and compresses command outputs before they reach your LLM context, saving 60-90% of tokens on common operations. +rtk filters and compresses command outputs before they reach your LLM context, saving 60-90% of tokens on common operations. Works with **Claude Code** and **Gemini CLI**. ## ⚠️ Important: Name Collision Warning @@ -684,6 +684,68 @@ chmod +x ~/.claude/hooks/rtk-suggest.sh The suggest hook detects the same commands as the rewrite hook but outputs a `systemMessage` instead of `updatedInput`, informing Claude Code that an rtk alternative exists. +## Gemini CLI Integration + +RTK also supports [Gemini CLI](https://github.com/google-gemini/gemini-cli) via its **BeforeTool** hook protocol. The same safety engine that powers the Claude Code hook is used for Gemini, providing consistent command rewriting and blocking across both agents. + +### Quick Install (Automated) + +```bash +rtk init --gemini +# → Patches ~/.gemini/settings.json with BeforeTool hook +# → Prompts: "Patch settings.json? [y/N]" +# → Creates backup (~/.gemini/settings.json.bak) if file exists + +# Options: +rtk init --gemini --auto-patch # Patch without prompting (CI/CD) +rtk init --gemini --no-patch # Skip patching, print manual JSON snippet + +# Verify installation +rtk init --show +``` + +### Manual Install + +Add the following to `~/.gemini/settings.json`: + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "run_shell_command", + "hooks": [ + { + "type": "command", + "command": "rtk hook gemini" + } + ] + } + ] + } +} +``` + +### How It Works + +When Gemini CLI is about to execute a shell command, it sends a JSON payload to `rtk hook gemini` on stdin. RTK's safety engine evaluates the command and responds with: + +- **Allow + rewrite**: Rewrites the command to its `rtk run -c '...'` equivalent +- **Block**: Returns `"deny"` with a reason explaining which native tool to use instead +- **Passthrough**: Commands already using `rtk` pass through unchanged + +The `matcher` field (`run_shell_command`) identifies Gemini's shell execution tool (analogous to Claude Code's `Bash` matcher). Non-shell tool events pass through without inspection. + +### Uninstalling Gemini Hook + +```bash +rtk init --gemini --uninstall +# → Removes RTK hook entry from ~/.gemini/settings.json +# → Preserves other hooks and settings +``` + +The global `rtk init -g --uninstall` also removes Gemini hooks alongside Claude Code hooks. + ## Uninstalling RTK **Complete Removal (Global Only)**: @@ -691,12 +753,12 @@ The suggest hook detects the same commands as the rewrite hook but outputs a `sy rtk init -g --uninstall # Removes: -# - ~/.claude/hooks/rtk-rewrite.sh +# - RTK hook from ~/.claude/settings.json +# - RTK hook from ~/.gemini/settings.json # - ~/.claude/RTK.md # - @RTK.md reference from ~/.claude/CLAUDE.md -# - RTK hook entry from ~/.claude/settings.json -# Restart Claude Code after uninstall +# Restart Claude Code / Gemini CLI after uninstall ``` **Restore from Backup** (if needed): diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02caa..f8957258 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -1,209 +1,4 @@ -#!/bin/bash -# RTK auto-rewrite hook for Claude Code PreToolUse:Bash -# Transparently rewrites raw commands to their rtk equivalents. -# Outputs JSON with updatedInput to modify the command before execution. - -# Guards: skip silently if dependencies missing -if ! command -v rtk &>/dev/null || ! command -v jq &>/dev/null; then - exit 0 -fi - -set -euo pipefail - -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') - -if [ -z "$CMD" ]; then - exit 0 -fi - -# Extract the first meaningful command (before pipes, &&, etc.) -# We only rewrite if the FIRST command in a chain matches. -FIRST_CMD="$CMD" - -# Skip if already using rtk -case "$FIRST_CMD" in - rtk\ *|*/rtk\ *) exit 0 ;; -esac - -# Skip commands with heredocs, variable assignments as the whole command, etc. -case "$FIRST_CMD" in - *'<<'*) exit 0 ;; -esac - -# Strip leading env var assignments for pattern matching -# e.g., "TEST_SESSION_ID=2 npx playwright test" → match against "npx playwright test" -# but preserve them in the rewritten command for execution. -ENV_PREFIX=$(echo "$FIRST_CMD" | grep -oE '^([A-Za-z_][A-Za-z0-9_]*=[^ ]* +)+' || echo "") -if [ -n "$ENV_PREFIX" ]; then - MATCH_CMD="${FIRST_CMD:${#ENV_PREFIX}}" - CMD_BODY="${CMD:${#ENV_PREFIX}}" -else - MATCH_CMD="$FIRST_CMD" - CMD_BODY="$CMD" -fi - -REWRITTEN="" - -# --- Git commands --- -if echo "$MATCH_CMD" | grep -qE '^git[[:space:]]'; then - GIT_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ - -e 's/^git[[:space:]]+//' \ - -e 's/(-C|-c)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ - -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ - -e 's/--(no-pager|no-optional-locks|bare|literal-pathspecs)[[:space:]]*//g' \ - -e 's/^[[:space:]]+//') - case "$GIT_SUBCMD" in - status|status\ *|diff|diff\ *|log|log\ *|add|add\ *|commit|commit\ *|push|push\ *|pull|pull\ *|branch|branch\ *|fetch|fetch\ *|stash|stash\ *|show|show\ *) - REWRITTEN="${ENV_PREFIX}rtk $CMD_BODY" - ;; - esac - -# --- GitHub CLI (added: api, release) --- -elif echo "$MATCH_CMD" | grep -qE '^gh[[:space:]]+(pr|issue|run|api|release)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^gh /rtk gh /')" - -# --- Cargo --- -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]'; then - CARGO_SUBCMD=$(echo "$MATCH_CMD" | sed -E 's/^cargo[[:space:]]+(\+[^[:space:]]+[[:space:]]+)?//') - case "$CARGO_SUBCMD" in - test|test\ *|build|build\ *|clippy|clippy\ *|check|check\ *|install|install\ *|fmt|fmt\ *) - REWRITTEN="${ENV_PREFIX}rtk $CMD_BODY" - ;; - esac - -# --- File operations --- -elif echo "$MATCH_CMD" | grep -qE '^cat[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cat /rtk read /')" -elif echo "$MATCH_CMD" | grep -qE '^(rg|grep)[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(rg|grep) /rtk grep /')" -elif echo "$MATCH_CMD" | grep -qE '^ls([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ls/rtk ls/')" -elif echo "$MATCH_CMD" | grep -qE '^tree([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^tree/rtk tree/')" -elif echo "$MATCH_CMD" | grep -qE '^find[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^find /rtk find /')" -elif echo "$MATCH_CMD" | grep -qE '^diff[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^diff /rtk diff /')" -elif echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+'; then - # Transform: head -N file → rtk read file --max-lines N - # Also handle: head --lines=N file - if echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+-[0-9]+[[:space:]]+'; then - LINES=$(echo "$MATCH_CMD" | sed -E 's/^head +-([0-9]+) +.+$/\1/') - FILE=$(echo "$MATCH_CMD" | sed -E 's/^head +-[0-9]+ +(.+)$/\1/') - REWRITTEN="${ENV_PREFIX}rtk read $FILE --max-lines $LINES" - elif echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+--lines=[0-9]+[[:space:]]+'; then - LINES=$(echo "$MATCH_CMD" | sed -E 's/^head +--lines=([0-9]+) +.+$/\1/') - FILE=$(echo "$MATCH_CMD" | sed -E 's/^head +--lines=[0-9]+ +(.+)$/\1/') - REWRITTEN="${ENV_PREFIX}rtk read $FILE --max-lines $LINES" - fi - -# --- JS/TS tooling (added: npm run, npm test, vue-tsc) --- -elif echo "$MATCH_CMD" | grep -qE '^(pnpm[[:space:]]+)?(npx[[:space:]]+)?vitest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(pnpm )?(npx )?vitest( run)?/rtk vitest run/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm test/rtk vitest run/')" -elif echo "$MATCH_CMD" | grep -qE '^npm[[:space:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^npm test/rtk npm test/')" -elif echo "$MATCH_CMD" | grep -qE '^npm[[:space:]]+run[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^npm run /rtk npm /')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?vue-tsc([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?vue-tsc/rtk tsc/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+tsc([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm tsc/rtk tsc/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?tsc([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?tsc/rtk tsc/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+lint([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm lint/rtk lint/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?eslint([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?eslint/rtk lint/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?prettier([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?prettier/rtk prettier/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?playwright([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?playwright/rtk playwright/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+playwright([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm playwright/rtk playwright/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?prisma([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?prisma/rtk prisma/')" - -# --- Containers (added: docker compose, docker run/build/exec, kubectl describe/apply) --- -elif echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]'; then - if echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]+compose([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" - else - DOCKER_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ - -e 's/^docker[[:space:]]+//' \ - -e 's/(-H|--context|--config)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ - -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ - -e 's/^[[:space:]]+//') - case "$DOCKER_SUBCMD" in - ps|ps\ *|images|images\ *|logs|logs\ *|run|run\ *|build|build\ *|exec|exec\ *) - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" - ;; - esac - fi -elif echo "$MATCH_CMD" | grep -qE '^kubectl[[:space:]]'; then - KUBE_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ - -e 's/^kubectl[[:space:]]+//' \ - -e 's/(--context|--kubeconfig|--namespace|-n)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ - -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ - -e 's/^[[:space:]]+//') - case "$KUBE_SUBCMD" in - get|get\ *|logs|logs\ *|describe|describe\ *|apply|apply\ *) - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^kubectl /rtk kubectl /')" - ;; - esac - -# --- Network --- -elif echo "$MATCH_CMD" | grep -qE '^curl[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^curl /rtk curl /')" -elif echo "$MATCH_CMD" | grep -qE '^wget[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^wget /rtk wget /')" - -# --- pnpm package management --- -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+(list|ls|outdated)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm /rtk pnpm /')" - -# --- Python tooling --- -elif echo "$MATCH_CMD" | grep -qE '^pytest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pytest/rtk pytest/')" -elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+pytest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m pytest/rtk pytest/')" -elif echo "$MATCH_CMD" | grep -qE '^ruff[[:space:]]+(check|format)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ruff /rtk ruff /')" -elif echo "$MATCH_CMD" | grep -qE '^pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pip /rtk pip /')" -elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv pip /rtk pip /')" - -# --- Go tooling --- -elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go test/rtk go test/')" -elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+build([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go build/rtk go build/')" -elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+vet([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go vet/rtk go vet/')" -elif echo "$MATCH_CMD" | grep -qE '^golangci-lint([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^golangci-lint/rtk golangci-lint/')" -fi - -# If no rewrite needed, approve as-is -if [ -z "$REWRITTEN" ]; then - exit 0 -fi - -# Build the updated tool_input with all original fields preserved, only command changed -ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') -UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') - -# Output the rewrite instruction -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' +#!/bin/sh +# Legacy shim — actual hook logic is in the rtk binary. +# Direct usage: rtk hook claude (reads JSON from stdin) +exec rtk hook claude diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index ec6d82b1..afffcecb 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -1,3 +1,4 @@ +use crate::stream::{FilterMode, StdinMode, StreamFilter}; use crate::tracking; use crate::utils::truncate; use anyhow::{Context, Result}; @@ -82,8 +83,189 @@ fn run_build(args: &[String], verbose: u8) -> Result<()> { run_cargo_filtered("build", args, verbose, filter_cargo_build) } +/// Progressive streaming filter for `cargo test` output. +/// +/// Replicates `filter_cargo_test` logic line-by-line. Defers all output to +/// `flush()` so the full failure+summary picture is available before emitting. +pub struct CargoTestStreamFilter { + failures: Vec, + summary_lines: Vec, + in_failure_section: bool, + current_failure: Vec, +} + +impl CargoTestStreamFilter { + pub fn new() -> Self { + Self { + failures: Vec::new(), + summary_lines: Vec::new(), + in_failure_section: false, + current_failure: Vec::new(), + } + } +} + +impl Default for CargoTestStreamFilter { + fn default() -> Self { + Self::new() + } +} + +impl StreamFilter for CargoTestStreamFilter { + fn feed_line(&mut self, line: &str) -> Option { + // Skip compilation lines + if line.trim_start().starts_with("Compiling") + || line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + || line.trim_start().starts_with("Finished") + { + return None; + } + + // Skip "running N tests" and individual "test ... ok" lines + if line.starts_with("running ") || (line.starts_with("test ") && line.ends_with("... ok")) { + return None; + } + + // Detect failures section + if line == "failures:" { + self.in_failure_section = true; + return None; + } + + if self.in_failure_section { + if line.starts_with("test result:") { + self.in_failure_section = false; + self.summary_lines.push(line.to_string()); + } else if line.starts_with(" ") || line.starts_with("---- ") { + self.current_failure.push(line.to_string()); + } else if line.trim().is_empty() && !self.current_failure.is_empty() { + let block = self.current_failure.join("\n"); + self.failures.push(block); + self.current_failure.clear(); + } else if !line.trim().is_empty() { + self.current_failure.push(line.to_string()); + } + } + + // Capture test result summary outside failure section + if !self.in_failure_section && line.starts_with("test result:") { + self.summary_lines.push(line.to_string()); + } + + None + } + + fn flush(&mut self) -> String { + // Close any open failure block + if !self.current_failure.is_empty() { + let block = self.current_failure.join("\n"); + self.failures.push(block); + self.current_failure.clear(); + } + + build_cargo_test_summary(&self.failures, &self.summary_lines) + } +} + +/// Build the formatted cargo test output from accumulated failures + summaries. +/// +/// Shared by `filter_cargo_test` (buffered) and `CargoTestStreamFilter` +/// (streaming) to ensure identical output format. +fn build_cargo_test_summary(failures: &[String], summary_lines: &[String]) -> String { + let mut result = String::new(); + + if failures.is_empty() && !summary_lines.is_empty() { + // All passed - try to aggregate + let mut aggregated: Option = None; + let mut all_parsed = true; + + for line in summary_lines { + if let Some(parsed) = AggregatedTestResult::parse_line(line) { + if let Some(ref mut agg) = aggregated { + agg.merge(&parsed); + } else { + aggregated = Some(parsed); + } + } else { + all_parsed = false; + break; + } + } + + if all_parsed { + if let Some(agg) = aggregated { + if agg.suites > 0 { + return agg.format_compact(); + } + } + } + + // Fallback: use original behavior if regex failed + for line in summary_lines { + result.push_str(&format!("✓ {}\n", line)); + } + return result.trim().to_string(); + } + + if !failures.is_empty() { + result.push_str(&format!("FAILURES ({}):\n", failures.len())); + result.push_str("═══════════════════════════════════════\n"); + for (i, failure) in failures.iter().enumerate().take(10) { + result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); + } + if failures.len() > 10 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + } + result.push('\n'); + } + + for line in summary_lines { + result.push_str(&format!("{}\n", line)); + } + + result.trim().to_string() +} + fn run_test(args: &[String], verbose: u8) -> Result<()> { - run_cargo_filtered("test", args, verbose, filter_cargo_test) + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("cargo"); + cmd.arg("test"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo test {}", args.join(" ")); + } + + let filter = CargoTestStreamFilter::new(); + let result = crate::stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("Failed to run cargo test")?; + + if let Some(hint) = crate::tee::tee_and_hint(&result.raw, "cargo_test", result.exit_code) { + println!("{}\n{}", result.filtered, hint); + } else { + println!("{}", result.filtered); + } + + timer.track( + &format!("cargo test {}", args.join(" ")), + &format!("rtk cargo test {}", args.join(" ")), + &result.raw, + &result.filtered, + ); + + if result.exit_code != 0 { + std::process::exit(result.exit_code); + } + + Ok(()) } fn run_clippy(args: &[String], verbose: u8) -> Result<()> { @@ -709,7 +891,7 @@ impl AggregatedTestResult { } /// Filter cargo test output - show failures + summary only -fn filter_cargo_test(output: &str) -> String { +pub(crate) fn filter_cargo_test(output: &str) -> String { let mut failures: Vec = Vec::new(); let mut summary_lines: Vec = Vec::new(); let mut in_failure_section = false; @@ -1619,4 +1801,88 @@ error: test run failed result ); } + + // ── CargoTestStreamFilter tests ──────────────────────────────────────────── + + const CARGO_ALL_PASS: &str = r#" Compiling rtk v0.5.0 + Finished test [unoptimized + debuginfo] target(s) in 2.53s + Running target/debug/deps/rtk-abc123 + +running 15 tests +test utils::tests::test_truncate_short_string ... ok +test utils::tests::test_truncate_long_string ... ok + +test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s +"#; + + const CARGO_WITH_FAILURE: &str = r#"running 5 tests +test foo::test_a ... ok +test foo::test_b ... FAILED +test foo::test_c ... ok + +failures: + +---- foo::test_b stdout ---- +thread 'foo::test_b' panicked at 'assert_eq!(1, 2)' + +failures: + foo::test_b + +test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out +"#; + + #[test] + fn test_cargo_test_stream_filter_feed_and_flush_all_pass() { + let mut f = CargoTestStreamFilter::new(); + for line in CARGO_ALL_PASS.lines() { + assert_eq!( + f.feed_line(line), + None, + "streaming filter must defer output" + ); + } + let output = f.flush(); + assert!(output.contains("✓ cargo test"), "output={}", output); + assert!(output.contains("15 passed"), "output={}", output); + } + + #[test] + fn test_cargo_test_stream_filter_feed_and_flush_with_failure() { + let mut f = CargoTestStreamFilter::new(); + for line in CARGO_WITH_FAILURE.lines() { + f.feed_line(line); + } + let output = f.flush(); + assert!(output.contains("FAILURES"), "output={}", output); + assert!(output.contains("test_b"), "output={}", output); + } + + #[test] + fn test_cargo_test_stream_filter_matches_buffered_all_pass() { + let buffered = filter_cargo_test(CARGO_ALL_PASS); + let mut f = CargoTestStreamFilter::new(); + for line in CARGO_ALL_PASS.lines() { + f.feed_line(line); + } + let streamed = f.flush(); + assert_eq!(streamed.trim(), buffered.trim()); + } + + #[test] + fn test_cargo_test_stream_filter_matches_buffered_with_failures() { + let buffered = filter_cargo_test(CARGO_WITH_FAILURE); + let mut f = CargoTestStreamFilter::new(); + for line in CARGO_WITH_FAILURE.lines() { + f.feed_line(line); + } + let streamed = f.flush(); + assert_eq!(streamed.trim(), buffered.trim()); + } + + #[test] + fn test_cargo_test_stream_filter_default_equals_new() { + let mut f1 = CargoTestStreamFilter::new(); + let mut f2 = CargoTestStreamFilter::default(); + assert_eq!(f1.flush(), f2.flush()); + } } diff --git a/src/cmd/analysis.rs b/src/cmd/analysis.rs new file mode 100644 index 00000000..bd62841e --- /dev/null +++ b/src/cmd/analysis.rs @@ -0,0 +1,675 @@ +//! Analyzes tokens to decide: Native execution or Passthrough? + +use super::lexer::{strip_quotes, ParsedToken, TokenKind}; + +/// Represents a single command in a chain +#[derive(Debug, Clone, PartialEq)] +pub struct NativeCommand { + pub binary: String, + pub args: Vec, + pub operator: Option, // &&, ||, ;, or None for last command +} + +/// Try to strip a known "safe" redirect or pipe suffix from the end of a token list. +/// +/// Safe suffixes are shell constructs that can be applied to any command's output +/// without changing the command's semantics. By stripping them, the hook can route +/// the core command through an RTK filter and then re-attach the suffix in the shell. +/// +/// # Returns +/// `(core_tokens, suffix_string)` where: +/// - `core_tokens` is the token list with the suffix removed (unchanged if no match) +/// - `suffix_string` is the raw shell suffix to append to the rewritten command, or `""` +/// +/// # Recognized patterns (checked from longest to shortest) +/// - `2>&1` — Arg("2") + Redirect(">") + Shellism("&") + Arg("1") +/// - `| tee ` — Pipe + Arg("tee") + Arg(any) +/// - `| head ` — Pipe + Arg("head"/"tail") + Arg(any) +/// - `| cat` — Pipe + Arg("cat") +/// - `2>/dev/null` — Arg("2") + Redirect(">") + Arg("/dev/null") +/// - `> /dev/null` — Redirect(">") + Arg("/dev/null") +/// - `>> ` — Redirect(">>") + Arg(any) +pub fn split_safe_suffix(tokens: Vec) -> (Vec, String) { + let n = tokens.len(); + + // 4-token: 2>&1 + if n >= 5 { + // Need at least 1 core token + 4 suffix tokens + let t = &tokens[n - 4..]; + if matches!(t[0].kind, TokenKind::Arg) + && t[0].value == "2" + && matches!(t[1].kind, TokenKind::Redirect) + && t[1].value == ">" + && matches!(t[2].kind, TokenKind::Shellism) + && t[2].value == "&" + && matches!(t[3].kind, TokenKind::Arg) + && t[3].value == "1" + { + return (tokens[..n - 4].to_vec(), "2>&1".to_string()); + } + } + + // 3-token: | tee + if n >= 4 { + let t = &tokens[n - 3..]; + if matches!(t[0].kind, TokenKind::Pipe) + && matches!(t[1].kind, TokenKind::Arg) + && t[1].value == "tee" + && matches!(t[2].kind, TokenKind::Arg) + { + let suffix = format!("| tee {}", t[2].value); + return (tokens[..n - 3].to_vec(), suffix); + } + } + + // 3-token: | head or | tail + if n >= 4 { + let t = &tokens[n - 3..]; + if matches!(t[0].kind, TokenKind::Pipe) + && matches!(t[1].kind, TokenKind::Arg) + && matches!(t[1].value.as_str(), "head" | "tail") + && matches!(t[2].kind, TokenKind::Arg) + { + let suffix = format!("| {} {}", t[1].value, t[2].value); + return (tokens[..n - 3].to_vec(), suffix); + } + } + + // 3-token: 2>/dev/null + if n >= 4 { + let t = &tokens[n - 3..]; + if matches!(t[0].kind, TokenKind::Arg) + && t[0].value == "2" + && matches!(t[1].kind, TokenKind::Redirect) + && t[1].value == ">" + && matches!(t[2].kind, TokenKind::Arg) + && t[2].value == "/dev/null" + { + return (tokens[..n - 3].to_vec(), "2>/dev/null".to_string()); + } + } + + // 2-token: | cat + if n >= 3 { + let t = &tokens[n - 2..]; + if matches!(t[0].kind, TokenKind::Pipe) + && matches!(t[1].kind, TokenKind::Arg) + && t[1].value == "cat" + { + return (tokens[..n - 2].to_vec(), "| cat".to_string()); + } + } + + // 2-token: > /dev/null + if n >= 3 { + let t = &tokens[n - 2..]; + if matches!(t[0].kind, TokenKind::Redirect) + && t[0].value == ">" + && matches!(t[1].kind, TokenKind::Arg) + && t[1].value == "/dev/null" + { + return (tokens[..n - 2].to_vec(), "> /dev/null".to_string()); + } + } + + // 2-token: >> + if n >= 3 { + let t = &tokens[n - 2..]; + if matches!(t[0].kind, TokenKind::Redirect) + && t[0].value == ">>" + && matches!(t[1].kind, TokenKind::Arg) + { + let suffix = format!(">> {}", t[1].value); + return (tokens[..n - 2].to_vec(), suffix); + } + } + + // No safe suffix found — return unchanged + (tokens, String::new()) +} + +/// Check if command needs real shell (has shellisms, pipes, redirects) +pub fn needs_shell(tokens: &[ParsedToken]) -> bool { + tokens.iter().any(|t| { + matches!( + t.kind, + TokenKind::Shellism | TokenKind::Pipe | TokenKind::Redirect + ) + }) +} + +/// Parse tokens into native command chain +/// Returns error if syntax is invalid (e.g., operator with no preceding command) +pub fn parse_chain(tokens: Vec) -> Result, String> { + let mut commands = Vec::new(); + let mut current_args = Vec::new(); + + for token in tokens { + match token.kind { + TokenKind::Arg => { + // Strip quotes from the argument + current_args.push(strip_quotes(&token.value)); + } + TokenKind::Operator => { + if current_args.is_empty() { + return Err(format!( + "Syntax error: operator {} with no command", + token.value + )); + } + // First arg is the binary, rest are args + let binary = current_args.remove(0); + commands.push(NativeCommand { + binary, + args: current_args.clone(), + operator: Some(token.value.clone()), + }); + current_args.clear(); + } + TokenKind::Pipe | TokenKind::Redirect | TokenKind::Shellism => { + // Should not reach here if needs_shell() was checked first + // But handle gracefully + return Err(format!( + "Unexpected {:?} in native mode - use passthrough", + token.kind + )); + } + } + } + + // Handle last command (no trailing operator) + if !current_args.is_empty() { + let binary = current_args.remove(0); + commands.push(NativeCommand { + binary, + args: current_args, + operator: None, + }); + } + + Ok(commands) +} + +/// Should the next command run based on operator and last result? +pub fn should_run(operator: Option<&str>, last_success: bool) -> bool { + match operator { + Some("&&") => last_success, + Some("||") => !last_success, + Some(";") | None => true, + _ => true, // Unknown operator, just run + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::lexer::tokenize; + + // === SPLIT_SAFE_SUFFIX TESTS === + + #[test] + fn test_split_suffix_2_redirect() { + let tokens = tokenize("cargo test 2>&1"); + let (core, suffix) = split_safe_suffix(tokens); + assert_eq!(suffix, "2>&1"); + assert!(!needs_shell(&core), "core must not need shell"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "cargo"); + assert_eq!(cmds[0].args, vec!["test"]); + } + + #[test] + fn test_split_suffix_dev_null() { + let tokens = tokenize("cargo test 2>/dev/null"); + let (core, suffix) = split_safe_suffix(tokens); + assert_eq!(suffix, "2>/dev/null"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "cargo"); + } + + #[test] + fn test_split_suffix_stdout_dev_null() { + let tokens = tokenize("cargo test > /dev/null"); + let (core, suffix) = split_safe_suffix(tokens); + assert_eq!(suffix, "> /dev/null"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "cargo"); + } + + #[test] + fn test_split_suffix_pipe_tee() { + let tokens = tokenize("cargo test | tee /tmp/log.txt"); + let (core, suffix) = split_safe_suffix(tokens); + assert!(suffix.starts_with("| tee"), "suffix: {suffix}"); + assert!(suffix.contains("/tmp/log.txt"), "suffix: {suffix}"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "cargo"); + } + + #[test] + fn test_split_suffix_pipe_head() { + let tokens = tokenize("git log | head -20"); + let (core, suffix) = split_safe_suffix(tokens); + assert!(suffix.starts_with("| head"), "suffix: {suffix}"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "git"); + } + + #[test] + fn test_split_suffix_pipe_tail() { + let tokens = tokenize("git log | tail -10"); + let (core, suffix) = split_safe_suffix(tokens); + assert!(suffix.starts_with("| tail"), "suffix: {suffix}"); + } + + #[test] + fn test_split_suffix_pipe_cat() { + let tokens = tokenize("ls --color | cat"); + let (core, suffix) = split_safe_suffix(tokens); + assert_eq!(suffix, "| cat"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "ls"); + } + + #[test] + fn test_split_suffix_append_redirect() { + let tokens = tokenize("cargo build >> /tmp/build.log"); + let (core, suffix) = split_safe_suffix(tokens); + assert!(suffix.starts_with(">>"), "suffix: {suffix}"); + let cmds = parse_chain(core).unwrap(); + assert_eq!(cmds[0].binary, "cargo"); + } + + #[test] + fn test_split_suffix_none() { + // No suffix — returns all tokens unchanged, empty suffix + let tokens = tokenize("cargo test"); + let n = tokens.len(); + let (core, suffix) = split_safe_suffix(tokens); + assert!(suffix.is_empty(), "no suffix expected, got: {suffix}"); + assert_eq!(core.len(), n); + } + + #[test] + fn test_split_suffix_glob_core_stays_shellism() { + // "ls *.rs 2>&1" — suffix strips fine but core has glob (needs shell) + let tokens = tokenize("ls *.rs 2>&1"); + let (core, suffix) = split_safe_suffix(tokens); + assert_eq!(suffix, "2>&1", "suffix must be stripped"); + assert!(needs_shell(&core), "core still needs shell due to glob"); + } + + #[test] + fn test_split_suffix_requires_core_token() { + // "2>&1" alone has no core — should not strip (or return empty core) + // The function requires at least 1 core token before the suffix + let tokens = tokenize("2>&1"); + let (core, suffix) = split_safe_suffix(tokens); + // No valid split (core would be empty) + assert!( + suffix.is_empty() || core.is_empty(), + "bare suffix with no core should not produce a valid split" + ); + } + + // === NEEDS_SHELL TESTS === + + #[test] + fn test_needs_shell_simple() { + let tokens = tokenize("git status"); + assert!(!needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_glob() { + let tokens = tokenize("ls *.rs"); + assert!(needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_pipe() { + let tokens = tokenize("cat file | grep x"); + assert!(needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_redirect() { + let tokens = tokenize("cmd > file"); + assert!(needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_chain() { + let tokens = tokenize("cd dir && git status"); + // && is an Operator, not a Shellism - should NOT need shell + assert!(!needs_shell(&tokens)); + } + + // === PARSE_CHAIN TESTS === + + #[test] + fn test_parse_simple_command() { + let tokens = tokenize("git status"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].binary, "git"); + assert_eq!(cmds[0].args, vec!["status"]); + assert_eq!(cmds[0].operator, None); + } + + #[test] + fn test_parse_command_with_multiple_args() { + let tokens = tokenize("git commit -m message"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].binary, "git"); + assert_eq!(cmds[0].args, vec!["commit", "-m", "message"]); + } + + #[test] + fn test_parse_chained_and() { + let tokens = tokenize("cd dir && git status"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].binary, "cd"); + assert_eq!(cmds[0].args, vec!["dir"]); + assert_eq!(cmds[0].operator, Some("&&".to_string())); + assert_eq!(cmds[1].binary, "git"); + assert_eq!(cmds[1].args, vec!["status"]); + assert_eq!(cmds[1].operator, None); + } + + #[test] + fn test_parse_chained_or() { + let tokens = tokenize("cmd1 || cmd2"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].operator, Some("||".to_string())); + } + + #[test] + fn test_parse_chained_semicolon() { + let tokens = tokenize("cmd1 ; cmd2 ; cmd3"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 3); + assert_eq!(cmds[0].operator, Some(";".to_string())); + assert_eq!(cmds[1].operator, Some(";".to_string())); + assert_eq!(cmds[2].operator, None); + } + + #[test] + fn test_parse_triple_chain() { + let tokens = tokenize("a && b && c"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 3); + } + + #[test] + fn test_parse_operator_at_start() { + let tokens = tokenize("&& cmd"); + let result = parse_chain(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_parse_operator_at_end() { + let tokens = tokenize("cmd &&"); + let cmds = parse_chain(tokens).unwrap(); + // cmd is parsed, && triggers flush but no second command + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].operator, Some("&&".to_string())); + } + + #[test] + fn test_parse_quoted_arg() { + let tokens = tokenize("git commit -m \"Fix && Bug\""); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 1); + // The && inside quotes should be in the arg, not an operator + // args are: commit, -m, "Fix && Bug" + assert_eq!(cmds[0].args.len(), 3); + assert_eq!(cmds[0].args[2], "Fix && Bug"); + } + + #[test] + fn test_parse_empty() { + let tokens = tokenize(""); + let cmds = parse_chain(tokens).unwrap(); + assert!(cmds.is_empty()); + } + + // === PIPE/FIND/GREP ROUTING TESTS === + // These verify that piped invocations of find/grep/rg correctly trigger + // needs_shell(), so the hook routes them to /bin/sh instead of native mode. + + #[test] + fn test_needs_shell_find_piped_to_grep() { + // `find . -name "*.rs" | grep pattern` must go through shell (has Pipe) + let tokens = tokenize("find . -name \"*.rs\" | grep pattern"); + assert!( + needs_shell(&tokens), + "find | grep must trigger shell (Pipe token present)" + ); + } + + #[test] + fn test_needs_shell_rg_piped_to_head() { + let tokens = tokenize("rg pattern src/ | head -20"); + assert!(needs_shell(&tokens), "rg | head must trigger shell"); + } + + #[test] + fn test_needs_shell_grep_with_redirect() { + let tokens = tokenize("grep -r pattern . > results.txt"); + assert!(needs_shell(&tokens), "grep > file must trigger shell"); + } + + #[test] + fn test_needs_shell_find_with_glob_arg() { + // find . -name *.rs — glob in arg triggers Shellism + let tokens = tokenize("find . -name *.rs"); + assert!(needs_shell(&tokens), "unquoted glob arg must trigger shell"); + } + + #[test] + fn test_needs_shell_quoted_pipe_in_grep_arg_no_shell() { + // grep "a|b" file — pipe inside quotes is an Arg, not a Pipe token + let tokens = tokenize("grep \"a|b\" src/"); + assert!( + !needs_shell(&tokens), + "pipe inside quoted arg must NOT trigger shell" + ); + } + + #[test] + fn test_parse_chain_find_with_quoted_name() { + // `find . -name "*.rs"` with quoted glob → no shellism; parses natively + let tokens = tokenize("find . -name \"*.rs\""); + assert!(!needs_shell(&tokens), "quoted glob should not need shell"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds[0].binary, "find"); + assert!(cmds[0].args.contains(&"-name".to_string())); + // strip_quotes removes the outer quotes + assert!( + cmds[0].args.iter().any(|a| a == "*.rs"), + "quoted glob stripped to bare glob in args: {:?}", + cmds[0].args + ); + } + + #[test] + fn test_parse_chain_grep_native_no_pipe() { + // `grep pattern file` — no pipe/redirect, parses natively + let tokens = tokenize("grep pattern file.rs"); + assert!(!needs_shell(&tokens)); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds[0].binary, "grep"); + assert_eq!(cmds[0].args, vec!["pattern", "file.rs"]); + } + + // === SHOULD_RUN TESTS === + + #[test] + fn test_should_run_and_success() { + assert!(should_run(Some("&&"), true)); + } + + #[test] + fn test_should_run_and_failure() { + assert!(!should_run(Some("&&"), false)); + } + + #[test] + fn test_should_run_or_success() { + assert!(!should_run(Some("||"), true)); + } + + #[test] + fn test_should_run_or_failure() { + assert!(should_run(Some("||"), false)); + } + + #[test] + fn test_should_run_semicolon() { + assert!(should_run(Some(";"), true)); + assert!(should_run(Some(";"), false)); + } + + #[test] + fn test_should_run_none() { + assert!(should_run(None, true)); + assert!(should_run(None, false)); + } + + // === SUFFIX REDIRECT TESTS — common patterns Claude Code appends === + // These lock in behaviour that must never silently regress. + + // --- stdout / stderr discard --- + #[test] + fn test_needs_shell_redirect_to_dev_null() { + let tokens = tokenize("cmd > /dev/null"); + assert!(needs_shell(&tokens), "> /dev/null must trigger shell"); + } + + #[test] + fn test_needs_shell_stderr_to_dev_null() { + // "2>/dev/null" produces a "2>" Redirect token → needs_shell + let tokens = tokenize("cmd 2>/dev/null"); + assert!(needs_shell(&tokens), "2>/dev/null must trigger shell"); + } + + #[test] + fn test_needs_shell_stderr_to_dev_null_spaced() { + let tokens = tokenize("cmd 2> /dev/null"); + assert!(needs_shell(&tokens), "2> /dev/null must trigger shell"); + } + + // --- compound FD redirects (2>&1, 1>&2) --- + // The `&` in "2>&1" tokenises as Shellism; Shellism triggers needs_shell. + #[test] + fn test_needs_shell_stderr_to_stdout() { + let tokens = tokenize("cmd 2>&1"); + assert!( + needs_shell(&tokens), + "2>&1 must trigger shell (& is Shellism)" + ); + } + + #[test] + fn test_needs_shell_stdout_to_stderr() { + let tokens = tokenize("cmd 1>&2"); + assert!( + needs_shell(&tokens), + "1>&2 must trigger shell (& is Shellism)" + ); + } + + #[test] + fn test_needs_shell_combined_redirect_chain() { + // Classic "silence everything": >/dev/null 2>&1 + let tokens = tokenize("cmd > /dev/null 2>&1"); + assert!(needs_shell(&tokens), ">/dev/null 2>&1 must trigger shell"); + } + + #[test] + fn test_needs_shell_redirect_append() { + let tokens = tokenize("cmd >> /tmp/output.txt"); + assert!(needs_shell(&tokens), ">> must trigger shell"); + } + + #[test] + fn test_needs_shell_stderr_redirect_to_file() { + let tokens = tokenize("cmd 2> /tmp/err.log"); + assert!(needs_shell(&tokens), "2> file must trigger shell"); + } + + // --- pipe suffixes --- + #[test] + fn test_needs_shell_pipe_to_tail() { + let tokens = tokenize("git log | tail -20"); + assert!(needs_shell(&tokens), "| tail must trigger shell"); + } + + #[test] + fn test_needs_shell_pipe_to_cat() { + // Piping to `cat` forces non-TTY output; must go through shell + let tokens = tokenize("ls --color | cat"); + assert!(needs_shell(&tokens), "| cat must trigger shell"); + } + + #[test] + fn test_needs_shell_pipe_to_tee() { + let tokens = tokenize("cargo build 2>&1 | tee /tmp/build.log"); + assert!(needs_shell(&tokens), "| tee must trigger shell"); + } + + #[test] + fn test_needs_shell_pipe_to_wc() { + let tokens = tokenize("find . -name '*.rs' | wc -l"); + assert!(needs_shell(&tokens), "| wc must trigger shell"); + } + + // --- compound chains that must NOT trigger shell alone --- + #[test] + fn test_operator_and_does_not_trigger_shell() { + // && is an Operator, not Shellism/Pipe/Redirect; handled by parse_chain + let tokens = tokenize("cargo fmt && cargo clippy"); + assert!( + !needs_shell(&tokens), + "&& alone must NOT trigger needs_shell" + ); + } + + #[test] + fn test_operator_or_does_not_trigger_shell() { + let tokens = tokenize("cargo test || true"); + assert!( + !needs_shell(&tokens), + "|| alone must NOT trigger needs_shell" + ); + } + + #[test] + fn test_operator_semicolon_does_not_trigger_shell() { + let tokens = tokenize("true ; false"); + assert!( + !needs_shell(&tokens), + "; alone must NOT trigger needs_shell" + ); + } + + // --- RTK-rewrite end-to-end: suffix redirect preserves original command --- + #[test] + fn test_redirect_suffix_is_passed_through_verbatim() { + // When needs_shell, the hook wraps in "rtk run -c ''" + // Verify routing logic sends the command to passthrough, not native routing + use crate::cmd::analysis::needs_shell; + use crate::cmd::lexer::tokenize; + let raw = "cargo test 2>&1 | tee /tmp/test.log"; + let tokens = tokenize(raw); + assert!( + needs_shell(&tokens), + "complex redirect+pipe must trigger shell passthrough" + ); + } +} diff --git a/src/cmd/builtins.rs b/src/cmd/builtins.rs new file mode 100644 index 00000000..61fcbf9e --- /dev/null +++ b/src/cmd/builtins.rs @@ -0,0 +1,247 @@ +//! Built-in commands that RTK handles natively within a single `rtk run -c` invocation. +//! Note: state does NOT persist across separate Claude Code hook calls (each is a new process). + +use super::predicates::{expand_tilde, get_home}; +use anyhow::{Context, Result}; + +/// Change directory within the current `rtk run -c` invocation. +/// Does NOT persist across separate hook invocations. +pub fn builtin_cd(args: &[String]) -> Result { + let target = args + .first() + .map(|s| expand_tilde(s)) + .unwrap_or_else(get_home); + + std::env::set_current_dir(&target) + .with_context(|| format!("cd: {}: No such file or directory", target))?; + + Ok(true) +} + +/// Export environment variable +pub fn builtin_export(args: &[String]) -> Result { + for arg in args { + if let Some((key, value)) = arg.split_once('=') { + // Handle quoted values: export FOO="bar baz" + let clean_value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + std::env::set_var(key, clean_value); + } + } + Ok(true) +} + +/// Check if a binary is a builtin +pub fn is_builtin(binary: &str) -> bool { + matches!( + binary, + "cd" | "export" | "pwd" | "echo" | "true" | "false" | ":" + ) +} + +/// Execute a builtin command +pub fn execute(binary: &str, args: &[String]) -> Result { + match binary { + "cd" => builtin_cd(args), + "export" => builtin_export(args), + "pwd" => { + println!("{}", std::env::current_dir()?.display()); + Ok(true) + } + "echo" => { + let (print_args, no_newline) = if args.first().map(|s| s.as_str()) == Some("-n") { + (&args[1..], true) + } else { + (args, false) + }; + print!("{}", print_args.join(" ")); + if !no_newline { + println!(); + } + Ok(true) + } + "true" | ":" => Ok(true), + "false" => Ok(false), + _ => anyhow::bail!("Unknown builtin: {}", binary), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + // === CD TESTS === + // Consolidated into one test: cwd is process-global, so parallel tests race. + + #[test] + fn test_cd_all_cases() { + let original = env::current_dir().unwrap(); + let home = get_home(); + + // 1. cd to existing dir + let result = builtin_cd(&["/tmp".to_string()]).unwrap(); + assert!(result); + let new_dir = env::current_dir().unwrap(); + // On macOS /tmp symlinks to /private/tmp — canonicalize both sides + let canon_tmp = std::fs::canonicalize("/tmp").unwrap(); + let canon_new = std::fs::canonicalize(&new_dir).unwrap(); + assert_eq!(canon_new, canon_tmp, "cd /tmp should land in /tmp"); + + // 2. cd to nonexistent dir + let result = builtin_cd(&["/nonexistent/path/xyz".to_string()]); + assert!(result.is_err()); + // cwd unchanged after failed cd + assert_eq!( + std::fs::canonicalize(env::current_dir().unwrap()).unwrap(), + canon_tmp + ); + + // 3. cd with no args → home + let result = builtin_cd(&[]).unwrap(); + assert!(result); + let cwd = env::current_dir().unwrap(); + let canon_home = std::fs::canonicalize(&home).unwrap(); + let canon_cwd = std::fs::canonicalize(&cwd).unwrap(); + assert_eq!(canon_cwd, canon_home, "cd with no args should go home"); + + // 4. cd ~ → home + let _ = env::set_current_dir("/tmp"); + let result = builtin_cd(&["~".to_string()]).unwrap(); + assert!(result); + let cwd = std::fs::canonicalize(env::current_dir().unwrap()).unwrap(); + assert_eq!(cwd, canon_home, "cd ~ should go home"); + + // 5. cd ~/nonexistent-subpath — may fail, just verify no panic + let _ = builtin_cd(&["~/nonexistent_rtk_test_subpath_xyz".to_string()]); + + // Restore original cwd + let _ = env::set_current_dir(&original); + } + + // === EXPORT TESTS === + + #[test] + fn test_export_simple() { + builtin_export(&["RTK_TEST_SIMPLE=value".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_SIMPLE").unwrap(), "value"); + env::remove_var("RTK_TEST_SIMPLE"); + } + + #[test] + fn test_export_with_equals_in_value() { + builtin_export(&["RTK_TEST_EQUALS=key=value".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_EQUALS").unwrap(), "key=value"); + env::remove_var("RTK_TEST_EQUALS"); + } + + #[test] + fn test_export_quoted_value() { + builtin_export(&["RTK_TEST_QUOTED=\"hello world\"".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_QUOTED").unwrap(), "hello world"); + env::remove_var("RTK_TEST_QUOTED"); + } + + #[test] + fn test_export_multiple() { + builtin_export(&["RTK_TEST_A=1".to_string(), "RTK_TEST_B=2".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_A").unwrap(), "1"); + assert_eq!(env::var("RTK_TEST_B").unwrap(), "2"); + env::remove_var("RTK_TEST_A"); + env::remove_var("RTK_TEST_B"); + } + + #[test] + fn test_export_no_equals() { + // Should be silently ignored (like bash) + let result = builtin_export(&["NO_EQUALS_HERE".to_string()]).unwrap(); + assert!(result); + } + + // === IS_BUILTIN TESTS === + + #[test] + fn test_is_builtin_cd() { + assert!(is_builtin("cd")); + } + + #[test] + fn test_is_builtin_export() { + assert!(is_builtin("export")); + } + + #[test] + fn test_is_builtin_pwd() { + assert!(is_builtin("pwd")); + } + + #[test] + fn test_is_builtin_echo() { + assert!(is_builtin("echo")); + } + + #[test] + fn test_is_builtin_true() { + assert!(is_builtin("true")); + } + + #[test] + fn test_is_builtin_false() { + assert!(is_builtin("false")); + } + + #[test] + fn test_is_builtin_external() { + assert!(!is_builtin("git")); + assert!(!is_builtin("ls")); + assert!(!is_builtin("cargo")); + } + + // === EXECUTE TESTS === + + #[test] + fn test_execute_pwd() { + let result = execute("pwd", &[]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_echo() { + let result = execute("echo", &["hello".to_string(), "world".to_string()]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_true() { + let result = execute("true", &[]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_false() { + let result = execute("false", &[]).unwrap(); + assert!(!result); + } + + #[test] + fn test_execute_unknown_builtin() { + let result = execute("notabuiltin", &[]); + assert!(result.is_err()); + } + + #[test] + fn test_execute_echo_n_flag() { + // echo -n should succeed (prints without newline) + let result = execute("echo", &["-n".to_string(), "hello".to_string()]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_echo_empty_args() { + let result = execute("echo", &[]).unwrap(); + assert!(result); + } +} diff --git a/src/cmd/claude_hook.rs b/src/cmd/claude_hook.rs new file mode 100644 index 00000000..60a17a9f --- /dev/null +++ b/src/cmd/claude_hook.rs @@ -0,0 +1,506 @@ +//! Claude Code PreToolUse hook protocol handler. +//! +//! Reads JSON from stdin, applies safety checks and rewrites, +//! outputs JSON to stdout. +//! +//! Protocol: https://docs.anthropic.com/en/docs/claude-code/hooks +//! +//! ## Exit Code Behavior +//! +//! - Exit 0 = success (allow/rewrite) — tool proceeds +//! - Exit 2 = blocking error (deny) — tool rejected +//! +//! ## Claude Code Stderr Rule (CRITICAL) +//! +//! **Source:** https://docs.anthropic.com/en/docs/claude-code/hooks +//! +//! ```text +//! CRITICAL: ANY stderr output at exit 0 = hook error = fail-open +//! ``` +//! +//! **Implication:** +//! - Exit 0 + ANY stderr → Claude Code treats hook as FAILED → tool executes anyway (fail-open) +//! - Exit 2 + stderr → Claude Code treats stderr as the block reason → tool blocked, AI sees reason +//! +//! **This module's stderr usage:** +//! - ✅ Exit 0 paths (NoOpinion, Allow): **NEVER write to stderr** +//! - ✅ Exit 2 path (Deny): **stderr ONLY** for bug #4669 workaround (see below) +//! +//! ## Bug #4669 Workaround (Dual-Path Deny) +//! +//! **Issue:** https://github.com/anthropics/claude-code/issues/4669 +//! **Versions:** v1.0.62+ through current (not fixed) +//! **Problem:** `permissionDecision: "deny"` at exit 0 is IGNORED — tool executes anyway +//! +//! **Workaround:** +//! ```text +//! stdout: JSON with permissionDecision "deny" (documented main path, but broken) +//! stderr: plain text reason (fallback path that actually works) +//! exit code: 2 (triggers Claude Code to read stderr as error) +//! ``` +//! +//! This ensures deny works regardless of which path Claude Code processes. +//! +//! ## I/O Enforcement (Module-Specific) +//! +//! **This restriction applies ONLY to claude_hook.rs and gemini_hook.rs.** +//! All other RTK modules (main.rs, git.rs, etc.) use `println!`/`eprintln!` normally. +//! +//! **Why restricted here:** +//! - Hook protocol requires JSON-only stdout +//! - Claude Code's "ANY stderr = hook error" rule (see above) +//! - Accidental prints corrupt the JSON protocol +//! +//! **Enforcement mechanism:** +//! - `#![deny(clippy::print_stdout, clippy::print_stderr)]` at module level (line 52) +//! - `run_inner()` returns `HookResponse` enum — pure logic, no I/O +//! - `run()` is the ONLY function that writes output — single I/O point +//! - Uses `write!`/`writeln!` which are NOT caught by the clippy lint +//! +//! **Pathway:** main.rs → Commands::Hook → claude_hook::run() [DENY ENFORCED HERE] +//! +//! Fail-open: Any parse error or unexpected input → exit 0, no output. + +// Compile-time I/O enforcement for THIS MODULE ONLY. +// Other RTK modules (main.rs, git.rs, etc.) use println!/eprintln! normally. +// +// Why restrict here: +// - Claude Code hook protocol requires JSON-only stdout +// - Claude Code rule: "ANY stderr at exit 0 = hook error = fail-open" +// (Source: https://docs.anthropic.com/en/docs/claude-code/hooks) +// - Accidental prints would corrupt the JSON response +// +// Mechanism: +// - Denies println!/eprintln! at compile-time +// - Allows write!/writeln! (used only in run() for controlled output) +// - run_inner() returns HookResponse (no I/O) +// - run() is the single I/O point +#![deny(clippy::print_stdout, clippy::print_stderr)] + +use super::hook::{ + check_for_hook, is_hook_disabled, should_passthrough, update_command_in_tool_input, + HookResponse, HookResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{self, Read, Write}; + +// --- Wire format structs (field names must match Claude Code spec exactly) --- + +#[derive(Deserialize)] +pub(crate) struct ClaudePayload { + tool_input: Option, + // Claude Code also sends: tool_name, session_id, session_cwd, + // transcript_path — serde silently ignores unknown fields. + // The settings.json matcher already filters to Bash-only events. +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClaudeResponse { + hook_specific_output: HookOutput, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct HookOutput { + hook_event_name: &'static str, + permission_decision: &'static str, + permission_decision_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + updated_input: Option, +} + +// --- Guard logic (extracted for testability) --- + +/// Extract the command string from a parsed payload. +/// Returns None if payload has no tool_input or no command field. +pub(crate) fn extract_command(payload: &ClaudePayload) -> Option<&str> { + payload + .tool_input + .as_ref()? + .get("command")? + .as_str() + .filter(|s| !s.is_empty()) +} + +// Guard functions `is_hook_disabled()` and `should_passthrough()` are shared +// with gemini_hook.rs via hook.rs to avoid duplication (DRY). + +/// Build a ClaudeResponse for an allowed/rewritten command. +pub(crate) fn allow_response(reason: String, updated_input: Option) -> ClaudeResponse { + ClaudeResponse { + hook_specific_output: HookOutput { + hook_event_name: "PreToolUse", + permission_decision: "allow", + permission_decision_reason: reason, + updated_input, + }, + } +} + +/// Build a ClaudeResponse for a blocked command. +pub(crate) fn deny_response(reason: String) -> ClaudeResponse { + ClaudeResponse { + hook_specific_output: HookOutput { + hook_event_name: "PreToolUse", + permission_decision: "deny", + permission_decision_reason: reason, + updated_input: None, + }, + } +} + +// --- Entry point --- + +/// Run the Claude Code hook handler. +/// +/// This is the ONLY function that performs I/O (stdout/stderr). +/// `run_inner()` returns a `HookResponse` enum — pure logic, no I/O. +/// Combined with `#![deny(clippy::print_stdout, clippy::print_stderr)]`, +/// this ensures no stray output corrupts the JSON hook protocol. +/// +/// Fail-open design: malformed input → exit 0, no output. +/// Claude Code interprets this as "no opinion" and proceeds normally. +pub fn run() -> anyhow::Result<()> { + // Fail-open: wrap entire handler so ANY error → exit 0 (no opinion). + let response = match run_inner() { + Ok(r) => r, + Err(_) => HookResponse::NoOpinion, // Fail-open: swallow errors + }; + + // ┌────────────────────────────────────────────────────────────────┐ + // │ SINGLE I/O POINT - All stdout/stderr output happens here only │ + // │ │ + // │ Why: Claude Code rule "ANY stderr at exit 0 = hook error" │ + // │ (Source: hooks_api_reference.md:720-728) │ + // │ │ + // │ Enforcement: #![deny(...)] at line 52 prevents println!/eprintln! │ + // │ write!/writeln! are not caught by lint (allowed) │ + // └────────────────────────────────────────────────────────────────┘ + match response { + HookResponse::NoOpinion => { + // Exit 0, NO stdout, NO stderr + // Claude Code sees no output → proceeds with original command + } + HookResponse::Allow(json) => { + // Exit 0, JSON to stdout, NO stderr + // CRITICAL: No stderr at exit 0 (would cause fail-open) + writeln!(io::stdout(), "{json}")?; + } + HookResponse::Deny(json, reason) => { + // Exit 2, JSON to stdout, reason to stderr + // This is the ONLY path that writes to stderr (valid at exit 2 only) + // + // Dual-path deny for bug #4669 workaround: + // - stdout: JSON with permissionDecision "deny" (documented path, but ignored) + // - stderr: plain text reason (actual blocking mechanism via exit 2) + // - exit 2: Triggers Claude Code to read stderr and block tool + writeln!(io::stdout(), "{json}")?; + writeln!(io::stderr(), "{reason}")?; + std::process::exit(2); + } + } + Ok(()) +} + +/// Inner handler: pure decision logic, no I/O. +/// Returns `HookResponse` for `run()` to output. +fn run_inner() -> anyhow::Result { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + + let payload: ClaudePayload = match serde_json::from_str(&buffer) { + Ok(p) => p, + Err(_) => return Ok(HookResponse::NoOpinion), + }; + + let cmd = match extract_command(&payload) { + Some(c) => c, + None => return Ok(HookResponse::NoOpinion), + }; + + if is_hook_disabled() || should_passthrough(cmd) { + return Ok(HookResponse::NoOpinion); + } + + let result = check_for_hook(cmd, "claude"); + + match result { + HookResult::Rewrite(new_cmd) => { + // Preserve all original tool_input fields, only replace "command" + // Shared helper (DRY with gemini_hook.rs via hook.rs) + let updated = update_command_in_tool_input(payload.tool_input, new_cmd); + + let response = allow_response("RTK safety rewrite applied".into(), Some(updated)); + let json = serde_json::to_string(&response)?; + Ok(HookResponse::Allow(json)) + } + HookResult::Blocked(msg) => { + let response = deny_response(msg.clone()); + let json = serde_json::to_string(&response)?; + Ok(HookResponse::Deny(json, msg)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // CLAUDE CODE WIRE FORMAT CONFORMANCE + // https://docs.anthropic.com/en/docs/claude-code/hooks + // + // These tests verify exact JSON field names per the Claude Code spec. + // A wrong field name means Claude Code silently ignores the response. + // ========================================================================= + + // --- Output: field name conformance --- + + #[test] + fn test_output_uses_hook_specific_output() { + // Claude expects "hookSpecificOutput" (camelCase), NOT "hook_specific_output" + let response = allow_response("test".into(), None); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed.get("hookSpecificOutput").is_some(), + "must have 'hookSpecificOutput' field" + ); + assert!( + parsed.get("hook_specific_output").is_none(), + "must NOT have snake_case field" + ); + } + + #[test] + fn test_output_uses_permission_decision() { + // Claude expects "permissionDecision", NOT "decision" + let response = allow_response("test".into(), None); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + let output = &parsed["hookSpecificOutput"]; + + assert!( + output.get("permissionDecision").is_some(), + "must have 'permissionDecision' field" + ); + assert!( + output.get("decision").is_none(), + "must NOT have Gemini-style 'decision' field" + ); + } + + #[test] + fn test_output_uses_permission_decision_reason() { + let response = deny_response("blocked".into()); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + let output = &parsed["hookSpecificOutput"]; + + assert!( + output.get("permissionDecisionReason").is_some(), + "must have 'permissionDecisionReason'" + ); + } + + #[test] + fn test_output_uses_hook_event_name() { + let response = allow_response("test".into(), None); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + } + + #[test] + fn test_output_uses_updated_input_for_rewrite() { + let input = serde_json::json!({"command": "rtk run -c 'git status'"}); + let response = allow_response("rewrite".into(), Some(input)); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed["hookSpecificOutput"].get("updatedInput").is_some(), + "must have 'updatedInput' for rewrites" + ); + } + + #[test] + fn test_allow_omits_updated_input_when_none() { + let response = allow_response("passthrough".into(), None); + let json = serde_json::to_string(&response).unwrap(); + + assert!( + !json.contains("updatedInput"), + "updatedInput must be omitted when None" + ); + } + + #[test] + fn test_rewrite_preserves_other_tool_input_fields() { + let original = serde_json::json!({ + "command": "git status", + "timeout": 30, + "description": "check repo" + }); + + let mut updated = original.clone(); + if let Some(obj) = updated.as_object_mut() { + obj.insert( + "command".into(), + Value::String("rtk run -c 'git status'".into()), + ); + } + + assert_eq!(updated["timeout"], 30); + assert_eq!(updated["description"], "check repo"); + assert_eq!(updated["command"], "rtk run -c 'git status'"); + } + + #[test] + fn test_output_decision_values() { + let allow = allow_response("test".into(), None); + let deny = deny_response("blocked".into()); + + let allow_json: Value = + serde_json::from_str(&serde_json::to_string(&allow).unwrap()).unwrap(); + let deny_json: Value = + serde_json::from_str(&serde_json::to_string(&deny).unwrap()).unwrap(); + + assert_eq!( + allow_json["hookSpecificOutput"]["permissionDecision"], + "allow" + ); + assert_eq!( + deny_json["hookSpecificOutput"]["permissionDecision"], + "deny" + ); + } + + // --- Input: payload parsing --- + + #[test] + fn test_input_extra_fields_ignored() { + // Claude sends session_id, tool_name, transcript_path, etc. + let json = r#"{"tool_input": {"command": "ls"}, "tool_name": "Bash", "session_id": "abc-123", "session_cwd": "/tmp", "transcript_path": "/path/to/transcript.jsonl"}"#; + let payload: ClaudePayload = serde_json::from_str(json).unwrap(); + assert_eq!(extract_command(&payload), Some("ls")); + } + + #[test] + fn test_input_tool_input_is_object() { + let json = r#"{"tool_input": {"command": "git status", "timeout": 30}}"#; + let payload: ClaudePayload = serde_json::from_str(json).unwrap(); + let input = payload.tool_input.unwrap(); + assert_eq!(input["command"].as_str().unwrap(), "git status"); + assert_eq!(input["timeout"].as_i64().unwrap(), 30); + } + + // --- Guard function tests --- + + #[test] + fn test_extract_command_basic() { + let payload: ClaudePayload = + serde_json::from_str(r#"{"tool_input": {"command": "git status"}}"#).unwrap(); + assert_eq!(extract_command(&payload), Some("git status")); + } + + #[test] + fn test_extract_command_missing_tool_input() { + let payload: ClaudePayload = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_extract_command_missing_command_field() { + let payload: ClaudePayload = + serde_json::from_str(r#"{"tool_input": {"cwd": "/tmp"}}"#).unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_extract_command_empty_string() { + let payload: ClaudePayload = + serde_json::from_str(r#"{"tool_input": {"command": ""}}"#).unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_shared_should_passthrough_rtk_prefix() { + assert!(should_passthrough("rtk run -c 'ls'")); + assert!(should_passthrough("rtk cargo test")); + assert!(should_passthrough("/usr/local/bin/rtk run -c 'ls'")); + } + + #[test] + fn test_shared_should_passthrough_heredoc() { + assert!(should_passthrough("cat <(input); + } + } + + // --- Fail-open behavior --- + + #[test] + fn test_run_inner_returns_no_opinion_for_empty_payload() { + // "{}" has no tool_input → no command → NoOpinion + let payload: ClaudePayload = serde_json::from_str("{}").unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_shared_is_hook_disabled_hook_enabled_zero() { + std::env::set_var("RTK_HOOK_ENABLED", "0"); + assert!(is_hook_disabled()); + std::env::remove_var("RTK_HOOK_ENABLED"); + } + + #[test] + fn test_shared_is_hook_disabled_rtk_active() { + std::env::set_var("RTK_ACTIVE", "1"); + assert!(is_hook_disabled()); + std::env::remove_var("RTK_ACTIVE"); + } + + // --- Integration: Bug #4669 workaround verification --- + + #[test] + fn test_deny_response_includes_reason_for_stderr() { + // Bug #4669 workaround: deny must provide plain text reason + // that can be output to stderr alongside the JSON stdout. + // The msg is cloned for both paths in run_inner(). + let msg = "RTK: cat is blocked (use rtk read instead)"; + let response = deny_response(msg.to_string()); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + // JSON stdout path + assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + parsed["hookSpecificOutput"]["permissionDecisionReason"], + msg + ); + // The same msg string is used for stderr in run() via HookResponse::Deny + } + + // Note: Integration tests for check_for_hook() safety decisions are in + // src/cmd/hook.rs (test_safe_commands_rewrite, test_blocked_commands, etc.) + // to avoid duplication. This module focuses on Claude Code wire format. +} diff --git a/src/cmd/exec.rs b/src/cmd/exec.rs new file mode 100644 index 00000000..0439b93d --- /dev/null +++ b/src/cmd/exec.rs @@ -0,0 +1,402 @@ +//! Command executor: runs simple chains natively, delegates complex shell to /bin/sh. + +use anyhow::{Context, Result}; +use std::process::Command; + +use super::{analysis, builtins, filters, lexer}; +use crate::stream::{FilterMode, LineFilter, StdinMode}; +use crate::tracking; + +/// Check if RTK is already active (recursion guard) +fn is_rtk_active() -> bool { + std::env::var("RTK_ACTIVE").is_ok() +} + +/// RAII guard: sets RTK_ACTIVE on creation, removes on drop (even on panic). +struct RtkActiveGuard; + +impl RtkActiveGuard { + fn new() -> Self { + std::env::set_var("RTK_ACTIVE", "1"); + RtkActiveGuard + } +} + +impl Drop for RtkActiveGuard { + fn drop(&mut self) { + std::env::remove_var("RTK_ACTIVE"); + } +} + +/// Execute a raw command string. +/// +/// Returns the exit code: 0 = success, non-zero = failure. +pub fn execute(raw: &str, verbose: u8) -> Result { + // Recursion guard + if is_rtk_active() { + if verbose > 0 { + eprintln!("rtk: Recursion detected, passing through"); + } + return run_passthrough(raw, verbose); + } + + // Handle empty input + if raw.trim().is_empty() { + return Ok(0); + } + + let _guard = RtkActiveGuard::new(); + execute_inner(raw, verbose) +} + +fn execute_inner(raw: &str, verbose: u8) -> Result { + // PR 2 adds: crate::config::rules::try_remap() alias expansion + + let tokens = lexer::tokenize(raw); + + // === STEP 1: Decide Native vs Passthrough === + if analysis::needs_shell(&tokens) { + // PR 2 adds: safety::check_raw(raw) before passthrough + return run_passthrough(raw, verbose); + } + + // === STEP 2: Parse into native command chain === + let commands = + analysis::parse_chain(tokens).map_err(|e| anyhow::anyhow!("Parse error: {}", e))?; + + // === STEP 3: Execute native chain === + run_native(&commands, verbose) +} + +/// Run commands in native mode (iterate, check safety, filter output) +fn run_native(commands: &[analysis::NativeCommand], verbose: u8) -> Result { + let mut last_exit: i32 = 0; + let mut prev_operator: Option<&str> = None; + + for cmd in commands { + // === SHORT-CIRCUIT LOGIC === + // Check if we should run based on PREVIOUS operator and result + // The operator stored in cmd is the one AFTER it, so we use prev_operator + if !analysis::should_run(prev_operator, last_exit == 0) { + // For && with failure or || with success, skip this command + prev_operator = cmd.operator.as_deref(); + continue; + } + + // === RECURSION PREVENTION === + // Handle "rtk run" or "rtk" binary specially + if cmd.binary == "rtk" && cmd.args.first().map(|s| s.as_str()) == Some("run") { + // Flatten: execute the inner command directly + // rtk run -c "git status" → args = ["run", "-c", "git status"] + let inner = if cmd.args.get(1).map(|s| s.as_str()) == Some("-c") { + cmd.args.get(2).cloned().unwrap_or_default() + } else { + cmd.args.get(1).cloned().unwrap_or_default() + }; + if verbose > 0 { + eprintln!("rtk: Flattening nested rtk run"); + } + return execute(&inner, verbose); + } + // Other rtk commands: spawn as external (they have their own filters) + + // PR 2 adds: safety::check() dispatch block + + // === BUILTINS === + if builtins::is_builtin(&cmd.binary) { + let ok = builtins::execute(&cmd.binary, &cmd.args)?; + last_exit = if ok { 0 } else { 1 }; + prev_operator = cmd.operator.as_deref(); + continue; + } + + // === EXTERNAL COMMAND WITH FILTERING === + last_exit = spawn_with_filter(&cmd.binary, &cmd.args, verbose)?; + prev_operator = cmd.operator.as_deref(); + } + + Ok(last_exit) +} + +/// Spawn external command and apply appropriate filter. +/// +/// Returns the real exit code (0–254) or 128+N for signal-killed processes. +fn spawn_with_filter(binary: &str, args: &[String], _verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + + // Try to find the binary in PATH + let binary_path = match which::which(binary) { + Ok(path) => path, + Err(_) => { + eprintln!("rtk: {}: command not found", binary); + return Ok(127); // standard "command not found" exit code + } + }; + + let mut cmd = Command::new(&binary_path); + cmd.args(args); + + let mode = filters::get_filter_mode(binary); + let result = crate::stream::run_streaming(&mut cmd, StdinMode::Inherit, mode) + .with_context(|| format!("Failed to execute: {}", binary))?; + + timer.track( + &format!("{} {}", binary, args.join(" ")), + &format!("rtk run {} {}", binary, args.join(" ")), + &result.raw, + &result.filtered, + ); + + Ok(result.exit_code) +} + +/// Run command via system shell (passthrough mode — complex shell expressions). +/// +/// Returns the real exit code propagated from the shell. +pub fn run_passthrough(raw: &str, verbose: u8) -> Result { + if verbose > 0 { + eprintln!("rtk: Passthrough mode for complex command"); + } + + let timer = tracking::TimedExecution::start(); + + let shell = if cfg!(windows) { "cmd" } else { "sh" }; + let flag = if cfg!(windows) { "/C" } else { "-c" }; + + let mut cmd = Command::new(shell); + cmd.arg(flag).arg(raw); + + // Per-line ANSI strip while streaming — no full-buffer wait + let filter = LineFilter::new(|l| Some(format!("{}\n", crate::utils::strip_ansi(l)))); + let result = crate::stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("Failed to execute passthrough")?; + + timer.track( + raw, + &format!("rtk passthrough {}", raw), + &result.raw, + &result.filtered, + ); + + Ok(result.exit_code) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::test_helpers::EnvGuard; + + // === RAII GUARD TESTS === + + #[test] + fn test_is_rtk_active_default() { + let _env = EnvGuard::new(); + assert!(!is_rtk_active()); + } + + #[test] + fn test_raii_guard_sets_and_clears() { + let _env = EnvGuard::new(); + { + let _guard = RtkActiveGuard::new(); + assert!(is_rtk_active()); + } + assert!( + !is_rtk_active(), + "RTK_ACTIVE must be cleared when guard drops" + ); + } + + #[test] + fn test_raii_guard_clears_on_panic() { + let _env = EnvGuard::new(); + let result = std::panic::catch_unwind(|| { + let _guard = RtkActiveGuard::new(); + assert!(is_rtk_active()); + panic!("simulated panic"); + }); + assert!(result.is_err()); + assert!( + !is_rtk_active(), + "RTK_ACTIVE must be cleared even after panic" + ); + } + + // === EXECUTE TESTS === + + #[test] + fn test_execute_empty() { + assert_eq!(execute("", 0).unwrap(), 0); + } + + #[test] + fn test_execute_whitespace_only() { + assert_eq!(execute(" ", 0).unwrap(), 0); + } + + #[test] + fn test_execute_simple_command() { + assert_eq!(execute("echo hello", 0).unwrap(), 0); + } + + #[test] + fn test_execute_builtin_cd() { + let original = std::env::current_dir().unwrap(); + assert_eq!(execute("cd /tmp", 0).unwrap(), 0); + // On macOS, /tmp might be a symlink to /private/tmp + let _ = std::env::set_current_dir(&original); + } + + #[test] + fn test_execute_builtin_pwd() { + assert_eq!(execute("pwd", 0).unwrap(), 0); + } + + #[test] + fn test_execute_builtin_true() { + assert_eq!(execute("true", 0).unwrap(), 0); + } + + #[test] + fn test_execute_builtin_false() { + // `false` returns exit code 1 + assert_ne!(execute("false", 0).unwrap(), 0); + } + + #[test] + fn test_execute_chain_and_success() { + assert_eq!(execute("true && echo success", 0).unwrap(), 0); + } + + #[test] + fn test_execute_chain_and_failure() { + // Chain stops at false; exit code is non-zero + assert_ne!(execute("false && echo should_not_run", 0).unwrap(), 0); + } + + #[test] + fn test_execute_chain_or_success() { + // true succeeds; || skips second command; exit code 0 + assert_eq!(execute("true || echo should_not_run", 0).unwrap(), 0); + } + + #[test] + fn test_execute_chain_or_failure() { + // false fails; || runs fallback (echo), which succeeds + assert_eq!(execute("false || echo fallback", 0).unwrap(), 0); + } + + #[test] + fn test_execute_chain_semicolon() { + // Both run; last result (false) is non-zero + assert_ne!(execute("true ; false", 0).unwrap(), 0); + } + + #[test] + fn test_execute_passthrough_for_glob() { + assert_eq!(execute("echo *", 0).unwrap(), 0); + } + + #[test] + fn test_execute_passthrough_for_pipe() { + assert_eq!(execute("echo hello | cat", 0).unwrap(), 0); + } + + #[test] + fn test_execute_quoted_operator() { + assert_eq!(execute(r#"echo "hello && world""#, 0).unwrap(), 0); + } + + #[test] + fn test_execute_binary_not_found() { + // 127 = standard "command not found" exit code + assert_eq!(execute("nonexistent_command_xyz_123", 0).unwrap(), 127); + } + + #[test] + fn test_execute_chain_and_three_commands() { + // true && false && true: stops at false → non-zero + assert_ne!(execute("true && false && true", 0).unwrap(), 0); + } + + #[test] + fn test_execute_chain_semicolon_last_wins() { + // false ; true: last command succeeds → 0 + assert_eq!(execute("false ; true", 0).unwrap(), 0); + } + + // === INTEGRATION TESTS (moved from edge_cases.rs) === + + #[test] + fn test_chain_mixed_operators() { + // false || true && echo works → 0 + assert_eq!(execute("false || true && echo works", 0).unwrap(), 0); + } + + #[test] + fn test_passthrough_redirect() { + assert_eq!(execute("echo test > /dev/null", 0).unwrap(), 0); + } + + #[test] + fn test_integration_cd_tilde() { + let original = std::env::current_dir().unwrap(); + assert_eq!(execute("cd ~", 0).unwrap(), 0); + let _ = std::env::set_current_dir(&original); + } + + #[test] + fn test_integration_export() { + assert_eq!(execute("export TEST_VAR=value", 0).unwrap(), 0); + std::env::remove_var("TEST_VAR"); + } + + #[test] + fn test_integration_env_prefix() { + let result = execute("TEST=1 echo hello", 0); + assert!(result.is_ok()); + } + + #[test] + fn test_integration_dash_args() { + assert_eq!(execute("echo --help -v --version", 0).unwrap(), 0); + } + + #[test] + fn test_integration_quoted_empty() { + assert_eq!(execute(r#"echo """#, 0).unwrap(), 0); + } + + // === RECURRENCE PREVENTION TESTS === + + #[test] + fn test_execute_rtk_recursion() { + // This should flatten, not infinitely recurse + let result = execute("rtk run \"echo hello\"", 0); + assert!(result.is_ok()); + } + + // === EXIT CODE ACCURACY TESTS === + + #[test] + fn test_execute_returns_real_exit_code() { + // sh -c "exit 42" must return 42, not 0 or 1 + let code = execute("sh -c \"exit 42\"", 0).unwrap(); + assert_eq!(code, 42, "exit code must be propagated exactly"); + } + + #[test] + fn test_execute_success_returns_zero() { + assert_eq!(execute("true", 0).unwrap(), 0); + } + + #[test] + fn test_run_native_and_chain_exit_code() { + // "true && false" — last command fails, exit code non-zero + assert_ne!(execute("true && false", 0).unwrap(), 0); + } +} diff --git a/src/cmd/filters.rs b/src/cmd/filters.rs new file mode 100644 index 00000000..dd647428 --- /dev/null +++ b/src/cmd/filters.rs @@ -0,0 +1,339 @@ +//! Filter Registry — basic token reduction for `rtk run` native execution. +//! +//! This module provides **basic filtering (20-40% savings)** for commands +//! executed through rtk run. It is a **fallback** for commands +//! without dedicated RTK implementations. +//! +//! For **specialized filtering (60-90% savings)**, use dedicated modules: +//! - `src/git.rs` — git commands (diff, log, status, etc.) +//! - `src/runner.rs` — test commands (cargo test, pytest, etc.) +//! - `src/grep_cmd.rs` — code search (grep, ripgrep) +//! - `src/pnpm_cmd.rs` — package managers + +use crate::stream::{FilterMode, LineFilter}; +use crate::utils; + +/// Filter types for different command categories +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FilterType { + Git, + Cargo, + Test, + Pnpm, + Npm, + Generic, + None, +} + +/// Determine which filter to apply based on binary name +pub fn get_filter_type(binary: &str) -> FilterType { + match binary { + "git" => FilterType::Git, + "cargo" => FilterType::Cargo, + "npm" | "npx" => FilterType::Npm, + "pnpm" => FilterType::Pnpm, + "pytest" | "go" | "vitest" | "jest" | "mocha" => FilterType::Test, + "ls" | "find" | "grep" | "rg" | "fd" => FilterType::Generic, + _ => FilterType::None, + } +} + +/// Apply filter to already-captured string output +pub fn apply_to_string(filter: FilterType, output: &str) -> String { + match filter { + FilterType::Git => utils::strip_ansi(output), + FilterType::Cargo => filter_cargo_output(output), + FilterType::Test => filter_test_output(output), + FilterType::Generic => truncate_lines(output, 100), + FilterType::Npm | FilterType::Pnpm => utils::strip_ansi(output), + FilterType::None => output.to_string(), + } +} + +/// Filter cargo output: remove verbose "Compiling" lines +fn filter_cargo_output(output: &str) -> String { + output + .lines() + .filter(|line| { + let line = line.trim(); + !line.starts_with("Compiling ") || line.contains("error") || line.contains("warning") + }) + .collect::>() + .join("\n") +} + +/// Filter test output: remove passing tests, keep failures +fn filter_test_output(output: &str) -> String { + output + .lines() + .filter(|line| { + let line = line.trim(); + line.contains("FAILED") + || line.contains("error") + || line.contains("Error") + || line.contains("failed") + || line.contains("test result:") + || line.starts_with("----") + }) + .collect::>() + .join("\n") +} + +/// Map a binary name to a [`FilterMode`] for use with [`crate::stream::run_streaming`]. +/// +/// This is the streaming counterpart to [`get_filter_type`] + [`apply_to_string`]. +/// Used by `spawn_with_filter` in exec.rs for external commands without dedicated modules. +pub fn get_filter_mode(binary: &str) -> FilterMode { + match binary { + // Streaming: per-line ANSI strip + line truncation (low savings, low overhead) + "ls" | "find" | "grep" | "rg" | "fd" => { + FilterMode::Streaming(Box::new(LineFilter::new(|l| { + let stripped = utils::strip_ansi(l); + let truncated = if stripped.len() > 120 { + format!("{}...", &stripped[..117]) + } else { + stripped + }; + Some(format!("{}\n", truncated)) + }))) + } + // Buffered: cargo, git, and test runners use simple filters here + // (dedicated modules like cargo_cmd.rs / go_cmd.rs provide 60-90% savings) + "cargo" => FilterMode::Buffered(filter_cargo_output), + "pytest" | "jest" | "mocha" | "vitest" => FilterMode::Buffered(filter_test_output), + // git: ANSI strip per-line (dedicated git.rs handles git subcommands) + "git" => FilterMode::Streaming(Box::new(LineFilter::new(|l| { + Some(format!("{}\n", utils::strip_ansi(l))) + }))), + // Unknown commands: passthrough (no filtering, preserves all output) + _ => FilterMode::Passthrough, + } +} + +/// Truncate output to max lines +fn truncate_lines(output: &str, max_lines: usize) -> String { + let lines: Vec<&str> = output.lines().collect(); + if lines.len() <= max_lines { + output.to_string() + } else { + let truncated: Vec<&str> = lines.iter().take(max_lines).copied().collect(); + format!( + "{}\n... ({} more lines)", + truncated.join("\n"), + lines.len() - max_lines + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // === GET_FILTER_TYPE TESTS === + + #[test] + fn test_filter_type_git() { + assert_eq!(get_filter_type("git"), FilterType::Git); + } + + #[test] + fn test_filter_type_cargo() { + assert_eq!(get_filter_type("cargo"), FilterType::Cargo); + } + + #[test] + fn test_filter_type_npm() { + assert_eq!(get_filter_type("npm"), FilterType::Npm); + assert_eq!(get_filter_type("npx"), FilterType::Npm); + } + + #[test] + fn test_filter_type_generic() { + assert_eq!(get_filter_type("ls"), FilterType::Generic); + assert_eq!(get_filter_type("grep"), FilterType::Generic); + } + + #[test] + fn test_filter_type_none() { + assert_eq!(get_filter_type("unknown_command"), FilterType::None); + } + + // === STRIP_ANSI TESTS (now testing utils::strip_ansi) === + + #[test] + fn test_strip_ansi_no_codes() { + assert_eq!(utils::strip_ansi("hello world"), "hello world"); + } + + #[test] + fn test_strip_ansi_color() { + assert_eq!(utils::strip_ansi("\x1b[32mgreen\x1b[0m"), "green"); + } + + #[test] + fn test_strip_ansi_bold() { + assert_eq!(utils::strip_ansi("\x1b[1mbold\x1b[0m"), "bold"); + } + + #[test] + fn test_strip_ansi_multiple() { + assert_eq!( + utils::strip_ansi("\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m"), + "red green" + ); + } + + #[test] + fn test_strip_ansi_complex() { + assert_eq!( + utils::strip_ansi("\x1b[1;31;42mbold red on green\x1b[0m"), + "bold red on green" + ); + } + + // === FILTER_CARGO_OUTPUT TESTS === + + #[test] + fn test_filter_cargo_keeps_errors() { + let input = "Compiling dep1\nerror: something wrong\nCompiling dep2"; + let output = filter_cargo_output(input); + assert!(output.contains("error")); + assert!(!output.contains("Compiling dep1")); + } + + #[test] + fn test_filter_cargo_keeps_warnings() { + let input = "Compiling dep1\nwarning: unused variable\nCompiling dep2"; + let output = filter_cargo_output(input); + assert!(output.contains("warning")); + } + + // === TRUNCATE_LINES TESTS === + + #[test] + fn test_truncate_short() { + let input = "line1\nline2\nline3"; + let output = truncate_lines(input, 10); + assert_eq!(output, input); + } + + #[test] + fn test_truncate_long() { + let input = "line1\nline2\nline3\nline4\nline5"; + let output = truncate_lines(input, 3); + assert!(output.contains("line3")); + assert!(!output.contains("line4")); + assert!(output.contains("2 more lines")); + } + + // === APPLY_TO_STRING TESTS === + + #[test] + fn test_apply_to_string_none() { + let input = "hello world"; + let output = apply_to_string(FilterType::None, input); + assert_eq!(output, input); + } + + #[test] + fn test_apply_to_string_git() { + let input = "\x1b[32mgreen\x1b[0m"; + let output = apply_to_string(FilterType::Git, input); + assert_eq!(output, "green"); + } + + // === GET_FILTER_MODE TESTS === + + #[test] + fn test_get_filter_mode_grep_is_streaming() { + matches!(get_filter_mode("grep"), FilterMode::Streaming(_)); + } + + #[test] + fn test_get_filter_mode_rg_is_streaming() { + matches!(get_filter_mode("rg"), FilterMode::Streaming(_)); + } + + #[test] + fn test_get_filter_mode_find_is_streaming() { + matches!(get_filter_mode("find"), FilterMode::Streaming(_)); + } + + #[test] + fn test_get_filter_mode_fd_is_streaming() { + matches!(get_filter_mode("fd"), FilterMode::Streaming(_)); + } + + #[test] + fn test_get_filter_mode_ls_is_streaming() { + matches!(get_filter_mode("ls"), FilterMode::Streaming(_)); + } + + #[test] + fn test_get_filter_mode_cargo_is_buffered() { + matches!(get_filter_mode("cargo"), FilterMode::Buffered(_)); + } + + #[test] + fn test_get_filter_mode_unknown_is_passthrough() { + matches!(get_filter_mode("unknowncmd"), FilterMode::Passthrough); + } + + #[test] + fn test_get_filter_mode_grep_strips_ansi_and_emits() { + // Feed a line with ANSI codes; the streaming filter must strip them and emit. + let mut mode = get_filter_mode("grep"); + if let FilterMode::Streaming(ref mut filter) = mode { + let result = filter.feed_line("\x1b[32msrc/main.rs:42:fn main\x1b[0m"); + assert!(result.is_some(), "streaming filter must emit a line"); + let out = result.unwrap(); + assert!( + out.contains("src/main.rs"), + "ANSI stripped, path preserved: {}", + out + ); + assert!( + !out.contains("\x1b["), + "ANSI codes must be stripped: {}", + out + ); + } else { + panic!("Expected FilterMode::Streaming for 'grep'"); + } + } + + #[test] + fn test_get_filter_mode_find_truncates_long_lines() { + // Feed a line > 120 chars; the streaming filter must truncate it. + let long_line = "a".repeat(200); + let mut mode = get_filter_mode("find"); + if let FilterMode::Streaming(ref mut filter) = mode { + let result = filter.feed_line(&long_line); + assert!(result.is_some()); + let out = result.unwrap(); + // Truncated content should end with "..." and be ≤ 120+3+1 ("\n") chars + assert!( + out.len() <= 125, + "line must be truncated: len={}", + out.len() + ); + assert!(out.contains("..."), "truncated line must contain '...'"); + } else { + panic!("Expected FilterMode::Streaming for 'find'"); + } + } + + #[test] + fn test_get_filter_mode_rg_short_line_passes_through() { + let short_line = "src/foo.rs:10:hello"; + let mut mode = get_filter_mode("rg"); + if let FilterMode::Streaming(ref mut filter) = mode { + let result = filter.feed_line(short_line); + assert!(result.is_some()); + let out = result.unwrap(); + assert!(out.contains("src/foo.rs"), "out={}", out); + } else { + panic!("Expected FilterMode::Streaming for 'rg'"); + } + } +} diff --git a/src/cmd/gemini_hook.rs b/src/cmd/gemini_hook.rs new file mode 100644 index 00000000..058ce173 --- /dev/null +++ b/src/cmd/gemini_hook.rs @@ -0,0 +1,490 @@ +//! Gemini CLI BeforeTool hook protocol handler. +//! +//! Reads JSON from stdin, applies safety checks and rewrites, +//! outputs JSON to stdout. +//! +//! Protocol: https://geminicli.com/docs/hooks/reference/ +//! +//! ## Exit Code Behavior +//! +//! - Exit 0 = normal (JSON `decision` field is respected) +//! - Exit 2 = blocking error (equivalent to `decision: "deny"`) +//! +//! ## Gemini CLI Stderr Rule +//! +//! **Source:** See `https://docs.anthropic.com/en/docs/claude-code/hooks` +//! +//! Unlike Claude Code, Gemini CLI **allows stderr for debugging**: +//! ```text +//! stderr is SAFE for debug/logging (shown to user/agent) +//! ``` +//! +//! **This module's stderr usage:** +//! - Currently: **NO stderr output** (JSON `reason` field sufficient for all cases) +//! - Future: Could add debug logging to stderr if needed (safe in Gemini) +//! +//! ## I/O Enforcement (Module-Specific) +//! +//! **This restriction applies ONLY to gemini_hook.rs and claude_hook.rs.** +//! All other RTK modules (main.rs, git.rs, etc.) use `println!`/`eprintln!` normally. +//! +//! **Why restricted here:** +//! - Hook protocol requires JSON-only stdout +//! - Accidental prints corrupt the JSON response +//! - Consistency with claude_hook.rs architecture +//! +//! **Enforcement mechanism:** +//! - `#![deny(clippy::print_stdout, clippy::print_stderr)]` at module level (line 42) +//! - `run_inner()` returns `HookResponse` enum — pure logic, no I/O +//! - `run()` is the ONLY function that writes output — single I/O point +//! - Uses `write!`/`writeln!` which are NOT caught by the clippy lint +//! +//! **Pathway:** main.rs → Commands::Hook → gemini_hook::run() [DENY ENFORCED HERE] +//! +//! Fail-open: Any parse error or unexpected input → exit 0, no output. + +// Compile-time I/O enforcement for THIS MODULE ONLY. +// Other RTK modules (main.rs, git.rs, etc.) use println!/eprintln! normally. +// +// Why restrict here: +// - Gemini CLI hook protocol requires JSON-only stdout +// - Accidental prints would corrupt the JSON response +// - Architectural consistency with claude_hook.rs +// +// Note: Unlike Claude Code, Gemini ALLOWS stderr for debug logging +// (see hooks_api_reference.md:740-753), but we don't need it. +// The JSON `reason` field is sufficient for all messaging. +// +// Mechanism: +// - Denies println!/eprintln! at compile-time +// - Allows write!/writeln! (used only in run() for controlled output) +// - run_inner() returns HookResponse (no I/O) +// - run() is the single I/O point +#![deny(clippy::print_stdout, clippy::print_stderr)] + +use super::hook::{ + check_for_hook, is_hook_disabled, should_passthrough, update_command_in_tool_input, + HookResponse, HookResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{self, Read, Write}; + +#[derive(Deserialize)] +struct GeminiPayload { + hook_event_name: Option, + tool_name: Option, + tool_input: Option, +} + +#[derive(Serialize)] +struct GeminiResponse { + decision: String, // "allow" or "deny" + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(rename = "hookSpecificOutput")] + #[serde(skip_serializing_if = "Option::is_none")] + hook_specific_output: Option, +} + +#[derive(Serialize)] +struct HookSpecificOutput { + tool_input: Value, +} + +/// Tool names that represent shell command execution in Gemini CLI +fn is_shell_tool(name: &str) -> bool { + // Gemini CLI built-in shell tool, plus common MCP patterns + name == "run_shell_command" || name == "shell" || name.ends_with("__run_shell_command") +} + +/// Run the Gemini hook handler. +/// +/// This is the ONLY function that performs I/O (stdout). +/// `run_inner()` returns a `HookResponse` enum — pure logic, no I/O. +/// Combined with `#![deny(clippy::print_stdout, clippy::print_stderr)]`, +/// this ensures no stray output corrupts the JSON hook protocol. +/// +/// Fail-open design: malformed input → exit 0, no output. +pub fn run() -> anyhow::Result<()> { + let response = match run_inner() { + Ok(r) => r, + Err(_) => HookResponse::NoOpinion, // Fail-open: swallow errors + }; + + // ┌────────────────────────────────────────────────────────────────┐ + // │ SINGLE I/O POINT - All stdout output happens here only │ + // │ │ + // │ Why: Gemini CLI hook protocol requires JSON-only stdout │ + // │ (Gemini ALLOWS stderr for debug, but we don't need it) │ + // │ │ + // │ Enforcement: #![deny(...)] at line 42 prevents println!/eprintln! │ + // │ write!/writeln! are not caught by lint (allowed) │ + // └────────────────────────────────────────────────────────────────┘ + match response { + HookResponse::NoOpinion => { + // Exit 0, NO stdout, NO stderr + // Gemini CLI sees no output → proceeds with original command + } + HookResponse::Allow(json) | HookResponse::Deny(json, _) => { + // Exit 0, JSON to stdout, NO stderr + // Note: Gemini ALLOWS stderr for debug (unlike Claude), but JSON + // `reason` field is sufficient. The HookResponse::Deny + // second field (stderr_reason) is empty for Gemini. + writeln!(io::stdout(), "{json}")?; + } + } + Ok(()) +} + +/// Inner handler: pure decision logic, no I/O. +/// Returns `HookResponse` for `run()` to output. +fn run_inner() -> anyhow::Result { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + + let payload: GeminiPayload = match serde_json::from_str(&buffer) { + Ok(p) => p, + Err(_) => return Ok(HookResponse::NoOpinion), + }; + + // Only handle BeforeTool events — other events get a plain allow + if payload.hook_event_name.as_deref() != Some("BeforeTool") { + return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())); + } + + // Only intercept shell execution tools + match &payload.tool_name { + Some(name) if is_shell_tool(name) => {} + _ => return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())), + }; + + // Extract the command string from tool_input + let cmd = match &payload.tool_input { + Some(input) => input + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + None => return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())), + }; + + if cmd.is_empty() { + return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())); + } + + // Shared guard checks (same as claude_hook.rs, DRY via hook.rs) + if is_hook_disabled() || should_passthrough(&cmd) { + return Ok(HookResponse::NoOpinion); + } + + let decision = check_for_hook(&cmd, "gemini"); + + let response = match decision { + HookResult::Rewrite(new_cmd) => { + // Preserve all original tool_input fields, only replace "command" + // Shared helper (DRY with claude_hook.rs via hook.rs) + let new_input = update_command_in_tool_input(payload.tool_input, new_cmd); + + GeminiResponse { + decision: "allow".into(), + reason: Some("RTK applied safety optimizations.".into()), + hook_specific_output: Some(HookSpecificOutput { + tool_input: new_input, + }), + } + } + HookResult::Blocked(msg) => GeminiResponse { + decision: "deny".into(), + reason: Some(msg), + hook_specific_output: None, + }, + }; + + let json = serde_json::to_string(&response)?; + // Gemini deny uses JSON response only (no stderr/exit-code workaround needed) + if response.decision == "deny" { + Ok(HookResponse::Deny(json, String::new())) + } else { + Ok(HookResponse::Allow(json)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // GEMINI WIRE FORMAT CONFORMANCE + // https://geminicli.com/docs/hooks/reference/ + // + // These tests verify exact JSON field names per the Gemini CLI spec. + // A wrong field name means Gemini silently ignores the response. + // ========================================================================= + + // --- Input: field name conformance --- + + #[test] + fn test_input_uses_hook_event_name_not_type() { + // Gemini sends "hook_event_name", NOT "type" + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "git status"}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.hook_event_name.as_deref(), Some("BeforeTool")); + + // Verify the old wrong field name does NOT populate our struct + let wrong_json = r#"{"type": "BeforeTool", "tool_name": "run_shell_command"}"#; + let payload: GeminiPayload = serde_json::from_str(wrong_json).unwrap(); + assert_eq!( + payload.hook_event_name, None, + "\"type\" must not be accepted as event name" + ); + } + + #[test] + fn test_input_includes_tool_name() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "ls"}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.tool_name.as_deref(), Some("run_shell_command")); + } + + #[test] + fn test_input_tool_input_is_object() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "git status", "timeout": 30}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + let input = payload.tool_input.unwrap(); + assert_eq!(input["command"].as_str().unwrap(), "git status"); + assert_eq!(input["timeout"].as_i64().unwrap(), 30); + } + + #[test] + fn test_input_extra_fields_ignored() { + // Gemini sends session_id, cwd, timestamp, transcript_path etc. + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "ls"}, "session_id": "abc123", "cwd": "/tmp", "timestamp": "2026-01-01T00:00:00Z", "transcript_path": "/path/to/transcript"}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.hook_event_name.as_deref(), Some("BeforeTool")); + } + + // --- Output: field name conformance --- + + #[test] + fn test_output_uses_decision_not_result() { + // Gemini expects "decision", NOT "result" + let response = GeminiResponse { + decision: "allow".into(), + reason: None, + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed.get("decision").is_some(), + "must have 'decision' field" + ); + assert!( + parsed.get("result").is_none(), + "must NOT have 'result' field" + ); + } + + #[test] + fn test_output_uses_reason_not_message() { + // Gemini expects "reason", NOT "message" + let response = GeminiResponse { + decision: "deny".into(), + reason: Some("Blocked for safety".into()), + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!(parsed.get("reason").is_some(), "must have 'reason' field"); + assert!( + parsed.get("message").is_none(), + "must NOT have 'message' field" + ); + } + + #[test] + fn test_output_uses_hook_specific_output_not_modified_input() { + // Gemini expects "hookSpecificOutput", NOT "modified_input" + let response = GeminiResponse { + decision: "allow".into(), + reason: None, + hook_specific_output: Some(HookSpecificOutput { + tool_input: serde_json::json!({"command": "rtk run -c 'ls'"}), + }), + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed.get("hookSpecificOutput").is_some(), + "must have 'hookSpecificOutput' field" + ); + assert!( + parsed.get("modified_input").is_none(), + "must NOT have 'modified_input' field" + ); + } + + #[test] + fn test_output_rewrite_nests_under_tool_input() { + // Gemini merges hookSpecificOutput.tool_input into the original + let response = GeminiResponse { + decision: "allow".into(), + reason: Some("RTK applied safety optimizations.".into()), + hook_specific_output: Some(HookSpecificOutput { + tool_input: serde_json::json!({"command": "rtk run -c 'git status'"}), + }), + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert_eq!( + parsed["hookSpecificOutput"]["tool_input"]["command"], + "rtk run -c 'git status'" + ); + } + + #[test] + fn test_output_allow_omits_optional_fields() { + let response = GeminiResponse { + decision: "allow".into(), + reason: None, + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(!json.contains("reason"), "reason must be omitted when None"); + assert!( + !json.contains("hookSpecificOutput"), + "hookSpecificOutput must be omitted when None" + ); + } + + #[test] + fn test_output_decision_values() { + // Only "allow" and "deny" are valid + for val in ["allow", "deny"] { + let response = GeminiResponse { + decision: val.into(), + reason: Some("test".into()), + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["decision"].as_str().unwrap(), val); + } + } + + // --- Tool filtering --- + + #[test] + fn test_is_shell_tool() { + assert!(is_shell_tool("run_shell_command")); + assert!(is_shell_tool("shell")); + assert!(is_shell_tool("mcp__server__run_shell_command")); + assert!(!is_shell_tool("read_file")); + assert!(!is_shell_tool("write_file")); + assert!(!is_shell_tool("search_code")); + assert!(!is_shell_tool("list_directory")); + } + + #[test] + fn test_non_shell_tools_always_allowed() { + // read_file, write_file, etc. must never be intercepted + for tool in ["read_file", "write_file", "search_code", "list_directory"] { + let json = format!( + r#"{{"hook_event_name": "BeforeTool", "tool_name": "{}", "tool_input": {{"path": "/etc/passwd"}}}}"#, + tool + ); + let payload: GeminiPayload = serde_json::from_str(&json).unwrap(); + assert!( + !is_shell_tool(payload.tool_name.as_deref().unwrap()), + "tool '{}' must not be treated as shell tool", + tool + ); + } + } + + // --- Event filtering --- + + #[test] + fn test_non_before_tool_events_ignored() { + for event in ["AfterTool", "BeforeAgent", "AfterAgent", "SessionStart"] { + let json = format!( + r#"{{"hook_event_name": "{}", "tool_name": "run_shell_command", "tool_input": {{"command": "rm -rf /"}}}}"#, + event + ); + let payload: GeminiPayload = serde_json::from_str(&json).unwrap(); + assert_ne!(payload.hook_event_name.as_deref(), Some("BeforeTool")); + } + } + + // --- Rewrite preserves other tool_input fields --- + + #[test] + fn test_rewrite_preserves_other_tool_input_fields() { + let original_input = serde_json::json!({ + "command": "git status", + "timeout": 30, + "cwd": "/project" + }); + + let mut new_input = original_input.clone(); + if let Some(obj) = new_input.as_object_mut() { + obj.insert( + "command".into(), + Value::String("rtk run -c 'git status'".into()), + ); + } + + assert_eq!(new_input["timeout"], 30); + assert_eq!(new_input["cwd"], "/project"); + assert_eq!(new_input["command"], "rtk run -c 'git status'"); + } + + // --- Edge cases --- + + #[test] + fn test_missing_tool_input() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command"}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert!(payload.tool_input.is_none()); + } + + #[test] + fn test_missing_command_in_tool_input() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"cwd": "/tmp"}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + let input = payload.tool_input.unwrap(); + assert!(input.get("command").is_none()); + } + + #[test] + fn test_malformed_json_does_not_panic() { + let bad_inputs = ["", "not json", "{}", r#"{"hook_event_name": 42}"#, "null"]; + for input in bad_inputs { + // Should not panic, just return Err or deserialize to defaults + let _ = serde_json::from_str::(input); + } + } + + // --- Guard parity with Claude hook --- + + #[test] + fn test_shared_guards_available() { + // Verify shared guard functions are accessible (DRY with claude_hook.rs) + assert!(!should_passthrough("git status")); + assert!(should_passthrough("rtk git status")); + assert!(should_passthrough("cat < HookResult { + check_for_hook_inner(raw, 0) +} + +fn check_for_hook_inner(raw: &str, depth: usize) -> HookResult { + if depth >= MAX_REWRITE_DEPTH { + return HookResult::Blocked("Rewrite loop detected (max depth exceeded)".to_string()); + } + if raw.trim().is_empty() { + return HookResult::Rewrite(raw.to_string()); + } + // PR 2 adds: crate::config::rules::try_remap() alias expansion + // PR 2 adds: safety::check_raw() and safety::check() dispatch + + let tokens = lexer::tokenize(raw); + + // === SUFFIX-AWARE ROUTING === + // Strip known safe redirect/pipe suffixes (2>&1, | tee, | head, etc.) from the + // end of the command so the core can be routed through an RTK filter. The suffix + // is appended verbatim to the rewritten command; the shell applies it to rtk's output. + // + // Example: "cargo test 2>&1" → strip suffix → core "cargo test" → "rtk cargo test 2>&1" + let (core_tokens, suffix) = analysis::split_safe_suffix(tokens); + + if analysis::needs_shell(&core_tokens) { + // Core needs shell even after suffix stripping — full passthrough. + // (When suffix is empty, core_tokens == original tokens so this is unchanged.) + return HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))); + } + + match analysis::parse_chain(core_tokens) { + Ok(commands) => { + // Single command: route to optimized RTK subcommand. + // Chained commands (&&, ||, ;): wrap entire chain in rtk run -c. + if commands.len() == 1 { + let routed = if suffix.is_empty() { + // No suffix stripped: use original raw to preserve quoting + route_native_command(&commands[0], raw) + } else { + // Suffix was stripped: reconstruct core_raw from parsed command. + // Quoting is simplified (join with spaces) but acceptable for the + // common cases where suffix-bearing commands use simple args. + let core_raw = if commands[0].args.is_empty() { + commands[0].binary.clone() + } else { + format!("{} {}", commands[0].binary, commands[0].args.join(" ")) + }; + route_native_command(&commands[0], &core_raw) + }; + + if suffix.is_empty() { + HookResult::Rewrite(routed) + } else { + HookResult::Rewrite(format!("{} {}", routed, suffix)) + } + } else { + // Multi-command chain (&&, ||, ;): wrap in shell but substitute each + // known command with its RTK equivalent for maximum token savings. + // + // Example: "cargo test && git log" → + // rtk run -c 'rtk cargo test && rtk git log' + // + // Unknown commands pass through unchanged — no nested rtk run -c. + let substituted = reconstruct_with_rtk(&commands); + let inner = if suffix.is_empty() { + substituted + } else { + format!("{} {}", substituted, suffix) + }; + HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(&inner))) + } + } + Err(_) => HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))), + } +} + +// --- Shared guard logic (used by both claude_hook.rs and gemini_hook.rs) --- + +/// Check if hook processing is disabled by environment. +/// +/// Returns true if: +/// - `RTK_HOOK_ENABLED=0` (master toggle off) +/// - `RTK_ACTIVE` is set (recursion prevention — rtk sets this when running commands) +pub fn is_hook_disabled() -> bool { + std::env::var("RTK_HOOK_ENABLED").as_deref() == Ok("0") || std::env::var("RTK_ACTIVE").is_ok() +} + +/// Check if this command should bypass hook processing entirely. +/// +/// Returns true for commands that should not be rewritten: +/// - Already routed through rtk (`rtk ...` or `/path/to/rtk ...`) +/// - Contains heredoc (`<<`) which needs raw shell processing +pub fn should_passthrough(cmd: &str) -> bool { + cmd.starts_with("rtk ") || cmd.contains("/rtk ") || cmd.contains("<<") +} + +/// Replace the command field in a tool_input object, preserving other fields. +/// +/// Used by both claude_hook.rs and gemini_hook.rs when rewriting commands. +/// If tool_input is None or not an object, creates a new object with just the command. +/// +/// # Arguments +/// * `tool_input` - The original tool_input from the hook payload (may be None) +/// * `new_cmd` - The rewritten command string to replace with +/// +/// # Returns +/// A Value with the command field updated, all other fields preserved. +pub fn update_command_in_tool_input( + tool_input: Option, + new_cmd: String, +) -> serde_json::Value { + use serde_json::Value; + let mut updated = tool_input.unwrap_or_else(|| Value::Object(Default::default())); + if let Some(obj) = updated.as_object_mut() { + obj.insert("command".into(), Value::String(new_cmd)); + } + updated +} + +/// Hook output for protocol handlers (claude_hook.rs, gemini_hook.rs). +/// +/// This enum separates decision logic from I/O: `run_inner()` returns a +/// `HookResponse`, and `run()` is the single place that writes to stdout/stderr. +/// Combined with `#[deny(clippy::print_stdout, clippy::print_stderr)]` on the +/// hook modules, this prevents any stray output from corrupting the JSON protocol. +#[derive(Debug, Clone, PartialEq)] +pub enum HookResponse { + /// No opinion — exit 0, no output. Host proceeds normally. + NoOpinion, + /// Allow/rewrite — exit 0, JSON to stdout. + Allow(String), + /// Deny — exit 2, JSON to stdout + reason to stderr. + /// Fields: (stdout_json, stderr_reason) + Deny(String, String), +} + +/// Commands whose RTK output format matches their raw output, making them +/// safe as the left side of any pipe. +/// +/// For a command to be format-preserving, RTK must emit the same logical +/// lines as the underlying tool — just possibly with ANSI codes stripped. +/// These can be substituted on the left of a pipe without breaking the +/// right-side consumer. +/// +/// # Contrast with format-changing commands +/// `cargo test`, `git log`, `pytest`, `go test` etc. heavily compress output. +/// They must **not** appear here — substituting them as a pipe-left would +/// break right-side semantic sinks (`grep`, `jq`, `awk`, `patch`, `xargs`). +pub(crate) const FORMAT_PRESERVING: &[&str] = &["tail", "echo", "cat", "find", "fd"]; + +/// Right-side commands that accept any input format (transparent sinks). +/// +/// These commands copy, truncate, or tee their stdin without interpreting its +/// structure, so RTK's compressed output is always compatible with them. +/// Already handled at the routing level by `split_safe_suffix` — listed here +/// for classification documentation and future pipe-left substitution logic. +pub(crate) const TRANSPARENT_SINKS: &[&str] = &["tee", "head", "tail", "cat"]; + +/// Escape single quotes for shell +fn escape_quotes(s: &str) -> String { + s.replace("'", "'\\''") +} + +/// Returns true if `s` looks like a shell env-var assignment: `IDENT=VALUE`. +/// +/// Accepts: `FOO=bar`, `FOO=`, `_FOO=123`, `FOO_BAR=baz` +/// Rejects: `=value`, `123=abc`, plain args, flag args like `--foo=bar` +fn is_env_assign(s: &str) -> bool { + if let Some(eq_pos) = s.find('=') { + let key = &s[..eq_pos]; + !key.is_empty() + && key + .chars() + .next() + .map_or(false, |c| c.is_ascii_alphabetic() || c == '_') + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + } else { + false + } +} + +/// Replace the first occurrence of `old_prefix` in `raw` with `new_prefix`. +/// +/// Preserves everything after the prefix (including original quoting). +/// Falls back to `rtk run -c ''` if prefix not found (safe degradation). +/// +/// # Examples +/// - `replace_first_word("grep -r p src/", "grep", "rtk grep")` → `"rtk grep -r p src/"` +/// - `replace_first_word("rg pattern", "rg", "rtk grep")` → `"rtk grep pattern"` +fn replace_first_word(raw: &str, old_prefix: &str, new_prefix: &str) -> String { + raw.strip_prefix(old_prefix) + .map(|rest| format!("{new_prefix}{rest}")) + .unwrap_or_else(|| format!("rtk run -c '{}'", escape_quotes(raw))) +} + +/// Route pnpm subcommands to RTK equivalents. +/// +/// Uses `cmd.args` (parsed, quote-stripped) for routing decisions. +/// Uses `raw` or reconstructed args for output to preserve original quoting. +fn route_pnpm(cmd: &analysis::NativeCommand, raw: &str) -> String { + let sub = cmd.args.first().map(String::as_str).unwrap_or(""); + match sub { + "list" | "ls" | "outdated" | "install" => format!("rtk {raw}"), + + // pnpm vitest [run] [flags] → rtk vitest run [flags] + // Shell script sed bug: 's/^(pnpm )?vitest/rtk vitest run/' on + // "pnpm vitest run --coverage" produces "rtk vitest run run --coverage". + // Binary hook corrects this by stripping the leading "run" from parsed args. + "vitest" => { + let after_vitest: Vec<&str> = cmd.args[1..] + .iter() + .map(String::as_str) + .skip_while(|&a| a == "run") + .collect(); + if after_vitest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", after_vitest.join(" ")) + } + } + + // pnpm test [flags] → rtk vitest run [flags] + "test" => { + let after_test: Vec<&str> = cmd.args[1..].iter().map(String::as_str).collect(); + if after_test.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", after_test.join(" ")) + } + } + + "tsc" => replace_first_word(raw, "pnpm tsc", "rtk tsc"), + "lint" => replace_first_word(raw, "pnpm lint", "rtk lint"), + "eslint" => replace_first_word(raw, "pnpm eslint", "rtk lint"), + "playwright" => replace_first_word(raw, "pnpm playwright", "rtk playwright"), + + _ => format!("rtk run -c '{}'", escape_quotes(raw)), + } +} + +/// Route npx subcommands to RTK equivalents. +fn route_npx(cmd: &analysis::NativeCommand, raw: &str) -> String { + let sub = cmd.args.first().map(String::as_str).unwrap_or(""); + match sub { + "tsc" | "typescript" => replace_first_word(raw, &format!("npx {sub}"), "rtk tsc"), + "eslint" => replace_first_word(raw, "npx eslint", "rtk lint"), + "prettier" => replace_first_word(raw, "npx prettier", "rtk prettier"), + "playwright" => replace_first_word(raw, "npx playwright", "rtk playwright"), + "prisma" => replace_first_word(raw, "npx prisma", "rtk prisma"), + + // npx vitest [run] [flags] → rtk vitest run [flags] + // Mirrors pnpm vitest handling: strip double-"run" if user writes "npx vitest run". + "vitest" => { + let after_vitest: Vec<&str> = cmd.args[1..] + .iter() + .map(String::as_str) + .skip_while(|&a| a == "run") + .collect(); + if after_vitest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", after_vitest.join(" ")) + } + } + + _ => format!("rtk run -c '{}'", escape_quotes(raw)), + } +} + +/// Route a single parsed native command to its optimized RTK subcommand. +/// +/// ## Design +/// - Uses `cmd.binary`/`cmd.args` (lexer→parse_chain output) for routing DECISIONS. +/// - Uses `raw: &str` with `replace_first_word` for string REPLACEMENT (preserves quoting). +/// - `format!("rtk {raw}")` works when the binary name equals the RTK subcommand. +/// - `replace_first_word` handles renames: `rg → rtk grep`, `cat → rtk read`. +/// +/// ## Fallback +/// Unknown binaries or unrecognized subcommands → `rtk run -c ''` (safe passthrough). +/// +/// ## Mirrors +/// `~/.claude/hooks/rtk-rewrite.sh` routing table. Corrects the shell script's +/// `vitest run` double-"run" bug by using parsed args rather than regex substitution. +/// +/// ## Safety interaction +/// PR 2 adds safety::check before this function. The `cat` arm is defensive for +/// when `RTK_BLOCK_TOKEN_WASTE=0`. +fn route_native_command(cmd: &analysis::NativeCommand, raw: &str) -> String { + // === ENV PREFIX STRIPPING === + // When the "binary" is actually a VAR=val env assignment (e.g. "GIT_PAGER=cat"), + // collect all leading env assigns, find the real binary in args, route it, and + // prepend the env vars so the shell sets them for the rtk subprocess. + // + // Example: "GIT_PAGER=cat git status" + // → env_prefix="GIT_PAGER=cat", real_binary="git", args=["status"] + // → route "git status" → "rtk git status" + // → result: "GIT_PAGER=cat rtk git status" + if is_env_assign(&cmd.binary) { + let mut env_parts: Vec<&str> = vec![cmd.binary.as_str()]; + let mut arg_idx = 0; + while arg_idx < cmd.args.len() && is_env_assign(&cmd.args[arg_idx]) { + env_parts.push(&cmd.args[arg_idx]); + arg_idx += 1; + } + if arg_idx < cmd.args.len() { + let env_prefix_str = env_parts.join(" "); + // Strip env prefix from raw to get core_raw, preserving original quoting. + let core_raw = raw + .strip_prefix(&env_prefix_str) + .map(|s| s.trim_start()) + .unwrap_or_else(|| { + // Fallback: count the env prefix length and skip past it + let skip = env_prefix_str.len(); + if skip < raw.len() { + raw[skip..].trim_start() + } else { + raw + } + }); + let real_binary = cmd.args[arg_idx].clone(); + let real_args = cmd.args[arg_idx + 1..].to_vec(); + let real_cmd = analysis::NativeCommand { + binary: real_binary, + args: real_args, + operator: cmd.operator.clone(), + }; + let routed = route_native_command(&real_cmd, core_raw); + return format!("{} {}", env_prefix_str, routed); + } + // All tokens are env assigns (no real command) — fall through to passthrough + } + + let sub = cmd.args.first().map(String::as_str).unwrap_or(""); + let sub2 = cmd.args.get(1).map(String::as_str).unwrap_or(""); + + // 1. Static routing table: O(1) lookup via HashMap (built once at startup). + // Covers all simple cases: direct routes and renames (rg→grep, eslint→lint). + if let Some(route) = crate::discover::registry::lookup(&cmd.binary, sub) { + return if route.rtk_cmd == cmd.binary.as_str() { + // Direct route (binary name == rtk subcommand): prepend "rtk " + format!("rtk {raw}") + } else { + // Rename route (rg → grep, eslint → lint): replace binary prefix + replace_first_word(raw, &cmd.binary, &format!("rtk {}", route.rtk_cmd)) + }; + } + + // 2. Complex cases that require Rust logic and cannot be expressed as table entries. + + // cat: blocked by safety rules before reaching here; defensive for RTK_BLOCK_TOKEN_WASTE=0 + if cmd.binary == "cat" { + return replace_first_word(raw, "cat", "rtk read"); + } + + match cmd.binary.as_str() { + // vitest: bare invocation → rtk vitest run (not rtk vitest) + "vitest" if sub.is_empty() => "rtk vitest run".to_string(), + "vitest" => format!("rtk {raw}"), + + // uv pip: two-word prefix replacement + "uv" if sub == "pip" && matches!(sub2, "list" | "outdated" | "install" | "show") => { + replace_first_word(raw, "uv pip", "rtk pip") + } + + // python/python3 -m pytest: two-arg prefix replacement + "python" | "python3" if sub == "-m" && sub2 == "pytest" => { + let prefix = format!("{} -m pytest", cmd.binary); + replace_first_word(raw, &prefix, "rtk pytest") + } + + // pnpm / npx: delegated to helpers (complex sub-routing) + "pnpm" => route_pnpm(cmd, raw), + "npx" => route_npx(cmd, raw), + + // Fallback: unknown binary or unrecognized subcommand + _ => format!("rtk run -c '{}'", escape_quotes(raw)), + } +} +/// Try to route a single command to its optimised RTK subcommand. +/// +/// Returns `Some(rtk_cmd)` when the command is natively routable (direct or renamed). +/// Returns `None` when the command would fall back to `rtk run -c '...'` passthrough — +/// the caller should keep the original `raw` string unchanged in that case. +/// +/// This avoids embedding nested `rtk run -c` calls inside an outer shell string, +/// which would require double-escaping and never improves token savings. +fn try_route_native_command(cmd: &analysis::NativeCommand, raw: &str) -> Option { + let routed = route_native_command(cmd, raw); + if routed.starts_with("rtk run -c") { + None // passthrough — keep original + } else { + Some(routed) + } +} + +/// Substitute RTK commands within a multi-command chain string. +/// +/// Iterates each command in the parsed chain. Known commands (those with an RTK +/// subcommand equivalent) are replaced with their `rtk ` form. Unknown commands +/// are kept verbatim so the shell can handle them. Operators (`&&`, `||`, `;`) are +/// preserved between commands. +/// +/// # Why this is safe +/// Only `&&`/`||`/`;` chains reach this function (pipe characters trigger `needs_shell` +/// before `parse_chain`, so pipes never appear here). Each command's stdout is +/// independent — no cross-command parsing is affected by RTK's output format changes. +/// +/// # Example +/// ```text +/// "cargo test && git log $BRANCH" +/// cmd[0]: binary="cargo" args=["test"] op=Some("&&") → "rtk cargo test" +/// cmd[1]: binary="git" args=["log","$BRANCH"] op=None → "rtk git log $BRANCH" +/// result: "rtk cargo test && rtk git log $BRANCH" +/// ``` +fn reconstruct_with_rtk(commands: &[analysis::NativeCommand]) -> String { + commands + .iter() + .map(|cmd| { + // Reconstruct the core raw string from parsed binary + args. + // Quote-stripping in parse_chain means we lose original quoting here, + // but this is acceptable for the common cases (simple args, no spaces). + let core_raw = if cmd.args.is_empty() { + cmd.binary.clone() + } else { + format!("{} {}", cmd.binary, cmd.args.join(" ")) + }; + + // Route if known; otherwise preserve the original core_raw verbatim. + let part = match try_route_native_command(cmd, &core_raw) { + Some(routed) => routed, + None => core_raw, + }; + + // Append operator if present (all but the last command have one). + match &cmd.operator { + Some(op) => format!("{} {}", part, op), + None => part, + } + }) + .collect::>() + .join(" ") +} + +/// Format hook result for Claude (text output) +/// +/// Exit codes: +/// - 0: Success, command rewritten/allowed +/// - 2: Blocking error, command should be denied +pub fn format_for_claude(result: HookResult) -> (String, bool, i32) { + match result { + HookResult::Rewrite(cmd) => (cmd, true, 0), + HookResult::Blocked(msg) => (msg, false, 2), // Exit 2 = blocking error per Claude Code spec + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // === TEST HELPERS === + + fn assert_rewrite(input: &str, contains: &str) { + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => assert!( + cmd.contains(contains), + "'{}' rewrite should contain '{}', got '{}'", + input, + contains, + cmd + ), + other => panic!("Expected Rewrite for '{}', got {:?}", input, other), + } + } + + fn assert_blocked(input: &str, contains: &str) { + match check_for_hook(input, "claude") { + HookResult::Blocked(msg) => assert!( + msg.contains(contains), + "'{}' block msg should contain '{}', got '{}'", + input, + contains, + msg + ), + other => panic!("Expected Blocked for '{}', got {:?}", input, other), + } + } + + // === ESCAPE_QUOTES === + + #[test] + fn test_escape_quotes() { + assert_eq!(escape_quotes("hello"), "hello"); + assert_eq!(escape_quotes("it's"), "it'\\''s"); + assert_eq!(escape_quotes("it's a test's"), "it'\\''s a test'\\''s"); + } + + // === EMPTY / WHITESPACE === + + #[test] + fn test_check_empty_and_whitespace() { + match check_for_hook("", "claude") { + HookResult::Rewrite(cmd) => assert!(cmd.is_empty()), + _ => panic!("Expected Rewrite for empty"), + } + match check_for_hook(" ", "claude") { + HookResult::Rewrite(cmd) => assert!(cmd.trim().is_empty()), + _ => panic!("Expected Rewrite for whitespace"), + } + } + + // === COMMANDS THAT SHOULD REWRITE (table-driven) === + + #[test] + fn test_safe_commands_rewrite() { + let cases = [ + ("git status", "rtk git status"), // now routes to optimized subcommand + ("ls *.rs", "rtk run"), // shellism passthrough (glob) + (r#"git commit -m "Fix && Bug""#, "rtk git commit"), // quoted &&: single cmd, routes + ("FOO=bar echo hello", "rtk run"), // env prefix → shellism + ("echo `date`", "rtk run"), // backticks + ("echo $(date)", "rtk run"), // subshell + ("echo {a,b}.txt", "rtk run"), // brace expansion + ("echo 'hello!@#$%^&*()'", "rtk run"), // special chars + ("echo '日本語 🎉'", "rtk run"), // unicode + ("cd /tmp && git status", "rtk run"), // chain rewrite + ]; + for (input, expected) in cases { + assert_rewrite(input, expected); + } + // Chain rewrite preserves operator structure + match check_for_hook("cd /tmp && git status", "claude") { + HookResult::Rewrite(cmd) => assert!( + cmd.contains("&&"), + "Chain rewrite must preserve '&&', got '{}'", + cmd + ), + other => panic!("Expected Rewrite for chain, got {:?}", other), + } + // Very long command + assert_rewrite(&format!("echo {}", "a".repeat(1000)), "rtk run"); + } + + // === ENV VAR PREFIX ROUTING === + // Commands prefixed with KEY=VALUE env vars must route to the optimized RTK + // subcommand with the env var preserved, not fall back to rtk run -c passthrough. + + #[test] + fn test_env_prefix_routes_to_rtk_subcommand() { + // Each case: (input, expected_rtk_subcommand_prefix, env_prefix_preserved) + let cases = [ + ("GIT_PAGER=cat git status", "rtk git", "GIT_PAGER=cat"), + ( + "GIT_PAGER=cat git log --oneline -10", + "rtk git", + "GIT_PAGER=cat", + ), + ("RUST_LOG=debug cargo test", "rtk cargo", "RUST_LOG=debug"), + ("LANG=C ls -la", "rtk ls", "LANG=C"), + ( + "TEST_SESSION_ID=2 npx playwright test --config=foo", + "rtk playwright", + "TEST_SESSION_ID=2", + ), + ]; + for (input, rtk_sub, env_prefix) in cases { + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!( + cmd.contains(rtk_sub), + "'{input}' must route to '{rtk_sub}', got '{cmd}'" + ); + assert!( + cmd.contains(env_prefix), + "'{input}' must preserve env prefix '{env_prefix}', got '{cmd}'" + ); + } + other => panic!("Expected Rewrite for '{input}', got {other:?}"), + } + } + } + + #[test] + fn test_env_prefix_multi_var_routes() { + // Multiple env vars before a known command + let input = "NODE_ENV=test CI=1 npx vitest run"; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!( + cmd.contains("rtk vitest"), + "must route to rtk vitest, got '{cmd}'" + ); + assert!( + cmd.contains("NODE_ENV=test"), + "must preserve NODE_ENV, got '{cmd}'" + ); + assert!(cmd.contains("CI=1"), "must preserve CI, got '{cmd}'"); + } + other => panic!("Expected Rewrite, got {other:?}"), + } + } + + #[test] + fn test_env_prefix_unknown_cmd_fallback() { + // Unknown command after env prefix → still wraps in rtk run -c (safe passthrough) + assert_rewrite("VAR=1 unknown_xyz_abc_cmd", "rtk run"); + } + + #[test] + fn test_env_prefix_npm_still_passthrough() { + // npm has no RTK subcommand → falls back to rtk run -c (correct, env preserved in shell) + assert_rewrite("NODE_ENV=test npm run test:e2e", "rtk run"); + } + + #[test] + fn test_env_prefix_docker_compose_passthrough() { + // docker compose up has no RTK route → falls back to rtk run -c + assert_rewrite("COMPOSE_PROJECT_NAME=test docker compose up -d", "rtk run"); + } + + // === GLOBAL OPTIONS (PR #99 parity) === + // Commands with global options before subcommands must not be blocked. + // Ported from upstream hooks/rtk-rewrite.sh global option stripping. + + #[test] + fn test_global_options_not_blocked() { + let cases = [ + // Git global options + "git --no-pager status", + "git -C /path/to/project status", + "git -C /path --no-pager log --oneline", + "git --no-optional-locks diff HEAD", + "git --bare log", + // Cargo toolchain prefix + "cargo +nightly test", + "cargo +stable build --release", + // Docker global options + "docker --context prod ps", + "docker -H tcp://host:2375 images", + // Kubectl global options + "kubectl -n kube-system get pods", + "kubectl --context prod describe pod foo", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === SPECIFIC COMMANDS NOT BLOCKED === + // Ported from old hooks/test-rtk-rewrite.sh Sections 1 & 3. + // These commands must pass through (not be blocked by safety rules). + + #[test] + fn test_specific_commands_not_blocked() { + let cases = [ + // Git variants + "git log --oneline -10", + "git diff HEAD", + "git show abc123", + "git add .", + // GitHub CLI + "gh pr list", + "gh api repos/owner/repo", + "gh release list", + // Package managers + "npm run test:e2e", + "npm run build", + "npm test", + // Docker + "docker compose up -d", + "docker compose logs postgrest", + "docker compose down", + "docker run --rm postgres", + "docker exec -it db psql", + // Kubernetes + "kubectl describe pod foo", + "kubectl apply -f deploy.yaml", + // Test runners + "npx playwright test", + "npx prisma migrate", + "cargo test", + // Vitest variants (dedup is internal to rtk run, not hook level) + "vitest", + "vitest run", + "vitest run --reporter=verbose", + "npx vitest run", + "pnpm vitest run --coverage", + // TypeScript + "vue-tsc -b", + "npx vue-tsc --noEmit", + // Utilities + "curl -s https://example.com", + "ls -la", + "grep -rn pattern src/", + "rg pattern src/", + ]; + for input in cases { + // Test name intent: commands must Rewrite (not Blocked), regardless of routing target. + // Specific routing targets are verified in test_routing_native_commands. + assert!( + matches!(check_for_hook(input, "claude"), HookResult::Rewrite(_)), + "'{}' should Rewrite (not Blocked)", + input + ); + } + } + + // === COMMANDS THAT PASS THROUGH (builtins/unknown) === + // Ported from old hooks/test-rtk-rewrite.sh Section 5. + // These are not blocked — they get wrapped in rtk run -c. + + #[test] + fn test_builtins_not_blocked() { + let cases = [ + "echo hello world", + "cd /tmp", + "mkdir -p foo/bar", + "python3 script.py", + "node -e 'console.log(1)'", + "find . -name '*.ts'", + "tree src/", + "wget https://example.com/file", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === COMPOUND COMMANDS (chained with &&, ||, ;) === + // Shell script only matched FIRST command in a chain. + // Rust hook parses each command independently (#112). + + #[test] + fn test_compound_commands_rewrite() { + let cases = [ + // Basic chains — each command rewritten independently + ("cd /tmp && git status", "&&"), + ("cd dir && git status && git diff", "&&"), + ("git add . && git commit -m msg", "&&"), + // Semicolon chains + ("echo start ; git status ; echo done", ";"), + // Or-chains + ("git pull || echo failed", "||"), + ]; + for (input, operator) in cases { + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!(cmd.contains("rtk run"), "'{input}' should rewrite"); + assert!( + cmd.contains(operator), + "'{input}' must preserve '{operator}', got '{cmd}'" + ); + } + other => panic!("Expected Rewrite for '{input}', got {other:?}"), + } + } + } + + // PR 2 adds: test_compound_blocked_in_chain (safety-dependent test) + + #[test] + fn test_compound_quoted_operators_not_split() { + // && inside quotes must NOT split the command into a chain. + // parse_chain sees one command: git commit with args ["-m", "Fix && Bug"]. + // That single command routes to rtk git commit (not rtk run -c). + let input = r#"git commit -m "Fix && Bug""#; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!( + cmd.contains("rtk git commit"), + "Quoted && must not split; should route to rtk git commit, got '{cmd}'" + ); + } + other => panic!("Expected Rewrite for quoted &&, got {other:?}"), + } + } + + // PR 2 adds: test_blocked_commands (safety-dependent test) + + // === SUFFIX-AWARE ROUTING: redirect/pipe suffix preserved, RTK filter applied === + // When a known RTK command has a "safe" redirect or pipe suffix, the hook should + // rewrite to `rtk ` so RTK's filter applies AND the shell handles the suffix. + + #[test] + fn test_suffix_2_redirect_routes_to_rtk() { + // "cargo test 2>&1" → "rtk cargo test 2>&1" (not rtk run -c) + let input = "cargo test 2>&1"; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!( + cmd.contains("rtk cargo"), + "must use rtk cargo filter, got '{cmd}'" + ); + assert!( + cmd.contains("2>&1"), + "must preserve 2>&1 suffix, got '{cmd}'" + ); + assert!( + !cmd.contains("rtk run -c"), + "must NOT fall back to passthrough, got '{cmd}'" + ); + } + other => panic!("Expected Rewrite, got {other:?}"), + } + } + + #[test] + fn test_suffix_dev_null_routes_to_rtk() { + // "cargo test 2>/dev/null" → "rtk cargo test 2>/dev/null" + let input = "cargo test 2>/dev/null"; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!(cmd.contains("rtk cargo"), "must use rtk cargo, got '{cmd}'"); + assert!( + cmd.contains("/dev/null"), + "must preserve /dev/null suffix, got '{cmd}'" + ); + assert!( + !cmd.contains("rtk run -c"), + "must NOT fall back to passthrough, got '{cmd}'" + ); + } + other => panic!("Expected Rewrite, got {other:?}"), + } + } + + #[test] + fn test_suffix_pipe_tee_routes_to_rtk() { + // "cargo test | tee /tmp/log.txt" → "rtk cargo test | tee /tmp/log.txt" + let input = "cargo test | tee /tmp/log.txt"; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!( + cmd.contains("rtk cargo"), + "must use rtk cargo filter, got '{cmd}'" + ); + assert!(cmd.contains("tee"), "must preserve tee suffix, got '{cmd}'"); + } + other => panic!("Expected Rewrite, got {other:?}"), + } + } + + #[test] + fn test_suffix_pipe_head_routes_to_rtk() { + // "git log | head -20" → "rtk git log | head -20" (not passthrough) + let input = "git log | head -20"; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!(cmd.contains("rtk git"), "must use rtk git, got '{cmd}'"); + assert!( + cmd.contains("head"), + "must preserve head suffix, got '{cmd}'" + ); + assert!( + !cmd.contains("rtk run -c"), + "must NOT fall back to passthrough, got '{cmd}'" + ); + } + other => panic!("Expected Rewrite, got {other:?}"), + } + } + + #[test] + fn test_suffix_unknown_cmd_still_passthrough() { + // Unknown command even with safe suffix → still wraps in rtk run -c + let input = "unknown_xyz_cmd 2>&1"; + assert_rewrite(input, "rtk run"); + } + + #[test] + fn test_suffix_unsafe_pipe_still_passthrough() { + // Pipe to grep (not a known safe sink) → stays as rtk run -c passthrough + // This is debatable; for safety, unknown pipe destinations stay in shell + let input = "cargo test | grep FAILED"; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + // Either passthrough or rtk routing is acceptable, but must not panic + let _ = cmd; + } + other => panic!("Expected Rewrite, got {other:?}"), + } + } + + // === SHELLISM PASSTHROUGH: cat/sed/head allowed with pipe/redirect === + + #[test] + fn test_token_waste_allowed_in_pipelines() { + let cases = [ + "cat file.txt | grep pattern", + "cat file.txt > output.txt", + "sed 's/old/new/' file.txt > output.txt", + "head -n 10 file.txt | grep pattern", + "for f in *.txt; do cat \"$f\" | grep x; done", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === MULTI-AGENT === + + #[test] + fn test_different_agents_same_result() { + // Both agents must Rewrite (not Block) safe commands. + // Specific routing targets verified in test_cross_agent_routing_identical. + for agent in ["claude", "gemini"] { + match check_for_hook("git status", agent) { + HookResult::Rewrite(_) => {} + other => panic!("Expected Rewrite for agent '{}', got {:?}", agent, other), + } + } + } + + // === FORMAT_FOR_CLAUDE === + + #[test] + fn test_format_for_claude() { + let (output, success, code) = + format_for_claude(HookResult::Rewrite("rtk run -c 'git status'".to_string())); + assert_eq!(output, "rtk run -c 'git status'"); + assert!(success); + assert_eq!(code, 0); + + let (output, success, code) = + format_for_claude(HookResult::Blocked("Error message".to_string())); + assert_eq!(output, "Error message"); + assert!(!success); + assert_eq!(code, 2); // Exit 2 = blocking error per Claude Code spec + } + + // === $VAR LEXER FIX: NATIVE ROUTING === + // After the lexer fix, simple $IDENT vars are Arg tokens, not Shellisms. + // This enables native RTK routing for commands with simple variable references. + + #[test] + fn test_dollar_var_routes_natively() { + // git log $BRANCH: $BRANCH should be Arg → routes to rtk git, not rtk run -c + let result = match check_for_hook("git log $BRANCH", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk git"), + "Expected rtk git routing for 'git log $BRANCH', got: {}", + result + ); + assert!( + !result.contains("rtk run"), + "Should not fall to passthrough for simple $VAR, got: {}", + result + ); + } + + #[test] + fn test_dollar_subshell_still_passthrough() { + // git log $(git rev-parse HEAD): $(…) needs shell — must passthrough + let result = match check_for_hook("git log $(git rev-parse HEAD)", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk run"), + "Subshell $(…) must route to passthrough, got: {}", + result + ); + } + + // === RECURSION DEPTH LIMIT === + + #[test] + fn test_rewrite_depth_limit() { + // At max depth → blocked + match check_for_hook_inner("echo hello", MAX_REWRITE_DEPTH) { + HookResult::Blocked(msg) => assert!(msg.contains("loop"), "msg: {}", msg), + _ => panic!("Expected Blocked at max depth"), + } + // At depth 0 → normal rewrite + match check_for_hook_inner("echo hello", 0) { + HookResult::Rewrite(cmd) => assert!(cmd.contains("rtk run")), + _ => panic!("Expected Rewrite at depth 0"), + } + } + + // ========================================================================= + // CLAUDE CODE WIRE FORMAT CONFORMANCE + // https://docs.anthropic.com/en/docs/claude-code/hooks + // + // Claude Code hook protocol: + // - Rewrite: command on stdout, exit code 0 + // - Block: message on stderr, exit code 2 + // - Other exit codes are non-blocking errors + // + // format_for_claude() is the boundary between HookResult and the wire. + // These tests verify it produces the exact contract Claude Code expects. + // ========================================================================= + + #[test] + fn test_claude_rewrite_exit_code_is_zero() { + let (_, _, code) = format_for_claude(HookResult::Rewrite("rtk run -c 'ls'".into())); + assert_eq!(code, 0, "Rewrite must exit 0 (success)"); + } + + #[test] + fn test_claude_block_exit_code_is_two() { + let (_, _, code) = format_for_claude(HookResult::Blocked("denied".into())); + assert_eq!( + code, 2, + "Block must exit 2 (blocking error per Claude Code spec)" + ); + } + + #[test] + fn test_claude_rewrite_output_is_command_text() { + // Claude Code reads stdout as the rewritten command — must be plain text, not JSON + let (output, success, _) = + format_for_claude(HookResult::Rewrite("rtk run -c 'git status'".into())); + assert_eq!(output, "rtk run -c 'git status'"); + assert!(success); + // Must NOT be JSON + assert!( + !output.starts_with('{'), + "Rewrite output must be plain text, not JSON" + ); + } + + #[test] + fn test_claude_block_output_is_human_message() { + // Claude Code reads stderr for the block reason + let (output, success, _) = + format_for_claude(HookResult::Blocked("Use Read tool instead".into())); + assert_eq!(output, "Use Read tool instead"); + assert!(!success); + // Must NOT be JSON + assert!( + !output.starts_with('{'), + "Block output must be plain text, not JSON" + ); + } + + #[test] + fn test_claude_rewrite_success_flag_true() { + let (_, success, _) = format_for_claude(HookResult::Rewrite("cmd".into())); + assert!(success, "Rewrite must set success=true"); + } + + #[test] + fn test_claude_block_success_flag_false() { + let (_, success, _) = format_for_claude(HookResult::Blocked("msg".into())); + assert!(!success, "Block must set success=false"); + } + + #[test] + fn test_claude_exit_codes_not_one() { + // Exit code 1 means non-blocking error in Claude Code — we must never use it + let (_, _, rewrite_code) = format_for_claude(HookResult::Rewrite("cmd".into())); + let (_, _, block_code) = format_for_claude(HookResult::Blocked("msg".into())); + assert_ne!( + rewrite_code, 1, + "Exit code 1 is non-blocking error, not valid for rewrite" + ); + assert_ne!( + block_code, 1, + "Exit code 1 is non-blocking error, not valid for block" + ); + } + + // === CROSS-PROTOCOL: Same decision for both agents === + + #[test] + fn test_cross_protocol_safe_command_allowed_by_both() { + // Both Claude and Gemini must allow the same safe commands + for cmd in ["git status", "cargo test", "ls -la", "echo hello"] { + let claude = check_for_hook(cmd, "claude"); + let gemini = check_for_hook(cmd, "gemini"); + match (&claude, &gemini) { + (HookResult::Rewrite(_), HookResult::Rewrite(_)) => {} + _ => panic!( + "'{}': Claude={:?}, Gemini={:?} — both should Rewrite", + cmd, claude, gemini + ), + } + } + } + + // PR 2 adds: test_cross_protocol_blocked_command_denied_by_both (safety-dependent test) + + // ===================================================================== + // ROUTING TESTS — verify route_native_command dispatch + // ===================================================================== + + #[test] + fn test_routing_native_commands() { + // Table-driven: commands that route to optimized rtk subcommands. + // Each (input, expected_substr) must appear in the rewritten output. + let cases = [ + // Git: known subcommands + ("git status", "rtk git status"), + ("git log --oneline -10", "rtk git log --oneline -10"), + ("git diff HEAD", "rtk git diff HEAD"), + ("git add .", "rtk git add ."), + ("git commit -m msg", "rtk git commit"), + // GitHub CLI + ("gh pr view 156", "rtk gh pr view 156"), + // Cargo + ("cargo test", "rtk cargo test"), + ( + "cargo clippy --all-targets", + "rtk cargo clippy --all-targets", + ), + // File ops (rg → rtk grep rename) + // NOTE: PR 2 adds safety that blocks cat before reaching router; arm is defensive. + ("grep -r pattern src/", "rtk grep -r pattern src/"), + ("rg pattern src/", "rtk grep pattern src/"), + ("ls -la", "rtk ls -la"), + // JS/TS tooling + ("vitest", "rtk vitest run"), // bare → rtk vitest run + ("vitest run", "rtk vitest run"), // explicit run preserved + ("vitest run --coverage", "rtk vitest run --coverage"), + ("pnpm test", "rtk vitest run"), + ("pnpm vitest", "rtk vitest run"), + ("pnpm lint", "rtk lint"), + ("pnpm eslint src/", "rtk lint"), // pnpm eslint → rtk lint (TDD Red) + ("pnpm eslint .", "rtk lint ."), // pnpm eslint bare form + ("pnpm eslint --fix src/", "rtk lint"), // pnpm eslint with flag + ("npx tsc --noEmit", "rtk tsc --noEmit"), + // Python + ("python -m pytest tests/", "rtk pytest tests/"), + ("uv pip list", "rtk pip list"), + // Go + ("go test ./...", "rtk go test ./..."), + ("go build ./...", "rtk go build ./..."), + ("go vet ./...", "rtk go vet ./..."), + // All ROUTES entries not yet covered above + ("eslint src/", "rtk lint src/"), // rename: eslint → lint + ("tsc --noEmit", "rtk tsc --noEmit"), // bare tsc (not npx tsc) + ("prettier src/", "rtk prettier src/"), + ("playwright test", "rtk playwright test"), + ("prisma migrate dev", "rtk prisma migrate dev"), + ( + "curl https://api.example.com", + "rtk curl https://api.example.com", + ), + ("pytest tests/", "rtk pytest tests/"), // bare pytest (not python -m pytest) + ("pytest -x tests/unit", "rtk pytest -x tests/unit"), + ("golangci-lint run ./...", "rtk golangci-lint run ./..."), + ("docker ps", "rtk docker ps"), + ("docker images", "rtk docker images"), + ("docker logs mycontainer", "rtk docker logs mycontainer"), + ("kubectl get pods", "rtk kubectl get pods"), + ("kubectl logs mypod", "rtk kubectl logs mypod"), + ("ruff check src/", "rtk ruff check src/"), + ("ruff format src/", "rtk ruff format src/"), + ("pip list", "rtk pip list"), + ("pip install requests", "rtk pip install requests"), + ("pip outdated", "rtk pip outdated"), + ("pip show requests", "rtk pip show requests"), + ("gh issue list", "rtk gh issue list"), + ("gh run view 123", "rtk gh run view 123"), + ("git stash pop", "rtk git stash pop"), + ("git fetch origin", "rtk git fetch origin"), + ]; + for (input, expected) in cases { + assert_rewrite(input, expected); + } + } + + #[test] + fn test_routing_subcommand_filter_fallback() { + // Commands where binary is in ROUTES but subcommand is NOT in the Only list + // must fall through to `rtk run -c '...'`. + let cases = [ + "docker build .", // docker Only: ps, images, logs + "docker run -it nginx", // docker Only: ps, images, logs + "kubectl apply -f dep.yaml", // kubectl Only: get, logs + "kubectl delete pod mypod", // kubectl Only: get, logs + "go mod tidy", // go Only: test, build, vet + "go generate ./...", // go Only: test, build, vet + "ruff lint src/", // ruff Only: check, format + "pip freeze", // pip Only: list, outdated, install, show + "pip uninstall requests", // pip Only: list, outdated, install, show + "cargo publish", // cargo Only: test, build, clippy, check + "cargo run", // cargo Only: test, build, clippy, check + "git rebase -i HEAD~3", // git Only list (rebase not included) + "git cherry-pick abc123", // git Only list + "gh repo clone foo/bar", // gh Only: pr, issue, run + ]; + for input in cases { + assert_rewrite(input, "rtk run -c"); + } + } + + #[test] + fn test_routing_vitest_no_double_run() { + // Shell script sed bug: 's/^(pnpm )?vitest/rtk vitest run/' on + // "pnpm vitest run --coverage" produces "rtk vitest run run --coverage". + // Binary hook corrects this by using parsed args instead of regex substitution. + let result = match check_for_hook("pnpm vitest run --coverage", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert_rewrite("pnpm vitest run --coverage", "rtk vitest run --coverage"); + assert!( + !result.contains("run run"), + "Must not double 'run' in output: '{}'", + result + ); + } + + #[test] + fn test_routing_fallbacks_to_rtk_run() { + // Unknown subcommand, chains (2+ cmds), and pipes fall back to rtk run -c. + let cases = [ + "git checkout main", // unknown git subcommand + "git add . && git commit -m msg", // chain → 2 commands → rtk run -c + "git log | grep fix", // pipe → needs_shell → rtk run -c + "tail -n 20 file.txt", // no rtk tail subcommand + "tail -f server.log", // no rtk tail subcommand + ]; + for input in cases { + assert_rewrite(input, "rtk run -c"); + } + } + + #[test] + fn test_cross_agent_routing_identical() { + // Both claude and gemini must route the same commands to the same output. + for cmd in ["git status", "cargo test", "ls -la"] { + let claude_result = check_for_hook(cmd, "claude"); + let gemini_result = check_for_hook(cmd, "gemini"); + match (&claude_result, &gemini_result) { + (HookResult::Rewrite(c), HookResult::Rewrite(g)) => { + assert_eq!(c, g, "claude and gemini must route '{}' identically", cmd); + assert!( + !c.contains("rtk run -c"), + "'{}' should not fall back to rtk run -c", + cmd + ); + } + _ => panic!( + "'{}' should Rewrite for both agents: claude={:?} gemini={:?}", + cmd, claude_result, gemini_result + ), + } + } + } + + // === INNER COMMAND SUBSTITUTION (&&, ||, ; chains) === + // When a multi-command chain is wrapped in "rtk run -c '...'", each individual + // command that has an RTK equivalent should be substituted so RTK's filter + // applies inside the shell string. + // + // Example: "cargo test && git log" + // Before: rtk run -c 'cargo test && git log' + // After: rtk run -c 'rtk cargo test && rtk git log' + // + // Safety invariant: only &&/||/; chains are substituted here. + // Pipe-separated commands are handled separately (split_safe_suffix / needs_shell). + + #[test] + fn test_chain_both_commands_substituted() { + // Both cargo test AND git log should route to rtk inside the shell string + let result = match check_for_hook("cargo test && git log", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk cargo"), + "cargo test must be substituted to rtk cargo inside chain: {}", + result + ); + assert!( + result.contains("rtk git"), + "git log must be substituted to rtk git inside chain: {}", + result + ); + // The outer wrapper is rtk run -c because && needs a shell + assert!( + result.contains("rtk run"), + "chain still needs shell wrapper (rtk run -c): {}", + result + ); + } + + #[test] + fn test_chain_with_dollar_var_substituted() { + // cargo test && git log $BRANCH: $BRANCH is Arg (after lexer fix) → both route natively + let result = match check_for_hook("cargo test && git log $BRANCH", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk cargo"), + "cargo test must be rtk in chain: {}", + result + ); + assert!( + result.contains("rtk git log"), + "git log $BRANCH must be rtk with var preserved: {}", + result + ); + assert!( + result.contains("$BRANCH"), + "$BRANCH must be preserved in rewritten chain: {}", + result + ); + } + + #[test] + fn test_chain_unknown_command_not_substituted() { + // unknown_xyz_cmd not in registry → stays unmodified inside the shell string + let result = match check_for_hook("cargo test && unknown_xyz_cmd", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk cargo"), + "cargo test must be substituted to rtk: {}", + result + ); + assert!( + result.contains("unknown_xyz_cmd"), + "unknown command must pass through unchanged: {}", + result + ); + assert!( + !result.contains("rtk unknown"), + "must not invent rtk subcommands for unknown binary: {}", + result + ); + } + + #[test] + fn test_semicolon_chain_substituted() { + // ; chains: each known command should be substituted + let result = match check_for_hook("cargo test ; git status", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk cargo"), + "cargo must be rtk in semicolon chain: {}", + result + ); + assert!( + result.contains("rtk git"), + "git must be rtk in semicolon chain: {}", + result + ); + } + + #[test] + fn test_or_chain_substituted() { + // || chains: each known command should be substituted + let result = match check_for_hook("cargo test || go test ./...", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert!( + result.contains("rtk cargo"), + "cargo must be rtk in || chain: {}", + result + ); + assert!( + result.contains("rtk go"), + "go must be rtk in || chain: {}", + result + ); + } + + // === PIPE OUTPUT CLASSIFICATION TESTS === + // FORMAT_PRESERVING: commands whose RTK output format matches raw output, + // making them safe as the left side of any pipe. + // TRANSPARENT_SINKS: right-side commands that consume any input format + // (already handled by split_safe_suffix for routing purposes). + // + // These classification constants document the safety policy for future + // pipe-left substitution logic and must contain the expected entries. + + #[test] + fn test_format_preserving_contains_expected() { + assert!( + FORMAT_PRESERVING.contains(&"tail"), + "tail is format-preserving (line-per-line passthrough)" + ); + assert!( + FORMAT_PRESERVING.contains(&"echo"), + "echo is format-preserving (output equals input)" + ); + assert!( + FORMAT_PRESERVING.contains(&"find"), + "find is format-preserving (path-per-line)" + ); + assert!( + FORMAT_PRESERVING.contains(&"cat"), + "cat is format-preserving (byte passthrough)" + ); + } + + #[test] + fn test_format_changing_not_in_format_preserving() { + // Commands that transform output heavily must NOT be in FORMAT_PRESERVING. + // If substituted as left side of a semantic-sink pipe (grep, jq, awk), + // the right side would receive unexpected compressed format and break. + assert!( + !FORMAT_PRESERVING.contains(&"cargo"), + "cargo test compresses output — not format-preserving" + ); + assert!( + !FORMAT_PRESERVING.contains(&"git"), + "git log/diff compresses output — not format-preserving" + ); + assert!( + !FORMAT_PRESERVING.contains(&"pytest"), + "pytest compresses output — not format-preserving" + ); + assert!( + !FORMAT_PRESERVING.contains(&"go"), + "go test compresses output — not format-preserving" + ); + } + + #[test] + fn test_transparent_sinks_contains_expected() { + // Transparent sinks accept any input format — already handled by split_safe_suffix. + assert!( + TRANSPARENT_SINKS.contains(&"tee"), + "tee is a transparent sink (copies stdin to file + stdout)" + ); + assert!( + TRANSPARENT_SINKS.contains(&"head"), + "head is a transparent sink (truncates lines)" + ); + assert!( + TRANSPARENT_SINKS.contains(&"cat"), + "cat is a transparent sink (passes through)" + ); + assert!( + TRANSPARENT_SINKS.contains(&"tail"), + "tail is a transparent sink (last N lines)" + ); + } + + // ── End-to-end token savings tests ─────────────────────────────────────── + // These tests simulate the full hook pipeline from the start: + // raw command → check_for_hook (lexer + router) → rewritten rtk cmd + // → execute both → compare token counts + // + // Run with: cargo test e2e -- --ignored + // Requires: `cargo install --path .` (rtk binary on PATH) + git repo + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + fn exec(cmd: &str) -> String { + let parts: Vec<&str> = cmd.split_whitespace().collect(); + let out = std::process::Command::new(parts[0]) + .args(&parts[1..]) + .output() + .unwrap_or_else(|e| panic!("failed to exec '{cmd}': {e}")); + String::from_utf8_lossy(&out.stdout).to_string() + } + + #[test] + #[ignore = "requires installed rtk binary (cargo install --path .) and git repo"] + fn test_e2e_git_status_saves_tokens() { + // Step 1: route through the full hook pipeline (lexer → router) + let raw_cmd = "git status"; + let rtk_cmd = match check_for_hook(raw_cmd, "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite for '{raw_cmd}', got {other:?}"), + }; + assert!( + rtk_cmd.starts_with("rtk git"), + "lexer+router should produce rtk git status, got: {rtk_cmd}" + ); + + // Step 2: execute both and compare token counts + let raw_out = exec(raw_cmd); + let rtk_out = exec(&rtk_cmd); + let raw_tok = count_tokens(&raw_out); + let rtk_tok = count_tokens(&rtk_out); + assert!(raw_tok > 0, "raw git status produced no output"); + + let savings = 100.0 * (1.0 - rtk_tok as f64 / raw_tok as f64); + assert!( + savings >= 40.0, + "rtk git status should save ≥40% tokens vs raw git status, \ + got {savings:.1}% ({raw_tok} raw → {rtk_tok} rtk tokens)" + ); + } + + #[test] + #[ignore = "requires installed rtk binary (cargo install --path .) and directory with files"] + fn test_e2e_ls_saves_tokens() { + // Step 1: route through the full hook pipeline (lexer → router) + let raw_cmd = "ls -la ."; + let rtk_cmd = match check_for_hook(raw_cmd, "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite for '{raw_cmd}', got {other:?}"), + }; + assert!( + rtk_cmd.starts_with("rtk ls"), + "lexer+router should produce rtk ls, got: {rtk_cmd}" + ); + + // Step 2: execute both and compare token counts + let raw_out = exec(raw_cmd); + let rtk_out = exec(&rtk_cmd); + let raw_tok = count_tokens(&raw_out); + let rtk_tok = count_tokens(&rtk_out); + assert!(raw_tok > 0, "raw ls -la produced no output"); + + let savings = 100.0 * (1.0 - rtk_tok as f64 / raw_tok as f64); + assert!( + savings >= 40.0, + "rtk ls should save ≥40% tokens vs raw ls -la, \ + got {savings:.1}% ({raw_tok} raw → {rtk_tok} rtk tokens)" + ); + } + + #[test] + #[ignore = "requires installed rtk binary (cargo install --path .) and git repo with history"] + fn test_e2e_git_log_saves_tokens() { + // Step 1: route through the full hook pipeline (lexer → router) + let raw_cmd = "git log --oneline -20"; + let rtk_cmd = match check_for_hook(raw_cmd, "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite for '{raw_cmd}', got {other:?}"), + }; + assert!( + rtk_cmd.starts_with("rtk git"), + "lexer+router should produce rtk git log, got: {rtk_cmd}" + ); + + // Step 2: execute both and compare token counts + let raw_out = exec(raw_cmd); + let rtk_out = exec(&rtk_cmd); + let raw_tok = count_tokens(&raw_out); + let rtk_tok = count_tokens(&rtk_out); + assert!( + raw_tok > 0, + "raw git log produced no output — need a repo with commits" + ); + + // git log --oneline is already compact; rtk may not save much beyond + // line-length capping. Truncating long lines with "..." can add a + // marginal token. Allow ≤5% overhead to account for this artefact. + let ratio = rtk_tok as f64 / raw_tok.max(1) as f64; + assert!( + ratio <= 1.05, + "rtk git log must not significantly bloat output vs raw git log \ + ({raw_tok} raw → {rtk_tok} rtk, ratio {ratio:.2})" + ); + } +} diff --git a/src/cmd/lexer.rs b/src/cmd/lexer.rs new file mode 100644 index 00000000..3147fa7d --- /dev/null +++ b/src/cmd/lexer.rs @@ -0,0 +1,674 @@ +//! State-machine lexer that respects quotes and escapes. +//! Critical: `git commit -m "Fix && Bug"` must NOT split on && + +#[derive(Debug, PartialEq, Clone)] +pub enum TokenKind { + Arg, // Regular argument + Operator, // &&, ||, ; + Pipe, // | + Redirect, // >, >>, <, 2> + Shellism, // *, ?, `, (, ), {, }, !, & — forces passthrough; $ only for complex forms +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedToken { + pub kind: TokenKind, + pub value: String, // The actual string value +} + +/// Tokenize input with quote awareness. +/// Returns Vec of parsed tokens. +pub fn tokenize(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + + let mut quote: Option = None; // None, Some('\''), Some('"') + let mut escaped = false; + + while let Some(c) = chars.next() { + // Handle escape sequences (but NOT inside single quotes) + if escaped { + current.push(c); + escaped = false; + continue; + } + if c == '\\' && quote != Some('\'') { + escaped = true; + current.push(c); + continue; + } + + // Handle quotes + if let Some(q) = quote { + if c == q { + quote = None; // Close quote + } + current.push(c); + continue; + } + if c == '\'' || c == '"' { + quote = Some(c); + current.push(c); + continue; + } + + // Outside quotes - handle operators and shellisms + match c { + // '$' handling: simple $IDENT forms become Arg tokens. + // The shell expands them when executing the rewritten "rtk cmd $VAR" — + // RTK itself never needs to expand variables. + // Complex forms ($(), ${}, $?, $$, $!, $0–$9) remain Shellism. + '$' => { + flush_arg(&mut tokens, &mut current); + // Peek at the next char: alphabetic or '_' → consume a $IDENT as Arg. + // Digits and special chars → Shellism (positional/special variables). + if chars + .peek() + .map_or(false, |&nc| nc.is_ascii_alphabetic() || nc == '_') + { + let mut name = String::from("$"); + while chars + .peek() + .map_or(false, |&nc| nc.is_ascii_alphanumeric() || nc == '_') + { + name.push(chars.next().unwrap()); + } + tokens.push(ParsedToken { + kind: TokenKind::Arg, + value: name, + }); + } else { + // $(), ${}, $?, $$, $!, $1, bare $ — all need real shell + tokens.push(ParsedToken { + kind: TokenKind::Shellism, + value: "$".to_string(), + }); + } + } + // Remaining shellisms force passthrough (includes ! for history expansion/negation) + '*' | '?' | '`' | '(' | ')' | '{' | '}' | '!' => { + flush_arg(&mut tokens, &mut current); + tokens.push(ParsedToken { + kind: TokenKind::Shellism, + value: c.to_string(), + }); + } + // Operators + '&' | '|' | ';' | '>' | '<' => { + flush_arg(&mut tokens, &mut current); + + let mut op = c.to_string(); + // Lookahead for double-char operators + if let Some(&next) = chars.peek() { + if (next == c && c != ';' && c != '<') || (c == '>' && next == '>') { + op.push(chars.next().unwrap()); + } + } + + let kind = match op.as_str() { + "&&" | "||" | ";" => TokenKind::Operator, + "|" => TokenKind::Pipe, + "&" => TokenKind::Shellism, // Background job needs real shell + _ => TokenKind::Redirect, + }; + tokens.push(ParsedToken { kind, value: op }); + } + // Whitespace delimits arguments + c if c.is_whitespace() => { + flush_arg(&mut tokens, &mut current); + } + // Regular character + _ => current.push(c), + } + } + + // Handle unclosed quote (treat remaining as arg, don't panic) + flush_arg(&mut tokens, &mut current); + tokens +} + +fn flush_arg(tokens: &mut Vec, current: &mut String) { + let trimmed = current.trim(); + if !trimmed.is_empty() { + tokens.push(ParsedToken { + kind: TokenKind::Arg, + value: trimmed.to_string(), + }); + } + current.clear(); +} + +/// Strip quotes from a token value +pub fn strip_quotes(s: &str) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() >= 2 + && ((chars[0] == '"' && chars[chars.len() - 1] == '"') + || (chars[0] == '\'' && chars[chars.len() - 1] == '\'')) + { + return chars[1..chars.len() - 1].iter().collect(); + } + s.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // === BASIC FUNCTIONALITY TESTS === + + #[test] + fn test_simple_command() { + let tokens = tokenize("git status"); + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[0].kind, TokenKind::Arg); + assert_eq!(tokens[0].value, "git"); + assert_eq!(tokens[1].value, "status"); + } + + #[test] + fn test_command_with_args() { + let tokens = tokenize("git commit -m message"); + assert_eq!(tokens.len(), 4); + assert_eq!(tokens[0].value, "git"); + assert_eq!(tokens[1].value, "commit"); + assert_eq!(tokens[2].value, "-m"); + assert_eq!(tokens[3].value, "message"); + } + + // === QUOTE HANDLING TESTS === + + #[test] + fn test_quoted_operator_not_split() { + let tokens = tokenize(r#"git commit -m "Fix && Bug""#); + // && inside quotes should NOT be an Operator token + assert!(!tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == "&&")); + assert!(tokens.iter().any(|t| t.value.contains("Fix && Bug"))); + } + + #[test] + fn test_single_quoted_string() { + let tokens = tokenize("echo 'hello world'"); + assert!(tokens.iter().any(|t| t.value == "'hello world'")); + } + + #[test] + fn test_double_quoted_string() { + let tokens = tokenize("echo \"hello world\""); + assert!(tokens.iter().any(|t| t.value == "\"hello world\"")); + } + + #[test] + fn test_empty_quoted_string() { + let tokens = tokenize("echo \"\""); + // Should have echo and "" + assert!(tokens.iter().any(|t| t.value == "\"\"")); + } + + #[test] + fn test_nested_quotes() { + let tokens = tokenize(r#"echo "outer 'inner' outer""#); + assert!(tokens.iter().any(|t| t.value.contains("'inner'"))); + } + + #[test] + fn test_strip_quotes_double() { + assert_eq!(strip_quotes("\"hello\""), "hello"); + } + + #[test] + fn test_strip_quotes_single() { + assert_eq!(strip_quotes("'hello'"), "hello"); + } + + #[test] + fn test_strip_quotes_none() { + assert_eq!(strip_quotes("hello"), "hello"); + } + + #[test] + fn test_strip_quotes_mismatched() { + assert_eq!(strip_quotes("\"hello'"), "\"hello'"); + } + + // === ESCAPE HANDLING TESTS === + + #[test] + fn test_escaped_space() { + let tokens = tokenize("echo hello\\ world"); + // Escaped space should be part of the arg + assert!(tokens.iter().any(|t| t.value.contains("hello"))); + } + + #[test] + fn test_backslash_in_single_quotes() { + // In single quotes, backslash is literal + let tokens = tokenize(r#"echo 'hello\nworld'"#); + assert!(tokens.iter().any(|t| t.value.contains(r#"\n"#))); + } + + #[test] + fn test_escaped_quote_in_double() { + let tokens = tokenize(r#"echo "hello\"world""#); + assert!(tokens.iter().any(|t| t.value.contains("hello"))); + } + + // === EDGE CASE TESTS === + + #[test] + fn test_empty_input() { + let tokens = tokenize(""); + assert!(tokens.is_empty()); + } + + #[test] + fn test_whitespace_only() { + let tokens = tokenize(" "); + assert!(tokens.is_empty()); + } + + #[test] + fn test_unclosed_single_quote() { + // Should not panic, treat remaining as part of arg + let tokens = tokenize("'unclosed"); + assert!(!tokens.is_empty()); + } + + #[test] + fn test_unclosed_double_quote() { + // Should not panic, treat remaining as part of arg + let tokens = tokenize("\"unclosed"); + assert!(!tokens.is_empty()); + } + + #[test] + fn test_unicode_preservation() { + let tokens = tokenize("echo \"héllo wörld\""); + assert!(tokens.iter().any(|t| t.value.contains("héllo"))); + } + + #[test] + fn test_multiple_spaces() { + let tokens = tokenize("git status"); + assert_eq!(tokens.len(), 2); + } + + #[test] + fn test_leading_trailing_spaces() { + let tokens = tokenize(" git status "); + assert_eq!(tokens.len(), 2); + } + + // === OPERATOR TESTS === + + #[test] + fn test_and_operator() { + let tokens = tokenize("cmd1 && cmd2"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == "&&")); + } + + #[test] + fn test_or_operator() { + let tokens = tokenize("cmd1 || cmd2"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == "||")); + } + + #[test] + fn test_semicolon() { + let tokens = tokenize("cmd1 ; cmd2"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == ";")); + } + + #[test] + fn test_multiple_and() { + let tokens = tokenize("a && b && c"); + let ops: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Operator)) + .collect(); + assert_eq!(ops.len(), 2); + } + + #[test] + fn test_mixed_operators() { + let tokens = tokenize("a && b || c"); + let ops: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Operator)) + .collect(); + assert_eq!(ops.len(), 2); + } + + #[test] + fn test_operator_at_start() { + let tokens = tokenize("&& cmd"); + // Should still parse, just with operator first + assert!(tokens.iter().any(|t| t.value == "&&")); + } + + #[test] + fn test_operator_at_end() { + let tokens = tokenize("cmd &&"); + assert!(tokens.iter().any(|t| t.value == "&&")); + } + + // === PIPE TESTS === + + #[test] + fn test_pipe_detection() { + let tokens = tokenize("cat file | grep pattern"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Pipe))); + } + + #[test] + fn test_quoted_pipe_not_pipe() { + let tokens = tokenize("\"a|b\""); + // Pipe inside quotes is not a Pipe token + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Pipe))); + } + + #[test] + fn test_multiple_pipes() { + let tokens = tokenize("a | b | c"); + let pipes: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Pipe)) + .collect(); + assert_eq!(pipes.len(), 2); + } + + // === SHELLISM TESTS === + + #[test] + fn test_glob_detection() { + let tokens = tokenize("ls *.rs"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_quoted_glob_not_shellism() { + let tokens = tokenize("echo \"*.txt\""); + // Glob inside quotes is not a Shellism token + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + // === $VAR HANDLING TESTS === + // Simple $IDENT forms are Arg (shell expands at execution time when running + // the rewritten "rtk cmd $VAR" command). Complex forms ($(), ${}, $?, $$, + // $!, $0–$9) remain Shellism and force passthrough to the real shell. + + #[test] + fn test_simple_var_is_arg() { + // $HOME after space → Arg("$HOME"), NOT Shellism. + // The shell expands it when executing the rewritten "rtk cmd $HOME" — RTK does not. + let tokens = tokenize("echo $HOME"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Arg) && t.value == "$HOME"), + "Simple $VAR must be Arg, not Shellism — shell expands at execution time" + ); + assert!( + !tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism)), + "No Shellism token expected for simple $VAR" + ); + } + + #[test] + fn test_simple_var_enables_native_routing() { + // git log $BRANCH: no Shellism → needs_shell()=false → can route to rtk git + let tokens = tokenize("git log $BRANCH"); + assert!( + !tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism)), + "git log $BRANCH must have no Shellism — simple $VAR is Arg, not Shellism" + ); + } + + #[test] + fn test_dollar_subshell_stays_shellism() { + // $() needs real shell for command substitution + let tokens = tokenize("echo $(date)"); + assert!( + tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism)), + "$(cmd) must produce Shellism — command substitution needs shell" + ); + } + + #[test] + fn test_dollar_brace_stays_shellism() { + // ${VAR} needs real shell (complex expansion / parameter substitution) + let tokens = tokenize("echo ${HOME}"); + assert!( + tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism)), + "${{VAR}} must produce Shellism — brace expansion needs shell" + ); + } + + #[test] + fn test_dollar_special_vars_stay_shellism() { + // $? $$ $! are special variables — not simple identifiers + for s in &["echo $?", "echo $$", "echo $!"] { + let tokens = tokenize(s); + assert!( + tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism)), + "{} should produce Shellism — special var needs shell", + s + ); + } + } + + #[test] + fn test_dollar_digit_stays_shellism() { + // $0–$9 are positional parameters — leave to shell + let tokens = tokenize("echo $1"); + assert!( + tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism)), + "$1 (positional parameter) must be Shellism — handled by shell" + ); + } + + #[test] + fn test_quoted_variable_not_shellism() { + let tokens = tokenize("echo \"$HOME\""); + // $ inside double quotes is NOT detected as a Shellism token + // because the lexer respects quotes + // This is correct - the variable can't be expanded by us anyway + // so the whole command will need to passthrough to shell + // But at the tokenization level, it's not a Shellism + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_backtick_substitution() { + let tokens = tokenize("echo `date`"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_subshell_detection() { + let tokens = tokenize("echo $(date)"); + // Both $ and ( should be shellisms + let shellisms: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Shellism)) + .collect(); + assert!(!shellisms.is_empty()); + } + + #[test] + fn test_brace_expansion() { + let tokens = tokenize("echo {a,b}.txt"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_escaped_glob() { + let tokens = tokenize("echo \\*.txt"); + // Escaped glob should not be a shellism + assert!(!tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "*")); + } + + // === REDIRECT TESTS === + + #[test] + fn test_redirect_out() { + let tokens = tokenize("cmd > file"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect))); + } + + #[test] + fn test_redirect_append() { + let tokens = tokenize("cmd >> file"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Redirect) && t.value == ">>")); + } + + #[test] + fn test_redirect_in() { + let tokens = tokenize("cmd < file"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect))); + } + + #[test] + fn test_redirect_stderr() { + let tokens = tokenize("cmd 2> file"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect))); + } + + #[test] + fn test_redirect_stderr_no_space() { + // "2>/dev/null" — the lexer flushes "2" as Arg then creates Redirect(">"). + // Both a Redirect token AND an Arg("2") must be present. + let tokens = tokenize("cmd 2>/dev/null"); + assert!( + tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect)), + "2>/dev/null must produce a Redirect token (needs_shell must fire)" + ); + // "2" appears as a separate Arg preceding the redirect + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Arg) && t.value == "2"), + "2 in 2>/dev/null must be a separate Arg token" + ); + } + + #[test] + fn test_redirect_dev_null() { + let tokens = tokenize("cmd > /dev/null"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Redirect) && t.value == ">"), + ">/dev/null must produce a > Redirect token" + ); + } + + #[test] + fn test_compound_fd_redirect_2_to_1_has_shellism() { + // "2>&1" tokenises as: Arg("2"), Redirect(">"), Shellism("&"), Arg("1") + // The & in the middle is what forces shell passthrough via needs_shell(). + let tokens = tokenize("cmd 2>&1"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "&"), + "& in 2>&1 must be Shellism — this is what triggers needs_shell" + ); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Redirect) && t.value == ">"), + "> in 2>&1 must be a Redirect token" + ); + // The "2" is a bare Arg before the redirect + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Arg) && t.value == "2"), + "2 in 2>&1 must be a separate Arg token" + ); + } + + #[test] + fn test_compound_fd_redirect_1_to_2_has_shellism() { + // "1>&2" — same pattern as 2>&1 + let tokens = tokenize("cmd 1>&2"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "&"), + "& in 1>&2 must be Shellism" + ); + assert!( + tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect)), + "1>&2 must produce a Redirect token" + ); + } + + #[test] + fn test_combined_redirect_chain() { + // ">/dev/null 2>&1": > Redirect, then Arg("2"), >, &(Shellism) + let tokens = tokenize("cmd > /dev/null 2>&1"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Redirect) && t.value == ">"), + "Must have > Redirect" + ); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "&"), + "Must have & Shellism from 2>&1" + ); + } + + #[test] + fn test_redirect_append_to_file() { + let tokens = tokenize("echo hello >> /tmp/output.txt"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Redirect) && t.value == ">>"), + ">> must produce a >> Redirect token" + ); + } + + // === EXCLAMATION / NEGATION TESTS === + + #[test] + fn test_exclamation_is_shellism() { + let tokens = tokenize("if ! grep -q pattern file; then echo missing; fi"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "!"), + "! (negation) must be Shellism" + ); + } + + // === BACKGROUND JOB TESTS === + + #[test] + fn test_background_job_is_shellism() { + let tokens = tokenize("sleep 10 &"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "&"), + "Single & (background job) must be Shellism, not Redirect" + ); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 00000000..e6702a80 --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,36 @@ +//! Command execution subsystem for RTK hook integration. +//! +//! This module provides the core hook engine that powers `rtk hook claude`. +//! It handles chained command rewriting, native command execution, and output filtering. + +// Analysis and lexing (no external deps) +pub(crate) mod analysis; +pub(crate) mod lexer; + +// Predicates and utilities (no external deps) +pub(crate) mod predicates; + +// Builtins (depends on predicates) +pub(crate) mod builtins; + +// Filters (depends on crate::utils) +pub(crate) mod filters; + +// Exec (depends on analysis, builtins, filters, lexer) +pub mod exec; + +// Hook logic (depends on analysis, lexer) +pub mod hook; + +// Claude hook protocol (depends on hook) +pub mod claude_hook; + +// Gemini hook protocol (depends on hook) +pub mod gemini_hook; + +#[cfg(test)] +pub(crate) mod test_helpers; + +// Public exports +pub use exec::execute; +pub use hook::check_for_hook; diff --git a/src/cmd/predicates.rs b/src/cmd/predicates.rs new file mode 100644 index 00000000..9bd5a6a9 --- /dev/null +++ b/src/cmd/predicates.rs @@ -0,0 +1,94 @@ +//! Context-aware predicates for conditional safety rules. +//! These give RTK "situational awareness" - checking git state, file existence, etc. + +use std::process::Command; + +/// Check if there are unstaged changes in the current git repo +pub(crate) fn has_unstaged_changes() -> bool { + Command::new("git") + .args(["diff", "--quiet"]) + .status() + .map(|s| !s.success()) // git diff --quiet returns 1 if changes exist + .unwrap_or(false) +} + +/// Critical for token reduction: detect if output goes to human or agent +pub(crate) fn is_interactive() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() +} + +/// Expand ~ to $HOME, with fallback +pub(crate) fn expand_tilde(path: &str) -> String { + if path.starts_with("~") { + // Try HOME first, then USERPROFILE (Windows) + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "/".to_string()); + path.replacen("~", &home, 1) + } else { + path.to_string() + } +} + +/// Get HOME directory with fallback +pub(crate) fn get_home() -> String { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "/".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + // === PATH EXPANSION TESTS === + + #[test] + fn test_expand_tilde_simple() { + let home = env::var("HOME").unwrap_or("/".to_string()); + assert_eq!(expand_tilde("~/src"), format!("{}/src", home)); + } + + #[test] + fn test_expand_tilde_no_tilde() { + assert_eq!(expand_tilde("/absolute/path"), "/absolute/path"); + } + + #[test] + fn test_expand_tilde_only_tilde() { + let home = env::var("HOME").unwrap_or("/".to_string()); + assert_eq!(expand_tilde("~"), home); + } + + #[test] + fn test_expand_tilde_relative() { + assert_eq!(expand_tilde("relative/path"), "relative/path"); + } + + // === HOME DIRECTORY TESTS === + + #[test] + fn test_get_home_returns_something() { + let home = get_home(); + assert!(!home.is_empty()); + } + + // === INTERACTIVE TESTS === + + #[test] + fn test_is_interactive() { + // This will be false when running tests + // Just ensure it doesn't panic + let _ = is_interactive(); + } + + // === GIT PREDICATE TESTS === + + #[test] + fn test_has_unstaged_changes() { + // Just ensure it doesn't panic + let _ = has_unstaged_changes(); + } +} diff --git a/src/cmd/test_helpers.rs b/src/cmd/test_helpers.rs new file mode 100644 index 00000000..06f929af --- /dev/null +++ b/src/cmd/test_helpers.rs @@ -0,0 +1,35 @@ +//! Shared test utilities for the cmd module. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +static ENV_LOCK: OnceLock> = OnceLock::new(); + +/// RAII guard that serializes env-var-mutating tests and auto-cleans on drop. +/// Prevents race conditions between parallel test threads and ensures cleanup +/// even if a test panics. +pub struct EnvGuard { + _lock: MutexGuard<'static, ()>, +} + +impl EnvGuard { + pub fn new() -> Self { + let lock = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + Self::cleanup(); + Self { _lock: lock } + } + + fn cleanup() { + std::env::remove_var("RTK_SAFE_COMMANDS"); + std::env::remove_var("RTK_BLOCK_TOKEN_WASTE"); + std::env::remove_var("RTK_ACTIVE"); + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + Self::cleanup(); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 7ef375cd..1c22c620 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1,5 +1,197 @@ use lazy_static::lazy_static; use regex::{Regex, RegexSet}; +use std::collections::HashMap; +use std::sync::OnceLock; + +// --------------------------------------------------------------------------- +// Hook routing table — used by `cmd::hook` for O(1) command rewriting. +// This is the single source of truth for which external binaries route through +// RTK and exactly which subcommands are covered. +// +// # Adding a new command +// 1. Add one `Route` entry to `ROUTES`. +// 2. Add a discover entry (PATTERNS + RULES) below if needed. +// 3. Done — hook routing is automatic. +// --------------------------------------------------------------------------- + +/// Subcommand filter for a route entry. +#[derive(Debug, Clone, Copy)] +pub enum Subcmds { + /// Route ALL subcommands of this binary (e.g., ls, curl, prettier). + Any, + /// Route ONLY these specific subcommands; others fall through to `rtk run -c`. + Only(&'static [&'static str]), +} + +/// One row in the static routing table. +/// +/// - `binaries`: one or more external binary names mapping to the same RTK subcommand. +/// - `subcmds`: subcommand filter — `Any` matches everything, `Only` restricts to a list. +/// - `rtk_cmd`: the RTK subcommand name (e.g., `"grep"`, `"lint"`, `"git"`). +/// +/// For direct routes where `binary == rtk_cmd`, the hook uses `format!("rtk {raw}")`. +/// For renames (`rg` → `grep`, `eslint` → `lint`), it uses `replace_first_word`. +#[derive(Debug, Clone, Copy)] +pub struct Route { + pub binaries: &'static [&'static str], + pub subcmds: Subcmds, + pub rtk_cmd: &'static str, +} + +/// Static routing table. Single source of truth for hook routing. +/// +/// Order does not matter — lookups use a HashMap built once at startup (O(1) per call). +/// +/// Complex cases (vitest bare invocation, `uv pip`, `python -m pytest`, pnpm, npx) +/// require Rust logic and stay as match arms in `cmd::hook::route_native_command`. +pub const ROUTES: &[Route] = &[ + // Version control + Route { + binaries: &["git"], + subcmds: Subcmds::Only(&[ + "status", "diff", "log", "add", "commit", "push", "pull", "branch", "fetch", "stash", + "show", + ]), + rtk_cmd: "git", + }, + // GitHub CLI + Route { + binaries: &["gh"], + subcmds: Subcmds::Only(&["pr", "issue", "run"]), + rtk_cmd: "gh", + }, + // Rust build tools + Route { + binaries: &["cargo"], + subcmds: Subcmds::Only(&["test", "build", "clippy", "check"]), + rtk_cmd: "cargo", + }, + // Search — two binaries, one RTK subcommand (rename) + Route { + binaries: &["rg", "grep"], + subcmds: Subcmds::Any, + rtk_cmd: "grep", + }, + // JavaScript linting — rename + Route { + binaries: &["eslint"], + subcmds: Subcmds::Any, + rtk_cmd: "lint", + }, + // File system + Route { + binaries: &["ls"], + subcmds: Subcmds::Any, + rtk_cmd: "ls", + }, + // TypeScript compiler + Route { + binaries: &["tsc"], + subcmds: Subcmds::Any, + rtk_cmd: "tsc", + }, + // JavaScript formatting + Route { + binaries: &["prettier"], + subcmds: Subcmds::Any, + rtk_cmd: "prettier", + }, + // E2E testing + Route { + binaries: &["playwright"], + subcmds: Subcmds::Any, + rtk_cmd: "playwright", + }, + // Database ORM + Route { + binaries: &["prisma"], + subcmds: Subcmds::Any, + rtk_cmd: "prisma", + }, + // Network + Route { + binaries: &["curl"], + subcmds: Subcmds::Any, + rtk_cmd: "curl", + }, + // Python testing + Route { + binaries: &["pytest"], + subcmds: Subcmds::Any, + rtk_cmd: "pytest", + }, + // Go linting + Route { + binaries: &["golangci-lint"], + subcmds: Subcmds::Any, + rtk_cmd: "golangci-lint", + }, + // Containers — read-only subcommands only + Route { + binaries: &["docker"], + subcmds: Subcmds::Only(&["ps", "images", "logs"]), + rtk_cmd: "docker", + }, + // Kubernetes — read-only subcommands only + Route { + binaries: &["kubectl"], + subcmds: Subcmds::Only(&["get", "logs"]), + rtk_cmd: "kubectl", + }, + // Go build tools + Route { + binaries: &["go"], + subcmds: Subcmds::Only(&["test", "build", "vet"]), + rtk_cmd: "go", + }, + // Python linting/formatting + Route { + binaries: &["ruff"], + subcmds: Subcmds::Only(&["check", "format"]), + rtk_cmd: "ruff", + }, + // Python package management + Route { + binaries: &["pip"], + subcmds: Subcmds::Only(&["list", "outdated", "install", "show"]), + rtk_cmd: "pip", + }, +]; + +/// Look up the routing entry for a binary + subcommand. +/// +/// Returns `Some(route)` if the binary is in the table AND the subcommand matches +/// the entry's filter. Returns `None` if unrecognised or subcommand not in `Only` list. +/// +/// The HashMap is built once per process (OnceLock). Each binary maps to the index of +/// its `Route` in `ROUTES`. Multiple binaries from the same entry (e.g., `rg`/`grep`) +/// both point to the same index. +pub fn lookup(binary: &str, sub: &str) -> Option<&'static Route> { + static MAP: OnceLock> = OnceLock::new(); + let map = MAP.get_or_init(|| { + let mut m = HashMap::new(); + for (i, route) in ROUTES.iter().enumerate() { + for &bin in route.binaries { + m.entry(bin).or_insert(i); + } + } + m + }); + + let idx = *map.get(binary)?; + let route = &ROUTES[idx]; + + let matches = match route.subcmds { + Subcmds::Any => true, + Subcmds::Only(subs) => subs.contains(&sub), + }; + + if matches { + Some(route) + } else { + None + } +} /// A rule mapping a shell command pattern to its RTK equivalent. struct RtkRule { @@ -70,6 +262,12 @@ const PATTERNS: &[&str] = &[ r"^kubectl\s+(get|logs)", r"^curl\s+", r"^wget\s+", + // Python/Go tooling (added with Python & Go support) + r"^pytest(\s|$)", + r"^go\s+(test|build|vet)(\s|$)", + r"^ruff\s+(check|format)(\s|$)", + r"^(pip|pip3)\s+(list|outdated|install|show)(\s|$)", + r"^golangci-lint(\s|$)", ]; const RULES: &[RtkRule] = &[ @@ -225,6 +423,42 @@ const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Python/Go tooling (added with Python & Go support) + RtkRule { + rtk_cmd: "rtk pytest", + category: "Tests", + savings_pct: 90.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk go", + category: "Build", + savings_pct: 85.0, + subcmd_savings: &[("test", 90.0)], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk ruff", + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk pip", + category: "PackageManager", + savings_pct: 75.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk golangci-lint", + category: "Build", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). @@ -662,6 +896,129 @@ mod tests { ); } + // --- Tests for commands added in Python/Go support (must be in both ROUTES and PATTERNS) --- + + #[test] + fn test_classify_pytest_bare() { + match classify_command("pytest tests/") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pytest") + } + other => panic!("pytest should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pytest_flags() { + match classify_command("pytest -x tests/unit") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pytest") + } + other => panic!("pytest -x should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_test() { + match classify_command("go test ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk go") + } + other => panic!("go test should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_build() { + match classify_command("go build ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk go") + } + other => panic!("go build should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_vet() { + match classify_command("go vet ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk go") + } + other => panic!("go vet should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_unsupported_subcommand_not_matched() { + // go mod tidy is not in the Only list; should not be classified as rtk go + match classify_command("go mod tidy") { + Classification::Unsupported { .. } | Classification::Ignored => {} + Classification::Supported { rtk_equivalent, .. } => { + panic!("go mod should not match, but got rtk_equivalent={rtk_equivalent}") + } + } + } + + #[test] + fn test_classify_ruff_check() { + match classify_command("ruff check src/") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk ruff") + } + other => panic!("ruff check should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_ruff_format() { + match classify_command("ruff format src/") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk ruff") + } + other => panic!("ruff format should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pip_list() { + match classify_command("pip list") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pip") + } + other => panic!("pip list should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pip_install() { + match classify_command("pip install requests") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pip") + } + other => panic!("pip install should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pip3_list() { + match classify_command("pip3 list") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pip") + } + other => panic!("pip3 list should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_golangci_lint() { + match classify_command("golangci-lint run ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk golangci-lint") + } + other => panic!("golangci-lint should be Supported, got {other:?}"), + } + } + #[test] fn test_patterns_rules_length_match() { assert_eq!( @@ -732,4 +1089,89 @@ mod tests { let cmd = "cat <<'EOF'\nhello && world\nEOF"; assert_eq!(split_command_chain(cmd), vec![cmd]); } + + // --- Route lookup tests --- + + #[test] + fn test_lookup_direct_route() { + let r = lookup("git", "status").unwrap(); + assert_eq!(r.rtk_cmd, "git"); + } + + #[test] + fn test_lookup_git_unknown_subcommand_returns_none() { + assert!(lookup("git", "rebase").is_none()); + assert!(lookup("git", "bisect").is_none()); + } + + #[test] + fn test_lookup_rename_rg_to_grep() { + let r = lookup("rg", "").unwrap(); + assert_eq!(r.rtk_cmd, "grep"); + } + + #[test] + fn test_lookup_rename_grep_to_grep() { + let r = lookup("grep", "-r").unwrap(); + assert_eq!(r.rtk_cmd, "grep"); + } + + #[test] + fn test_lookup_rename_eslint_to_lint() { + let r = lookup("eslint", "src/").unwrap(); + assert_eq!(r.rtk_cmd, "lint"); + } + + #[test] + fn test_lookup_any_subcommand() { + let r = lookup("ls", "-la").unwrap(); + assert_eq!(r.rtk_cmd, "ls"); + let r2 = lookup("ls", "").unwrap(); + assert_eq!(r2.rtk_cmd, "ls"); + } + + #[test] + fn test_lookup_unknown_binary_returns_none() { + assert!(lookup("unknownbinary99", "").is_none()); + // These stay as complex Rust match arms, not in ROUTES + assert!(lookup("vitest", "").is_none()); + assert!(lookup("pnpm", "list").is_none()); + assert!(lookup("npx", "tsc").is_none()); + assert!(lookup("uv", "pip").is_none()); + } + + #[test] + fn test_lookup_docker_subcommand_filter() { + assert!(lookup("docker", "ps").is_some()); + assert!(lookup("docker", "images").is_some()); + assert!(lookup("docker", "build").is_none()); + assert!(lookup("docker", "run").is_none()); + } + + #[test] + fn test_lookup_cargo_subcommand_filter() { + assert!(lookup("cargo", "test").is_some()); + assert!(lookup("cargo", "clippy").is_some()); + assert!(lookup("cargo", "publish").is_none()); + } + + #[test] + fn test_no_duplicate_binaries_in_routes() { + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for route in ROUTES { + for &bin in route.binaries { + assert!( + seen.insert(bin), + "Binary '{bin}' appears in multiple ROUTES entries" + ); + } + } + } + + #[test] + fn test_lookup_is_o1_consistent() { + let r1 = lookup("git", "status"); + let r2 = lookup("git", "status"); + assert_eq!(r1.map(|r| r.rtk_cmd), r2.map(|r| r.rtk_cmd)); + } } diff --git a/src/git.rs b/src/git.rs index 3709e79f..36448921 100644 --- a/src/git.rs +++ b/src/git.rs @@ -372,7 +372,7 @@ fn run_log(args: &[String], _max_lines: Option, verbose: u8) -> Result<() } /// Filter git log output: truncate long messages, cap lines -fn filter_log_output(output: &str, limit: usize) -> String { +pub(crate) fn filter_log_output(output: &str, limit: usize) -> String { let lines: Vec<&str> = output.lines().collect(); let capped: Vec = lines .iter() @@ -391,7 +391,7 @@ fn filter_log_output(output: &str, limit: usize) -> String { } /// Format porcelain output into compact RTK status display -fn format_status_output(porcelain: &str) -> String { +pub(crate) fn format_status_output(porcelain: &str) -> String { let lines: Vec<&str> = porcelain.lines().collect(); if lines.is_empty() { diff --git a/src/go_cmd.rs b/src/go_cmd.rs index 1503841c..e37541d8 100644 --- a/src/go_cmd.rs +++ b/src/go_cmd.rs @@ -1,3 +1,4 @@ +use crate::stream::{FilterMode, StdinMode, StreamFilter}; use crate::tracking; use crate::utils::truncate; use anyhow::{Context, Result}; @@ -31,6 +32,87 @@ struct PackageResult { failed_tests: Vec<(String, Vec)>, // (test_name, output_lines) } +/// Progressive streaming filter for `go test -json` NDJSON output. +/// +/// Accumulates test events line-by-line via `feed_line`, emits the full +/// summary on `flush()` when the process exits. This matches the behavior of +/// the buffered `filter_go_test_json` but works with the streaming pipeline. +pub struct GoTestStreamFilter { + packages: HashMap, + current_test_output: HashMap<(String, String), Vec>, +} + +impl GoTestStreamFilter { + pub fn new() -> Self { + Self { + packages: HashMap::new(), + current_test_output: HashMap::new(), + } + } +} + +impl Default for GoTestStreamFilter { + fn default() -> Self { + Self::new() + } +} + +impl StreamFilter for GoTestStreamFilter { + /// Parse one NDJSON line and accumulate state. Returns `None` — output is + /// deferred until `flush()` so we can produce the package summary. + fn feed_line(&mut self, line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + let event: GoTestEvent = match serde_json::from_str(trimmed) { + Ok(e) => e, + Err(_) => return None, // skip non-JSON lines + }; + + let package = event.package.unwrap_or_else(|| "unknown".to_string()); + let pkg_result = self.packages.entry(package.clone()).or_default(); + + match event.action.as_str() { + "pass" => { + if event.test.is_some() { + pkg_result.pass += 1; + } + } + "fail" => { + if let Some(test) = &event.test { + pkg_result.fail += 1; + let key = (package.clone(), test.clone()); + let outputs = self.current_test_output.remove(&key).unwrap_or_default(); + pkg_result.failed_tests.push((test.clone(), outputs)); + } + } + "skip" => { + if event.test.is_some() { + pkg_result.skip += 1; + } + } + "output" => { + if let (Some(test), Some(output_text)) = (&event.test, &event.output) { + let key = (package.clone(), test.clone()); + self.current_test_output + .entry(key) + .or_default() + .push(output_text.trim_end().to_string()); + } + } + _ => {} + } + + None + } + + fn flush(&mut self) -> String { + build_go_test_summary(&self.packages) + } +} + pub fn run_test(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -50,41 +132,29 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { eprintln!("Running: go test -json {}", args.join(" ")); } - let output = cmd - .output() - .context("Failed to run go test. Is Go installed?")?; + let filter = GoTestStreamFilter::new(); + let result = crate::stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("Failed to run go test. Is Go installed?")?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - let exit_code = output - .status - .code() - .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_go_test_json(&stdout); - - if let Some(hint) = crate::tee::tee_and_hint(&raw, "go_test", exit_code) { - println!("{}\n{}", filtered, hint); + if let Some(hint) = crate::tee::tee_and_hint(&result.raw, "go_test", result.exit_code) { + println!("{}\n{}", result.filtered, hint); } else { - println!("{}", filtered); - } - - // Include stderr if present (build errors, etc.) - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); + println!("{}", result.filtered); } timer.track( &format!("go test {}", args.join(" ")), &format!("rtk go test {}", args.join(" ")), - &raw, - &filtered, + &result.raw, + &result.filtered, ); - // Preserve exit code for CI/CD - if !output.status.success() { - std::process::exit(exit_code); + if result.exit_code != 0 { + std::process::exit(result.exit_code); } Ok(()) @@ -241,61 +311,11 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { Ok(()) } -/// Parse go test -json output (NDJSON format) -fn filter_go_test_json(output: &str) -> String { - let mut packages: HashMap = HashMap::new(); - let mut current_test_output: HashMap<(String, String), Vec> = HashMap::new(); // (package, test) -> outputs - - for line in output.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let event: GoTestEvent = match serde_json::from_str(trimmed) { - Ok(e) => e, - Err(_) => continue, // Skip non-JSON lines - }; - - let package = event.package.unwrap_or_else(|| "unknown".to_string()); - let pkg_result = packages.entry(package.clone()).or_default(); - - match event.action.as_str() { - "pass" => { - if event.test.is_some() { - pkg_result.pass += 1; - } - } - "fail" => { - if let Some(test) = &event.test { - pkg_result.fail += 1; - - // Collect output for failed test - let key = (package.clone(), test.clone()); - let outputs = current_test_output.remove(&key).unwrap_or_default(); - pkg_result.failed_tests.push((test.clone(), outputs)); - } - } - "skip" => { - if event.test.is_some() { - pkg_result.skip += 1; - } - } - "output" => { - // Collect output for current test - if let (Some(test), Some(output_text)) = (&event.test, &event.output) { - let key = (package.clone(), test.clone()); - current_test_output - .entry(key) - .or_default() - .push(output_text.trim_end().to_string()); - } - } - _ => {} // run, pause, cont, etc. - } - } - - // Build summary +/// Build the human-readable summary from accumulated package results. +/// +/// Shared by both `filter_go_test_json` (buffered) and `GoTestStreamFilter` +/// (streaming) to ensure identical output format. +fn build_go_test_summary(packages: &HashMap) -> String { let total_packages = packages.len(); let total_pass: usize = packages.values().map(|p| p.pass).sum(); let total_fail: usize = packages.values().map(|p| p.fail).sum(); @@ -365,8 +385,21 @@ fn filter_go_test_json(output: &str) -> String { result.trim().to_string() } +/// Parse go test -json output (NDJSON format). +/// +/// Buffered variant — for use when input is already fully accumulated (e.g. +/// `rtk pipe --filter go-test`). For live subprocess output, prefer +/// `GoTestStreamFilter` with `run_streaming`. +pub(crate) fn filter_go_test_json(output: &str) -> String { + let mut filter = GoTestStreamFilter::new(); + for line in output.lines() { + filter.feed_line(line); + } + filter.flush() +} + /// Filter go build output - show only errors -fn filter_go_build(output: &str) -> String { +pub(crate) fn filter_go_build(output: &str) -> String { let mut errors: Vec = Vec::new(); for line in output.lines() { @@ -526,4 +559,89 @@ utils.go:15:5: unreachable code"#; assert_eq!(compact_package_name("example.com/foo"), "foo"); assert_eq!(compact_package_name("simple"), "simple"); } + + // ── GoTestStreamFilter tests ─────────────────────────────────────────────── + + const ALL_PASS_NDJSON: &str = r#"{"Time":"2024-01-01T10:00:00Z","Action":"run","Package":"example.com/foo","Test":"TestBar"} +{"Time":"2024-01-01T10:00:01Z","Action":"output","Package":"example.com/foo","Test":"TestBar","Output":"=== RUN TestBar\n"} +{"Time":"2024-01-01T10:00:02Z","Action":"pass","Package":"example.com/foo","Test":"TestBar","Elapsed":0.5} +{"Time":"2024-01-01T10:00:02Z","Action":"pass","Package":"example.com/foo","Elapsed":0.5}"#; + + const WITH_FAILURE_NDJSON: &str = r#"{"Time":"2024-01-01T10:00:00Z","Action":"run","Package":"example.com/foo","Test":"TestFail"} +{"Time":"2024-01-01T10:00:01Z","Action":"output","Package":"example.com/foo","Test":"TestFail","Output":"=== RUN TestFail\n"} +{"Time":"2024-01-01T10:00:02Z","Action":"output","Package":"example.com/foo","Test":"TestFail","Output":" Error: expected 5, got 3\n"} +{"Time":"2024-01-01T10:00:03Z","Action":"fail","Package":"example.com/foo","Test":"TestFail","Elapsed":0.5} +{"Time":"2024-01-01T10:00:03Z","Action":"fail","Package":"example.com/foo","Elapsed":0.5}"#; + + #[test] + fn test_go_test_stream_filter_feed_and_flush_all_pass() { + let mut f = GoTestStreamFilter::new(); + // feed_line returns None for all NDJSON events (output deferred to flush) + for line in ALL_PASS_NDJSON.lines() { + assert_eq!( + f.feed_line(line), + None, + "streaming filter must defer output" + ); + } + let output = f.flush(); + assert!(output.contains("✓ Go test"), "output={}", output); + assert!(output.contains("1 passed"), "output={}", output); + } + + #[test] + fn test_go_test_stream_filter_feed_and_flush_with_failure() { + let mut f = GoTestStreamFilter::new(); + for line in WITH_FAILURE_NDJSON.lines() { + f.feed_line(line); + } + let output = f.flush(); + assert!(output.contains("1 failed"), "output={}", output); + assert!(output.contains("TestFail"), "output={}", output); + assert!(output.contains("expected 5, got 3"), "output={}", output); + } + + #[test] + fn test_go_test_stream_filter_matches_buffered() { + // Streaming result must be identical to buffered result for same input. + let buffered = filter_go_test_json(ALL_PASS_NDJSON); + let mut f = GoTestStreamFilter::new(); + for line in ALL_PASS_NDJSON.lines() { + f.feed_line(line); + } + let streamed = f.flush(); + assert_eq!(streamed.trim(), buffered.trim()); + } + + #[test] + fn test_go_test_stream_filter_matches_buffered_with_failures() { + let buffered = filter_go_test_json(WITH_FAILURE_NDJSON); + let mut f = GoTestStreamFilter::new(); + for line in WITH_FAILURE_NDJSON.lines() { + f.feed_line(line); + } + let streamed = f.flush(); + assert_eq!(streamed.trim(), buffered.trim()); + } + + #[test] + fn test_go_test_stream_filter_skips_non_json() { + let mut f = GoTestStreamFilter::new(); + // Non-JSON lines (e.g. build noise) must not panic + f.feed_line("not json at all"); + f.feed_line(""); + f.feed_line(" "); + let output = f.flush(); + assert!(output.contains("No tests found"), "output={}", output); + } + + #[test] + fn test_go_test_stream_filter_default_equals_new() { + let f1 = GoTestStreamFilter::new(); + let f2 = GoTestStreamFilter::default(); + // Both start empty — verify by flushing immediately + let mut f1 = f1; + let mut f2 = f2; + assert_eq!(f1.flush(), f2.flush()); + } } diff --git a/src/grep_cmd.rs b/src/grep_cmd.rs index 03c5a850..148ba287 100644 --- a/src/grep_cmd.rs +++ b/src/grep_cmd.rs @@ -20,21 +20,14 @@ pub fn run( eprintln!("grep: '{}' in {}", pattern, path); } - // Fix: convert BRE alternation \| → | for rg (which uses PCRE-style regex) - let rg_pattern = pattern.replace(r"\|", "|"); - let mut rg_cmd = Command::new("rg"); - rg_cmd.args(["-n", "--no-heading", &rg_pattern, path]); + rg_cmd.args(["-n", "--no-heading", pattern, path]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); } for arg in extra_args { - // Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace) - if arg == "-r" || arg == "--recursive" { - continue; - } rg_cmd.arg(arg); } @@ -44,18 +37,10 @@ pub fn run( .context("grep/rg failed")?; let stdout = String::from_utf8_lossy(&output.stdout); - let exit_code = output.status.code().unwrap_or(1); let raw_output = stdout.to_string(); if stdout.trim().is_empty() { - // Show stderr for errors (bad regex, missing file, etc.) - if exit_code == 2 { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); - } - } let msg = format!("🔍 0 for '{}'", pattern); println!("{}", msg); timer.track( @@ -64,9 +49,6 @@ pub fn run( &raw_output, &msg, ); - if exit_code != 0 { - std::process::exit(exit_code); - } return Ok(()); } @@ -132,11 +114,171 @@ pub fn run( &rtk_output, ); - if exit_code != 0 { - std::process::exit(exit_code); + Ok(()) +} + +/// Filter raw rg/grep output into compact grouped display. +/// +/// Handles two formats: +/// - Three-part `file:line_num:content` — produced by `rg -n` or `grep -n` +/// - Two-part `file:content` — produced by `grep` without `-n` or BSD grep +/// +/// Suitable for `rtk pipe --filter grep` / `rtk pipe --filter rg`. +/// Uses defaults: max_line_len=80, max_results=50. +pub(crate) fn filter_grep_raw(input: &str) -> String { + if input.trim().is_empty() { + return "🔍 0 matches\n".to_string(); } - Ok(()) + let mut by_file: HashMap> = HashMap::new(); + let mut total = 0; + const MAX_RESULTS: usize = 50; + const MAX_LINE_LEN: usize = 80; + + for line in input.lines() { + if line.trim().is_empty() { + continue; + } + let parts: Vec<&str> = line.splitn(3, ':').collect(); + // Three-part: file:line_num:content (rg -n / grep -n) + // Two-part: file:content (grep without -n) + let (file, line_num, content) = if parts.len() == 3 { + if let Ok(ln) = parts[1].parse::() { + // Confirmed three-part with numeric line number + (parts[0].to_string(), ln, parts[2]) + } else { + // parts[1] is not a number; treat as two-part with ':' in content + let content = &line[parts[0].len() + 1..]; // everything after first ':' + (parts[0].to_string(), 0, content) + } + } else if parts.len() == 2 { + // Two-part: file:content + (parts[0].to_string(), 0, parts[1]) + } else { + continue; + }; + + total += 1; + let cleaned = clean_line(content, MAX_LINE_LEN, false, ""); + by_file.entry(file).or_default().push((line_num, cleaned)); + } + + if total == 0 { + return "🔍 0 matches\n".to_string(); + } + + let mut out = String::new(); + out.push_str(&format!("🔍 {} in {}F:\n\n", total, by_file.len())); + + let mut shown = 0; + let mut files: Vec<_> = by_file.iter().collect(); + files.sort_by_key(|(f, _)| *f); + + for (file, matches) in files { + if shown >= MAX_RESULTS { + break; + } + let file_display = compact_path(file); + out.push_str(&format!("📄 {} ({}):\n", file_display, matches.len())); + for (line_num, content) in matches.iter().take(10) { + out.push_str(&format!(" {:>4}: {}\n", line_num, content)); + shown += 1; + if shown >= MAX_RESULTS { + break; + } + } + if matches.len() > 10 { + out.push_str(&format!(" +{}\n", matches.len() - 10)); + } + out.push('\n'); + } + + if total > shown { + out.push_str(&format!("... +{}\n", total - shown)); + } + + out +} + +/// Filter `find`/`fd` output (one path per line) into a compact summary. +/// +/// Groups results by parent directory and counts files by extension. +/// Suitable for `rtk pipe --filter find` and `rtk pipe --filter fd`. +pub(crate) fn filter_find_output(input: &str) -> String { + if input.trim().is_empty() { + return "find: 0 results\n".to_string(); + } + + let mut by_dir: HashMap = HashMap::new(); + let mut by_ext: HashMap = HashMap::new(); + let mut total = 0usize; + + for line in input.lines() { + let path = line.trim(); + if path.is_empty() { + continue; + } + total += 1; + + // Parent directory + let dir = if let Some(pos) = path.rfind('/') { + &path[..pos] + } else { + "." + }; + *by_dir.entry(dir.to_string()).or_insert(0) += 1; + + // Extension + let ext = if let Some(pos) = path.rfind('.') { + let candidate = &path[pos + 1..]; + // Only treat as extension if no '/' after the dot (i.e. it's in the filename) + if candidate.contains('/') { + "(no ext)" + } else { + candidate + } + } else { + "(no ext)" + }; + *by_ext.entry(ext.to_string()).or_insert(0) += 1; + } + + if total == 0 { + return "find: 0 results\n".to_string(); + } + + let mut out = String::new(); + out.push_str(&format!("find: {} files\n", total)); + + // Top directories (up to 10) + let mut dirs: Vec<_> = by_dir.iter().collect(); + dirs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + out.push_str("Dirs:\n"); + for (dir, count) in dirs.iter().take(10) { + out.push_str(&format!(" {} ({})\n", dir, count)); + } + if dirs.len() > 10 { + out.push_str(&format!(" ... +{} more dirs\n", dirs.len() - 10)); + } + + // Extension breakdown (up to 8) + let mut exts: Vec<_> = by_ext.iter().collect(); + exts.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + if !exts.is_empty() { + out.push_str("Types: "); + let ext_parts: Vec = exts + .iter() + .take(8) + .map(|(ext, count)| format!(".{} ({})", ext, count)) + .collect(); + out.push_str(&ext_parts.join(", ")); + if exts.len() > 8 { + out.push_str(&format!(", +{} more", exts.len() - 8)); + } + out.push('\n'); + } + + out } fn clean_line(line: &str, max_len: usize, context_only: bool, pattern: &str) -> String { @@ -248,41 +390,131 @@ mod tests { assert!(!cleaned.is_empty()); } - // Fix: BRE \| alternation is translated to PCRE | for rg + // ── filter_grep_raw: 3-part format (rg -n / grep -n) ───────────────────── + #[test] - fn test_bre_alternation_translated() { - let pattern = r"fn foo\|pub.*bar"; - let rg_pattern = pattern.replace(r"\|", "|"); - assert_eq!(rg_pattern, "fn foo|pub.*bar"); + fn test_filter_grep_raw_three_part() { + let input = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub fn helper() {}\n"; + let out = filter_grep_raw(input); + assert!(out.contains("main.rs"), "out={}", out); + assert!(out.contains("lib.rs"), "out={}", out); + assert!(out.contains("2 in"), "expected 2 matches: out={}", out); } - // Fix: -r flag (grep recursive) is stripped from extra_args (rg is recursive by default) #[test] - fn test_recursive_flag_stripped() { - let extra_args: Vec = vec!["-r".to_string(), "-i".to_string()]; - let filtered: Vec<&String> = extra_args - .iter() - .filter(|a| *a != "-r" && *a != "--recursive") - .collect(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0], "-i"); + fn test_filter_grep_raw_empty() { + let out = filter_grep_raw(""); + assert!(out.contains("0 matches"), "out={}", out); + } + + #[test] + fn test_filter_grep_raw_whitespace_only() { + let out = filter_grep_raw(" \n\t\n"); + assert!(out.contains("0 matches"), "out={}", out); + } + + // ── filter_grep_raw: 2-part format (grep without -n) ───────────────────── + + #[test] + fn test_filter_grep_raw_two_part_no_line_number() { + // grep without -n produces "file:content" (no line number) + let input = "src/main.rs:fn main() {\nsrc/lib.rs:pub fn helper() {}\n"; + let out = filter_grep_raw(input); + assert!(out.contains("main.rs"), "2-part: out={}", out); + assert!(out.contains("lib.rs"), "2-part: out={}", out); + assert!( + out.contains("2 in"), + "2-part: expected 2 matches: out={}", + out + ); + } + + #[test] + fn test_filter_grep_raw_two_part_content_with_colon() { + // Two-part where content itself contains ':' (e.g. URL or time) + let input = "config.yaml:server: http://localhost:8080\n"; + let out = filter_grep_raw(input); + // Should not panic and should show config.yaml + assert!(out.contains("config.yaml"), "out={}", out); + } + + #[test] + fn test_filter_grep_raw_mixed_two_and_three_part() { + // Some lines have line numbers, some don't — both should be counted + let input = "src/a.rs:10:fn foo() {}\nsrc/b.rs:fn bar() {}\n"; + let out = filter_grep_raw(input); + assert!(out.contains("a.rs"), "out={}", out); + assert!(out.contains("b.rs"), "out={}", out); + } + + #[test] + fn test_filter_grep_raw_three_part_nonnumeric_middle() { + // Three-part split but middle is not a number (e.g. Windows path C:\file:content) + // Should fall back gracefully — either include or skip, but not panic + let input = "C:\\path\\file.rs:some content\n"; + let out = filter_grep_raw(input); // must not panic + assert!(!out.is_empty()); + } + + // ── filter_find_output ──────────────────────────────────────────────────── + + #[test] + fn test_filter_find_output_empty() { + let out = filter_find_output(""); + assert!(out.contains("0 results"), "out={}", out); } - // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). - // The -n/--line-numbers clap flag in main.rs is a no-op accepted for compat. #[test] - 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"); - 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() { - assert!( - output.status.code() == Some(1) || output.status.success(), - "rg -n should be accepted" - ); + fn test_filter_find_output_basic() { + let input = "./src/main.rs\n./src/lib.rs\n./src/cmd/mod.rs\n"; + let out = filter_find_output(input); + assert!(out.contains("3 files"), "out={}", out); + // Extension breakdown + assert!(out.contains(".rs"), "out={}", out); + } + + #[test] + fn test_filter_find_output_groups_by_dir() { + let input = "./src/a.rs\n./src/b.rs\n./tests/c.rs\n"; + let out = filter_find_output(input); + assert!(out.contains("./src"), "out={}", out); + assert!(out.contains("./tests"), "out={}", out); + } + + #[test] + fn test_filter_find_output_extension_counts() { + let input = "./a.rs\n./b.rs\n./c.toml\n./d.md\n"; + let out = filter_find_output(input); + // .rs appears twice, toml and md once each + assert!(out.contains(".rs (2)"), "out={}", out); + assert!( + out.contains(".toml (1)") || out.contains(".md (1)"), + "out={}", + out + ); + } + + #[test] + fn test_filter_find_output_no_extension() { + let input = "./Makefile\n./Dockerfile\n"; + let out = filter_find_output(input); + assert!(out.contains("2 files"), "out={}", out); + assert!(out.contains("(no ext)"), "out={}", out); + } + + #[test] + fn test_filter_find_output_many_dirs_truncated() { + // More than 10 unique dirs — should show "+N more dirs" + let mut input = String::new(); + for i in 0..15 { + input.push_str(&format!("./dir{}/file.rs\n", i)); } - // If rg is not installed, skip gracefully (test still passes) + let out = filter_find_output(&input); + assert!(out.contains("15 files"), "out={}", out); + assert!( + out.contains("more dirs"), + "should truncate dirs: out={}", + out + ); } } diff --git a/src/init.rs b/src/init.rs index 961e4ac3..04d1ddb4 100644 --- a/src/init.rs +++ b/src/init.rs @@ -471,9 +471,10 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join("settings.json"); - let hook_command = hook_path - .to_str() - .context("Hook path contains invalid UTF-8")?; + // Use binary command instead of .sh file path for PR 1 v2 + // The rtk hook claude command is a compiled Rust binary + let hook_command = "rtk hook claude"; + let _ = hook_path; // Suppress unused parameter warning (still passed for API compatibility) // Read or create settings.json let mut root = if settings_path.exists() { @@ -1093,6 +1094,412 @@ pub fn show_config() -> Result<()> { Ok(()) } +// ============================================================================ +// GEMINI CLI INTEGRATION +// ============================================================================ + +/// Resolve ~/.gemini directory with proper home expansion +fn resolve_gemini_dir() -> Result { + dirs::home_dir() + .map(|h| h.join(".gemini")) + .context("Cannot determine home directory. Is $HOME set?") +} + +/// Shared: patch an instruction file (CLAUDE.md or GEMINI.md) to add @RTK.md reference. +/// Migrates old RTK block if present. Returns true if migration occurred. +fn patch_instruction_file(path: &Path, file_label: &str, verbose: u8) -> Result { + let mut content = if path.exists() { + fs::read_to_string(path)? + } else { + String::new() + }; + + let mut migrated = false; + + // Check for old block and migrate + if content.contains("