diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 977604729d..bdf0d21b89 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -98,3 +98,19 @@ jobs: workspaces: rust -> target - name: Run workspace clippy run: cargo clippy --workspace + + claw-analog: + name: claw-analog (test + clippy -p) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + - name: cargo test -p claw-analog + run: cargo test -p claw-analog + - name: cargo clippy -p claw-analog --no-deps + run: cargo clippy -p claw-analog --no-deps diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 740147e78e..56332e61c4 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -71,9 +71,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -84,6 +84,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -104,9 +114,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.58" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -530,6 +540,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "half" version = "2.7.1" @@ -543,9 +566,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hermit-abi" @@ -623,15 +646,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -663,12 +685,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -676,9 +699,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -689,9 +712,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -703,15 +726,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -723,15 +746,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -755,19 +778,35 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -817,9 +856,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -829,9 +868,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linked-hash-map" @@ -853,9 +892,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -959,9 +998,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ "bitflags", "libc", @@ -971,9 +1010,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -1022,15 +1061,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64", "indexmap", @@ -1077,9 +1116,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1129,9 +1168,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] @@ -1218,9 +1257,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -1361,7 +1400,10 @@ dependencies = [ name = "runtime" version = "0.1.0" dependencies = [ + "getrandom 0.2.17", "glob", + "globset", + "ignore", "plugins", "regex", "serde", @@ -1406,9 +1448,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "once_cell", "ring", @@ -1420,9 +1462,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1430,9 +1472,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -1764,9 +1806,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1799,9 +1841,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1815,9 +1857,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1847,6 +1889,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "walkdir", ] [[package]] @@ -1921,9 +1964,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicase" @@ -2012,18 +2055,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -2034,9 +2077,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.66" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -2044,9 +2087,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2054,9 +2097,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -2067,18 +2110,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.93" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -2096,9 +2139,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -2307,15 +2350,15 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yaml-rust" @@ -2328,9 +2371,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2339,9 +2382,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2371,18 +2414,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2398,9 +2441,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2409,9 +2452,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2420,9 +2463,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/rust/README.md b/rust/README.md index 804e5bc41f..71ed04ae59 100644 --- a/rust/README.md +++ b/rust/README.md @@ -24,6 +24,22 @@ cargo run -p rusty-claude-cli -- prompt "explain this codebase" cargo run -p rusty-claude-cli -- --output-format json prompt "summarize src/main.rs" ``` +### Minimal harness (`claw-analog`) + +Smaller tool loop on the same `api` providers: `read_file`, `list_dir`, `glob_workspace`, `grep_workspace` / `grep_search`, optional `write_file`, workspace jail, optional `stream_message` (`--stream` / `--no-stream`), `.claw-analog.toml`, `--session` JSON resume, `--preset`, optional `~/.claw-analog/profile.toml`, `claw-analog doctor`, and `runtime::PermissionEnforcer` by default. See [`../how_to_run.md`](../how_to_run.md) for usage and design notes. + +```bash +cd rust/ +cargo run -p claw-analog -- --help +cargo run -p claw-analog -- -w . "What does rust/README.md say about parity?" +cargo run -p claw-analog -- --stream -w . "Short summary of crates/claw-analog" +# NDJSON lines for CI/agents (see how_to_run.md): +cargo run -p claw-analog -- --output-format json --stream -w . "Say hi once." +# allow writes under -w: +cargo run -p claw-analog -- --permission workspace-write -w . "Add a one-line comment to Cargo.toml if missing" +cargo test -p claw-analog +``` + ## Configuration Set your API credentials: @@ -186,6 +202,7 @@ rust/ ├── commands/ # Shared slash-command registry + help rendering ├── compat-harness/ # TS manifest extraction harness ├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock + ├── claw-rag-service/ # Local RAG: ingest + SQLite + HTTP query (embeddings via OpenAI API or mock) ├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces ├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop ├── rusty-claude-cli/ # Main CLI binary (`claw`) @@ -199,6 +216,7 @@ rust/ - **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering - **compat-harness** — extracts tool/prompt manifests from upstream TS source - **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs +- **claw-rag-service** — workspace ingest, SQLite chunk+embedding store, HTTP `serve` with `GET /` (minimal UI), `/v1/stats`, `/v1/query` (cosine search; optional mock embeddings for tests) - **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces - **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking - **rusty-claude-cli** — REPL, one-shot prompt, direct CLI subcommands, streaming display, tool call rendering, CLI argument parsing diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 7c9f02945e..98f6918b4a 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -763,7 +763,13 @@ fn read_auth_token() -> Option { #[must_use] pub fn read_base_url() -> String { - std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) + std::env::var("ANTHROPIC_BASE_URL") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + super::dotenv_value("ANTHROPIC_BASE_URL") + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()) + }) } fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option { @@ -1087,12 +1093,16 @@ mod tests { let _guard = env_lock(); std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_API_KEY"); - std::env::remove_var("CLAW_CONFIG_HOME"); + let dir = std::env::temp_dir().join("claw_api_test_empty_config_home"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp config home"); + std::env::set_var("CLAW_CONFIG_HOME", &dir); let error = super::read_api_key().expect_err("missing key should error"); assert!(matches!( error, crate::error::ApiError::MissingCredentials { .. } )); + std::env::remove_var("CLAW_CONFIG_HOME"); } #[test] @@ -1100,12 +1110,17 @@ mod tests { let _guard = env_lock(); std::env::set_var("ANTHROPIC_AUTH_TOKEN", ""); std::env::remove_var("ANTHROPIC_API_KEY"); + let dir = std::env::temp_dir().join("claw_api_test_empty_config_home"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp config home"); + std::env::set_var("CLAW_CONFIG_HOME", &dir); let error = super::read_api_key().expect_err("empty key should error"); assert!(matches!( error, crate::error::ApiError::MissingCredentials { .. } )); std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); + std::env::remove_var("CLAW_CONFIG_HOME"); } #[test] diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 86871a82a1..7093bdf539 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -446,13 +446,36 @@ pub(crate) fn load_dotenv_file( Some(parse_dotenv(&content)) } -/// Look up `key` in a `.env` file located in the current working directory. -/// Returns `None` when the file is missing, the key is absent, or the value -/// is empty. +const DOTENV_WALK_MAX_ANCESTORS: usize = 64; + +fn dotenv_candidate_files( + start: &std::path::Path, +) -> impl Iterator + '_ { + start + .ancestors() + .take(DOTENV_WALK_MAX_ANCESTORS) + .filter(|dir| !dir.as_os_str().is_empty()) + .flat_map(|dir| [dir.join(".env"), dir.join(".claude").join(".env")].into_iter()) +} + +/// Look up `key` in `.env` files discovered from the current working directory +/// upward: each directory is tried as `DIR/.env` then `DIR/.claude/.env`, then +/// the parent directory, and so on (bounded walk). Returns `None` when no file +/// contains a non-empty value for `key`. pub(crate) fn dotenv_value(key: &str) -> Option { let cwd = std::env::current_dir().ok()?; - let values = load_dotenv_file(&cwd.join(".env"))?; - values.get(key).filter(|value| !value.is_empty()).cloned() + dotenv_value_for_workdir(&cwd, key) +} + +fn dotenv_value_for_workdir(workdir: &std::path::Path, key: &str) -> Option { + for path in dotenv_candidate_files(workdir) { + if let Some(values) = load_dotenv_file(&path) { + if let Some(value) = values.get(key).filter(|value| !value.is_empty()) { + return Some(value.clone()); + } + } + } + None } #[cfg(test)] @@ -896,6 +919,33 @@ NO_EQUALS_LINE let _ = std::fs::remove_dir_all(&temp_root); } + #[test] + fn dotenv_value_finds_key_in_parent_claude_env() { + let temp_root = std::env::temp_dir().join(format!( + "api-dotenv-walk-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) + )); + let child = temp_root.join("rust"); + std::fs::create_dir_all(&child).expect("create nested dir"); + let claude_env = temp_root.join(".claude"); + std::fs::create_dir_all(&claude_env).expect("create .claude"); + std::fs::write( + claude_env.join(".env"), + "OPENAI_API_KEY=from-parent-claude-env\n", + ) + .expect("write parent .claude/.env"); + + assert_eq!( + super::dotenv_value_for_workdir(&child, "OPENAI_API_KEY").as_deref(), + Some("from-parent-claude-env") + ); + + let _ = std::fs::remove_dir_all(&temp_root); + } + #[test] fn anthropic_missing_credentials_hint_is_none_when_no_foreign_creds_present() { // given diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a810502e66..a071f1fd68 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1320,7 +1320,13 @@ pub fn has_api_key(key: &str) -> bool { #[must_use] pub fn read_base_url(config: OpenAiCompatConfig) -> String { - std::env::var(config.base_url_env).unwrap_or_else(|_| config.default_base_url.to_string()) + std::env::var(config.base_url_env) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + super::dotenv_value(config.base_url_env) + .unwrap_or_else(|| config.default_base_url.to_string()) + }) } fn chat_completions_endpoint(base_url: &str) -> String { diff --git a/rust/crates/api/tests/proxy_integration.rs b/rust/crates/api/tests/proxy_integration.rs index 7e3906983f..f29ebce07e 100644 --- a/rust/crates/api/tests/proxy_integration.rs +++ b/rust/crates/api/tests/proxy_integration.rs @@ -36,6 +36,7 @@ impl Drop for EnvVarGuard { } #[test] +#[cfg(not(windows))] fn proxy_config_from_env_reads_uppercase_proxy_vars() { // given let _lock = env_lock(); @@ -60,6 +61,28 @@ fn proxy_config_from_env_reads_uppercase_proxy_vars() { assert!(!config.is_empty()); } +#[test] +#[cfg(windows)] +fn proxy_config_from_env_reads_proxy_vars_on_windows() { + // Windows treats environment variables as case-insensitive, so we only + // assert that setting the proxy variables is reflected in the config. + let _lock = env_lock(); + let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128")); + let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129")); + let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1")); + + let config = ProxyConfig::from_env(); + + assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128")); + assert_eq!( + config.https_proxy.as_deref(), + Some("http://secure.corp:3129") + ); + assert_eq!(config.no_proxy.as_deref(), Some("localhost,127.0.0.1")); + assert!(config.proxy_url.is_none()); + assert!(!config.is_empty()); +} + #[test] fn proxy_config_from_env_reads_lowercase_proxy_vars() { // given @@ -155,6 +178,7 @@ fn build_client_with_proxy_url_config_succeeds() { } #[test] +#[cfg(not(windows))] fn proxy_config_from_env_prefers_uppercase_over_lowercase() { // given let _lock = env_lock(); diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index d4f1770673..73328c7a7c 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::env; use std::fmt; +use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; @@ -1891,7 +1892,7 @@ fn slash_command_category(name: &str) -> &'static str { | "desktop" | "upgrade" => "Config", "debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog" | "metrics" => "Debug", - _ => "Tools", + _ => "Other", } } diff --git a/rust/crates/plugins/src/hooks.rs b/rust/crates/plugins/src/hooks.rs index ff02c2ac27..1ecae21f5d 100644 --- a/rust/crates/plugins/src/hooks.rs +++ b/rust/crates/plugins/src/hooks.rs @@ -1,4 +1,5 @@ use std::ffi::OsStr; +#[cfg(not(windows))] use std::path::Path; use std::process::Command; @@ -402,33 +403,52 @@ mod tests { fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); fs::create_dir_all(root.join("hooks")).expect("hooks dir"); - let pre_path = root.join("hooks").join("pre.sh"); + #[cfg(not(windows))] + let (pre_name, post_name, failure_name) = ("pre.sh", "post.sh", "failure.sh"); + #[cfg(windows)] + let (pre_name, post_name, failure_name) = ("pre.cmd", "post.cmd", "failure.cmd"); + + let pre_path = root.join("hooks").join(pre_name); + #[cfg(not(windows))] fs::write( &pre_path, format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"), ) .expect("write pre hook"); + #[cfg(windows)] + fs::write(&pre_path, format!("@echo off\necho {pre_message}\n")).expect("write pre hook"); make_executable(&pre_path); - let post_path = root.join("hooks").join("post.sh"); + let post_path = root.join("hooks").join(post_name); + #[cfg(not(windows))] fs::write( &post_path, format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"), ) .expect("write post hook"); + #[cfg(windows)] + fs::write(&post_path, format!("@echo off\necho {post_message}\n")) + .expect("write post hook"); make_executable(&post_path); - let failure_path = root.join("hooks").join("failure.sh"); + let failure_path = root.join("hooks").join(failure_name); + #[cfg(not(windows))] fs::write( &failure_path, format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"), ) .expect("write failure hook"); + #[cfg(windows)] + fs::write( + &failure_path, + format!("@echo off\necho {failure_message}\n"), + ) + .expect("write failure hook"); make_executable(&failure_path); fs::write( root.join(".claude-plugin").join("plugin.json"), format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}" + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/{pre_name}\"],\n \"PostToolUse\": [\"./hooks/{post_name}\"],\n \"PostToolUseFailure\": [\"./hooks/{failure_name}\"]\n }}\n}}" ), ) .expect("write plugin manifest"); @@ -499,7 +519,16 @@ mod tests { fn pre_tool_use_denies_when_plugin_hook_exits_two() { // given let runner = HookRunner::new(crate::PluginHooks { - pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()], + pre_tool_use: vec![{ + #[cfg(windows)] + { + "echo blocked by plugin & exit /b 2".to_string() + } + #[cfg(not(windows))] + { + "printf 'blocked by plugin'; exit 2".to_string() + } + }], post_tool_use: Vec::new(), post_tool_use_failure: Vec::new(), }); @@ -517,8 +546,26 @@ mod tests { // given let runner = HookRunner::new(crate::PluginHooks { pre_tool_use: vec![ - "printf 'broken plugin hook'; exit 1".to_string(), - "printf 'later plugin hook'".to_string(), + { + #[cfg(windows)] + { + "echo broken plugin hook & exit /b 1".to_string() + } + #[cfg(not(windows))] + { + "printf 'broken plugin hook'; exit 1".to_string() + } + }, + { + #[cfg(windows)] + { + "echo later plugin hook".to_string() + } + #[cfg(not(windows))] + { + "printf 'later plugin hook'".to_string() + } + }, ], post_tool_use: Vec::new(), post_tool_use_failure: Vec::new(), diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 765c0ac242..824317a43c 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -2426,18 +2426,37 @@ mod tests { fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf { let log_path = root.join("lifecycle.log"); + #[cfg(not(windows))] + let (init_name, shutdown_name) = ("init.sh", "shutdown.sh"); + #[cfg(windows)] + let (init_name, shutdown_name) = ("init.cmd", "shutdown.cmd"); + + #[cfg(not(windows))] write_file( - root.join("lifecycle").join("init.sh").as_path(), + root.join("lifecycle").join(init_name).as_path(), "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", ); + #[cfg(not(windows))] write_file( - root.join("lifecycle").join("shutdown.sh").as_path(), + root.join("lifecycle").join(shutdown_name).as_path(), "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", ); + + #[cfg(windows)] + write_file( + root.join("lifecycle").join(init_name).as_path(), + "@echo off\necho init>> lifecycle.log\n", + ); + #[cfg(windows)] + write_file( + root.join("lifecycle").join(shutdown_name).as_path(), + "@echo off\necho shutdown>> lifecycle.log\n", + ); + write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}" + "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/{init_name}\"],\n \"Shutdown\": [\"./lifecycle/{shutdown_name}\"]\n }}\n}}" ) .as_str(), ); @@ -2449,11 +2468,22 @@ mod tests { } fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) { - let script_path = root.join("tools").join("echo-json.sh"); + #[cfg(not(windows))] + let script_name = "echo-json.sh"; + #[cfg(windows)] + let script_name = "echo-json.cmd"; + + let script_path = root.join("tools").join(script_name); + #[cfg(not(windows))] write_file( &script_path, "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n", ); + #[cfg(windows)] + write_file( + &script_path, + "@echo off\r\npowershell -NoProfile -Command \"$inputJson=[Console]::In.ReadToEnd(); $obj=@{plugin=$env:CLAWD_PLUGIN_ID; tool=$env:CLAWD_TOOL_NAME; input=(ConvertFrom-Json $inputJson)}; $obj | ConvertTo-Json -Compress\"\r\n", + ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -2465,7 +2495,7 @@ mod tests { write_file( root.join(MANIFEST_RELATIVE_PATH).as_path(), format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}" + "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/{script_name}\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}" ) .as_str(), ); @@ -3417,7 +3447,7 @@ mod tests { registry.shutdown().expect("shutdown should succeed"); let log = fs::read_to_string(&log_path).expect("lifecycle log should exist"); - assert_eq!(log, "init\nshutdown\n"); + assert_eq!(log.replace("\r\n", "\n"), "init\nshutdown\n"); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); diff --git a/rust/crates/runtime/Cargo.toml b/rust/crates/runtime/Cargo.toml index b1bd04f374..377cab657f 100644 --- a/rust/crates/runtime/Cargo.toml +++ b/rust/crates/runtime/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true [dependencies] sha2 = "0.10" glob = "0.3" +globset = "0.4" plugins = { path = "../plugins" } regex = "1" serde = { version = "1", features = ["derive"] } @@ -15,6 +16,8 @@ serde_json.workspace = true telemetry = { path = "../telemetry" } tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } walkdir = "2" +getrandom = "0.2" +ignore = "0.4" [lints] workspace = true diff --git a/rust/crates/runtime/src/bash.rs b/rust/crates/runtime/src/bash.rs index f7c3d45b5b..44ab035862 100644 --- a/rust/crates/runtime/src/bash.rs +++ b/rust/crates/runtime/src/bash.rs @@ -309,9 +309,12 @@ fn prepare_sandbox_dirs(cwd: &std::path::Path) { #[cfg(test)] mod tests { + #[cfg(not(windows))] use super::{execute_bash, BashCommandInput}; + #[cfg(not(windows))] use crate::sandbox::FilesystemIsolationMode; + #[cfg(not(windows))] #[test] fn executes_simple_command() { let output = execute_bash(BashCommandInput { @@ -332,6 +335,7 @@ mod tests { assert!(output.sandbox_status.is_some()); } + #[cfg(not(windows))] #[test] fn disables_sandbox_when_requested() { let output = execute_bash(BashCommandInput { diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..3775aead17 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -65,6 +65,32 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + auto_tdd: AutoTddConfig, +} + +/// Configures automatic lint/test runs after write tools complete. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AutoTddConfig { + enabled: bool, + tools: Vec, + commands: Vec, +} + +impl AutoTddConfig { + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } + + #[must_use] + pub fn tools(&self) -> &[String] { + &self.tools + } + + #[must_use] + pub fn commands(&self) -> &[String] { + &self.commands + } } /// Ordered chain of fallback model identifiers used when the primary @@ -315,6 +341,7 @@ impl ConfigLoader { sandbox: parse_optional_sandbox_config(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?, + auto_tdd: parse_optional_auto_tdd_config(&merged_value)?, }; Ok(RuntimeConfig { @@ -414,6 +441,11 @@ impl RuntimeConfig { pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots } + + #[must_use] + pub fn auto_tdd(&self) -> &AutoTddConfig { + &self.feature_config.auto_tdd + } } impl RuntimeFeatureConfig { @@ -483,6 +515,11 @@ impl RuntimeFeatureConfig { pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots } + + #[must_use] + pub fn auto_tdd(&self) -> &AutoTddConfig { + &self.auto_tdd + } } impl ProviderFallbackConfig { @@ -754,6 +791,26 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result Result { + let Some(object) = root.as_object() else { + return Ok(AutoTddConfig::default()); + }; + let Some(value) = object.get("autoTdd") else { + return Ok(AutoTddConfig::default()); + }; + let auto = expect_object(value, "merged settings.autoTdd")?; + let enabled = optional_bool(auto, "enabled", "merged settings.autoTdd")?.unwrap_or(false); + let tools = optional_string_array(auto, "tools", "merged settings.autoTdd")? + .unwrap_or_else(|| vec![String::from("write_file"), String::from("edit_file")]); + let commands = + optional_string_array(auto, "commands", "merged settings.autoTdd")?.unwrap_or_default(); + Ok(AutoTddConfig { + enabled, + tools, + commands, + }) +} + fn parse_optional_hooks_config_object( object: &BTreeMap, context: &str, diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 7a9c1c4adc..638695979c 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -197,6 +197,25 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "trustedRoots", expected: FieldType::StringArray, }, + FieldSpec { + name: "autoTdd", + expected: FieldType::Object, + }, +]; + +const AUTO_TDD_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "enabled", + expected: FieldType::Bool, + }, + FieldSpec { + name: "tools", + expected: FieldType::StringArray, + }, + FieldSpec { + name: "commands", + expected: FieldType::StringArray, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ @@ -501,6 +520,15 @@ pub fn validate_config_file( &path_display, )); } + if let Some(auto_tdd) = object.get("autoTdd").and_then(JsonValue::as_object) { + result.merge(validate_object_keys( + auto_tdd, + AUTO_TDD_FIELDS, + "autoTdd", + source, + &path_display, + )); + } result } @@ -737,6 +765,7 @@ mod tests { "hooks": {"PreToolUse": ["guard"]}, "permissions": {"defaultMode": "plan", "allow": ["Read"]}, "mcpServers": {}, + "autoTdd": {"enabled": true, "commands": ["echo ok"]}, "sandbox": {"enabled": false} }"#; let parsed = JsonValue::parse(source).expect("valid json"); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 610ba1a879..8e901c1ac6 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::fmt::Write as _; use std::fmt::{Display, Formatter}; use serde_json::{Map, Value}; @@ -13,7 +14,7 @@ use crate::permissions::{ PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter, }; use crate::session::{ContentBlock, ConversationMessage, Session}; -use crate::usage::{TokenUsage, UsageTracker}; +use crate::usage::{pricing_for_model, TokenUsage, UsageTracker}; const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000; const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS"; @@ -132,6 +133,8 @@ pub struct ConversationRuntime { max_iterations: usize, usage_tracker: UsageTracker, hook_runner: HookRunner, + auto_tdd: crate::AutoTddConfig, + model: Option, auto_compaction_input_tokens_threshold: u32, hook_abort_signal: HookAbortSignal, hook_progress_reporter: Option>, @@ -181,6 +184,8 @@ where max_iterations: usize::MAX, usage_tracker, hook_runner: HookRunner::from_feature_config(feature_config), + auto_tdd: feature_config.auto_tdd().clone(), + model: feature_config.model().map(ToOwned::to_owned), auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(), hook_abort_signal: HookAbortSignal::default(), hook_progress_reporter: None, @@ -482,6 +487,15 @@ where || post_hook_result.is_cancelled(), ); + if !is_error { + self.apply_auto_tdd_after_tool( + &tool_name, + &effective_input, + &mut output, + &mut is_error, + ); + } + ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error) } PermissionOutcome::Deny { reason } => ConversationMessage::tool_result( @@ -670,6 +684,34 @@ where "prompt_cache_events".to_string(), Value::from(summary.prompt_cache_events.len() as u64), ); + attributes.insert( + "usage".to_string(), + serde_json::json!({ + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + "total_tokens": summary.usage.total_tokens(), + }), + ); + + let pricing = self.model.as_deref().and_then(pricing_for_model); + let cost = pricing.map_or_else( + || summary.usage.estimate_cost_usd(), + |p| summary.usage.estimate_cost_usd_with_pricing(p), + ); + attributes.insert( + "cost".to_string(), + serde_json::json!({ + "model": self.model, + "pricing_known": pricing.is_some(), + "total_cost_usd": cost.total_cost_usd(), + "input_cost_usd": cost.input_cost_usd, + "output_cost_usd": cost.output_cost_usd, + "cache_creation_cost_usd": cost.cache_creation_cost_usd, + "cache_read_cost_usd": cost.cache_read_cost_usd, + }), + ); session_tracer.record("turn_completed", attributes); } @@ -685,6 +727,149 @@ where } } +impl ConversationRuntime { + fn apply_auto_tdd_after_tool( + &self, + tool_name: &str, + tool_input: &str, + output: &mut String, + is_error: &mut bool, + ) { + if !self.auto_tdd.enabled() { + return; + } + if self.auto_tdd.commands().is_empty() { + return; + } + if !self.auto_tdd.tools().iter().any(|t| t == tool_name) { + return; + } + if std::env::var("CLAW_STAGING_WRITE") + .ok() + .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + { + return; + } + // If the tool output indicates a staged write, avoid running tests on unchanged working tree. + if output.contains("\"kind\":\"stage\"") || output.contains("\"kind\": \"stage\"") { + return; + } + + let mut section = String::new(); + section.push_str("\n\n---\nAuto-TDD (post-write checks)\n"); + let _ = writeln!(section, "tool: {tool_name}"); + let _ = writeln!(section, "input: {tool_input}"); + + for cmd in self.auto_tdd.commands() { + let run = run_shell_command(cmd); + section.push_str("\n$ "); + section.push_str(cmd); + section.push('\n'); + match run { + CommandRun::Ok { stdout, stderr } => { + if !stdout.is_empty() { + section.push_str(&truncate_for_tool_output(&stdout)); + section.push('\n'); + } + if !stderr.is_empty() { + section.push_str(&truncate_for_tool_output(&stderr)); + section.push('\n'); + } + } + CommandRun::Failed { + code, + stdout, + stderr, + } => { + *is_error = true; + let _ = writeln!(section, "(exit code {code})"); + if !stdout.is_empty() { + section.push_str(&truncate_for_tool_output(&stdout)); + section.push('\n'); + } + if !stderr.is_empty() { + section.push_str(&truncate_for_tool_output(&stderr)); + section.push('\n'); + } + break; + } + } + } + + output.push_str(§ion); + } +} + +enum CommandRun { + Ok { + stdout: String, + stderr: String, + }, + Failed { + code: i32, + stdout: String, + stderr: String, + }, +} + +fn truncate_for_tool_output(s: &str) -> String { + const LIMIT: usize = 24_000; + if s.len() <= LIMIT { + return s.to_string(); + } + let mut out = s[..LIMIT].to_string(); + out.push_str("\n… truncated …"); + out +} + +fn run_shell_command(command: &str) -> CommandRun { + use std::process::Command; + + #[cfg(windows)] + let mut cmd = { + let mut c = Command::new("cmd"); + c.arg("/C").arg(command); + c + }; + + #[cfg(not(windows))] + let mut cmd = { + let mut c = Command::new("sh"); + c.arg("-lc").arg(command); + c + }; + + let output = match cmd.output() { + Ok(o) => o, + Err(e) => { + return CommandRun::Failed { + code: 127, + stdout: String::new(), + stderr: e.to_string(), + }; + } + }; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + match output.status.code() { + Some(0) => CommandRun::Ok { stdout, stderr }, + Some(code) => CommandRun::Failed { + code, + stdout, + stderr, + }, + None => CommandRun::Failed { + code: 1, + stdout, + stderr: if stderr.is_empty() { + "terminated by signal".to_string() + } else { + stderr + }, + }, + } +} + /// Reads the automatic compaction threshold from the environment. #[must_use] pub fn auto_compaction_threshold_from_env() -> u32 { @@ -842,6 +1027,16 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use telemetry::{MemoryTelemetrySink, SessionTracer, TelemetryEvent}; + #[cfg(windows)] + fn hook_print(text: &str) -> String { + format!(r"echo {text}") + } + + #[cfg(not(windows))] + fn hook_print(text: &str) -> String { + format!("printf '{text}'") + } + struct ScriptedApiClient { call_count: usize, } @@ -1219,8 +1414,8 @@ mod tests { PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], &RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( - vec![shell_snippet("printf 'pre hook ran'")], - vec![shell_snippet("printf 'post hook ran'")], + vec![shell_snippet(&hook_print("pre hook ran"))], + vec![shell_snippet(&hook_print("post hook ran"))], Vec::new(), )), ); @@ -1297,8 +1492,8 @@ mod tests { vec!["system".to_string()], &RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( Vec::new(), - vec![shell_snippet("printf 'post hook should not run'")], - vec![shell_snippet("printf 'failure hook ran'")], + vec![shell_snippet(&hook_print("post hook should not run"))], + vec![shell_snippet(&hook_print("failure hook ran"))], )), ); diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index db51215ee3..345b7cf8cc 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -5,9 +5,9 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use glob::Pattern; +use ignore::WalkBuilder; use regex::RegexBuilder; use serde::{Deserialize, Serialize}; -use walkdir::WalkDir; /// Maximum file size that can be read (10 MB). const MAX_READ_SIZE: u64 = 10 * 1024 * 1024; @@ -15,6 +15,47 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024; /// Maximum file size that can be written (10 MB). const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024; +fn staging_write_enabled() -> bool { + matches!( + std::env::var("CLAW_STAGING_WRITE").ok().as_deref(), + Some("1" | "true" | "TRUE" | "yes" | "YES") + ) +} + +fn staging_dir(workspace_root: &Path) -> PathBuf { + if let Ok(p) = std::env::var("CLAW_STAGING_DIR") { + return PathBuf::from(p); + } + workspace_root.join(".claw").join("staging") +} + +fn now_ms() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +fn structured_patch_to_unified_diff(path: &str, hunks: &[StructuredPatchHunk]) -> String { + use std::fmt::Write; + let mut out = String::new(); + let _ = writeln!(out, "--- a/{path}"); + let _ = writeln!(out, "+++ b/{path}"); + for h in hunks { + let _ = writeln!( + out, + "@@ -{},{} +{},{} @@", + h.old_start, h.old_lines, h.new_start, h.new_lines + ); + for line in &h.lines { + out.push_str(line); + out.push('\n'); + } + } + out +} + /// Check whether a file appears to contain binary content by examining /// the first chunk for NUL bytes. fn is_binary_file(path: &Path) -> io::Result { @@ -302,27 +343,90 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result 100; + let filenames = matches + .into_iter() + .take(100) + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(); + + return Ok(GlobSearchOutput { + duration_ms: started.elapsed().as_millis(), + num_files: filenames.len(), + filenames, + truncated, + }); + } + + // Relative patterns: walk the tree and respect .gitignore/.ignore + custom .clawignore. + let mut b = globset::GlobSetBuilder::new(); + let g = globset::Glob::new(pattern) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; + b.add(g); + let set = b + .build() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; + + // Brace expansion: build multiple globsets so patterns like + // `Assets/**/*.{cs,uxml,uss}` match all alternatives. + // + // Note: we intentionally do *not* use the `glob` crate for relative patterns, + // because we want to respect `.gitignore` / `.ignore` and a custom `.clawignore`. + let expanded = expand_braces(pattern); + let sets = expanded + .into_iter() + .map(|pat| { + let mut b = globset::GlobSetBuilder::new(); + let g = globset::Glob::new(&pat) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; + b.add(g); + b.build() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string())) + }) + .collect::>>()?; + + let mut matches = Vec::new(); + let mut walker = WalkBuilder::new(&base_dir); + walker + .follow_links(false) + .git_ignore(true) + .git_exclude(true) + .ignore(true) + .hidden(false) + .add_custom_ignore_filename(".clawignore"); + for result in walker.build() { + let Ok(entry) = result else { + continue; + }; + if !entry.file_type().is_some_and(|t| t.is_file()) { + continue; + } + let path = entry.path(); + let Ok(rel) = path.strip_prefix(&base_dir) else { + continue; + }; + let rel_s = rel.to_string_lossy().replace('\\', "/"); + if sets.iter().any(|set| set.is_match(rel_s.as_str())) { + matches.push(path.to_path_buf()); + } } matches.sort_by_key(|path| { @@ -379,7 +483,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { let mut content_lines = Vec::new(); let mut total_matches = 0usize; - for file_path in collect_search_files(&base_path)? { + for file_path in collect_search_files(&base_path) { if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) { continue; } @@ -457,19 +561,29 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { }) } -fn collect_search_files(base_path: &Path) -> io::Result> { +fn collect_search_files(base_path: &Path) -> Vec { if base_path.is_file() { - return Ok(vec![base_path.to_path_buf()]); + return vec![base_path.to_path_buf()]; } let mut files = Vec::new(); - for entry in WalkDir::new(base_path) { - let entry = entry.map_err(|error| io::Error::other(error.to_string()))?; - if entry.file_type().is_file() { + let mut walker = WalkBuilder::new(base_path); + walker + .follow_links(false) + .git_ignore(true) + .git_exclude(true) + .ignore(true) + .hidden(false) + .add_custom_ignore_filename(".clawignore"); + for result in walker.build() { + let Ok(entry) = result else { + continue; + }; + if entry.file_type().is_some_and(|t| t.is_file()) { files.push(entry.path().to_path_buf()); } } - Ok(files) + files } fn matches_optional_filters( @@ -593,6 +707,52 @@ pub fn write_file_in_workspace( .canonicalize() .unwrap_or_else(|_| workspace_root.to_path_buf()); validate_workspace_boundary(&absolute_path, &canonical_root)?; + + if staging_write_enabled() { + if content.len() > MAX_WRITE_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "content is too large ({} bytes, max {} bytes)", + content.len(), + MAX_WRITE_SIZE + ), + )); + } + + let original_file = fs::read_to_string(&absolute_path).ok(); + let structured_patch = make_patch(original_file.as_deref().unwrap_or(""), content); + + let rel_path = absolute_path + .strip_prefix(&canonical_root) + .unwrap_or(&absolute_path) + .to_string_lossy() + .replace('\\', "/"); + + let staging_root = staging_dir(&canonical_root); + let batch_dir = staging_root.join(format!("write-{}", now_ms())); + let proposed_path = batch_dir.join(rel_path.as_str()); + let diff_path = batch_dir.join(format!("{rel_path}.diff")); + if let Some(parent) = proposed_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&proposed_path, content)?; + let diff = structured_patch_to_unified_diff(&rel_path, &structured_patch); + if let Some(parent) = diff_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&diff_path, diff)?; + + return Ok(WriteFileOutput { + kind: String::from("stage"), + file_path: absolute_path.to_string_lossy().into_owned(), + content: content.to_owned(), + structured_patch, + original_file, + git_diff: None, + }); + } + write_file(path, content) } @@ -610,6 +770,59 @@ pub fn edit_file_in_workspace( .canonicalize() .unwrap_or_else(|_| workspace_root.to_path_buf()); validate_workspace_boundary(&absolute_path, &canonical_root)?; + + if staging_write_enabled() { + let original_file = fs::read_to_string(&absolute_path)?; + if old_string == new_string { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "old_string and new_string must differ", + )); + } + if !original_file.contains(old_string) { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "old_string not found in file", + )); + } + let updated = if replace_all { + original_file.replace(old_string, new_string) + } else { + original_file.replacen(old_string, new_string, 1) + }; + + let structured_patch = make_patch(&original_file, &updated); + let rel_path = absolute_path + .strip_prefix(&canonical_root) + .unwrap_or(&absolute_path) + .to_string_lossy() + .replace('\\', "/"); + let staging_root = staging_dir(&canonical_root); + let batch_dir = staging_root.join(format!("edit-{}", now_ms())); + let proposed_path = batch_dir.join(rel_path.as_str()); + let diff_path = batch_dir.join(format!("{rel_path}.diff")); + if let Some(parent) = proposed_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&proposed_path, &updated)?; + let diff = structured_patch_to_unified_diff(&rel_path, &structured_patch); + if let Some(parent) = diff_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&diff_path, diff)?; + + return Ok(EditFileOutput { + file_path: absolute_path.to_string_lossy().into_owned(), + old_string: old_string.to_owned(), + new_string: new_string.to_owned(), + original_file, + structured_patch, + user_modified: false, + replace_all, + git_diff: None, + }); + } + edit_file(path, old_string, new_string, replace_all) } @@ -742,11 +955,11 @@ mod tests { let outside = temp_path("symlink-target.txt"); std::fs::write(&outside, "target content").expect("target should write"); - let link_path = workspace.join("escape-link.txt"); + let _link_path = workspace.join("escape-link.txt"); #[cfg(unix)] { - std::os::unix::fs::symlink(&outside, &link_path).expect("symlink should create"); - assert!(is_symlink_escape(&link_path, &workspace).expect("check should succeed")); + std::os::unix::fs::symlink(&outside, &_link_path).expect("symlink should create"); + assert!(is_symlink_escape(&_link_path, &workspace).expect("check should succeed")); } // Non-symlink file should not be an escape @@ -755,6 +968,41 @@ mod tests { assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed")); } + #[test] + fn staging_write_creates_diff_without_modifying_file() { + let workspace = temp_path("staging-workspace"); + std::fs::create_dir_all(&workspace).expect("workspace dir should be created"); + let file = workspace.join("demo.txt"); + write_file(file.to_string_lossy().as_ref(), "old").expect("seed write"); + + std::env::set_var("CLAW_STAGING_WRITE", "1"); + let out = + super::write_file_in_workspace(file.to_string_lossy().as_ref(), "new", &workspace) + .expect("staged write should succeed"); + assert_eq!(out.kind, "stage"); + assert_eq!( + std::fs::read_to_string(&file).expect("original should remain"), + "old" + ); + + let staging = workspace.join(".claw").join("staging"); + let mut found_diff = false; + for entry in walkdir::WalkDir::new(&staging) { + let entry = entry.expect("walk"); + if entry.path().is_file() + && entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("diff")) + { + found_diff = true; + break; + } + } + assert!(found_diff, "expected a staged .diff file"); + std::env::remove_var("CLAW_STAGING_WRITE"); + } + #[test] fn globs_and_greps_directory() { let dir = temp_path("search-dir"); @@ -790,6 +1038,68 @@ mod tests { assert!(grep_output.content.unwrap_or_default().contains("hello")); } + #[test] + fn glob_and_grep_respect_gitignore_and_clawignore() { + let dir = temp_path("search-ignore-dir"); + std::fs::create_dir_all(&dir).expect("directory should be created"); + std::fs::create_dir_all(dir.join(".git")).expect("repo marker"); + std::fs::write(dir.join(".gitignore"), "node_modules/\n").expect("write gitignore"); + std::fs::write(dir.join(".clawignore"), "ignored_dir/\n").expect("write clawignore"); + + let kept = dir.join("src").join("kept.rs"); + std::fs::create_dir_all(kept.parent().expect("parent")).expect("mk src"); + write_file( + kept.to_string_lossy().as_ref(), + "fn kept() {\n println!(\"hello\");\n}\n", + ) + .expect("kept write"); + + let ignored_nm = dir.join("node_modules").join("ignored.rs"); + std::fs::create_dir_all(ignored_nm.parent().expect("parent")).expect("mk node_modules"); + write_file( + ignored_nm.to_string_lossy().as_ref(), + "fn ignored() {\n println!(\"hello\");\n}\n", + ) + .expect("ignored write"); + + let ignored_custom = dir.join("ignored_dir").join("also_ignored.rs"); + std::fs::create_dir_all(ignored_custom.parent().expect("parent")).expect("mk ignored_dir"); + write_file( + ignored_custom.to_string_lossy().as_ref(), + "fn ignored2() {\n println!(\"hello\");\n}\n", + ) + .expect("ignored2 write"); + + let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref())) + .expect("glob should succeed"); + assert_eq!(globbed.num_files, 1); + assert!(globbed.filenames[0] + .replace('\\', "/") + .ends_with("/src/kept.rs")); + + let grep_output = grep_search(&GrepSearchInput { + pattern: String::from("hello"), + path: Some(dir.to_string_lossy().into_owned()), + glob: Some(String::from("**/*.rs")), + output_mode: Some(String::from("files_with_matches")), + before: None, + after: None, + context_short: None, + context: None, + line_numbers: Some(true), + case_insensitive: Some(false), + file_type: None, + head_limit: Some(50), + offset: Some(0), + multiline: Some(false), + }) + .expect("grep should succeed"); + assert_eq!(grep_output.num_files, 1); + assert!(grep_output.filenames[0] + .replace('\\', "/") + .ends_with("/src/kept.rs")); + } + #[test] fn expand_braces_no_braces() { assert_eq!(expand_braces("*.rs"), vec!["*.rs"]); @@ -799,20 +1109,14 @@ mod tests { fn expand_braces_single_group() { let mut result = expand_braces("Assets/**/*.{cs,uxml,uss}"); result.sort(); - assert_eq!( - result, - vec!["Assets/**/*.cs", "Assets/**/*.uss", "Assets/**/*.uxml",] - ); + assert_eq!(result, vec!["Assets/**/*.cs", "Assets/**/*.uss", "Assets/**/*.uxml",]); } #[test] fn expand_braces_nested() { let mut result = expand_braces("src/{a,b}.{rs,toml}"); result.sort(); - assert_eq!( - result, - vec!["src/a.rs", "src/a.toml", "src/b.rs", "src/b.toml"] - ); + assert_eq!(result, vec!["src/a.rs", "src/a.toml", "src/b.rs", "src/b.toml"]); } #[test] @@ -828,12 +1132,8 @@ mod tests { std::fs::write(dir.join("b.toml"), "[package]").unwrap(); std::fs::write(dir.join("c.txt"), "hello").unwrap(); - let result = - glob_search("*.{rs,toml}", Some(dir.to_str().unwrap())).expect("glob should succeed"); - assert_eq!( - result.num_files, 2, - "should match .rs and .toml but not .txt" - ); + let result = glob_search("*.{rs,toml}", Some(dir.to_str().unwrap())).expect("glob ok"); + assert_eq!(result.num_files, 2, "should match .rs and .toml but not .txt"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/rust/crates/runtime/src/git_tools.rs b/rust/crates/runtime/src/git_tools.rs new file mode 100644 index 0000000000..50ce989adc --- /dev/null +++ b/rust/crates/runtime/src/git_tools.rs @@ -0,0 +1,262 @@ +use std::io; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitTextOutput { + #[serde(rename = "type")] + pub kind: String, + pub stdout: String, + pub stderr: String, + pub truncated: bool, + pub exit_code: Option, +} + +fn git_gate_is_repo(workspace_root: &Path) -> io::Result<()> { + let out = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(workspace_root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !out.success() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a git work tree", + )); + } + Ok(()) +} + +fn is_safe_git_rev_range(s: &str) -> bool { + let t = s.trim(); + if t.is_empty() || t.len() > 200 { + return false; + } + t.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_' | '-' | '^' | '~' | ':' | '@') + }) +} + +fn read_pipe_capped(r: impl std::io::Read, cap: usize) -> io::Result<(Vec, bool)> { + use std::io::Read; + let mut buf = Vec::new(); + let mut limited = r.take(u64::try_from(cap.saturating_add(1)).unwrap_or(u64::MAX)); + limited.read_to_end(&mut buf)?; + let truncated = buf.len() > cap; + if truncated { + buf.truncate(cap); + } + Ok((buf, truncated)) +} + +fn run_git_capped(workspace_root: &Path, args: &[String], cap: usize) -> io::Result { + git_gate_is_repo(workspace_root)?; + let mut child = Command::new("git") + .arg("--no-optional-locks") + .args(args) + .current_dir(workspace_root) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("git stdout unavailable"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("git stderr unavailable"))?; + + let out_handle = std::thread::spawn(move || read_pipe_capped(stdout, cap)); + let err_handle = std::thread::spawn(move || read_pipe_capped(stderr, cap)); + + let status = child.wait()?; + let exit_code = status.code(); + let (out_bytes, out_trunc) = out_handle + .join() + .map_err(|_| io::Error::other("git stdout thread panicked"))??; + let (err_bytes, err_trunc) = err_handle + .join() + .map_err(|_| io::Error::other("git stderr thread panicked"))??; + + Ok(GitTextOutput { + kind: "git".to_string(), + stdout: String::from_utf8_lossy(&out_bytes).into_owned(), + stderr: String::from_utf8_lossy(&err_bytes).into_owned(), + truncated: out_trunc || err_trunc, + exit_code, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct GitDiffOptions { + pub cached: bool, + pub rev_range: Option, + pub context_lines: Option, + pub paths: Option>, +} + +pub fn git_diff_in_workspace( + workspace_root: &Path, + options: GitDiffOptions, + max_bytes: usize, +) -> io::Result { + let mut args: Vec = vec![ + "diff".to_string(), + "--no-color".to_string(), + "--no-ext-diff".to_string(), + ]; + if let Some(n) = options.context_lines { + let n = n.clamp(0, 100); + args.push(format!("-U{n}")); + } + if options.cached { + args.push("--cached".to_string()); + } + if let Some(rr) = options.rev_range.as_deref() { + if !rr.trim().is_empty() { + if !is_safe_git_rev_range(rr) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid rev_range", + )); + } + args.push(rr.trim().to_string()); + } + } + if let Some(paths) = options.paths { + let mut cleaned = Vec::new(); + for p in paths { + if p.trim().is_empty() { + continue; + } + // paths are passed after `--`, so they are not parsed as flags; still enforce "relative-ish". + if Path::new(&p).is_absolute() || p.contains("..") { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid path")); + } + cleaned.push(p.replace('\\', "/")); + } + if !cleaned.is_empty() { + args.push("--".to_string()); + args.extend(cleaned); + } + } + run_git_capped(workspace_root, &args, max_bytes) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct GitLogOptions { + pub max_count: Option, + pub rev_range: Option, + pub paths: Option>, +} + +pub fn git_log_in_workspace( + workspace_root: &Path, + options: GitLogOptions, + max_bytes: usize, +) -> io::Result { + let max_count = options.max_count.unwrap_or(20).min(50); + let mut args: Vec = vec![ + "log".to_string(), + "--no-color".to_string(), + "--no-decorate".to_string(), + format!("--max-count={max_count}"), + "--pretty=format:%h %s".to_string(), + ]; + if let Some(rr) = options.rev_range.as_deref() { + if !rr.trim().is_empty() { + if !is_safe_git_rev_range(rr) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid rev_range", + )); + } + args.push(rr.trim().to_string()); + } + } + if let Some(paths) = options.paths { + let mut cleaned = Vec::new(); + for p in paths { + if p.trim().is_empty() { + continue; + } + if Path::new(&p).is_absolute() || p.contains("..") { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid path")); + } + cleaned.push(p.replace('\\', "/")); + } + if !cleaned.is_empty() { + args.push("--".to_string()); + args.extend(cleaned); + } + } + run_git_capped(workspace_root, &args, max_bytes) +} + +#[cfg(test)] +mod tests { + use super::{git_diff_in_workspace, git_log_in_workspace, GitDiffOptions, GitLogOptions}; + use std::fs; + use std::path::Path; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(label: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("runtime-git-tools-{label}-{nanos}")) + } + + fn git(cwd: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("git should run"); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + } + + #[test] + fn diff_and_log_work() { + let _guard = crate::test_env_lock(); + let root = temp_dir("basic"); + fs::create_dir_all(&root).expect("dir"); + + git(&root, &["init", "--quiet", "--initial-branch=main"]); + git(&root, &["config", "user.email", "tests@example.com"]); + git(&root, &["config", "user.name", "Runtime Git Tools"]); + fs::write(root.join("a.txt"), "a\n").expect("write"); + git(&root, &["add", "a.txt"]); + git(&root, &["commit", "-m", "initial", "--quiet"]); + fs::write(root.join("a.txt"), "a!\n").expect("modify"); + + let log = git_log_in_workspace( + &root, + GitLogOptions { + max_count: Some(5), + ..Default::default() + }, + 64 * 1024, + ) + .expect("log"); + assert!(log.stdout.contains("initial")); + + let diff = + git_diff_in_workspace(&root, GitDiffOptions::default(), 64 * 1024).expect("diff"); + assert!(diff.stdout.contains("diff --git") || diff.stdout.contains("@@")); + + fs::remove_dir_all(&root).expect("cleanup"); + } +} diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs index 6abd69fbbd..2afb369649 100644 --- a/rust/crates/runtime/src/hooks.rs +++ b/rust/crates/runtime/src/hooks.rs @@ -737,7 +737,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: & fn shell_command(command: &str) -> CommandWithStdin { #[cfg(windows)] - let mut command_builder = { + let command_builder = { let mut command_builder = Command::new("cmd"); command_builder.arg("/C").arg(command); CommandWithStdin::new(command_builder) @@ -828,6 +828,52 @@ mod tests { use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; use crate::permissions::PermissionOverride; + #[cfg(windows)] + fn hook_print(text: &str) -> String { + // `echo` appends CRLF; hook parsing trims, so this is OK. + format!(r"echo {text}") + } + + #[cfg(not(windows))] + fn hook_print(text: &str) -> String { + format!("printf '{text}'") + } + + #[cfg(windows)] + fn hook_print_and_exit(text: &str, code: i32) -> String { + // Use cmd chaining. `exit /b N` sets process exit code. + format!(r"echo {text} & exit /b {code}") + } + + #[cfg(not(windows))] + fn hook_print_and_exit(text: &str, code: i32) -> String { + format!("printf '{text}'; exit {code}") + } + + #[cfg(windows)] + fn hook_sleep(seconds: u64) -> String { + // cmd has no sleep; ping to localhost is a common portable workaround. + let count = seconds.saturating_add(1); + format!(r"ping -n {count} 127.0.0.1 >nul") + } + + #[cfg(not(windows))] + fn hook_sleep(seconds: u64) -> String { + format!("sleep {seconds}") + } + + #[cfg(all(windows, not(test)))] + fn hook_permission_override_json() -> String { + // Use cmd+PowerShell, but put the whole PS program in single quotes for cmd, + // and print JSON with ConvertTo-Json to avoid quoting issues. + "powershell -NoProfile -Command \"$o=@{systemMessage='updated';hookSpecificOutput=@{permissionDecision='allow';permissionDecisionReason='hook ok';updatedInput=@{command='git status'}}};$s=($o|ConvertTo-Json -Compress);[Console]::Out.Write($s)\"".to_string() + } + + #[cfg(not(windows))] + fn hook_permission_override_json() -> String { + r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#.to_string() + } + struct RecordingReporter { events: Vec, } @@ -841,7 +887,7 @@ mod tests { #[test] fn allows_exit_code_zero_and_captures_stdout() { let runner = HookRunner::new(RuntimeHookConfig::new( - vec![shell_snippet("printf 'pre ok'")], + vec![shell_snippet(&hook_print("pre ok"))], Vec::new(), Vec::new(), )); @@ -854,7 +900,7 @@ mod tests { #[test] fn denies_exit_code_two() { let runner = HookRunner::new(RuntimeHookConfig::new( - vec![shell_snippet("printf 'blocked by hook'; exit 2")], + vec![shell_snippet(&hook_print_and_exit("blocked by hook", 2))], Vec::new(), Vec::new(), )); @@ -869,7 +915,7 @@ mod tests { fn propagates_other_non_zero_statuses_as_failures() { let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks( RuntimeHookConfig::new( - vec![shell_snippet("printf 'warning hook'; exit 1")], + vec![shell_snippet(&hook_print_and_exit("warning hook", 1))], Vec::new(), Vec::new(), ), @@ -887,12 +933,11 @@ mod tests { .any(|message| message.contains("warning hook"))); } + #[cfg(not(windows))] #[test] fn parses_pre_hook_permission_override_and_updated_input() { let runner = HookRunner::new(RuntimeHookConfig::new( - vec![shell_snippet( - r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#, - )], + vec![shell_snippet(&hook_permission_override_json())], Vec::new(), Vec::new(), )); @@ -908,13 +953,28 @@ mod tests { assert!(result.messages().iter().any(|message| message == "updated")); } + #[cfg(windows)] + #[test] + fn parses_hook_output_permission_override_and_updated_input_from_json() { + let parsed = super::parse_hook_output( + r#"{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}"#, + ); + assert_eq!(parsed.permission_override, Some(PermissionOverride::Allow)); + assert_eq!(parsed.permission_reason.as_deref(), Some("hook ok")); + assert_eq!( + parsed.updated_input.as_deref(), + Some(r#"{"command":"git status"}"#) + ); + assert!(parsed.messages.iter().any(|m| m == "updated")); + } + #[test] fn runs_post_tool_use_failure_hooks() { // given let runner = HookRunner::new(RuntimeHookConfig::new( Vec::new(), Vec::new(), - vec![shell_snippet("printf 'failure hook ran'")], + vec![shell_snippet(&hook_print("failure hook ran"))], )); // when @@ -933,8 +993,8 @@ mod tests { Vec::new(), Vec::new(), vec![ - shell_snippet("printf 'broken failure hook'; exit 1"), - shell_snippet("printf 'later failure hook'"), + shell_snippet(&hook_print_and_exit("broken failure hook", 1)), + shell_snippet(&hook_print("later failure hook")), ], )); @@ -959,8 +1019,8 @@ mod tests { // given let runner = HookRunner::new(RuntimeHookConfig::new( vec![ - shell_snippet("printf 'first'"), - shell_snippet("printf 'second'"), + shell_snippet(&hook_print("first")), + shell_snippet(&hook_print("second")), ], Vec::new(), Vec::new(), @@ -987,7 +1047,7 @@ mod tests { event: HookEvent::PreToolUse, command, .. - } if command == "printf 'first'" + } if *command == shell_snippet(&hook_print("first")) )); assert!(matches!( &reporter.events[1], @@ -995,7 +1055,7 @@ mod tests { event: HookEvent::PreToolUse, command, .. - } if command == "printf 'first'" + } if *command == shell_snippet(&hook_print("first")) )); assert!(matches!( &reporter.events[2], @@ -1003,7 +1063,7 @@ mod tests { event: HookEvent::PreToolUse, command, .. - } if command == "printf 'second'" + } if *command == shell_snippet(&hook_print("second")) )); assert!(matches!( &reporter.events[3], @@ -1011,7 +1071,7 @@ mod tests { event: HookEvent::PreToolUse, command, .. - } if command == "printf 'second'" + } if *command == shell_snippet(&hook_print("second")) )); } @@ -1020,8 +1080,8 @@ mod tests { // given let runner = HookRunner::new(RuntimeHookConfig::new( vec![ - shell_snippet("printf 'broken'; exit 1"), - shell_snippet("printf 'later'"), + shell_snippet(&hook_print_and_exit("broken", 1)), + shell_snippet(&hook_print("later")), ], Vec::new(), Vec::new(), @@ -1067,7 +1127,7 @@ mod tests { #[test] fn abort_signal_cancels_long_running_hook_and_reports_progress() { let runner = HookRunner::new(RuntimeHookConfig::new( - vec![shell_snippet("sleep 5")], + vec![shell_snippet(&hook_sleep(5))], Vec::new(), Vec::new(), )); @@ -1106,7 +1166,7 @@ mod tests { #[cfg(windows)] fn shell_snippet(script: &str) -> String { - script.replace('\'', "\"") + script.to_string() } #[cfg(not(windows))] diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c7d87091fa..d03477e2e4 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -14,6 +14,7 @@ pub mod config_validate; mod conversation; mod file_ops; mod git_context; +mod git_tools; pub mod green_contract; mod hooks; mod json; @@ -57,7 +58,7 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, + AutoTddConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, @@ -79,6 +80,9 @@ pub use file_ops::{ WriteFileOutput, }; pub use git_context::{GitCommitEntry, GitContext}; +pub use git_tools::{ + git_diff_in_workspace, git_log_in_workspace, GitDiffOptions, GitLogOptions, GitTextOutput, +}; pub use hooks::{ HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner, }; diff --git a/rust/crates/runtime/src/mcp_stdio.rs b/rust/crates/runtime/src/mcp_stdio.rs index 5fbc31ba58..c5b4c75e5f 100644 --- a/rust/crates/runtime/src/mcp_stdio.rs +++ b/rust/crates/runtime/src/mcp_stdio.rs @@ -1405,7 +1405,7 @@ fn default_initialize_params() -> McpInitializeParams { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use std::collections::BTreeMap; use std::fs; diff --git a/rust/crates/runtime/src/mcp_tool_bridge.rs b/rust/crates/runtime/src/mcp_tool_bridge.rs index af637a98d1..a159e34589 100644 --- a/rust/crates/runtime/src/mcp_tool_bridge.rs +++ b/rust/crates/runtime/src/mcp_tool_bridge.rs @@ -310,7 +310,7 @@ impl McpToolRegistry { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use std::collections::BTreeMap; use std::fs; diff --git a/rust/crates/runtime/src/oauth.rs b/rust/crates/runtime/src/oauth.rs index aa3ca158c7..d59b9dcdf1 100644 --- a/rust/crates/runtime/src/oauth.rs +++ b/rust/crates/runtime/src/oauth.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use std::fs::{self, File}; -use std::io::{self, Read}; +use std::fs; +use std::io; use std::path::PathBuf; use serde::{Deserialize, Serialize}; @@ -326,7 +326,16 @@ pub fn parse_oauth_callback_query(query: &str) -> Result io::Result { let mut buffer = vec![0_u8; bytes]; - File::open("/dev/urandom")?.read_exact(&mut buffer)?; + #[cfg(not(windows))] + { + use std::fs::File; + use std::io::Read; + File::open("/dev/urandom")?.read_exact(&mut buffer)?; + } + #[cfg(windows)] + { + getrandom::getrandom(&mut buffer).map_err(|error| io::Error::other(error.to_string()))?; + } Ok(base64url_encode(&buffer)) } @@ -336,6 +345,13 @@ fn credentials_home_dir() -> io::Result { } let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) + .or_else(|| { + let drive = std::env::var_os("HOMEDRIVE")?; + let path = std::env::var_os("HOMEPATH")?; + let mut joined = drive; + joined.push(path); + Some(joined) + }) .ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9d9df4f7ed..87d04aa72a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2006,23 +2006,30 @@ fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box - // Hint: worker state is written by the interactive REPL or a non-interactive prompt. - // Run: claw # start the REPL (writes state on first turn) - // Or: claw prompt # run one non-interactive turn - // Then rerun: claw state [--output-format json] - return Err(format!( - "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", - path = state_path.display() - ) - .into()); + let hint = format!( + "worker state is written by the interactive REPL or a non-interactive prompt.\n\ + Run: claw # start the REPL (writes state on first turn)\n\ + Or: claw prompt # run one non-interactive turn\n\ + Then rerun: claw state [--output-format json]" + ); + match output_format { + CliOutputFormat::Text => { + println!( + "No worker state file found at {}\nHint: {hint}", + state_path.display() + ); + } + CliOutputFormat::Json => println!( + "{}", + serde_json::json!({ + "type": "error", + "error": "no_state_file", + "path": state_path.display().to_string(), + "hint": hint, + }) + ), + } + return Ok(()); } let raw = std::fs::read_to_string(&state_path)?; match output_format { @@ -6473,15 +6480,19 @@ fn civil_from_days(days: i64) -> (i32, u32, u32) { } else { (z - 146_096) / 146_097 }; - let doe = (z - era * 146_097) as u64; // [0, 146_096] + let doe = u64::try_from(z - era * 146_097).unwrap_or(0); // [0, 146_096] let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; + let y = i64::try_from(yoe).unwrap_or(0) + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] let y = y + i64::from(m <= 2); - (y as i32, m as u32, d as u32) + ( + i32::try_from(y).unwrap_or(0), + u32::try_from(m).unwrap_or(0), + u32::try_from(d).unwrap_or(0), + ) } fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { @@ -9271,6 +9282,32 @@ mod tests { ); } + fn cleanup_dir(root: &Path) { + #[cfg(windows)] + { + // Windows sometimes keeps file handles alive briefly (antivirus/indexer/process spawn). + // Retry a few times to avoid flaky tests. + for _ in 0..30 { + match fs::remove_dir_all(root) { + Ok(()) => return, + Err(e) => { + if e.raw_os_error() == Some(32) { + thread::sleep(Duration::from_millis(50)); + continue; + } + panic!("cleanup temp dir: {e}"); + } + } + } + panic!("cleanup temp dir: timed out waiting for Windows file locks"); + } + + #[cfg(not(windows))] + { + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + } + fn env_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -9306,33 +9343,73 @@ mod tests { fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); if include_hooks { fs::create_dir_all(root.join("hooks")).expect("hooks dir"); - fs::write( - root.join("hooks").join("pre.sh"), - "#!/bin/sh\nprintf 'plugin pre hook'\n", - ) + #[cfg(not(windows))] + let hook_name = "pre.sh"; + #[cfg(windows)] + let hook_name = "pre.cmd"; + fs::write(root.join("hooks").join(hook_name), { + #[cfg(not(windows))] + { + "#!/bin/sh\nprintf 'plugin pre hook'\n" + } + #[cfg(windows)] + { + "@echo off\necho plugin pre hook\n" + } + }) .expect("write hook"); } if include_lifecycle { fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); - fs::write( - root.join("lifecycle").join("init.sh"), - "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", - ) + #[cfg(not(windows))] + let (init_name, shutdown_name) = ("init.sh", "shutdown.sh"); + #[cfg(windows)] + let (init_name, shutdown_name) = ("init.cmd", "shutdown.cmd"); + fs::write(root.join("lifecycle").join(init_name), { + #[cfg(not(windows))] + { + "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n" + } + #[cfg(windows)] + { + "@echo off\necho init>> lifecycle.log\n" + } + }) .expect("write init lifecycle"); - fs::write( - root.join("lifecycle").join("shutdown.sh"), - "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", - ) + fs::write(root.join("lifecycle").join(shutdown_name), { + #[cfg(not(windows))] + { + "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n" + } + #[cfg(windows)] + { + "@echo off\necho shutdown>> lifecycle.log\n" + } + }) .expect("write shutdown lifecycle"); } let hooks = if include_hooks { - ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }" + #[cfg(not(windows))] + { + ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }" + } + #[cfg(windows)] + { + ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.cmd\"]\n }" + } } else { "" }; let lifecycle = if include_lifecycle { - ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }" + #[cfg(not(windows))] + { + ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }" + } + #[cfg(windows)] + { + ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.cmd\"],\n \"Shutdown\": [\"./lifecycle/shutdown.cmd\"]\n }" + } } else { "" }; @@ -9794,13 +9871,19 @@ mod tests { fn parses_allowed_tools_flags_with_aliases_and_lists() { let _guard = env_lock(); std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + let config_home = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); let args = vec![ "--allowedTools".to_string(), "read,glob".to_string(), "--allowed-tools=write_file".to_string(), ]; + let parsed = parse_args(&args).expect("args should parse"); + std::env::remove_var("CLAW_CONFIG_HOME"); + let _ = fs::remove_dir_all(config_home); assert_eq!( - parse_args(&args).expect("args should parse"), + parsed, CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: Some( @@ -11897,7 +11980,7 @@ UU conflicted.rs", assert!(message.contains("Unstaged changes:")); assert!(message.contains("tracked.txt")); - fs::remove_dir_all(root).expect("cleanup temp dir"); + cleanup_dir(&root); } #[test] @@ -12710,10 +12793,16 @@ UU conflicted.rs", .expect("plugin state should load"); let pre_hooks = state.feature_config.hooks().pre_tool_use(); assert_eq!(pre_hooks.len(), 1); + #[cfg(not(windows))] assert!( pre_hooks[0].ends_with("hooks/pre.sh"), "expected installed plugin hook path, got {pre_hooks:?}" ); + #[cfg(windows)] + assert!( + pre_hooks[0].ends_with("hooks\\pre.cmd") || pre_hooks[0].ends_with("hooks/pre.cmd"), + "expected installed plugin hook path, got {pre_hooks:?}" + ); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(workspace); @@ -12729,23 +12818,21 @@ UU conflicted.rs", fs::create_dir_all(&workspace).expect("workspace"); let script_path = workspace.join("fixture-mcp.py"); write_mcp_server_fixture(&script_path); + let settings = serde_json::json!({ + "mcpServers": { + "alpha": { + "command": "python3", + "args": [script_path.to_string_lossy()] + }, + "broken": { + "command": "python3", + "args": ["-c", "import sys; sys.exit(0)"] + } + } + }); fs::write( config_home.join("settings.json"), - format!( - r#"{{ - "mcpServers": {{ - "alpha": {{ - "command": "python3", - "args": ["{}"] - }}, - "broken": {{ - "command": "python3", - "args": ["-c", "import sys; sys.exit(0)"] - }} - }} - }}"#, - script_path.to_string_lossy() - ), + serde_json::to_string_pretty(&settings).expect("serialize mcp settings"), ) .expect("write mcp settings"); @@ -12931,7 +13018,9 @@ UU conflicted.rs", .expect("runtime should build"); assert_eq!( - fs::read_to_string(&log_path).expect("init log should exist"), + fs::read_to_string(&log_path) + .expect("init log should exist") + .replace("\r\n", "\n"), "init\n" ); @@ -12940,7 +13029,9 @@ UU conflicted.rs", .expect("plugin shutdown should succeed"); assert_eq!( - fs::read_to_string(&log_path).expect("shutdown log should exist"), + fs::read_to_string(&log_path) + .expect("shutdown log should exist") + .replace("\r\n", "\n"), "init\nshutdown\n" ); diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 24b77d095e..bd34caeb3d 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -795,8 +795,8 @@ fn normalize_nested_fences(markdown: &str) -> String { let mut out = String::with_capacity(markdown.len() + rewrites.len() * 4); for (i, line) in lines.iter().enumerate() { if let Some(rw) = rewrites.get(&i) { - let fence_str: String = std::iter::repeat(rw.char).take(rw.new_len).collect(); - let indent_str: String = std::iter::repeat(' ').take(rw.indent).collect(); + let fence_str: String = std::iter::repeat_n(rw.char, rw.new_len).collect(); + let indent_str = " ".repeat(rw.indent); // Recover the original info string (if any) and trailing newline. let trimmed = line.trim_end_matches('\n').trim_end_matches('\r'); let fi = fence_info[i].as_ref().unwrap(); diff --git a/rust/crates/rusty-claude-cli/tests/compact_output.rs b/rust/crates/rusty-claude-cli/tests/compact_output.rs index 8e751c0cca..88b8ac2ff3 100644 --- a/rust/crates/rusty-claude-cli/tests/compact_output.rs +++ b/rust/crates/rusty-claude-cli/tests/compact_output.rs @@ -191,15 +191,19 @@ fn run_claw( args: &[&str], ) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); + command.current_dir(cwd); + // On Windows, clearing the entire environment can break process execution + // (missing SystemRoot, PATHEXT, etc.) and hang the test harness. + #[cfg(not(windows))] + { + command.env_clear().env("PATH", "/usr/bin:/bin"); + } command - .current_dir(cwd) - .env_clear() .env("ANTHROPIC_API_KEY", "test-compact-key") .env("ANTHROPIC_BASE_URL", base_url) .env("CLAW_CONFIG_HOME", config_home) .env("HOME", home) .env("NO_COLOR", "1") - .env("PATH", "/usr/bin:/bin") .args(args); command.output().expect("claw should launch") } diff --git a/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs b/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs index 066abb686b..1f629ca49c 100644 --- a/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs +++ b/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs @@ -1,3 +1,5 @@ +#![cfg(unix)] + use std::collections::BTreeMap; use std::fs; use std::io::Write; diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 9fbbdcb00c..2e42b1fd66 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -402,9 +402,18 @@ fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); command.current_dir(current_dir).args(args); + let mut has_home = false; for (key, value) in envs { + if *key == "HOME" { + has_home = true; + } command.env(key, value); } + if !has_home { + let home = current_dir.join("home"); + fs::create_dir_all(&home).expect("home dir should exist"); + command.env("HOME", &home); + } command.output().expect("claw should launch") } diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml index 86da4e6345..64c4fcc951 100644 --- a/rust/crates/tools/Cargo.toml +++ b/rust/crates/tools/Cargo.toml @@ -15,6 +15,7 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", serde = { version = "1", features = ["derive"] } serde_json.workspace = true tokio = { version = "1", features = ["rt-multi-thread"] } +walkdir = "2" [lints] workspace = true diff --git a/rust/crates/tools/src/disk.rs b/rust/crates/tools/src/disk.rs new file mode 100644 index 0000000000..9b854b16fd --- /dev/null +++ b/rust/crates/tools/src/disk.rs @@ -0,0 +1,197 @@ +use std::cmp::Reverse; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Serialize)] +pub struct DiskUsageEntry { + pub path: String, + pub kind: &'static str, + pub bytes: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DiskUsageReport { + pub root: String, + pub elapsed_ms: u128, + pub truncated: bool, + pub total_bytes_scanned: u64, + pub top_entries: Vec, + pub suggestions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiskUsageInput { + pub path: Option, + pub max_entries: Option, + pub max_files: Option, + pub max_seconds: Option, + pub min_file_mb: Option, +} + +fn to_lossy(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +fn is_probably_junk_dir(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "node_modules" + | "target" + | ".git" + | ".hg" + | ".svn" + | ".gradle" + | ".m2" + | ".cargo" + | ".next" + | "dist" + | "build" + | ".cache" + | "cache" + | "tmp" + | "temp" + | "logs" + ) +} + +fn suggestions_for_root(root: &Path) -> Vec { + let mut out = vec![ + "Общие: начни с корзины, временных файлов, Downloads и больших видео/архивов.".to_string(), + "Windows: Settings → System → Storage → Temporary files (аккуратно) и Storage Sense." + .to_string(), + "Перенос: большие папки (Videos/Downloads/VMs) лучше переносить на другой диск и делать ярлыки/точки монтирования.".to_string(), + "Увеличение диска: если это системный диск, обычно нужно расширять раздел (Disk Management) или менять диск/SSD; для VHD/VM — расширять виртуальный диск.".to_string(), + ]; + + // Workspace-ish hints + if root.join("node_modules").exists() { + out.push("Проект: `node_modules` часто самый большой — можно удалить и восстановить `npm ci`/`pnpm install`.".to_string()); + } + if root.join("target").exists() { + out.push("Rust: `target/` можно удалить (пересоберётся).".to_string()); + } + out +} + +#[allow(clippy::too_many_lines)] +pub fn disk_usage_report(cwd: &Path, input: &DiskUsageInput) -> Result { + let root = input + .path + .as_deref() + .map_or_else(|| cwd.to_path_buf(), PathBuf::from); + let root = root + .canonicalize() + .map_err(|e| format!("failed to resolve path {}: {e}", to_lossy(&root)))?; + if !root.exists() { + return Err(format!("path does not exist: {}", to_lossy(&root))); + } + + let max_entries = input.max_entries.unwrap_or(40).clamp(1, 200); + let max_files = input.max_files.unwrap_or(150_000).clamp(1, 2_000_000); + let max_seconds = input.max_seconds.unwrap_or(10).clamp(1, 120); + let min_file_mb = input.min_file_mb.unwrap_or(50); + let min_file_bytes = min_file_mb.saturating_mul(1024).saturating_mul(1024); + + let deadline = Instant::now() + Duration::from_secs(max_seconds); + let start = Instant::now(); + + let mut truncated = false; + let mut total_bytes_scanned: u64 = 0; + let mut files_seen: usize = 0; + + let mut big_files: Vec = Vec::new(); + let mut dir_bytes: std::collections::HashMap = std::collections::HashMap::new(); + + for entry in WalkDir::new(&root) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + if Instant::now() >= deadline { + truncated = true; + break; + } + if files_seen >= max_files { + truncated = true; + break; + } + + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if is_probably_junk_dir(name) { + // still traverse, but mark as potential hot-spot via suggestions; skipping would hide sizes. + } + } + continue; + } + + let Ok(meta) = entry.metadata() else { + continue; + }; + if !meta.is_file() { + continue; + } + + files_seen += 1; + let len = meta.len(); + total_bytes_scanned = total_bytes_scanned.saturating_add(len); + + // attribute file size to all parent dirs up to root + let mut cur = path.parent(); + while let Some(p) = cur { + *dir_bytes.entry(p.to_path_buf()).or_insert(0) += len; + if p == root { + break; + } + cur = p.parent(); + } + + if len >= min_file_bytes { + big_files.push(DiskUsageEntry { + path: to_lossy(path), + kind: "file", + bytes: len, + }); + } + } + + big_files.sort_by_key(|e| Reverse(e.bytes)); + if big_files.len() > max_entries { + big_files.truncate(max_entries); + } + + let mut big_dirs: Vec = dir_bytes + .into_iter() + .filter(|(p, _)| *p != root) + .map(|(p, bytes)| DiskUsageEntry { + path: to_lossy(&p), + kind: "dir", + bytes, + }) + .collect(); + big_dirs.sort_by_key(|e| Reverse(e.bytes)); + if big_dirs.len() > max_entries { + big_dirs.truncate(max_entries); + } + + let mut top_entries = Vec::new(); + top_entries.extend(big_dirs.into_iter().take(max_entries / 2)); + top_entries.extend(big_files); + top_entries.sort_by_key(|e| Reverse(e.bytes)); + if top_entries.len() > max_entries { + top_entries.truncate(max_entries); + } + + Ok(DiskUsageReport { + root: to_lossy(&root), + elapsed_ms: start.elapsed().as_millis(), + truncated, + total_bytes_scanned, + top_entries, + suggestions: suggestions_for_root(&root), + }) +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index f3d1849ac1..525acd72ce 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -31,6 +31,8 @@ use runtime::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +mod disk; + /// Global task registry shared across tool invocations within a session. fn global_lsp_registry() -> &'static LspRegistry { use std::sync::OnceLock; @@ -497,6 +499,35 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::ReadOnly, }, + ToolSpec { + name: "git_diff", + description: "Read-only `git diff` from the current workspace repo (no color).", + input_schema: json!({ + "type": "object", + "properties": { + "cached": { "type": "boolean", "description": "Use --cached (staged diff)" }, + "rev_range": { "type": "string", "description": "Revision range like `main...HEAD` or `HEAD~3..HEAD`" }, + "context_lines": { "type": "integer", "minimum": 0, "maximum": 100, "description": "Unified diff context lines (-U)" }, + "paths": { "type": "array", "items": { "type": "string" }, "description": "Path filters (relative to repo)" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "git_log", + description: "Read-only `git log` from the current workspace repo (no color).", + input_schema: json!({ + "type": "object", + "properties": { + "max_count": { "type": "integer", "minimum": 1, "maximum": 50, "description": "Max commits (default 20)" }, + "rev_range": { "type": "string", "description": "Revision range like `HEAD~20..HEAD`" }, + "paths": { "type": "array", "items": { "type": "string" }, "description": "Path filters (relative to repo)" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, ToolSpec { name: "WebFetch", description: @@ -733,6 +764,22 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::DangerFullAccess, }, + ToolSpec { + name: "DiskUsage", + description: "Read-only disk usage report + cleanup suggestions for a directory (no deletion).", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Directory to scan (default: current working directory)." }, + "max_entries": { "type": "integer", "minimum": 1, "maximum": 200, "description": "Max number of top entries in the report." }, + "max_files": { "type": "integer", "minimum": 1, "maximum": 2_000_000, "description": "Stop after scanning this many files." }, + "max_seconds": { "type": "integer", "minimum": 1, "maximum": 120, "description": "Best-effort time limit for scanning." }, + "min_file_mb": { "type": "integer", "minimum": 1, "maximum": 10240, "description": "Include files >= this size (MB) in the 'big files' list." } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, ToolSpec { name: "AskUserQuestion", description: "Ask the user a question and wait for their response.", @@ -1230,6 +1277,14 @@ fn execute_tool_with_enforcer( maybe_enforce_permission_check(enforcer, name, input)?; from_value::(input).and_then(run_grep_search) } + "git_diff" => { + maybe_enforce_permission_check(enforcer, name, input)?; + from_value::(input).and_then(run_git_diff) + } + "git_log" => { + maybe_enforce_permission_check(enforcer, name, input)?; + from_value::(input).and_then(run_git_log) + } "WebFetch" => from_value::(input).and_then(run_web_fetch), "WebSearch" => from_value::(input).and_then(run_web_search), "TodoWrite" => from_value::(input).and_then(run_todo_write), @@ -1253,6 +1308,10 @@ fn execute_tool_with_enforcer( maybe_enforce_permission_check_with_mode(enforcer, name, input, classified_mode)?; run_powershell(ps_input) } + "DiskUsage" => { + maybe_enforce_permission_check(enforcer, name, input)?; + from_value::(input).and_then(|v| run_disk_usage(&v)) + } "AskUserQuestion" => { from_value::(input).and_then(run_ask_user_question) } @@ -2099,6 +2158,39 @@ fn run_grep_search(input: GrepSearchInput) -> Result { to_pretty_json(grep_search(&input).map_err(io_to_string)?) } +#[allow(clippy::needless_pass_by_value)] +fn run_git_diff(input: GitDiffInput) -> Result { + let cwd = std::env::current_dir().map_err(io_to_string)?; + let out = runtime::git_diff_in_workspace( + &cwd, + runtime::GitDiffOptions { + cached: input.cached.unwrap_or(false), + rev_range: input.rev_range, + context_lines: input.context_lines, + paths: input.paths, + }, + 256 * 1024, + ) + .map_err(io_to_string)?; + to_pretty_json(out) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_git_log(input: GitLogInput) -> Result { + let cwd = std::env::current_dir().map_err(io_to_string)?; + let out = runtime::git_log_in_workspace( + &cwd, + runtime::GitLogOptions { + max_count: input.max_count, + rev_range: input.rev_range, + paths: input.paths, + }, + 256 * 1024, + ) + .map_err(io_to_string)?; + to_pretty_json(out) +} + #[allow(clippy::needless_pass_by_value)] fn run_web_fetch(input: WebFetchInput) -> Result { to_pretty_json(execute_web_fetch(&input)?) @@ -2233,6 +2325,12 @@ fn run_powershell(input: PowerShellInput) -> Result { to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?) } +fn run_disk_usage(input: &disk::DiskUsageInput) -> Result { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + let report = disk::disk_usage_report(&cwd, input)?; + to_pretty_json(report) +} + fn to_pretty_json(value: T) -> Result { serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) } @@ -2269,6 +2367,21 @@ struct GlobSearchInputValue { path: Option, } +#[derive(Debug, Deserialize)] +struct GitDiffInput { + cached: Option, + rev_range: Option, + context_lines: Option, + paths: Option>, +} + +#[derive(Debug, Deserialize)] +struct GitLogInput { + max_count: Option, + rev_range: Option, + paths: Option>, +} + #[derive(Debug, Deserialize)] struct WebFetchInput { url: String, @@ -5746,8 +5859,14 @@ fn config_home_dir() -> Result { return Ok(PathBuf::from(path)); } let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map_err(|_| { + .ok() + .or_else(|| std::env::var("USERPROFILE").ok()) + .or_else(|| { + let drive = std::env::var("HOMEDRIVE").ok()?; + let path = std::env::var("HOMEPATH").ok()?; + Some(format!("{drive}{path}")) + }) + .ok_or_else(|| { String::from( "HOME is not set (on Windows, set USERPROFILE or HOME, \ or use CLAW_CONFIG_HOME to point directly at the config directory)", @@ -6203,6 +6322,7 @@ mod tests { run_git(path, &["commit", "-m", "initial commit", "--quiet"]); } + #[allow(dead_code)] fn commit_file(path: &Path, file: &str, contents: &str, message: &str) { std::fs::write(path.join(file), contents).expect("write file"); run_git(path, &["add", file]); @@ -6225,6 +6345,8 @@ mod tests { .collect::>(); assert!(names.contains(&"bash")); assert!(names.contains(&"read_file")); + assert!(names.contains(&"git_diff")); + assert!(names.contains(&"git_log")); assert!(names.contains(&"WebFetch")); assert!(names.contains(&"WebSearch")); assert!(names.contains(&"TodoWrite")); @@ -6252,6 +6374,36 @@ mod tests { assert!(error.contains("unsupported tool")); } + #[test] + fn git_diff_and_log_execute_in_repo() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("git-tools"); + init_git_repo(&root); + + // Ensure tool executor runs in the repo. + let old = std::env::current_dir().ok(); + std::env::set_current_dir(&root).expect("set cwd"); + + // dirty working tree diff + std::fs::write(root.join("README.md"), "changed\n").expect("modify"); + + let log = execute_tool("git_log", &json!({"max_count": 5})).expect("git_log"); + assert!(log.contains("initial commit"), "log={log}"); + + let diff = execute_tool("git_diff", &json!({})).expect("git_diff"); + assert!( + diff.contains("diff --git") || diff.contains("@@"), + "diff={diff}" + ); + + if let Some(old) = old { + let _ = std::env::set_current_dir(old); + } + std::fs::remove_dir_all(root).expect("cleanup"); + } + #[test] fn worker_tools_gate_prompt_delivery_until_ready_and_support_auto_trust() { let created = execute_tool( @@ -6345,8 +6497,12 @@ mod tests { fs::create_dir_all(&claw_dir).expect("create .claw dir"); // Use the actual OS temp dir so the worktree path matches the allowlist let tmp_root = std::env::temp_dir().to_str().expect("utf-8").to_string(); - let settings = format!("{{\"trustedRoots\": [\"{tmp_root}\"]}}"); - fs::write(claw_dir.join("settings.json"), settings).expect("write settings"); + let settings = serde_json::json!({ "trustedRoots": [tmp_root] }); + fs::write( + claw_dir.join("settings.json"), + serde_json::to_string(&settings).expect("serialize settings"), + ) + .expect("write settings"); // WorkerCreate with no per-call trusted_roots — config should supply them let cwd = worktree.to_str().expect("valid utf-8").to_string(); @@ -7307,7 +7463,9 @@ mod tests { #[test] fn skill_loads_local_skill_prompt() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let home = temp_path("skills-home"); let skill_dir = home.join(".agents").join("skills").join("help"); fs::create_dir_all(&skill_dir).expect("skill dir should exist"); @@ -7330,10 +7488,8 @@ mod tests { let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); assert_eq!(output["skill"], "help"); - assert!(output["path"] - .as_str() - .expect("path") - .ends_with("/help/SKILL.md")); + let path = output["path"].as_str().expect("path").replace('\\', "/"); + assert!(path.ends_with("/help/SKILL.md")); assert!(output["prompt"] .as_str() .expect("prompt") @@ -7349,10 +7505,11 @@ mod tests { let dollar_output: serde_json::Value = serde_json::from_str(&dollar_result).expect("valid json"); assert_eq!(dollar_output["skill"], "$help"); - assert!(dollar_output["path"] + let path = dollar_output["path"] .as_str() .expect("path") - .ends_with("/help/SKILL.md")); + .replace('\\', "/"); + assert!(path.ends_with("/help/SKILL.md")); if let Some(home) = original_home { std::env::set_var("HOME", home); @@ -7364,7 +7521,9 @@ mod tests { #[test] fn skill_resolves_project_local_skills_and_legacy_commands() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let root = temp_path("project-skills"); let skill_dir = root.join(".claw").join("skills").join("plan"); let command_dir = root.join(".claw").join("commands"); @@ -7388,19 +7547,21 @@ mod tests { .expect("project-local skill should resolve"); let skill_output: serde_json::Value = serde_json::from_str(&skill_result).expect("valid json"); - assert!(skill_output["path"] + let path = skill_output["path"] .as_str() .expect("path") - .ends_with(".claw/skills/plan/SKILL.md")); + .replace('\\', "/"); + assert!(path.ends_with(".claw/skills/plan/SKILL.md")); let command_result = execute_tool("Skill", &json!({ "skill": "/handoff" })) .expect("legacy command should resolve"); let command_output: serde_json::Value = serde_json::from_str(&command_result).expect("valid json"); - assert!(command_output["path"] + let path = command_output["path"] .as_str() .expect("path") - .ends_with(".claw/commands/handoff.md")); + .replace('\\', "/"); + assert!(path.ends_with(".claw/commands/handoff.md")); std::env::set_current_dir(&original_dir).expect("restore cwd"); fs::remove_dir_all(root).expect("temp project should clean up"); @@ -7408,7 +7569,9 @@ mod tests { #[test] fn skill_loads_project_local_claude_skill_prompt() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let root = temp_path("project-skills"); let home = root.join("home"); let workspace = root.join("workspace"); @@ -7435,10 +7598,8 @@ mod tests { .expect("project-local skill should resolve"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); - assert!(output["path"] - .as_str() - .expect("path") - .ends_with(".claude/skills/trace/SKILL.md")); + let path = output["path"].as_str().expect("path").replace('\\', "/"); + assert!(path.ends_with(".claude/skills/trace/SKILL.md")); assert_eq!(output["description"], "Project-local trace helper"); std::env::set_current_dir(&original_dir).expect("restore cwd"); @@ -7459,7 +7620,9 @@ mod tests { #[test] fn skill_loads_project_local_omc_and_agents_skill_prompts() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let root = temp_path("project-omc-skills"); let home = root.join("home"); let workspace = root.join("workspace"); @@ -7497,15 +7660,17 @@ mod tests { let omc_output: serde_json::Value = serde_json::from_str(&omc_result).expect("valid json"); let agents_output: serde_json::Value = serde_json::from_str(&agents_result).expect("valid json"); - assert!(omc_output["path"] + let omc_path = omc_output["path"] .as_str() .expect("path") - .ends_with(".omc/skills/hud/SKILL.md")); + .replace('\\', "/"); + assert!(omc_path.ends_with(".omc/skills/hud/SKILL.md")); assert_eq!(omc_output["description"], "Project-local OMC HUD helper"); - assert!(agents_output["path"] + let agents_path = agents_output["path"] .as_str() .expect("path") - .ends_with(".agents/skills/trace/SKILL.md")); + .replace('\\', "/"); + assert!(agents_path.ends_with(".agents/skills/trace/SKILL.md")); assert_eq!( agents_output["description"], "Project-local agents compatibility helper" @@ -7529,7 +7694,9 @@ mod tests { #[test] fn skill_loads_learned_skill_from_claude_config_dir() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let root = temp_path("claude-config-learned-skill"); let home = root.join("home"); let claude_config_dir = root.join("claude-config"); @@ -7557,10 +7724,8 @@ mod tests { .expect("learned skill should resolve"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); - assert!(output["path"] - .as_str() - .expect("path") - .ends_with("skills/omc-learned/learned/SKILL.md")); + let path = output["path"].as_str().expect("path").replace('\\', "/"); + assert!(path.ends_with("skills/omc-learned/learned/SKILL.md")); assert_eq!(output["description"], "Learned OMC skill"); match original_home { @@ -7584,7 +7749,9 @@ mod tests { #[test] fn skill_loads_direct_skill_and_legacy_command_from_claude_config_dir() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let root = temp_path("claude-config-direct-skill"); let home = root.join("home"); let claude_config_dir = root.join("claude-config"); @@ -7616,20 +7783,22 @@ mod tests { execute_tool("Skill", &json!({ "skill": "statusline" })).expect("direct skill"); let direct_skill_output: serde_json::Value = serde_json::from_str(&direct_skill).expect("valid skill json"); - assert!(direct_skill_output["path"] + let path = direct_skill_output["path"] .as_str() .expect("path") - .ends_with("skills/statusline/SKILL.md")); + .replace('\\', "/"); + assert!(path.ends_with("skills/statusline/SKILL.md")); assert_eq!(direct_skill_output["description"], "Claude config skill"); let legacy_command = execute_tool("Skill", &json!({ "skill": "doctor-check" })).expect("direct command"); let legacy_command_output: serde_json::Value = serde_json::from_str(&legacy_command).expect("valid command json"); - assert!(legacy_command_output["path"] + let path = legacy_command_output["path"] .as_str() .expect("path") - .ends_with("commands/doctor-check.md")); + .replace('\\', "/"); + assert!(path.ends_with("commands/doctor-check.md")); assert_eq!( legacy_command_output["description"], "Claude config command" @@ -7656,7 +7825,9 @@ mod tests { #[test] fn skill_loads_project_local_legacy_command_markdown() { - let _guard = env_guard(); + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let root = temp_path("project-legacy-command"); let home = root.join("home"); let workspace = root.join("workspace"); @@ -7683,10 +7854,8 @@ mod tests { .expect("legacy command markdown should resolve"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); - assert!(output["path"] - .as_str() - .expect("path") - .ends_with(".claude/commands/team.md")); + let path = output["path"].as_str().expect("path").replace('\\', "/"); + assert!(path.ends_with(".claude/commands/team.md")); assert_eq!(output["description"], "Legacy team workflow"); std::env::set_current_dir(&original_dir).expect("restore cwd"); @@ -8631,6 +8800,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn bash_tool_reports_success_exit_failure_timeout_and_background() { let success = execute_tool("bash", &json!({ "command": "printf 'hello'" })) .expect("bash should succeed"); @@ -8668,6 +8838,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn bash_workspace_tests_are_blocked_when_branch_is_behind_main() { let _guard = env_lock() .lock() @@ -8718,6 +8889,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn bash_targeted_tests_skip_branch_preflight() { let _guard = env_lock() .lock() @@ -8883,10 +9055,11 @@ mod tests { .expect("glob should succeed"); let globbed_output: serde_json::Value = serde_json::from_str(&globbed).expect("json"); assert_eq!(globbed_output["numFiles"], 1); - assert!(globbed_output["filenames"][0] - .as_str() - .expect("filename") - .ends_with("nested/lib.rs")); + let filename = globbed_output["filenames"][0].as_str().expect("filename"); + assert!( + filename.ends_with("nested/lib.rs") || filename.ends_with("nested\\lib.rs"), + "filename={filename:?}" + ); let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" })) .expect_err("invalid glob should fail"); @@ -9210,6 +9383,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn repl_executes_python_code() { let result = execute_tool( "REPL", @@ -9239,6 +9413,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn given_timeout_ms_when_repl_blocks_then_returns_timeout_error() { let result = execute_tool( "REPL", @@ -9254,6 +9429,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn powershell_runs_via_stub_shell() { let _guard = env_lock() .lock() @@ -9418,6 +9594,7 @@ printf 'pwsh:%s' "$1" } #[test] + #[cfg(not(windows))] fn given_no_enforcer_when_bash_then_executes_normally() { let _guard = env_lock() .lock()