diff --git a/Cargo.lock b/Cargo.lock index 29a678cc..1737fa36 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", @@ -371,7 +399,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -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" @@ -491,6 +533,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -509,12 +576,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pkg-config" 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 +609,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 +635,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 +647,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 +658,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" @@ -594,10 +677,13 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_yaml", "tempfile", "thiserror", "toml", + "trash", "walkdir", + "which", ] [[package]] @@ -616,9 +702,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", @@ -633,6 +719,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -642,6 +734,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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" @@ -695,6 +799,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -715,9 +832,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 +843,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", @@ -798,11 +915,47 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "trash" +version = "5.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf8parse" @@ -847,11 +1000,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 +1024,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 +1034,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 +1047,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" @@ -901,19 +1109,52 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -925,6 +1166,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -942,6 +1194,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1117,26 +1378,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 +1494,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..6ad545e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,15 @@ regex = "1" lazy_static = "1.4" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } +serde_yaml = "0.9" colored = "2" +trash = "5" dirs = "5" rusqlite = { version = "0.31", features = ["bundled"] } toml = "0.8" chrono = "0.4" thiserror = "1.0" +which = "7" tempfile = "3" [dev-dependencies] 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..5253f038 --- /dev/null +++ b/src/cmd/exec.rs @@ -0,0 +1,436 @@ +//! 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, safety, trash_cmd}; +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 { + // === STEP 0: Remap expansion (aliases like "t" → "cargo test") === + if let Some(expanded) = crate::config::rules::try_remap(raw) { + if verbose > 0 { + eprintln!( + "rtk remap: {} → {}", + raw.split_whitespace().next().unwrap_or(raw), + expanded + ); + } + return execute_inner(&expanded, verbose); + } + + let tokens = lexer::tokenize(raw); + + // === STEP 1: Decide Native vs Passthrough === + if analysis::needs_shell(&tokens) { + // Even in passthrough, check safety on raw string + if let safety::SafetyResult::Blocked(msg) = safety::check_raw(raw) { + eprintln!("{}", msg); + return Ok(2); + } + 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) + + // === SAFETY CHECK === + match safety::check(&cmd.binary, &cmd.args) { + safety::SafetyResult::Blocked(msg) => { + eprintln!("{}", msg); + return Ok(2); + } + safety::SafetyResult::Rewritten(new_cmd) => { + // Re-execute the rewritten command + if verbose > 0 { + eprintln!("rtk safety: Rewrote command"); + } + return execute(&new_cmd, verbose); + } + safety::SafetyResult::TrashRequested(paths) => { + let ok = trash_cmd::execute(&paths)?; + last_exit = if ok { 0 } else { 1 }; + prev_operator = cmd.operator.as_deref(); + continue; + } + safety::SafetyResult::Safe => {} + } + + // === 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/hook.rs b/src/cmd/hook.rs new file mode 100644 index 00000000..69043d27 --- /dev/null +++ b/src/cmd/hook.rs @@ -0,0 +1,1682 @@ +//! Hook protocol for Claude Code and Gemini support. +//! +//! This module provides **shared decision logic** for both Claude Code and Gemini CLI hooks. +//! Protocol-specific I/O handling lives in `claude_hook.rs` and `gemini_hook.rs`. +//! +//! ## Architecture: Separation of Concerns +//! +//! ```text +//! main.rs (CAN use println! - normal RTK behavior) +//! ↓ +//! Commands::Hook match +//! ├─→ HookCommands::Check → hook::check_for_hook() (THIS MODULE - CAN use println!) +//! ├─→ HookCommands::Claude → claude_hook::run() [DENY ENFORCED - see claude_hook.rs:52] +//! └─→ HookCommands::Gemini → gemini_hook::run() [DENY ENFORCED - see gemini_hook.rs:42] +//! ``` +//! +//! **I/O Policy Scope:** +//! - **This module (hook.rs)**: CAN use `println!`/`eprintln!` (used by `rtk hook check` text protocol) +//! - **main.rs and all command modules**: CAN use `println!`/`eprintln!` (normal RTK behavior) +//! - **claude_hook.rs, gemini_hook.rs ONLY**: CANNOT use `println!`/`eprintln!` (JSON protocols) +//! +//! The `#![deny(clippy::print_stdout, clippy::print_stderr)]` attribute is applied +//! at the **module boundary** (earliest possible stage) — when control enters +//! `claude_hook::run()` or `gemini_hook::run()`, the deny is enforced. +//! +//! ## Protocol Differences +//! +//! **Claude Code** (`rtk hook check` text protocol): +//! - Success: rewritten command on stdout, exit 0 +//! - Blocked: error message on stderr, exit 2 (blocking error) +//! - Other exit codes: non-blocking errors +//! +//! **Claude Code** (JSON protocol via `claude_hook.rs`): +//! - See `claude_hook.rs` module documentation +//! +//! **Gemini CLI** (JSON protocol via `gemini_hook.rs`): +//! - See `gemini_hook.rs` module documentation + +use super::{analysis, lexer, safety}; + +/// Hook check result +#[derive(Debug, Clone)] +pub enum HookResult { + /// Command is safe, rewrite to this + Rewrite(String), + /// Command is blocked with this message + Blocked(String), +} + +/// Maximum rewrite depth to prevent infinite recursion from cyclic safety rules. +const MAX_REWRITE_DEPTH: usize = 3; + +/// Check a command for the hook protocol. +/// Returns the rewritten command or an error message. +/// +/// The `_agent` parameter is reserved for future per-agent behavior. +pub fn check_for_hook(raw: &str, _agent: &str) -> 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( + "Safety rewrite loop detected (max depth exceeded)".to_string(), + ); + } + + // Handle empty + if raw.trim().is_empty() { + return HookResult::Rewrite(raw.to_string()); + } + + // Remap expansion (aliases like "t" → "cargo test") + if let Some(expanded) = crate::config::rules::try_remap(raw) { + return check_for_hook_inner(&expanded, depth + 1); + } + + 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. + // Still check safety before passing through. + match safety::check_raw(raw) { + safety::SafetyResult::Blocked(msg) => return HookResult::Blocked(msg), + safety::SafetyResult::Safe => {} + safety::SafetyResult::Rewritten(_) | safety::SafetyResult::TrashRequested(_) => {} + } + return HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))); + } + + // Native mode: parse and check each command + match analysis::parse_chain(core_tokens) { + Ok(commands) => { + // Check safety on each command + for cmd in &commands { + match safety::check(&cmd.binary, &cmd.args) { + safety::SafetyResult::Blocked(msg) => { + return HookResult::Blocked(msg); + } + safety::SafetyResult::Rewritten(new_cmd) => { + return check_for_hook_inner(&new_cmd, depth + 1); + } + safety::SafetyResult::TrashRequested(_) => { + // Redirect to rtk run which handles trash + return HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))); + } + safety::SafetyResult::Safe => {} + } + } + + // 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(_) => { + // Parse error - passthrough with wrapping + 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:?}"), + } + } + } + + #[test] + fn test_compound_blocked_in_chain() { + // Safety rules catch dangerous commands even mid-chain + let cases = [ + ("cd /tmp && cat file.txt", "file-reading"), + ("echo start && sed -i 's/x/y/' f", "file-editing"), + ("git add . && head -5 f.txt", "file-reading"), + ]; + for (input, expected_msg) in cases { + assert_blocked(input, expected_msg); + } + } + + #[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:?}"), + } + } + + // === COMMANDS THAT SHOULD BLOCK (table-driven) === + + #[test] + fn test_blocked_commands() { + let cases = [ + ("cat file.txt", "file-reading"), + ("sed -i 's/old/new/' file.txt", "file-editing"), + ("head -n 10 file.txt", "file-reading"), + ("cd /tmp && cat file.txt", "file-reading"), // cat in chain + ]; + for (input, expected_msg) in cases { + assert_blocked(input, expected_msg); + } + } + + // === 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 + ), + } + } + } + + #[test] + fn test_cross_protocol_blocked_command_denied_by_both() { + // Both Claude and Gemini must block the same unsafe commands + for cmd in ["cat file.txt", "head -n 10 file.txt"] { + let claude = check_for_hook(cmd, "claude"); + let gemini = check_for_hook(cmd, "gemini"); + match (&claude, &gemini) { + (HookResult::Blocked(_), HookResult::Blocked(_)) => {} + _ => panic!( + "'{}': Claude={:?}, Gemini={:?} — both should Block", + cmd, claude, gemini + ), + } + } + } + + // ===================================================================== + // 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..60ace484 --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,39 @@ +//! 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; + +// Safety engine (depends on config::rules) +pub(crate) mod safety; + +// Trash command (depends on trash crate) +pub(crate) mod trash_cmd; + +// 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; + +#[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/safety.rs b/src/cmd/safety.rs new file mode 100644 index 00000000..21ea981f --- /dev/null +++ b/src/cmd/safety.rs @@ -0,0 +1,538 @@ +//! Safety Policy Engine — unified rule-based implementation. +//! +//! All safety rules, remaps, and blocking rules are loaded from the unified +//! Rule system (`config::rules`). Rules are MD files with YAML frontmatter, +//! loaded from built-in defaults and user directories. + +use crate::config::rules::{self, Rule}; + +use super::predicates; + +/// Result of safety check +#[derive(Clone, Debug, PartialEq)] +pub enum SafetyResult { + /// Command is safe to execute as-is + Safe, + /// Command is blocked with error message + Blocked(String), + /// Command was rewritten to a new command string + Rewritten(String), + /// Request to move files to trash (built-in) + TrashRequested(Vec), +} + +/// Dispatch a matched rule into a SafetyResult. +fn dispatch(rule: &Rule, args: &str) -> SafetyResult { + match rule.action.as_str() { + "trash" => { + let paths: Vec = args + .split_whitespace() + .filter(|a| !a.starts_with('-')) + .map(String::from) + .collect(); + SafetyResult::TrashRequested(paths) + } + "rewrite" => { + let redirect = rule.redirect.as_deref().unwrap_or(args); + SafetyResult::Rewritten(redirect.replace("{args}", args)) + } + "suggest_tool" | "block" => { + // Use interactive-aware message (human vs agent) + let msg = if predicates::is_interactive() { + // For suggest_tool, human message references the tool name + if rule.action == "suggest_tool" { + // First line of message is typically the human-friendly version + rule.message + .lines() + .next() + .unwrap_or(&rule.message) + .to_string() + } else { + rule.message.clone() + } + } else { + // Agent: use the full message (contains BLOCK: prefix) + rule.message.clone() + }; + SafetyResult::Blocked(msg) + } + "warn" => { + eprintln!("{}", rule.message); + SafetyResult::Safe + } + _ => SafetyResult::Safe, + } +} + +/// Check a parsed command against all safety rules. +pub fn check(binary: &str, args: &[String]) -> SafetyResult { + let full_cmd = if args.is_empty() { + binary.to_string() + } else { + format!("{} {}", binary, args.join(" ")) + }; + + for rule in rules::load_all() { + if !rules::matches_rule(rule, Some(binary), &full_cmd) { + continue; + } + if !rule.should_apply() { + continue; + } + return dispatch(rule, &args.join(" ")); + } + SafetyResult::Safe +} + +/// Check raw command string (for passthrough mode). +/// Catches dangerous patterns even when we can't parse the command. +pub fn check_raw(raw: &str) -> SafetyResult { + for rule in rules::load_all() { + if !rules::matches_rule(rule, None, raw) { + continue; + } + if !rule.should_apply() { + continue; + } + // In passthrough, suggest_tool rules don't apply (cat in pipelines is valid) + if rule.action == "suggest_tool" { + continue; + } + // In passthrough, trash becomes block (can't extract paths reliably) + if rule.action == "trash" { + return SafetyResult::Blocked(format!( + "Passthrough blocked: '{}' detected. Use native mode for safe trash.", + rule.patterns.first().map(|s| s.as_str()).unwrap_or("rm") + )); + } + return dispatch(rule, raw); + } + SafetyResult::Safe +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::test_helpers::EnvGuard; + use std::env; + + // === BASIC CHECK TESTS === + + #[test] + fn test_check_safe_command() { + let _guard = EnvGuard::new(); + let result = check("ls", &["-la".to_string()]); + assert_eq!(result, SafetyResult::Safe); + } + + #[test] + fn test_check_git_status() { + let _guard = EnvGuard::new(); + let result = check("git", &["status".to_string()]); + assert_eq!(result, SafetyResult::Safe); + } + + #[test] + fn test_check_empty_args() { + let _guard = EnvGuard::new(); + let result = check("pwd", &[]); + assert_eq!(result, SafetyResult::Safe); + } + + // === RM SAFETY TESTS (RTK_SAFE_COMMANDS) === + + #[test] + fn test_check_rm_blocked_when_env_set() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check("rm", &["file.txt".to_string()]); + match result { + SafetyResult::TrashRequested(paths) => { + assert_eq!(paths, vec!["file.txt"]); + } + _ => panic!("Expected TrashRequested, got {:?}", result), + } + env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_rm_blocked_by_default() { + let _guard = EnvGuard::new(); + // rm should be redirected to trash by default now + let result = check("rm", &["file.txt".to_string()]); + match result { + SafetyResult::TrashRequested(paths) => { + assert_eq!(paths, vec!["file.txt"]); + } + _ => panic!("Expected TrashRequested by default, got {:?}", result), + } + } + + #[test] + fn test_check_rm_passes_when_disabled() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "0"); + let result = check("rm", &["file.txt".to_string()]); + assert_eq!(result, SafetyResult::Safe); + env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_rm_with_flags() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check("rm", &["-rf".to_string(), "dir".to_string()]); + match result { + SafetyResult::TrashRequested(paths) => { + // Flags should be filtered out + assert_eq!(paths, vec!["dir"]); + } + _ => panic!("Expected TrashRequested"), + } + env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_rm_multiple_files() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check( + "rm", + &[ + "a.txt".to_string(), + "b.txt".to_string(), + "c.txt".to_string(), + ], + ); + match result { + SafetyResult::TrashRequested(paths) => { + assert_eq!(paths, vec!["a.txt", "b.txt", "c.txt"]); + } + _ => panic!("Expected TrashRequested"), + } + env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_rm_no_files() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check("rm", &["-rf".to_string()]); + match result { + SafetyResult::TrashRequested(paths) => { + assert!(paths.is_empty()); + } + _ => panic!("Expected TrashRequested, got {:?}", result), + } + env::remove_var("RTK_SAFE_COMMANDS"); + } + + // === CAT/SED/HEAD TESTS (blocked by default, opt-out with RTK_BLOCK_TOKEN_WASTE=0) === + + #[test] + fn test_check_cat_blocked() { + let _guard = EnvGuard::new(); + let result = check("cat", &["file.txt".to_string()]); + match result { + SafetyResult::Blocked(msg) => { + assert!(msg.contains("file-reading"), "msg: {}", msg); + } + _ => panic!("Expected Blocked"), + } + } + + #[test] + fn test_check_cat_passes_when_disabled() { + let _guard = EnvGuard::new(); + env::set_var("RTK_BLOCK_TOKEN_WASTE", "0"); + let result = check("cat", &["file.txt".to_string()]); + env::remove_var("RTK_BLOCK_TOKEN_WASTE"); + assert_eq!(result, SafetyResult::Safe); + } + + #[test] + fn test_check_sed_blocked() { + let _guard = EnvGuard::new(); + let result = check("sed", &["-i".to_string(), "s/old/new/g".to_string()]); + match result { + SafetyResult::Blocked(msg) => { + assert!(msg.contains("file-editing"), "msg: {}", msg); + } + _ => panic!("Expected Blocked"), + } + } + + #[test] + fn test_check_head_blocked() { + let _guard = EnvGuard::new(); + let result = check( + "head", + &["-n".to_string(), "10".to_string(), "file.txt".to_string()], + ); + match result { + SafetyResult::Blocked(msg) => { + assert!(msg.contains("file-reading"), "msg: {}", msg); + } + _ => panic!("Expected Blocked"), + } + } + + // === GIT SAFETY TESTS (RTK_SAFE_COMMANDS) === + + #[test] + fn test_check_git_reset_hard_blocked_when_env_set() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "1"); + // This test may or may not trigger depending on git state + // Just ensure it doesn't panic + let _ = check("git", &["reset".to_string(), "--hard".to_string()]); + env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_git_clean_fd_rewritten() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check("git", &["clean".to_string(), "-fd".to_string()]); + match result { + SafetyResult::Rewritten(cmd) => { + assert!(cmd.contains("stash -u")); + assert!(cmd.contains("clean")); + } + _ => panic!("Expected Rewritten, got {:?}", result), + } + env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_git_clean_rewritten_by_default() { + let _guard = EnvGuard::new(); + // git clean should be rewritten with stash by default + let result = check("git", &["clean".to_string(), "-fd".to_string()]); + match result { + SafetyResult::Rewritten(cmd) => { + assert!(cmd.contains("stash -u")); + } + _ => panic!("Expected Rewritten by default, got {:?}", result), + } + } + + #[test] + fn test_check_git_clean_passes_when_disabled() { + let _guard = EnvGuard::new(); + env::set_var("RTK_SAFE_COMMANDS", "0"); + let result = check("git", &["clean".to_string(), "-fd".to_string()]); + assert_eq!(result, SafetyResult::Safe); + env::remove_var("RTK_SAFE_COMMANDS"); + } + + // === CHECK_RAW TESTS === + + #[test] + fn test_check_raw_rm_detected() { + let _guard = EnvGuard::new(); + // RTK_SAFE_COMMANDS is enabled by default, so rm should be blocked + let result = check_raw("rm file.txt"); + match result { + SafetyResult::Blocked(_) => {} + _ => panic!("Expected Blocked"), + } + } + + #[test] + fn test_check_raw_sudo_rm_detected() { + let _guard = EnvGuard::new(); + // RTK_SAFE_COMMANDS is enabled by default, so sudo rm should be blocked + let result = check_raw("sudo rm file.txt"); + match result { + SafetyResult::Blocked(_) => {} + _ => panic!("Expected Blocked"), + } + } + + #[test] + fn test_check_raw_sudo_flags_rm_detected() { + let _guard = EnvGuard::new(); + let result = check_raw("sudo -u root rm file.txt"); + match result { + SafetyResult::Blocked(_) => {} + _ => panic!("Expected Blocked for sudo -u root rm"), + } + } + + #[test] + fn test_check_raw_safe_command() { + let _guard = EnvGuard::new(); + let result = check_raw("ls -la"); + assert_eq!(result, SafetyResult::Safe); + } + + #[test] + fn test_check_raw_rm_in_quoted_string() { + let _guard = EnvGuard::new(); + let result = check_raw("echo \"rm file\""); + // This will be blocked because we can't distinguish quoted rm + // That's intentional - better safe than sorry + match result { + SafetyResult::Blocked(_) => {} + SafetyResult::Safe => {} // Either is acceptable + SafetyResult::Rewritten(_) => {} + SafetyResult::TrashRequested(_) => {} + } + } + + // === NEW GIT SAFETY TESTS === + + #[test] + fn test_git_checkout_dot_stash_prepended() { + let _guard = EnvGuard::new(); + let result = check("git", &["checkout".to_string(), ".".to_string()]); + // May or may not trigger based on predicate, just ensure no panic + match result { + SafetyResult::Rewritten(cmd) => { + assert!(cmd.contains("stash")); + assert!(cmd.contains("checkout")); + } + SafetyResult::Safe => {} // Predicate returned false (no changes) + _ => {} + } + } + + #[test] + fn test_git_checkout_dashdash_stash_prepended() { + let _guard = EnvGuard::new(); + let result = check( + "git", + &[ + "checkout".to_string(), + "--".to_string(), + "file.txt".to_string(), + ], + ); + match result { + SafetyResult::Rewritten(cmd) => { + assert!(cmd.contains("stash")); + assert!(cmd.contains("checkout")); + } + SafetyResult::Safe => {} + _ => {} + } + } + + #[test] + fn test_git_stash_drop_rewritten_to_pop() { + let _guard = EnvGuard::new(); + let result = check("git", &["stash".to_string(), "drop".to_string()]); + match result { + SafetyResult::Rewritten(cmd) => { + assert!(cmd.contains("stash pop")); + } + _ => panic!("Expected Rewritten to stash pop"), + } + } + + #[test] + fn test_git_clean_f_rewritten() { + let _guard = EnvGuard::new(); + let result = check("git", &["clean".to_string(), "-f".to_string()]); + match result { + SafetyResult::Rewritten(cmd) => { + assert!(cmd.contains("stash -u")); + assert!(cmd.contains("clean")); + } + _ => panic!("Expected Rewritten with stash -u"), + } + } + + #[test] + fn test_git_branch_checkout_safe() { + // git checkout should be safe (not matched by checkout . or checkout --) + let _guard = EnvGuard::new(); + let result = check("git", &["checkout".to_string(), "main".to_string()]); + assert_eq!(result, SafetyResult::Safe); + } + + #[test] + fn test_git_checkout_new_branch_safe() { + let _guard = EnvGuard::new(); + let result = check( + "git", + &[ + "checkout".to_string(), + "-b".to_string(), + "feature".to_string(), + ], + ); + assert_eq!(result, SafetyResult::Safe); + } + + // === PATTERN MATCHING FALSE POSITIVE TESTS === + + #[test] + fn test_no_false_positive_catalog() { + let _guard = EnvGuard::new(); + let result = check("catalog", &["show".to_string()]); + assert_eq!( + result, + SafetyResult::Safe, + "catalog must not match cat rule" + ); + } + + #[test] + fn test_no_false_positive_sedan() { + let _guard = EnvGuard::new(); + let result = check("sedan", &[]); + assert_eq!(result, SafetyResult::Safe, "sedan must not match sed rule"); + } + + #[test] + fn test_no_false_positive_headless() { + let _guard = EnvGuard::new(); + let result = check("headless", &["chrome".to_string()]); + assert_eq!( + result, + SafetyResult::Safe, + "headless must not match head rule" + ); + } + + #[test] + fn test_no_false_positive_rmdir() { + let _guard = EnvGuard::new(); + let result = check("rmdir", &["empty_dir".to_string()]); + assert_eq!(result, SafetyResult::Safe, "rmdir must not match rm rule"); + } + + // === CHECK_RAW WORD BOUNDARY TESTS === + + #[test] + fn test_check_raw_no_false_positive_trim() { + let _guard = EnvGuard::new(); + std::env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check_raw("trim file.txt"); + assert_eq!(result, SafetyResult::Safe, "trim must not match rm pattern"); + std::env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_raw_no_false_positive_farm() { + let _guard = EnvGuard::new(); + std::env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check_raw("farm --harvest"); + assert_eq!(result, SafetyResult::Safe, "farm must not match rm pattern"); + std::env::remove_var("RTK_SAFE_COMMANDS"); + } + + #[test] + fn test_check_raw_catches_standalone_rm() { + let _guard = EnvGuard::new(); + std::env::set_var("RTK_SAFE_COMMANDS", "1"); + let result = check_raw("rm file.txt"); + assert!( + matches!(result, SafetyResult::Blocked(_)), + "standalone rm must be caught" + ); + std::env::remove_var("RTK_SAFE_COMMANDS"); + } +} 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/cmd/trash_cmd.rs b/src/cmd/trash_cmd.rs new file mode 100644 index 00000000..70ac9aeb --- /dev/null +++ b/src/cmd/trash_cmd.rs @@ -0,0 +1,76 @@ +//! Built-in trash - mirrors rm behavior: silent on success, error on failure. + +use anyhow::Result; +use std::path::Path; + +pub fn execute(paths: &[String]) -> Result { + let expanded: Vec = paths + .iter() + .filter(|p| !p.is_empty()) + .map(|p| super::predicates::expand_tilde(p)) + .collect(); + + if expanded.is_empty() { + eprintln!("trash: no paths specified"); + return Ok(false); + } + + let (existing, missing): (Vec<_>, Vec<_>) = + expanded.iter().partition(|p| Path::new(p).exists()); + + // Report missing like rm does + for p in &missing { + eprintln!("trash: cannot remove '{}': No such path", p); + } + + if existing.is_empty() { + return Ok(false); + } + + let refs: Vec<&str> = existing.iter().map(|s| s.as_str()).collect(); + match trash::delete_all(&refs) { + Ok(_) => Ok(true), + Err(e) => { + eprintln!("trash: {}", e); + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("rtk_{}", name)); + fs::write(&p, "x").unwrap(); + p + } + fn rm(p: &PathBuf) { + let _ = fs::remove_file(p); + } + + #[test] + fn t_empty() { + assert!(!execute(&[]).unwrap()); + } + #[test] + fn t_missing() { + assert!(!execute(&["/nope".into()]).unwrap()); + } + #[test] + fn t_single() { + let p = tmp("s"); + assert!(execute(&[p.to_string_lossy().into()]).unwrap()); + rm(&p); + } + #[test] + fn t_multi() { + let (a, b) = (tmp("a"), tmp("b")); + assert!(execute(&[a.to_string_lossy().into(), b.to_string_lossy().into()]).unwrap()); + rm(&a); + rm(&b); + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 1015012a..00000000 --- a/src/config.rs +++ /dev/null @@ -1,127 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Debug, Serialize, Deserialize, Default)] -pub struct Config { - #[serde(default)] - pub tracking: TrackingConfig, - #[serde(default)] - pub display: DisplayConfig, - #[serde(default)] - pub filters: FilterConfig, - #[serde(default)] - pub tee: crate::tee::TeeConfig, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct TrackingConfig { - pub enabled: bool, - pub history_days: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub database_path: Option, -} - -impl Default for TrackingConfig { - fn default() -> Self { - Self { - enabled: true, - history_days: 90, - database_path: None, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DisplayConfig { - pub colors: bool, - pub emoji: bool, - pub max_width: usize, -} - -impl Default for DisplayConfig { - fn default() -> Self { - Self { - colors: true, - emoji: true, - max_width: 120, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct FilterConfig { - pub ignore_dirs: Vec, - pub ignore_files: Vec, -} - -impl Default for FilterConfig { - fn default() -> Self { - Self { - ignore_dirs: vec![ - ".git".into(), - "node_modules".into(), - "target".into(), - "__pycache__".into(), - ".venv".into(), - "vendor".into(), - ], - ignore_files: vec!["*.lock".into(), "*.min.js".into(), "*.min.css".into()], - } - } -} - -impl Config { - pub fn load() -> Result { - let path = get_config_path()?; - - if path.exists() { - let content = std::fs::read_to_string(&path)?; - let config: Config = toml::from_str(&content)?; - Ok(config) - } else { - Ok(Config::default()) - } - } - - pub fn save(&self) -> Result<()> { - let path = get_config_path()?; - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let content = toml::to_string_pretty(self)?; - std::fs::write(&path, content)?; - Ok(()) - } - - pub fn create_default() -> Result { - let config = Config::default(); - config.save()?; - get_config_path() - } -} - -fn get_config_path() -> Result { - let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); - Ok(config_dir.join("rtk").join("config.toml")) -} - -pub fn show_config() -> Result<()> { - let path = get_config_path()?; - println!("Config: {}", path.display()); - println!(); - - if path.exists() { - let config = Config::load()?; - println!("{}", toml::to_string_pretty(&config)?); - } else { - println!("(default config, file not created)"); - println!(); - let config = Config::default(); - println!("{}", toml::to_string_pretty(&config)?); - } - - Ok(()) -} diff --git a/src/config/discovery.rs b/src/config/discovery.rs new file mode 100644 index 00000000..edd51126 --- /dev/null +++ b/src/config/discovery.rs @@ -0,0 +1,330 @@ +//! Directory walk-up discovery for `rtk.*.md` rule files. +//! +//! Walks from cwd to home, scanning configurable dirs in each ancestor. +//! Search dirs, global dirs, and extra rules_dirs are read from config. +//! Results cached via `OnceLock` — zero cost after first call. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +static DISCOVERED: OnceLock> = OnceLock::new(); + +/// Return all `rtk.*.md` files ordered lowest→highest priority. +/// +/// Precedence (highest wins): +/// 0 (lowest). Compiled `include_str!()` defaults (handled in rules.rs, not here) +/// 1. Platform config dir + `~/.config/rtk/` (global RTK config) +/// 2. Config `discovery.rules_dirs` (explicit extra dirs) +/// 3. Config `discovery.global_dirs` under $HOME (default: `.claude/`, `.gemini/`) +/// 4. Walk up from cwd using config `discovery.search_dirs` +/// (default: `.claude/`, `.gemini/`, `.rtk/` — furthest from cwd first, cwd last) +/// 5. CLI `--rules-add` paths (highest file priority) +/// +/// If `--rules-path` is set, ONLY those paths are searched (skips all discovery). +/// All dirs configurable via `[discovery]` section in config.toml or env vars. +pub fn discover_rtk_files() -> &'static [PathBuf] { + DISCOVERED.get_or_init(discover_impl) +} + +fn discover_impl() -> Vec { + let mut seen = HashSet::new(); + let mut files = Vec::new(); + let overrides = super::cli_overrides(); + + // If --rules-path is set, use ONLY those paths (exclusive mode) + if let Some(ref exclusive_paths) = overrides.rules_path { + for dir in exclusive_paths { + collect_from_dir(dir, &mut files, &mut seen); + } + return files; + } + + let config = super::get_merged(); + + // Normal discovery + let home = match dirs::home_dir() { + Some(h) => h, + None => return files, + }; + + // 1. Platform-specific config dir (macOS: ~/Library/Application Support/rtk/) + if let Some(config_dir) = dirs::config_dir() { + let platform_rtk = config_dir.join("rtk"); + collect_from_dir(&platform_rtk, &mut files, &mut seen); + } + + // 2. Canonical RTK config dir: ~/.config/rtk/ + let canonical_rtk = home.join(".config").join("rtk"); + collect_from_dir(&canonical_rtk, &mut files, &mut seen); + + // 3. Config discovery.rules_dirs (explicit extra directories) + for dir in &config.discovery.rules_dirs { + collect_from_dir(dir, &mut files, &mut seen); + } + + // 4. Global dirs under $HOME (from config discovery.global_dirs) + for name in &config.discovery.global_dirs { + collect_from_dir(&home.join(name), &mut files, &mut seen); + } + + // 5. Walk up from cwd to home using config discovery.search_dirs + let cwd = match std::env::current_dir() { + Ok(c) => c, + Err(_) => return files, + }; + + let mut ancestors: Vec = Vec::new(); + let mut current = cwd.as_path(); + loop { + ancestors.push(current.to_path_buf()); + if current == home { + break; + } + match current.parent() { + Some(p) if p != current => current = p, + _ => break, + } + } + // Reverse: furthest ancestor first (lowest priority), cwd last (highest) + ancestors.reverse(); + + for ancestor in &ancestors { + for search_dir in &config.discovery.search_dirs { + let dir = ancestor.join(search_dir); + collect_from_dir(&dir, &mut files, &mut seen); + } + } + + // 6. --rules-add paths (highest file priority, after all discovery) + for dir in &overrides.rules_add { + collect_from_dir(dir, &mut files, &mut seen); + } + + files +} + +/// Collect `rtk.*.md` files from a directory, deduplicating by canonical path. +fn collect_from_dir(dir: &Path, files: &mut Vec, seen: &mut HashSet) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, // Silently skip unreadable dirs + }; + + let mut dir_files: Vec = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if is_rtk_rule_file(&name_str) { + let path = entry.path(); + // Canonicalize for dedup: detects symlink loops and duplicate real paths + let canon = match path.canonicalize() { + Ok(c) => c, + Err(_) => continue, // Broken symlink or unreadable + }; + if seen.insert(canon) { + dir_files.push(path); + } + } + } + // Sort within directory for deterministic ordering + dir_files.sort(); + files.extend(dir_files); +} + +/// Match `rtk.*.md` pattern: starts with "rtk.", ends with ".md", has content between. +fn is_rtk_rule_file(name: &str) -> bool { + name.starts_with("rtk.") && name.ends_with(".md") && name.len() > 7 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_is_rtk_rule_file_valid() { + assert!(is_rtk_rule_file("rtk.safety.rm-to-trash.md")); + assert!(is_rtk_rule_file("rtk.remap.t.md")); + assert!(is_rtk_rule_file("rtk.x.md")); // minimal valid: 8 chars + } + + #[test] + fn test_is_rtk_rule_file_invalid() { + assert!(!is_rtk_rule_file("rtk.md")); // too short (7 chars, not > 7) + assert!(!is_rtk_rule_file("foo.md")); + assert!(!is_rtk_rule_file("rtk.safety.txt")); + assert!(!is_rtk_rule_file("")); + } + + #[test] + fn test_collect_from_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(tmp.path(), &mut files, &mut seen); + assert!(files.is_empty()); + } + + #[test] + fn test_collect_from_dir_with_rules() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("rtk.test.md"), "---\nname: test\n---\n").unwrap(); + fs::write(tmp.path().join("not-a-rule.md"), "ignored").unwrap(); + fs::write(tmp.path().join("rtk.md"), "too short name").unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(tmp.path(), &mut files, &mut seen); + assert_eq!(files.len(), 1); + assert!(files[0].file_name().unwrap().to_str().unwrap() == "rtk.test.md"); + } + + #[test] + fn test_collect_deduplicates_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let real = tmp.path().join("rtk.test.md"); + fs::write(&real, "---\nname: test\n---\n").unwrap(); + + // Create a subdirectory with a symlink to the same file + let subdir = tmp.path().join("sub"); + fs::create_dir(&subdir).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&real, subdir.join("rtk.test.md")).unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(tmp.path(), &mut files, &mut seen); + collect_from_dir(&subdir, &mut files, &mut seen); + + #[cfg(unix)] + assert_eq!(files.len(), 1, "Symlink should be deduplicated"); + } + + #[test] + fn test_collect_skips_unreadable_dir() { + let mut files = Vec::new(); + let mut seen = HashSet::new(); + // Non-existent directory should be silently skipped + collect_from_dir(Path::new("/nonexistent/path"), &mut files, &mut seen); + assert!(files.is_empty()); + } + + #[test] + fn test_collect_skips_file_as_dir() { + // If a file is passed instead of a directory, read_dir will fail — should be skipped + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("not_a_dir"); + fs::write(&file_path, "i am a file").unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(&file_path, &mut files, &mut seen); + assert!(files.is_empty()); // Not a dir, silently skipped + } + + #[test] + fn test_collect_skips_broken_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + + #[cfg(unix)] + { + // Create a broken symlink (target doesn't exist) + let broken_link = tmp.path().join("rtk.broken.md"); + std::os::unix::fs::symlink("/nonexistent/target", &broken_link).unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(tmp.path(), &mut files, &mut seen); + // Broken symlink: canonicalize fails → continue (skipped) + assert!(files.is_empty()); + } + } + + #[test] + fn test_collect_handles_non_utf8_filenames() { + // Files with non-UTF8 names should be handled via to_string_lossy + let tmp = tempfile::tempdir().unwrap(); + // Create a normal rtk rule file alongside a non-matching file + fs::write(tmp.path().join("rtk.valid.md"), "---\nname: v\n---\n").unwrap(); + fs::write(tmp.path().join("other.txt"), "not a rule").unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(tmp.path(), &mut files, &mut seen); + assert_eq!(files.len(), 1); + } + + #[test] + fn test_collect_multiple_dirs_deduplicates() { + let tmp = tempfile::tempdir().unwrap(); + let dir_a = tmp.path().join("a"); + let dir_b = tmp.path().join("b"); + fs::create_dir_all(&dir_a).unwrap(); + fs::create_dir_all(&dir_b).unwrap(); + + let real_file = dir_a.join("rtk.test.md"); + fs::write(&real_file, "---\nname: test\n---\n").unwrap(); + + #[cfg(unix)] + { + // Symlink from dir_b to same real file + std::os::unix::fs::symlink(&real_file, dir_b.join("rtk.test.md")).unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(&dir_a, &mut files, &mut seen); + collect_from_dir(&dir_b, &mut files, &mut seen); + assert_eq!( + files.len(), + 1, + "Same file via symlink should be deduplicated" + ); + } + } + + #[cfg(unix)] + #[test] + fn test_collect_permission_denied_dir_skipped() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let restricted = tmp.path().join("restricted"); + fs::create_dir(&restricted).unwrap(); + fs::write(restricted.join("rtk.test.md"), "---\nname: t\n---\n").unwrap(); + + // Remove read permission + fs::set_permissions(&restricted, fs::Permissions::from_mode(0o000)).unwrap(); + + let mut files = Vec::new(); + let mut seen = HashSet::new(); + collect_from_dir(&restricted, &mut files, &mut seen); + // Permission denied → silently skipped + assert!(files.is_empty()); + + // Restore permissions for cleanup + fs::set_permissions(&restricted, fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[test] + fn test_default_search_dirs_match_expected() { + // Verify defaults match the previously hardcoded values + let config = crate::config::DiscoveryConfig::default(); + assert_eq!(config.search_dirs, vec![".claude", ".gemini", ".rtk"]); + } + + #[test] + fn test_default_global_dirs_match_expected() { + let config = crate::config::DiscoveryConfig::default(); + assert_eq!(config.global_dirs, vec![".claude", ".gemini"]); + } + + #[test] + fn test_default_rules_dirs_empty() { + let config = crate::config::DiscoveryConfig::default(); + assert!( + config.rules_dirs.is_empty(), + "Default rules_dirs should be empty (uses ~/.config/rtk/ implicitly)" + ); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 00000000..1d5235a5 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,1210 @@ +//! Configuration system: scalar config (TOML) + unified rules (MD with YAML frontmatter). +//! +//! Two config layers: +//! 1. Scalar config (`config.toml`): tracking, display, filters +//! 2. Rules (`rtk.*.md`): safety, remaps, warnings — via `rules` submodule + +pub mod discovery; +pub mod rules; + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// CLI overrides for config paths. Set from main.rs before any config loading. +#[derive(Debug, Default)] +pub struct CliConfigOverrides { + /// Exclusive config paths — replaces all discovery. Multiple files merged in order. + pub config_path: Option>, + /// Additional config paths — loaded with highest priority (after env vars). + pub config_add: Vec, + /// Exclusive rule discovery paths — replaces walk-up discovery. + pub rules_path: Option>, + /// Additional rule discovery paths — loaded with highest priority. + pub rules_add: Vec, +} + +static CLI_OVERRIDES: OnceLock = OnceLock::new(); + +/// Set CLI config overrides. Must be called before any config loading. +pub fn set_cli_overrides(overrides: CliConfigOverrides) { + let _ = CLI_OVERRIDES.set(overrides); +} + +/// Get CLI config overrides (or defaults if never set). +pub fn cli_overrides() -> &'static CliConfigOverrides { + CLI_OVERRIDES.get_or_init(CliConfigOverrides::default) +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct Config { + #[serde(default)] + pub tracking: TrackingConfig, + #[serde(default)] + pub display: DisplayConfig, + #[serde(default)] + pub filters: FilterConfig, + #[serde(default)] + pub discovery: DiscoveryConfig, + #[serde(default)] + pub tee: crate::tee::TeeConfig, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TrackingConfig { + pub enabled: bool, + pub history_days: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub database_path: Option, +} + +impl Default for TrackingConfig { + fn default() -> Self { + Self { + enabled: true, + history_days: 90, + database_path: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DisplayConfig { + pub colors: bool, + pub emoji: bool, + pub max_width: usize, +} + +impl Default for DisplayConfig { + fn default() -> Self { + Self { + colors: true, + emoji: true, + max_width: 120, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FilterConfig { + pub ignore_dirs: Vec, + pub ignore_files: Vec, +} + +impl Default for FilterConfig { + fn default() -> Self { + Self { + ignore_dirs: vec![ + ".git".into(), + "node_modules".into(), + "target".into(), + "__pycache__".into(), + ".venv".into(), + "vendor".into(), + ], + ignore_files: vec!["*.lock".into(), "*.min.js".into(), "*.min.css".into()], + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DiscoveryConfig { + /// Dirs to search in each ancestor during walk-up (e.g. [".claude", ".gemini", ".rtk"]). + pub search_dirs: Vec, + /// Global dirs under $HOME to check before walk-up (e.g. [".claude", ".gemini"]). + pub global_dirs: Vec, + /// Additional rule directories to search. First entry is also the export/write target. + /// Default: [] (uses ~/.config/rtk/ as the implicit primary). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules_dirs: Vec, +} + +impl Default for DiscoveryConfig { + fn default() -> Self { + Self { + search_dirs: vec![".claude".into(), ".gemini".into(), ".rtk".into()], + global_dirs: vec![".claude".into(), ".gemini".into()], + rules_dirs: vec![], + } + } +} + +impl Config { + /// Load global config from `~/.config/rtk/config.toml`. + /// Falls back to defaults if file is missing or unreadable. + pub fn load() -> Result { + let path = match get_config_path() { + Ok(p) => p, + Err(_) => return Ok(Config::default()), + }; + + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => match toml::from_str(&content) { + Ok(config) => Ok(config), + Err(_) => Ok(Config::default()), // Malformed config → defaults + }, + Err(_) => Ok(Config::default()), // Unreadable → defaults + } + } else { + Ok(Config::default()) + } + } + + /// Load merged config with full precedence chain. + /// + /// Precedence (highest wins): + /// 0. CLI params: `--config-path` (exclusive) or `--config-add` (additive) + /// 1. Environment variables (RTK_*) + /// 2. Project-local `.rtk/config.toml` (nearest ancestor) + /// 3. Global `~/.config/rtk/config.toml` (or platform config dir) + /// 4. Compiled defaults + pub fn load_merged() -> Result { + let overrides = cli_overrides(); + + // If --config-path is set, use ONLY those files (skip global + walk-up) + let mut config = if let Some(ref exclusive_paths) = overrides.config_path { + let mut cfg = Config::default(); + for path in exclusive_paths { + if path.exists() { + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(overlay) = toml::from_str::(&content) { + overlay.apply(&mut cfg); + } + } + } + } + cfg + } else { + // Normal: start with global config + let mut cfg = Self::load()?; + + // Layer 3: Walk up from cwd looking for .rtk/config.toml + if let Ok(cwd) = std::env::current_dir() { + let mut current = cwd.as_path(); + loop { + let project_config = current.join(".rtk").join("config.toml"); + if project_config.exists() { + match std::fs::read_to_string(&project_config) { + Ok(content) => { + if let Ok(overlay) = toml::from_str::(&content) { + overlay.apply(&mut cfg); + } + } + Err(_) => {} // Silently skip unreadable project config + } + break; + } + match current.parent() { + Some(p) if p != current => current = p, + _ => break, + } + } + } + cfg + }; + + // Layer 1.5: --config-add paths (higher than project-local, lower than env vars) + for add_path in &overrides.config_add { + if add_path.exists() { + if let Ok(content) = std::fs::read_to_string(add_path) { + if let Ok(overlay) = toml::from_str::(&content) { + overlay.apply(&mut config); + } + } + } + } + + // Layer 1 (highest priority): Environment variable overrides + if let Ok(val) = std::env::var("RTK_TRACKING_ENABLED") { + if let Ok(b) = val.parse::() { + config.tracking.enabled = b; + } else if val == "0" { + config.tracking.enabled = false; + } else if val == "1" { + config.tracking.enabled = true; + } + } + if let Ok(val) = std::env::var("RTK_HISTORY_DAYS") { + if let Ok(days) = val.parse::() { + config.tracking.history_days = days; + } + } + if let Ok(path) = std::env::var("RTK_DB_PATH") { + config.tracking.database_path = Some(PathBuf::from(path)); + } + if let Ok(val) = std::env::var("RTK_DISPLAY_COLORS") { + if let Ok(b) = val.parse::() { + config.display.colors = b; + } + } + if let Ok(val) = std::env::var("RTK_DISPLAY_EMOJI") { + if let Ok(b) = val.parse::() { + config.display.emoji = b; + } + } + if let Ok(val) = std::env::var("RTK_MAX_WIDTH") { + if let Ok(w) = val.parse::() { + config.display.max_width = w; + } + } + if let Ok(val) = std::env::var("RTK_SEARCH_DIRS") { + config.discovery.search_dirs = val.split(',').map(|s| s.trim().to_string()).collect(); + } + if let Ok(val) = std::env::var("RTK_RULES_DIRS") { + config.discovery.rules_dirs = val.split(',').map(|s| PathBuf::from(s.trim())).collect(); + } + + Ok(config) + } + + pub fn save(&self) -> Result<()> { + let path = get_config_path()?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + Ok(()) + } + + /// Save to a specific path (for --local support). + pub fn save_to(&self, path: &std::path::Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } + + pub fn create_default() -> Result { + let config = Config::default(); + config.save()?; + get_config_path() + } +} + +/// Overlay config for merging project config onto global config. +/// All fields are Option — only present fields override. +#[derive(Debug, Deserialize, Default)] +pub struct ConfigOverlay { + pub tracking: Option, + pub display: Option, + pub filters: Option, + pub discovery: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TrackingOverlay { + pub enabled: Option, + pub history_days: Option, + pub database_path: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DisplayOverlay { + pub colors: Option, + pub emoji: Option, + pub max_width: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FilterOverlay { + pub ignore_dirs: Option>, + pub ignore_files: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct DiscoveryOverlay { + pub search_dirs: Option>, + pub global_dirs: Option>, + pub rules_dirs: Option>, +} + +impl ConfigOverlay { + fn apply(&self, config: &mut Config) { + if let Some(ref t) = self.tracking { + if let Some(v) = t.enabled { + config.tracking.enabled = v; + } + if let Some(v) = t.history_days { + config.tracking.history_days = v; + } + if let Some(ref v) = t.database_path { + config.tracking.database_path = Some(v.clone()); + } + } + if let Some(ref d) = self.display { + if let Some(v) = d.colors { + config.display.colors = v; + } + if let Some(v) = d.emoji { + config.display.emoji = v; + } + if let Some(v) = d.max_width { + config.display.max_width = v; + } + } + if let Some(ref f) = self.filters { + if let Some(ref v) = f.ignore_dirs { + config.filters.ignore_dirs = v.clone(); + } + if let Some(ref v) = f.ignore_files { + config.filters.ignore_files = v.clone(); + } + } + if let Some(ref d) = self.discovery { + if let Some(ref v) = d.search_dirs { + config.discovery.search_dirs = v.clone(); + } + if let Some(ref v) = d.global_dirs { + config.discovery.global_dirs = v.clone(); + } + if let Some(ref v) = d.rules_dirs { + config.discovery.rules_dirs = v.clone(); + } + } + } +} + +/// Global config path: `~/.config/rtk/config.toml` +pub fn get_config_path() -> Result { + let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); + Ok(config_dir.join("rtk").join("config.toml")) +} + +/// Canonical RTK rules directory: `~/.config/rtk/` +/// +/// This is distinct from `dirs::config_dir()` which on macOS returns +/// `~/Library/Application Support/` — not appropriate for a CLI tool's +/// user-facing rule files. We use `~/.config/rtk/` on all platforms. +/// Primary rules directory (for writes/exports). First entry of rules_dirs, or ~/.config/rtk/. +pub fn get_rules_dir() -> Result { + let config = get_merged(); + if let Some(first) = config.discovery.rules_dirs.first() { + return Ok(first.clone()); + } + let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?; + Ok(home.join(".config").join("rtk")) +} + +/// Project-local config path: `.rtk/config.toml` in cwd +pub fn get_local_config_path() -> Result { + let cwd = std::env::current_dir()?; + Ok(cwd.join(".rtk").join("config.toml")) +} + +/// Cached merged config (loaded once per process). +static MERGED_CONFIG: OnceLock = OnceLock::new(); + +/// Get the merged config (cached). For use by tracking, display, etc. +pub fn get_merged() -> &'static Config { + MERGED_CONFIG.get_or_init(|| Config::load_merged().unwrap_or_default()) +} + +pub fn show_config() -> Result<()> { + let path = get_config_path()?; + if path.exists() { + println!("# {}", path.display()); + let config = Config::load()?; + println!("{}", toml::to_string_pretty(&config)?); + } else { + println!("# (defaults, no config file)"); + println!("{}", toml::to_string_pretty(&Config::default())?); + } + Ok(()) +} + +// === Config CRUD === + +/// Get a config value by dotted key (e.g., "tracking.enabled"). +pub fn get_value(key: &str) -> Result { + let config = Config::load_merged()?; + let toml_val = toml::Value::try_from(&config)?; + + let parts: Vec<&str> = key.split('.').collect(); + let mut current = &toml_val; + for part in &parts { + current = current + .get(part) + .ok_or_else(|| anyhow!("Unknown config key: {key}"))?; + } + + match current { + toml::Value::String(s) => Ok(s.clone()), + toml::Value::Boolean(b) => Ok(b.to_string()), + toml::Value::Integer(i) => Ok(i.to_string()), + toml::Value::Float(f) => Ok(f.to_string()), + toml::Value::Array(a) => Ok(format!("{:?}", a)), + other => Ok(other.to_string()), + } +} + +/// Set a config value by dotted key. +pub fn set_value(key: &str, value: &str, local: bool) -> Result<()> { + let path = if local { + get_local_config_path()? + } else { + get_config_path()? + }; + + let mut config = if path.exists() { + let content = std::fs::read_to_string(&path)?; + toml::from_str(&content)? + } else { + Config::default() + }; + + apply_value(&mut config, key, value)?; + + if local { + config.save_to(&path)?; + } else { + config.save()?; + } + Ok(()) +} + +/// Unset a config value (reset to default). +pub fn unset_value(key: &str, local: bool) -> Result<()> { + let path = if local { + get_local_config_path()? + } else { + get_config_path()? + }; + + if !path.exists() { + return Err(anyhow!("Config file not found: {}", path.display())); + } + + let content = std::fs::read_to_string(&path)?; + let mut toml_val: toml::Value = toml::from_str(&content)?; + + let parts: Vec<&str> = key.split('.').collect(); + if parts.len() == 2 { + if let Some(table) = toml_val.get_mut(parts[0]).and_then(|v| v.as_table_mut()) { + table.remove(parts[1]); + } + } else { + return Err(anyhow!("Invalid key format: {key}. Use section.field")); + } + + let content = toml::to_string_pretty(&toml_val)?; + std::fs::write(&path, content)?; + Ok(()) +} + +/// List all config values with optional origin info. +pub fn list_values(origin: bool) -> Result<()> { + let config = Config::load_merged()?; + let toml_str = toml::to_string_pretty(&config)?; + + if origin { + let global_path = get_config_path()?; + let has_global = global_path.exists(); + + // Check for project config + let mut has_project = false; + if let Ok(cwd) = std::env::current_dir() { + let mut current = cwd.as_path(); + loop { + if current.join(".rtk").join("config.toml").exists() { + has_project = true; + break; + } + match current.parent() { + Some(p) if p != current => current = p, + _ => break, + } + } + } + + println!("# Sources:"); + if has_global { + println!("# global: {}", global_path.display()); + } + if has_project { + println!("# project: .rtk/config.toml"); + } + if !has_global && !has_project { + println!("# (all defaults)"); + } + println!(); + } + + println!("{toml_str}"); + + // Show rules summary only with --origin flag + if origin { + let rules = rules::load_all(); + if !rules.is_empty() { + println!("# Rules ({} loaded):", rules.len()); + for rule in rules { + println!("# {} [{}] — {}", rule.name, rule.action, rule.source); + } + } + } + + Ok(()) +} + +/// Apply a string value to a config struct by dotted key. +fn apply_value(config: &mut Config, key: &str, value: &str) -> Result<()> { + match key { + "tracking.enabled" => config.tracking.enabled = value.parse()?, + "tracking.history_days" => config.tracking.history_days = value.parse()?, + "tracking.database_path" => { + config.tracking.database_path = Some(PathBuf::from(value)); + } + "display.colors" => config.display.colors = value.parse()?, + "display.emoji" => config.display.emoji = value.parse()?, + "display.max_width" => config.display.max_width = value.parse()?, + "discovery.search_dirs" => { + config.discovery.search_dirs = value.split(',').map(|s| s.trim().to_string()).collect(); + } + "discovery.global_dirs" => { + config.discovery.global_dirs = value.split(',').map(|s| s.trim().to_string()).collect(); + } + "discovery.rules_dirs" => { + config.discovery.rules_dirs = + value.split(',').map(|s| PathBuf::from(s.trim())).collect(); + } + _ => return Err(anyhow!("Unknown config key: {key}")), + } + Ok(()) +} + +/// Create or update a rule MD file. +pub fn set_rule( + name: &str, + pattern: Option<&str>, + action: Option<&str>, + redirect: Option<&str>, + local: bool, +) -> Result<()> { + let dir = if local { + let cwd = std::env::current_dir()?; + cwd.join(".rtk") + } else { + get_rules_dir()? + }; + std::fs::create_dir_all(&dir)?; + + let action_str = action.unwrap_or("rewrite"); + let filename = format!("rtk.{name}.md"); + let path = dir.join(&filename); + + let mut content = String::from("---\n"); + content.push_str(&format!("name: {name}\n")); + if let Some(pat) = pattern { + // Single pattern without quotes for simple, quoted for multi-word + if pat.contains(' ') { + content.push_str(&format!("patterns: [\"{pat}\"]\n")); + } else { + content.push_str(&format!("patterns: [{pat}]\n")); + } + } + content.push_str(&format!("action: {action_str}\n")); + if let Some(redir) = redirect { + content.push_str(&format!("redirect: \"{redir}\"\n")); + } + content.push_str("---\n\nUser-defined rule.\n"); + + std::fs::write(&path, &content)?; + println!("Created rule: {}", path.display()); + Ok(()) +} + +/// Delete a rule MD file. +pub fn unset_rule(name: &str, local: bool) -> Result<()> { + let dir = if local { + let cwd = std::env::current_dir()?; + cwd.join(".rtk") + } else { + get_rules_dir()? + }; + + let filename = format!("rtk.{name}.md"); + let path = dir.join(&filename); + + if path.exists() { + std::fs::remove_file(&path)?; + println!("Removed rule: {}", path.display()); + } else { + // If it's a built-in rule, create a disabled override + let is_builtin = rules::DEFAULT_RULES.iter().any(|content| { + rules::parse_rule(content, "builtin") + .map(|r| r.name == name) + .unwrap_or(false) + }); + if is_builtin { + std::fs::create_dir_all(&dir)?; + let content = format!("---\nname: {name}\nenabled: false\n---\n\nDisabled by user.\n"); + std::fs::write(&path, content)?; + println!("Disabled built-in rule: {}", path.display()); + } else { + return Err(anyhow!("Rule file not found: {}", path.display())); + } + } + Ok(()) +} + +/// Export built-in rules to a directory. +pub fn export_rules(claude: bool) -> Result<()> { + let dir = if claude { + crate::init::resolve_claude_dir()? + } else { + get_rules_dir()? + }; + std::fs::create_dir_all(&dir)?; + + let mut count = 0; + for content in rules::DEFAULT_RULES { + let rule = rules::parse_rule(content, "builtin")?; + let filename = format!("rtk.{}.md", rule.name); + let path = dir.join(&filename); + // Skip if content unchanged; tolerate unreadable existing files + if path.exists() { + if let Ok(existing) = std::fs::read_to_string(&path) { + if existing.trim() == content.trim() { + continue; + } + } + // If unreadable, overwrite anyway + } + std::fs::write(&path, content)?; + count += 1; + } + + println!("Exported {} rules to {}", count, dir.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = Config::default(); + assert!(config.tracking.enabled); + assert_eq!(config.tracking.history_days, 90); + assert!(config.display.colors); + assert_eq!(config.display.max_width, 120); + } + + #[test] + fn test_config_overlay_none_fields_dont_override() { + let mut config = Config::default(); + config.tracking.history_days = 30; + config.display.max_width = 80; + + let overlay = ConfigOverlay::default(); + overlay.apply(&mut config); + + // None fields should not override + assert_eq!(config.tracking.history_days, 30); + assert_eq!(config.display.max_width, 80); + } + + #[test] + fn test_config_overlay_applies() { + let mut config = Config::default(); + + let overlay_toml = r#" +[tracking] +history_days = 30 + +[display] +max_width = 80 +"#; + let overlay: ConfigOverlay = toml::from_str(overlay_toml).unwrap(); + overlay.apply(&mut config); + + assert_eq!(config.tracking.history_days, 30); + assert_eq!(config.display.max_width, 80); + // Unmentioned fields unchanged + assert!(config.tracking.enabled); + assert!(config.display.colors); + } + + #[test] + fn test_apply_value_tracking() { + let mut config = Config::default(); + apply_value(&mut config, "tracking.enabled", "false").unwrap(); + assert!(!config.tracking.enabled); + + apply_value(&mut config, "tracking.history_days", "30").unwrap(); + assert_eq!(config.tracking.history_days, 30); + } + + #[test] + fn test_apply_value_display() { + let mut config = Config::default(); + apply_value(&mut config, "display.max_width", "80").unwrap(); + assert_eq!(config.display.max_width, 80); + + apply_value(&mut config, "display.colors", "false").unwrap(); + assert!(!config.display.colors); + } + + #[test] + fn test_apply_value_unknown_key() { + let mut config = Config::default(); + assert!(apply_value(&mut config, "unknown.key", "value").is_err()); + } + + #[test] + fn test_get_value_existing() { + // This uses load_merged which reads from disk, so just test the happy path + let result = get_value("tracking.enabled"); + assert!(result.is_ok()); + let val = result.unwrap(); + assert!(val == "true" || val == "false"); + } + + #[test] + fn test_get_value_unknown() { + let result = get_value("nonexistent.key"); + assert!(result.is_err()); + } + + #[test] + fn test_load_merged_env_override() { + std::env::set_var("RTK_DB_PATH", "/tmp/test.db"); + let config = Config::load_merged().unwrap(); + assert_eq!( + config.tracking.database_path, + Some(PathBuf::from("/tmp/test.db")) + ); + std::env::remove_var("RTK_DB_PATH"); + } + + #[test] + fn test_env_overrides_all_fields() { + // Single test to avoid parallel env var interference. + // Tests all RTK_* env var overrides sequentially. + + // tracking.enabled: "false" overrides default true + std::env::set_var("RTK_TRACKING_ENABLED", "false"); + let config = Config::load_merged().unwrap(); + assert!(!config.tracking.enabled); + std::env::remove_var("RTK_TRACKING_ENABLED"); + + // tracking.enabled: "0" also disables + std::env::set_var("RTK_TRACKING_ENABLED", "0"); + let config = Config::load_merged().unwrap(); + assert!(!config.tracking.enabled); + std::env::remove_var("RTK_TRACKING_ENABLED"); + + // tracking.enabled: "1" enables + std::env::set_var("RTK_TRACKING_ENABLED", "1"); + let config = Config::load_merged().unwrap(); + assert!(config.tracking.enabled); + std::env::remove_var("RTK_TRACKING_ENABLED"); + + // tracking.history_days + std::env::set_var("RTK_HISTORY_DAYS", "7"); + let config = Config::load_merged().unwrap(); + assert_eq!(config.tracking.history_days, 7); + std::env::remove_var("RTK_HISTORY_DAYS"); + + // display.colors + std::env::set_var("RTK_DISPLAY_COLORS", "false"); + let config = Config::load_merged().unwrap(); + assert!(!config.display.colors); + std::env::remove_var("RTK_DISPLAY_COLORS"); + + // display.emoji + std::env::set_var("RTK_DISPLAY_EMOJI", "false"); + let config = Config::load_merged().unwrap(); + assert!(!config.display.emoji); + std::env::remove_var("RTK_DISPLAY_EMOJI"); + + // display.max_width + std::env::set_var("RTK_MAX_WIDTH", "200"); + let config = Config::load_merged().unwrap(); + assert_eq!(config.display.max_width, 200); + std::env::remove_var("RTK_MAX_WIDTH"); + } + + #[test] + fn test_project_local_overlay_overrides_global() { + let tmp = tempfile::tempdir().unwrap(); + let rtk_dir = tmp.path().join(".rtk"); + std::fs::create_dir_all(&rtk_dir).unwrap(); + std::fs::write( + rtk_dir.join("config.toml"), + "[tracking]\nhistory_days = 14\n", + ) + .unwrap(); + + // Simulate being in a project with .rtk/config.toml + let mut config = Config::default(); + assert_eq!(config.tracking.history_days, 90); // default + + let overlay_toml = "[tracking]\nhistory_days = 14\n"; + let overlay: ConfigOverlay = toml::from_str(overlay_toml).unwrap(); + overlay.apply(&mut config); + assert_eq!(config.tracking.history_days, 14); // project-local overrides + } + + #[test] + fn test_env_overrides_project_local_overlay() { + // Env vars have highest priority — even over project-local config. + // Tests overlay application directly (no env var race). + let mut config = Config::default(); + let overlay_toml = "[tracking]\nhistory_days = 14\n"; + let overlay: ConfigOverlay = toml::from_str(overlay_toml).unwrap(); + overlay.apply(&mut config); + assert_eq!(config.tracking.history_days, 14); // overlay applied + + // In load_merged, env vars are applied AFTER project overlay, + // so env vars always win. Tested via test_env_overrides_all_fields. + } + + #[test] + fn test_load_robust_to_missing_config() { + // Config::load() should fall back to defaults when config doesn't exist + let config = Config::load().unwrap(); + // Should have defaults — no crash + assert!(config.tracking.enabled); + assert_eq!(config.tracking.history_days, 90); + } + + #[test] + fn test_overlay_partial_sections() { + // Only display section in overlay — tracking should be untouched + let mut config = Config::default(); + config.tracking.history_days = 45; + + let overlay_toml = "[display]\nmax_width = 60\n"; + let overlay: ConfigOverlay = toml::from_str(overlay_toml).unwrap(); + overlay.apply(&mut config); + + assert_eq!(config.display.max_width, 60); // overridden + assert_eq!(config.tracking.history_days, 45); // untouched + } + + #[test] + fn test_overlay_partial_fields_within_section() { + // Only one field in tracking overlay — others untouched + let mut config = Config::default(); + config.tracking.enabled = false; + + let overlay_toml = "[tracking]\nhistory_days = 7\n"; + let overlay: ConfigOverlay = toml::from_str(overlay_toml).unwrap(); + overlay.apply(&mut config); + + assert_eq!(config.tracking.history_days, 7); // overridden + assert!(!config.tracking.enabled); // untouched (was false) + } + + #[test] + fn test_get_rules_dir_returns_dot_config_rtk() { + let dir = get_rules_dir().unwrap(); + let home = dirs::home_dir().unwrap(); + assert_eq!(dir, home.join(".config").join("rtk")); + } + + #[test] + fn test_env_override_invalid_value_ignored() { + // Invalid env values should be silently ignored, keeping the default + std::env::set_var("RTK_HISTORY_DAYS", "not_a_number"); + let config = Config::load_merged().unwrap(); + assert_eq!(config.tracking.history_days, 90); // default kept + std::env::remove_var("RTK_HISTORY_DAYS"); + + std::env::set_var("RTK_MAX_WIDTH", "abc"); + let config = Config::load_merged().unwrap(); + assert_eq!(config.display.max_width, 120); // default kept + std::env::remove_var("RTK_MAX_WIDTH"); + } + + #[test] + fn test_cli_overrides_default() { + // Default CLI overrides should not change behavior + let overrides = CliConfigOverrides::default(); + assert!(overrides.config_path.is_none()); // None = use normal discovery + assert!(overrides.config_add.is_empty()); + assert!(overrides.rules_path.is_none()); // None = use normal discovery + assert!(overrides.rules_add.is_empty()); + } + + #[test] + fn test_cli_config_path_multiple_files_merged() { + // --config-path a.toml --config-path b.toml merges both in order + let tmp = tempfile::tempdir().unwrap(); + let file_a = tmp.path().join("a.toml"); + let file_b = tmp.path().join("b.toml"); + std::fs::write(&file_a, "[tracking]\nhistory_days = 5\n").unwrap(); + std::fs::write(&file_b, "[display]\nmax_width = 60\n").unwrap(); + + // Simulate load_merged with exclusive paths + let mut cfg = Config::default(); + for path in &[&file_a, &file_b] { + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(overlay) = toml::from_str::(&content) { + overlay.apply(&mut cfg); + } + } + } + + assert_eq!(cfg.tracking.history_days, 5); // from a.toml + assert_eq!(cfg.display.max_width, 60); // from b.toml + assert!(cfg.tracking.enabled); // default (not in either file) + } + + #[test] + fn test_cli_config_path_exclusive() { + // --config-path loads ONLY from that file + let tmp = tempfile::tempdir().unwrap(); + let config_file = tmp.path().join("custom.toml"); + std::fs::write( + &config_file, + "[tracking]\nhistory_days = 5\nenabled = false\n", + ) + .unwrap(); + + // Simulate what load_merged does with exclusive path + let path = &config_file; + let config: Config = if path.exists() { + let content = std::fs::read_to_string(path).unwrap(); + toml::from_str(&content).unwrap() + } else { + Config::default() + }; + + assert_eq!(config.tracking.history_days, 5); + assert!(!config.tracking.enabled); + // Other fields get defaults since only tracking was specified + assert!(config.display.colors); + } + + #[test] + fn test_cli_config_add_overlay() { + // --config-add applies as high-priority overlay + let mut config = Config::default(); + assert_eq!(config.display.max_width, 120); + + let add_toml = "[display]\nmax_width = 60\n"; + let overlay: ConfigOverlay = toml::from_str(add_toml).unwrap(); + overlay.apply(&mut config); + + assert_eq!(config.display.max_width, 60); // overridden by --config-add + assert!(config.tracking.enabled); // untouched + } + + // === Error Robustness Tests === + + #[test] + fn test_load_robust_to_malformed_toml() { + let tmp = tempfile::tempdir().unwrap(); + let bad_config = tmp.path().join("config.toml"); + std::fs::write(&bad_config, "this is not valid toml {{{{").unwrap(); + + // Malformed TOML should parse to default (not crash) + let result: Result = toml::from_str("this is not valid toml {{{{"); + assert!(result.is_err()); + + // Config::load falls back to defaults for malformed content + let config = Config::load().unwrap(); + assert!(config.tracking.enabled); // defaults + } + + #[test] + fn test_load_robust_to_empty_config_file() { + // Empty string is valid TOML (all defaults) + let config: Config = toml::from_str("").unwrap(); + assert!(config.tracking.enabled); + assert_eq!(config.tracking.history_days, 90); + assert_eq!(config.display.max_width, 120); + } + + #[test] + fn test_load_robust_to_binary_garbage_config() { + let garbage = "\x00\x01\x02 binary garbage"; + let result: Result = toml::from_str(garbage); + assert!(result.is_err()); // Should error, not panic + } + + #[test] + fn test_overlay_robust_to_malformed_toml() { + let result: Result = toml::from_str("not valid {{{"); + assert!(result.is_err()); // Should error, not panic + } + + #[test] + fn test_overlay_from_empty_string() { + // Empty overlay should be all-None (no overrides) + let overlay: ConfigOverlay = toml::from_str("").unwrap(); + assert!(overlay.tracking.is_none()); + assert!(overlay.display.is_none()); + assert!(overlay.filters.is_none()); + } + + #[test] + fn test_config_path_exclusive_nonexistent_falls_back() { + // If --config-path points to non-existent file, use defaults + let path = PathBuf::from("/nonexistent/config.toml"); + assert!(!path.exists()); + // Simulates load_merged logic: non-existent → Config::default() + let config = Config::default(); + assert!(config.tracking.enabled); + } + + #[test] + fn test_config_add_nonexistent_path_skipped() { + // --config-add with non-existent path should be silently skipped + let path = PathBuf::from("/nonexistent/overlay.toml"); + assert!(!path.exists()); + // The load_merged code does `if add_path.exists()` — non-existent skipped + let mut config = Config::default(); + config.tracking.history_days = 42; + // Config unchanged because path doesn't exist + assert_eq!(config.tracking.history_days, 42); + } + + #[test] + fn test_config_add_malformed_file_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let bad_file = tmp.path().join("bad.toml"); + std::fs::write(&bad_file, "not valid {{{{ toml").unwrap(); + + // Simulates load_merged: if let Ok(overlay) = toml::from_str(...) + let content = std::fs::read_to_string(&bad_file).unwrap(); + let result = toml::from_str::(&content); + assert!(result.is_err()); // Bad TOML → no overlay applied + + // Config should remain at defaults + let config = Config::default(); + assert!(config.tracking.enabled); + } + + #[test] + fn test_set_value_creates_parent_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("nested").join("deep").join("config.toml"); + + // save_to should create parent dirs + let config = Config::default(); + let result = config.save_to(&config_path); + assert!(result.is_ok()); + assert!(config_path.exists()); + } + + // === DiscoveryConfig tests === + + #[test] + fn test_default_discovery_config() { + let config = DiscoveryConfig::default(); + assert_eq!(config.search_dirs, vec![".claude", ".gemini", ".rtk"]); + assert_eq!(config.global_dirs, vec![".claude", ".gemini"]); + assert!(config.rules_dirs.is_empty()); + } + + #[test] + fn test_discovery_config_roundtrip_toml() { + let config = Config::default(); + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.discovery.search_dirs, config.discovery.search_dirs); + assert_eq!(parsed.discovery.global_dirs, config.discovery.global_dirs); + assert_eq!(parsed.discovery.rules_dirs, config.discovery.rules_dirs); + } + + #[test] + fn test_discovery_config_from_toml_custom() { + let toml_str = r#" +[discovery] +search_dirs = [".rtk", ".custom"] +global_dirs = [".mytools"] +rules_dirs = ["/opt/rtk/rules", "/home/user/rules"] +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.discovery.search_dirs, vec![".rtk", ".custom"]); + assert_eq!(config.discovery.global_dirs, vec![".mytools"]); + assert_eq!( + config.discovery.rules_dirs, + vec![ + PathBuf::from("/opt/rtk/rules"), + PathBuf::from("/home/user/rules") + ] + ); + } + + #[test] + fn test_discovery_overlay_applies() { + let mut config = Config::default(); + let overlay: ConfigOverlay = toml::from_str( + r#" +[discovery] +search_dirs = [".only-rtk"] +rules_dirs = ["/custom/rules"] +"#, + ) + .unwrap(); + overlay.apply(&mut config); + assert_eq!(config.discovery.search_dirs, vec![".only-rtk"]); + // global_dirs unchanged (not in overlay) + assert_eq!(config.discovery.global_dirs, vec![".claude", ".gemini"]); + assert_eq!( + config.discovery.rules_dirs, + vec![PathBuf::from("/custom/rules")] + ); + } + + #[test] + fn test_apply_value_discovery_search_dirs() { + let mut config = Config::default(); + apply_value(&mut config, "discovery.search_dirs", ".rtk,.custom").unwrap(); + assert_eq!(config.discovery.search_dirs, vec![".rtk", ".custom"]); + } + + #[test] + fn test_apply_value_discovery_global_dirs() { + let mut config = Config::default(); + apply_value(&mut config, "discovery.global_dirs", ".claude").unwrap(); + assert_eq!(config.discovery.global_dirs, vec![".claude"]); + } + + #[test] + fn test_apply_value_discovery_rules_dirs() { + let mut config = Config::default(); + apply_value(&mut config, "discovery.rules_dirs", "/a,/b,/c").unwrap(); + assert_eq!( + config.discovery.rules_dirs, + vec![ + PathBuf::from("/a"), + PathBuf::from("/b"), + PathBuf::from("/c") + ] + ); + } + + #[test] + fn test_get_rules_dir_default() { + // Without any config override, get_rules_dir returns ~/.config/rtk/ + let dir = get_rules_dir().unwrap(); + assert!( + dir.to_string_lossy().contains("rtk"), + "Default rules dir should contain 'rtk': {}", + dir.display() + ); + } + + #[test] + fn test_discovery_config_empty_rules_dirs_not_serialized() { + // Empty rules_dirs should be omitted from TOML output (skip_serializing_if) + let config = Config::default(); + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!( + !toml_str.contains("rules_dirs"), + "Empty rules_dirs should be omitted from serialization" + ); + } +} diff --git a/src/config/rules.rs b/src/config/rules.rs new file mode 100644 index 00000000..13634746 --- /dev/null +++ b/src/config/rules.rs @@ -0,0 +1,881 @@ +//! Unified Rule system: safety rules, remaps, and warnings as data-driven MD files. +//! +//! Replaces `SafetyAction`, `SafetyRule`, `rule!()` macro, and `get_rules()` from safety.rs. +//! Rules are MD files with YAML frontmatter, loaded from built-in defaults and user directories. + +use anyhow::{anyhow, Result}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::OnceLock; + +/// A unified rule: safety, remap, warning, or block. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Rule { + pub name: String, + #[serde(default)] + pub patterns: Vec, + #[serde(default = "default_block")] + pub action: String, + #[serde(default)] + pub redirect: Option, + #[serde(default = "default_always")] + pub when: String, + #[serde(default)] + pub env_var: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(skip)] + pub message: String, + #[serde(skip)] + pub source: String, +} + +fn default_block() -> String { + "block".into() +} +fn default_always() -> String { + "always".into() +} +fn default_true() -> bool { + true +} + +impl Rule { + /// Check if rule should apply given current env + predicates. + pub fn should_apply(&self) -> bool { + // Env var opt-out check + if let Some(ref env) = self.env_var { + if let Ok(val) = std::env::var(env) { + if val == "0" || val == "false" { + return false; + } + } + } + // When predicate + check_when(&self.when) + } +} + +// === Predicate Registry === + +type PredicateFn = fn() -> bool; + +fn predicate_registry() -> &'static HashMap<&'static str, PredicateFn> { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut m = HashMap::new(); + m.insert("always", (|| true) as PredicateFn); + m.insert( + "has_unstaged_changes", + crate::cmd::predicates::has_unstaged_changes as PredicateFn, + ); + m + }) +} + +pub fn check_when(when: &str) -> bool { + if when == "always" || when.is_empty() { + return true; + } + if let Some(func) = predicate_registry().get(when) { + return func(); + } + // Bash fallback (matches clautorun behavior) + std::process::Command::new("sh") + .args(["-c", when]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +// === Parse & Load === + +/// Parse a rule from MD content with YAML frontmatter. +pub fn parse_rule(content: &str, source: &str) -> Result { + let trimmed = content.trim(); + let rest = trimmed + .strip_prefix("---") + .ok_or_else(|| anyhow!("No frontmatter: missing opening ---"))?; + let end = rest + .find("\n---") + .ok_or_else(|| anyhow!("Unclosed frontmatter: missing closing ---"))?; + let yaml = &rest[..end]; + let body = rest[end + 4..].trim(); + let mut rule: Rule = serde_yaml::from_str(yaml)?; + rule.message = body.to_string(); + rule.source = source.to_string(); + Ok(rule) +} + +/// Embedded default rules (compiled into binary). +pub const DEFAULT_RULES: &[&str] = &[ + include_str!("../rules/rtk.safety.rm-to-trash.md"), + include_str!("../rules/rtk.safety.git-reset-hard.md"), + include_str!("../rules/rtk.safety.git-checkout-dashdash.md"), + include_str!("../rules/rtk.safety.git-checkout-dot.md"), + include_str!("../rules/rtk.safety.git-stash-drop.md"), + include_str!("../rules/rtk.safety.git-clean-fd.md"), + include_str!("../rules/rtk.safety.git-clean-df.md"), + include_str!("../rules/rtk.safety.git-clean-f.md"), + include_str!("../rules/rtk.safety.block-cat.md"), + include_str!("../rules/rtk.safety.block-sed.md"), + include_str!("../rules/rtk.safety.block-head.md"), +]; + +static RULES_CACHE: OnceLock> = OnceLock::new(); + +/// Load all rules: embedded defaults + user overrides. Cached via OnceLock. +pub fn load_all() -> &'static [Rule] { + RULES_CACHE.get_or_init(|| { + let mut rules_by_name: BTreeMap = BTreeMap::new(); + + // 1. Embedded defaults (lowest priority) + for content in DEFAULT_RULES { + match parse_rule(content, "builtin") { + Ok(rule) if rule.enabled => { + rules_by_name.insert(rule.name.clone(), rule); + } + Ok(rule) => { + rules_by_name.remove(&rule.name); + } + Err(e) => eprintln!("rtk: bad builtin rule: {e}"), + } + } + + // 2. User files (higher priority overrides by name) + for path in super::discovery::discover_rtk_files() { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + match parse_rule(&content, &path.display().to_string()) { + Ok(rule) if rule.enabled => { + rules_by_name.insert(rule.name.clone(), rule); + } + Ok(rule) => { + rules_by_name.remove(&rule.name); + } + Err(_) => continue, + } + } + + rules_by_name.into_values().collect() + }) +} + +// === Global Option Stripping === + +/// Strip global options that appear between a command and its subcommand. +/// +/// Tools like git, cargo, docker, and kubectl accept global options before +/// the subcommand (e.g., `git -C /path --no-pager status`). These must be +/// stripped before pattern matching so that safety rules like `"git reset --hard"` +/// still match `git --no-pager reset --hard`. +/// +/// Based on the patterns from upstream PR #99 (hooks/rtk-rewrite.sh). +fn strip_global_options(full_cmd: &str) -> String { + let words: Vec<&str> = full_cmd.split_whitespace().collect(); + if words.is_empty() { + return full_cmd.to_string(); + } + + let binary = words[0]; + let rest = &words[1..]; + + match binary { + "git" => { + // Strip: -C , -c , --no-pager, --no-optional-locks, + // --bare, --literal-pathspecs, --key=value + let mut result = vec!["git"]; + let mut i = 0; + while i < rest.len() { + let w = rest[i]; + if (w == "-C" || w == "-c") && i + 1 < rest.len() { + i += 2; // skip flag + argument + } else if w.starts_with("--") + && w.contains('=') + && !w.starts_with("--hard") + && !w.starts_with("--force") + { + i += 1; // skip --key=value global options + } else if matches!( + w, + "--no-pager" + | "--no-optional-locks" + | "--bare" + | "--literal-pathspecs" + | "--paginate" + | "--git-dir" + ) { + i += 1; // skip standalone boolean global options + } else { + // First non-global-option word is the subcommand; keep everything from here + result.extend_from_slice(&rest[i..]); + break; + } + } + result.join(" ") + } + "cargo" => { + // Strip: +toolchain (e.g., cargo +nightly test) + let mut result = vec!["cargo"]; + let mut i = 0; + while i < rest.len() { + let w = rest[i]; + if w.starts_with('+') { + i += 1; // skip +toolchain + } else { + result.extend_from_slice(&rest[i..]); + break; + } + } + result.join(" ") + } + "docker" => { + // Strip: -H , --context , --config , --key=value + let mut result = vec!["docker"]; + let mut i = 0; + while i < rest.len() { + let w = rest[i]; + if matches!(w, "-H" | "--context" | "--config") && i + 1 < rest.len() { + i += 2; // skip flag + argument + } else if w.starts_with("--") && w.contains('=') { + i += 1; // skip --key=value + } else { + result.extend_from_slice(&rest[i..]); + break; + } + } + result.join(" ") + } + "kubectl" => { + // Strip: --context , --kubeconfig , --namespace , -n , --key=value + let mut result = vec!["kubectl"]; + let mut i = 0; + while i < rest.len() { + let w = rest[i]; + if matches!(w, "--context" | "--kubeconfig" | "--namespace" | "-n") + && i + 1 < rest.len() + { + i += 2; // skip flag + argument + } else if w.starts_with("--") && w.contains('=') { + i += 1; // skip --key=value + } else { + result.extend_from_slice(&rest[i..]); + break; + } + } + result.join(" ") + } + _ => full_cmd.to_string(), + } +} + +// === Pattern Matching === + +/// Check if a rule matches a command. +/// +/// - Single-word pattern: exact binary match (avoids "cat" matching "catalog") +/// - Multi-word pattern: prefix match on full command string (with global option stripping) +/// - Raw mode (binary=None): word-boundary search (handles "sudo rm") +pub fn matches_rule(rule: &Rule, binary: Option<&str>, full_cmd: &str) -> bool { + rule.patterns.iter().any(|pat| { + if pat.contains(' ') { + // Multi-word: prefix match, also try with global options stripped + let normalized = strip_global_options(full_cmd); + full_cmd.starts_with(pat.as_str()) || normalized.starts_with(pat.as_str()) + } else if let Some(bin) = binary { + // Parsed mode: exact binary + bin == pat + } else { + // Raw mode: word-boundary (handles "sudo rm", "/usr/bin/rm") + full_cmd + .split_whitespace() + .any(|w| w == pat || w.ends_with(&format!("/{pat}"))) + } + }) +} + +// === Remap Helper === + +/// Try to expand a single-word remap alias (e.g., "t --lib" → "cargo test --lib"). +/// +/// Only matches single-word patterns with `action: "rewrite"`. Multi-word rewrites +/// are safety rules handled by `check()`. Order: remap → safety → execute. +pub fn try_remap(raw: &str) -> Option { + let first_word = raw.split_whitespace().next()?; + for rule in load_all() { + if rule.action != "rewrite" { + continue; + } + // Only remap single-word pattern matches (aliases like "t" → "cargo test") + if !rule + .patterns + .iter() + .any(|p| !p.contains(' ') && p == first_word) + { + continue; + } + if !rule.should_apply() { + continue; + } + if let Some(ref redirect) = rule.redirect { + let rest = raw[first_word.len()..].trim(); + return Some(redirect.replace("{args}", rest)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_rule_valid() { + let content = "---\nname: test-rule\npatterns: [rm]\naction: trash\n---\nSafety message."; + let rule = parse_rule(content, "test").unwrap(); + assert_eq!(rule.name, "test-rule"); + assert_eq!(rule.patterns, vec!["rm"]); + assert_eq!(rule.action, "trash"); + assert_eq!(rule.message, "Safety message."); + assert_eq!(rule.source, "test"); + } + + #[test] + fn test_parse_rule_no_frontmatter() { + let content = "No frontmatter here"; + assert!(parse_rule(content, "test").is_err()); + } + + #[test] + fn test_parse_rule_unclosed_frontmatter() { + let content = "---\nname: broken\n"; + assert!(parse_rule(content, "test").is_err()); + } + + #[test] + fn test_parse_rule_message_body() { + let content = "---\nname: test\n---\n\nLine 1\n\nLine 2"; + let rule = parse_rule(content, "test").unwrap(); + assert_eq!(rule.message, "Line 1\n\nLine 2"); + } + + #[test] + fn test_parse_rule_defaults() { + let content = "---\nname: minimal\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + assert_eq!(rule.action, "block"); // default + assert_eq!(rule.when, "always"); // default + assert!(rule.enabled); // default true + assert!(rule.patterns.is_empty()); // default empty + } + + #[test] + fn test_parse_rule_all_fields() { + let content = r#"--- +name: full +patterns: ["git reset --hard"] +action: rewrite +redirect: "git stash && git reset --hard {args}" +when: has_unstaged_changes +env_var: RTK_SAFE_COMMANDS +enabled: true +--- +Full message."#; + let rule = parse_rule(content, "builtin").unwrap(); + assert_eq!(rule.name, "full"); + assert_eq!(rule.patterns, vec!["git reset --hard"]); + assert_eq!(rule.action, "rewrite"); + assert_eq!( + rule.redirect.as_deref(), + Some("git stash && git reset --hard {args}") + ); + assert_eq!(rule.when, "has_unstaged_changes"); + assert_eq!(rule.env_var.as_deref(), Some("RTK_SAFE_COMMANDS")); + assert!(rule.enabled); + assert_eq!(rule.message, "Full message."); + } + + #[test] + fn test_matches_rule_single_word_binary() { + let content = "---\nname: test\npatterns: [rm]\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + assert!(matches_rule(&rule, Some("rm"), "rm file.txt")); + assert!(!matches_rule(&rule, Some("rmdir"), "rmdir empty")); + } + + #[test] + fn test_matches_rule_multiple_patterns_in_one_rule() { + let content = + "---\nname: test\npatterns: [\"chmod -R 777\", \"chmod 777\"]\naction: warn\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + assert_eq!(rule.patterns.len(), 2); + assert!(matches_rule(&rule, Some("chmod"), "chmod -R 777 /tmp")); + assert!(matches_rule(&rule, Some("chmod"), "chmod 777 /tmp")); + assert!(!matches_rule(&rule, Some("chmod"), "chmod 755 /tmp")); + } + + #[test] + fn test_matches_rule_multi_word_prefix() { + let content = "---\nname: test\npatterns: [\"git reset --hard\"]\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + assert!(matches_rule(&rule, Some("git"), "git reset --hard HEAD~1")); + assert!(!matches_rule(&rule, Some("git"), "git reset --soft HEAD")); + } + + #[test] + fn test_matches_rule_raw_mode_word_boundary() { + let content = "---\nname: test\npatterns: [rm]\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + // Raw mode: None for binary + assert!(matches_rule(&rule, None, "rm file.txt")); + assert!(matches_rule(&rule, None, "sudo rm file.txt")); + assert!(matches_rule(&rule, None, "/usr/bin/rm file.txt")); + // Should NOT match substrings + assert!(!matches_rule(&rule, None, "trim file.txt")); + assert!(!matches_rule(&rule, None, "farm --harvest")); + } + + #[test] + fn test_should_apply_env_var_opt_out() { + let content = "---\nname: test\npatterns: [rm]\nenv_var: RTK_TEST_VAR\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + + // No env var set → applies (opt-out model) + assert!(rule.should_apply()); + + // Set to "0" → disabled + std::env::set_var("RTK_TEST_VAR", "0"); + assert!(!rule.should_apply()); + + // Set to "false" → disabled + std::env::set_var("RTK_TEST_VAR", "false"); + assert!(!rule.should_apply()); + + // Set to "1" → enabled + std::env::set_var("RTK_TEST_VAR", "1"); + assert!(rule.should_apply()); + + std::env::remove_var("RTK_TEST_VAR"); + } + + #[test] + fn test_should_apply_when_always() { + let content = "---\nname: test\nwhen: always\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + assert!(rule.should_apply()); + } + + #[test] + fn test_load_all_includes_builtins() { + let rules = load_all(); + assert!( + rules.len() >= 11, + "Should have at least 11 built-in rules, got {}", + rules.len() + ); + // Check specific built-in names + let names: Vec<&str> = rules.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"rm-to-trash")); + assert!(names.contains(&"block-cat")); + assert!(names.contains(&"git-reset-hard")); + } + + #[test] + fn test_check_when_always() { + assert!(check_when("always")); + assert!(check_when("")); + } + + #[test] + fn test_check_when_builtin_predicate() { + // has_unstaged_changes is registered - should not panic + let _ = check_when("has_unstaged_changes"); + } + + #[test] + fn test_check_when_bash_fallback() { + assert!(check_when("true")); + assert!(!check_when("false")); + } + + #[test] + fn test_try_remap_no_match() { + // "ls" is not a registered remap alias + assert!(try_remap("ls -la").is_none()); + } + + // Note: try_remap with a match requires user-defined rules in discovery dirs, + // which is tested in E2E tests rather than unit tests. + + #[test] + fn test_rule_override_by_name() { + // Simulate: builtin rule overridden by user rule with same name + let mut rules_by_name: BTreeMap = BTreeMap::new(); + + let builtin = parse_rule( + "---\nname: rm-to-trash\npatterns: [rm]\naction: trash\n---\nBuiltin message.", + "builtin", + ) + .unwrap(); + rules_by_name.insert(builtin.name.clone(), builtin); + + // User override: same name, different action + let user_rule = parse_rule( + "---\nname: rm-to-trash\npatterns: [rm]\naction: block\n---\nUser blocked rm.", + "~/.config/rtk/rtk.safety.rm-to-trash.md", + ) + .unwrap(); + rules_by_name.insert(user_rule.name.clone(), user_rule); + + let rules: Vec = rules_by_name.into_values().collect(); + assert_eq!(rules.len(), 1); // Overridden, not duplicated + assert_eq!(rules[0].action, "block"); // User's action wins + assert_eq!(rules[0].message, "User blocked rm."); // User's message wins + } + + #[test] + fn test_rule_disabled_override_removes() { + // Simulate: user disables a builtin rule + let mut rules_by_name: BTreeMap = BTreeMap::new(); + + let builtin = parse_rule( + "---\nname: block-cat\npatterns: [cat]\naction: suggest_tool\n---\nUse Read.", + "builtin", + ) + .unwrap(); + rules_by_name.insert(builtin.name.clone(), builtin); + + // User disables it + let disabled = parse_rule( + "---\nname: block-cat\nenabled: false\n---\nDisabled by user.", + "~/.config/rtk/rtk.safety.block-cat.md", + ) + .unwrap(); + assert!(!disabled.enabled); + + // The load_all logic: enabled=false removes from map + if !disabled.enabled { + rules_by_name.remove(&disabled.name); + } + + assert!(rules_by_name.is_empty()); // Rule removed + } + + #[test] + fn test_all_builtin_rules_parse_successfully() { + for (i, content) in DEFAULT_RULES.iter().enumerate() { + let result = parse_rule(content, "builtin"); + assert!( + result.is_ok(), + "Built-in rule #{} failed to parse: {:?}", + i, + result.err() + ); + let rule = result.unwrap(); + assert!(!rule.name.is_empty(), "Rule #{} has empty name", i); + assert!( + rule.enabled, + "Rule #{} ({}) should be enabled", + i, rule.name + ); + } + } + + #[test] + fn test_all_builtin_rules_have_patterns() { + for content in DEFAULT_RULES { + let rule = parse_rule(content, "builtin").unwrap(); + assert!( + !rule.patterns.is_empty(), + "Rule '{}' has no patterns", + rule.name + ); + } + } + + // === Error Robustness Tests === + + #[test] + fn test_parse_rule_empty_string() { + assert!(parse_rule("", "test").is_err()); + } + + #[test] + fn test_parse_rule_binary_garbage() { + assert!(parse_rule("\x00\x01\x02 garbage", "test").is_err()); + } + + #[test] + fn test_parse_rule_valid_frontmatter_invalid_yaml() { + let content = "---\n: : : not valid yaml\n---\nbody"; + assert!(parse_rule(content, "test").is_err()); + } + + #[test] + fn test_parse_rule_missing_name_field() { + // YAML without required 'name' field + let content = "---\npatterns: [rm]\n---\nbody"; + assert!(parse_rule(content, "test").is_err()); + } + + #[test] + fn test_parse_rule_only_frontmatter_delimiters() { + let content = "---\n---\n"; + // Empty YAML → missing name → error + assert!(parse_rule(content, "test").is_err()); + } + + #[test] + fn test_parse_rule_extra_fields_ignored() { + // Unknown fields in YAML should be silently ignored (serde default) + let content = "---\nname: test\nunknown_field: 42\nextra: true\n---\nbody"; + let rule = parse_rule(content, "test"); + assert!( + rule.is_ok(), + "Unknown fields should be ignored, got: {:?}", + rule.err() + ); + assert_eq!(rule.unwrap().name, "test"); + } + + #[test] + fn test_check_when_nonexistent_command() { + // A nonsense bash command should return false (not panic) + assert!(!check_when("totally_nonexistent_command_xyz_12345")); + } + + #[test] + fn test_try_remap_empty_string() { + assert!(try_remap("").is_none()); + } + + #[test] + fn test_try_remap_whitespace_only() { + assert!(try_remap(" ").is_none()); + } + + #[test] + fn test_matches_rule_empty_patterns() { + let content = "---\nname: no-patterns\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + assert!(!matches_rule(&rule, Some("rm"), "rm file")); + assert!(!matches_rule(&rule, None, "rm file")); + } + + // === Precedence Chain Tests === + + #[test] + fn test_full_precedence_chain_builtin_global_project() { + // Simulates the full load_all() precedence: builtin → global → project + let mut rules_by_name: BTreeMap = BTreeMap::new(); + + // 1. Builtin (lowest priority): action=trash + let builtin = parse_rule( + "---\nname: rm-to-trash\npatterns: [rm]\naction: trash\n---\nBuiltin.", + "builtin", + ) + .unwrap(); + rules_by_name.insert(builtin.name.clone(), builtin); + + // 2. Global user file (~/.config/rtk/): action=warn (user edited the exported file) + let global = parse_rule( + "---\nname: rm-to-trash\npatterns: [rm]\naction: warn\n---\nGlobal user override.", + "~/.config/rtk/rtk.safety.rm-to-trash.md", + ) + .unwrap(); + rules_by_name.insert(global.name.clone(), global); + + // 3. Project-local (.rtk/): action=block (project-specific) + let project = parse_rule( + "---\nname: rm-to-trash\npatterns: [rm]\naction: block\n---\nProject override.", + "/project/.rtk/rtk.safety.rm-to-trash.md", + ) + .unwrap(); + rules_by_name.insert(project.name.clone(), project); + + let rules: Vec = rules_by_name.into_values().collect(); + assert_eq!(rules.len(), 1, "Should be 1 rule after all overrides"); + assert_eq!(rules[0].action, "block", "Project-local should win"); + assert_eq!(rules[0].source, "/project/.rtk/rtk.safety.rm-to-trash.md"); + } + + #[test] + fn test_user_edited_export_overrides_builtin() { + // User exports builtins then edits one: edited file should override compiled builtin + let mut rules_by_name: BTreeMap = BTreeMap::new(); + + // Compiled builtin + let builtin = parse_rule( + "---\nname: block-cat\npatterns: [cat]\naction: suggest_tool\nredirect: Read\n---\nBuiltin.", + "builtin", + ) + .unwrap(); + rules_by_name.insert(builtin.name.clone(), builtin); + + // User-edited export: changed redirect + let edited = parse_rule( + "---\nname: block-cat\npatterns: [cat]\naction: suggest_tool\nredirect: \"Read (with limit=50)\"\n---\nUser customized.", + "~/.config/rtk/rtk.safety.block-cat.md", + ) + .unwrap(); + rules_by_name.insert(edited.name.clone(), edited); + + let rules: Vec = rules_by_name.into_values().collect(); + assert_eq!(rules.len(), 1); + assert_eq!( + rules[0].redirect.as_deref(), + Some("Read (with limit=50)"), + "User-edited redirect should win" + ); + assert!(rules[0].source.contains(".config/rtk/")); + } + + #[test] + fn test_project_local_disable_overrides_global_and_builtin() { + // Project disables a rule that exists both in builtins and global + let mut rules_by_name: BTreeMap = BTreeMap::new(); + + // Builtin + let builtin = parse_rule( + "---\nname: block-sed\npatterns: [sed]\naction: suggest_tool\n---\nBuiltin.", + "builtin", + ) + .unwrap(); + rules_by_name.insert(builtin.name.clone(), builtin); + + // Global user file (same as builtin, maybe exported) + let global = parse_rule( + "---\nname: block-sed\npatterns: [sed]\naction: suggest_tool\n---\nGlobal.", + "~/.config/rtk/rtk.safety.block-sed.md", + ) + .unwrap(); + rules_by_name.insert(global.name.clone(), global); + + // Project-local disables it + let disabled = parse_rule( + "---\nname: block-sed\nenabled: false\n---\nDisabled for this project.", + "/project/.rtk/rtk.safety.block-sed.md", + ) + .unwrap(); + if !disabled.enabled { + rules_by_name.remove(&disabled.name); + } + + assert!( + rules_by_name.is_empty(), + "Project-local disable should remove rule entirely" + ); + } + + // === Global Option Stripping (PR #99 parity) === + // Table-driven: (input, expected_output) pairs covering git, cargo, docker, kubectl. + + #[test] + fn test_strip_global_options() { + let cases: &[(&str, &str)] = &[ + // Git: single flags + ("git --no-pager status", "git status"), + ("git -C /path/to/project status", "git status"), + ("git -c core.autocrlf=true diff", "git diff"), + ("git --git-dir=/path/.git status", "git status"), + ("git --no-optional-locks status", "git status"), + ("git --bare log --oneline", "git log --oneline"), + ("git --literal-pathspecs add .", "git add ."), + // Git: multiple globals stacked + ( + "git -C /path --no-pager --no-optional-locks reset --hard", + "git reset --hard", + ), + // Git: subcommand flags preserved (not stripped) + ("git reset --hard HEAD~1", "git reset --hard HEAD~1"), + ("git checkout --force main", "git checkout --force main"), + // Git: no globals (identity) + ("git status", "git status"), + ("git log --oneline -10", "git log --oneline -10"), + // Cargo: toolchain prefix + ("cargo +nightly test", "cargo test"), + ("cargo +stable build --release", "cargo build --release"), + ("cargo test", "cargo test"), // no prefix (identity) + // Docker: global flags + ("docker --context prod ps", "docker ps"), + ("docker -H tcp://host:2375 images", "docker images"), + ("docker --config /tmp/.docker run hello", "docker run hello"), + ("docker ps", "docker ps"), // no globals (identity) + // Kubectl: global flags + ("kubectl -n kube-system get pods", "kubectl get pods"), + ( + "kubectl --context prod --namespace default describe pod foo", + "kubectl describe pod foo", + ), + ("kubectl --kubeconfig=/path get svc", "kubectl get svc"), + ("kubectl get pods", "kubectl get pods"), // no globals (identity) + // Non-matching commands (identity) + ("rm -rf /tmp/foo", "rm -rf /tmp/foo"), + ("cat file.txt", "cat file.txt"), + ("echo hello", "echo hello"), + ]; + for (input, expected) in cases { + assert_eq!( + strip_global_options(input), + *expected, + "strip_global_options({input:?})" + ); + } + } + + // === Rule Matching with Global Options (PR #99 parity) === + // Multi-word safety patterns must match even with global options inserted. + + #[test] + fn test_matches_rule_with_global_options() { + let cases: &[(&str, &str, bool)] = &[ + // (pattern, full_cmd, expected_match) + ("git reset --hard", "git --no-pager reset --hard HEAD", true), + ("git reset --hard", "git -C /path reset --hard", true), + ( + "git reset --hard", + "git -C /p --no-pager --no-optional-locks reset --hard", + true, + ), + ("git checkout .", "git -C /project checkout .", true), + ( + "git checkout --", + "git --no-pager checkout -- file.txt", + true, + ), + ( + "git clean -fd", + "git -C /path --no-pager --no-optional-locks clean -fd", + true, + ), + ("git stash drop", "git --no-pager stash drop", true), + // No globals: direct match still works + ("git reset --hard", "git reset --hard HEAD~1", true), + ("git checkout .", "git checkout .", true), + // Non-matching + ("git reset --hard", "git reset --soft HEAD", false), + ("git checkout .", "git checkout main", false), + ]; + for (pattern, full_cmd, expected) in cases { + let yaml = format!("---\nname: test\npatterns: [\"{pattern}\"]\n---\n"); + let rule = parse_rule(&yaml, "test").unwrap(); + let binary = full_cmd.split_whitespace().next(); + assert_eq!( + matches_rule(&rule, binary, full_cmd), + *expected, + "matches_rule(pat={pattern:?}, cmd={full_cmd:?})" + ); + } + } + + #[test] + fn test_matches_rule_empty_command() { + let content = "---\nname: test\npatterns: [rm]\n---\n"; + let rule = parse_rule(content, "test").unwrap(); + // Parsed mode: binary match is independent of full_cmd + assert!(matches_rule(&rule, Some("rm"), "")); + // Raw mode: empty string has no words → no match + assert!(!matches_rule(&rule, None, "")); + } +} 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..cdc1a820 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() { @@ -974,7 +975,7 @@ fn remove_rtk_block(content: &str) -> (String, bool) { } /// Resolve ~/.claude directory with proper home expansion -fn resolve_claude_dir() -> Result { +pub(crate) fn resolve_claude_dir() -> Result { dirs::home_dir() .map(|h| h.join(".claude")) .context("Cannot determine home directory. Is $HOME set?") @@ -1134,15 +1135,10 @@ mod tests { #[test] fn test_hook_has_guards() { - assert!(REWRITE_HOOK.contains("command -v rtk")); - assert!(REWRITE_HOOK.contains("command -v jq")); - // Guards must be BEFORE set -euo pipefail - let guard_pos = REWRITE_HOOK.find("command -v rtk").unwrap(); - let set_pos = REWRITE_HOOK.find("set -euo pipefail").unwrap(); - assert!( - guard_pos < set_pos, - "Guards must come before set -euo pipefail" - ); + // PR 1 v2: Replaced bash hook with binary shim + // Old test checked for "command -v rtk" and "command -v jq" guards + // New shim just execs rtk hook claude binary (no bash guards needed) + assert!(REWRITE_HOOK.contains("exec rtk hook claude")); } #[test] diff --git a/src/main.rs b/src/main.rs index fcb39303..99027809 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cargo_cmd; mod cc_economics; mod ccusage; +mod cmd; mod config; mod container; mod curl_cmd; @@ -30,6 +31,7 @@ mod next_cmd; mod npm_cmd; mod parser; mod pip_cmd; +mod pipe_cmd; mod playwright_cmd; mod pnpm_cmd; mod prettier_cmd; @@ -38,6 +40,7 @@ mod pytest_cmd; mod read; mod ruff_cmd; mod runner; +mod stream; mod summary; mod tee; mod tracking; @@ -75,6 +78,10 @@ struct Cli { /// Set SKIP_ENV_VALIDATION=1 for child processes (Next.js, tsc, lint, prisma) #[arg(long = "skip-env", global = true)] skip_env: bool, + + /// Passthrough mode: emit raw output without RTK filtering + #[arg(long, global = true)] + passthrough: bool, } #[derive(Subcommand)] @@ -356,9 +363,11 @@ enum Commands { format: String, }, - /// Show or create configuration file + /// Show, create, or modify configuration Config { - /// Create default config file + #[command(subcommand)] + action: Option, + /// Create default config file (backward compat) #[arg(long)] create: bool, }, @@ -495,6 +504,16 @@ enum Commands { args: Vec, }, + /// Read stdin and reduce tokens (pipe-as-filter mode) + Pipe { + /// Filter to apply (cargo-test, pytest, go-test, go-build, tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status) + #[arg(long, short = 'f')] + filter: Option, + /// Emit stdin unchanged (no filtering) + #[arg(long)] + passthrough: bool, + }, + /// Ruff linter/formatter with compact output Ruff { /// Ruff arguments (e.g., check, format --check) @@ -537,6 +556,81 @@ enum Commands { #[arg(short, long, default_value = "7")] since: u64, }, + + /// Run command with safety checks and token-optimized output + Run { + /// Command string to execute + #[arg(short = 'c', long)] + command: String, + }, + + /// Hook protocol for Claude Code integration + Hook { + #[command(subcommand)] + command: HookCommands, + }, +} + +#[derive(Subcommand)] +enum HookCommands { + /// Check command for safety and rewrite (text protocol for debugging) + Check { + /// Agent type: claude or gemini + #[arg(long, default_value = "claude")] + agent: String, + /// Command to check + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, + }, + /// Claude Code JSON protocol handler (reads stdin, writes stdout) + Claude, +} + +#[derive(Subcommand)] +enum ConfigCommands { + /// Get a config value by key + Get { + /// Dotted key (e.g., "tracking.enabled") + key: String, + }, + /// Set a config value or create a rule + Set { + /// Dotted key (e.g., "display.max_width" or "rules.my-alias") + key: String, + /// Value to set (for scalar config) or redirect template (for rules) + value: Option, + /// Pattern for rule (e.g., "t" or "git reset --hard") + #[arg(long)] + pattern: Option, + /// Action for rule: block, warn, rewrite, trash, suggest_tool + #[arg(long)] + action: Option, + /// Write to project-local .rtk/config.toml + #[arg(long)] + local: bool, + }, + /// List all config values + List { + /// Show where each value comes from + #[arg(long)] + origin: bool, + }, + /// Remove a config key (reset to default) + Unset { + /// Dotted key to remove + key: String, + /// Remove from project-local .rtk/config.toml + #[arg(long)] + local: bool, + }, + /// Create default config file + Create, + /// Export built-in rules as editable MD files + ExportRules { + /// Export to ~/.claude/ instead of ~/.config/rtk/ + #[arg(long)] + claude: bool, + }, } #[derive(Subcommand)] @@ -1179,11 +1273,64 @@ fn main() -> Result<()> { cc_economics::run(daily, weekly, monthly, all, &format, cli.verbose)?; } - Commands::Config { create } => { + Commands::Config { action, create } => { + // Backward compat: --create flag if create { let path = config::Config::create_default()?; println!("Created: {}", path.display()); + } else if let Some(action) = action { + match action { + ConfigCommands::Get { key } => match config::get_value(&key) { + Ok(val) => println!("{val}"), + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }, + ConfigCommands::Set { + key, + value, + pattern, + action, + local, + } => { + if key.starts_with("rules.") { + let rule_name = key.strip_prefix("rules.").unwrap(); + config::set_rule( + rule_name, + pattern.as_deref(), + action.as_deref(), + value.as_deref(), + local, + )?; + } else { + let val = value.ok_or_else(|| { + anyhow::anyhow!("Value required for scalar config key: {key}") + })?; + config::set_value(&key, &val, local)?; + } + } + ConfigCommands::List { origin } => { + config::list_values(origin)?; + } + ConfigCommands::Unset { key, local } => { + if key.starts_with("rules.") { + let rule_name = key.strip_prefix("rules.").unwrap(); + config::unset_rule(rule_name, local)?; + } else { + config::unset_value(&key, local)?; + } + } + ConfigCommands::Create => { + let path = config::Config::create_default()?; + println!("Created: {}", path.display()); + } + ConfigCommands::ExportRules { claude } => { + config::export_rules(claude)?; + } + } } else { + // No subcommand: show config (backward compat) config::show_config()?; } } @@ -1431,6 +1578,37 @@ fn main() -> Result<()> { hook_audit_cmd::run(since, cli.verbose)?; } + Commands::Run { command } => { + let code = cmd::execute(&command, cli.verbose)?; + if code != 0 { + std::process::exit(code); + } + } + + Commands::Pipe { + filter, + passthrough, + } => { + pipe_cmd::run(filter.as_deref(), passthrough)?; + } + + Commands::Hook { command } => match command { + HookCommands::Check { agent, command } => { + let cmd_str = command.join(" "); + let result = cmd::check_for_hook(&cmd_str, &agent); + let (output, _success, code) = cmd::hook::format_for_claude(result); + if code == 0 { + println!("{}", output); + } else { + eprintln!("{}", output); + std::process::exit(code); + } + } + HookCommands::Claude => { + cmd::claude_hook::run()?; + } + }, + Commands::Proxy { args } => { use std::process::Command; diff --git a/src/pipe_cmd.rs b/src/pipe_cmd.rs new file mode 100644 index 00000000..e6dfbe62 --- /dev/null +++ b/src/pipe_cmd.rs @@ -0,0 +1,392 @@ +//! `rtk pipe` — Read stdin, reduce tokens, print filtered output. +//! +//! Enables `command | rtk pipe [--filter ]` usage where RTK acts as a +//! pure token-reduction filter in a Unix pipeline. +//! +//! # Supported filters +//! | Name | Source module | Typical input | +//! |------|--------------|---------------| +//! | `cargo-test` | cargo_cmd::filter_cargo_test | `cargo test` output | +//! | `pytest` | pytest_cmd::filter_pytest_output | `pytest` output | +//! | `go-test` | go_cmd::filter_go_test_json | `go test -json` output | +//! | `go-build` | go_cmd::filter_go_build | `go build` stderr | +//! | `tsc` | tsc_cmd::filter_tsc_output | `tsc` compiler output | +//! | `vitest` | vitest_cmd::filter_vitest_output | `vitest --reporter=json` | +//! | `grep` / `rg` | grep_cmd::filter_grep_raw | `rg -n --no-heading` output | +//! | `find` / `fd` | grep_cmd::filter_find_output | `find` / `fd` path output | +//! | `git-log` | git::filter_log_output | `git log` output | +//! | `git-diff` | git::compact_diff | `git diff` output | +//! | `git-status` | git::format_status_output | `git status --porcelain=v1` | + +use anyhow::Result; +use std::io::Read; + +/// Resolve a filter name to a `fn(&str) -> String` function pointer. +/// +/// Returns `None` if the filter name is not recognised. +pub fn resolve_filter(name: &str) -> Option String> { + match name { + "cargo-test" | "cargo" => Some(crate::cargo_cmd::filter_cargo_test), + "pytest" => Some(crate::pytest_cmd::filter_pytest_output), + "go-test" => Some(go_test_wrapper), + "go-build" => Some(crate::go_cmd::filter_go_build), + "tsc" => Some(crate::tsc_cmd::filter_tsc_output), + "vitest" => Some(crate::vitest_cmd::filter_vitest_output), + "grep" | "rg" => Some(crate::grep_cmd::filter_grep_raw), + "find" | "fd" => Some(crate::grep_cmd::filter_find_output), + "git-log" => Some(git_log_wrapper), + "git-diff" => Some(git_diff_wrapper), + "git-status" => Some(crate::git::format_status_output), + _ => None, + } +} + +// Wrappers to adapt functions with extra parameters to fn(&str) -> String + +fn go_test_wrapper(input: &str) -> String { + crate::go_cmd::filter_go_test_json(input) +} + +fn git_log_wrapper(input: &str) -> String { + // Default to 50 log lines when used as a pipe filter + crate::git::filter_log_output(input, 50) +} + +fn git_diff_wrapper(input: &str) -> String { + // Default to 200 diff lines + crate::git::compact_diff(input, 200) +} + +/// Auto-detect the appropriate filter based on input content heuristics. +/// +/// Falls back to identity (no-op) if no filter is detected. +pub fn auto_detect_filter(input: &str) -> fn(&str) -> String { + let first_1k = &input[..input.len().min(1024)]; + + // cargo test: "test result: ok. N passed; M failed" + if first_1k.contains("test result:") && first_1k.contains("passed;") { + return crate::cargo_cmd::filter_cargo_test; + } + + // pytest: starts with "=== test session starts" + if first_1k.contains("=== test session starts") { + return crate::pytest_cmd::filter_pytest_output; + } + + // go test -json: NDJSON with "Action" key + let first_trimmed = first_1k.trim_start(); + if first_trimmed.starts_with('{') && first_1k.contains("\"Action\"") { + return go_test_wrapper; + } + + // grep/rg: lines matching file:number:content pattern + if first_1k + .lines() + .take(5) + .filter(|l| !l.trim().is_empty()) + .any(|l| { + let parts: Vec<_> = l.splitn(3, ':').collect(); + parts.len() == 3 && parts[1].parse::().is_ok() + }) + { + return crate::grep_cmd::filter_grep_raw; + } + + // vitest: JSON with "testResults" key + if first_1k.contains("\"testResults\"") || first_1k.contains("\"numTotalTests\"") { + return crate::vitest_cmd::filter_vitest_output; + } + + // find/fd: all non-empty lines look like file paths (contain '/' or '.', no ':' separator) + // Require at least 3 path-like lines to avoid false positives. + let path_like_lines: usize = first_1k + .lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() + && !t.contains(':') + && (t.starts_with('.') || t.starts_with('/') || t.contains('/')) + }) + .count(); + let nonempty_lines: usize = first_1k.lines().filter(|l| !l.trim().is_empty()).count(); + if nonempty_lines >= 3 && path_like_lines == nonempty_lines { + return crate::grep_cmd::filter_find_output; + } + + // Default: identity (no-op) + identity_filter +} + +fn identity_filter(input: &str) -> String { + input.to_string() +} + +/// Run `rtk pipe`: read stdin, apply filter, print result. +pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { + // Read all stdin (stdin is complete when piped; no streaming benefit here) + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| anyhow::anyhow!("Failed to read stdin: {}", e))?; + + if passthrough { + print!("{}", buf); + return Ok(()); + } + + let filter_fn = match filter_name { + Some(name) => resolve_filter(name).ok_or_else(|| { + anyhow::anyhow!( + "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ + tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status", + name + ) + })?, + None => auto_detect_filter(&buf), + }; + + let output = filter_fn(&buf); + print!("{}", output); + Ok(()) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── resolve_filter ───────────────────────────────────────────────────────── + + #[test] + fn test_resolve_filter_cargo_test() { + let f = resolve_filter("cargo-test").expect("cargo-test filter must exist"); + let out = f("test result: ok. 5 passed; 0 failed"); + assert!(out.contains("passed"), "Should contain pass count: {}", out); + } + + #[test] + fn test_resolve_filter_cargo_alias() { + // "cargo" is an alias for "cargo-test" + assert!(resolve_filter("cargo").is_some()); + } + + #[test] + fn test_resolve_filter_grep() { + let f = resolve_filter("grep").expect("grep filter must exist"); + let input = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub fn helper() {}\n"; + let out = f(input); + assert!( + out.contains("main.rs") || out.contains("matches"), + "out={}", + out + ); + } + + #[test] + fn test_resolve_filter_rg_alias() { + // "rg" is an alias for "grep" + assert!(resolve_filter("rg").is_some()); + } + + #[test] + fn test_resolve_filter_pytest() { + assert!(resolve_filter("pytest").is_some()); + } + + #[test] + fn test_resolve_filter_go_test() { + assert!(resolve_filter("go-test").is_some()); + } + + #[test] + fn test_resolve_filter_tsc() { + assert!(resolve_filter("tsc").is_some()); + } + + #[test] + fn test_resolve_filter_vitest() { + assert!(resolve_filter("vitest").is_some()); + } + + #[test] + fn test_resolve_filter_git_log() { + assert!(resolve_filter("git-log").is_some()); + } + + #[test] + fn test_resolve_filter_git_diff() { + assert!(resolve_filter("git-diff").is_some()); + } + + #[test] + fn test_resolve_filter_git_status() { + assert!(resolve_filter("git-status").is_some()); + } + + #[test] + fn test_resolve_filter_unknown_returns_none() { + assert!(resolve_filter("nonexistent-filter").is_none()); + } + + // ── auto_detect_filter ──────────────────────────────────────────────────── + + #[test] + fn test_auto_detect_cargo_test() { + let input = "test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(!out.is_empty(), "cargo-test filter should produce output"); + } + + #[test] + fn test_auto_detect_pytest() { + let input = "=== test session starts ===\ncollected 3 items\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(!out.is_empty(), "pytest filter should produce output"); + } + + #[test] + fn test_auto_detect_grep_format() { + // rg -n --no-heading format: file:line_num:content + let input = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub fn helper() {}\n"; + let f = auto_detect_filter(input); + let out = f(input); + // Should use grep filter and produce grouped output or matches + assert!(!out.is_empty()); + } + + #[test] + fn test_auto_detect_go_test_ndjson() { + let input = r#"{"Time":"2024-01-01T00:00:00Z","Action":"run","Package":"example/pkg"} +{"Time":"2024-01-01T00:00:01Z","Action":"pass","Package":"example/pkg","Elapsed":0.5} +"#; + let f = auto_detect_filter(input); + let out = f(input); + assert!(!out.is_empty()); + } + + #[test] + fn test_auto_detect_unknown_returns_identity() { + let input = "some random text that doesn't match any filter pattern\n"; + let f = auto_detect_filter(input); + let out = f(input); + // Identity filter returns input unchanged + assert_eq!(out, input); + } + + // ── git wrappers ─────────────────────────────────────────────────────────── + + #[test] + fn test_git_log_wrapper() { + let input = "abc1234 Fix bug in parser (2 days ago) \n\ + def5678 Add new feature (3 days ago) \n"; + let out = git_log_wrapper(input); + assert!(!out.is_empty()); + } + + #[test] + fn test_git_diff_wrapper() { + let input = "diff --git a/src/main.rs b/src/main.rs\n\ + --- a/src/main.rs\n\ + +++ b/src/main.rs\n\ + @@ -1,3 +1,4 @@\n\ + +// new comment\n\ + fn main() {}\n"; + let out = git_diff_wrapper(input); + assert!(!out.is_empty()); + } + + // ── resolve_filter: find/fd ──────────────────────────────────────────────── + + #[test] + fn test_resolve_filter_find() { + let f = resolve_filter("find").expect("find filter must exist"); + let input = "./src/main.rs\n./src/lib.rs\n./tests/foo.rs\n"; + let out = f(input); + assert!(out.contains("3 files"), "out={}", out); + } + + #[test] + fn test_resolve_filter_fd_alias() { + // "fd" is an alias for "find" filter + assert!(resolve_filter("fd").is_some()); + } + + #[test] + fn test_resolve_filter_unknown_error_message_lists_find() { + // Confirm the error message mentions find/fd + assert!(resolve_filter("not-a-filter").is_none()); + // We can't easily test the error message from resolve_filter (returns None), + // but we verify the mapping exists + assert!(resolve_filter("find").is_some()); + assert!(resolve_filter("fd").is_some()); + } + + // ── auto_detect_filter: find/fd ──────────────────────────────────────────── + + #[test] + fn test_auto_detect_find_paths() { + // find/fd output: one path per line, no colons + let input = "./src/main.rs\n./src/lib.rs\n./src/cmd/mod.rs\n./tests/foo.rs\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(out.contains("4 files"), "out={}", out); + } + + #[test] + fn test_auto_detect_find_absolute_paths() { + let input = "/home/user/src/main.rs\n/home/user/src/lib.rs\n/home/user/tests/foo.rs\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(out.contains("3 files"), "out={}", out); + } + + #[test] + fn test_auto_detect_find_not_triggered_for_few_lines() { + // Only 2 path-like lines — should NOT trigger find filter (below threshold of 3) + let input = "./src/main.rs\n./src/lib.rs\n"; + let f = auto_detect_filter(input); + let out = f(input); + // identity filter: output equals input + assert_eq!(out, input); + } + + #[test] + fn test_auto_detect_find_not_triggered_for_grep_output() { + // grep output has colons — should NOT be treated as find paths + let input = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub fn helper() {}\nsrc/a.rs:1:x\n"; + let f = auto_detect_filter(input); + let out = f(input); + // grep filter runs (has colons), find filter must NOT be triggered + assert!( + !out.contains("files"), + "should not trigger find filter: out={}", + out + ); + } + + // ── pipe_cmd edge cases ──────────────────────────────────────────────────── + + #[test] + fn test_auto_detect_empty_input_is_identity() { + let f = auto_detect_filter(""); + let out = f(""); + assert_eq!(out, ""); + } + + #[test] + fn test_auto_detect_single_line_unknown() { + // Single line of unknown content → identity + let input = "hello world\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert_eq!(out, input); + } + + #[test] + fn test_resolve_filter_go_build() { + assert!(resolve_filter("go-build").is_some()); + } +} diff --git a/src/pytest_cmd.rs b/src/pytest_cmd.rs index 03fe806a..96346c6c 100644 --- a/src/pytest_cmd.rs +++ b/src/pytest_cmd.rs @@ -1,16 +1,110 @@ +use crate::stream::{FilterMode, StdinMode, StreamFilter}; use crate::tracking; use crate::utils::truncate; use anyhow::{Context, Result}; use std::process::Command; -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Default)] enum ParseState { + #[default] Header, TestProgress, Failures, Summary, } +/// Progressive streaming filter for `pytest` output. +/// +/// Replicates the `filter_pytest_output` state machine line-by-line. +/// Defers all output to `flush()` so the summary section is always included. +#[derive(Default)] +pub struct PyTestStreamFilter { + state: ParseState, + test_files: Vec, + failures: Vec, + current_failure: Vec, + summary_line: String, +} + +impl PyTestStreamFilter { + pub fn new() -> Self { + Self::default() + } +} + +impl StreamFilter for PyTestStreamFilter { + fn feed_line(&mut self, line: &str) -> Option { + let trimmed = line.trim(); + + // State transitions (same as filter_pytest_output loop body) + if trimmed.starts_with("===") && trimmed.contains("test session starts") { + self.state = ParseState::Header; + return None; + } else if trimmed.starts_with("===") && trimmed.contains("FAILURES") { + self.state = ParseState::Failures; + return None; + } else if trimmed.starts_with("===") && trimmed.contains("short test summary") { + self.state = ParseState::Summary; + if !self.current_failure.is_empty() { + let block = self.current_failure.join("\n"); + self.failures.push(block); + self.current_failure.clear(); + } + return None; + } else if trimmed.starts_with("===") + && (trimmed.contains("passed") || trimmed.contains("failed")) + { + self.summary_line = trimmed.to_string(); + return None; + } + + // Per-state processing + match self.state { + ParseState::Header => { + if trimmed.starts_with("collected") { + self.state = ParseState::TestProgress; + } + } + ParseState::TestProgress => { + if !trimmed.is_empty() + && !trimmed.starts_with("===") + && (trimmed.contains(".py") || trimmed.contains("%]")) + { + self.test_files.push(trimmed.to_string()); + } + } + ParseState::Failures => { + if trimmed.starts_with("___") { + if !self.current_failure.is_empty() { + let block = self.current_failure.join("\n"); + self.failures.push(block); + self.current_failure.clear(); + } + self.current_failure.push(trimmed.to_string()); + } else if !trimmed.is_empty() && !trimmed.starts_with("===") { + self.current_failure.push(trimmed.to_string()); + } + } + ParseState::Summary => { + if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { + self.failures.push(trimmed.to_string()); + } + } + } + + None + } + + fn flush(&mut self) -> String { + if !self.current_failure.is_empty() { + let block = self.current_failure.join("\n"); + self.failures.push(block); + self.current_failure.clear(); + } + build_pytest_summary(&self.summary_line, &self.test_files, &self.failures) + } +} + pub fn run(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -43,41 +137,29 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { eprintln!("Running: pytest --tb=short -q {}", args.join(" ")); } - let output = cmd - .output() - .context("Failed to run pytest. Is it installed? Try: pip install pytest")?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - let filtered = filter_pytest_output(&stdout); + let filter = PyTestStreamFilter::new(); + let result = crate::stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("Failed to run pytest. Is it installed? Try: pip install pytest")?; - let exit_code = output - .status - .code() - .unwrap_or(if output.status.success() { 0 } else { 1 }); - if let Some(hint) = crate::tee::tee_and_hint(&raw, "pytest", exit_code) { - println!("{}\n{}", filtered, hint); + if let Some(hint) = crate::tee::tee_and_hint(&result.raw, "pytest", result.exit_code) { + println!("{}\n{}", result.filtered, hint); } else { - println!("{}", filtered); - } - - // Include stderr if present (import errors, etc.) - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); + println!("{}", result.filtered); } timer.track( &format!("pytest {}", args.join(" ")), &format!("rtk pytest {}", 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(()) @@ -95,84 +177,17 @@ fn which_command(cmd: &str) -> Option { .filter(|s| !s.is_empty()) } -/// Parse pytest output using state machine -fn filter_pytest_output(output: &str) -> String { - let mut state = ParseState::Header; - let mut test_files: Vec = Vec::new(); - let mut failures: Vec = Vec::new(); - let mut current_failure: Vec = Vec::new(); - let mut summary_line = String::new(); - +/// Parse pytest output using state machine. +/// +/// Buffered variant — for use when input is already fully accumulated (e.g. +/// `rtk pipe --filter pytest`). For live subprocess output, prefer +/// `PyTestStreamFilter` with `run_streaming`. +pub(crate) fn filter_pytest_output(output: &str) -> String { + let mut filter = PyTestStreamFilter::new(); for line in output.lines() { - let trimmed = line.trim(); - - // State transitions - if trimmed.starts_with("===") && trimmed.contains("test session starts") { - state = ParseState::Header; - continue; - } else if trimmed.starts_with("===") && trimmed.contains("FAILURES") { - state = ParseState::Failures; - continue; - } else if trimmed.starts_with("===") && trimmed.contains("short test summary") { - state = ParseState::Summary; - // Save current failure if any - if !current_failure.is_empty() { - failures.push(current_failure.join("\n")); - current_failure.clear(); - } - continue; - } else if trimmed.starts_with("===") - && (trimmed.contains("passed") || trimmed.contains("failed")) - { - summary_line = trimmed.to_string(); - continue; - } - - // Process based on state - match state { - ParseState::Header => { - if trimmed.starts_with("collected") { - state = ParseState::TestProgress; - } - } - ParseState::TestProgress => { - // Lines like "tests/test_foo.py .... [ 40%]" - if !trimmed.is_empty() - && !trimmed.starts_with("===") - && (trimmed.contains(".py") || trimmed.contains("%]")) - { - test_files.push(trimmed.to_string()); - } - } - ParseState::Failures => { - // Collect failure details - if trimmed.starts_with("___") { - // New failure section - if !current_failure.is_empty() { - failures.push(current_failure.join("\n")); - current_failure.clear(); - } - current_failure.push(trimmed.to_string()); - } else if !trimmed.is_empty() && !trimmed.starts_with("===") { - current_failure.push(trimmed.to_string()); - } - } - ParseState::Summary => { - // FAILED test lines - if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { - failures.push(trimmed.to_string()); - } - } - } - } - - // Save last failure if any - if !current_failure.is_empty() { - failures.push(current_failure.join("\n")); + filter.feed_line(line); } - - // Build compact output - build_pytest_summary(&summary_line, &test_files, &failures) + filter.flush() } fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[String]) -> String { @@ -381,4 +396,87 @@ collected 0 items (3, 1, 2) ); } + + // ── PyTestStreamFilter tests ─────────────────────────────────────────────── + + const PYTEST_ALL_PASS: &str = r#"=== test session starts === +platform darwin -- Python 3.11.0 +collected 5 items + +tests/test_foo.py ..... [100%] + +=== 5 passed in 0.50s ==="#; + + const PYTEST_WITH_FAILURE: &str = r#"=== test session starts === +collected 5 items + +tests/test_foo.py ..F.. [100%] + +=== FAILURES === +___ test_something ___ + + def test_something(): +> assert False +E assert False + +tests/test_foo.py:10: AssertionError + +=== short test summary info === +FAILED tests/test_foo.py::test_something - assert False +=== 4 passed, 1 failed in 0.50s ==="#; + + #[test] + fn test_pytest_stream_filter_feed_and_flush_all_pass() { + let mut f = PyTestStreamFilter::new(); + for line in PYTEST_ALL_PASS.lines() { + assert_eq!( + f.feed_line(line), + None, + "streaming filter must defer output" + ); + } + let output = f.flush(); + assert!(output.contains("✓ Pytest"), "output={}", output); + assert!(output.contains("5 passed"), "output={}", output); + } + + #[test] + fn test_pytest_stream_filter_feed_and_flush_with_failure() { + let mut f = PyTestStreamFilter::new(); + for line in PYTEST_WITH_FAILURE.lines() { + f.feed_line(line); + } + let output = f.flush(); + assert!(output.contains("4 passed, 1 failed"), "output={}", output); + assert!(output.contains("test_something"), "output={}", output); + } + + #[test] + fn test_pytest_stream_filter_matches_buffered_all_pass() { + let buffered = filter_pytest_output(PYTEST_ALL_PASS); + let mut f = PyTestStreamFilter::new(); + for line in PYTEST_ALL_PASS.lines() { + f.feed_line(line); + } + let streamed = f.flush(); + assert_eq!(streamed.trim(), buffered.trim()); + } + + #[test] + fn test_pytest_stream_filter_matches_buffered_with_failures() { + let buffered = filter_pytest_output(PYTEST_WITH_FAILURE); + let mut f = PyTestStreamFilter::new(); + for line in PYTEST_WITH_FAILURE.lines() { + f.feed_line(line); + } + let streamed = f.flush(); + assert_eq!(streamed.trim(), buffered.trim()); + } + + #[test] + fn test_pytest_stream_filter_default_equals_new() { + let mut f1 = PyTestStreamFilter::new(); + let mut f2 = PyTestStreamFilter::default(); + assert_eq!(f1.flush(), f2.flush()); + } } diff --git a/src/rules/rtk.safety.block-cat.md b/src/rules/rtk.safety.block-cat.md new file mode 100644 index 00000000..7b559272 --- /dev/null +++ b/src/rules/rtk.safety.block-cat.md @@ -0,0 +1,11 @@ +--- +name: block-cat +patterns: [cat] +action: suggest_tool +redirect: Read +env_var: RTK_BLOCK_TOKEN_WASTE +--- + +Use the **Read tool** for large files. + +BLOCK: cat wastes tokens. Use your file-reading tool instead. diff --git a/src/rules/rtk.safety.block-head.md b/src/rules/rtk.safety.block-head.md new file mode 100644 index 00000000..01f188b8 --- /dev/null +++ b/src/rules/rtk.safety.block-head.md @@ -0,0 +1,11 @@ +--- +name: block-head +patterns: [head] +action: suggest_tool +redirect: "Read (with limit)" +env_var: RTK_BLOCK_TOKEN_WASTE +--- + +Use **Read tool with limit parameter** instead of head. + +BLOCK: head wastes tokens. Use your file-reading tool with a line limit instead. diff --git a/src/rules/rtk.safety.block-sed.md b/src/rules/rtk.safety.block-sed.md new file mode 100644 index 00000000..a30923ed --- /dev/null +++ b/src/rules/rtk.safety.block-sed.md @@ -0,0 +1,11 @@ +--- +name: block-sed +patterns: [sed] +action: suggest_tool +redirect: Edit +env_var: RTK_BLOCK_TOKEN_WASTE +--- + +Use the **Edit tool** for validated file modifications. + +BLOCK: sed unsafe. Use your file-editing tool instead. diff --git a/src/rules/rtk.safety.git-checkout-dashdash.md b/src/rules/rtk.safety.git-checkout-dashdash.md new file mode 100644 index 00000000..173bb35c --- /dev/null +++ b/src/rules/rtk.safety.git-checkout-dashdash.md @@ -0,0 +1,10 @@ +--- +name: git-checkout-dashdash +patterns: ["git checkout --"] +action: rewrite +redirect: "git stash push -m 'RTK: checkout backup' && git checkout -- {args}" +when: has_unstaged_changes +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Stashing before checkout. diff --git a/src/rules/rtk.safety.git-checkout-dot.md b/src/rules/rtk.safety.git-checkout-dot.md new file mode 100644 index 00000000..007b3476 --- /dev/null +++ b/src/rules/rtk.safety.git-checkout-dot.md @@ -0,0 +1,10 @@ +--- +name: git-checkout-dot +patterns: ["git checkout ."] +action: rewrite +redirect: "git stash push -m 'RTK: checkout backup' && git checkout . {args}" +when: has_unstaged_changes +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Stashing before checkout. diff --git a/src/rules/rtk.safety.git-clean-df.md b/src/rules/rtk.safety.git-clean-df.md new file mode 100644 index 00000000..ef7b1c86 --- /dev/null +++ b/src/rules/rtk.safety.git-clean-df.md @@ -0,0 +1,9 @@ +--- +name: git-clean-df +patterns: ["git clean -df"] +action: rewrite +redirect: "git stash -u -m 'RTK: clean backup' && git clean -df {args}" +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Stashing untracked before clean. diff --git a/src/rules/rtk.safety.git-clean-f.md b/src/rules/rtk.safety.git-clean-f.md new file mode 100644 index 00000000..5582e914 --- /dev/null +++ b/src/rules/rtk.safety.git-clean-f.md @@ -0,0 +1,9 @@ +--- +name: git-clean-f +patterns: ["git clean -f"] +action: rewrite +redirect: "git stash -u -m 'RTK: clean backup' && git clean -f {args}" +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Stashing untracked before clean. diff --git a/src/rules/rtk.safety.git-clean-fd.md b/src/rules/rtk.safety.git-clean-fd.md new file mode 100644 index 00000000..ceb2e902 --- /dev/null +++ b/src/rules/rtk.safety.git-clean-fd.md @@ -0,0 +1,9 @@ +--- +name: git-clean-fd +patterns: ["git clean -fd"] +action: rewrite +redirect: "git stash -u -m 'RTK: clean backup' && git clean -fd {args}" +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Stashing untracked before clean. diff --git a/src/rules/rtk.safety.git-reset-hard.md b/src/rules/rtk.safety.git-reset-hard.md new file mode 100644 index 00000000..4b3a6d7b --- /dev/null +++ b/src/rules/rtk.safety.git-reset-hard.md @@ -0,0 +1,10 @@ +--- +name: git-reset-hard +patterns: ["git reset --hard"] +action: rewrite +redirect: "git stash push -m 'RTK: reset backup' && git reset --hard {args}" +when: has_unstaged_changes +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Stashing before reset. diff --git a/src/rules/rtk.safety.git-stash-drop.md b/src/rules/rtk.safety.git-stash-drop.md new file mode 100644 index 00000000..6bd38f00 --- /dev/null +++ b/src/rules/rtk.safety.git-stash-drop.md @@ -0,0 +1,9 @@ +--- +name: git-stash-drop +patterns: ["git stash drop"] +action: rewrite +redirect: "git stash pop" +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Using pop instead of drop (recoverable). diff --git a/src/rules/rtk.safety.rm-to-trash.md b/src/rules/rtk.safety.rm-to-trash.md new file mode 100644 index 00000000..49e690a0 --- /dev/null +++ b/src/rules/rtk.safety.rm-to-trash.md @@ -0,0 +1,9 @@ +--- +name: rm-to-trash +patterns: [rm] +action: trash +redirect: "trash {args}" +env_var: RTK_SAFE_COMMANDS +--- + +Safety: Moving to trash instead of permanent deletion. diff --git a/src/stream.rs b/src/stream.rs new file mode 100644 index 00000000..d5e29b11 --- /dev/null +++ b/src/stream.rs @@ -0,0 +1,566 @@ +//! Streaming process execution infrastructure for RTK. +//! +//! Provides a bidirectional process shim that preserves all process state +//! (exit codes, signals, SIGPIPE) while filtering stdout progressively. +//! +//! # Process Transparency +//! RTK inserts itself between a subprocess and the OS environment. +//! All process expectations are preserved: +//! - Exit codes 0–254 propagated exactly via [`StreamResult::exit_code`]. +//! - Signal-killed exit becomes `128 + signal_num` per POSIX convention. +//! - SIGPIPE (broken downstream pipe) breaks the output loop cleanly. +//! - SIGINT reaches both RTK and child via shared process group. +//! - stdin inherited by default (`StdinMode::Inherit`). +//! +//! # RAII Guarantees +//! Three resources follow RAII patterns (mirrors `RtkActiveGuard` in cmd/exec.rs): +//! - `ChildGuard`: ensures `wait()` is called even on early `?` returns → no zombies. +//! - `io::stdout().lock()`: released in scoped block before any joins. +//! - Stdin `JoinHandle`: stored and joined (not detached) to surface panics. + +use anyhow::{Context, Result}; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; +use std::process::{Command, Stdio}; + +// ─── Traits ────────────────────────────────────────────────────────────────── + +/// A filter that processes command output incrementally, line by line. +/// +/// Implement this to stream-filter subprocess output as it's produced, +/// rather than buffering the entire output first. +pub trait StreamFilter { + /// Process one line from subprocess stdout. + /// Returns `Some(output)` to emit that text downstream, `None` to suppress. + /// The output string should include a trailing newline if needed. + fn feed_line(&mut self, line: &str) -> Option; + + /// Called at end-of-stream to flush any buffered state. + /// Returns any final output (e.g., a summary block). + fn flush(&mut self) -> String; +} + +/// A filter for stdin transformation. Requires [`Send`] because it runs in a thread. +pub trait StdinFilter: Send { + /// Transform one line of stdin before forwarding to the child process. + fn feed_line(&mut self, line: &str) -> Option; + + /// Flush any remaining buffered state at stdin EOF. + fn flush(&mut self) -> String; +} + +// ─── LineFilter ────────────────────────────────────────────────────────────── + +/// Generic per-line stateless filter adapter (Category A filters). +/// +/// Wraps a closure for simple line-by-line transformations without state. +/// The closure receives each line (without trailing newline) and returns +/// `Some(output)` to emit or `None` to suppress. +/// +/// # Example +/// ```rust,ignore +/// let f = LineFilter::new(|l| Some(format!("{}\n", l.to_uppercase()))); +/// ``` +pub struct LineFilter Option> { + f: F, +} + +impl Option> LineFilter { + pub fn new(f: F) -> Self { + Self { f } + } +} + +impl Option> StreamFilter for LineFilter { + fn feed_line(&mut self, line: &str) -> Option { + (self.f)(line) + } + + fn flush(&mut self) -> String { + String::new() + } +} + +// ─── FilterMode / StdinMode ────────────────────────────────────────────────── + +/// How subprocess stdout is processed. +pub enum FilterMode { + /// Line-by-line filtering, emitting output progressively as the subprocess produces it. + /// Use for commands with predictable per-line output (go test NDJSON, cargo test, etc.). + Streaming(Box), + + /// Buffer all stdout, apply a function, emit at end. + /// Use only for filters that genuinely require complete input (e.g., full JSON doc). + Buffered(fn(&str) -> String), + + /// Emit raw lines immediately without filtering (ANSI codes preserved). + Passthrough, +} + +/// How subprocess stdin is handled. +// Filter and Null are used in tests and reserved for bidirectional shim use. +#[allow(dead_code)] +pub enum StdinMode { + /// Pass RTK's stdin directly to the child (default, zero overhead, no pipe). + Inherit, + + /// Transform input lines through a filter before forwarding to child. + /// The filter runs in a dedicated thread to prevent deadlock. + Filter(Box), + + /// Immediately send EOF to child (child sees no stdin input). + Null, +} + +// ─── StreamResult ───────────────────────────────────────────────────────────── + +/// Result of [`run_streaming`], carrying the full POSIX exit code. +pub struct StreamResult { + /// POSIX exit code: 0 = success, 1–127 = app error, 128+N = killed by signal N. + pub exit_code: i32, + + /// Raw stdout + stderr combined (capped at 1 MiB) for tee recovery. + pub raw: String, + + /// Filtered stdout content for token savings tracking. + pub filtered: String, +} + +impl StreamResult { + /// Returns `true` if `exit_code == 0`. + // Used in tests and by future callers; suppress dead_code for binary target. + #[allow(dead_code)] + pub fn success(&self) -> bool { + self.exit_code == 0 + } +} + +// ─── status_to_exit_code ────────────────────────────────────────────────────── + +/// Convert `ExitStatus` to a POSIX-compatible integer exit code. +/// +/// - Normal exit via `exit(N)`: returns `N`. +/// - Signal-killed (Unix): returns `128 + signal_number` per POSIX convention. +/// Example: SIGKILL (9) → 137, SIGTERM (15) → 143. +/// - Unknown state: returns `1` (generic failure fallback). +pub fn status_to_exit_code(status: std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + // Process was killed by a signal — POSIX convention: 128 + signal_num + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + return 128 + sig; + } + } + 1 // fallback: unknown failure +} + +// ─── run_streaming ──────────────────────────────────────────────────────────── + +/// Execute `cmd` as a bidirectional process shim with streaming stdout filtering. +/// +/// Spawns the command and concurrently: +/// - **(stdin thread)** Optionally transforms stdin before forwarding to child. +/// - **(stderr thread)** Streams stderr directly to `io::stderr()` (responsive). +/// - **(main thread)** Reads stdout, applies `stdout_mode` filter, writes to `io::stdout()`. +/// +/// # Exit Code Transparency +/// The returned [`StreamResult::exit_code`] is the child's exact POSIX exit code. +/// Callers should propagate it via `std::process::exit(result.exit_code)` if non-zero. +/// +/// # SIGPIPE Handling +/// If the downstream pipe closes (e.g., `rtk go test | head -5`), writing to stdout +/// returns `BrokenPipe`. The output loop breaks cleanly, child is drained, and the +/// child's exit code is returned unchanged. +/// +/// # Memory Bound +/// Raw stdout+stderr is capped at 1 MiB (matches `tee.rs` `DEFAULT_MAX_FILE_SIZE`). +/// Output beyond the cap is streamed to stdout but not accumulated in `raw`. +pub fn run_streaming( + cmd: &mut Command, + stdin_mode: StdinMode, + stdout_mode: FilterMode, +) -> Result { + // ── Configure pipes ──────────────────────────────────────────────────────── + match &stdin_mode { + StdinMode::Inherit => { + cmd.stdin(Stdio::inherit()); + } + StdinMode::Filter(_) | StdinMode::Null => { + cmd.stdin(Stdio::piped()); + } + } + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + // ── RAII child guard ────────────────────────────────────────────────────── + // Nested type definition (valid in Rust). Mirrors `RtkActiveGuard` in + // src/cmd/exec.rs:14-28. Ensures child.wait() is always called, preventing zombies. + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + // Reap zombie. Ignores ECHILD error when child was already waited explicitly. + self.0.wait().ok(); + } + } + + let mut child = ChildGuard(cmd.spawn().context("Failed to spawn process")?); + + // ── Stdin thread ────────────────────────────────────────────────────────── + // JoinHandle stored (not detached) — avoids clippy `unused_must_use` warning + // and ensures panics in the stdin thread are not silently lost. + let stdin_thread: Option> = match stdin_mode { + StdinMode::Filter(mut filter) => { + let child_stdin = child.0.stdin.take().context("No child stdin handle")?; + Some(std::thread::spawn(move || { + let mut writer = BufWriter::new(child_stdin); + // Bind stdin to variable first — avoids temporary lifetime issue with lock(). + let stdin_handle = io::stdin(); + for line in BufReader::new(stdin_handle.lock()) + .lines() + .filter_map(Result::ok) + { + if let Some(out) = filter.feed_line(&line) { + if writeln!(writer, "{}", out).is_err() { + break; // child closed its stdin — stop sending + } + } + } + let tail = filter.flush(); + if !tail.is_empty() { + write!(writer, "{}", tail).ok(); + } + // writer drop → BufWriter flushes → ChildStdin drops → EOF to child + })) + } + StdinMode::Null => { + child.0.stdin.take(); // drop ChildStdin immediately → child gets EOF + None + } + StdinMode::Inherit => None, // stdin already configured as Stdio::inherit() + }; + + // ── Stderr thread ───────────────────────────────────────────────────────── + // Streams stderr directly to io::stderr() (responsive, not buffered). + // Accumulates raw string for tee recovery. + let stderr = child.0.stderr.take().context("No child stderr handle")?; + let stderr_thread = std::thread::spawn(move || -> String { + let mut raw_err = String::new(); + let stderr_out = io::stderr(); + let mut err_out = stderr_out.lock(); // RAII: released when closure completes + for line in BufReader::new(stderr).lines().filter_map(Result::ok) { + writeln!(err_out, "{}", line).ok(); // emit immediately (responsive) + raw_err.push_str(&line); + raw_err.push('\n'); + } + raw_err + }); + + // ── Stdout: main thread ─────────────────────────────────────────────────── + let stdout = child.0.stdout.take().context("No child stdout handle")?; + const RAW_CAP: usize = 1_048_576; // 1 MiB, matches tee.rs DEFAULT_MAX_FILE_SIZE + let mut raw_stdout = String::new(); + let mut filtered = String::new(); + + { + // Scoped block: stdout lock held ONLY here. + // RAII-dropped before joining threads or calling child.wait(), + // preventing lock contention during blocking operations. + let stdout_handle = io::stdout(); + let mut out = stdout_handle.lock(); + + match stdout_mode { + FilterMode::Passthrough => { + for line in BufReader::new(stdout).lines().filter_map(Result::ok) { + if raw_stdout.len() < RAW_CAP { + raw_stdout.push_str(&line); + raw_stdout.push('\n'); + } + match writeln!(out, "{}", line) { + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break, + Err(e) => return Err(e.into()), + Ok(_) => {} + } + } + filtered = raw_stdout.clone(); + } + FilterMode::Streaming(mut filter) => { + for line in BufReader::new(stdout).lines().filter_map(Result::ok) { + if raw_stdout.len() < RAW_CAP { + raw_stdout.push_str(&line); + raw_stdout.push('\n'); + } + if let Some(output) = filter.feed_line(&line) { + filtered.push_str(&output); + match write!(out, "{}", output) { + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break, + Err(e) => return Err(e.into()), + Ok(_) => {} + } + } + } + let tail = filter.flush(); + filtered.push_str(&tail); + // Guard against BrokenPipe (loop may have broken early above) + match write!(out, "{}", tail) { + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {} + Err(e) => return Err(e.into()), + Ok(_) => {} + } + } + FilterMode::Buffered(filter_fn) => { + for line in BufReader::new(stdout).lines().filter_map(Result::ok) { + if raw_stdout.len() < RAW_CAP { + raw_stdout.push_str(&line); + raw_stdout.push('\n'); + } + } + let result = filter_fn(&raw_stdout); + filtered = result.clone(); + match write!(out, "{}", result) { + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {} + Err(e) => return Err(e.into()), + Ok(_) => {} + } + } + } + } // stdout lock RAII-dropped here — before any blocking joins or child.wait() + + // ── Join threads ────────────────────────────────────────────────────────── + // stderr_thread finishes when child's stderr pipe closes (child exits). + let raw_stderr = stderr_thread.join().unwrap_or_else(|_| String::new()); + // stdin_thread finishes when our stdin closes or child closed its stdin end. + if let Some(t) = stdin_thread { + t.join().ok(); + } + + // ── Wait for child ──────────────────────────────────────────────────────── + // Explicit wait captures the actual exit status. + // ChildGuard.drop() will also call wait() as a safety net (ECHILD error ignored). + let status = child.0.wait().context("Failed to wait for child")?; + + Ok(StreamResult { + exit_code: status_to_exit_code(status), + raw: format!("{}{}", raw_stdout, raw_stderr), + filtered, + }) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + // ── status_to_exit_code ──────────────────────────────────────────────────── + + #[test] + fn test_exit_code_zero() { + let status = Command::new("true").status().unwrap(); + assert_eq!(status_to_exit_code(status), 0); + } + + #[test] + fn test_exit_code_nonzero() { + let status = Command::new("false").status().unwrap(); + assert_eq!(status_to_exit_code(status), 1); + } + + #[cfg(unix)] + #[test] + fn test_exit_code_signal_kill() { + // kill() sends SIGKILL (9); POSIX exit code = 128 + 9 = 137 + let mut child = Command::new("sleep").arg("60").spawn().unwrap(); + child.kill().unwrap(); + let status = child.wait().unwrap(); + assert_eq!(status_to_exit_code(status), 137); + } + + // ── LineFilter ───────────────────────────────────────────────────────────── + + #[test] + fn test_line_filter_passes_lines() { + let mut f = LineFilter::new(|l| Some(format!("{}\n", l.to_uppercase()))); + assert_eq!(f.feed_line("hello"), Some("HELLO\n".to_string())); + } + + #[test] + fn test_line_filter_drops_lines() { + let mut f = LineFilter::new(|l| { + if l.starts_with('#') { + None + } else { + Some(l.to_string()) + } + }); + assert_eq!(f.feed_line("# comment"), None); + assert_eq!(f.feed_line("code"), Some("code".to_string())); + } + + #[test] + fn test_line_filter_flush_empty() { + let mut f = LineFilter::new(|l| Some(l.to_string())); + assert_eq!(f.flush(), String::new()); + } + + // ── StreamResult ─────────────────────────────────────────────────────────── + + #[test] + fn test_stream_result_success() { + let r = StreamResult { + exit_code: 0, + raw: String::new(), + filtered: String::new(), + }; + assert!(r.success()); + } + + #[test] + fn test_stream_result_failure() { + let r = StreamResult { + exit_code: 1, + raw: String::new(), + filtered: String::new(), + }; + assert!(!r.success()); + } + + #[test] + fn test_stream_result_signal_not_success() { + let r = StreamResult { + exit_code: 137, + raw: String::new(), + filtered: String::new(), + }; + assert!(!r.success()); + } + + // ── run_streaming integration ────────────────────────────────────────────── + + #[test] + fn test_run_streaming_passthrough_echo() { + let mut cmd = Command::new("echo"); + cmd.arg("hello"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.raw.contains("hello")); + } + + #[test] + fn test_run_streaming_exit_code_preserved() { + // sh -c "exit 42" → exit_code must be exactly 42, not 0 or 1 + let mut cmd = Command::new("sh"); + cmd.args(["-c", "exit 42"]); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert_eq!(result.exit_code, 42); + } + + #[test] + fn test_run_streaming_exit_code_zero() { + let mut cmd = Command::new("true"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.success()); + } + + #[test] + fn test_run_streaming_exit_code_one() { + let mut cmd = Command::new("false"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert_eq!(result.exit_code, 1); + assert!(!result.success()); + } + + #[test] + fn test_run_streaming_streaming_filter_drops_lines() { + let mut cmd = Command::new("printf"); + cmd.arg("a\nb\nc\n"); + let filter = LineFilter::new(|l| { + if l == "b" { + None + } else { + Some(format!("{}\n", l)) + } + }); + let result = run_streaming( + &mut cmd, + StdinMode::Null, + FilterMode::Streaming(Box::new(filter)), + ) + .unwrap(); + assert!(result.filtered.contains('a')); + assert!(!result.filtered.contains('b')); + assert!(result.filtered.contains('c')); + assert_eq!(result.exit_code, 0); + } + + #[test] + fn test_run_streaming_buffered_filter() { + let mut cmd = Command::new("printf"); + cmd.arg("line1\nline2\nline3\n"); + fn upper(s: &str) -> String { + s.to_uppercase() + } + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Buffered(upper)).unwrap(); + assert!(result.filtered.contains("LINE1")); + assert!(result.filtered.contains("LINE2")); + assert_eq!(result.exit_code, 0); + } + + #[test] + fn test_run_streaming_raw_cap_at_1mb() { + // Generate >1 MiB: 'yes | head -600000' ≈ 1.2 MiB of "y\n" lines. + // raw must be capped at 1 MiB, not OOM. + let mut cmd = Command::new("sh"); + cmd.args(["-c", "yes | head -600000"]); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + // Allow small overshoot: last partial line may push us 1-2 bytes over cap. + assert!( + result.raw.len() <= 1_048_576 + 100, + "raw should be capped at ~1 MiB, got {} bytes", + result.raw.len() + ); + // Must still have captured significant data. + assert!( + result.raw.len() > 100_000, + "Should have captured significant data" + ); + } + + #[test] + fn test_child_guard_prevents_zombie() { + // Verifies ChildGuard returns cleanly on fast-exiting commands. + let mut cmd = Command::new("true"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough); + assert!(result.is_ok()); + assert_eq!(result.unwrap().exit_code, 0); + } + + #[test] + fn test_run_streaming_null_stdin_cat() { + // With StdinMode::Null, cat gets EOF and exits 0 with empty output. + let mut cmd = Command::new("cat"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert_eq!(result.exit_code, 0); + } + + #[test] + fn test_run_streaming_raw_contains_stdout() { + let mut cmd = Command::new("echo"); + cmd.arg("test_output_xyz"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert!(result.raw.contains("test_output_xyz")); + } + + #[test] + fn test_run_streaming_filtered_equals_raw_in_passthrough() { + // In passthrough mode, filtered content matches raw stdout. + let mut cmd = Command::new("echo"); + cmd.arg("check_equality"); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); + assert_eq!(result.filtered.trim(), result.raw.trim()); + } +} diff --git a/src/tsc_cmd.rs b/src/tsc_cmd.rs index 5fabb6d0..bd244da1 100644 --- a/src/tsc_cmd.rs +++ b/src/tsc_cmd.rs @@ -60,7 +60,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } /// Filter TypeScript compiler output - group errors by file, show every error -fn filter_tsc_output(output: &str) -> String { +pub(crate) fn filter_tsc_output(output: &str) -> String { lazy_static::lazy_static! { // Pattern: src/file.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'. static ref TSC_ERROR: Regex = Regex::new( diff --git a/src/vitest_cmd.rs b/src/vitest_cmd.rs index e9c24be3..f64d09eb 100644 --- a/src/vitest_cmd.rs +++ b/src/vitest_cmd.rs @@ -208,6 +208,19 @@ fn extract_failures_regex(output: &str) -> Vec { failures } +/// Filter vitest output (JSON or text) for use with `rtk pipe --filter vitest`. +/// +/// Adapts `VitestParser` (which uses the `OutputParser` trait) to a simple +/// `fn(&str) -> String` interface for pipe filtering. +pub(crate) fn filter_vitest_output(input: &str) -> String { + let mode = FormatMode::Compact; + match VitestParser::parse(input) { + ParseResult::Full(data) => data.format(mode), + ParseResult::Degraded(data, _) => data.format(mode), + ParseResult::Passthrough(raw) => raw, + } +} + #[derive(Debug, Clone)] pub enum VitestCommand { Run,