diff --git a/rust-sandlot/.gitignore b/rust-sandlot/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/rust-sandlot/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/rust-sandlot/Cargo.lock b/rust-sandlot/Cargo.lock new file mode 100644 index 0000000..0d94803 --- /dev/null +++ b/rust-sandlot/Cargo.lock @@ -0,0 +1,1872 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sandlot" +version = "0.0.50" +dependencies = [ + "anyhow", + "clap", + "dirs", + "libc", + "rand", + "regex", + "reqwest", + "serde", + "serde_json", + "tokio", + "uuid", + "which", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust-sandlot/Cargo.toml b/rust-sandlot/Cargo.toml new file mode 100644 index 0000000..f78ac08 --- /dev/null +++ b/rust-sandlot/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "sandlot" +version = "0.0.50" +edition = "2024" +description = "Sandboxed, branch-based development with Claude" +license = "MIT" + +[[bin]] +name = "sandlot" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "string"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +uuid = { version = "1", features = ["v4"] } +rand = "0.9" +dirs = "6" +regex = "1" +which = "7" +libc = "0.2" +anyhow = "1" diff --git a/rust-sandlot/TESTING.md b/rust-sandlot/TESTING.md new file mode 100644 index 0000000..f6c0ddc --- /dev/null +++ b/rust-sandlot/TESTING.md @@ -0,0 +1,895 @@ +# Sandlot Rust Rewrite: VM Integration Testing + +This document describes how to test the Rust rewrite of sandlot against the TypeScript original. The goal is to verify **identical behavior** for every command that interacts with the VM/container, git worktrees, or session state. + +## Prerequisites + +- macOS on Apple Silicon +- Apple Container installed (`brew install container`) +- Rust toolchain (`rustup`) +- Bun installed (`brew install oven-sh/bun/bun`) +- An `ANTHROPIC_API_KEY` in `~/.env` (format: `ANTHROPIC_API_KEY=sk-ant-...`) +- A git repo to use as a test bed (create a throwaway one) + +## Setup + +### 1. Build the Rust binary + +```bash +cd rust-sandlot +cargo build --release +``` + +The binary is at `./rust-sandlot/target/release/sandlot`. + +### 2. Set up aliases + +Use two distinct aliases so you can run either implementation: + +```bash +alias sandlot-ts='bun run /path/to/rust-rewrite/src/cli.ts' +alias sandlot-rs='/path/to/rust-rewrite/rust-sandlot/target/release/sandlot' +``` + +### 3. Destroy any existing VM + +Start from a clean slate. Both implementations share the same container name (`sandlot`), so only one can be tested at a time: + +```bash +sandlot-ts vm destroy 2>/dev/null +``` + +### 4. Create a test repo + +```bash +mkdir /tmp/sandlot-test-repo && cd /tmp/sandlot-test-repo +git init +echo "hello" > README.md +git add . && git commit -m "initial commit" +``` + +All tests below assume you run commands from inside this repo. + +--- + +## Testing methodology + +For each test: + +1. Run the command with `sandlot-ts` first, observe the result +2. Clean up / reset state +3. Run the same command with `sandlot-rs`, observe the result +4. Compare: stdout content, stderr content, exit code, and side effects (files created, git state, container state) + +Some commands produce animated spinner output on stderr. The final line of spinner output is what matters (the success/failure message). Intermediate spinner frames are cosmetic and may differ in timing. + +When comparing output, strip ANSI codes for semantic comparison: + +```bash +sandlot-rs list 2>&1 | sed 's/\x1b\[[0-9;]*m//g' +sandlot-ts list 2>&1 | sed 's/\x1b\[[0-9;]*m//g' +``` + +--- + +## Phase 1: VM Lifecycle + +These tests verify container management. Run them in order. + +### Test 1.1: `vm create` + +```bash +sandlot-ts vm destroy 2>/dev/null # clean slate +sandlot-rs vm create +``` + +**Expect:** +- Spinner output on stderr progressing through: "Creating VM" -> "Pulling image & creating container" -> "Installing packages" -> "Installing Bun" -> "Installing Claude Code" -> "Installing neofetch" -> "Installing Neovim" -> "Configuring environment" +- Final line: `✔ VM created` +- Exit code: 0 + +**Verify side effects:** +```bash +container list --format json --all # should show "sandlot" container running +container exec sandlot which claude # should print /home/ubuntu/.local/bin/claude +container exec sandlot which bun # should print /home/ubuntu/.local/bin/bun +container exec sandlot which fish # should print /usr/bin/fish +container exec sandlot test -f /home/ubuntu/.claude/settings.json && echo ok +container exec sandlot test -f /home/ubuntu/.claude/api-key-helper.sh && echo ok +container exec sandlot cat /home/ubuntu/.claude.json # should have hasCompletedOnboarding: true +``` + +Now destroy and repeat with TS: +```bash +sandlot-rs vm destroy +sandlot-ts vm create +``` +Verify the same side effects exist. + +### Test 1.2: `vm status` + +```bash +# With VM running: +sandlot-rs vm status +sandlot-ts vm status +``` + +**Expect (no sessions):** +``` +VM: running (in green) + +No active sessions. (in dim) +``` + +```bash +# JSON mode: +sandlot-rs vm status --json +sandlot-ts vm status --json +``` + +**Expect:** JSON with `"vm": "running"` and `"sessions": []`. + +### Test 1.3: `vm stop` + +```bash +sandlot-rs vm stop +``` + +**Expect:** Spinner, then `✔ VM stopped`. Exit code 0. + +```bash +sandlot-rs vm status +``` + +**Expect:** `VM: stopped` (in yellow). + +### Test 1.4: `vm start` + +```bash +sandlot-rs vm start +``` + +**Expect:** `✔ VM started` on stdout. Exit code 0. + +### Test 1.5: `vm info` + +```bash +sandlot-rs vm info +sandlot-ts vm info +``` + +**Expect:** neofetch output (system info). Both should show identical container specs. + +### Test 1.6: `vm shell` + +```bash +sandlot-rs vm shell +``` + +**Expect:** Drops into an interactive fish shell inside the container. Type `exit` to leave. Verify the prompt works and `echo $PATH` includes the expected paths. + +### Test 1.7: `vm destroy` + +```bash +sandlot-rs vm destroy +``` + +**Expect:** Spinner, then `✔ VM destroyed`. Exit code 0. + +```bash +sandlot-rs vm status +``` + +**Expect:** `VM: missing` (in red). + +### Test 1.8: `vm create` (duplicate) + +```bash +sandlot-rs vm create +# Then try again: +sandlot-rs vm create +``` + +**Expect second call:** Error: `Container already exists. Use 'sandlot vm destroy' first to recreate it.` Exit code 1. + +### Test 1.9: `vm uncache` + +```bash +sandlot-rs vm uncache +``` + +**Expect:** `✔ Package cache cleared` if cache existed, or `No cache to clear`. + +### Test 1.10: `vm start` when missing + +```bash +sandlot-rs vm destroy +sandlot-rs vm start +``` + +**Expect:** Error: `Container does not exist. Use 'sandlot vm create' first.` Exit code 1. + +--- + +## Phase 2: Session Lifecycle + +Ensure a VM is running before starting: `sandlot-rs vm create` (or `ensure` will auto-create). + +### Test 2.1: `new` with explicit branch name + +```bash +sandlot-rs new test-branch-1 +# Claude launches interactively. Press Ctrl+C or /exit to quit. +``` + +**Expect:** +- Spinner: "Creating worktree" -> "Starting container" -> `✔ [test-branch-1] Session ready` +- Claude Code launches in the container +- After exit, auto-save runs (spinner: "Staging changes" -> either "No changes to commit" or "Saved: ...") + +**Verify side effects:** +```bash +ls -la ~/.sandlot/sandlot-test-repo/test-branch-1/ # worktree exists +ls -la .sandlot/test-branch-1 # symlink exists +cat .sandlot/state.json # session entry exists +git worktree list # shows the worktree +``` + +### Test 2.2: `new` with no branch (random name) + +```bash +sandlot-rs new +``` + +**Expect:** A random `adjective-noun` branch name is generated (e.g., `calm-fern`). The rest of the flow is identical to 2.1. + +### Test 2.3: `new` with prompt (spaces in "branch") + +```bash +sandlot-rs new "fix the login bug on the settings page" +``` + +**Expect:** The text is treated as a prompt. A branch name is derived via Claude Haiku API (e.g., `login-fix`). If the API call fails, falls back to first two words (`fix-the`). The prompt is stored in `state.json`. + +### Test 2.4: `new` with `-p` (print mode) + +```bash +sandlot-rs new -p "what is 2+2" +``` + +**Expect:** +- Branch name derived from the prompt +- Spinner: "Creating worktree" -> "Starting container" -> "Running prompt..." +- Claude's response printed to stdout (rendered as markdown) +- No interactive session +- Auto-save runs after + +### Test 2.5: `new` duplicate session + +```bash +sandlot-rs new test-branch-1 +``` + +**Expect:** `✖ Session "test-branch-1" already exists. Use "sandlot open test-branch-1" to re-enter it.` Exit code 1. + +### Test 2.6: `list` with sessions + +```bash +sandlot-rs list +``` + +**Expect:** +``` + BRANCH PROMPT +◯ test-branch-1 +◯ other-branch fix the login bug... + +◯ idle · ◎ active · ◐ unsaved · ● saved · ⦿ review +``` + +Status icons use ANSI colors (dim for idle, cyan for active, yellow for dirty, green for saved, magenta for review). + +```bash +sandlot-rs list --json +``` + +**Expect:** JSON array with each session having `branch`, `worktree`, `created_at`, `prompt`, `in_review`, `status`, `repoRoot` fields. + +### Test 2.7: `open` existing session + +```bash +sandlot-rs open test-branch-1 +``` + +**Expect:** +- Spinner: "Starting container" -> `✔ [test-branch-1] Session ready` +- Claude launches with `--continue` (resumes prior conversation) +- After exit, auto-save runs + +### Test 2.8: `open` with `--no-save` + +```bash +sandlot-rs open test-branch-1 --no-save +``` + +**Expect:** Same as 2.7 but no auto-save after Claude exits. + +### Test 2.9: `open` nonexistent session but existing branch + +If you manually create a branch and remove the session from state.json, `open` should recreate the session: + +```bash +# Remove from state but keep the branch +cat .sandlot/state.json # note the session +# Manually edit state.json to remove the session entry +sandlot-rs open test-branch-1 +``` + +**Expect:** Worktree is recreated, session is re-added to state, Claude launches. + +### Test 2.10: `open` nonexistent branch + +```bash +sandlot-rs open nonexistent-branch-xyz +``` + +**Expect:** `✖ No session or branch found for "nonexistent-branch-xyz".` Exit code 1. + +--- + +## Phase 3: Branch Operations (read-only) + +These commands read git state without modifying it. Create a session with some commits first: + +```bash +sandlot-rs new branch-ops-test +# Inside Claude, make some changes and commit, then exit +# Or manually: +cd ~/.sandlot/sandlot-test-repo/branch-ops-test +echo "new file" > test.txt +git add . && git commit -m "add test file" +cd /tmp/sandlot-test-repo +``` + +### Test 3.1: `diff` + +```bash +sandlot-rs diff branch-ops-test +``` + +**Expect:** +- If uncommitted changes in worktree: shows `git diff HEAD` +- If clean: shows `git diff main...branch-ops-test` +- Output piped through git's native diff display (with colors if terminal supports) + +Compare with: +```bash +sandlot-ts diff branch-ops-test +``` + +### Test 3.2: `log` + +```bash +sandlot-rs log branch-ops-test +``` + +**Expect:** +- If the session has a prompt, prints `PROMPT: ` to stderr first +- Shows `git log main..HEAD` output with commit hashes highlighted in yellow +- Piped through pager if output exceeds terminal height + +### Test 3.3: `show` + +```bash +sandlot-rs show branch-ops-test +``` + +**Expect:** +- Prints prompt to stderr (if stored) +- Shows full `git diff main...branch` output on stdout + +### Test 3.4: `web` + +```bash +sandlot-rs web branch-ops-test +``` + +**Expect:** +- Generates `/tmp/sandlot-branch-ops-test.html` +- Opens it in the default browser +- HTML contains: branch name, prompt, commit log, diff stats, syntax-highlighted diff + +**Verify:** Open the generated HTML file and compare it with the one generated by `sandlot-ts web branch-ops-test`. + +### Test 3.5: `dir` + +```bash +sandlot-rs dir branch-ops-test +``` + +**Expect:** Prints the absolute worktree path to stdout, e.g., `/Users/you/.sandlot/sandlot-test-repo/branch-ops-test`. + +### Test 3.6: `dir` nonexistent session + +```bash +sandlot-rs dir nonexistent +``` + +**Expect:** `✖ No session found for branch "nonexistent".` Exit code 1. + +--- + +## Phase 4: Save, Merge, Squash, Rebase + +### Test 4.1: `save` with auto-generated message + +```bash +# Make changes in the worktree first: +echo "change" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt +sandlot-rs save branch-ops-test +``` + +**Expect:** +- Spinner: `[branch-ops-test] Staging changes` -> `Starting container` -> `Generating commit message` -> `Committing` -> `✔ [branch-ops-test] Saved: ` +- The commit message is AI-generated from the diff + +### Test 4.2: `save` with explicit message + +```bash +echo "another change" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt +sandlot-rs save branch-ops-test "manual commit message" +``` + +**Expect:** +- Spinner: `Staging changes` -> `Committing` -> `✔ [branch-ops-test] Saved: manual commit message` +- No AI generation (no container startup needed for the message) + +### Test 4.3: `save` with no changes + +```bash +sandlot-rs save branch-ops-test +``` + +**Expect:** `✖ [branch-ops-test] No changes to commit`. Exit code 1. + +### Test 4.4: `squash` + +```bash +# Ensure branch has multiple commits beyond main +sandlot-rs squash branch-ops-test +``` + +**Expect:** +- Spinner: `[branch-ops-test] Squashing` -> `Starting container` -> `Generating commit message` -> `✔ [branch-ops-test] Squashed branch-ops-test into a single commit` +- `git log main..HEAD` in the worktree should show exactly 1 commit + +### Test 4.5: `squash` with no commits + +```bash +sandlot-rs new fresh-branch +# Exit Claude immediately without making changes +sandlot-rs squash fresh-branch +``` + +**Expect:** `✖ Branch "fresh-branch" has no commits beyond main.` Exit code 1. + +### Test 4.6: `squash` with dirty worktree + +```bash +echo "dirty" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt +sandlot-rs squash branch-ops-test +``` + +**Expect:** `✖ Branch "branch-ops-test" has unsaved changes. Run "sandlot save branch-ops-test" first.` Exit code 1. + +### Test 4.7: `rebase` + +Set up a scenario where main has advanced: + +```bash +# In the main repo, add a commit to main +cd /tmp/sandlot-test-repo +echo "main change" > main-file.txt +git add . && git commit -m "advance main" + +sandlot-rs rebase branch-ops-test +``` + +**Expect (clean rebase):** +- Spinner: `[branch-ops-test] Fetching origin` -> `Rebasing onto origin/main` -> `✔ [branch-ops-test] Rebased branch-ops-test onto main` + +**Expect (with conflicts):** +- `◆ Rebase conflicts in N file(s). Resolving with Claude...` +- Spinner: `[branch-ops-test] Starting container` -> `(1/N) Resolving (round 1)` -> `✔ [branch-ops-test] Rebased branch-ops-test onto main (resolved N conflict round(s))` + +### Test 4.8: `rebase` with dirty worktree + +```bash +echo "dirty" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt +sandlot-rs rebase branch-ops-test +``` + +**Expect:** `✖ Branch "branch-ops-test" has unsaved changes. Run "sandlot save branch-ops-test" first.` Exit code 1. + +### Test 4.9: `merge` + +```bash +cd /tmp/sandlot-test-repo +git checkout main +sandlot-rs merge branch-ops-test +``` + +**Expect (clean merge):** +- Spinner: `Merging branch-ops-test` -> `✔ Merged branch-ops-test into main` +- Session is torn down (worktree removed, symlink removed, state cleared) +- Local branch is deleted + +**Expect (with conflicts):** +- Spinner: `Resolving N conflict(s)` -> `Starting container` -> `(1/N) Resolving ` -> `✔ Resolved N conflict(s) and merged branch-ops-test` +- Same cleanup as clean merge + +### Test 4.10: `merge` not on main + +```bash +git checkout -b other-branch +sandlot-rs merge some-branch +``` + +**Expect:** `✖ You must be on "main" to merge. Currently on "other-branch". Use --force to merge into "other-branch" anyway.` Exit code 1. + +### Test 4.11: `merge --force` on non-main + +```bash +sandlot-rs merge some-branch --force +``` + +**Expect:** Merge proceeds into `other-branch` instead of `main`. + +### Test 4.12: `merge` with dirty session + +```bash +echo "dirty" >> ~/.sandlot/sandlot-test-repo/some-branch/file.txt +sandlot-rs merge some-branch +``` + +**Expect:** `✖ Branch "some-branch" has unsaved changes. Run "sandlot save some-branch" first.` Exit code 1. + +--- + +## Phase 5: Review + +### Test 5.1: `review` interactive + +```bash +sandlot-rs review branch-ops-test +``` + +**Expect:** +- Spinner: `[branch-ops-test] Starting container` -> `✔ [branch-ops-test] Session ready` +- Claude launches with the review prompt (4-agent grumpy senior engineer review) +- `state.json` shows `in_review: true` during the review +- After exit: `in_review` is cleared, auto-save runs + +### Test 5.2: `review --print` + +```bash +sandlot-rs review branch-ops-test --print +``` + +**Expect:** +- Spinner: `[branch-ops-test] Starting container` -> `Running review...` +- Review output printed to stdout (not interactive) +- No auto-save after + +### Test 5.3: `review` with extra prompt + +```bash +sandlot-rs review branch-ops-test "also check for SQL injection" +``` + +**Expect:** The extra text is appended to the review prompt. Claude receives both the standard review instructions and the additional context. + +--- + +## Phase 6: Shell and Edit + +### Test 6.1: `shell` with branch + +```bash +sandlot-rs shell branch-ops-test +``` + +**Expect:** Interactive fish shell opens in the worktree directory inside the container. `pwd` should show the container-translated worktree path. + +### Test 6.2: `shell` without branch + +```bash +sandlot-rs shell +``` + +**Expect:** Interactive fish shell opens at a default location (no `--workdir` flag). + +### Test 6.3: `edit` + +```bash +export EDITOR=vim +sandlot-rs edit branch-ops-test test.txt +``` + +**Expect:** vim opens the file at the worktree path. After closing, exits cleanly. + +### Test 6.4: `edit` with missing EDITOR + +```bash +unset EDITOR +sandlot-rs edit branch-ops-test test.txt +``` + +**Expect:** `✖ $EDITOR is not set.` Exit code 1. + +### Test 6.5: `edit` with missing file + +```bash +export EDITOR=vim +sandlot-rs edit branch-ops-test nonexistent.txt +``` + +**Expect:** `✖ File not found: nonexistent.txt` Exit code 1. + +### Test 6.6: `edit` path escape attempt + +```bash +sandlot-rs edit branch-ops-test ../../etc/passwd +``` + +**Expect:** Error (path escapes the worktree). The exact message may vary but should prevent access. + +--- + +## Phase 7: Close and Checkout + +### Test 7.1: `close` clean session + +```bash +sandlot-rs close test-branch-1 +``` + +**Expect:** +- `✔ Closed session test-branch-1` on stdout +- Worktree removed from `~/.sandlot/...` +- Symlink removed from `.sandlot/test-branch-1` +- Session removed from `state.json` +- Local branch deleted +- Exit code 0 + +### Test 7.2: `close` dirty session + +```bash +# Set up a dirty session first +sandlot-rs new dirty-test +echo "uncommitted" > ~/.sandlot/sandlot-test-repo/dirty-test/uncommitted.txt +sandlot-rs close dirty-test +``` + +**Expect:** `✖ Branch "dirty-test" has unsaved changes. Run "sandlot save dirty-test" first, or use -f to force.` Exit code 1. + +### Test 7.3: `close --force` dirty session + +```bash +sandlot-rs close dirty-test --force +``` + +**Expect:** `✔ Closed session dirty-test`. Session is torn down despite uncommitted changes. + +### Test 7.4: `rm` alias + +```bash +sandlot-rs rm some-branch +``` + +**Expect:** Identical to `close`. The `rm` command is a hidden alias. + +### Test 7.5: `close` nonexistent session + +```bash +sandlot-rs close nonexistent-xyz +``` + +**Expect:** `✖ No session found for branch "nonexistent-xyz".` Exit code 1. + +### Test 7.6: `checkout` + +```bash +sandlot-rs new checkout-test +# Make a commit +echo "data" > ~/.sandlot/sandlot-test-repo/checkout-test/data.txt +cd ~/.sandlot/sandlot-test-repo/checkout-test && git add . && git commit -m "data" +cd /tmp/sandlot-test-repo +sandlot-rs checkout checkout-test +``` + +**Expect:** +- `✔ Checked out checkout-test` +- Session torn down (worktree, symlink, state removed) +- `git branch` in main repo shows you're now on `checkout-test` +- Branch is NOT deleted (unlike `close` and `merge`) + +### Test 7.7: `checkout` with dirty main worktree + +```bash +echo "dirty" > /tmp/sandlot-test-repo/dirty.txt +sandlot-rs checkout some-branch +``` + +**Expect:** `✖ Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.` Exit code 1. + +### Test 7.8: `checkout --force` with dirty main worktree + +```bash +sandlot-rs checkout some-branch --force +``` + +**Expect:** Proceeds despite dirty working tree. + +--- + +## Phase 8: Cleanup and Upgrade + +### Test 8.1: `cleanup` with stale sessions + +```bash +# Create a session, then manually delete the worktree +sandlot-rs new stale-test +rm -rf ~/.sandlot/sandlot-test-repo/stale-test +sandlot-rs cleanup +``` + +**Expect:** `✔ Removed stale session: stale-test`. Session removed from state.json. + +### Test 8.2: `cleanup` with no stale sessions + +```bash +sandlot-rs cleanup +``` + +**Expect:** `No stale sessions found.` (or `No sessions to clean up.` if no sessions at all). + +### Test 8.3: `upgrade` + +```bash +sandlot-rs upgrade +``` + +**Expect:** Attempts to upgrade sandlot. Compare behavior with `sandlot-ts upgrade`. Both should attempt the same upgrade mechanism. + +--- + +## Phase 9: List Status Resolution + +This tests that `list` correctly resolves session status. + +### Test 9.1: Idle session + +```bash +sandlot-rs new idle-test +# Exit Claude immediately, no changes +sandlot-rs list +``` + +**Expect:** `idle-test` shows `◯` (dim circle) = idle. + +### Test 9.2: Dirty session + +```bash +echo "dirty" > ~/.sandlot/sandlot-test-repo/idle-test/dirty.txt +sandlot-rs list +``` + +**Expect:** `idle-test` shows `◐` (yellow half-circle) = unsaved. + +### Test 9.3: Saved session + +```bash +cd ~/.sandlot/sandlot-test-repo/idle-test +git add . && git commit -m "save" +cd /tmp/sandlot-test-repo +sandlot-rs list +``` + +**Expect:** `idle-test` shows `●` (green circle) = saved. + +### Test 9.4: `list --all` + +```bash +sandlot-rs list --all +``` + +**Expect:** Sessions grouped by repo name with headers: +``` +── repo-name ── + BRANCH PROMPT +◯ branch prompt text +``` + +### Test 9.5: `list` with no sessions + +```bash +# Close all sessions first +sandlot-rs list +``` + +**Expect:** `◆ No active sessions.` + +### Test 9.6: `list` with VM down + +```bash +sandlot-rs vm stop +sandlot-rs list +``` + +**Expect:** Normal session list (all show as idle since VM can't check status), plus: +``` +VM is not running. (in red) +``` + +--- + +## Phase 10: End-to-End Comparison + +For each command tested above, run the same scenario with both `sandlot-ts` and `sandlot-rs` and compare: + +1. **Exit codes** must be identical +2. **Stdout content** must be semantically identical (exact match after stripping ANSI if formatting differs) +3. **Stderr content** must match (error messages, spinner final lines) +4. **Side effects** must match: + - Same files created/deleted + - Same git state (branches, worktrees, commits) + - Same state.json content (modulo timestamps) + - Same container state + +### Comparison script + +```bash +#!/bin/bash +# Compare a command between TS and Rust +CMD="$@" +echo "=== TypeScript ===" +sandlot-ts $CMD 2>/tmp/ts-stderr; TS_EXIT=$? +echo "EXIT: $TS_EXIT" +cat /tmp/ts-stderr + +echo "" +echo "=== Rust ===" +sandlot-rs $CMD 2>/tmp/rs-stderr; RS_EXIT=$? +echo "EXIT: $RS_EXIT" +cat /tmp/rs-stderr + +echo "" +if [ "$TS_EXIT" = "$RS_EXIT" ]; then + echo "EXIT CODES: MATCH ($TS_EXIT)" +else + echo "EXIT CODES: MISMATCH (ts=$TS_EXIT rs=$RS_EXIT)" +fi +``` + +--- + +## Known Differences to Accept + +- **Timestamps** in `state.json` will differ between runs (different `created_at` values). Compare structure and non-timestamp fields only. +- **Spinner frame timing** may differ slightly. Only compare the final spinner message. +- **AI-generated content** (branch names from prompts, commit messages, conflict resolutions, reviews) will differ between runs since they involve LLM calls. Verify the format is correct, not the exact text. +- **Random branch names** from `sandlot new` (no args) will differ. Verify the format is `adjective-noun` from the same word lists. +- **Order of JSON object keys** may differ between serde_json (Rust) and JSON.stringify (TS). Compare semantically. + +## What Must Be Identical + +- All error messages (exact wording, Unicode markers) +- Exit codes for all error and success paths +- File paths (worktree locations, symlink targets, state file location) +- Git operations (same branches created/deleted, same merge behavior) +- Container commands (same `container exec` invocations, same environment variables) +- Flag parsing (`-f`, `--force`, `-p`, `--print`, `-n`, `--no-save`, `--json`, `-a`, `--all`) +- Default behavior (no args = `list`) +- Shell init output (`init fish`, `init bash`, `init zsh`) -- these were already verified byte-for-byte identical +- Fish/bash/zsh completions -- already verified byte-for-byte identical diff --git a/rust-sandlot/src/commands/cd.rs b/rust-sandlot/src/commands/cd.rs new file mode 100644 index 0000000..94f50d7 --- /dev/null +++ b/rust-sandlot/src/commands/cd.rs @@ -0,0 +1,7 @@ +use anyhow::Result; + +pub fn action(_branch: &str) -> Result<()> { + crate::fmt::die( + "\"sandlot cd\" requires shell integration.\n\nAdd one of these to your shell config:\n\n Fish (~/.config/fish/config.fish):\n sandlot init fish | source\n\n Bash (~/.bashrc):\n eval \"$(sandlot init bash)\"\n\n Zsh (~/.zshrc):\n eval \"$(sandlot init zsh)\"" + ) +} diff --git a/rust-sandlot/src/commands/checkout.rs b/rust-sandlot/src/commands/checkout.rs new file mode 100644 index 0000000..29515ab --- /dev/null +++ b/rust-sandlot/src/commands/checkout.rs @@ -0,0 +1,28 @@ +use anyhow::Result; + +use crate::git; + +use super::helpers::{require_session, teardown_session}; + +pub async fn action(branch: &str, force: bool) -> Result<()> { + let (root, session) = require_session(branch).await; + + if git::is_dirty(&session.worktree).await { + crate::fmt::die(&format!( + "Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first." + )); + } + + if !force && git::is_dirty(&root).await { + crate::fmt::die( + "Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.", + ); + } + + teardown_session(&root, branch, &session.worktree).await; + + git::checkout(branch, &root).await?; + + println!("\u{2714} Checked out {branch}"); + Ok(()) +} diff --git a/rust-sandlot/src/commands/cleanup.rs b/rust-sandlot/src/commands/cleanup.rs new file mode 100644 index 0000000..792267d --- /dev/null +++ b/rust-sandlot/src/commands/cleanup.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use std::path::Path; + +use crate::git; +use crate::state; + +use super::helpers::unlink_session_symlink; + +pub async fn action() -> Result<()> { + let root = git::repo_root(None).await.unwrap_or_else(|e| { + crate::fmt::die(&e.to_string()); + }); + let st = state::load(&root).await; + let sessions: Vec<_> = st.sessions.values().collect(); + + if sessions.is_empty() { + println!("No sessions to clean up."); + return Ok(()); + } + + let stale: Vec<_> = sessions + .iter() + .filter(|s| !Path::new(&s.worktree).exists()) + .collect(); + + if stale.is_empty() { + println!("No stale sessions found."); + return Ok(()); + } + + for s in &stale { + state::remove_session(&root, &s.branch).await.ok(); + unlink_session_symlink(&root, &s.branch).await; + println!("\u{2714} Removed stale session: {}", s.branch); + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/close.rs b/rust-sandlot/src/commands/close.rs new file mode 100644 index 0000000..4ec91ce --- /dev/null +++ b/rust-sandlot/src/commands/close.rs @@ -0,0 +1,22 @@ +use anyhow::Result; + +use crate::git; + +use super::helpers::{require_session, teardown_session}; + +pub async fn action(branch: &str, force: bool) -> Result<()> { + let (root, session) = require_session(branch).await; + + if !force && git::is_dirty(&session.worktree).await { + crate::fmt::die(&format!( + "Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first, or use -f to force." + )); + } + + teardown_session(&root, branch, &session.worktree).await; + + git::delete_local_branch(branch, &root).await; + + println!("\u{2714} Closed session {branch}"); + Ok(()) +} diff --git a/rust-sandlot/src/commands/completions.rs b/rust-sandlot/src/commands/completions.rs new file mode 100644 index 0000000..f58c5a5 --- /dev/null +++ b/rust-sandlot/src/commands/completions.rs @@ -0,0 +1,217 @@ +use anyhow::Result; + +use crate::config::VALID_KEYS; + +/// Commands that accept a branch argument +pub const BRANCH_COMMANDS: &[&str] = &[ + "new", "open", "close", "rm", "checkout", "diff", "log", "show", "web", + "save", "merge", "squash", "rebase", "review", "shell", "edit", "dir", "cd", +]; + +/// All visible subcommands +pub const SUBCOMMANDS: &[(&str, &str)] = &[ + ("list", "Show all active sessions"), + ("new", "Create a new session and launch Claude"), + ("open", "Open an existing Claude session"), + ("close", "Remove a worktree and clean up the session"), + ("checkout", "Close the session and check out the branch locally"), + ("diff", "Show uncommitted changes, or full branch diff vs main"), + ("log", "Show commits on a branch that are not on main"), + ("show", "Show the prompt and full diff for a branch"), + ("web", "Open the branch diff in a web browser"), + ("save", "Stage all changes and commit"), + ("merge", "Merge a branch into main and close the session"), + ("squash", "Squash all commits on a branch into a single commit"), + ("rebase", "Rebase a branch onto the latest main"), + ("review", "Launch an interactive grumpy code review for a branch"), + ("shell", "Open a shell in the VM"), + ("edit", "Open a file from a session in $EDITOR"), + ("dir", "Print the worktree path for a session"), + ("cd", "Change to a branch's worktree directory"), + ("config", "Get or set configuration (e.g. sandlot config memory 16G)"), + ("cleanup", "Remove stale sessions whose worktrees no longer exist"), + ("vm", "Manage the sandlot VM"), + ("upgrade", "Upgrade sandlot to the latest version"), + ("version", "Print the version number"), + ("completions", "Output fish shell completions"), + ("init", "Print shell init script (eval in your shell config)"), +]; + +const VM_SUBCOMMANDS: &[(&str, &str)] = &[ + ("create", "Create and provision the VM"), + ("start", "Start the VM"), + ("shell", "Open a shell in the VM"), + ("status", "Show VM status and all sessions across repos"), + ("info", "Show VM system info (via neofetch)"), + ("stop", "Stop the VM"), + ("destroy", "Stop and delete the VM"), + ("uncache", "Clear the package cache (next create will re-download)"), +]; + +fn esc(s: &str) -> String { + format!("\"{}\"", s.replace('"', "\\\"")) +} + +pub fn generate_fish_completions() -> Vec { + let mut lines = vec![ + "# Fish completions for sandlot (auto-generated)".to_string(), + String::new(), + "complete -c sandlot -f".to_string(), + String::new(), + "function __sandlot_sessions".to_string(), + " command sandlot list --json 2>/dev/null | string match -r '\"branch\":\\s*\"[^\"]+\"' | string replace -r '.*\"branch\":\\s*\"([^\"]+)\".*' '$1'".to_string(), + "end".to_string(), + String::new(), + ]; + + // Commands with their options interleaved (matching TS traversal order) + // Each entry: (name, desc, options) + // Options: (short, long, desc, required) + let commands_with_opts: Vec<(&str, &str, Vec<(Option<&str>, Option<&str>, &str, bool)>)> = vec![ + ("list", "Show all active sessions", vec![ + (None, Some("json"), "Output as JSON", false), + (Some("a"), Some("all"), "Show sessions across all projects", false), + ]), + ("new", "Create a new session and launch Claude", vec![ + (Some("p"), Some("print"), "run Claude in non-interactive mode with -p", true), + (Some("n"), Some("no-save"), "skip auto-save after Claude exits", false), + ]), + ("open", "Open an existing Claude session", vec![ + (Some("p"), Some("print"), "run Claude in non-interactive mode with -p", true), + (Some("n"), Some("no-save"), "skip auto-save after Claude exits", false), + ]), + ("close", "Remove a worktree and clean up the session", vec![ + (Some("f"), Some("force"), "close even if there are unsaved changes", false), + ]), + ("rm", "Remove a session (alias for close)", vec![ + (Some("f"), Some("force"), "close even if there are unsaved changes", false), + ]), + ("checkout", "Close the session and check out the branch locally", vec![ + (Some("f"), Some("force"), "checkout even if there are unsaved changes", false), + ]), + ("diff", "Show uncommitted changes, or full branch diff vs main", vec![]), + ("log", "Show commits on a branch that are not on main", vec![]), + ("show", "Show the prompt and full diff for a branch", vec![]), + ("web", "Open the branch diff in a web browser", vec![]), + ("save", "Stage all changes and commit", vec![]), + ("merge", "Merge a branch into main and close the session", vec![ + (Some("f"), Some("force"), "allow merging into a non-main branch", false), + ]), + ("squash", "Squash all commits on a branch into a single commit", vec![]), + ("rebase", "Rebase a branch onto the latest main", vec![]), + ("review", "Launch an interactive grumpy code review for a branch", vec![ + (Some("p"), Some("print"), "print the review to stdout instead of launching interactive mode", false), + ]), + ("shell", "Open a shell in the VM", vec![]), + ("edit", "Open a file from a session in $EDITOR", vec![]), + ("dir", "Print the worktree path for a session", vec![]), + ("cd", "Change to a branch's worktree directory", vec![]), + ("config", "Get or set configuration (e.g. sandlot config memory 16G)", vec![]), + ("cleanup", "Remove stale sessions whose worktrees no longer exist", vec![]), + ]; + + for (name, desc, opts) in &commands_with_opts { + lines.push(format!( + "complete -c sandlot -n __fish_use_subcommand -a {name} -d {}", + esc(desc) + )); + for (short, long, opt_desc, required) in opts { + let mut parts = vec![format!("complete -c sandlot -n \"__fish_seen_subcommand_from {name}\"")]; + if let Some(s) = short { + parts.push(format!("-s {s}")); + } + if let Some(l) = long { + parts.push(format!("-l {l}")); + } + parts.push(format!("-d {}", esc(opt_desc))); + if *required { + parts.push("-r".to_string()); + } + lines.push(parts.join(" ")); + } + } + + // VM parent command with subcommands + lines.push(format!( + "complete -c sandlot -n __fish_use_subcommand -a vm -d {}", + esc("Manage the sandlot VM") + )); + let sub_names: Vec<&str> = VM_SUBCOMMANDS.iter().map(|(n, _)| *n).collect(); + let guard = format!( + "\"__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from {}\"", + sub_names.join(" ") + ); + for (sub, sub_desc) in VM_SUBCOMMANDS { + lines.push(format!( + "complete -c sandlot -n {guard} -a {sub} -d {}", + esc(sub_desc) + )); + } + // VM subcommand options + lines.push(format!( + "complete -c sandlot -n \"__fish_seen_subcommand_from vm status\" -l json -d {}", + esc("Output as JSON") + )); + + // Remaining top-level commands without options + for (name, desc) in &[ + ("upgrade", "Upgrade sandlot to the latest version"), + ("version", "Print the version number"), + ] { + lines.push(format!( + "complete -c sandlot -n __fish_use_subcommand -a {name} -d {}", + esc(desc) + )); + } + + lines.push(format!( + "complete -c sandlot -n __fish_use_subcommand -a completions -d {}", + esc("Output fish shell completions") + )); + lines.push(format!( + "complete -c sandlot -n \"__fish_seen_subcommand_from completions\" -l install -d {}", + esc("Output a shell script that installs the completions file") + )); + lines.push(format!( + "complete -c sandlot -n __fish_use_subcommand -a init -d {}", + esc("Print shell init script (eval in your shell config)") + )); + + // Session completions for branch-taking commands + lines.push(String::new()); + lines.push(format!( + "complete -c sandlot -n \"__fish_seen_subcommand_from {}\" -xa \"(__sandlot_sessions)\"", + BRANCH_COMMANDS.join(" ") + )); + + // Config key completions + lines.push(String::new()); + lines.push(format!( + "complete -c sandlot -n \"__fish_seen_subcommand_from config\" -xa \"{}\"", + VALID_KEYS.join(" ") + )); + + lines.push(String::new()); + lines +} + +pub fn action(install: bool) -> Result<()> { + if install { + let dest = "~/.config/fish/completions/sandlot.fish"; + println!("#!/bin/sh"); + println!("mkdir -p ~/.config/fish/completions"); + println!("sandlot completions > {dest}"); + println!("echo \"Installed fish completions to {dest}\""); + return Ok(()); + } + + let mut lines = generate_fish_completions(); + lines.insert( + 1, + "# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish".to_string(), + ); + for line in &lines { + println!("{line}"); + } + Ok(()) +} diff --git a/rust-sandlot/src/commands/config.rs b/rust-sandlot/src/commands/config.rs new file mode 100644 index 0000000..3c1fc5c --- /dev/null +++ b/rust-sandlot/src/commands/config.rs @@ -0,0 +1,60 @@ +use anyhow::Result; + +use crate::config::{self, DEFAULTS_MEMORY, VALID_KEYS}; + +pub async fn action(args: &[String]) -> Result<()> { + if args.is_empty() { + let cfg = config::load().await; + for key in VALID_KEYS { + let display = match *key { + "memory" => match &cfg.memory { + Some(v) => v.to_string(), + None => format!("{DEFAULTS_MEMORY} (default)"), + }, + _ => "(unknown)".to_string(), + }; + println!("{key} = {display}"); + } + return Ok(()); + } + + let key = &args[0]; + if !VALID_KEYS.contains(&key.as_str()) { + crate::fmt::die(&format!( + "Unknown config key: {key}\nAvailable keys: {}", + VALID_KEYS.join(", ") + )); + } + + if args.len() == 1 { + let val = match key.as_str() { + "memory" => config::get_memory().await, + _ => None, + }; + let default = match key.as_str() { + "memory" => DEFAULTS_MEMORY, + _ => "", + }; + match val { + Some(v) => println!("{v}"), + None => println!("{default} (default)"), + } + return Ok(()); + } + + if args.len() > 2 { + crate::fmt::die(&format!( + "Too many arguments. Usage: sandlot config {key} " + )); + } + + let value = &args[1]; + let normalized = match config::validate_memory(value) { + Ok(v) => v, + Err(_) => crate::fmt::die("Must be a number followed by G or M, minimum 512M (e.g. 16G)"), + }; + config::set_memory(normalized.clone()).await?; + println!("{key} = {normalized}"); + + Ok(()) +} diff --git a/rust-sandlot/src/commands/diff.html b/rust-sandlot/src/commands/diff.html new file mode 100644 index 0000000..d911e07 --- /dev/null +++ b/rust-sandlot/src/commands/diff.html @@ -0,0 +1,116 @@ + + + + +{{BRANCH}} — sandlot diff + + + + + + +
+

{{BRANCH}}

+ {{PROMPT_SECTION}} +
+ +
+
+ {{LOG_SECTION}} + {{STAT_SECTION}} +
+
+
+ + + + + diff --git a/rust-sandlot/src/commands/diff.rs b/rust-sandlot/src/commands/diff.rs new file mode 100644 index 0000000..58a6ef1 --- /dev/null +++ b/rust-sandlot/src/commands/diff.rs @@ -0,0 +1,63 @@ +use anyhow::Result; + +use crate::git; + +use super::helpers::require_session; + +pub async fn action(branch: &str) -> Result<()> { + let (_, session) = require_session(branch).await; + + // Check for uncommitted changes (staged + unstaged) + let status = tokio::process::Command::new("git") + .args(["-C", &session.worktree, "status", "--porcelain"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + + let status = match status { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => { + eprintln!("\u{2716} git status failed"); + std::process::exit(1); + } + }; + + let args: Vec = if !status.trim().is_empty() { + // Show uncommitted changes + let has_head = tokio::process::Command::new("git") + .args(["-C", &session.worktree, "rev-parse", "--verify", "HEAD"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + if has_head.is_ok_and(|s| s.success()) { + vec!["diff".into(), "HEAD".into()] + } else { + vec!["diff".into()] + } + } else { + // No uncommitted changes — show full branch diff vs main + let main = git::main_branch(Some(&session.worktree)).await?; + vec!["diff".into(), format!("{main}...{branch}")] + }; + + // Run git diff with inherited stdio + let status = std::process::Command::new("git") + .args( + std::iter::once("-C".to_string()) + .chain(std::iter::once(session.worktree.clone())) + .chain(args), + ) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()? + .wait()?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/dir.rs b/rust-sandlot/src/commands/dir.rs new file mode 100644 index 0000000..eb30962 --- /dev/null +++ b/rust-sandlot/src/commands/dir.rs @@ -0,0 +1,9 @@ +use anyhow::Result; + +use super::helpers::require_session; + +pub async fn action(branch: &str) -> Result<()> { + let (_, session) = require_session(branch).await; + println!("{}", session.worktree); + Ok(()) +} diff --git a/rust-sandlot/src/commands/edit.rs b/rust-sandlot/src/commands/edit.rs new file mode 100644 index 0000000..6856872 --- /dev/null +++ b/rust-sandlot/src/commands/edit.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use std::path::Path; + +use super::helpers::require_session; + +pub async fn action(branch: &str, file: &str) -> Result<()> { + let editor = match std::env::var("EDITOR") { + Ok(e) if !e.is_empty() => e, + _ => crate::fmt::die("$EDITOR is not set."), + }; + + let (_, session) = require_session(branch).await; + let worktree = std::fs::canonicalize(&session.worktree) + .unwrap_or_else(|_| Path::new(&session.worktree).to_path_buf()); + let worktree_str = worktree.to_string_lossy().to_string(); + let path = std::fs::canonicalize(worktree.join(file)) + .unwrap_or_else(|_| worktree.join(file)); + let path_str = path.to_string_lossy().to_string(); + + if !path_str.starts_with(&format!("{worktree_str}/")) && path_str != worktree_str { + crate::fmt::die("File path escapes the worktree."); + } + + if !path.exists() { + crate::fmt::die(&format!("File not found: {file}")); + } + + let parts: Vec<&str> = editor.split_whitespace().collect(); + let (cmd, args) = parts.split_first().unwrap(); + + let mut command_args: Vec<&str> = args.to_vec(); + command_args.push(&path_str); + + let status = std::process::Command::new(cmd) + .args(&command_args) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()? + .wait()?; + + if !status.success() { + crate::fmt::die(&format!( + "Editor exited with code {}.", + status.code().unwrap_or(1) + )); + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/helpers.rs b/rust-sandlot/src/commands/helpers.rs new file mode 100644 index 0000000..52b22dc --- /dev/null +++ b/rust-sandlot/src/commands/helpers.rs @@ -0,0 +1,414 @@ +use anyhow::Result; +use std::collections::HashSet; +use std::path::Path; + +use crate::git; +use crate::spinner::Spinner; +use crate::state::{self, Session}; +use crate::vm; + +/// Generated files to skip AI resolution — accept theirs and move on. +fn skip_resolve_set() -> HashSet<&'static str> { + [ + "bun.lock", + "bun.lockb", + "Cargo.lock", + "composer.lock", + "Gemfile.lock", + "go.sum", + "mix.lock", + "package-lock.json", + "Pipfile.lock", + "pnpm-lock.yaml", + "Podfile.lock", + "poetry.lock", + "pubspec.lock", + "flake.lock", + "gradle.lockfile", + "npm-shrinkwrap.json", + "Package.resolved", + "uv.lock", + "yarn.lock", + ] + .into_iter() + .collect() +} + +/// Remove a .sandlot/ symlink and prune empty parent dirs up to .sandlot/. +pub async fn unlink_session_symlink(root: &str, branch: &str) { + let sandlot_dir = Path::new(root).join(".sandlot"); + let symlink_path = sandlot_dir.join(branch); + tokio::fs::remove_file(&symlink_path).await.ok(); + + // Walk up from the symlink's parent, removing empty dirs, stopping at .sandlot/ itself + let mut dir = symlink_path.parent().map(|p| p.to_path_buf()); + while let Some(d) = &dir { + if d == &sandlot_dir { + break; + } + if tokio::fs::remove_dir(d).await.is_err() { + break; + } + dir = d.parent().map(|p| p.to_path_buf()); + } +} + +/// Look up a session by branch, dying if it doesn't exist. +pub async fn require_session(branch: &str) -> (String, Session) { + let root = git::repo_root(None).await.unwrap_or_else(|e| { + crate::fmt::die(&e.to_string()); + }); + let session = state::get_session(&root, branch).await; + match session { + Some(s) => (root, s), + None => crate::fmt::die(&format!("No session found for branch \"{branch}\".")), + } +} + +/// Look up a session by branch, recreating worktree/session if the branch exists but the session doesn't. +pub async fn ensure_session(branch: &str) -> (String, Session) { + let root = git::repo_root(None).await.unwrap_or_else(|e| { + crate::fmt::die(&e.to_string()); + }); + + if let Some(existing) = state::get_session(&root, branch).await { + return (root, existing); + } + + // No session — check if the branch exists + let exists = git::branch_exists(branch, Some(&root), false).await; + if exists.is_none() { + crate::fmt::die(&format!("No session or branch found for \"{branch}\".")); + } + + // Recreate worktree and session + let home = dirs::home_dir().expect("cannot find home directory"); + let repo_name = Path::new(&root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let worktree_abs = home + .join(".sandlot") + .join(&repo_name) + .join(branch) + .to_string_lossy() + .to_string(); + + match git::create_worktree(branch, &worktree_abs, &root).await { + Ok(_) => { + let symlink_path = Path::new(&root).join(".sandlot").join(branch); + if let Some(parent) = symlink_path.parent() { + tokio::fs::create_dir_all(parent).await.ok(); + } + #[cfg(unix)] + { + tokio::fs::symlink(&worktree_abs, &symlink_path).await.ok(); + } + } + Err(err) => { + git::remove_worktree(&worktree_abs, &root).await.ok(); + unlink_session_symlink(&root, branch).await; + crate::fmt::die(&format!("Failed to recreate session: {err}")); + } + } + + let session = Session { + branch: branch.to_string(), + worktree: worktree_abs, + created_at: chrono_now(), + prompt: None, + in_review: None, + }; + state::set_session(&root, session.clone()).await.ok(); + (root, session) +} + +/// Tear down a session: clear activity, remove worktree, unlink symlink, remove state. +pub async fn teardown_session(root: &str, branch: &str, worktree: &str) { + vm::clear_activity(worktree, branch).await; + + if let Err(e) = git::remove_worktree(worktree, root).await { + eprintln!("\u{26A0} Failed to remove worktree: {e}"); + } + + unlink_session_symlink(root, branch).await; + + state::remove_session(root, branch).await.ok(); +} + +/// Resolve conflict markers in files using Claude, then stage them. +pub async fn resolve_conflicts( + files: &[String], + cwd: &str, + on_file: &dyn Fn(&str, usize, usize), +) -> Result<()> { + let skip = skip_resolve_set(); + + for (i, file) in files.iter().enumerate() { + on_file(file, i + 1, files.len()); + + let basename = Path::new(file) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + if skip.contains(basename.as_str()) { + git::checkout_theirs(file, cwd).await?; + git::stage_file(file, cwd).await?; + continue; + } + + let file_path = Path::new(cwd).join(file); + let content = tokio::fs::read_to_string(&file_path).await.map_err(|_| { + anyhow::anyhow!("Failed to read conflicted file: {file}") + })?; + + let (exit_code, stdout, stderr) = vm::claude_pipe( + &content, + "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", + ) + .await; + + if exit_code != 0 || stdout.trim().is_empty() { + let detail = if stderr.trim().is_empty() { + "(no output)" + } else { + stderr.trim() + }; + anyhow::bail!("Claude failed to resolve {file}: {detail}"); + } + + let resolved = format!("{}\n", stdout.trim_end()); + tokio::fs::write(&file_path, &resolved).await?; + git::stage_file(file, cwd).await?; + } + + Ok(()) +} + +/// Merge a branch into main, resolve conflicts if needed, and close the session. +pub async fn merge_and_close(branch: &str, force: bool) -> Result<()> { + let root = git::repo_root(None).await?; + let main = git::main_branch(Some(&root)).await?; + let current = git::current_branch(Some(&root)).await?; + + if current != main && !force { + crate::fmt::die(&format!( + "You must be on \"{main}\" to merge. Currently on \"{current}\". Use --force to merge into \"{current}\" anyway." + )); + } + + let session = state::get_session(&root, branch).await; + + if let Some(ref s) = session { + if git::is_dirty(&s.worktree).await { + crate::fmt::die(&format!( + "Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first." + )); + } + } + + let spin = Spinner::new("Merging", Some(branch)); + let conflicts = git::merge(branch, &root).await?; + + if conflicts.is_empty() { + spin.succeed(&format!("Merged {branch} into {current}")); + if let Some(ref s) = session { + teardown_session(&root, branch, &s.worktree).await; + } + git::delete_local_branch(branch, &root).await; + return Ok(()); + } + + // Resolve conflicts with Claude + spin.set_text(&format!("Resolving {} conflict(s)", conflicts.len())); + + let result: Result<()> = async { + vm::ensure(&|msg| spin.set_text(msg)).await?; + if let Some(ref s) = session { + vm::set_activity(&s.worktree, branch).await; + } + resolve_conflicts(&conflicts, &root, &|file, i, total| { + if total > 1 { + spin.set_text(&format!("({i}/{total}) Resolving {file}")); + } else { + spin.set_text(&format!("Resolving {file}")); + } + }) + .await?; + + git::commit_merge(&root).await?; + spin.succeed(&format!( + "Resolved {} conflict(s) and merged {branch}", + conflicts.len() + )); + Ok(()) + } + .await; + + if let Err(e) = result { + spin.fail(&e.to_string()); + if let Some(ref s) = session { + vm::clear_activity(&s.worktree, branch).await; + } + git::abort_merge(&root).await; + std::process::exit(1); + } + + if let Some(ref s) = session { + vm::clear_activity(&s.worktree, branch).await; + teardown_session(&root, branch, &s.worktree).await; + } + git::delete_local_branch(branch, &root).await; + + Ok(()) +} + +/// Stage all changes, generate a commit message, and commit. Returns true on success. +pub async fn save_changes(worktree: &str, branch: &str, message: Option<&str>) -> bool { + let spin = Spinner::new("Staging changes", Some(branch)); + + // git add . + let _ = tokio::process::Command::new("git") + .args(["-C", worktree, "add", "."]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + // Check for staged changes + let check = tokio::process::Command::new("git") + .args(["-C", worktree, "diff", "--staged", "--quiet"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + if check.is_ok_and(|s| s.success()) { + spin.fail("No changes to commit"); + return false; + } + + let msg = if let Some(m) = message { + m.to_string() + } else { + spin.set_text("Starting container"); + if let Err(e) = vm::ensure(&|m| spin.set_text(m)).await { + spin.fail(&format!("Failed to start container: {e}")); + return false; + } + + spin.set_text("Generating commit message"); + let diff_output = tokio::process::Command::new("git") + .args(["-C", worktree, "diff", "--staged"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await; + let diff = match diff_output { + Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(), + Err(_) => String::new(), + }; + + let (exit_code, stdout, stderr) = vm::claude_pipe( + &diff, + "Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.", + ) + .await; + + if exit_code != 0 { + spin.fail("Failed to generate commit message"); + if !stderr.is_empty() { + eprintln!("{stderr}"); + } + return false; + } + stdout + }; + + spin.set_text("Committing"); + let commit = tokio::process::Command::new("git") + .args(["-C", worktree, "commit", "-m", &msg]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + + match commit { + Ok(o) if o.status.success() => { + let first_line = msg.lines().next().unwrap_or(&msg); + spin.succeed(&format!("Saved: {first_line}")); + true + } + Ok(o) => { + spin.fail("Commit failed"); + let stderr = String::from_utf8_lossy(&o.stderr); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr.trim()); + } + false + } + Err(_) => { + spin.fail("Commit failed"); + false + } + } +} + +pub fn chrono_now() -> String { + // Simple ISO 8601 timestamp without chrono dependency + use std::time::SystemTime; + let duration = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs(); + // Basic UTC timestamp + let days = secs / 86400; + let time_secs = secs % 86400; + let hours = time_secs / 3600; + let minutes = (time_secs % 3600) / 60; + let seconds = time_secs % 60; + + // Calculate date from days since epoch (1970-01-01) + let mut y = 1970i64; + let mut remaining_days = days as i64; + + loop { + let days_in_year = if is_leap_year(y) { 366 } else { 365 }; + if remaining_days < days_in_year { + break; + } + remaining_days -= days_in_year; + y += 1; + } + + let month_days = if is_leap_year(y) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + let mut m = 0; + for (i, &md) in month_days.iter().enumerate() { + if remaining_days < md as i64 { + m = i; + break; + } + remaining_days -= md as i64; + } + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + y, + m + 1, + remaining_days + 1, + hours, + minutes, + seconds + ) +} + +fn is_leap_year(y: i64) -> bool { + (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) +} diff --git a/rust-sandlot/src/commands/init.rs b/rust-sandlot/src/commands/init.rs new file mode 100644 index 0000000..bd8c8ed --- /dev/null +++ b/rust-sandlot/src/commands/init.rs @@ -0,0 +1,97 @@ +use anyhow::Result; + +use super::completions::{generate_fish_completions, BRANCH_COMMANDS, SUBCOMMANDS}; + +pub fn action(shell: &str) -> Result<()> { + match shell { + "fish" => emit_fish(), + "bash" => emit_bash(), + "zsh" => emit_zsh(), + _ => crate::fmt::die(&format!( + "Unsupported shell: {shell}. Supported shells: fish, bash, zsh" + )), + } +} + +fn emit_fish() -> Result<()> { + let lines = vec![ + "function sandlot --wraps sandlot --description 'Sandlot CLI wrapper'", + " if test (count $argv) -ge 1; and test \"$argv[1]\" = cd", + " set -l dir (command sandlot dir $argv[2..])", + " and cd $dir", + " else", + " command sandlot $argv", + " end", + "end", + "", + ]; + for line in &lines { + println!("{line}"); + } + for line in &generate_fish_completions() { + println!("{line}"); + } + Ok(()) +} + +/// Hidden commands excluded from bash/zsh completions (fish includes them via the full generator). +const HIDDEN_COMMANDS: &[&str] = &["rm"]; + +fn emit_bash() -> Result<()> { + let subcommands: Vec<&str> = SUBCOMMANDS + .iter() + .map(|(name, _)| *name) + .filter(|name| !HIDDEN_COMMANDS.contains(name)) + .collect(); + + let lines = vec![ + "sandlot() {", + " if [ \"$#\" -ge 1 ] && [ \"$1\" = \"cd\" ]; then", + " local dir", + " dir=\"$(command sandlot dir \"${@:2}\")\" && cd \"$dir\"", + " else", + " command sandlot \"$@\"", + " fi", + "}", + "", + "_sandlot_completions() {", + " local cur prev", + " cur=\"${COMP_WORDS[COMP_CWORD]}\"", + " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"", + "", + " if [ \"$COMP_CWORD\" -eq 1 ]; then", + ]; + + for line in &lines { + println!("{line}"); + } + println!( + " COMPREPLY=( $(compgen -W \"{}\" -- \"$cur\") )", + subcommands.join(" ") + ); + println!(" return"); + println!(" fi"); + println!(); + let bash_branch_cmds: Vec<&str> = BRANCH_COMMANDS + .iter() + .filter(|name| !HIDDEN_COMMANDS.contains(name)) + .copied() + .collect(); + println!(" case \"$prev\" in"); + println!(" {})", bash_branch_cmds.join("|")); + println!(" local branches"); + println!(" branches=\"$(command sandlot list --json 2>/dev/null | grep -o '\"branch\": *\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/')\""); + println!(" COMPREPLY=( $(compgen -W \"$branches\" -- \"$cur\") )"); + println!(" return"); + println!(" ;;"); + println!(" esac"); + println!("}}"); + println!("complete -F _sandlot_completions sandlot"); + + Ok(()) +} + +fn emit_zsh() -> Result<()> { + println!("autoload -Uz bashcompinit && bashcompinit"); + emit_bash() +} diff --git a/rust-sandlot/src/commands/list.rs b/rust-sandlot/src/commands/list.rs new file mode 100644 index 0000000..2fcdb94 --- /dev/null +++ b/rust-sandlot/src/commands/list.rs @@ -0,0 +1,313 @@ +use anyhow::Result; +use std::collections::HashMap; + +use crate::fmt::{self, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW}; +use crate::git; +use crate::state::{self, GlobalSession}; +use crate::vm; + +struct StyleDef { + icon: String, + color: &'static str, +} + +fn styles() -> HashMap<&'static str, StyleDef> { + let mut m = HashMap::new(); + m.insert( + "idle", + StyleDef { + icon: format!("{DIM}\u{25EF}{RESET}"), + color: DIM, + }, + ); + m.insert( + "active", + StyleDef { + icon: format!("{CYAN}\u{25CE}{RESET}"), + color: CYAN, + }, + ); + m.insert( + "dirty", + StyleDef { + icon: format!("{YELLOW}\u{25D0}{RESET}"), + color: YELLOW, + }, + ); + m.insert( + "saved", + StyleDef { + icon: format!("{GREEN}\u{25CF}{RESET}"), + color: GREEN, + }, + ); + m.insert( + "review", + StyleDef { + icon: format!("{MAGENTA}\u{29BF}{RESET}"), + color: MAGENTA, + }, + ); + m +} + +fn render_sessions( + sessions: &[&GlobalSession], + status_map: &HashMap, + indices: &[usize], +) { + let styles = styles(); + let branch_width = sessions + .iter() + .map(|s| s.session.branch.len()) + .max() + .unwrap_or(6) + .max(6); + let cols = fmt::terminal_width(); + let prefix_width = branch_width + 4; + + println!( + " {DIM}{:branch_width$} PROMPT{RESET}", + "BRANCH" + ); + + for (i, gs) in sessions.iter().enumerate() { + let idx = indices[i]; + let prompt = gs + .session + .prompt + .as_deref() + .unwrap_or("") + .lines() + .next() + .unwrap_or(""); + let status = status_map + .get(&idx) + .map(|s| s.as_str()) + .unwrap_or("idle"); + let style = styles.get(status).unwrap_or(styles.get("idle").unwrap()); + let max_prompt = if cols > prefix_width { + cols - prefix_width + } else { + 0 + }; + let truncated = if max_prompt <= 3 { + String::new() + } else if prompt.len() <= max_prompt { + prompt.to_string() + } else { + format!("{}...", &prompt[..max_prompt - 3]) + }; + println!( + "{} {}{:branch_width$}{RESET} {DIM}{truncated}{RESET}", + style.icon, style.color, gs.session.branch, + ); + } +} + +async fn resolve_status(session: &state::Session, vm_running: bool) -> String { + if !std::path::Path::new(&session.worktree).exists() { + return "idle".to_string(); + } + if vm_running { + let active = vm::is_claude_active(&session.worktree, &session.branch).await; + if active && session.in_review == Some(true) { + return "review".to_string(); + } + if active { + return "active".to_string(); + } + } + if git::is_dirty(&session.worktree).await { + return "dirty".to_string(); + } + if git::has_new_commits(&session.worktree).await { + return "saved".to_string(); + } + "idle".to_string() +} + +async fn clear_stale_reviews(sessions: &[GlobalSession], status_map: &HashMap) { + let mut stale_by_repo: HashMap> = HashMap::new(); + for (i, s) in sessions.iter().enumerate() { + if s.session.in_review == Some(true) { + let status = status_map.get(&i).map(|s| s.as_str()).unwrap_or("idle"); + if status != "review" { + stale_by_repo + .entry(s.repo_root.clone()) + .or_default() + .push(s.session.branch.clone()); + } + } + } + for (repo_root, branches) in &stale_by_repo { + let mut fresh = state::load(repo_root).await; + for branch in branches { + if let Some(s) = fresh.sessions.get_mut(branch) { + s.in_review = Some(false); + } + } + state::save(repo_root, &fresh).await.ok(); + } +} + +async fn backfill_prompts(sessions: &mut [GlobalSession], vm_running: bool) { + if !vm_running { + return; + } + let needs_prompt: Vec = sessions + .iter() + .enumerate() + .filter(|(_, s)| s.session.prompt.is_none()) + .map(|(i, _)| i) + .collect(); + if needs_prompt.is_empty() { + return; + } + + let home = dirs::home_dir().unwrap_or_default(); + let sandlot_dir = home.join(".sandlot").to_string_lossy().to_string(); + let (code, stdout, _) = + vm::exec(&sandlot_dir, "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").await; + if code != 0 || stdout.is_empty() { + return; + } + + let mut by_project: HashMap = HashMap::new(); + for line in stdout.lines() { + if line.is_empty() { + continue; + } + if let Ok(e) = serde_json::from_str::(line) { + if let (Some(project), Some(display)) = + (e.get("project").and_then(|p| p.as_str()), e.get("display").and_then(|d| d.as_str())) + { + by_project.insert(project.to_string(), display.to_string()); + } + } + } + + for i in needs_prompt { + let container_wt = vm::container_path(&sessions[i].session.worktree); + if let Some(display) = by_project.get(&container_wt) { + sessions[i].session.prompt = Some(display.clone()); + } + } +} + +pub async fn action(json: bool, all: bool) -> Result<()> { + let mut sessions: Vec = if all { + state::load_all().await + } else { + let root = git::repo_root(None).await.unwrap_or_else(|e| { + crate::fmt::die(&e.to_string()); + }); + let st = state::load(&root).await; + st.sessions + .into_values() + .map(|s| GlobalSession { + session: s, + repo_root: root.clone(), + }) + .collect() + }; + + let vm_running = vm::status().await == "running"; + + if sessions.is_empty() && !json { + if all { + println!("\u{25C6} No active sessions across any project."); + } else { + println!("\u{25C6} No active sessions."); + } + if !all && !vm_running { + println!("\n{RED}VM is not running.{RESET}"); + } + return Ok(()); + } + + backfill_prompts(&mut sessions, vm_running).await; + + // Resolve statuses + let mut status_map: HashMap = HashMap::new(); + for (i, gs) in sessions.iter().enumerate() { + let status = resolve_status(&gs.session, vm_running).await; + status_map.insert(i, status); + } + + clear_stale_reviews(&sessions, &status_map).await; + + if json { + let with_status: Vec = sessions + .iter() + .enumerate() + .map(|(i, gs)| { + let mut val = serde_json::to_value(&gs.session).unwrap_or_default(); + if let Some(obj) = val.as_object_mut() { + obj.insert( + "status".to_string(), + serde_json::Value::String( + status_map + .get(&i) + .cloned() + .unwrap_or("idle".to_string()), + ), + ); + obj.insert( + "repoRoot".to_string(), + serde_json::Value::String(gs.repo_root.clone()), + ); + } + val + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&with_status)?); + return Ok(()); + } + + if all { + // Group by repo + let mut by_repo: HashMap> = HashMap::new(); + for (i, gs) in sessions.iter().enumerate() { + by_repo + .entry(gs.repo_root.clone()) + .or_default() + .push(i); + } + let mut repos: Vec<_> = by_repo.keys().cloned().collect(); + repos.sort_by(|a, b| { + let a_name = std::path::Path::new(a) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + let b_name = std::path::Path::new(b) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + a_name.cmp(&b_name) + }); + + for repo_root in &repos { + let repo_name = std::path::Path::new(repo_root) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + println!("\n{DIM}\u{2500}\u{2500} {RESET}{repo_name}{DIM} \u{2500}\u{2500}{RESET}"); + let indices = by_repo.get(repo_root).unwrap(); + let repo_sessions: Vec<&GlobalSession> = + indices.iter().map(|&i| &sessions[i]).collect(); + render_sessions(&repo_sessions, &status_map, indices); + } + } else { + let indices: Vec = (0..sessions.len()).collect(); + let refs: Vec<&GlobalSession> = sessions.iter().collect(); + render_sessions(&refs, &status_map, &indices); + } + + println!("\n{DIM}\u{25EF} idle{RESET} \u{00B7} {CYAN}\u{25CE} active{RESET} \u{00B7} {YELLOW}\u{25D0} unsaved{RESET} \u{00B7} {GREEN}\u{25CF} saved{RESET} \u{00B7} {MAGENTA}\u{29BF} review{RESET}"); + if !vm_running { + println!("\n{RED}VM is not running.{RESET}"); + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/log.rs b/rust-sandlot/src/commands/log.rs new file mode 100644 index 0000000..91208ac --- /dev/null +++ b/rust-sandlot/src/commands/log.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use regex::Regex; + +use crate::fmt::{self, RESET, YELLOW}; + +use super::helpers::require_session; + +pub async fn action(branch: &str) -> Result<()> { + let (_, session) = require_session(branch).await; + + let output = tokio::process::Command::new("git") + .args(["-C", &session.worktree, "log", "--no-color", "main..HEAD"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + + let output = match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => { + crate::fmt::die("git log failed"); + } + }; + + // Highlight commit hashes in yellow + let re = Regex::new(r"(?m)^(commit [0-9a-f]+)").unwrap(); + let colored = re.replace_all(&output, format!("{YELLOW}$1{RESET}")); + + fmt::pager(&colored).await; + + Ok(()) +} diff --git a/rust-sandlot/src/commands/merge.rs b/rust-sandlot/src/commands/merge.rs new file mode 100644 index 0000000..c0bfadd --- /dev/null +++ b/rust-sandlot/src/commands/merge.rs @@ -0,0 +1,7 @@ +use anyhow::Result; + +use super::helpers::merge_and_close; + +pub async fn action(branch: &str, force: bool) -> Result<()> { + merge_and_close(branch, force).await +} diff --git a/rust-sandlot/src/commands/mod.rs b/rust-sandlot/src/commands/mod.rs new file mode 100644 index 0000000..6ef8d4b --- /dev/null +++ b/rust-sandlot/src/commands/mod.rs @@ -0,0 +1,25 @@ +pub mod cd; +pub mod checkout; +pub mod cleanup; +pub mod close; +pub mod completions; +pub mod config; +pub mod diff; +pub mod dir; +pub mod edit; +pub mod helpers; +pub mod init; +pub mod list; +pub mod log; +pub mod merge; +pub mod new; +pub mod open; +pub mod rebase; +pub mod review; +pub mod save; +pub mod shell; +pub mod show; +pub mod squash; +pub mod upgrade; +pub mod vm_cmd; +pub mod web; diff --git a/rust-sandlot/src/commands/new.rs b/rust-sandlot/src/commands/new.rs new file mode 100644 index 0000000..1e531fd --- /dev/null +++ b/rust-sandlot/src/commands/new.rs @@ -0,0 +1,230 @@ +use anyhow::Result; +use rand::Rng; +use std::path::Path; + +use crate::git; +use crate::markdown::render_markdown; +use crate::spinner::Spinner; +use crate::state::{self, Session}; +use crate::vm; + +use super::helpers::{save_changes, unlink_session_symlink}; + +const ADJECTIVES: &[&str] = &[ + "calm", "bold", "warm", "cool", "keen", "soft", "fast", "wild", "fair", "rare", + "deep", "dark", "pale", "wide", "slim", "tall", "glad", "pure", "safe", "free", + "hazy", "lazy", "cozy", "tiny", "vast", "busy", "easy", "gray", "gold", "blue", + "rosy", "wavy", "mild", "loud", "firm", "flat", "crisp", "dry", "raw", "odd", +]; + +const NOUNS: &[&str] = &[ + "fern", "dune", "cove", "pine", "reef", "hawk", "pond", "mesa", "vale", "glen", + "haze", "moss", "peak", "tide", "dawn", "lynx", "wren", "sage", "crag", "flint", + "leaf", "reed", "cave", "star", "gust", "surf", "opal", "lark", "vale", "plum", + "birch", "clay", "jade", "ivy", "fox", "elk", "bay", "ash", "dew", "oak", +]; + +fn random_branch_name() -> String { + let mut rng = rand::rng(); + let adj = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())]; + let noun = NOUNS[rng.random_range(0..NOUNS.len())]; + format!("{adj}-{noun}") +} + +fn fallback_branch_name(text: &str) -> String { + let lower = text.to_lowercase(); + let cleaned: String = lower + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == ' ' || c == '-' { + c + } else { + ' ' + } + }) + .collect(); + let trimmed = cleaned.trim(); + trimmed + .split_whitespace() + .take(2) + .collect::>() + .join("-") +} + +async fn branch_from_prompt(text: &str) -> String { + let api_key = match crate::env::get_api_key().await { + Some(k) => k, + None => return fallback_branch_name(text), + }; + + let body = serde_json::json!({ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 15, + "temperature": 0, + "messages": [{"role": "user", "content": format!("Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n{text}\n\nOutput ONLY the branch name, nothing else.")}], + }); + + let client = match reqwest::Client::new() + .post("https://api.anthropic.com/v1/messages") + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .json(&body) + .send() + .await + { + Ok(res) if res.status().is_success() => res, + _ => return fallback_branch_name(text), + }; + + let json: serde_json::Value = match client.json().await { + Ok(j) => j, + Err(_) => return fallback_branch_name(text), + }; + + let raw = json + .get("content") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|t| t.get("text")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + + let name: String = raw + .trim() + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) + .collect(); + // Collapse multiple hyphens, strip leading/trailing + let re = regex::Regex::new(r"-+").unwrap(); + let name = re.replace_all(&name, "-").to_string(); + let name = name.trim_matches('-'); + + if !name.is_empty() && name.len() <= 50 { + name.to_string() + } else { + fallback_branch_name(text) + } +} + +pub async fn action( + branch: Option, + prompt: Option, + print: Option, + save: bool, +) -> Result<()> { + let mut branch = branch; + let mut prompt = prompt; + + // No branch given — derive from -p prompt + if branch.is_none() && print.is_some() { + branch = Some(branch_from_prompt(print.as_ref().unwrap()).await); + } else if branch.is_none() { + branch = Some(random_branch_name()); + } else if let Some(ref b) = branch { + if b.contains(' ') { + // If the "branch" contains spaces, it's actually a prompt + prompt = Some(b.clone()); + branch = Some(branch_from_prompt(b).await); + } + } + + let branch = branch.unwrap(); + let root = git::repo_root(None).await.unwrap_or_else(|e| { + crate::fmt::die(&e.to_string()); + }); + + let home = dirs::home_dir().expect("cannot find home directory"); + let repo_name = Path::new(&root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let worktree_abs = home + .join(".sandlot") + .join(&repo_name) + .join(&branch) + .to_string_lossy() + .to_string(); + + let existing = state::get_session(&root, &branch).await; + if existing.is_some() { + crate::fmt::die(&format!( + "Session \"{branch}\" already exists. Use \"sandlot open {branch}\" to re-enter it." + )); + } + + let spin = Spinner::new("Creating worktree", Some(&branch)); + let mut branch_created = false; + + match git::create_worktree(&branch, &worktree_abs, &root).await { + Ok(created) => { + branch_created = created; + let symlink_path = Path::new(&root).join(".sandlot").join(&branch); + if let Some(parent) = symlink_path.parent() { + tokio::fs::create_dir_all(parent).await.ok(); + } + #[cfg(unix)] + { + tokio::fs::symlink(&worktree_abs, &symlink_path).await.ok(); + } + + spin.set_text("Starting container"); + if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await { + spin.fail(&e.to_string()); + git::remove_worktree(&worktree_abs, &root).await.ok(); + if branch_created { + git::delete_local_branch(&branch, &root).await; + } + unlink_session_symlink(&root, &branch).await; + std::process::exit(1); + } + if print.is_none() { + spin.succeed("Session ready"); + } + } + Err(e) => { + spin.fail(&e.to_string()); + git::remove_worktree(&worktree_abs, &root).await.ok(); + if branch_created { + git::delete_local_branch(&branch, &root).await; + } + unlink_session_symlink(&root, &branch).await; + std::process::exit(1); + } + } + + let effective_prompt = print.as_ref().or(prompt.as_ref()).cloned(); + let session = Session { + branch: branch.clone(), + worktree: worktree_abs.clone(), + created_at: super::helpers::chrono_now(), + prompt: effective_prompt, + in_review: None, + }; + state::set_session(&root, session).await.ok(); + + if let Some(ref p) = print { + spin.set_text("Running prompt\u{2026}"); + let (_, output) = + vm::claude(&worktree_abs, prompt.as_deref(), Some(p), false).await?; + if let Some(ref out) = output { + spin.stop(); + print!("{}\n", render_markdown(out)); + } else { + spin.succeed("Done"); + } + } else { + vm::claude(&worktree_abs, prompt.as_deref(), None, false).await?; + } + + vm::clear_activity(&worktree_abs, &branch).await; + + if save { + save_changes(&worktree_abs, &branch, None).await; + } + + Ok(()) +} + diff --git a/rust-sandlot/src/commands/open.rs b/rust-sandlot/src/commands/open.rs new file mode 100644 index 0000000..39de16a --- /dev/null +++ b/rust-sandlot/src/commands/open.rs @@ -0,0 +1,53 @@ +use anyhow::Result; + +use crate::markdown::render_markdown; +use crate::spinner::Spinner; +use crate::state; +use crate::vm; + +use super::helpers::{ensure_session, save_changes}; + +pub async fn action( + branch: String, + prompt: Option, + print: Option, + save: bool, +) -> Result<()> { + let (root, session) = ensure_session(&branch).await; + + let effective_prompt = print.as_ref().or(prompt.as_ref()).cloned(); + if let Some(ref p) = effective_prompt { + let mut updated = session.clone(); + updated.prompt = Some(p.clone()); + state::set_session(&root, updated).await.ok(); + } + + let spin = Spinner::new("Starting container", Some(&branch)); + if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await { + spin.fail(&e.to_string()); + std::process::exit(1); + } + + if let Some(ref p) = print { + spin.set_text("Running prompt\u{2026}"); + let (_, output) = + vm::claude(&session.worktree, prompt.as_deref(), Some(p), true).await?; + if let Some(ref out) = output { + spin.stop(); + print!("{}\n", render_markdown(out)); + } else { + spin.succeed("Done"); + } + } else { + spin.succeed("Session ready"); + vm::claude(&session.worktree, prompt.as_deref(), None, true).await?; + } + + vm::clear_activity(&session.worktree, &branch).await; + + if save { + save_changes(&session.worktree, &branch, None).await; + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/rebase.rs b/rust-sandlot/src/commands/rebase.rs new file mode 100644 index 0000000..fe4f209 --- /dev/null +++ b/rust-sandlot/src/commands/rebase.rs @@ -0,0 +1,98 @@ +use anyhow::Result; + +use crate::git; +use crate::spinner::Spinner; +use crate::vm; + +use super::helpers::{require_session, resolve_conflicts}; + +const MAX_REBASE_ROUNDS: usize = 10; + +pub async fn action(branch: &str) -> Result<()> { + let (root, session) = require_session(branch).await; + let worktree = &session.worktree; + + if git::is_dirty(worktree).await { + crate::fmt::die(&format!( + "Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first." + )); + } + + let main = git::main_branch(Some(&root)).await?; + let fetch_spin = Spinner::new("Fetching origin", Some(branch)); + + // Fetch origin main + let _ = tokio::process::Command::new("git") + .args(["-C", &root, "fetch", "origin", &main]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + fetch_spin.set_text(&format!("Rebasing onto origin/{main}")); + + let onto = format!("origin/{main}"); + let mut conflicts = match git::rebase(&onto, worktree).await { + Ok(c) => c, + Err(e) => { + fetch_spin.fail(&e.to_string()); + std::process::exit(1); + } + }; + + if conflicts.is_empty() { + fetch_spin.succeed(&format!("Rebased {branch} onto {main}")); + return Ok(()); + } + + fetch_spin.stop(); + println!( + "\u{25C6} Rebase conflicts in {} file(s). Resolving with Claude...", + conflicts.len() + ); + let resolve_spin = Spinner::new("Starting container", Some(branch)); + + let result: Result<()> = async { + vm::ensure(&|msg| resolve_spin.set_text(msg)).await?; + vm::set_activity(worktree, branch).await; + + let mut round = 1usize; + while !conflicts.is_empty() { + if round > MAX_REBASE_ROUNDS { + anyhow::bail!( + "Exceeded {MAX_REBASE_ROUNDS} conflict resolution rounds \u{2014} aborting rebase" + ); + } + + resolve_conflicts(&conflicts, worktree, &|file, i, total| { + if total > 1 { + resolve_spin + .set_text(&format!("({i}/{total}) Resolving {file} (round {round})")); + } else { + resolve_spin.set_text(&format!("Resolving {file} (round {round})")); + } + }) + .await?; + + conflicts = git::rebase_continue(worktree).await?; + round += 1; + } + + resolve_spin.succeed(&format!( + "Rebased {branch} onto {main} (resolved {} conflict round(s))", + round - 1 + )); + Ok(()) + } + .await; + + if let Err(e) = result { + resolve_spin.fail(&e.to_string()); + git::rebase_abort(worktree).await; + std::process::exit(1); + } + + vm::clear_activity(worktree, branch).await; + + Ok(()) +} diff --git a/rust-sandlot/src/commands/review.rs b/rust-sandlot/src/commands/review.rs new file mode 100644 index 0000000..484110b --- /dev/null +++ b/rust-sandlot/src/commands/review.rs @@ -0,0 +1,119 @@ +use anyhow::Result; + +use crate::spinner::Spinner; +use crate::state; +use crate::vm; + +use super::helpers::{require_session, save_changes}; + +pub async fn action(branch: &str, extra: Option<&str>, print: bool) -> Result<()> { + let (root, session) = require_session(branch).await; + + let spin = Spinner::new("Starting container", Some(branch)); + if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await { + spin.fail(&e.to_string()); + std::process::exit(1); + } + + let mut prompt = r#" +You're a grumpy old senior software engineer. You need to review some code my co-worker wrote. + +Launch four agents to review the diff between this branch and main with the following specializations: + +1. Checks CLAUDE.md compliance +2. Looks specifically for bugs +3. Also looks specifically for bugs +4. Looks for opportunities to simplify code + +Have them focus only on the diff! + lines are added in this branch, - lines are overwritten or deleted. They must focus mostly on the + lines. + +Each agent should deliver you a report in this format (the are just for you, not part of their output): + + +# Problem Identified + +Description of problem. + + +Tell each agent: Run `git diff main...HEAD` and focus on the "+" lines, not the "-" lines. + +Once the agents are done, look at all their suggestions and let me know what you think. + +Give me your opinion in this format, with the : + + +# {branch name} Review + +**OK TO SHIP: yes or no** + +# Showstoppers + +1. BUG: Bug that both bug hunters found. +2. BUG: Bug that one of the hunters found. +3. COMPLIANCE: Describe CLAUDE.md compliance issue. + +# Recommendations + +4. BUG: Bug that both bug hunters found. +5. BUG: Bug that one of the hunters found. +6. COMPLIANCE: Describe CLAUDE.md compliance issue. +7. SIMPLIFY: Opportunities to simplify code. + +# Optional + +8. BUG: Bug that both bug hunters found. +9. BUG: Bug that one of the hunters found. +10. COMPLIANCE: Describe CLAUDE.md compliance issue. +11. SIMPLIFY: Opportunities to simplify code. + +# Summary + +Your thoughts, in brief. + + +"# + .to_string(); + + if let Some(extra_text) = extra { + prompt.push_str("\n\n"); + prompt.push_str(extra_text); + } + + // Set in_review flag + let mut updated = session.clone(); + updated.in_review = Some(true); + state::set_session(&root, updated).await.ok(); + + let result = if print { + spin.set_text("Running review\u{2026}"); + let r = vm::claude(&session.worktree, None, Some(&prompt), false).await; + match r { + Ok((_, Some(ref output))) => { + print!("{output}\n"); + } + Ok(_) => {} + Err(ref e) => { + spin.fail(&e.to_string()); + } + } + r.map(|_| ()) + } else { + spin.succeed("Session ready"); + vm::claude(&session.worktree, Some(&prompt), None, false) + .await + .map(|_| ()) + }; + + // Clean up: clear in_review flag + spin.stop(); + if let Some(fresh) = state::get_session(&root, &session.branch).await { + let mut fresh = fresh; + fresh.in_review = Some(false); + state::set_session(&root, fresh).await.ok(); + } + if !print { + save_changes(&session.worktree, &session.branch, None).await; + } + + result +} diff --git a/rust-sandlot/src/commands/save.rs b/rust-sandlot/src/commands/save.rs new file mode 100644 index 0000000..a0ffa37 --- /dev/null +++ b/rust-sandlot/src/commands/save.rs @@ -0,0 +1,13 @@ +use anyhow::Result; + +use super::helpers::{require_session, save_changes}; + +pub async fn action(branch: &str, message: Option<&str>) -> Result<()> { + let (_, session) = require_session(branch).await; + + let ok = save_changes(&session.worktree, branch, message).await; + if !ok { + std::process::exit(1); + } + Ok(()) +} diff --git a/rust-sandlot/src/commands/shell.rs b/rust-sandlot/src/commands/shell.rs new file mode 100644 index 0000000..95ba124 --- /dev/null +++ b/rust-sandlot/src/commands/shell.rs @@ -0,0 +1,16 @@ +use anyhow::Result; + +use crate::vm; + +use super::helpers::require_session; + +pub async fn action(branch: Option<&str>) -> Result<()> { + if let Some(branch) = branch { + let (_, session) = require_session(branch).await; + vm::ensure(&|_| {}).await?; + vm::shell(Some(&session.worktree)).await + } else { + vm::ensure(&|_| {}).await?; + vm::shell(None).await + } +} diff --git a/rust-sandlot/src/commands/show.rs b/rust-sandlot/src/commands/show.rs new file mode 100644 index 0000000..d2cc148 --- /dev/null +++ b/rust-sandlot/src/commands/show.rs @@ -0,0 +1,30 @@ +use anyhow::Result; + +use crate::git; + +use super::helpers::require_session; + +pub async fn action(branch: &str) -> Result<()> { + let (_, session) = require_session(branch).await; + + if let Some(ref prompt) = session.prompt { + eprint!("PROMPT: {prompt}\n\n"); + } + + let main = git::main_branch(Some(&session.worktree)).await?; + + // Run git diff with inherited stdio + let status = std::process::Command::new("git") + .args(["-C", &session.worktree, "diff", &format!("{main}...{branch}")]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()? + .wait()?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/squash.rs b/rust-sandlot/src/commands/squash.rs new file mode 100644 index 0000000..15b4163 --- /dev/null +++ b/rust-sandlot/src/commands/squash.rs @@ -0,0 +1,83 @@ +use anyhow::Result; + +use crate::git; +use crate::spinner::Spinner; +use crate::vm; + +use super::helpers::require_session; + +pub async fn action(branch: &str) -> Result<()> { + let (_, session) = require_session(branch).await; + let worktree = &session.worktree; + + if git::is_dirty(worktree).await { + crate::fmt::die(&format!( + "Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first." + )); + } + + let main = git::main_branch(Some(worktree)).await?; + + if !git::has_new_commits(worktree).await { + crate::fmt::die(&format!( + "Branch \"{branch}\" has no commits beyond {main}." + )); + } + + let base = git::merge_base(&main, "HEAD", worktree).await?; + let original_head = git::head_ref(worktree).await?; + + let spin = Spinner::new("Squashing", Some(branch)); + let mut did_reset = false; + + let result: Result<()> = async { + git::reset_soft(&base, worktree).await?; + did_reset = true; + + spin.set_text("Starting container"); + vm::ensure(&|msg| spin.set_text(msg)).await?; + + spin.set_text("Generating commit message"); + let diff = git::diff_staged(worktree).await; + + if diff.trim().is_empty() { + git::reset_soft(&original_head, worktree).await.ok(); + spin.fail("No changes after squash"); + std::process::exit(1); + } + + let (exit_code, stdout, _) = vm::claude_pipe( + &diff, + "Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.", + ) + .await; + + let msg = if exit_code == 0 && !stdout.trim().is_empty() { + stdout + } else { + spin.set_text("AI commit message failed, using fallback"); + format!("squash {branch}") + }; + + git::commit(&msg, worktree).await?; + spin.succeed(&format!("Squashed {branch} into a single commit")); + Ok(()) + } + .await; + + if let Err(e) = result { + if !did_reset { + spin.fail(&format!("Squash failed: {e}")); + } else { + match git::reset_soft(&original_head, worktree).await { + Ok(_) => spin.fail(&format!("Squash failed, changes restored: {e}")), + Err(_) => spin.fail(&format!( + "Squash failed and rollback failed \u{2014} check \"git reflog\" in the worktree: {e}" + )), + } + } + std::process::exit(1); + } + + Ok(()) +} diff --git a/rust-sandlot/src/commands/upgrade.rs b/rust-sandlot/src/commands/upgrade.rs new file mode 100644 index 0000000..b54504c --- /dev/null +++ b/rust-sandlot/src/commands/upgrade.rs @@ -0,0 +1,12 @@ +use anyhow::Result; + +pub async fn action() -> Result<()> { + let status = std::process::Command::new("bun") + .args(["install", "-g", "@because/sandlot@latest"]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()? + .wait()?; + std::process::exit(status.code().unwrap_or(1)); +} diff --git a/rust-sandlot/src/commands/vm_cmd.rs b/rust-sandlot/src/commands/vm_cmd.rs new file mode 100644 index 0000000..69ac956 --- /dev/null +++ b/rust-sandlot/src/commands/vm_cmd.rs @@ -0,0 +1,222 @@ +use anyhow::Result; +use std::collections::HashMap; +use std::path::Path; + +use crate::fmt::{CYAN, DIM, GREEN, RED, RESET, YELLOW}; +use crate::git; +use crate::spinner::Spinner; +use crate::state; +use crate::vm; + +pub async fn create() -> Result<()> { + let spin = Spinner::new("Creating VM", None); + match vm::create(&|msg| spin.set_text(msg)).await { + Ok(()) => { + spin.succeed("VM created"); + Ok(()) + } + Err(e) => { + spin.fail(&e.to_string()); + std::process::exit(1); + } + } +} + +pub async fn start() -> Result<()> { + match vm::start().await { + Ok(()) => { + println!("\u{2714} VM started"); + Ok(()) + } + Err(e) => { + eprintln!("\u{2716} {e}"); + std::process::exit(1); + } + } +} + +pub async fn shell() -> Result<()> { + vm::ensure(&|_| {}).await?; + vm::shell(None).await +} + +pub async fn status(json: bool) -> Result<()> { + let s = vm::status().await; + let sessions = state::load_all().await; + + if json { + let json_val = serde_json::json!({ + "vm": s, + "sessions": sessions.iter().map(|gs| { + let mut v = serde_json::to_value(&gs.session).unwrap_or_default(); + if let Some(obj) = v.as_object_mut() { + obj.insert("repoRoot".to_string(), serde_json::Value::String(gs.repo_root.clone())); + } + v + }).collect::>(), + }); + println!("{}", serde_json::to_string_pretty(&json_val)?); + return Ok(()); + } + + let status_colors: HashMap<&str, &str> = + [("running", GREEN), ("stopped", YELLOW), ("missing", RED)] + .into_iter() + .collect(); + + let color = status_colors.get(s).copied().unwrap_or(""); + println!("{DIM}VM:{RESET} {color}{s}{RESET}"); + + if sessions.is_empty() { + println!("\n{DIM}No active sessions.{RESET}"); + return Ok(()); + } + + // Determine statuses + let mut statuses: HashMap = HashMap::new(); + for sess in &sessions { + let repo_name = Path::new(&sess.repo_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let key = format!("{repo_name}/{}", sess.session.branch); + let status = if vm::is_claude_active(&sess.session.worktree, &sess.session.branch).await { + "active" + } else if git::is_dirty(&sess.session.worktree).await { + "dirty" + } else if git::has_new_commits(&sess.session.worktree).await { + "saved" + } else { + "idle" + }; + statuses.insert(key, status); + } + + let icons: HashMap<&str, String> = [ + ("idle", format!("{DIM}\u{25EF}{RESET}")), + ("active", format!("{CYAN}\u{25CE}{RESET}")), + ("dirty", format!("{YELLOW}\u{25D0}{RESET}")), + ("saved", format!("{GREEN}\u{25CF}{RESET}")), + ] + .into_iter() + .collect(); + + let branch_colors: HashMap<&str, &str> = [ + ("idle", DIM), + ("active", CYAN), + ("dirty", YELLOW), + ("saved", GREEN), + ] + .into_iter() + .collect(); + + let repo_width = sessions + .iter() + .map(|s| { + Path::new(&s.repo_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .len() + }) + .max() + .unwrap_or(4) + .max(4); + let branch_width = sessions + .iter() + .map(|s| s.session.branch.len()) + .max() + .unwrap_or(6) + .max(6); + let cols = crate::fmt::terminal_width(); + let prefix_width = repo_width + branch_width + 6; + + println!( + "\n {DIM}{:repo_width$} {:branch_width$} PROMPT{RESET}", + "REPO", "BRANCH" + ); + + for sess in &sessions { + let repo_name = Path::new(&sess.repo_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let prompt = sess + .session + .prompt + .as_deref() + .unwrap_or("") + .lines() + .next() + .unwrap_or(""); + let key = format!("{repo_name}/{}", sess.session.branch); + let status = statuses.get(key.as_str()).copied().unwrap_or("idle"); + let icon = icons.get(status).cloned().unwrap_or_default(); + let bc = branch_colors.get(status).copied().unwrap_or(DIM); + let max_prompt = if cols > prefix_width { + cols - prefix_width + } else { + 0 + }; + let truncated = if max_prompt > 3 && prompt.len() > max_prompt { + format!("{}...", &prompt[..max_prompt - 3]) + } else { + prompt.to_string() + }; + println!( + "{icon} {DIM}{:repo_width$}{RESET} {bc}{:branch_width$}{RESET} {DIM}{truncated}{RESET}", + repo_name, sess.session.branch + ); + } + + println!( + "\n{DIM}\u{25EF} idle{RESET} \u{00B7} {CYAN}\u{25CE} active{RESET} \u{00B7} {YELLOW}\u{25D0} unsaved{RESET} \u{00B7} {GREEN}\u{25CF} saved{RESET}" + ); + + Ok(()) +} + +pub async fn info() -> Result<()> { + vm::ensure(&|_| {}).await?; + vm::neofetch().await +} + +pub async fn stop() -> Result<()> { + let spin = Spinner::new("Stopping VM", None); + match vm::stop().await { + Ok(()) => { + spin.succeed("VM stopped"); + Ok(()) + } + Err(e) => { + spin.fail(&e.to_string()); + std::process::exit(1); + } + } +} + +pub async fn destroy() -> Result<()> { + let spin = Spinner::new("Destroying VM", None); + match vm::destroy().await { + Ok(()) => { + spin.succeed("VM destroyed"); + Ok(()) + } + Err(e) => { + spin.fail(&e.to_string()); + std::process::exit(1); + } + } +} + +pub async fn uncache() -> Result<()> { + let had = vm::clear_cache().await; + if had { + println!("\u{2714} Package cache cleared"); + } else { + println!("No cache to clear"); + } + Ok(()) +} diff --git a/rust-sandlot/src/commands/web.rs b/rust-sandlot/src/commands/web.rs new file mode 100644 index 0000000..db8c068 --- /dev/null +++ b/rust-sandlot/src/commands/web.rs @@ -0,0 +1,111 @@ +use anyhow::Result; + +use crate::git; + +use super::helpers::require_session; + +const TEMPLATE: &str = include_str!("diff.html"); + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn format_stat(raw: &str) -> String { + let trimmed: String = raw + .lines() + .map(|l| { + if let Some(stripped) = l.strip_prefix(' ') { + stripped + } else { + l + } + }) + .collect::>() + .join("\n"); + let escaped = escape_html(&trimmed); + escaped + .lines() + .map(|line| { + if let Some(pipe_pos) = line.find('|') { + let before = &line[..pipe_pos + 1]; + let after = &line[pipe_pos + 1..]; + let colored: String = after + .chars() + .map(|c| match c { + '+' => "+".to_string(), + '-' => "-".to_string(), + _ => c.to_string(), + }) + .collect(); + format!("{before}{colored}") + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +pub async fn action(branch: &str) -> Result<()> { + let (_, session) = require_session(branch).await; + let worktree = &session.worktree; + let main = git::main_branch(Some(worktree)).await?; + + let log_range = format!("{main}..{branch}"); + let stat_range = format!("{main}...{branch}"); + let (diff, log, stat) = tokio::join!( + git::branch_diff(branch, &main, worktree), + git::commit_log(&log_range, worktree), + git::diff_stat(&stat_range, worktree), + ); + + if diff.trim().is_empty() { + crate::fmt::die(&format!( + "No changes on branch \"{branch}\" compared to {main}." + )); + } + + let diff_json = serde_json::to_string(&diff)?.replace('<', "\\u003c"); + + let prompt_section = match &session.prompt { + Some(p) => format!("

{}

", escape_html(p)), + None => String::new(), + }; + let log_section = if !log.is_empty() { + format!( + "

Commits

{}
", + escape_html(&log) + ) + } else { + String::new() + }; + let stat_section = if !stat.is_empty() { + format!( + "

Stats

{}
", + format_stat(&stat) + ) + } else { + String::new() + }; + + let html = TEMPLATE + .replace("{{BRANCH}}", &escape_html(branch)) + .replace("{{PROMPT_SECTION}}", &prompt_section) + .replace("{{LOG_SECTION}}", &log_section) + .replace("{{STAT_SECTION}}", &stat_section) + .replace("{{DIFF_JSON}}", &diff_json); + + let tmp_path = format!("/tmp/sandlot-{branch}.html"); + tokio::fs::write(&tmp_path, &html).await?; + let _ = tokio::process::Command::new("open") + .arg(&tmp_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + Ok(()) +} diff --git a/rust-sandlot/src/config.rs b/rust-sandlot/src/config.rs new file mode 100644 index 0000000..6efcf03 --- /dev/null +++ b/rust-sandlot/src/config.rs @@ -0,0 +1,65 @@ +use anyhow::{Result, bail}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +const MIN_MEMORY_MB: u64 = 512; + +pub const DEFAULTS_MEMORY: &str = "16G"; +pub const VALID_KEYS: &[&str] = &["memory"]; + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, +} + +fn config_dir() -> std::path::PathBuf { + dirs::home_dir() + .expect("cannot find home directory") + .join(".config") + .join("sandlot") +} + +fn config_path() -> std::path::PathBuf { + config_dir().join("config.json") +} + +pub fn validate_memory(v: &str) -> Result { + let re = Regex::new(r"^[1-9]\d*[GMgm]$").unwrap(); + if !re.is_match(v) { + bail!("Invalid memory value: {v} (must be a number followed by G or M, e.g. 16G)"); + } + let num: u64 = v[..v.len() - 1].parse().unwrap(); + let unit = v.chars().last().unwrap().to_ascii_uppercase(); + let mb = if unit == 'G' { num * 1024 } else { num }; + if mb < MIN_MEMORY_MB { + bail!("Memory too low: {v} (minimum {MIN_MEMORY_MB}M)"); + } + Ok(v.to_uppercase()) +} + +pub async fn load() -> Config { + let path = config_path(); + match tokio::fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Config::default(), + } +} + +pub async fn save(config: &Config) -> Result<()> { + let dir = config_dir(); + tokio::fs::create_dir_all(&dir).await?; + let json = serde_json::to_string_pretty(config)? + "\n"; + tokio::fs::write(config_path(), json).await?; + Ok(()) +} + +pub async fn get_memory() -> Option { + load().await.memory +} + +pub async fn set_memory(value: String) -> Result<()> { + let mut config = load().await; + config.memory = Some(value); + save(&config).await +} diff --git a/rust-sandlot/src/env.rs b/rust-sandlot/src/env.rs new file mode 100644 index 0000000..3cbd249 --- /dev/null +++ b/rust-sandlot/src/env.rs @@ -0,0 +1,20 @@ +use regex::Regex; + +/// Read the ANTHROPIC_API_KEY from ~/.env. Returns None if not found. +pub async fn get_api_key() -> Option { + let home = dirs::home_dir()?; + let env_file = home.join(".env"); + let content = tokio::fs::read_to_string(&env_file).await.ok()?; + let re = Regex::new(r#"(?m)^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?"#).ok()?; + re.captures(&content) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) +} + +/// Read the ANTHROPIC_API_KEY from ~/.env, dying if not found. +pub async fn require_api_key() -> String { + match get_api_key().await { + Some(key) => key, + None => crate::fmt::die("ANTHROPIC_API_KEY not found in ~/.env"), + } +} diff --git a/rust-sandlot/src/fmt.rs b/rust-sandlot/src/fmt.rs new file mode 100644 index 0000000..46d169c --- /dev/null +++ b/rust-sandlot/src/fmt.rs @@ -0,0 +1,104 @@ +use std::io::Write; + +// ── ANSI escape codes ─────────────────────────────────────────────── + +pub const RESET: &str = "\x1b[0m"; +pub const DIM: &str = "\x1b[2m"; +pub const GREEN: &str = "\x1b[32m"; +pub const YELLOW: &str = "\x1b[33m"; +pub const RED: &str = "\x1b[31m"; +pub const MAGENTA: &str = "\x1b[35m"; +pub const CYAN: &str = "\x1b[36m"; + +// ── Formatted output ──────────────────────────────────────────────── + +pub fn die(message: &str) -> ! { + eprint!("\u{2716} {message}\n"); + std::process::exit(1) +} + +#[allow(dead_code)] +pub fn success(message: &str) { + eprint!("\u{2714} {message}\n"); +} + +pub fn info(message: &str) { + eprint!("\u{25C6} {message}\n"); +} + +// ── Pager ─────────────────────────────────────────────────────────── + +pub async fn pager(content: &str) { + let lines = content.split('\n').count(); + let term_height = terminal_height(); + if lines > term_height { + let mut child = match tokio::process::Command::new("less") + .arg("-R") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(_) => { + print!("{content}"); + return; + } + }; + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(content.as_bytes()).await; + drop(stdin); + } + let _ = child.wait().await; + } else { + print!("{content}"); + let _ = std::io::stdout().flush(); + } +} + +fn terminal_height() -> usize { + // Try to get terminal size + if let Ok(s) = std::env::var("LINES") { + if let Ok(n) = s.parse::() { + return n; + } + } + // Use ioctl + #[cfg(unix)] + { + use std::mem::MaybeUninit; + unsafe { + let mut ws = MaybeUninit::::uninit(); + if libc::ioctl(1, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 { + let ws = ws.assume_init(); + if ws.ws_row > 0 { + return ws.ws_row as usize; + } + } + } + } + 24 +} + +pub fn terminal_width() -> usize { + if let Ok(s) = std::env::var("COLUMNS") { + if let Ok(n) = s.parse::() { + return n; + } + } + #[cfg(unix)] + { + use std::mem::MaybeUninit; + unsafe { + let mut ws = MaybeUninit::::uninit(); + if libc::ioctl(1, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 { + let ws = ws.assume_init(); + if ws.ws_col > 0 { + return ws.ws_col as usize; + } + } + } + } + 80 +} diff --git a/rust-sandlot/src/git.rs b/rust-sandlot/src/git.rs new file mode 100644 index 0000000..efa2486 --- /dev/null +++ b/rust-sandlot/src/git.rs @@ -0,0 +1,435 @@ +use anyhow::{Result, bail}; +use std::path::Path; +use tokio::process::Command; + +/// Format a git error with a fallback for empty stderr. +fn git_error(action: &str, stderr: &str) -> anyhow::Error { + let msg = stderr.trim(); + if msg.is_empty() { + anyhow::anyhow!("{action}: (no output)") + } else { + anyhow::anyhow!("{action}: {msg}") + } +} + +async fn run_git_nothrow(cwd: &str, args: &[&str]) -> (i32, String, String) { + match Command::new("git") + .current_dir(cwd) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await + { + Ok(output) => ( + output.status.code().unwrap_or(1), + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + ), + Err(_) => (1, String::new(), String::new()), + } +} + +/// Get the repo root from a working directory. +pub async fn repo_root(cwd: Option<&str>) -> Result { + let dir = cwd.unwrap_or("."); + let (code, stdout, _) = run_git_nothrow(dir, &["rev-parse", "--show-toplevel"]).await; + if code != 0 { + bail!("Not a git repository. Run this command from inside a git repo."); + } + Ok(stdout.trim().to_string()) +} + +/// Get the current branch name. +pub async fn current_branch(cwd: Option<&str>) -> Result { + let dir = cwd.unwrap_or("."); + let (code, stdout, _) = run_git_nothrow(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + if code != 0 { + bail!("Could not determine current branch."); + } + Ok(stdout.trim().to_string()) +} + +/// Check if a branch exists locally or remotely. Returns "local", "remote", or None. +pub async fn branch_exists(branch: &str, cwd: Option<&str>, fetch: bool) -> Option<&'static str> { + let dir = cwd.unwrap_or("."); + let local_ref = format!("refs/heads/{branch}"); + let (code, _, _) = run_git_nothrow(dir, &["show-ref", "--verify", "--quiet", &local_ref]).await; + if code == 0 { + return Some("local"); + } + + if fetch { + let _ = run_git_nothrow(dir, &["fetch", "origin"]).await; + } + let remote_ref = format!("refs/remotes/origin/{branch}"); + let (code, _, _) = + run_git_nothrow(dir, &["show-ref", "--verify", "--quiet", &remote_ref]).await; + if code == 0 { + return Some("remote"); + } + + None +} + +/// Create a worktree for the given branch. +pub async fn create_worktree( + branch: &str, + worktree_path: &str, + cwd: &str, +) -> Result { + // Clean up stale worktree path if it exists + if Path::new(worktree_path).exists() { + let _ = run_git_nothrow(cwd, &["worktree", "remove", worktree_path, "--force"]).await; + if Path::new(worktree_path).exists() { + tokio::fs::remove_dir_all(worktree_path).await.ok(); + } + } + let _ = run_git_nothrow(cwd, &["worktree", "prune"]).await; + + let exists = branch_exists(branch, Some(cwd), true).await; + + let mut switched_from_branch = false; + let (code, _, stderr) = match exists { + Some("local") => { + let main = main_branch(Some(cwd)).await?; + if branch == main { + bail!("Cannot create a worktree for the main branch \"{main}\"."); + } + // If the branch is checked out in the main worktree, switch it to main first + if current_branch(Some(cwd)).await? == branch { + if is_dirty(cwd).await { + bail!("Cannot move branch \"{branch}\" to a worktree: the main worktree has uncommitted changes. Commit or stash them first."); + } + checkout(&main, cwd).await?; + switched_from_branch = true; + } + run_git_nothrow(cwd, &["worktree", "add", worktree_path, branch]).await + } + Some("remote") => { + let tracking = format!("origin/{branch}"); + run_git_nothrow( + cwd, + &["worktree", "add", worktree_path, "-b", branch, &tracking], + ) + .await + } + _ => { + // New branch from current HEAD + run_git_nothrow(cwd, &["worktree", "add", "-b", branch, worktree_path]).await + } + }; + + if code != 0 { + if switched_from_branch { + let _ = checkout(branch, cwd).await; + } + return Err(git_error( + &format!("Failed to create worktree for \"{branch}\""), + &stderr, + )); + } + + Ok(exists != Some("local")) +} + +/// Remove a worktree. Silently succeeds if the worktree is already gone. +pub async fn remove_worktree(worktree_path: &str, cwd: &str) -> Result<()> { + let (code, _, _) = + run_git_nothrow(cwd, &["worktree", "remove", worktree_path, "--force"]).await; + if code != 0 { + let _ = run_git_nothrow(cwd, &["worktree", "prune"]).await; + if Path::new(worktree_path).exists() { + tokio::fs::remove_dir_all(worktree_path).await.ok(); + } + } + Ok(()) +} + +/// Delete a local branch. +pub async fn delete_local_branch(branch: &str, cwd: &str) { + let _ = run_git_nothrow(cwd, &["branch", "-D", branch]).await; +} + +/// Checkout a branch. +pub async fn checkout(branch: &str, cwd: &str) -> Result<()> { + let (code, _, stderr) = run_git_nothrow(cwd, &["checkout", branch]).await; + if code != 0 { + return Err(git_error( + &format!("Failed to checkout branch \"{branch}\""), + &stderr, + )); + } + Ok(()) +} + +/// Merge a branch into the current branch. Returns conflicted file paths, or empty vec if clean. +pub async fn merge(branch: &str, cwd: &str) -> Result> { + let (code, _, stderr) = run_git_nothrow(cwd, &["merge", branch]).await; + if code == 0 { + return Ok(vec![]); + } + + let (_, unmerged, _) = + run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await; + let files: Vec = unmerged + .trim() + .split('\n') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + if !files.is_empty() { + return Ok(files); + } + + Err(git_error( + &format!("Failed to merge branch \"{branch}\""), + &stderr, + )) +} + +/// Return the staged diff as text. +pub async fn diff_staged(cwd: &str) -> String { + let (_, stdout, _) = run_git_nothrow(cwd, &["diff", "--staged"]).await; + stdout +} + +/// Commit staged changes with a message. +pub async fn commit(message: &str, cwd: &str) -> Result<()> { + let (code, _, stderr) = run_git_nothrow(cwd, &["commit", "-m", message]).await; + if code != 0 { + return Err(git_error("Failed to commit", &stderr)); + } + Ok(()) +} + +/// Accept "theirs" version of a conflicted file. +pub async fn checkout_theirs(file: &str, cwd: &str) -> Result<()> { + let (code, _, stderr) = run_git_nothrow(cwd, &["checkout", "--theirs", "--", file]).await; + if code != 0 { + return Err(git_error( + &format!("Failed to checkout theirs for {file}"), + &stderr, + )); + } + Ok(()) +} + +/// Stage a file. +pub async fn stage_file(file: &str, cwd: &str) -> Result<()> { + let (code, _, stderr) = run_git_nothrow(cwd, &["add", file]).await; + if code != 0 { + return Err(git_error(&format!("Failed to stage {file}"), &stderr)); + } + Ok(()) +} + +/// Finalize a merge commit after resolving conflicts. +pub async fn commit_merge(cwd: &str) -> Result<()> { + let (code, _, stderr) = run_git_nothrow(cwd, &["commit", "--no-edit"]).await; + if code != 0 { + return Err(git_error("Failed to commit merge", &stderr)); + } + Ok(()) +} + +/// Abort an in-progress merge. +pub async fn abort_merge(cwd: &str) { + let _ = run_git_nothrow(cwd, &["merge", "--abort"]).await; +} + +/// Soft-reset to a given ref (keeps changes staged). +pub async fn reset_soft(reference: &str, cwd: &str) -> Result<()> { + let (code, _, stderr) = run_git_nothrow(cwd, &["reset", "--soft", reference]).await; + if code != 0 { + bail!( + "Failed to reset to \"{reference}\": {}", + stderr.trim() + ); + } + Ok(()) +} + +/// Get the full SHA of HEAD. +pub async fn head_ref(cwd: &str) -> Result { + let (code, stdout, _) = run_git_nothrow(cwd, &["rev-parse", "HEAD"]).await; + if code != 0 { + bail!("Could not resolve HEAD."); + } + Ok(stdout.trim().to_string()) +} + +/// Rebase the current branch onto another. Returns conflicted file paths, or empty vec if clean. +pub async fn rebase(onto: &str, cwd: &str) -> Result> { + // Check for existing rebase state + let (_, rebase_merge, _) = + run_git_nothrow(cwd, &["rev-parse", "--git-path", "rebase-merge"]).await; + let (_, rebase_apply, _) = + run_git_nothrow(cwd, &["rev-parse", "--git-path", "rebase-apply"]).await; + if Path::new(rebase_merge.trim()).exists() || Path::new(rebase_apply.trim()).exists() { + bail!( + "A rebase is already in progress. Run \"git -C {cwd} rebase --abort\" to cancel it first." + ); + } + + let (code, _, stderr) = run_git_nothrow(cwd, &["rebase", onto]).await; + if code == 0 { + return Ok(vec![]); + } + + let (_, unmerged, _) = + run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await; + let files: Vec = unmerged + .trim() + .split('\n') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + if !files.is_empty() { + return Ok(files); + } + + let msg = stderr.trim(); + bail!( + "Rebase onto \"{onto}\" failed: {}", + if msg.is_empty() { + "(no output from git)" + } else { + msg + } + ); +} + +/// Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done. +pub async fn rebase_continue(cwd: &str) -> Result> { + let (code, _, stderr) = + run_git_nothrow(cwd, &["-c", "core.editor=true", "rebase", "--continue"]).await; + if code == 0 { + return Ok(vec![]); + } + + let (_, unmerged, _) = + run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await; + let files: Vec = unmerged + .trim() + .split('\n') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + if !files.is_empty() { + return Ok(files); + } + + Err(git_error("Rebase --continue failed", &stderr)) +} + +/// Abort an in-progress rebase. +pub async fn rebase_abort(cwd: &str) { + let _ = run_git_nothrow(cwd, &["rebase", "--abort"]).await; +} + +/// Check if a worktree has uncommitted changes. +pub async fn is_dirty(worktree_path: &str) -> bool { + let (code, stdout, _) = run_git_nothrow( + ".", + &["-C", worktree_path, "status", "--porcelain"], + ) + .await; + if code != 0 { + return false; + } + !stdout.trim().is_empty() +} + +/// Find the merge base (common ancestor) between two refs. +pub async fn merge_base(ref1: &str, ref2: &str, cwd: &str) -> Result { + let (code, stdout, _) = run_git_nothrow(cwd, &["merge-base", ref1, ref2]).await; + if code != 0 { + bail!("Could not find merge base between \"{ref1}\" and \"{ref2}\""); + } + Ok(stdout.trim().to_string()) +} + +/// Get a one-line-per-commit log for a revision range. +pub async fn commit_log(range: &str, cwd: &str) -> String { + let (code, stdout, _) = run_git_nothrow(cwd, &["log", "--oneline", range]).await; + if code != 0 { + return String::new(); + } + stdout.trim().to_string() +} + +/// Get a diff stat summary for a revision range. +pub async fn diff_stat(range: &str, cwd: &str) -> String { + let (code, stdout, _) = + run_git_nothrow(cwd, &["diff", "--stat", "--stat-width=68", range]).await; + if code != 0 { + return String::new(); + } + stdout.trim().to_string() +} + +/// Get the diff for a specific file between two refs. +#[allow(dead_code)] +pub async fn file_diff(ref1: &str, ref2: &str, file: &str, cwd: &str) -> String { + let (code, stdout, _) = + run_git_nothrow(cwd, &["diff", ref1, ref2, "--", file]).await; + if code != 0 { + return String::new(); + } + stdout.trim().to_string() +} + +/// Check if a branch has commits beyond main. +pub async fn has_new_commits(worktree_path: &str) -> bool { + let main = match main_branch(Some(worktree_path)).await { + Ok(m) => m, + Err(_) => return false, + }; + let range = format!("{main}..HEAD"); + let (code, stdout, _) = + run_git_nothrow(".", &["-C", worktree_path, "rev-list", &range, "--count"]).await; + if code != 0 { + return false; + } + stdout.trim().parse::().unwrap_or(0) > 0 +} + +/// Get the full unified diff of a branch vs main as a string. +pub async fn branch_diff(branch: &str, main: &str, cwd: &str) -> String { + let range = format!("{main}...{branch}"); + let (code, stdout, _) = run_git_nothrow(cwd, &["diff", "--no-ext-diff", &range]).await; + if code != 0 { + return String::new(); + } + stdout +} + +/// Detect the main branch name (main or master). +pub async fn main_branch(cwd: Option<&str>) -> Result { + let dir = cwd.unwrap_or("."); + let (code, _, _) = run_git_nothrow( + ".", + &["-C", dir, "rev-parse", "--verify", "--quiet", "refs/heads/main"], + ) + .await; + if code == 0 { + return Ok("main".to_string()); + } + let (code, _, _) = run_git_nothrow( + ".", + &[ + "-C", + dir, + "rev-parse", + "--verify", + "--quiet", + "refs/heads/master", + ], + ) + .await; + if code == 0 { + return Ok("master".to_string()); + } + bail!("Could not detect main branch: neither \"main\" nor \"master\" exists.") +} diff --git a/rust-sandlot/src/main.rs b/rust-sandlot/src/main.rs new file mode 100644 index 0000000..b9f801b --- /dev/null +++ b/rust-sandlot/src/main.rs @@ -0,0 +1,341 @@ +mod commands; +mod config; +mod env; +mod fmt; +mod git; +mod markdown; +mod spinner; +mod state; +mod vm; + +use clap::{Parser, Subcommand}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser)] +#[command( + name = "sandlot", + about = "Sandboxed development with Claude.", + disable_version_flag = true +)] +struct Cli { + #[arg(short = 'V', long = "version")] + version: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Show all active sessions + List { + /// Output as JSON + #[arg(long)] + json: bool, + /// Show sessions across all projects + #[arg(short, long)] + all: bool, + }, + + /// Create a new session and launch Claude + New { + /// branch name or prompt (if it contains spaces) + branch: Option, + /// initial prompt for Claude + prompt: Option, + /// run Claude in non-interactive mode with -p + #[arg(short, long)] + print: Option, + /// skip auto-save after Claude exits + #[arg(short = 'n', long = "no-save")] + no_save: bool, + }, + + /// Open an existing Claude session + Open { + /// branch name + branch: String, + /// initial prompt for Claude + prompt: Option, + /// run Claude in non-interactive mode with -p + #[arg(short, long)] + print: Option, + /// skip auto-save after Claude exits + #[arg(short = 'n', long = "no-save")] + no_save: bool, + }, + + /// Remove a worktree and clean up the session + Close { + /// branch name + branch: String, + /// close even if there are unsaved changes + #[arg(short, long)] + force: bool, + }, + + /// Remove a session (alias for close) + #[command(hide = true)] + Rm { + /// branch name + branch: String, + /// close even if there are unsaved changes + #[arg(short, long)] + force: bool, + }, + + /// Close the session and check out the branch locally + #[command(alias = "co")] + Checkout { + /// branch name + branch: String, + /// checkout even if there are unsaved changes + #[arg(short, long)] + force: bool, + }, + + // ── Branch Commands ── + + /// Show uncommitted changes, or full branch diff vs main + Diff { + /// branch name + branch: String, + }, + + /// Show commits on a branch that are not on main + Log { + /// branch name + branch: String, + }, + + /// Show the prompt and full diff for a branch + Show { + /// branch name + branch: String, + }, + + /// Open the branch diff in a web browser + Web { + /// branch name + branch: String, + }, + + /// Stage all changes and commit + Save { + /// branch name + branch: String, + /// commit message (AI-generated if omitted) + message: Option, + }, + + /// Merge a branch into main and close the session + Merge { + /// branch name + branch: String, + /// allow merging into a non-main branch + #[arg(short, long)] + force: bool, + }, + + /// Squash all commits on a branch into a single commit + Squash { + /// branch name + branch: String, + }, + + /// Rebase a branch onto the latest main + Rebase { + /// branch name + branch: String, + }, + + /// Launch an interactive grumpy code review for a branch + Review { + /// branch name + branch: String, + /// additional instructions to append to the review prompt + prompt: Option, + /// print the review to stdout instead of launching interactive mode + #[arg(short, long)] + print: bool, + }, + + /// Open a shell in the VM + Shell { + /// branch name (omit for a plain VM shell) + branch: Option, + }, + + /// Open a file from a session in $EDITOR + Edit { + /// branch name + branch: String, + /// file path relative to worktree root + file: String, + }, + + /// Print the worktree path for a session + Dir { + /// branch name + branch: String, + }, + + /// Change to a branch's worktree directory + Cd { + /// branch name + branch: String, + }, + + // ── Admin Commands ── + + /// Get or set configuration (e.g. sandlot config memory 16G) + Config { + /// key [value] + args: Vec, + }, + + /// Remove stale sessions whose worktrees no longer exist + Cleanup, + + /// Manage the sandlot VM + Vm { + #[command(subcommand)] + command: VmCommands, + }, + + /// Upgrade sandlot to the latest version + Upgrade, + + /// Print the version number + Version, + + /// Output fish shell completions + Completions { + /// Output a shell script that installs the completions file + #[arg(long)] + install: bool, + }, + + /// Print shell init script (eval in your shell config) + Init { + /// shell type (fish, bash, zsh) + shell: String, + }, +} + +#[derive(Subcommand)] +enum VmCommands { + /// Create and provision the VM + Create, + /// Start the VM + Start, + /// Open a shell in the VM + Shell, + /// Show VM status and all sessions across repos + Status { + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show VM system info (via neofetch) + Info, + /// Stop the VM + Stop, + /// Stop and delete the VM + Destroy, + /// Clear the package cache (next create will re-download) + Uncache, +} + +#[tokio::main] +async fn main() { + // Default: `sandlot` → `sandlot list` + let args: Vec = std::env::args().collect(); + let effective_args = if args.len() <= 1 { + vec![args[0].clone(), "list".to_string()] + } else { + args + }; + + let cli = match Cli::try_parse_from(&effective_args) { + Ok(cli) => cli, + Err(e) => { + // clap handles --help and error display + e.exit(); + } + }; + + if cli.version { + let parts: Vec<&str> = VERSION.split('.').collect(); + println!("v{}", parts.last().unwrap_or(&VERSION)); + return; + } + + let result = match cli.command.unwrap_or(Commands::List { + json: false, + all: false, + }) { + Commands::List { json, all } => commands::list::action(json, all).await, + Commands::New { + branch, + prompt, + print, + no_save, + } => commands::new::action(branch, prompt, print, !no_save).await, + Commands::Open { + branch, + prompt, + print, + no_save, + } => commands::open::action(branch, prompt, print, !no_save).await, + Commands::Close { branch, force } | Commands::Rm { branch, force } => { + commands::close::action(&branch, force).await + } + Commands::Checkout { branch, force } => commands::checkout::action(&branch, force).await, + Commands::Diff { branch } => commands::diff::action(&branch).await, + Commands::Log { branch } => commands::log::action(&branch).await, + Commands::Show { branch } => commands::show::action(&branch).await, + Commands::Web { branch } => commands::web::action(&branch).await, + Commands::Save { branch, message } => { + commands::save::action(&branch, message.as_deref()).await + } + Commands::Merge { branch, force } => commands::merge::action(&branch, force).await, + Commands::Squash { branch } => commands::squash::action(&branch).await, + Commands::Rebase { branch } => commands::rebase::action(&branch).await, + Commands::Review { + branch, + prompt, + print, + } => commands::review::action(&branch, prompt.as_deref(), print).await, + Commands::Shell { branch } => commands::shell::action(branch.as_deref()).await, + Commands::Edit { branch, file } => commands::edit::action(&branch, &file).await, + Commands::Dir { branch } => commands::dir::action(&branch).await, + Commands::Cd { branch } => commands::cd::action(&branch), + Commands::Config { args } => commands::config::action(&args).await, + Commands::Cleanup => commands::cleanup::action().await, + Commands::Vm { command } => match command { + VmCommands::Create => commands::vm_cmd::create().await, + VmCommands::Start => commands::vm_cmd::start().await, + VmCommands::Shell => commands::vm_cmd::shell().await, + VmCommands::Status { json } => commands::vm_cmd::status(json).await, + VmCommands::Info => commands::vm_cmd::info().await, + VmCommands::Stop => commands::vm_cmd::stop().await, + VmCommands::Destroy => commands::vm_cmd::destroy().await, + VmCommands::Uncache => commands::vm_cmd::uncache().await, + }, + Commands::Upgrade => commands::upgrade::action().await, + Commands::Version => { + let parts: Vec<&str> = VERSION.split('.').collect(); + println!("v{}", parts.last().unwrap_or(&VERSION)); + Ok(()) + } + Commands::Completions { install } => commands::completions::action(install), + Commands::Init { shell } => commands::init::action(&shell), + }; + + if let Err(e) = result { + eprintln!("\u{2716} {e}"); + std::process::exit(1); + } +} diff --git a/rust-sandlot/src/markdown.rs b/rust-sandlot/src/markdown.rs new file mode 100644 index 0000000..f4b5ba5 --- /dev/null +++ b/rust-sandlot/src/markdown.rs @@ -0,0 +1,254 @@ +use regex::Regex; + +fn strip_ansi(s: &str) -> String { + let re1 = Regex::new(r"\x1b\]8;;[^\x07]*\x07").unwrap(); + let re2 = Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + let s = re1.replace_all(s, ""); + re2.replace_all(&s, "").to_string() +} + +fn render_table(block: &str) -> String { + let lines: Vec<&str> = block.trim().split('\n').collect(); + if lines.len() < 2 { + return block.to_string(); + } + + let parse_row = |line: &str| -> Vec { + let l = line.strip_prefix('|').unwrap_or(line); + let l = l.strip_suffix('|').unwrap_or(l); + l.split('|').map(|c| c.trim().to_string()).collect() + }; + + let header = parse_row(lines[0]); + let sep_cells = parse_row(lines[1]); + + let sep_re = Regex::new(r"^:?-+:?$").unwrap(); + if !sep_cells.iter().all(|s| sep_re.is_match(s.trim())) { + return block.to_string(); + } + + let cols = header.len(); + let align: Vec<&str> = sep_cells + .iter() + .map(|s| { + let t = s.trim(); + if t.starts_with(':') && t.ends_with(':') { + "center" + } else if t.ends_with(':') { + "right" + } else { + "left" + } + }) + .collect(); + + let rows: Vec> = lines[2..].iter().map(|l| parse_row(l)).collect(); + + let mut widths = vec![0usize; cols]; + for c in 0..cols { + widths[c] = widths[c].max(strip_ansi(header.get(c).map(|s| s.as_str()).unwrap_or("")).len()); + for row in &rows { + widths[c] = widths[c].max(strip_ansi(row.get(c).map(|s| s.as_str()).unwrap_or("")).len()); + } + } + + let pad = |text: &str, width: usize, a: &str| -> String { + let visible = strip_ansi(text).len(); + if visible >= width { + return text.to_string(); + } + let needed = width - visible; + match a { + "right" => format!("{}{}", " ".repeat(needed), text), + "center" => { + let l = needed / 2; + format!("{}{}{}", " ".repeat(l), text, " ".repeat(needed - l)) + } + _ => format!("{}{}", text, " ".repeat(needed)), + } + }; + + let d = "\x1b[2m"; + let r = "\x1b[22m"; + + let render_row = |cells: &[String], bold: bool| -> String { + let parts: Vec = cells + .iter() + .enumerate() + .map(|(i, c)| pad(c, *widths.get(i).unwrap_or(&0), align.get(i).copied().unwrap_or("left"))) + .collect(); + if bold { + format!( + "{d}\u{2502}{r} {} {d}\u{2502}{r}", + parts + .iter() + .map(|p| format!("\x1b[1m{p}\x1b[22m")) + .collect::>() + .join(&format!(" {d}\u{2502}{r} ")) + ) + } else { + format!( + "{d}\u{2502}{r} {} {d}\u{2502}{r}", + parts.join(&format!(" {d}\u{2502}{r} ")) + ) + } + }; + + let hline = |l: &str, m: &str, r_ch: &str| -> String { + let segs: Vec = widths.iter().map(|w| "\u{2500}".repeat(w + 2)).collect(); + format!("{d}{l}{}{r_ch}{r}", segs.join(m)) + }; + + let mut out = Vec::new(); + out.push(hline("\u{250C}", "\u{252C}", "\u{2510}")); + out.push(render_row(&header, true)); + out.push(hline("\u{251C}", "\u{253C}", "\u{2524}")); + for row in &rows { + out.push(render_row(row, false)); + } + out.push(hline("\u{2514}", "\u{2534}", "\u{2518}")); + out.join("\n") +} + +pub fn render_markdown(text: &str) -> String { + // Extract fenced code blocks before anything else + let mut code_blocks: Vec = Vec::new(); + let code_block_re = Regex::new(r"(?m)^```\w*\n([\s\S]*?)^```\s*$").unwrap(); + let mut result = code_block_re + .replace_all(text, |caps: ®ex::Captures| { + code_blocks.push(caps[1].to_string()); + format!("\x00CODEBLOCK{}\x00", code_blocks.len() - 1) + }) + .to_string(); + + // Extract backslash escapes + let mut escapes: Vec = Vec::new(); + let esc_re = Regex::new(r"\\([\\`*_~\[\]()#>!\-])").unwrap(); + result = esc_re + .replace_all(&result, |caps: ®ex::Captures| { + escapes.push(caps[1].to_string()); + format!("\x00ESC{}\x00", escapes.len() - 1) + }) + .to_string(); + + // Extract code spans + let mut code_spans: Vec = Vec::new(); + let span_re = Regex::new(r"`([^`]+)`").unwrap(); + result = span_re + .replace_all(&result, |caps: ®ex::Captures| { + code_spans.push(caps[1].to_string()); + format!("\x00CODE{}\x00", code_spans.len() - 1) + }) + .to_string(); + + // Links: [text](url) -> OSC 8 terminal hyperlink + let link_re = Regex::new(r#"(? bold+italic+underline + let h1_re = Regex::new(r"(?m)^# (.+)$").unwrap(); + result = h1_re + .replace_all(&result, "\x1b[1;3;4m$1\x1b[22;23;24m") + .to_string(); + + // H2/H3: ## Header -> bold + let h2_re = Regex::new(r"(?m)^#{2,6} (.+)$").unwrap(); + result = h2_re + .replace_all(&result, "\x1b[1m$1\x1b[22m") + .to_string(); + + // Blockquotes: > text -> dim+italic + let bq_re = Regex::new(r"(?m)^> (.+)$").unwrap(); + result = bq_re + .replace_all(&result, "\x1b[2;3m$1\x1b[22;23m") + .to_string(); + + // Bare blockquote lines + let bq_bare_re = Regex::new(r"(?m)^>\s*$").unwrap(); + result = bq_bare_re.replace_all(&result, "").to_string(); + + // Task lists: - [x] -> green check, - [ ] -> dim box + let task_x_re = Regex::new(r"(?m)^(\s*)[-*] \[x\] (.+)$").unwrap(); + result = task_x_re + .replace_all(&result, "$1\x1b[32m\u{2713}\x1b[39m $2") + .to_string(); + + let task_o_re = Regex::new(r"(?m)^(\s*)[-*] \[ \] (.+)$").unwrap(); + result = task_o_re + .replace_all(&result, "$1\x1b[2m\u{2610}\x1b[22m $2") + .to_string(); + + // Bold: **text** + let bold_re = Regex::new(r"\*\*(.+?)\*\*").unwrap(); + result = bold_re + .replace_all(&result, "\x1b[1m$1\x1b[22m") + .to_string(); + + // Italic: *text* + let italic_re = Regex::new(r"\*(.+?)\*").unwrap(); + result = italic_re + .replace_all(&result, "\x1b[3m$1\x1b[23m") + .to_string(); + + // Restore code spans as light blue + let code_restore_re = Regex::new(r"\x00CODE(\d+)\x00").unwrap(); + result = code_restore_re + .replace_all(&result, |caps: ®ex::Captures| { + let i: usize = caps[1].parse().unwrap(); + format!( + "\x1b[38;5;147m{}\x1b[39m", + code_spans.get(i).map(|s| s.as_str()).unwrap_or("") + ) + }) + .to_string(); + + // Restore backslash escapes + let esc_restore_re = Regex::new(r"\x00ESC(\d+)\x00").unwrap(); + result = esc_restore_re + .replace_all(&result, |caps: ®ex::Captures| { + let i: usize = caps[1].parse().unwrap(); + escapes.get(i).map(|s| s.as_str()).unwrap_or("").to_string() + }) + .to_string(); + + // Tables: pipe tables with box-drawing + let table_re = Regex::new(r"(?m)^(\|[^\n]+\|\n)(\|[\s:|\-]+\|\n)((?:\|[^\n]+\|\n?)*)").unwrap(); + result = table_re + .replace_all(&result, |caps: ®ex::Captures| { + render_table(&caps[0]) + }) + .to_string(); + + // Restore code blocks + let cb_restore_re = Regex::new(r"\x00CODEBLOCK(\d+)\x00").unwrap(); + result = cb_restore_re + .replace_all(&result, |caps: ®ex::Captures| { + let i: usize = caps[1].parse().unwrap(); + code_blocks.get(i).map(|s| s.as_str()).unwrap_or("").to_string() + }) + .to_string(); + + // Breathe: add blank line before list starts + let mut lines: Vec = result.split('\n').map(|s| s.to_string()).collect(); + let list_re = Regex::new(r"^[\s]*[-*] ").unwrap(); + let mut i = lines.len(); + while i > 1 { + i -= 1; + if list_re.is_match(&lines[i]) + && !lines[i - 1].trim().is_empty() + && !list_re.is_match(&lines[i - 1]) + { + lines.insert(i, String::new()); + } + } + + lines.join("\n") +} diff --git a/rust-sandlot/src/spinner.rs b/rust-sandlot/src/spinner.rs new file mode 100644 index 0000000..e8f5094 --- /dev/null +++ b/rust-sandlot/src/spinner.rs @@ -0,0 +1,99 @@ +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +const FRAMES: &[&str] = &[ + "\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", + "\u{2827}", "\u{2807}", "\u{280F}", +]; + +pub struct Spinner { + text: Arc>, + prefix_tag: String, + running: Arc, + handle: Option>, + debug: bool, +} + +impl Spinner { + pub fn new(text: &str, prefix: Option<&str>) -> Self { + let debug = std::env::var("DEBUG").is_ok_and(|v| !v.is_empty()); + let prefix_tag = match prefix { + Some(p) => format!("\x1b[2m[{p}]\x1b[22m "), + None => String::new(), + }; + + if debug { + eprint!("\u{25B8} {}{text}\n", prefix_tag); + return Self { + text: Arc::new(Mutex::new(text.to_string())), + prefix_tag, + running: Arc::new(AtomicBool::new(false)), + handle: None, + debug: true, + }; + } + + let text = Arc::new(Mutex::new(text.to_string())); + let running = Arc::new(AtomicBool::new(true)); + + let t_text = text.clone(); + let t_running = running.clone(); + let t_tag = prefix_tag.clone(); + let handle = std::thread::spawn(move || { + let mut i = 0usize; + while t_running.load(Ordering::Relaxed) { + let txt = t_text.lock().unwrap().clone(); + eprint!("\r\x1b[2K{} {t_tag}{txt}", FRAMES[i % FRAMES.len()]); + let _ = std::io::stderr().flush(); + i += 1; + std::thread::sleep(std::time::Duration::from_millis(80)); + } + }); + + Self { + text, + prefix_tag, + running, + handle: Some(handle), + debug, + } + } + + pub fn set_text(&self, t: &str) { + if self.debug { + eprint!("\u{25B8} {}{t}\n", self.prefix_tag); + return; + } + *self.text.lock().unwrap() = t.to_string(); + } + + pub fn succeed(&self, msg: &str) { + self.stop_thread(); + eprint!("\r\x1b[2K\u{2714} {}{msg}\n", self.prefix_tag); + } + + pub fn fail(&self, msg: &str) { + self.stop_thread(); + eprint!("\r\x1b[2K\u{2716} {}{msg}\n", self.prefix_tag); + } + + pub fn stop(&self) { + self.stop_thread(); + eprint!("\r\x1b[2K"); + let _ = std::io::stderr().flush(); + } + + fn stop_thread(&self) { + self.running.store(false, Ordering::Relaxed); + } +} + +impl Drop for Spinner { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} diff --git a/rust-sandlot/src/state.rs b/rust-sandlot/src/state.rs new file mode 100644 index 0000000..14deaaa --- /dev/null +++ b/rust-sandlot/src/state.rs @@ -0,0 +1,160 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub branch: String, + pub worktree: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_review: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct State { + pub sessions: HashMap, +} + +#[derive(Debug, Clone)] +pub struct GlobalSession { + pub session: Session, + pub repo_root: String, +} + +fn state_path(repo_root: &str) -> PathBuf { + Path::new(repo_root).join(".sandlot").join("state.json") +} + +pub async fn load(repo_root: &str) -> State { + let path = state_path(repo_root); + match tokio::fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => State::default(), + } +} + +pub async fn save(repo_root: &str, state: &State) -> Result<()> { + let path = state_path(repo_root); + let dir = Path::new(repo_root).join(".sandlot"); + tokio::fs::create_dir_all(&dir).await?; + // Ensure dir exists via .gitkeep + let gitkeep = dir.join(".gitkeep"); + if !gitkeep.exists() { + tokio::fs::write(&gitkeep, "").await.ok(); + } + let tmp_path = format!("{}.tmp", path.display()); + let json = serde_json::to_string_pretty(state)? + "\n"; + tokio::fs::write(&tmp_path, &json).await?; + tokio::fs::rename(&tmp_path, &path).await?; + Ok(()) +} + +pub async fn get_session(repo_root: &str, branch: &str) -> Option { + let state = load(repo_root).await; + state.sessions.get(branch).cloned() +} + +pub async fn set_session(repo_root: &str, session: Session) -> Result<()> { + let mut state = load(repo_root).await; + state.sessions.insert(session.branch.clone(), session); + save(repo_root, &state).await +} + +pub async fn remove_session(repo_root: &str, branch: &str) -> Result<()> { + let mut state = load(repo_root).await; + state.sessions.remove(branch); + save(repo_root, &state).await +} + +/// Discover all sessions across all repos by scanning ~/.sandlot/ +pub async fn load_all() -> Vec { + let home = match dirs::home_dir() { + Some(h) => h, + None => return vec![], + }; + let sandlot_dir = home.join(".sandlot"); + let mut all = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + let mut repo_dirs = match tokio::fs::read_dir(&sandlot_dir).await { + Ok(rd) => rd, + Err(_) => return vec![], + }; + + while let Ok(Some(entry)) = repo_dirs.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + + let repo_dir = sandlot_dir.join(&name); + let mut repo_root: Option = None; + + // Find the main repo root from a worktree's .git pointer + let mut branch_entries = match tokio::fs::read_dir(&repo_dir).await { + Ok(be) => be, + Err(_) => continue, + }; + + while let Ok(Some(be)) = branch_entries.next_entry().await { + let be_name = be.file_name().to_string_lossy().to_string(); + if be_name.starts_with('.') { + continue; + } + let be_ft = match be.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !be_ft.is_dir() { + continue; + } + + let dot_git = repo_dir.join(&be_name).join(".git"); + if let Ok(content) = tokio::fs::read_to_string(&dot_git).await { + if let Some(m) = regex::Regex::new(r"(?m)^gitdir:\s*(.+)") + .ok() + .and_then(|re| re.captures(&content)) + .and_then(|c| c.get(1)) + { + let gitdir = m.as_str().trim(); + // gitdir: /path/to/repo/.git/worktrees/ + let main_git = regex::Regex::new(r"/worktrees/[^/]+$") + .unwrap() + .replace(gitdir, ""); + let main_git_path = Path::new(main_git.as_ref()); + if let Some(parent) = main_git_path.parent() { + repo_root = Some(parent.to_string_lossy().to_string()); + } + break; + } + } + } + + if let Some(ref root) = repo_root { + if seen.contains(root) { + continue; + } + seen.insert(root.clone()); + let st = load(root).await; + for session in st.sessions.into_values() { + all.push(GlobalSession { + session, + repo_root: root.clone(), + }); + } + } + } + + all +} diff --git a/rust-sandlot/src/vm.rs b/rust-sandlot/src/vm.rs new file mode 100644 index 0000000..47b2731 --- /dev/null +++ b/rust-sandlot/src/vm.rs @@ -0,0 +1,908 @@ +use anyhow::{Result, bail}; +use std::path::Path; +use tokio::process::Command; +use uuid::Uuid; + +const CONTAINER_NAME: &str = "sandlot"; +const USER: &str = "ubuntu"; +const CLAUDE_BIN: &str = "/home/ubuntu/.local/bin/claude"; +const CONTAINER_PATH: &str = "/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +const CONTAINER_ENV: &[(&str, &str)] = &[ + ("RUSTUP_HOME", "/sandlot/.rustup"), + ("CARGO_HOME", "/sandlot/.cargo"), + ("GOROOT", "/sandlot/.go"), + ("GOPATH", "/sandlot/.gopath"), + ("RUSTC_WRAPPER", "/sandlot/.cargo/bin/sccache"), + ("SCCACHE_DIR", "/sandlot/.sccache"), +]; + +fn debug_mode() -> bool { + std::env::var("DEBUG").is_ok_and(|v| !v.is_empty()) +} + +fn home_dir() -> String { + dirs::home_dir() + .expect("cannot find home directory") + .to_string_lossy() + .to_string() +} + +fn cache_dir() -> String { + format!("{}/.sandlot/.cache", home_dir()) +} + +/// Translate a host path to its corresponding container path. +pub fn container_path(host_path: &str) -> String { + let home = home_dir(); + let sandlot_prefix = format!("{home}/.sandlot"); + let dev_prefix = format!("{home}/dev"); + let code_prefix = format!("{home}/code"); + if host_path.starts_with(&sandlot_prefix) { + return format!("/sandlot{}", &host_path[sandlot_prefix.len()..]); + } + if host_path.starts_with(&dev_prefix) { + return format!("/host/dev{}", &host_path[dev_prefix.len()..]); + } + if host_path.starts_with(&code_prefix) { + return format!("/host/code{}", &host_path[code_prefix.len()..]); + } + host_path.to_string() +} + +fn require_container() { + if which::which("container").is_err() { + eprintln!("\u{2716} Apple Container is not installed. Install it with: brew install container"); + std::process::exit(1); + } +} + +/// Run a shell command, returning error on failure. +async fn run(args: &[&str], step: &str) -> Result<()> { + let mut cmd = Command::new(args[0]); + cmd.args(&args[1..]); + if !debug_mode() { + cmd.stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + } + let output = cmd.output().await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "(no output)".to_string() + }; + bail!("{step} failed (exit {}):\n{detail}", output.status.code().unwrap_or(1)); + } + Ok(()) +} + +/// Check which host source directories exist. +fn host_mounts(home: &str) -> (bool, bool) { + let dev = Path::new(&format!("{home}/dev")).exists(); + let code = Path::new(&format!("{home}/code")).exists(); + (dev, code) +} + +/// Check whether the package cache is populated. +async fn has_cached_tooling() -> bool { + let cache = cache_dir(); + for f in &["bun", "claude", "neofetch", "nvim.tar.gz"] { + if !Path::new(&format!("{cache}/{f}")).exists() { + return false; + } + } + true +} + +async fn create_container(home: &str) -> Result<()> { + let (dev, code) = host_mounts(home); + let memory = match crate::config::get_memory().await { + Some(m) => match crate::config::validate_memory(&m) { + Ok(v) => v, + Err(e) => { + crate::fmt::info(&format!("Invalid memory config, using default: {e}")); + crate::config::DEFAULTS_MEMORY.to_string() + } + }, + None => crate::config::DEFAULTS_MEMORY.to_string(), + }; + let mut args: Vec = vec![ + "container".into(), "run".into(), "-d".into(), + "--name".into(), CONTAINER_NAME.into(), + "-m".into(), memory, + ]; + if dev { + args.push("--mount".into()); + args.push(format!( + "type=bind,source={home}/dev,target=/host/dev,readonly" + )); + } + if code { + args.push("--mount".into()); + args.push(format!( + "type=bind,source={home}/code,target=/host/code,readonly" + )); + } + args.push("-v".into()); + args.push(format!("{home}/.sandlot:/sandlot")); + args.push("ubuntu:24.04".into()); + args.push("sleep".into()); + args.push("infinity".into()); + + let mut cmd = Command::new(&args[0]); + cmd.args(&args[1..]); + if !debug_mode() { + cmd.stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + } + let output = cmd.output().await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + bail!( + "Container creation failed (exit {}):\n{}", + output.status.code().unwrap_or(1), + if !stderr.is_empty() { stderr } else if !stdout.is_empty() { stdout } else { "(no output)".to_string() } + ); + } + Ok(()) +} + +async fn install_packages(cached: bool) -> Result<()> { + let packages = if cached { + "curl git fish build-essential" + } else { + "curl git fish unzip build-essential" + }; + run( + &[ + "container", "exec", CONTAINER_NAME, "bash", "-c", + &format!("apt update && apt install -y {packages}"), + ], + "Package installation", + ) + .await +} + +async fn create_host_symlinks(home: &str) -> Result<()> { + let (dev, code) = host_mounts(home); + let mut cmds = vec![ + format!("mkdir -p '{home}'"), + format!("ln -s /sandlot '{home}/.sandlot'"), + ]; + if dev { + cmds.push(format!("ln -s /host/dev '{home}/dev'")); + } + if code { + cmds.push(format!("ln -s /host/code '{home}/code'")); + } + run( + &[ + "container", "exec", CONTAINER_NAME, "bash", "-c", + &cmds.join(" && "), + ], + "Symlink creation", + ) + .await +} + +async fn install_tooling(cached: bool, log: &dyn Fn(&str)) -> Result<()> { + // Ensure cache directory exists + tokio::fs::create_dir_all(cache_dir()).await.ok(); + + if cached { + log("Installing packages (cached)"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", "mkdir -p ~/.local/bin", + ], + "Create bin directory", + ) + .await?; + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx", + ], + "Install cached binaries", + ) + .await?; + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1", + ], + "Install cached Neovim", + ) + .await?; + return Ok(()); + } + + log("Installing Bun"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "env", &format!("BUN_INSTALL=/home/{USER}/.local"), + "bash", "-c", "curl -fsSL https://bun.sh/install | bash", + ], + "Bun installation", + ) + .await?; + + log("Installing Claude Code"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash", + ], + "Claude Code installation", + ) + .await?; + + log("Installing neofetch"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch", + ], + "neofetch installation", + ) + .await?; + + log("Installing Neovim"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1", + ], + "Neovim installation", + ) + .await?; + + // Cache binaries + let _ = Command::new("container") + .args([ + "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + install_persistent_tooling(log).await?; + Ok(()) +} + +async fn install_persistent_tooling(log: &dyn Fn(&str)) -> Result<()> { + // Rust + let has_rust = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/rustc"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + if !has_rust.success() { + log("Installing Rust"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "env", + &format!("RUSTUP_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1), + &format!("CARGO_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1), + "bash", "-c", + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y", + ], + "Rust installation", + ) + .await?; + // Add musl target + let cargo_home = CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1; + let rustup_home = CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1; + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "env", + &format!("RUSTUP_HOME={rustup_home}"), + &format!("CARGO_HOME={cargo_home}"), + &format!("PATH={cargo_home}/bin:$PATH"), + "rustup", "target", "add", "aarch64-unknown-linux-musl", + ], + "Rust musl target", + ) + .await?; + } + + // Cargo config + let has_cargo_config = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-f", "/sandlot/.cargo/config.toml"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + if !has_cargo_config.success() { + let cargo_config = r#"[target.aarch64-unknown-linux-musl]\nlinker = "rust-lld"\n\n[build]\ntarget = "aarch64-unknown-linux-musl"\n"#; + let _ = Command::new("container") + .args([ + "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + &format!("echo -e '{cargo_config}' > /sandlot/.cargo/config.toml"), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + } + + // sccache + let has_sccache = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/sccache"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + if !has_sccache.success() { + log("Installing sccache"); + let sccache_version = "v0.14.0"; + let sccache_archive = format!("sccache-{sccache_version}-aarch64-unknown-linux-musl.tar.gz"); + let sccache_url = format!("https://github.com/mozilla/sccache/releases/download/{sccache_version}/{sccache_archive}"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + &format!( + "curl -fsSL {sccache_url} | tar xz -C /tmp && cp /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl/sccache /sandlot/.cargo/bin/sccache && chmod +x /sandlot/.cargo/bin/sccache && rm -rf /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl" + ), + ], + "sccache installation", + ) + .await?; + let _ = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.sccache"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + } + + // Go + let has_go = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.go/bin/go"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + if !has_go.success() { + log("Installing Go"); + run( + &[ + "container", "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "mkdir -p /sandlot/.go && curl -fsSL https://go.dev/dl/go1.24.1.linux-arm64.tar.gz | tar xz -C /sandlot/.go --strip-components=1", + ], + "Go installation", + ) + .await?; + let _ = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.gopath"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + } + + Ok(()) +} + +async fn install_script(home: &str, name: &str, content: &str) -> Result<()> { + let tmp = format!("{home}/.sandlot/.{name}.tmp"); + tokio::fs::write(&tmp, content).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?; + } + let _ = Command::new("container") + .args([ + "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + &format!("cp /sandlot/.{name}.tmp ~/.local/bin/{name}"), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + tokio::fs::remove_file(&tmp).await.ok(); + Ok(()) +} + +async fn configure_environment(home: &str, api_key: &str) -> Result<()> { + // Git identity + let git_name = Command::new("git") + .args(["config", "user.name"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + let git_email = Command::new("git") + .args(["config", "user.email"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + if !git_name.is_empty() { + let _ = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.name", &git_name]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + } + if !git_email.is_empty() { + let _ = Command::new("container") + .args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.email", &git_email]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + } + + // Claude settings + let activity_bin = format!("/home/{USER}/.local/bin/sandlot-activity"); + let hooks = serde_json::json!({ + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}], + "PreToolUse": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}], + }); + let status_line = serde_json::json!({ + "type": "command", + "command": format!("/home/{USER}/.local/bin/sandlot-statusline"), + }); + let settings = serde_json::json!({ + "apiKeyHelper": "~/.claude/api-key-helper.sh", + "skipDangerousModePermissionPrompt": true, + "hooks": hooks, + "statusLine": status_line, + }); + let claude_json = serde_json::json!({ + "hasCompletedOnboarding": true, + "effortCalloutDismissed": true, + "projects": { "/": { "hasTrustDialogAccepted": true } }, + }); + let settings_json = serde_json::to_string(&settings)?; + let claude_json_str = serde_json::to_string(&claude_json)?; + + // API key helper (write to temp file so key never appears in ps) + let escaped_key = api_key.replace('\'', "'\\''"); + let tmp = format!("{home}/.sandlot/.api-key-helper.tmp"); + tokio::fs::write(&tmp, format!("#!/bin/sh\necho '{escaped_key}'\n")).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?; + } + let _ = Command::new("container") + .args([ + "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + "mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + tokio::fs::remove_file(&tmp).await.ok(); + + // Activity hook script + install_script( + home, + "sandlot-activity", + "#!/bin/bash\nP=\"${CLAUDE_PROJECT_DIR%/}\"\necho \"$1\" > \"$(dirname \"$P\")/.activity-$(basename \"$P\")\"\n", + ) + .await?; + + // Statusline script + install_script( + home, + "sandlot-statusline", + "#!/bin/bash\ninput=$(cat)\ncwd=$(echo \"$input\" | grep -oP '\"cwd\"\\s*:\\s*\"\\K[^\"]+' | head -1)\n[ -n \"$cwd\" ] && printf '\\033[36m\u{2387} %s\\033[0m\\n' \"$(basename \"$cwd\")\"\n", + ) + .await?; + + // Write Claude settings + let _ = Command::new("container") + .args([ + "exec", "--user", USER, CONTAINER_NAME, + "bash", "-c", + &format!( + "mkdir -p ~/.claude\necho '{settings_json}' > ~/.claude/settings.json\necho '{claude_json_str}' > ~/.claude.json\n" + ), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + Ok(()) +} + +// ── Public API ────────────────────────────────────────────────────── + +/// Create and provision the container from scratch. +pub async fn create(log: &dyn Fn(&str)) -> Result<()> { + require_container(); + let api_key = crate::env::require_api_key().await; + + let s = status().await; + if s != "missing" { + bail!("Container already exists. Use 'sandlot vm destroy' first to recreate it."); + } + + let home = home_dir(); + let cached = has_cached_tooling().await; + + log("Pulling image & creating container"); + create_container(&home).await?; + + log("Installing packages"); + install_packages(cached).await?; + create_host_symlinks(&home).await?; + + install_tooling(cached, log).await?; + + log("Configuring environment"); + configure_environment(&home, &api_key).await?; + + Ok(()) +} + +/// Start a stopped container. +pub async fn start() -> Result<()> { + require_container(); + let s = status().await; + if s == "running" { + return Ok(()); + } + if s == "missing" { + bail!("Container does not exist. Use 'sandlot vm create' first."); + } + run(&["container", "start", CONTAINER_NAME], "Container start").await +} + +/// Ensure the sandlot container exists and is running. +pub async fn ensure(log: &dyn Fn(&str)) -> Result<()> { + require_container(); + crate::env::require_api_key().await; + + // Ensure container daemon is running + let mut cmd = Command::new("container"); + cmd.args(["system", "start", "--enable-kernel-install"]); + if !debug_mode() { + cmd.stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + } + let _ = cmd.output().await; + + let s = status().await; + if s == "running" { + return Ok(()); + } + if s == "stopped" { + return start().await; + } + + create(log).await +} + +/// Check container status. +pub async fn status() -> &'static str { + let output = Command::new("container") + .args(["list", "--format", "json", "--all"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + let output = match output { + Ok(o) => o, + Err(_) => return "missing", + }; + + let text = String::from_utf8_lossy(&output.stdout); + let containers: Vec = match serde_json::from_str(text.trim()) { + Ok(v) => v, + Err(_) => return "missing", + }; + + for c in &containers { + if c.get("configuration") + .and_then(|cfg| cfg.get("id")) + .and_then(|id| id.as_str()) + == Some(CONTAINER_NAME) + { + let status_str = c + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_lowercase(); + return if status_str == "running" { + "running" + } else { + "stopped" + }; + } + } + + "missing" +} + +/// Launch claude in the container at the given workdir. +pub fn claude<'a>( + workdir: &'a str, + prompt: Option<&'a str>, + print: Option<&'a str>, + continue_session: bool, +) -> std::pin::Pin)>> + Send + 'a>> { + Box::pin(async move { + let cwd = container_path(workdir); + let home = home_dir(); + let (dev, code) = host_mounts(&home); + let mut system_prompt_lines = vec![ + "You are running inside a sandlot container (Apple Container, ubuntu:24.04).".to_string(), + format!("Your working directory is {cwd}, a git worktree managed by sandlot."), + ]; + if dev { + system_prompt_lines.push("The host's ~/dev is mounted read-only at /host/dev.".to_string()); + } + if code { + system_prompt_lines.push("The host's ~/code is mounted read-only at /host/code.".to_string()); + } + system_prompt_lines.push("The host's ~/.sandlot is mounted at /sandlot.".to_string()); + system_prompt_lines.push("Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.".to_string()); + system_prompt_lines.push("Rust (cargo/rustc) is installed at /sandlot/.cargo/. Go is installed at /sandlot/.go/. sccache is configured as RUSTC_WRAPPER for build caching.".to_string()); + if print.is_some() { + system_prompt_lines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.".to_string()); + } + let system_prompt = system_prompt_lines.join("\n"); + + let term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); + let mut env_args: Vec = vec![ + format!("TERM={term}"), + format!("PATH={CONTAINER_PATH}"), + ]; + for (k, v) in CONTAINER_ENV { + env_args.push(format!("{k}={v}")); + } + + let mut args: Vec = vec![ + "container".into(), "exec".into(), "-it".into(), + "--user".into(), USER.into(), + "--workdir".into(), cwd.clone(), + CONTAINER_NAME.into(), "env".into(), + ]; + args.extend(env_args); + args.extend([ + CLAUDE_BIN.into(), + "--dangerously-skip-permissions".into(), + "--model".into(), "claude-opus-4-6".into(), + "--effort".into(), "max".into(), + "--append-system-prompt".into(), system_prompt, + ]); + if continue_session { + args.push("--continue".into()); + } + if let Some(p) = print { + args.push("-p".into()); + args.push(p.into()); + } else if let Some(p) = prompt { + args.push(p.into()); + } + + if print.is_some() { + let mut cmd = std::process::Command::new(&args[0]); + cmd.args(&args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()); + let child = cmd.spawn()?; + let output = child.wait_with_output()?; + let exit_code = output.status.code().unwrap_or(1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + if exit_code != 0 && continue_session { + crate::fmt::info("Retrying without --continue"); + return claude(workdir, prompt, print, false).await; + } + return Ok((exit_code, Some(stdout))); + } + + let mut cmd = std::process::Command::new(&args[0]); + cmd.args(&args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + let status = cmd.spawn()?.wait()?; + let exit_code = status.code().unwrap_or(1); + + if exit_code != 0 && continue_session { + crate::fmt::info("Retrying without --continue"); + return claude(workdir, prompt, print, false).await; + } + Ok((exit_code, None)) + }) +} + +/// Open an interactive fish shell in the container. +pub async fn shell(workdir: Option<&str>) -> Result<()> { + let mut args: Vec = vec![ + "container".into(), "exec".into(), "-it".into(), + "--user".into(), USER.into(), + ]; + if let Some(wd) = workdir { + args.push("--workdir".into()); + args.push(container_path(wd)); + } + let mut env_args: Vec = vec![ + "TERM=xterm-256color".into(), + format!("PATH={CONTAINER_PATH}"), + ]; + for (k, v) in CONTAINER_ENV { + env_args.push(format!("{k}={v}")); + } + args.push(CONTAINER_NAME.into()); + args.push("env".into()); + args.extend(env_args); + args.push("fish".into()); + args.push("--login".into()); + + let status = std::process::Command::new(&args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()? + .wait()?; + let _ = status; + Ok(()) +} + +/// Run neofetch in the container. +pub async fn neofetch() -> Result<()> { + let mut env_args: Vec = vec![format!("PATH={CONTAINER_PATH}")]; + for (k, v) in CONTAINER_ENV { + env_args.push(format!("{k}={v}")); + } + let mut args: Vec = vec![ + "container".into(), "exec".into(), + "--user".into(), USER.into(), + CONTAINER_NAME.into(), "env".into(), + ]; + args.extend(env_args); + args.push("neofetch".into()); + + let status = std::process::Command::new(&args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()? + .wait()?; + let _ = status; + Ok(()) +} + +/// Run a bash command in the container at the given workdir, capturing output. +pub async fn exec(workdir: &str, command: &str) -> (i32, String, String) { + let env_exports: String = CONTAINER_ENV + .iter() + .map(|(k, v)| format!("export {k}={v}")) + .collect::>() + .join("; "); + let full_cmd = format!("export PATH={CONTAINER_PATH}; {env_exports}; {command}"); + let output = Command::new("container") + .args([ + "exec", "--user", USER, + "--workdir", &container_path(workdir), + CONTAINER_NAME, "bash", "-c", &full_cmd, + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + + match output { + Ok(o) => ( + o.status.code().unwrap_or(1), + String::from_utf8_lossy(&o.stdout).trim().to_string(), + String::from_utf8_lossy(&o.stderr).trim().to_string(), + ), + Err(_) => (1, String::new(), String::new()), + } +} + +/// Pipe input text to Claude in the container with a prompt, returning the output. +pub async fn claude_pipe(input: &str, prompt: &str) -> (i32, String, String) { + let tmp_name = format!(".claude-pipe-{}", Uuid::new_v4()); + let home = home_dir(); + let tmp_path = format!("{home}/.sandlot/{tmp_name}"); + tokio::fs::write(&tmp_path, input).await.ok(); + let escaped_prompt = prompt.replace('"', "\\\""); + let result = exec( + &format!("{home}/.sandlot"), + &format!( + "cat /sandlot/{tmp_name} | claude --model claude-opus-4-6 --effort max -p \"{escaped_prompt}\"" + ), + ) + .await; + tokio::fs::remove_file(&tmp_path).await.ok(); + result +} + +/// Check if Claude is actively working in the given worktree. +pub async fn is_claude_active(worktree: &str, branch: &str) -> bool { + let parent = Path::new(worktree).parent().unwrap_or(Path::new(".")); + let file = parent.join(format!(".activity-{branch}")); + match tokio::fs::read_to_string(&file).await { + Ok(content) => content.trim() == "active", + Err(_) => false, + } +} + +/// Set the activity marker for a worktree. +pub async fn set_activity(worktree: &str, branch: &str) { + let parent = Path::new(worktree).parent().unwrap_or(Path::new(".")); + let file = parent.join(format!(".activity-{branch}")); + tokio::fs::write(&file, "active\n").await.ok(); +} + +/// Remove the activity marker file for a worktree. +pub async fn clear_activity(worktree: &str, branch: &str) { + let parent = Path::new(worktree).parent().unwrap_or(Path::new(".")); + let file = parent.join(format!(".activity-{branch}")); + tokio::fs::remove_file(&file).await.ok(); +} + +/// Stop the container. +pub async fn stop() -> Result<()> { + let _ = Command::new("container") + .args(["stop", CONTAINER_NAME]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + Ok(()) +} + +/// Stop and delete the container. +pub async fn destroy() -> Result<()> { + stop().await?; + let _ = Command::new("container") + .args(["delete", CONTAINER_NAME]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + Ok(()) +} + +/// Clear the package cache. +pub async fn clear_cache() -> bool { + let cache = cache_dir(); + let existed = Path::new(&format!("{cache}/bun")).exists(); + tokio::fs::remove_dir_all(&cache).await.ok(); + existed +}