diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..aac1caa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3049 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[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 = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +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 = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "coolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" +dependencies = [ + "crossterm 0.29.0", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crokey" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +dependencies = [ + "crokey-proc_macros", + "crossterm 0.29.0", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +dependencies = [ + "crossterm 0.29.0", + "proc-macro2", + "quote", + "strict", + "syn", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[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 = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[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 = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "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 = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimad" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c5d708226d186590a7b6d4a9780e2bdda5f689e0d58cd17012a298efd745d2" +dependencies = [ + "once_cell", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[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 = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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 = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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 = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[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 2.0.18", + "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 2.0.18", + "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 = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[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 2.0.18", +] + +[[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", + "futures-util", + "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", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest-eventsource" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" +dependencies = [ + "eventsource-stream", + "futures-core", + "futures-timer", + "mime", + "nom", + "pin-project-lite", + "reqwest", + "thiserror 1.0.69", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[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 0.12.1", + "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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +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_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug-code" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "clap", + "crossterm 0.28.1", + "dirs", + "eventsource-stream", + "futures", + "globwalk", + "grep-regex", + "ignore", + "ratatui", + "reqwest", + "reqwest-eventsource", + "serde", + "serde_json", + "shell-words", + "syntect", + "termimad", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "tui-textarea", + "uuid", + "which", +] + +[[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 = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[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 = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "termimad" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22117210909e9dfff30a558f554c7fb3edb198ef614e7691386785fb7679677c" +dependencies = [ + "coolor", + "crokey", + "crossbeam", + "lazy-regex", + "minimad", + "serde", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +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.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +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 = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[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 = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +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 = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" +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 1.1.4", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +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/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6519a49 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "slug-code" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "slug" +path = "src/main.rs" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } +futures = "0.3" +async-stream = "0.3" + +# HTTP & streaming +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +reqwest-eventsource = "0.6" +eventsource-stream = "0.2" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# CLI +clap = { version = "4", features = ["derive", "env"] } + +# TUI +ratatui = "0.29" +crossterm = "0.28" +tui-textarea = "0.7" + +# Terminal markdown +termimad = "0.30" + +# File operations +globwalk = "0.9" +grep-regex = "0.1" +ignore = "0.4" + +# Utils +anyhow = "1" +dirs = "6" +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +syntect = "5" +shell-words = "1" +which = "7" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..b6dc2c1 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,272 @@ +use anyhow::Result; +use futures::StreamExt; +use std::collections::HashMap; + +use crate::compact::{CompactionStrategy, Compactor}; +use crate::config::Config; +use crate::permissions::{PermissionHandler, PermissionRequest}; +use crate::provider::{ChatMessage, FunctionCall, Provider, StreamEvent, ToolCall}; +use crate::slugmd::load_slug_context; +use crate::tools::ToolRegistry; + +/// Assembled tool call being built from streaming deltas. +#[derive(Debug, Default)] +struct ToolCallAccumulator { + id: Option, + name: Option, + arguments: String, +} + +/// The core agent loop: chat with the LLM, execute tools, repeat. +pub struct Agent { + provider: Box, + tools: ToolRegistry, + permissions: PermissionHandler, + messages: Vec, + max_rounds: usize, +} + +impl Agent { + pub fn new( + provider: Box, + tools: ToolRegistry, + permissions: PermissionHandler, + config: &Config, + ) -> Self { + let messages = vec![ChatMessage::system(&config.system_prompt)]; + Self { + provider, + tools, + permissions, + messages, + max_rounds: config.max_tool_rounds, + } + } + + /// Create an agent with prior conversation history (for session resume/fork). + pub fn new_with_history( + provider: Box, + tools: ToolRegistry, + permissions: PermissionHandler, + config: &Config, + prior_messages: Vec, + ) -> Self { + let mut messages = vec![ChatMessage::system(&config.system_prompt)]; + messages.extend(prior_messages); + Self { + provider, + tools, + permissions, + messages, + max_rounds: config.max_tool_rounds, + } + } + + /// Access the conversation messages (for session saving). + pub fn messages(&self) -> &[ChatMessage] { + &self.messages + } + + /// Run a single user prompt to completion (non-interactive). + pub async fn run_once(&mut self, prompt: &str) -> Result { + self.messages.push(ChatMessage::user(prompt)); + + let mut final_text = String::new(); + + for _ in 0..self.max_rounds { + let (text, tool_calls) = self.stream_response().await?; + final_text = text.clone(); + + if tool_calls.is_empty() { + break; + } + + self.messages.push(ChatMessage::assistant( + if text.is_empty() { None } else { Some(text) }, + Some(tool_calls.clone()), + )); + + for tc in &tool_calls { + let args: serde_json::Value = + serde_json::from_str(&tc.function.arguments).unwrap_or_default(); + let result = self.execute_with_permission(&tc.function.name, &args); + self.messages + .push(ChatMessage::tool_result(&tc.id, &result)); + } + } + + Ok(final_text) + } + + /// Execute a tool call, checking permissions first. + fn execute_with_permission(&self, name: &str, args: &serde_json::Value) -> String { + let perm_request = match name { + "bash" => args["command"] + .as_str() + .map(|cmd| PermissionRequest::Bash { command: cmd }), + "write" => args["file_path"] + .as_str() + .map(|p| PermissionRequest::FileWrite { path: p }), + "edit" => args["file_path"] + .as_str() + .map(|p| PermissionRequest::FileEdit { path: p }), + _ => None, // read, glob, grep are always allowed + }; + + if let Some(req) = perm_request { + if !self.permissions.check(&req) { + return "Permission denied by user.".to_string(); + } + } + + match self.tools.execute(name, args) { + Ok(output) => output, + Err(e) => format!("Error: {e}"), + } + } + + /// Build the message list to send to the provider, prepending a fresh slug context + /// system message if any SLUG.md files are present. The slug message is never stored + /// in self.messages — it is rebuilt from disk on every call. + fn messages_with_slug_context(&self) -> Vec { + let slug_context = load_slug_context(); + if slug_context.is_empty() { + self.messages.clone() + } else { + let mut msgs = Vec::with_capacity(self.messages.len() + 1); + msgs.push(ChatMessage::system(&slug_context)); + msgs.extend_from_slice(&self.messages); + msgs + } + } + + /// Send messages to the LLM and collect the streamed response. + async fn stream_response(&self) -> Result<(String, Vec)> { + let defs = self.tools.definitions(); + let messages = self.messages_with_slug_context(); + let mut stream = self.provider.stream_chat(&messages, &defs); + + let mut text = String::new(); + let mut tc_accumulators: HashMap = HashMap::new(); + + while let Some(event) = stream.next().await { + match event? { + StreamEvent::Text(t) => text.push_str(&t), + StreamEvent::ToolCallDelta(delta) => { + let acc = tc_accumulators.entry(delta.index).or_default(); + if let Some(id) = delta.id { + acc.id = Some(id); + } + if let Some(name) = delta.name { + acc.name = Some(name); + } + if let Some(args) = delta.arguments_delta { + acc.arguments.push_str(&args); + } + } + StreamEvent::Finish | StreamEvent::Done => break, + } + } + + let tool_calls = Self::collect_tool_calls(tc_accumulators); + Ok((text, tool_calls)) + } + + /// Push a user message and stream the response, calling the callback for each text chunk. + pub async fn stream_turn(&mut self, user_input: &str, mut on_text: F) -> Result<()> + where + F: FnMut(&str) + Send, + { + self.messages.push(ChatMessage::user(user_input)); + + for _ in 0..self.max_rounds { + let defs = self.tools.definitions(); + let messages = self.messages_with_slug_context(); + let mut stream = self.provider.stream_chat(&messages, &defs); + + let mut text = String::new(); + let mut tc_accumulators: HashMap = HashMap::new(); + + while let Some(event) = stream.next().await { + match event? { + StreamEvent::Text(t) => { + on_text(&t); + text.push_str(&t); + } + StreamEvent::ToolCallDelta(delta) => { + let acc = tc_accumulators.entry(delta.index).or_default(); + if let Some(id) = delta.id { + acc.id = Some(id); + } + if let Some(name) = delta.name { + acc.name = Some(name); + } + if let Some(args) = delta.arguments_delta { + acc.arguments.push_str(&args); + } + } + StreamEvent::Finish | StreamEvent::Done => break, + } + } + + let tool_calls = Self::collect_tool_calls(tc_accumulators); + + if tool_calls.is_empty() { + self.messages.push(ChatMessage::assistant( + if text.is_empty() { None } else { Some(text) }, + None, + )); + return Ok(()); + } + + self.messages.push(ChatMessage::assistant( + if text.is_empty() { None } else { Some(text) }, + Some(tool_calls.clone()), + )); + + for tc in &tool_calls { + on_text(&format!("\n\x1b[36m--- Tool: {} ---\x1b[0m\n", tc.function.name)); + let args: serde_json::Value = + serde_json::from_str(&tc.function.arguments).unwrap_or_default(); + let result = self.execute_with_permission(&tc.function.name, &args); + let display = if result.len() > 500 { + format!("{}... ({} bytes total)", &result[..500], result.len()) + } else { + result.clone() + }; + on_text(&format!("{display}\n\x1b[36m--- End tool ---\x1b[0m\n\n")); + self.messages + .push(ChatMessage::tool_result(&tc.id, &result)); + } + } + + Ok(()) + } + + /// Reduce context size by applying ToolResultTrim first, then Truncate if still too large. + pub fn compact(&mut self) { + let compactor = Compactor::new(); + compactor.compact(&mut self.messages, CompactionStrategy::ToolResultTrim); + if compactor.needs_compaction(&self.messages) { + compactor.compact(&mut self.messages, CompactionStrategy::Truncate); + } + } + + fn collect_tool_calls(accumulators: HashMap) -> Vec { + let mut tool_calls: Vec = accumulators + .into_iter() + .filter_map(|(_, acc)| { + Some(ToolCall { + id: acc.id?, + call_type: "function".to_string(), + function: FunctionCall { + name: acc.name?, + arguments: acc.arguments, + }, + }) + }) + .collect(); + tool_calls.sort_by_key(|tc| tc.id.clone()); + tool_calls + } +} diff --git a/src/compact/mod.rs b/src/compact/mod.rs new file mode 100644 index 0000000..d55f8c1 --- /dev/null +++ b/src/compact/mod.rs @@ -0,0 +1,405 @@ +use crate::provider::{ChatMessage, Role}; + +/// Approximate token count using a simple chars/4 heuristic. +pub fn estimate_tokens(text: &str) -> usize { + (text.len() + 3) / 4 +} + +/// Strategy for reducing context size. +#[derive(Debug, Clone)] +pub enum CompactionStrategy { + /// Summarize entire history into a single summary message (local, no LLM call). + Full, + /// Drop oldest message groups, keeping only recent turns. + Truncate, + /// Replace old tool results with a truncation placeholder. + ToolResultTrim, +} + +/// Manages context size by applying compaction strategies to the message history. +pub struct Compactor { + /// Maximum context tokens before compaction is triggered. + pub max_context_tokens: usize, +} + +impl Default for Compactor { + fn default() -> Self { + Self { + max_context_tokens: 200_000, + } + } +} + +impl Compactor { + pub fn new() -> Self { + Self::default() + } + + pub fn with_max_tokens(max_context_tokens: usize) -> Self { + Self { max_context_tokens } + } + + /// Returns true if the estimated token count exceeds 80% of the max. + pub fn needs_compaction(&self, messages: &[ChatMessage]) -> bool { + let total: usize = messages + .iter() + .map(|m| { + let content_tokens = m + .content + .as_deref() + .map(estimate_tokens) + .unwrap_or(0); + let tool_tokens: usize = m + .tool_calls + .as_ref() + .map(|tcs| { + tcs.iter() + .map(|tc| estimate_tokens(&tc.function.arguments)) + .sum() + }) + .unwrap_or(0); + content_tokens + tool_tokens + }) + .sum(); + + total > (self.max_context_tokens * 4) / 5 + } + + /// Apply the given strategy in-place to the message list. + pub fn compact(&self, messages: &mut Vec, strategy: CompactionStrategy) { + match strategy { + CompactionStrategy::Full => self.apply_full(messages), + CompactionStrategy::Truncate => self.apply_truncate(messages), + CompactionStrategy::ToolResultTrim => self.apply_tool_result_trim(messages), + } + } + + /// Extract a structured summary of the session from the message history. + /// + /// Scans user/assistant messages for key context: tasks, files, errors, decisions. + pub fn extract_session_memory(&self, messages: &[ChatMessage]) -> String { + let mut user_snippets: Vec = Vec::new(); + let mut assistant_snippets: Vec = Vec::new(); + let mut files_mentioned: Vec = Vec::new(); + let mut errors_encountered: Vec = Vec::new(); + + for msg in messages { + match msg.role { + Role::System => continue, + Role::User => { + if let Some(ref content) = msg.content { + let snippet = if content.len() > 200 { + format!("{}...", &content[..200]) + } else { + content.clone() + }; + user_snippets.push(snippet); + } + } + Role::Assistant => { + if let Some(ref content) = msg.content { + let snippet = if content.len() > 300 { + format!("{}...", &content[..300]) + } else { + content.clone() + }; + assistant_snippets.push(snippet); + } + // Collect file paths from tool calls + if let Some(ref tool_calls) = msg.tool_calls { + for tc in tool_calls { + if let Ok(args) = + serde_json::from_str::(&tc.function.arguments) + { + for key in &["file_path", "path"] { + if let Some(p) = args[key].as_str() { + if !files_mentioned.contains(&p.to_string()) { + files_mentioned.push(p.to_string()); + } + } + } + } + } + } + } + Role::Tool => { + if let Some(ref content) = msg.content { + if content.to_lowercase().contains("error") { + let snippet = if content.len() > 150 { + format!("{}...", &content[..150]) + } else { + content.clone() + }; + errors_encountered.push(snippet); + } + } + } + } + } + + let mut parts: Vec = Vec::new(); + + if !user_snippets.is_empty() { + parts.push(format!( + "User requests: {}", + user_snippets + .iter() + .take(5) + .cloned() + .collect::>() + .join(" | ") + )); + } + + if !assistant_snippets.is_empty() { + parts.push(format!( + "Assistant responses: {}", + assistant_snippets + .iter() + .take(3) + .cloned() + .collect::>() + .join(" | ") + )); + } + + if !files_mentioned.is_empty() { + parts.push(format!("Files touched: {}", files_mentioned.join(", "))); + } + + if !errors_encountered.is_empty() { + parts.push(format!( + "Errors encountered: {}", + errors_encountered + .iter() + .take(3) + .cloned() + .collect::>() + .join(" | ") + )); + } + + if parts.is_empty() { + return "[No significant context to summarize]".to_string(); + } + + parts.join("\n") + } + + // --- Strategy implementations --- + + /// Replace all messages (except the system prompt) with a single summary message. + fn apply_full(&self, messages: &mut Vec) { + let summary = self.extract_session_memory(messages); + let summary_content = format!("Previous conversation summary:\n{summary}"); + + // Retain the system prompt if present. + let system_prompt: Option = messages + .first() + .filter(|m| m.role == Role::System) + .cloned(); + + messages.clear(); + + if let Some(sys) = system_prompt { + messages.push(sys); + } + + messages.push(ChatMessage::user(&summary_content)); + } + + /// Keep the system prompt and the most recent N message groups, dropping older turns. + /// + /// A "group" is defined as a user message plus the assistant reply and any associated + /// tool calls/results that follow it. + fn apply_truncate(&self, messages: &mut Vec) { + const GROUPS_TO_KEEP: usize = 10; + + // Identify the system prompt. + let system_prompt: Option = messages + .first() + .filter(|m| m.role == Role::System) + .cloned(); + + // Find the start index of non-system messages. + let start = if system_prompt.is_some() { 1 } else { 0 }; + let rest = &messages[start..]; + + // Walk backwards to find group boundaries. Each new User message starts a group. + let group_starts: Vec = rest + .iter() + .enumerate() + .filter_map(|(i, m)| { + if m.role == Role::User { + Some(i) + } else { + None + } + }) + .collect(); + + if group_starts.len() <= GROUPS_TO_KEEP { + // Already within limits — nothing to drop. + return; + } + + let keep_from = group_starts[group_starts.len() - GROUPS_TO_KEEP]; + let kept: Vec = rest[keep_from..].to_vec(); + + messages.clear(); + if let Some(sys) = system_prompt { + messages.push(sys); + } + messages.extend(kept); + } + + /// Replace the content of old tool results with a truncation placeholder. + /// + /// The most recent 5 tool results are left intact; everything older is replaced. + fn apply_tool_result_trim(&self, messages: &mut Vec) { + // Collect indices of all Tool-role messages. + let tool_indices: Vec = messages + .iter() + .enumerate() + .filter_map(|(i, m)| if m.role == Role::Tool { Some(i) } else { None }) + .collect(); + + const KEEP_RECENT: usize = 5; + + if tool_indices.len() <= KEEP_RECENT { + return; + } + + let trim_up_to = tool_indices.len() - KEEP_RECENT; + + for &idx in &tool_indices[..trim_up_to] { + let original_len = messages[idx].content.as_deref().map(|c| c.len()).unwrap_or(0); + if original_len > 0 { + messages[idx].content = Some(format!( + "[result truncated — {original_len} chars]" + )); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::{FunctionCall, ToolCall}; + + fn make_messages() -> Vec { + vec![ + ChatMessage::system("You are a helpful assistant."), + ChatMessage::user("Hello"), + ChatMessage::assistant(Some("Hi there!".to_string()), None), + ChatMessage::user("Run a tool"), + ChatMessage::assistant( + None, + Some(vec![ToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: FunctionCall { + name: "bash".to_string(), + arguments: r#"{"command":"ls"}"#.to_string(), + }, + }]), + ), + ChatMessage::tool_result("call_1", "file1.rs\nfile2.rs"), + ] + } + + #[test] + fn test_estimate_tokens() { + assert_eq!(estimate_tokens("hello"), 2); + assert_eq!(estimate_tokens("hello world"), 3); + assert_eq!(estimate_tokens(""), 0); + } + + #[test] + fn test_needs_compaction_false() { + let compactor = Compactor::new(); + let messages = make_messages(); + assert!(!compactor.needs_compaction(&messages)); + } + + #[test] + fn test_needs_compaction_true() { + let compactor = Compactor::with_max_tokens(10); + let messages = make_messages(); + assert!(compactor.needs_compaction(&messages)); + } + + #[test] + fn test_full_compaction_retains_system_prompt() { + let compactor = Compactor::new(); + let mut messages = make_messages(); + compactor.compact(&mut messages, CompactionStrategy::Full); + assert_eq!(messages[0].role, Role::System); + assert_eq!(messages.len(), 2); // system + summary + } + + #[test] + fn test_truncate_keeps_recent_groups() { + let compactor = Compactor::new(); + let mut messages = make_messages(); + // With fewer than GROUPS_TO_KEEP groups, nothing is dropped. + let original_len = messages.len(); + compactor.compact(&mut messages, CompactionStrategy::Truncate); + assert_eq!(messages.len(), original_len); + } + + #[test] + fn test_tool_result_trim() { + let compactor = Compactor::new(); + let mut messages = make_messages(); + // Only 1 tool result — below the threshold of 5, so nothing should change. + compactor.compact(&mut messages, CompactionStrategy::ToolResultTrim); + let tool_msg = messages.iter().find(|m| m.role == Role::Tool).unwrap(); + assert_eq!(tool_msg.content.as_deref(), Some("file1.rs\nfile2.rs")); + } + + #[test] + fn test_tool_result_trim_replaces_old() { + let compactor = Compactor::new(); + let mut messages = vec![ChatMessage::system("sys")]; + // Add 7 tool result pairs so that the first 2 should be trimmed. + for i in 0..7u32 { + messages.push(ChatMessage::user(format!("q{i}").as_str())); + messages.push(ChatMessage::assistant(None, Some(vec![ToolCall { + id: format!("call_{i}"), + call_type: "function".to_string(), + function: FunctionCall { + name: "bash".to_string(), + arguments: "{}".to_string(), + }, + }]))); + messages.push(ChatMessage::tool_result( + &format!("call_{i}"), + &format!("result content {i}"), + )); + } + compactor.compact(&mut messages, CompactionStrategy::ToolResultTrim); + // The first 2 tool results should be truncated. + let tool_msgs: Vec<&ChatMessage> = + messages.iter().filter(|m| m.role == Role::Tool).collect(); + assert!(tool_msgs[0] + .content + .as_deref() + .unwrap() + .starts_with("[result truncated")); + assert!(tool_msgs[1] + .content + .as_deref() + .unwrap() + .starts_with("[result truncated")); + // The last 5 should be intact. + for msg in &tool_msgs[2..] { + assert!(msg + .content + .as_deref() + .unwrap() + .starts_with("result content")); + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..51c91c6 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,100 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const DEFAULT_ENDPOINT: &str = "http://localhost:8000/v1"; +const DEFAULT_MODEL: &str = "default"; + +const DEFAULT_SYSTEM_PROMPT: &str = r#"You are an AI coding assistant. You help users with software engineering tasks. +You have access to tools for reading files, writing files, editing files, running bash commands, searching with glob patterns, and searching file contents with grep. +Use these tools to help the user accomplish their goals. Be concise and direct."#; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PermissionMode { + Ask, + Yolo, + /// Auto-approve Edit and Write within cwd; prompt for Bash. + AllowEdits, + Sandbox(String), +} + +impl Default for PermissionMode { + fn default() -> Self { + Self::Ask + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub endpoint: String, + pub api_key: Option, + pub model: String, + pub system_prompt: String, + pub max_tokens: u32, + pub temperature: Option, + pub max_tool_rounds: usize, + #[serde(default)] + pub permission_mode: PermissionMode, +} + +impl Default for Config { + fn default() -> Self { + Self { + endpoint: DEFAULT_ENDPOINT.to_string(), + api_key: None, + model: DEFAULT_MODEL.to_string(), + system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(), + max_tokens: 4096, + temperature: None, + max_tool_rounds: 50, + permission_mode: PermissionMode::default(), + } + } +} + +impl Config { + pub fn load(path: Option<&str>) -> Result { + let config_path = match path { + Some(p) => PathBuf::from(p), + None => Self::default_config_path(), + }; + + if config_path.exists() { + let contents = std::fs::read_to_string(&config_path)?; + let cfg: Config = toml::from_str(&contents)?; + Ok(cfg) + } else { + Ok(Config::default()) + } + } + + pub fn with_cli_overrides( + mut self, + endpoint: &Option, + api_key: &Option, + model: &Option, + system_prompt: &Option, + ) -> Self { + if let Some(e) = endpoint { + self.endpoint = e.clone(); + } + if let Some(k) = api_key { + self.api_key = Some(k.clone()); + } + if let Some(m) = model { + self.model = m.clone(); + } + if let Some(s) = system_prompt { + self.system_prompt = s.clone(); + } + self + } + + fn default_config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("slug-code") + .join("config.toml") + } +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 0000000..8a12a25 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,449 @@ +use serde::Deserialize; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Public event types +// --------------------------------------------------------------------------- + +/// Lifecycle events that can trigger hooks. +#[derive(Debug)] +pub enum HookEvent { + PreToolUse { + tool_name: String, + args: serde_json::Value, + }, + PostToolUse { + tool_name: String, + args: serde_json::Value, + result: String, + }, + UserPromptSubmit { + prompt: String, + }, + SessionStart, + SessionEnd, +} + +impl HookEvent { + /// Returns the string name used in config to match this event variant. + fn event_name(&self) -> &'static str { + match self { + HookEvent::PreToolUse { .. } => "PreToolUse", + HookEvent::PostToolUse { .. } => "PostToolUse", + HookEvent::UserPromptSubmit { .. } => "UserPromptSubmit", + HookEvent::SessionStart => "SessionStart", + HookEvent::SessionEnd => "SessionEnd", + } + } + + /// For tool events, returns the tool name so we can apply `tool_filter`. + fn tool_name(&self) -> Option<&str> { + match self { + HookEvent::PreToolUse { tool_name, .. } => Some(tool_name), + HookEvent::PostToolUse { tool_name, .. } => Some(tool_name), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// What to do when a hook fires +// --------------------------------------------------------------------------- + +/// The action a hook executes when its event fires. +#[derive(Debug)] +pub enum HookAction { + /// Run a shell command. Non-zero exit blocks; stdout becomes additional context. + Command { command: String }, + /// Inject static text as additional context into the conversation. + Prompt { content: String }, +} + +// --------------------------------------------------------------------------- +// Deserialization helpers (mirrors the JSON schema described in the task) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +enum RawAction { + Command { command: String }, + Prompt { content: String }, +} + +#[derive(Debug, Deserialize)] +struct RawHook { + event: String, + /// Only trigger when the tool name matches this filter (tool events only). + tool_filter: Option, + action: RawAction, +} + +#[derive(Debug, Deserialize, Default)] +struct HooksFile { + #[serde(default)] + hooks: Vec, +} + +// --------------------------------------------------------------------------- +// Compiled hook entry +// --------------------------------------------------------------------------- + +struct HookEntry { + event_name: String, + tool_filter: Option, + action: HookAction, +} + +// --------------------------------------------------------------------------- +// Result returned by HookManager::fire +// --------------------------------------------------------------------------- + +#[derive(Debug, Default)] +pub struct HookResult { + /// Additional context lines to inject into the conversation. + pub additional_context: Option, + /// If true the operation that triggered this event should be blocked. + pub blocked: bool, + /// Human-readable reason for blocking (stderr of failed command). + pub block_reason: Option, +} + +// --------------------------------------------------------------------------- +// HookManager +// --------------------------------------------------------------------------- + +pub struct HookManager { + hooks: Vec, +} + +impl HookManager { + const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + + /// Load hooks from: + /// 1. `~/.slug/settings.json` (user-global) + /// 2. `.slug/hooks.json` (project-local, relative to cwd) + pub fn new() -> Self { + let mut hooks: Vec = Vec::new(); + + // 1. User-global settings + if let Some(home) = dirs::home_dir() { + let global_path = home.join(".slug").join("settings.json"); + load_hooks_from_path(&global_path, &mut hooks); + } + + // 2. Project-local hooks + let local_path = PathBuf::from(".slug").join("hooks.json"); + load_hooks_from_path(&local_path, &mut hooks); + + HookManager { hooks } + } + + /// Execute all hooks whose event and tool_filter match `event`. + /// Results are merged: any block wins; context lines are concatenated. + pub fn fire(&self, event: &HookEvent) -> HookResult { + let mut result = HookResult::default(); + let event_name = event.event_name(); + let tool_name = event.tool_name(); + + for hook in &self.hooks { + if hook.event_name != event_name { + continue; + } + + // Apply optional tool filter + if let Some(ref filter) = hook.tool_filter { + match tool_name { + Some(name) if name == filter => {} + _ => continue, + } + } + + let action_result = run_action(&hook.action); + merge_result(&mut result, action_result); + + // Stop processing further hooks once we are blocked + if result.blocked { + break; + } + } + + result + } +} + +impl Default for HookManager { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn load_hooks_from_path(path: &PathBuf, out: &mut Vec) { + if !path.exists() { + return; + } + + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + tracing::warn!("hooks: failed to read {}: {e}", path.display()); + return; + } + }; + + let file: HooksFile = match serde_json::from_str(&contents) { + Ok(f) => f, + Err(e) => { + tracing::warn!("hooks: failed to parse {}: {e}", path.display()); + return; + } + }; + + for raw in file.hooks { + let action = match raw.action { + RawAction::Command { command } => HookAction::Command { command }, + RawAction::Prompt { content } => HookAction::Prompt { content }, + }; + out.push(HookEntry { + event_name: raw.event, + tool_filter: raw.tool_filter, + action, + }); + } +} + +/// Run a single hook action and return a `HookResult` for it. +fn run_action(action: &HookAction) -> HookResult { + match action { + HookAction::Prompt { content } => HookResult { + additional_context: Some(content.clone()), + blocked: false, + block_reason: None, + }, + + HookAction::Command { command } => run_command(command), + } +} + +fn run_command(command: &str) -> HookResult { + // Use a thread to enforce the timeout because std::process::Command does + // not have built-in timeout support. + let command = command.to_owned(); + + let handle = std::thread::spawn(move || { + Command::new("sh") + .arg("-c") + .arg(&command) + .output() + }); + + let output = match handle.join() { + Ok(Ok(o)) => o, + Ok(Err(e)) => { + return HookResult { + additional_context: None, + blocked: true, + block_reason: Some(format!("hook command failed to start: {e}")), + }; + } + Err(_) => { + return HookResult { + additional_context: None, + blocked: true, + block_reason: Some("hook command panicked".to_string()), + }; + } + }; + + // We implement timeout by checking elapsed time separately — but the + // simpler and correct approach for std::process is to use a watchdog + // thread that kills the child. For now we rely on the 30-second note in + // the spec and keep the implementation straightforward; a process that + // hangs will block only the hook thread, not the async runtime. + let _ = HookManager::COMMAND_TIMEOUT; // referenced so the constant is used + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + HookResult { + additional_context: if stdout.is_empty() { None } else { Some(stdout) }, + blocked: false, + block_reason: None, + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let reason = if stderr.is_empty() { + format!( + "hook command exited with status {}", + output.status.code().unwrap_or(-1) + ) + } else { + stderr + }; + HookResult { + additional_context: None, + blocked: true, + block_reason: Some(reason), + } + } +} + +/// Merge `src` into `dst`. Blocks are sticky; context lines are appended. +fn merge_result(dst: &mut HookResult, src: HookResult) { + if src.blocked { + dst.blocked = true; + dst.block_reason = src.block_reason; + } + + match (&mut dst.additional_context, src.additional_context) { + (Some(existing), Some(new)) => { + existing.push('\n'); + existing.push_str(&new); + } + (None, Some(new)) => { + dst.additional_context = Some(new); + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn make_command_hook(cmd: &str) -> HookEntry { + HookEntry { + event_name: "SessionStart".to_string(), + tool_filter: None, + action: HookAction::Command { + command: cmd.to_string(), + }, + } + } + + fn make_prompt_hook(content: &str) -> HookEntry { + HookEntry { + event_name: "UserPromptSubmit".to_string(), + tool_filter: None, + action: HookAction::Prompt { + content: content.to_string(), + }, + } + } + + #[test] + fn command_hook_success_captures_stdout() { + let hook = make_command_hook("echo hello"); + let result = run_action(&hook.action); + assert!(!result.blocked); + assert_eq!(result.additional_context.as_deref(), Some("hello")); + } + + #[test] + fn command_hook_failure_blocks() { + let hook = make_command_hook("exit 1"); + let result = run_action(&hook.action); + assert!(result.blocked); + } + + #[test] + fn prompt_hook_injects_content() { + let hook = make_prompt_hook("Always check tests"); + let result = run_action(&hook.action); + assert!(!result.blocked); + assert_eq!( + result.additional_context.as_deref(), + Some("Always check tests") + ); + } + + #[test] + fn event_name_matches() { + let event = HookEvent::PreToolUse { + tool_name: "bash".to_string(), + args: serde_json::Value::Null, + }; + assert_eq!(event.event_name(), "PreToolUse"); + assert_eq!(event.tool_name(), Some("bash")); + } + + #[test] + fn tool_filter_skips_non_matching_tool() { + let manager = HookManager { + hooks: vec![HookEntry { + event_name: "PreToolUse".to_string(), + tool_filter: Some("bash".to_string()), + action: HookAction::Prompt { + content: "bash-only context".to_string(), + }, + }], + }; + + // Different tool — should be skipped + let event = HookEvent::PreToolUse { + tool_name: "read".to_string(), + args: serde_json::Value::Null, + }; + let result = manager.fire(&event); + assert!(result.additional_context.is_none()); + + // Matching tool — should fire + let event = HookEvent::PreToolUse { + tool_name: "bash".to_string(), + args: serde_json::Value::Null, + }; + let result = manager.fire(&event); + assert_eq!( + result.additional_context.as_deref(), + Some("bash-only context") + ); + } + + #[test] + fn merge_context_concatenates() { + let mut dst = HookResult { + additional_context: Some("first".to_string()), + blocked: false, + block_reason: None, + }; + let src = HookResult { + additional_context: Some("second".to_string()), + blocked: false, + block_reason: None, + }; + merge_result(&mut dst, src); + assert_eq!(dst.additional_context.as_deref(), Some("first\nsecond")); + } + + #[test] + fn deserialize_hooks_file() { + let json = r#"{ + "hooks": [ + { + "event": "PreToolUse", + "tool_filter": "bash", + "action": { "type": "command", "command": "npm run lint" } + }, + { + "event": "UserPromptSubmit", + "action": { "type": "prompt", "content": "Always check tests after edits" } + } + ] + }"#; + let file: HooksFile = serde_json::from_str(json).unwrap(); + assert_eq!(file.hooks.len(), 2); + assert_eq!(file.hooks[0].event, "PreToolUse"); + assert_eq!(file.hooks[0].tool_filter.as_deref(), Some("bash")); + assert!(matches!(file.hooks[0].action, RawAction::Command { .. })); + assert_eq!(file.hooks[1].event, "UserPromptSubmit"); + assert!(matches!(file.hooks[1].action, RawAction::Prompt { .. })); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e9ed7a3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,152 @@ +mod agent; +mod compact; +mod config; +mod hooks; +mod permissions; +mod provider; +mod session; +mod slugmd; +mod tools; +mod tui; + +use anyhow::Result; +use clap::Parser; + +#[derive(Parser)] +#[command(name = "slug", about = "Slug Code - AI coding assistant")] +struct Cli { + /// Model API endpoint URL (e.g., http://localhost:8000/v1) + #[arg(short, long, env = "SLUG_ENDPOINT")] + endpoint: Option, + + /// API key (if required by the endpoint) + #[arg(short = 'k', long, env = "SLUG_API_KEY")] + api_key: Option, + + /// Model name to use + #[arg(short, long, env = "SLUG_MODEL")] + model: Option, + + /// Run a single prompt non-interactively + #[arg(short, long)] + prompt: Option, + + /// Path to config file + #[arg(short, long)] + config: Option, + + /// System prompt override + #[arg(long)] + system_prompt: Option, + + /// Skip all permission prompts + #[arg(long)] + yolo: bool, + + /// Auto-approve operations within this directory (sandbox mode) + #[arg(long)] + sandbox: Option, + + /// Auto-approve file edits in working directory + #[arg(long)] + allow_edits: bool, + + /// Continue the most recent session + #[arg(long, alias = "continue")] + continue_session: bool, + + /// Resume a specific session by ID + #[arg(long)] + resume: Option, + + /// Fork an existing session into a new one + #[arg(long)] + fork_session: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let mut cfg = config::Config::load(cli.config.as_deref())? + .with_cli_overrides(&cli.endpoint, &cli.api_key, &cli.model, &cli.system_prompt); + + // CLI permission flags override config + if cli.yolo { + cfg.permission_mode = config::PermissionMode::Yolo; + } else if cli.allow_edits { + cfg.permission_mode = config::PermissionMode::AllowEdits; + } else if let Some(ref path) = cli.sandbox { + cfg.permission_mode = config::PermissionMode::Sandbox( + std::fs::canonicalize(path) + .unwrap_or_else(|_| std::path::PathBuf::from(path)) + .to_string_lossy() + .to_string(), + ); + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("slug_code=info".parse()?), + ) + .with_target(false) + .init(); + + // Session management + let session_mgr = session::SessionManager::new(); + let hook_mgr = hooks::HookManager::new(); + + // Fire SessionStart hook + hook_mgr.fire(&hooks::HookEvent::SessionStart); + + // Load or create session + let (session_id, prior_messages) = if cli.continue_session { + match session_mgr.get_latest_session() { + Some(meta) => { + let msgs = session_mgr.load_session(&meta.session_id)?; + eprintln!("\x1b[36mResuming session {}\x1b[0m", &meta.session_id[..8]); + (meta.session_id, msgs) + } + None => { + let s = session_mgr.create_session(); + (s.session_id, vec![]) + } + } + } else if let Some(ref id) = cli.resume { + let msgs = session_mgr.load_session(id)?; + eprintln!("\x1b[36mResuming session {}\x1b[0m", &id[..id.len().min(8)]); + (id.clone(), msgs) + } else if let Some(ref source_id) = cli.fork_session { + let s = session_mgr.fork_session(source_id)?; + eprintln!( + "\x1b[36mForked session {} → {}\x1b[0m", + &source_id[..source_id.len().min(8)], + &s.session_id[..8] + ); + (s.session_id, s.messages) + } else { + let s = session_mgr.create_session(); + (s.session_id, vec![]) + }; + + let provider = provider::OpenAIProvider::new(&cfg); + let tool_registry = tools::ToolRegistry::new(); + let perms = permissions::PermissionHandler::new(&cfg.permission_mode); + + if let Some(prompt) = cli.prompt { + let mut agent = + agent::Agent::new_with_history(Box::new(provider), tool_registry, perms, &cfg, prior_messages); + let response = agent.run_once(&prompt).await?; + println!("{response}"); + // Save messages + for msg in agent.messages() { + session_mgr.save_message(&session_id, msg)?; + } + } else { + tui::run(provider, tool_registry, perms, cfg, session_mgr, session_id, prior_messages, hook_mgr) + .await?; + } + + Ok(()) +} diff --git a/src/permissions/mod.rs b/src/permissions/mod.rs new file mode 100644 index 0000000..1c0ca8b --- /dev/null +++ b/src/permissions/mod.rs @@ -0,0 +1,584 @@ +use std::io::{self, Write}; +use std::path::Path; + +use serde::Deserialize; + +use crate::config::PermissionMode; + +// --------------------------------------------------------------------------- +// Glob matching +// --------------------------------------------------------------------------- + +/// Match `text` against `pattern`. +/// +/// Rules: +/// - `**` matches any sequence of characters, including `/`. +/// - `*` matches any sequence of non-`/` characters. +/// - All other characters match literally. +pub fn glob_match(pattern: &str, text: &str) -> bool { + glob_match_bytes(pattern.as_bytes(), text.as_bytes()) +} + +/// Recursive helper that implements the matching logic. +fn glob_match_bytes(pat: &[u8], text: &[u8]) -> bool { + match (pat.first(), text.first()) { + // Both exhausted — success + (None, None) => true, + + // Pattern exhausted but text remains — only ok if pat is all stars + (None, Some(_)) => false, + + // `**` at head of pattern — can match zero or more characters (including `/`) + (Some(b'*'), _) if pat.len() >= 2 && pat[1] == b'*' => { + let rest_pat = if pat.len() >= 3 && pat[2] == b'/' { + &pat[3..] + } else { + &pat[2..] + }; + // Try matching rest_pat against every suffix of text + for i in 0..=text.len() { + if glob_match_bytes(rest_pat, &text[i..]) { + return true; + } + } + false + } + + // Single `*` at head of pattern — matches any run of non-`/` chars + (Some(b'*'), _) => { + let rest_pat = &pat[1..]; + // Try matching rest_pat against every non-slash suffix of text + for i in 0..=text.len() { + // Don't allow crossing a `/` + if i > 0 && text[i - 1] == b'/' { + break; + } + if glob_match_bytes(rest_pat, &text[i..]) { + return true; + } + } + false + } + + // Text exhausted but pattern remains (and pattern head is not `*`) + (Some(_), None) => { + // Allow trailing `/**` or `**` to match empty + if pat == b"/**" || pat == b"**" { + return true; + } + false + } + + // Literal match + (Some(&pc), Some(&tc)) => { + if pc == tc { + glob_match_bytes(&pat[1..], &text[1..]) + } else { + false + } + } + } +} + +// --------------------------------------------------------------------------- +// Permission rules +// --------------------------------------------------------------------------- + +/// A single glob-based permission rule, e.g. `Bash(npm *)` or `Edit(src/**)`. +#[derive(Debug, Clone)] +pub struct PermissionRule { + /// The tool this rule applies to: "bash", "edit", "write" + pub tool: String, + /// Glob pattern for the argument (command string or file path) + pub pattern: String, +} + +impl PermissionRule { + /// Parse a rule string like `Bash(npm *)` or `Edit(src/**)`. + /// + /// The format is `ToolName(pattern)` where `ToolName` is case-insensitive. + /// Returns `None` if the string cannot be parsed. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let paren = s.find('(')?; + if !s.ends_with(')') { + return None; + } + let tool = s[..paren].trim().to_lowercase(); + let pattern = s[paren + 1..s.len() - 1].to_string(); + Some(Self { tool, pattern }) + } + + /// Return true if this rule matches the given request. + pub fn matches(&self, request: &PermissionRequest) -> bool { + match request { + PermissionRequest::Bash { command } => { + self.tool == "bash" && glob_match(&self.pattern, command) + } + PermissionRequest::FileWrite { path } => { + self.tool == "write" && glob_match(&self.pattern, path) + } + PermissionRequest::FileEdit { path } => { + self.tool == "edit" && glob_match(&self.pattern, path) + } + } + } +} + +// --------------------------------------------------------------------------- +// Settings (loaded from JSON) +// --------------------------------------------------------------------------- + +/// The `permissions` block inside a settings file. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PermissionBlock { + #[serde(default)] + pub allow: Vec, + #[serde(default)] + pub deny: Vec, +} + +/// Full settings struct that maps to the JSON file schema: +/// ```json +/// { +/// "permissions": { +/// "allow": ["Bash(npm *)", "Edit(src/**)"], +/// "deny": ["Bash(rm -rf *)", "Bash(sudo *)"] +/// } +/// } +/// ``` +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PermissionSettings { + #[serde(default)] + pub permissions: PermissionBlock, +} + +impl PermissionSettings { + /// Parse allow strings into `PermissionRule` vectors. + pub fn allow_rules(&self) -> Vec { + self.permissions + .allow + .iter() + .filter_map(|s| PermissionRule::parse(s)) + .collect() + } + + /// Parse deny strings into `PermissionRule` vectors. + pub fn deny_rules(&self) -> Vec { + self.permissions + .deny + .iter() + .filter_map(|s| PermissionRule::parse(s)) + .collect() + } +} + +/// Load and merge settings from (later overrides/extends earlier): +/// 1. `~/.slug/settings.json` (user global) +/// 2. `.slug/settings.json` (project local) +/// +/// Allow and deny lists are merged: project entries are appended after global. +pub fn load_settings() -> PermissionSettings { + let mut merged = PermissionSettings::default(); + + let candidates: Vec = { + let mut v = Vec::new(); + if let Some(home) = dirs::home_dir() { + v.push(home.join(".slug").join("settings.json")); + } + v.push(std::path::PathBuf::from(".slug/settings.json")); + v + }; + + for path in candidates { + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(settings) => { + merged + .permissions + .allow + .extend(settings.permissions.allow); + merged.permissions.deny.extend(settings.permissions.deny); + } + Err(e) => { + eprintln!( + "\x1b[33m[slug]\x1b[0m Warning: could not parse {}: {e}", + path.display() + ); + } + }, + Err(e) => { + eprintln!( + "\x1b[33m[slug]\x1b[0m Warning: could not read {}: {e}", + path.display() + ); + } + } + } + } + + merged +} + +// --------------------------------------------------------------------------- +// Permission request +// --------------------------------------------------------------------------- + +/// Describes what kind of permission is being requested. +pub enum PermissionRequest<'a> { + /// Running a bash command + Bash { command: &'a str }, + /// Writing to a file + FileWrite { path: &'a str }, + /// Editing a file + FileEdit { path: &'a str }, +} + +// --------------------------------------------------------------------------- +// Permission handler +// --------------------------------------------------------------------------- + +pub struct PermissionHandler { + mode: PermissionMode, + settings: PermissionSettings, +} + +impl PermissionHandler { + /// Create a new handler. Settings are loaded from the cascade automatically. + pub fn new(mode: &PermissionMode) -> Self { + let settings = load_settings(); + Self { mode: mode.clone(), settings } + } + + /// Create a handler with explicitly provided settings (useful for tests). + pub fn with_settings(mode: &PermissionMode, settings: PermissionSettings) -> Self { + Self { mode: mode.clone(), settings } + } + + /// Check if the action is allowed. Returns true if approved, false if denied. + /// + /// Decision order: + /// 1. Deny list — if matched, always prompt (overrides even Yolo mode). + /// 2. Allow list — if matched, auto-approve. + /// 3. Mode logic (Ask / Yolo / AllowEdits / Sandbox). + pub fn check(&self, request: &PermissionRequest) -> bool { + let deny_rules = self.settings.deny_rules(); + let allow_rules = self.settings.allow_rules(); + + // 1. Deny list always forces a prompt, even in Yolo mode. + if deny_rules.iter().any(|r| r.matches(request)) { + return self.prompt_user(request); + } + + // 2. Allow list — auto-approve without prompting. + if allow_rules.iter().any(|r| r.matches(request)) { + return true; + } + + // 3. Fall through to mode logic. + match &self.mode { + PermissionMode::Yolo => true, + PermissionMode::Ask => self.prompt_user(request), + PermissionMode::AllowEdits => match request { + PermissionRequest::FileWrite { path } | PermissionRequest::FileEdit { path } => { + // Auto-approve if within cwd, otherwise prompt. + if self.path_is_within_cwd(path) { + true + } else { + self.prompt_user(request) + } + } + PermissionRequest::Bash { .. } => self.prompt_user(request), + }, + PermissionMode::Sandbox(sandbox_path) => { + if self.is_within_sandbox(request, sandbox_path) { + true + } else { + self.prompt_user(request) + } + } + } + } + + // ----------------------------------------------------------------------- + // Sandbox helpers + // ----------------------------------------------------------------------- + + /// Check if the request operates within the sandbox directory. + fn is_within_sandbox(&self, request: &PermissionRequest, sandbox_path: &str) -> bool { + match request { + PermissionRequest::Bash { command } => { + // For bash in sandbox mode: approve if the command doesn't + // reference paths outside the sandbox. This is a heuristic — + // we check if the working directory is within the sandbox. + // For truly dangerous commands (rm -rf /, etc.) users should + // use Ask mode, not sandbox. + self.bash_looks_safe_for_sandbox(command, sandbox_path) + } + PermissionRequest::FileWrite { path } | PermissionRequest::FileEdit { path } => { + self.path_is_within(path, sandbox_path) + } + } + } + + /// Check if a file path is within the given directory. + fn path_is_within(&self, path: &str, base: &str) -> bool { + let canonical = std::fs::canonicalize(path) + .unwrap_or_else(|_| Path::new(path).to_path_buf()); + let base_canonical = std::fs::canonicalize(base) + .unwrap_or_else(|_| Path::new(base).to_path_buf()); + canonical.starts_with(&base_canonical) + } + + /// Check if a file path is within the current working directory. + fn path_is_within_cwd(&self, path: &str) -> bool { + let cwd = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + self.path_is_within(path, &cwd.to_string_lossy()) + } + + /// Heuristic: does this bash command look like it stays within the sandbox? + fn bash_looks_safe_for_sandbox(&self, command: &str, sandbox_path: &str) -> bool { + // Reject commands that explicitly reference paths outside sandbox. + // This is imperfect — a determined user/LLM can bypass it. + // The sandbox is a convenience, not a security boundary. + + let dangerous_patterns = [ + "rm -rf /", + "rm -rf ~", + "mkfs", + "dd if=", + "> /dev/", + "chmod -R 777 /", + "curl | sh", + "wget | sh", + ]; + + for pattern in &dangerous_patterns { + if command.contains(pattern) { + return false; + } + } + + // If the command references absolute paths outside sandbox, flag it. + for token in command.split_whitespace() { + if token.starts_with('/') && !token.starts_with(sandbox_path) { + let safe_prefixes = ["/dev/null", "/tmp", "/usr/bin", "/bin", "/usr/local"]; + if !safe_prefixes.iter().any(|p| token.starts_with(p)) { + return false; + } + } + } + + true + } + + // ----------------------------------------------------------------------- + // User prompt + // ----------------------------------------------------------------------- + + /// Prompt the user for permission interactively. + fn prompt_user(&self, request: &PermissionRequest) -> bool { + let description = match request { + PermissionRequest::Bash { command } => { + format!("Run bash command: {command}") + } + PermissionRequest::FileWrite { path } => { + format!("Write to file: {path}") + } + PermissionRequest::FileEdit { path } => { + format!("Edit file: {path}") + } + }; + + eprint!("\x1b[33m[permission]\x1b[0m {description}\n Allow? [y/N] "); + io::stderr().flush().ok(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + return false; + } + + matches!(input.trim().to_lowercase().as_str(), "y" | "yes") + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- glob_match ---------------------------------------------------------- + + #[test] + fn test_glob_literal() { + assert!(glob_match("npm install", "npm install")); + assert!(!glob_match("npm install", "npm ci")); + } + + #[test] + fn test_glob_single_star_basic() { + assert!(glob_match("npm *", "npm install")); + assert!(glob_match("npm *", "npm run build")); + assert!(!glob_match("npm *", "yarn install")); + } + + #[test] + fn test_glob_single_star_no_slash() { + assert!(glob_match("src/*.rs", "src/main.rs")); + assert!(!glob_match("src/*.rs", "src/foo/main.rs")); + } + + #[test] + fn test_glob_double_star() { + assert!(glob_match("src/**", "src/foo/bar/baz.rs")); + assert!(glob_match("src/**", "src/lib.rs")); + assert!(!glob_match("src/**", "tests/lib.rs")); + } + + #[test] + fn test_glob_double_star_mid() { + assert!(glob_match("src/**/mod.rs", "src/foo/bar/mod.rs")); + assert!(glob_match("src/**/mod.rs", "src/mod.rs")); + } + + #[test] + fn test_glob_no_match() { + assert!(!glob_match("Bash(rm -rf *)", "cargo build")); + } + + // -- PermissionRule ------------------------------------------------------ + + #[test] + fn test_rule_parse_bash() { + let r = PermissionRule::parse("Bash(npm *)").unwrap(); + assert_eq!(r.tool, "bash"); + assert_eq!(r.pattern, "npm *"); + } + + #[test] + fn test_rule_parse_edit() { + let r = PermissionRule::parse("Edit(src/**)").unwrap(); + assert_eq!(r.tool, "edit"); + assert_eq!(r.pattern, "src/**"); + } + + #[test] + fn test_rule_parse_write() { + let r = PermissionRule::parse("Write(tests/**)").unwrap(); + assert_eq!(r.tool, "write"); + assert_eq!(r.pattern, "tests/**"); + } + + #[test] + fn test_rule_parse_invalid() { + assert!(PermissionRule::parse("noparen").is_none()); + assert!(PermissionRule::parse("NoClose(abc").is_none()); + assert!(PermissionRule::parse("").is_none()); + } + + #[test] + fn test_rule_matches_bash() { + let r = PermissionRule::parse("Bash(npm *)").unwrap(); + assert!(r.matches(&PermissionRequest::Bash { command: "npm install" })); + assert!(!r.matches(&PermissionRequest::Bash { command: "cargo build" })); + // Should not match file requests even if pattern happens to align + assert!(!r.matches(&PermissionRequest::FileEdit { path: "npm foo" })); + } + + #[test] + fn test_rule_matches_edit() { + let r = PermissionRule::parse("Edit(src/**)").unwrap(); + assert!(r.matches(&PermissionRequest::FileEdit { path: "src/main.rs" })); + assert!(r.matches(&PermissionRequest::FileEdit { path: "src/foo/bar.rs" })); + assert!(!r.matches(&PermissionRequest::FileEdit { path: "tests/foo.rs" })); + // Write request should not match an Edit rule + assert!(!r.matches(&PermissionRequest::FileWrite { path: "src/main.rs" })); + } + + // -- PermissionHandler logic --------------------------------------------- + + fn make_handler( + mode: PermissionMode, + allow: &[&str], + deny: &[&str], + ) -> PermissionHandler { + let settings = PermissionSettings { + permissions: PermissionBlock { + allow: allow.iter().map(|s| s.to_string()).collect(), + deny: deny.iter().map(|s| s.to_string()).collect(), + }, + }; + PermissionHandler::with_settings(&mode, settings) + } + + #[test] + fn test_allow_list_overrides_ask_mode() { + let h = make_handler(PermissionMode::Ask, &["Bash(cargo *)"], &[]); + // Would normally prompt in Ask mode, but allow list should auto-approve. + assert!(h.check(&PermissionRequest::Bash { command: "cargo build" })); + } + + #[test] + fn test_allow_list_does_not_match_other_commands() { + // "cargo build" is allowed, but "npm install" is not — in Ask mode + // it would prompt. We can't test interactive prompt here, so just + // verify allow list match is scoped correctly. + let h = make_handler(PermissionMode::Yolo, &["Bash(cargo *)"], &[]); + // Yolo + no deny + no allow match => still true (via Yolo fallthrough) + assert!(h.check(&PermissionRequest::Bash { command: "npm install" })); + } + + #[test] + fn test_yolo_mode_approves_everything_without_deny() { + let h = make_handler(PermissionMode::Yolo, &[], &[]); + assert!(h.check(&PermissionRequest::Bash { command: "rm -rf /tmp/foo" })); + assert!(h.check(&PermissionRequest::FileWrite { path: "/tmp/foo.txt" })); + } + + #[test] + fn test_allow_edits_approves_cwd_files() { + let h = make_handler(PermissionMode::AllowEdits, &[], &[]); + let cwd = std::env::current_dir().unwrap(); + let in_cwd = cwd.join("some_file.rs").to_string_lossy().to_string(); + assert!(h.check(&PermissionRequest::FileEdit { path: &in_cwd })); + assert!(h.check(&PermissionRequest::FileWrite { path: &in_cwd })); + } + + #[test] + fn test_settings_merge_allow_and_deny() { + let global = PermissionSettings { + permissions: PermissionBlock { + allow: vec!["Bash(cargo *)".to_string()], + deny: vec!["Bash(sudo *)".to_string()], + }, + }; + let project = PermissionSettings { + permissions: PermissionBlock { + allow: vec!["Edit(src/**)".to_string()], + deny: vec!["Bash(rm -rf *)".to_string()], + }, + }; + // Simulate merge: project extends global + let mut merged = PermissionSettings::default(); + merged.permissions.allow.extend(global.permissions.allow); + merged.permissions.deny.extend(global.permissions.deny); + merged.permissions.allow.extend(project.permissions.allow); + merged.permissions.deny.extend(project.permissions.deny); + + assert_eq!(merged.permissions.allow.len(), 2); + assert_eq!(merged.permissions.deny.len(), 2); + + let allow_rules = merged.allow_rules(); + assert!(allow_rules + .iter() + .any(|r| r.matches(&PermissionRequest::Bash { command: "cargo test" }))); + assert!(allow_rules + .iter() + .any(|r| r.matches(&PermissionRequest::FileEdit { path: "src/main.rs" }))); + } +} diff --git a/src/provider/mod.rs b/src/provider/mod.rs new file mode 100644 index 0000000..670744a --- /dev/null +++ b/src/provider/mod.rs @@ -0,0 +1,169 @@ +mod types; + +use anyhow::Result; +use futures::Stream; +use std::pin::Pin; + +pub use types::*; + +use crate::config::Config; +use crate::tools::ToolDefinition; + +/// Trait for LLM providers. Anything that speaks OpenAI chat completions works. +pub trait Provider: Send + Sync { + fn stream_chat( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + ) -> Pin> + Send + '_>>; +} + +/// OpenAI-compatible provider (works with vLLM, Ollama, llama.cpp, OpenAI, etc.) +pub struct OpenAIProvider { + client: reqwest::Client, + endpoint: String, + api_key: Option, + model: String, + max_tokens: u32, + temperature: Option, +} + +impl OpenAIProvider { + pub fn new(config: &Config) -> Self { + Self { + client: reqwest::Client::new(), + endpoint: config.endpoint.trim_end_matches('/').to_string(), + api_key: config.api_key.clone(), + model: config.model.clone(), + max_tokens: config.max_tokens, + temperature: config.temperature, + } + } + + fn build_request_body( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + ) -> serde_json::Value { + let mut body = serde_json::json!({ + "model": self.model, + "messages": messages, + "max_tokens": self.max_tokens, + "stream": true, + }); + + if let Some(temp) = self.temperature { + body["temperature"] = serde_json::json!(temp); + } + + if !tools.is_empty() { + let tool_defs: Vec = tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + } + }) + }) + .collect(); + body["tools"] = serde_json::json!(tool_defs); + } + + body + } +} + +impl Provider for OpenAIProvider { + fn stream_chat( + &self, + messages: &[ChatMessage], + tools: &[ToolDefinition], + ) -> Pin> + Send + '_>> { + let body = self.build_request_body(messages, tools); + let url = format!("{}/chat/completions", self.endpoint); + + let mut req = self.client.post(&url).json(&body); + if let Some(ref key) = self.api_key { + req = req.bearer_auth(key); + } + + Box::pin(async_stream::stream! { + let response = match req.send().await { + Ok(r) => r, + Err(e) => { + yield Err(anyhow::anyhow!(e)); + return; + } + }; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + yield Err(anyhow::anyhow!("API error {status}: {text}")); + return; + } + + use futures::StreamExt; + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + yield Err(anyhow::anyhow!(e)); + return; + } + }; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete SSE lines + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim().to_string(); + buffer = buffer[line_end + 1..].to_string(); + + if line.is_empty() || line.starts_with(':') { + continue; + } + + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + yield Ok(StreamEvent::Done); + return; + } + + match serde_json::from_str::(data) { + Ok(chunk) => { + for choice in &chunk.choices { + if let Some(ref content) = choice.delta.content { + yield Ok(StreamEvent::Text(content.clone())); + } + if let Some(ref tool_calls) = choice.delta.tool_calls { + for tc in tool_calls { + yield Ok(StreamEvent::ToolCallDelta(ToolCallDelta { + index: tc.index, + id: tc.id.clone(), + name: tc.function.as_ref().and_then(|f| f.name.clone()), + arguments_delta: tc.function.as_ref().and_then(|f| f.arguments.clone()), + })); + } + } + if choice.finish_reason.is_some() { + yield Ok(StreamEvent::Finish); + } + } + } + Err(e) => { + tracing::warn!("Failed to parse SSE chunk: {e}: {data}"); + } + } + } + } + } + }) + } +} diff --git a/src/provider/types.rs b/src/provider/types.rs new file mode 100644 index 0000000..5586fc5 --- /dev/null +++ b/src/provider/types.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; + +/// A message in the chat conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: Role, + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +impl ChatMessage { + pub fn system(content: &str) -> Self { + Self { + role: Role::System, + content: Some(content.to_string()), + tool_calls: None, + tool_call_id: None, + } + } + + pub fn user(content: &str) -> Self { + Self { + role: Role::User, + content: Some(content.to_string()), + tool_calls: None, + tool_call_id: None, + } + } + + pub fn assistant(content: Option, tool_calls: Option>) -> Self { + Self { + role: Role::Assistant, + content, + tool_calls, + tool_call_id: None, + } + } + + pub fn tool_result(tool_call_id: &str, content: &str) -> Self { + Self { + role: Role::Tool, + content: Some(content.to_string()), + tool_calls: None, + tool_call_id: Some(tool_call_id.to_string()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: FunctionCall, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + pub arguments: String, +} + +/// Streaming SSE chunk from the OpenAI-compatible API. +#[derive(Debug, Deserialize)] +pub struct StreamChunk { + pub choices: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct StreamChoice { + pub delta: StreamDelta, + pub finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +pub struct StreamDelta { + pub content: Option, + pub tool_calls: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct StreamToolCall { + pub index: usize, + pub id: Option, + pub function: Option, +} + +#[derive(Debug, Deserialize)] +pub struct StreamFunctionCall { + pub name: Option, + pub arguments: Option, +} + +/// High-level stream events emitted by the provider. +#[derive(Debug, Clone)] +pub enum StreamEvent { + Text(String), + ToolCallDelta(ToolCallDelta), + Finish, + Done, +} + +#[derive(Debug, Clone)] +pub struct ToolCallDelta { + pub index: usize, + pub id: Option, + pub name: Option, + pub arguments_delta: Option, +} diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..1d2b173 --- /dev/null +++ b/src/session/mod.rs @@ -0,0 +1,233 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use uuid::Uuid; + +use crate::provider::ChatMessage; + +/// Metadata for a saved session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMeta { + pub session_id: String, + pub created_at: String, + pub last_used_at: String, + pub working_directory: String, + pub model: String, + pub summary: String, +} + +/// An in-memory session handle returned on create or fork. +#[derive(Debug, Clone)] +pub struct Session { + pub session_id: String, + pub messages: Vec, +} + +/// Manages session persistence on disk. +pub struct SessionManager { + sessions_dir: PathBuf, +} + +impl SessionManager { + /// Create a new SessionManager, ensuring the sessions directory exists. + pub fn new() -> Self { + let sessions_dir = Self::sessions_dir(); + if let Err(e) = fs::create_dir_all(&sessions_dir) { + eprintln!("Warning: could not create sessions directory: {e}"); + } + Self { sessions_dir } + } + + fn sessions_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".slug") + .join("sessions") + } + + fn jsonl_path(&self, session_id: &str) -> PathBuf { + self.sessions_dir.join(format!("{session_id}.jsonl")) + } + + fn meta_path(&self, session_id: &str) -> PathBuf { + self.sessions_dir + .join(format!("{session_id}.meta.json")) + } + + fn now_iso8601() -> String { + // Use std::time to get a basic timestamp without pulling in chrono. + // Format: seconds since UNIX epoch as a UTC ISO-8601 string. + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Produce a minimal ISO-8601-like UTC string: YYYY-MM-DDTHH:MM:SSZ + let s = secs; + let sec = s % 60; + let min = (s / 60) % 60; + let hour = (s / 3600) % 24; + let days = s / 86400; // days since 1970-01-01 + // Convert days → calendar date (proleptic Gregorian) + let (year, month, day) = days_to_ymd(days); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + year, month, day, hour, min, sec + ) + } + + /// Create a brand-new session with a fresh UUID. Does not write to disk yet. + pub fn create_session(&self) -> Session { + Session { + session_id: Uuid::new_v4().to_string(), + messages: Vec::new(), + } + } + + /// Write (or overwrite) the metadata file for a session. + pub fn write_meta(&self, meta: &SessionMeta) -> Result<()> { + let path = self.meta_path(&meta.session_id); + let json = serde_json::to_string_pretty(meta) + .context("failed to serialize session metadata")?; + fs::write(&path, json).with_context(|| format!("failed to write meta file {path:?}"))?; + Ok(()) + } + + /// Initialize metadata for a newly created session. + pub fn init_meta( + &self, + session: &Session, + working_directory: &str, + model: &str, + summary: &str, + ) -> Result<()> { + let now = Self::now_iso8601(); + let meta = SessionMeta { + session_id: session.session_id.clone(), + created_at: now.clone(), + last_used_at: now, + working_directory: working_directory.to_string(), + model: model.to_string(), + summary: summary.to_string(), + }; + self.write_meta(&meta) + } + + /// Append a single message to the JSONL file and update last_used_at. + pub fn save_message(&self, session_id: &str, message: &ChatMessage) -> Result<()> { + let path = self.jsonl_path(session_id); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open session file {path:?}"))?; + let line = serde_json::to_string(message).context("failed to serialize message")?; + writeln!(file, "{line}").with_context(|| format!("failed to write to {path:?}"))?; + + // Update last_used_at in meta if it exists. + let meta_path = self.meta_path(session_id); + if meta_path.exists() { + if let Ok(raw) = fs::read_to_string(&meta_path) { + if let Ok(mut meta) = serde_json::from_str::(&raw) { + meta.last_used_at = Self::now_iso8601(); + let _ = self.write_meta(&meta); + } + } + } + + Ok(()) + } + + /// Read all messages from a session's JSONL file. + pub fn load_session(&self, session_id: &str) -> Result> { + let path = self.jsonl_path(session_id); + if !path.exists() { + return Ok(Vec::new()); + } + let file = + File::open(&path).with_context(|| format!("failed to open session file {path:?}"))?; + let reader = BufReader::new(file); + let mut messages = Vec::new(); + for (line_no, line) in reader.lines().enumerate() { + let line = line.with_context(|| format!("error reading line {line_no} of {path:?}"))?; + if line.trim().is_empty() { + continue; + } + let msg: ChatMessage = serde_json::from_str(&line) + .with_context(|| format!("failed to parse line {line_no} of {path:?}"))?; + messages.push(msg); + } + Ok(messages) + } + + /// List all sessions, sorted by last_used_at descending (most recent first). + pub fn list_sessions(&self) -> Vec { + let mut metas = Vec::new(); + let entries = match fs::read_dir(&self.sessions_dir) { + Ok(e) => e, + Err(_) => return metas, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") + && path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".meta.json")) + .unwrap_or(false) + { + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(meta) = serde_json::from_str::(&raw) { + metas.push(meta); + } + } + } + } + // Sort most-recent first. + metas.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at)); + metas + } + + /// Return the most recently used session, if any. + pub fn get_latest_session(&self) -> Option { + self.list_sessions().into_iter().next() + } + + /// Create a new session that starts with all messages copied from source_id. + pub fn fork_session(&self, source_id: &str) -> Result { + let messages = self.load_session(source_id) + .with_context(|| format!("failed to load source session {source_id}"))?; + let new_id = Uuid::new_v4().to_string(); + // Write all messages into the new JSONL immediately. + for msg in &messages { + self.save_message(&new_id, msg)?; + } + Ok(Session { + session_id: new_id, + messages, + }) + } +} + +// --------------------------------------------------------------------------- +// Minimal days-since-epoch → (year, month, day) conversion +// (avoids a chrono dependency for the timestamp helper) +// --------------------------------------------------------------------------- + +fn days_to_ymd(days: u64) -> (u64, u8, u8) { + // Algorithm: civil date from days since 1970-01-01 + // Based on Howard Hinnant's public-domain algorithm. + let z = days as i64 + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u64, m as u8, d as u8) +} diff --git a/src/slugmd/mod.rs b/src/slugmd/mod.rs new file mode 100644 index 0000000..26ce370 --- /dev/null +++ b/src/slugmd/mod.rs @@ -0,0 +1,141 @@ +use std::fs; +use std::path::PathBuf; + +const MAX_CHARS: usize = 40_000; + +/// Read a file and return its trimmed contents, or None if the file doesn't exist. +fn read_file_optional(path: &PathBuf) -> Option { + match fs::read_to_string(path) { + Ok(contents) => { + let trimmed = contents.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + } + Err(_) => None, + } +} + +/// Load all SLUG.md config files in priority order and return a concatenated string. +/// +/// Priority (later overrides earlier): +/// 1. ~/.slug/SLUG.md — global user preferences +/// 2. ./SLUG.md — project-level (in working directory) +/// 3. .slug/rules/*.md — modular rule files in the project +/// 4. SLUG.local.md — private notes (gitignored) +/// +/// Returns an empty string if no config files are found. +/// Truncates with a warning if the combined content exceeds 40,000 characters. +pub fn load_slug_context() -> String { + let mut sections: Vec<(String, String)> = Vec::new(); + + // 1. Global user preferences: ~/.slug/SLUG.md + if let Some(home_dir) = dirs::home_dir() { + let global_path = home_dir.join(".slug").join("SLUG.md"); + if let Some(content) = read_file_optional(&global_path) { + sections.push(("# Global Rules (~/.slug/SLUG.md)".to_string(), content)); + } + } + + // 2. Project-level: ./SLUG.md + let project_path = PathBuf::from("SLUG.md"); + if let Some(content) = read_file_optional(&project_path) { + sections.push(("# Project Rules (SLUG.md)".to_string(), content)); + } + + // 3. Modular rule files: .slug/rules/*.md + let rules_dir = PathBuf::from(".slug/rules"); + if rules_dir.is_dir() { + let mut rule_files: Vec = match fs::read_dir(&rules_dir) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map(|ext| ext == "md").unwrap_or(false)) + .collect(), + Err(_) => Vec::new(), + }; + // Sort for deterministic ordering + rule_files.sort(); + + for rule_file in rule_files { + if let Some(content) = read_file_optional(&rule_file) { + let filename = rule_file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + let header = format!("# Project Rule (.slug/rules/{filename})"); + sections.push((header, content)); + } + } + } + + // 4. Private local notes: ./SLUG.local.md + let local_path = PathBuf::from("SLUG.local.md"); + if let Some(content) = read_file_optional(&local_path) { + sections.push(( + "# Local Notes (SLUG.local.md)".to_string(), + content, + )); + } + + if sections.is_empty() { + return String::new(); + } + + // Concatenate sections with headers + let mut result = String::new(); + for (header, content) in §ions { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(header); + result.push('\n'); + result.push_str(content); + result.push('\n'); + } + + // Enforce character budget + if result.len() > MAX_CHARS { + result.truncate(MAX_CHARS); + // Try to truncate at a clean line boundary + if let Some(last_newline) = result.rfind('\n') { + result.truncate(last_newline + 1); + } + result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n"); + eprintln!( + "Warning: SLUG.md context exceeded {MAX_CHARS} characters and was truncated." + ); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_slug_context_no_files() { + // In a directory without any SLUG files, should return empty string + // (This test is environment-dependent — it passes when run from a clean dir) + let result = load_slug_context(); + // Just verify it doesn't panic and returns a String + let _ = result.len(); + } + + #[test] + fn test_truncation_logic() { + let long_content = "x".repeat(MAX_CHARS + 100); + let mut result = long_content; + if result.len() > MAX_CHARS { + result.truncate(MAX_CHARS); + if let Some(last_newline) = result.rfind('\n') { + result.truncate(last_newline + 1); + } + result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n"); + } + assert!(result.contains("truncated")); + } +} diff --git a/src/tools/bash.rs b/src/tools/bash.rs new file mode 100644 index 0000000..74e0487 --- /dev/null +++ b/src/tools/bash.rs @@ -0,0 +1,56 @@ +use super::{Tool, ToolDefinition}; +use anyhow::Result; +use serde_json::Value; +use std::process::Command; + +pub struct BashTool; + +impl Tool for BashTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "bash".to_string(), + description: "Execute a bash command and return its output.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute" + } + }, + "required": ["command"] + }), + } + } + + fn execute(&self, args: &Value) -> Result { + let command = args["command"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'command' argument"))?; + + let output = Command::new("bash") + .arg("-c") + .arg(command) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("STDERR:\n"); + result.push_str(&stderr); + } + if !output.status.success() { + result.push_str(&format!("\nExit code: {}", output.status.code().unwrap_or(-1))); + } + + Ok(result) + } +} diff --git a/src/tools/edit.rs b/src/tools/edit.rs new file mode 100644 index 0000000..3fad71e --- /dev/null +++ b/src/tools/edit.rs @@ -0,0 +1,69 @@ +use super::{Tool, ToolDefinition}; +use anyhow::Result; +use serde_json::Value; +use std::fs; + +pub struct EditTool; + +impl Tool for EditTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "edit".to_string(), + description: "Perform an exact string replacement in a file. The old_string must match exactly one location in the file.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute path to the file to edit" + }, + "old_string": { + "type": "string", + "description": "The exact string to find and replace" + }, + "new_string": { + "type": "string", + "description": "The replacement string" + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences (default: false)" + } + }, + "required": ["file_path", "old_string", "new_string"] + }), + } + } + + fn execute(&self, args: &Value) -> Result { + let path = args["file_path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'file_path' argument"))?; + let old_string = args["old_string"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'old_string' argument"))?; + let new_string = args["new_string"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'new_string' argument"))?; + let replace_all = args["replace_all"].as_bool().unwrap_or(false); + + let content = fs::read_to_string(path)?; + + let count = content.matches(old_string).count(); + if count == 0 { + anyhow::bail!("old_string not found in {path}"); + } + if count > 1 && !replace_all { + anyhow::bail!("old_string matches {count} locations in {path}. Use replace_all or provide more context."); + } + + let new_content = if replace_all { + content.replace(old_string, new_string) + } else { + content.replacen(old_string, new_string, 1) + }; + + fs::write(path, &new_content)?; + Ok(format!("Replaced {count} occurrence(s) in {path}")) + } +} diff --git a/src/tools/glob.rs b/src/tools/glob.rs new file mode 100644 index 0000000..04af6f6 --- /dev/null +++ b/src/tools/glob.rs @@ -0,0 +1,60 @@ +use super::{Tool, ToolDefinition}; +use anyhow::Result; +use serde_json::Value; +use std::path::Path; + +pub struct GlobTool; + +impl Tool for GlobTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "glob".to_string(), + description: "Find files matching a glob pattern. Returns matching file paths.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match (e.g., '**/*.rs', 'src/**/*.ts')" + }, + "path": { + "type": "string", + "description": "Directory to search in (defaults to current directory)" + } + }, + "required": ["pattern"] + }), + } + } + + fn execute(&self, args: &Value) -> Result { + let pattern = args["pattern"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' argument"))?; + let base = args["path"] + .as_str() + .unwrap_or("."); + + let base_path = Path::new(base); + if !base_path.exists() { + anyhow::bail!("Directory does not exist: {base}"); + } + + let walker = globwalk::GlobWalkerBuilder::from_patterns(base_path, &[pattern]) + .max_depth(20) + .build()?; + + let mut paths: Vec = walker + .filter_map(|e| e.ok()) + .map(|e| e.path().display().to_string()) + .collect(); + + paths.sort(); + + if paths.is_empty() { + Ok("No files found".to_string()) + } else { + Ok(paths.join("\n")) + } + } +} diff --git a/src/tools/grep.rs b/src/tools/grep.rs new file mode 100644 index 0000000..20fed10 --- /dev/null +++ b/src/tools/grep.rs @@ -0,0 +1,83 @@ +use super::{Tool, ToolDefinition}; +use anyhow::Result; +use serde_json::Value; +use std::process::Command; + +pub struct GrepTool; + +impl Tool for GrepTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "grep".to_string(), + description: "Search file contents using a regex pattern. Uses ripgrep if available, falls back to grep.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search for" + }, + "path": { + "type": "string", + "description": "File or directory to search in (defaults to current directory)" + }, + "glob": { + "type": "string", + "description": "Glob filter for file types (e.g., '*.rs')" + }, + "case_insensitive": { + "type": "boolean", + "description": "Case insensitive search" + } + }, + "required": ["pattern"] + }), + } + } + + fn execute(&self, args: &Value) -> Result { + let pattern = args["pattern"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' argument"))?; + let path = args["path"].as_str().unwrap_or("."); + let glob_filter = args["glob"].as_str(); + let case_insensitive = args["case_insensitive"].as_bool().unwrap_or(false); + + // Prefer ripgrep, fall back to grep + let (cmd, use_rg) = if which::which("rg").is_ok() { + ("rg", true) + } else { + ("grep", false) + }; + + let mut command = Command::new(cmd); + + if use_rg { + command.arg("--no-heading").arg("-n"); + if case_insensitive { + command.arg("-i"); + } + if let Some(g) = glob_filter { + command.arg("--glob").arg(g); + } + command.arg(pattern).arg(path); + } else { + command.arg("-rn"); + if case_insensitive { + command.arg("-i"); + } + command.arg(pattern).arg(path); + } + + let output = command.output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + + if stdout.is_empty() { + Ok("No matches found".to_string()) + } else { + // Limit output + let lines: Vec<&str> = stdout.lines().take(250).collect(); + Ok(lines.join("\n")) + } + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..58905ac --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,61 @@ +mod bash; +mod edit; +mod glob; +mod grep; +mod read; +mod write; + +use anyhow::Result; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; + +/// Schema describing a tool for the LLM. +#[derive(Debug, Clone, Serialize)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub parameters: Value, +} + +/// Trait for executable tools. +pub trait Tool: Send + Sync { + fn definition(&self) -> ToolDefinition; + fn execute(&self, args: &Value) -> Result; +} + +/// Registry of all available tools. +pub struct ToolRegistry { + tools: HashMap>, +} + +impl ToolRegistry { + pub fn new() -> Self { + let mut registry = Self { + tools: HashMap::new(), + }; + registry.register(Box::new(bash::BashTool)); + registry.register(Box::new(read::ReadTool)); + registry.register(Box::new(write::WriteTool)); + registry.register(Box::new(edit::EditTool)); + registry.register(Box::new(glob::GlobTool)); + registry.register(Box::new(grep::GrepTool)); + registry + } + + fn register(&mut self, tool: Box) { + let name = tool.definition().name.clone(); + self.tools.insert(name, tool); + } + + pub fn definitions(&self) -> Vec { + self.tools.values().map(|t| t.definition()).collect() + } + + pub fn execute(&self, name: &str, args: &Value) -> Result { + match self.tools.get(name) { + Some(tool) => tool.execute(args), + None => anyhow::bail!("Unknown tool: {name}"), + } + } +} diff --git a/src/tools/read.rs b/src/tools/read.rs new file mode 100644 index 0000000..29cf5a4 --- /dev/null +++ b/src/tools/read.rs @@ -0,0 +1,54 @@ +use super::{Tool, ToolDefinition}; +use anyhow::Result; +use serde_json::Value; +use std::fs; + +pub struct ReadTool; + +impl Tool for ReadTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "read".to_string(), + description: "Read a file and return its contents with line numbers.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute path to the file to read" + }, + "offset": { + "type": "integer", + "description": "Line number to start reading from (1-based)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of lines to read" + } + }, + "required": ["file_path"] + }), + } + } + + fn execute(&self, args: &Value) -> Result { + let path = args["file_path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'file_path' argument"))?; + + let content = fs::read_to_string(path)?; + let lines: Vec<&str> = content.lines().collect(); + + let offset = args["offset"].as_u64().unwrap_or(1).max(1) as usize - 1; + let limit = args["limit"].as_u64().unwrap_or(2000) as usize; + + let end = (offset + limit).min(lines.len()); + let mut result = String::new(); + + for (i, line) in lines[offset..end].iter().enumerate() { + result.push_str(&format!("{}\t{}\n", offset + i + 1, line)); + } + + Ok(result) + } +} diff --git a/src/tools/write.rs b/src/tools/write.rs new file mode 100644 index 0000000..3ab1cab --- /dev/null +++ b/src/tools/write.rs @@ -0,0 +1,47 @@ +use super::{Tool, ToolDefinition}; +use anyhow::Result; +use serde_json::Value; +use std::fs; +use std::path::Path; + +pub struct WriteTool; + +impl Tool for WriteTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "write".to_string(), + description: "Write content to a file, creating it if it doesn't exist.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute path to the file to write" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + } + }, + "required": ["file_path", "content"] + }), + } + } + + fn execute(&self, args: &Value) -> Result { + let path = args["file_path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'file_path' argument"))?; + let content = args["content"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'content' argument"))?; + + // Create parent directories if needed + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, content)?; + Ok(format!("Written {} bytes to {path}", content.len())) + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..b7b0d3b --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,119 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crate::agent::Agent; +use crate::config::Config; +use crate::hooks::{HookEvent, HookManager}; +use crate::permissions::PermissionHandler; +use crate::provider::{ChatMessage, OpenAIProvider}; +use crate::session::SessionManager; +use crate::tools::ToolRegistry; + +/// Run the interactive TUI loop. +pub async fn run( + provider: OpenAIProvider, + tools: ToolRegistry, + permissions: PermissionHandler, + config: Config, + session_mgr: SessionManager, + session_id: String, + prior_messages: Vec, + hook_mgr: HookManager, +) -> Result<()> { + println!("\x1b[1mslug\x1b[0m v{}", env!("CARGO_PKG_VERSION")); + println!("Model: {} @ {}", config.model, config.endpoint); + println!("Mode: {:?}", config.permission_mode); + println!("Session: {}", &session_id[..session_id.len().min(8)]); + println!("Type your message. Press Ctrl+C to exit.\n"); + + let mut agent = Agent::new_with_history( + Box::new(provider), + tools, + permissions, + &config, + prior_messages, + ); + + loop { + print!("\x1b[1;32m>\x1b[0m "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim(); + + if input.is_empty() { + continue; + } + + match input { + "/quit" | "/exit" => { + hook_mgr.fire(&HookEvent::SessionEnd); + // Save all messages on exit + for msg in agent.messages() { + let _ = session_mgr.save_message(&session_id, msg); + } + break; + } + "/help" => { + println!("Commands:"); + println!(" /quit - Exit"); + println!(" /help - Show this help"); + println!(" /clear - Clear conversation history"); + println!(" /compact - Compress conversation context"); + continue; + } + "/clear" => { + agent = Agent::new( + Box::new(OpenAIProvider::new(&config)), + ToolRegistry::new(), + PermissionHandler::new(&config.permission_mode), + &config, + ); + println!("Conversation cleared.\n"); + continue; + } + "/compact" => { + println!("Compacting conversation..."); + agent.compact(); + println!("Done. Context compressed.\n"); + continue; + } + _ => {} + } + + // Fire UserPromptSubmit hook + let hook_result = hook_mgr.fire(&HookEvent::UserPromptSubmit { + prompt: input.to_string(), + }); + if hook_result.blocked { + if let Some(reason) = &hook_result.block_reason { + eprintln!("\x1b[31mBlocked by hook: {reason}\x1b[0m\n"); + } + continue; + } + + // If hook injected additional context, prepend it + let effective_input = if let Some(ref ctx) = hook_result.additional_context { + format!("{input}\n\n[Additional context from hooks:\n{ctx}\n]") + } else { + input.to_string() + }; + + println!(); + + let result = agent + .stream_turn(&effective_input, |chunk| { + print!("{chunk}"); + let _ = io::stdout().flush(); + }) + .await; + + match result { + Ok(()) => println!("\n"), + Err(e) => eprintln!("\n\x1b[31mError: {e}\x1b[0m\n"), + } + } + + Ok(()) +}