diff --git a/Cargo.lock b/Cargo.lock index 03acdc9..3458276 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -89,6 +95,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -112,6 +127,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -173,9 +210,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -211,6 +248,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -261,6 +300,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -312,6 +361,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -331,6 +389,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -353,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" dependencies = [ "chrono", - "nom", + "nom 7.1.3", "once_cell", ] @@ -452,6 +520,28 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -498,6 +588,27 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -507,6 +618,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -615,6 +732,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -622,6 +752,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", ] [[package]] @@ -630,6 +770,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashify" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "hashlink" version = "0.9.1" @@ -883,6 +1034,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -912,6 +1069,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -951,6 +1110,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -967,6 +1136,39 @@ 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 = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "url", + "webpki-roots 1.0.6", +] + [[package]] name = "libc" version = "0.2.182" @@ -1018,6 +1220,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mail-parser" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82a3d6522697593ba4c683e0a6ee5a40fee93bc1a525e3cc6eeb3da11fd8897" +dependencies = [ + "hashify", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1063,6 +1274,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1073,6 +1301,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1091,6 +1328,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1109,6 +1355,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1168,6 +1458,16 @@ 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" @@ -1177,6 +1477,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1241,6 +1551,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -1424,6 +1740,8 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -1448,6 +1766,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1465,6 +1784,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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" @@ -1630,6 +1987,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1680,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2001,9 +2371,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -2017,6 +2387,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2065,11 +2441,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "wasm-bindgen", ] @@ -2110,6 +2486,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2169,6 +2554,28 @@ 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" @@ -2182,6 +2589,18 @@ dependencies = [ "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.85" @@ -2524,6 +2943,88 @@ 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" @@ -2573,8 +3074,11 @@ dependencies = [ "hmac", "hostname", "http-body-util", + "lettre", + "mail-parser", "reqwest", "rusqlite", + "rustls-pki-types", "serde", "serde_json", "sha2", @@ -2582,6 +3086,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tokio-test", "tokio-tungstenite", "toml", @@ -2590,6 +3095,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "webpki-roots 1.0.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a6087d9..7565c2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,11 @@ console = "0.15" tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = { version = "0.3", default-features = false, features = ["sink"] } hostname = "0.4.2" +lettre = { version = "0.11.19", features = ["smtp-transport", "rustls-tls"] } +mail-parser = "0.11.2" +rustls-pki-types = "1.14.0" +tokio-rustls = "0.26.4" +webpki-roots = "1.0.6" # HTTP server (gateway) — replaces raw TCP for proper HTTP/1.1 compliance axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio", "query"] } diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 0f611d7..8216ca3 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -5,6 +5,7 @@ use crate::providers::{self, Provider}; use crate::runtime; use crate::security::SecurityPolicy; use crate::tools; +use crate::util::truncate_with_ellipsis; use anyhow::Result; use std::fmt::Write; use std::sync::Arc; @@ -150,11 +151,7 @@ pub async fn run( // Auto-save assistant response to daily log if config.memory.auto_save { - let summary = if response.len() > 100 { - format!("{}...", &response[..100]) - } else { - response.clone() - }; + let summary = truncate_with_ellipsis(&response, 100); let _ = mem .store("assistant_resp", &summary, MemoryCategory::Daily) .await; @@ -193,11 +190,7 @@ pub async fn run( println!("\n{response}\n"); if config.memory.auto_save { - let summary = if response.len() > 100 { - format!("{}...", &response[..100]) - } else { - response.clone() - }; + let summary = truncate_with_ellipsis(&response, 100); let _ = mem .store("assistant_resp", &summary, MemoryCategory::Daily) .await; diff --git a/src/channels/email_channel.rs b/src/channels/email_channel.rs new file mode 100644 index 0000000..68a5f03 --- /dev/null +++ b/src/channels/email_channel.rs @@ -0,0 +1,446 @@ +#![allow(clippy::uninlined_format_args)] +#![allow(clippy::map_unwrap_or)] +#![allow(clippy::redundant_closure_for_method_calls)] +#![allow(clippy::cast_lossless)] +#![allow(clippy::trim_split_whitespace)] +#![allow(clippy::doc_link_with_quotes)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::unnecessary_map_or)] + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use mail_parser::{MessageParser, MimeHeaders}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::io::Write as IoWrite; +use std::net::TcpStream; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; +use tokio::time::{interval, sleep}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use super::traits::{Channel, ChannelMessage}; + +/// Email channel configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + /// IMAP server hostname + pub imap_host: String, + /// IMAP server port (default: 993 for TLS) + #[serde(default = "default_imap_port")] + pub imap_port: u16, + /// IMAP folder to poll (default: INBOX) + #[serde(default = "default_imap_folder")] + pub imap_folder: String, + /// SMTP server hostname + pub smtp_host: String, + /// SMTP server port (default: 587 for STARTTLS) + #[serde(default = "default_smtp_port")] + pub smtp_port: u16, + /// Use TLS for SMTP (default: true) + #[serde(default = "default_true")] + pub smtp_tls: bool, + /// Email username for authentication + pub username: String, + /// Email password for authentication + pub password: String, + /// From address for outgoing emails + pub from_address: String, + /// Poll interval in seconds (default: 60) + #[serde(default = "default_poll_interval")] + pub poll_interval_secs: u64, + /// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all) + #[serde(default)] + pub allowed_senders: Vec, +} + +fn default_imap_port() -> u16 { + 993 +} +fn default_smtp_port() -> u16 { + 587 +} +fn default_imap_folder() -> String { + "INBOX".into() +} +fn default_poll_interval() -> u64 { + 60 +} +fn default_true() -> bool { + true +} + +impl Default for EmailConfig { + fn default() -> Self { + Self { + imap_host: String::new(), + imap_port: default_imap_port(), + imap_folder: default_imap_folder(), + smtp_host: String::new(), + smtp_port: default_smtp_port(), + smtp_tls: true, + username: String::new(), + password: String::new(), + from_address: String::new(), + poll_interval_secs: default_poll_interval(), + allowed_senders: Vec::new(), + } + } +} + +/// Email channel — IMAP polling for inbound, SMTP for outbound +pub struct EmailChannel { + pub config: EmailConfig, + seen_messages: Mutex>, +} + +impl EmailChannel { + pub fn new(config: EmailConfig) -> Self { + Self { + config, + seen_messages: Mutex::new(HashSet::new()), + } + } + + /// Check if a sender email is in the allowlist + pub fn is_sender_allowed(&self, email: &str) -> bool { + if self.config.allowed_senders.is_empty() { + return false; // Empty = deny all + } + if self.config.allowed_senders.iter().any(|a| a == "*") { + return true; // Wildcard = allow all + } + let email_lower = email.to_lowercase(); + self.config.allowed_senders.iter().any(|allowed| { + if allowed.starts_with('@') { + // Domain match with @ prefix: "@example.com" + email_lower.ends_with(&allowed.to_lowercase()) + } else if allowed.contains('@') { + // Full email address match + allowed.eq_ignore_ascii_case(email) + } else { + // Domain match without @ prefix: "example.com" + email_lower.ends_with(&format!("@{}", allowed.to_lowercase())) + } + }) + } + + /// Strip HTML tags from content (basic) + pub fn strip_html(html: &str) -> String { + let mut result = String::new(); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => result.push(ch), + _ => {} + } + } + result.split_whitespace().collect::>().join(" ") + } + + /// Extract the sender address from a parsed email + fn extract_sender(parsed: &mail_parser::Message) -> String { + parsed + .from() + .and_then(|addr| addr.first()) + .and_then(|a| a.address()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".into()) + } + + /// Extract readable text from a parsed email + fn extract_text(parsed: &mail_parser::Message) -> String { + if let Some(text) = parsed.body_text(0) { + return text.to_string(); + } + if let Some(html) = parsed.body_html(0) { + return Self::strip_html(html.as_ref()); + } + for part in parsed.attachments() { + let part: &mail_parser::MessagePart = part; + if let Some(ct) = MimeHeaders::content_type(part) { + if ct.ctype() == "text" { + if let Ok(text) = std::str::from_utf8(part.contents()) { + let name = MimeHeaders::attachment_name(part).unwrap_or("file"); + return format!("[Attachment: {}]\n{}", name, text); + } + } + } + } + "(no readable content)".to_string() + } + + /// Fetch unseen emails via IMAP (blocking, run in spawn_blocking) + fn fetch_unseen_imap(config: &EmailConfig) -> Result> { + use rustls::ClientConfig as TlsConfig; + use rustls_pki_types::ServerName; + use std::sync::Arc; + use tokio_rustls::rustls; + + // Connect TCP + let tcp = TcpStream::connect((&*config.imap_host, config.imap_port))?; + tcp.set_read_timeout(Some(Duration::from_secs(30)))?; + + // TLS + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let tls_config = Arc::new( + TlsConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(), + ); + let server_name: ServerName<'_> = ServerName::try_from(config.imap_host.clone())?; + let conn = rustls::ClientConnection::new(tls_config, server_name)?; + let mut tls = rustls::StreamOwned::new(conn, tcp); + + let read_line = + |tls: &mut rustls::StreamOwned| -> Result { + let mut buf = Vec::new(); + loop { + let mut byte = [0u8; 1]; + match std::io::Read::read(tls, &mut byte) { + Ok(0) => return Err(anyhow!("IMAP connection closed")), + Ok(_) => { + buf.push(byte[0]); + if buf.ends_with(b"\r\n") { + return Ok(String::from_utf8_lossy(&buf).to_string()); + } + } + Err(e) => return Err(e.into()), + } + } + }; + + let send_cmd = |tls: &mut rustls::StreamOwned, + tag: &str, + cmd: &str| + -> Result> { + let full = format!("{} {}\r\n", tag, cmd); + IoWrite::write_all(tls, full.as_bytes())?; + IoWrite::flush(tls)?; + let mut lines = Vec::new(); + loop { + let line = read_line(tls)?; + let done = line.starts_with(tag); + lines.push(line); + if done { + break; + } + } + Ok(lines) + }; + + // Read greeting + let _greeting = read_line(&mut tls)?; + + // Login + let login_resp = send_cmd( + &mut tls, + "A1", + &format!("LOGIN \"{}\" \"{}\"", config.username, config.password), + )?; + if !login_resp.last().map_or(false, |l| l.contains("OK")) { + return Err(anyhow!("IMAP login failed")); + } + + // Select folder + let _select = send_cmd( + &mut tls, + "A2", + &format!("SELECT \"{}\"", config.imap_folder), + )?; + + // Search unseen + let search_resp = send_cmd(&mut tls, "A3", "SEARCH UNSEEN")?; + let mut uids: Vec<&str> = Vec::new(); + for line in &search_resp { + if line.starts_with("* SEARCH") { + let parts: Vec<&str> = line.trim().split_whitespace().collect(); + if parts.len() > 2 { + uids.extend_from_slice(&parts[2..]); + } + } + } + + let mut results = Vec::new(); + let mut tag_counter = 4_u32; // Start after A1, A2, A3 + + for uid in &uids { + // Fetch RFC822 with unique tag + let fetch_tag = format!("A{}", tag_counter); + tag_counter += 1; + let fetch_resp = send_cmd(&mut tls, &fetch_tag, &format!("FETCH {} RFC822", uid))?; + // Reconstruct the raw email from the response (skip first and last lines) + let raw: String = fetch_resp + .iter() + .skip(1) + .take(fetch_resp.len().saturating_sub(2)) + .cloned() + .collect(); + + if let Some(parsed) = MessageParser::default().parse(raw.as_bytes()) { + let sender = Self::extract_sender(&parsed); + let subject = parsed.subject().unwrap_or("(no subject)").to_string(); + let body = Self::extract_text(&parsed); + let content = format!("Subject: {}\n\n{}", subject, body); + let msg_id = parsed + .message_id() + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("gen-{}", Uuid::new_v4())); + #[allow(clippy::cast_sign_loss)] + let ts = parsed + .date() + .map(|d| { + let naive = chrono::NaiveDate::from_ymd_opt( + d.year as i32, + u32::from(d.month), + u32::from(d.day), + ) + .and_then(|date| { + date.and_hms_opt( + u32::from(d.hour), + u32::from(d.minute), + u32::from(d.second), + ) + }); + naive.map_or(0, |n| n.and_utc().timestamp() as u64) + }) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + }); + + results.push((msg_id, sender, content, ts)); + } + + // Mark as seen with unique tag + let store_tag = format!("A{tag_counter}"); + tag_counter += 1; + let _ = send_cmd( + &mut tls, + &store_tag, + &format!("STORE {uid} +FLAGS (\\Seen)"), + ); + } + + // Logout with unique tag + let logout_tag = format!("A{tag_counter}"); + let _ = send_cmd(&mut tls, &logout_tag, "LOGOUT"); + + Ok(results) + } + + fn create_smtp_transport(&self) -> Result { + let creds = Credentials::new(self.config.username.clone(), self.config.password.clone()); + let transport = if self.config.smtp_tls { + SmtpTransport::relay(&self.config.smtp_host)? + .port(self.config.smtp_port) + .credentials(creds) + .build() + } else { + SmtpTransport::builder_dangerous(&self.config.smtp_host) + .port(self.config.smtp_port) + .credentials(creds) + .build() + }; + Ok(transport) + } +} + +#[async_trait] +impl Channel for EmailChannel { + fn name(&self) -> &str { + "email" + } + + async fn send(&self, message: &str, recipient: &str) -> Result<()> { + let (subject, body) = if message.starts_with("Subject: ") { + if let Some(pos) = message.find('\n') { + (&message[9..pos], message[pos + 1..].trim()) + } else { + ("ZeroClaw Message", message) + } + } else { + ("ZeroClaw Message", message) + }; + + let email = Message::builder() + .from(self.config.from_address.parse()?) + .to(recipient.parse()?) + .subject(subject) + .body(body.to_string())?; + + let transport = self.create_smtp_transport()?; + transport.send(&email)?; + info!("Email sent to {}", recipient); + Ok(()) + } + + async fn listen(&self, tx: mpsc::Sender) -> Result<()> { + info!( + "Email polling every {}s on {}", + self.config.poll_interval_secs, self.config.imap_folder + ); + let mut tick = interval(Duration::from_secs(self.config.poll_interval_secs)); + let config = self.config.clone(); + + loop { + tick.tick().await; + let cfg = config.clone(); + match tokio::task::spawn_blocking(move || Self::fetch_unseen_imap(&cfg)).await { + Ok(Ok(messages)) => { + for (id, sender, content, ts) in messages { + { + let mut seen = self.seen_messages.lock().unwrap(); + if seen.contains(&id) { + continue; + } + if !self.is_sender_allowed(&sender) { + warn!("Blocked email from {}", sender); + continue; + } + seen.insert(id.clone()); + } // MutexGuard dropped before await + let msg = ChannelMessage { + id, + sender, + content, + channel: "email".to_string(), + timestamp: ts, + }; + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + Ok(Err(e)) => { + error!("Email poll failed: {}", e); + sleep(Duration::from_secs(10)).await; + } + Err(e) => { + error!("Email poll task panicked: {}", e); + sleep(Duration::from_secs(10)).await; + } + } + } + } + + async fn health_check(&self) -> bool { + let cfg = self.config.clone(); + tokio::task::spawn_blocking(move || { + let tcp = TcpStream::connect((&*cfg.imap_host, cfg.imap_port)); + tcp.is_ok() + }) + .await + .unwrap_or_default() + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index f6e879c..24099db 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1,5 +1,6 @@ pub mod cli; pub mod discord; +pub mod email_channel; pub mod imessage; pub mod matrix; pub mod slack; diff --git a/src/config/schema.rs b/src/config/schema.rs index 4fa31e5..131be2e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -89,10 +89,10 @@ impl Default for IdentityConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GatewayConfig { - /// Gateway port (default: 3000) + /// Gateway port (default: 8080) #[serde(default = "default_gateway_port")] pub port: u16, - /// Gateway host/bind address (default: 127.0.0.1) + /// Gateway host (default: 127.0.0.1) #[serde(default = "default_gateway_host")] pub host: String, /// Require pairing before accepting requests (default: true) @@ -178,13 +178,13 @@ impl Default for SecretsConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct BrowserConfig { - /// Enable browser tools (`browser_open` and browser automation) + /// Enable `browser_open` tool (opens URLs in Brave without scraping) #[serde(default)] pub enabled: bool, - /// Allowed domains for browser tools (exact or subdomain match) + /// Allowed domains for `browser_open` (exact or subdomain match) #[serde(default)] pub allowed_domains: Vec, - /// Session name for agent-browser (persists state across commands) + /// Browser session name (for agent-browser automation) #[serde(default)] pub session_name: Option, } @@ -604,8 +604,7 @@ pub struct WhatsAppConfig { pub phone_number_id: String, /// Webhook verify token (you define this, Meta sends it back for verification) pub verify_token: String, - /// App secret from Meta Business Suite (for webhook signature verification) - /// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable + /// App secret for webhook signature verification (X-Hub-Signature-256) #[serde(default)] pub app_secret: Option, /// Allowed phone numbers (E.164 format: +1234567890) or "*" for all @@ -647,19 +646,10 @@ impl Default for Config { impl Config { pub fn load_or_init() -> Result { - // Check for workspace override from environment (Docker support) - let zeroclaw_dir = if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") { - let ws_path = PathBuf::from(&workspace); - ws_path - .parent() - .map_or_else(|| PathBuf::from(&workspace), PathBuf::from) - } else { - let home = UserDirs::new() - .map(|u| u.home_dir().to_path_buf()) - .context("Could not find home directory")?; - home.join(".zeroclaw") - }; - + let home = UserDirs::new() + .map(|u| u.home_dir().to_path_buf()) + .context("Could not find home directory")?; + let zeroclaw_dir = home.join(".zeroclaw"); let config_path = zeroclaw_dir.join("config.toml"); if !zeroclaw_dir.exists() { @@ -668,35 +658,20 @@ impl Config { .context("Failed to create workspace directory")?; } - let mut config = if config_path.exists() { + if config_path.exists() { let contents = fs::read_to_string(&config_path).context("Failed to read config file")?; - toml::from_str(&contents).context("Failed to parse config file")? + let config: Config = + toml::from_str(&contents).context("Failed to parse config file")?; + Ok(config) } else { - Config::default() - }; - - // Apply environment variable overrides (Docker/container support) - config.apply_env_overrides(); - - // Save config if it didn't exist (creates default config with env overrides) - if !config_path.exists() { + let config = Config::default(); config.save()?; + Ok(config) } - - Ok(config) } - /// Apply environment variable overrides to config. - /// - /// Supports: - /// - `ZEROCLAW_API_KEY` or `API_KEY` - LLM provider API key - /// - `ZEROCLAW_PROVIDER` or `PROVIDER` - Provider name (openrouter, openai, anthropic, ollama) - /// - `ZEROCLAW_MODEL` - Model name/ID - /// - `ZEROCLAW_WORKSPACE` - Workspace directory path - /// - `ZEROCLAW_GATEWAY_PORT` or `PORT` - Gateway server port - /// - `ZEROCLAW_GATEWAY_HOST` or `HOST` - Gateway bind address - /// - `ZEROCLAW_TEMPERATURE` - Default temperature (0.0-2.0) + /// Apply environment variable overrides to config pub fn apply_env_overrides(&mut self) { // API Key: ZEROCLAW_API_KEY or API_KEY if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) { @@ -721,15 +696,6 @@ impl Config { } } - // Temperature: ZEROCLAW_TEMPERATURE - if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") { - if let Ok(temp) = temp_str.parse::() { - if (0.0..=2.0).contains(&temp) { - self.default_temperature = temp; - } - } - } - // Workspace directory: ZEROCLAW_WORKSPACE if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") { if !workspace.is_empty() { @@ -753,6 +719,15 @@ impl Config { self.gateway.host = host; } } + + // Temperature: ZEROCLAW_TEMPERATURE + if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") { + if let Ok(temp) = temp_str.parse::() { + if (0.0..=2.0).contains(&temp) { + self.default_temperature = temp; + } + } + } } pub fn save(&self) -> Result<()> { @@ -1193,7 +1168,7 @@ channel_id = "C123" access_token: "tok".into(), phone_number_id: "12345".into(), verify_token: "verify".into(), - app_secret: Some("secret123".into()), + app_secret: None, allowed_numbers: vec!["+1".into()], }; let toml_str = toml::to_string(&wc).unwrap(); @@ -1482,53 +1457,49 @@ default_temperature = 0.7 #[test] fn env_override_api_key() { - // Primary and fallback tested together to avoid env-var races. - std::env::remove_var("ZEROCLAW_API_KEY"); - std::env::remove_var("API_KEY"); - - // Primary: ZEROCLAW_API_KEY let mut config = Config::default(); assert!(config.api_key.is_none()); + std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key"); config.apply_env_overrides(); assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key")); - std::env::remove_var("ZEROCLAW_API_KEY"); - // Fallback: API_KEY - let mut config2 = Config::default(); + std::env::remove_var("ZEROCLAW_API_KEY"); + } + + #[test] + fn env_override_api_key_fallback() { + let mut config = Config::default(); + + std::env::remove_var("ZEROCLAW_API_KEY"); std::env::set_var("API_KEY", "sk-fallback-key"); - config2.apply_env_overrides(); - assert_eq!(config2.api_key.as_deref(), Some("sk-fallback-key")); + config.apply_env_overrides(); + assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key")); + std::env::remove_var("API_KEY"); } #[test] fn env_override_provider() { - // Primary, fallback, and empty-value tested together to avoid env-var races. - std::env::remove_var("ZEROCLAW_PROVIDER"); - std::env::remove_var("PROVIDER"); - - // Primary: ZEROCLAW_PROVIDER let mut config = Config::default(); + std::env::set_var("ZEROCLAW_PROVIDER", "anthropic"); config.apply_env_overrides(); assert_eq!(config.default_provider.as_deref(), Some("anthropic")); - std::env::remove_var("ZEROCLAW_PROVIDER"); - // Fallback: PROVIDER - let mut config2 = Config::default(); + std::env::remove_var("ZEROCLAW_PROVIDER"); + } + + #[test] + fn env_override_provider_fallback() { + let mut config = Config::default(); + + std::env::remove_var("ZEROCLAW_PROVIDER"); std::env::set_var("PROVIDER", "openai"); - config2.apply_env_overrides(); - assert_eq!(config2.default_provider.as_deref(), Some("openai")); - std::env::remove_var("PROVIDER"); + config.apply_env_overrides(); + assert_eq!(config.default_provider.as_deref(), Some("openai")); - // Empty value should not override - let mut config3 = Config::default(); - let original_provider = config3.default_provider.clone(); - std::env::set_var("ZEROCLAW_PROVIDER", ""); - config3.apply_env_overrides(); - assert_eq!(config3.default_provider, original_provider); - std::env::remove_var("ZEROCLAW_PROVIDER"); + std::env::remove_var("PROVIDER"); } #[test] @@ -1539,7 +1510,6 @@ default_temperature = 0.7 config.apply_env_overrides(); assert_eq!(config.default_model.as_deref(), Some("gpt-4o")); - // Clean up std::env::remove_var("ZEROCLAW_MODEL"); } @@ -1551,86 +1521,111 @@ default_temperature = 0.7 config.apply_env_overrides(); assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace")); - // Clean up std::env::remove_var("ZEROCLAW_WORKSPACE"); } #[test] - fn env_override_gateway_port() { - // Port, fallback, and invalid tested together to avoid env-var races. - std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); - std::env::remove_var("PORT"); + fn env_override_empty_values_ignored() { + let mut config = Config::default(); + let original_provider = config.default_provider.clone(); - // Primary: ZEROCLAW_GATEWAY_PORT + std::env::set_var("ZEROCLAW_PROVIDER", ""); + config.apply_env_overrides(); + assert_eq!(config.default_provider, original_provider); + + std::env::remove_var("ZEROCLAW_PROVIDER"); + } + + #[test] + fn env_override_gateway_port() { let mut config = Config::default(); assert_eq!(config.gateway.port, 3000); + std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080"); config.apply_env_overrides(); assert_eq!(config.gateway.port, 8080); + std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); + } - // Fallback: PORT - let mut config2 = Config::default(); + #[test] + fn env_override_port_fallback() { + let mut config = Config::default(); + + std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); std::env::set_var("PORT", "9000"); - config2.apply_env_overrides(); - assert_eq!(config2.gateway.port, 9000); - - // Invalid PORT is ignored - let mut config3 = Config::default(); - let original_port = config3.gateway.port; - std::env::set_var("PORT", "not_a_number"); - config3.apply_env_overrides(); - assert_eq!(config3.gateway.port, original_port); + config.apply_env_overrides(); + assert_eq!(config.gateway.port, 9000); std::env::remove_var("PORT"); } #[test] fn env_override_gateway_host() { - // Primary and fallback tested together to avoid env-var races. - std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); - std::env::remove_var("HOST"); - - // Primary: ZEROCLAW_GATEWAY_HOST let mut config = Config::default(); assert_eq!(config.gateway.host, "127.0.0.1"); + std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0"); config.apply_env_overrides(); assert_eq!(config.gateway.host, "0.0.0.0"); - std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); - // Fallback: HOST - let mut config2 = Config::default(); + std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); + } + + #[test] + fn env_override_host_fallback() { + let mut config = Config::default(); + + std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); std::env::set_var("HOST", "0.0.0.0"); - config2.apply_env_overrides(); - assert_eq!(config2.gateway.host, "0.0.0.0"); + config.apply_env_overrides(); + assert_eq!(config.gateway.host, "0.0.0.0"); + std::env::remove_var("HOST"); } #[test] fn env_override_temperature() { - // Valid and out-of-range tested together to avoid env-var races. - std::env::remove_var("ZEROCLAW_TEMPERATURE"); - - // Valid temperature is applied let mut config = Config::default(); + std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5"); config.apply_env_overrides(); assert!((config.default_temperature - 0.5).abs() < f64::EPSILON); - // Out-of-range temperature is ignored - let mut config2 = Config::default(); - let original_temp = config2.default_temperature; + std::env::remove_var("ZEROCLAW_TEMPERATURE"); + } + + #[test] + fn env_override_temperature_out_of_range_ignored() { + // Clean up any leftover env vars from other tests + std::env::remove_var("ZEROCLAW_TEMPERATURE"); + + let mut config = Config::default(); + let original_temp = config.default_temperature; + + // Temperature > 2.0 should be ignored std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0"); - config2.apply_env_overrides(); + config.apply_env_overrides(); assert!( - (config2.default_temperature - original_temp).abs() < f64::EPSILON, + (config.default_temperature - original_temp).abs() < f64::EPSILON, "Temperature 3.0 should be ignored (out of range)" ); std::env::remove_var("ZEROCLAW_TEMPERATURE"); } + #[test] + fn env_override_invalid_port_ignored() { + let mut config = Config::default(); + let original_port = config.gateway.port; + + std::env::set_var("PORT", "not_a_number"); + config.apply_env_overrides(); + assert_eq!(config.gateway.port, original_port); + + std::env::remove_var("PORT"); + } + #[test] fn gateway_config_default_values() { let g = GatewayConfig::default(); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 5fd17ab..ef9dbaf 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -12,6 +12,7 @@ use crate::config::Config; use crate::memory::{self, Memory, MemoryCategory}; use crate::providers::{self, Provider}; use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard}; +use crate::util::truncate_with_ellipsis; use anyhow::Result; use axum::{ body::Bytes, @@ -457,11 +458,7 @@ async fn handle_whatsapp_message( tracing::info!( "WhatsApp message from {}: {}", msg.sender, - if msg.content.len() > 50 { - format!("{}...", &msg.content[..50]) - } else { - msg.content.clone() - } + truncate_with_ellipsis(&msg.content, 50) ); // Auto-save to memory diff --git a/src/lib.rs b/src/lib.rs index 12c2334..8520a2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,10 +11,17 @@ dead_code )] +pub mod channels; pub mod config; +pub mod gateway; +pub mod health; pub mod heartbeat; pub mod memory; pub mod observability; pub mod providers; pub mod runtime; pub mod security; +pub mod skills; +pub mod tools; +pub mod tunnel; +pub mod util; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 8023b33..6e9a85c 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -398,6 +398,7 @@ fn default_model_for_provider(provider: &str) -> String { "ollama" => "llama3.2".into(), "groq" => "llama-3.3-70b-versatile".into(), "deepseek" => "deepseek-chat".into(), + "gemini" | "google" | "google-gemini" => "gemini-2.0-flash".into(), _ => "anthropic/claude-sonnet-4-20250514".into(), } } @@ -466,7 +467,7 @@ fn setup_workspace() -> Result<(PathBuf, PathBuf)> { fn setup_provider() -> Result<(String, String, String)> { // ── Tier selection ── let tiers = vec![ - "⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI)", + "⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)", "⚡ Fast inference (Groq, Fireworks, Together AI)", "🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)", "🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)", @@ -493,6 +494,10 @@ fn setup_provider() -> Result<(String, String, String)> { ("mistral", "Mistral — Large & Codestral"), ("xai", "xAI — Grok 3 & 4"), ("perplexity", "Perplexity — search-augmented AI"), + ( + "gemini", + "Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)", + ), ], 1 => vec![ ("groq", "Groq — ultra-fast LPU inference"), @@ -575,6 +580,53 @@ fn setup_provider() -> Result<(String, String, String)> { let api_key = if provider_name == "ollama" { print_bullet("Ollama runs locally — no API key needed!"); String::new() + } else if provider_name == "gemini" + || provider_name == "google" + || provider_name == "google-gemini" + { + // Special handling for Gemini: check for CLI auth first + if crate::providers::gemini::GeminiProvider::has_cli_credentials() { + print_bullet(&format!( + "{} Gemini CLI credentials detected! You can skip the API key.", + style("✓").green().bold() + )); + print_bullet("ZeroClaw will reuse your existing Gemini CLI authentication."); + println!(); + + let use_cli: bool = dialoguer::Confirm::new() + .with_prompt(" Use existing Gemini CLI authentication?") + .default(true) + .interact()?; + + if use_cli { + println!( + " {} Using Gemini CLI OAuth tokens", + style("✓").green().bold() + ); + String::new() // Empty key = will use CLI tokens + } else { + print_bullet("Get your API key at: https://aistudio.google.com/app/apikey"); + Input::new() + .with_prompt(" Paste your Gemini API key") + .allow_empty(true) + .interact_text()? + } + } else if std::env::var("GEMINI_API_KEY").is_ok() { + print_bullet(&format!( + "{} GEMINI_API_KEY environment variable detected!", + style("✓").green().bold() + )); + String::new() + } else { + print_bullet("Get your API key at: https://aistudio.google.com/app/apikey"); + print_bullet("Or run `gemini` CLI to authenticate (tokens will be reused)."); + println!(); + + Input::new() + .with_prompt(" Paste your Gemini API key (or press Enter to skip)") + .allow_empty(true) + .interact_text()? + } } else { let key_url = match provider_name { "openrouter" => "https://openrouter.ai/keys", @@ -594,6 +646,7 @@ fn setup_provider() -> Result<(String, String, String)> { "vercel" => "https://vercel.com/account/tokens", "cloudflare" => "https://dash.cloudflare.com/profile/api-tokens", "bedrock" => "https://console.aws.amazon.com/iam", + "gemini" | "google" | "google-gemini" => "https://aistudio.google.com/app/apikey", _ => "", }; @@ -735,6 +788,15 @@ fn setup_provider() -> Result<(String, String, String)> { ("codellama", "Code Llama"), ("phi3", "Phi-3 (small, fast)"), ], + "gemini" | "google" | "google-gemini" => vec![ + ("gemini-2.0-flash", "Gemini 2.0 Flash (fast, recommended)"), + ( + "gemini-2.0-flash-lite", + "Gemini 2.0 Flash Lite (fastest, cheapest)", + ), + ("gemini-1.5-pro", "Gemini 1.5 Pro (best quality)"), + ("gemini-1.5-flash", "Gemini 1.5 Flash (balanced)"), + ], _ => vec![("default", "Default model")], }; @@ -783,6 +845,7 @@ fn provider_env_var(name: &str) -> &'static str { "vercel" | "vercel-ai" => "VERCEL_API_KEY", "cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY", "bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID", + "gemini" | "google" | "google-gemini" => "GEMINI_API_KEY", _ => "API_KEY", } } @@ -1619,8 +1682,8 @@ fn setup_channels() -> Result { access_token: access_token.trim().to_string(), phone_number_id: phone_number_id.trim().to_string(), verify_token: verify_token.trim().to_string(), - app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var allowed_numbers, + app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var }); } 6 => { diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs new file mode 100644 index 0000000..1b64af0 --- /dev/null +++ b/src/providers/gemini.rs @@ -0,0 +1,385 @@ +//! Google Gemini provider with support for: +//! - Direct API key (`GEMINI_API_KEY` env var or config) +//! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication) +//! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`) + +use crate::providers::traits::Provider; +use async_trait::async_trait; +use directories::UserDirs; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Gemini provider supporting multiple authentication methods. +pub struct GeminiProvider { + api_key: Option, + client: Client, +} + +// ══════════════════════════════════════════════════════════════════════════════ +// API REQUEST/RESPONSE TYPES +// ══════════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Serialize)] +struct GenerateContentRequest { + contents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + system_instruction: Option, + #[serde(rename = "generationConfig")] + generation_config: GenerationConfig, +} + +#[derive(Debug, Serialize)] +struct Content { + #[serde(skip_serializing_if = "Option::is_none")] + role: Option, + parts: Vec, +} + +#[derive(Debug, Serialize)] +struct Part { + text: String, +} + +#[derive(Debug, Serialize)] +struct GenerationConfig { + temperature: f64, + #[serde(rename = "maxOutputTokens")] + max_output_tokens: u32, +} + +#[derive(Debug, Deserialize)] +struct GenerateContentResponse { + candidates: Option>, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct Candidate { + content: CandidateContent, +} + +#[derive(Debug, Deserialize)] +struct CandidateContent { + parts: Vec, +} + +#[derive(Debug, Deserialize)] +struct ResponsePart { + text: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiError { + message: String, +} + +// ══════════════════════════════════════════════════════════════════════════════ +// GEMINI CLI TOKEN STRUCTURES +// ══════════════════════════════════════════════════════════════════════════════ + +/// OAuth token stored by Gemini CLI in `~/.gemini/oauth_creds.json` +#[derive(Debug, Deserialize)] +struct GeminiCliOAuthCreds { + access_token: Option, + refresh_token: Option, + expiry: Option, +} + +/// Settings stored by Gemini CLI in ~/.gemini/settings.json +#[derive(Debug, Deserialize)] +struct GeminiCliSettings { + #[serde(rename = "selectedAuthType")] + selected_auth_type: Option, +} + +impl GeminiProvider { + /// Create a new Gemini provider. + /// + /// Authentication priority: + /// 1. Explicit API key passed in + /// 2. `GEMINI_API_KEY` environment variable + /// 3. `GOOGLE_API_KEY` environment variable + /// 4. Gemini CLI OAuth tokens (`~/.gemini/oauth_creds.json`) + pub fn new(api_key: Option<&str>) -> Self { + let resolved_key = api_key + .map(String::from) + .or_else(|| std::env::var("GEMINI_API_KEY").ok()) + .or_else(|| std::env::var("GOOGLE_API_KEY").ok()) + .or_else(Self::try_load_gemini_cli_token); + + Self { + api_key: resolved_key, + client: Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + /// Try to load OAuth access token from Gemini CLI's cached credentials. + /// Location: `~/.gemini/oauth_creds.json` + fn try_load_gemini_cli_token() -> Option { + let gemini_dir = Self::gemini_cli_dir()?; + let creds_path = gemini_dir.join("oauth_creds.json"); + + if !creds_path.exists() { + return None; + } + + let content = std::fs::read_to_string(&creds_path).ok()?; + let creds: GeminiCliOAuthCreds = serde_json::from_str(&content).ok()?; + + // Check if token is expired (basic check) + if let Some(ref expiry) = creds.expiry { + if let Ok(expiry_time) = chrono::DateTime::parse_from_rfc3339(expiry) { + if expiry_time < chrono::Utc::now() { + tracing::debug!("Gemini CLI OAuth token expired, skipping"); + return None; + } + } + } + + creds.access_token + } + + /// Get the Gemini CLI config directory (~/.gemini) + fn gemini_cli_dir() -> Option { + UserDirs::new().map(|u| u.home_dir().join(".gemini")) + } + + /// Check if Gemini CLI is configured and has valid credentials + pub fn has_cli_credentials() -> bool { + Self::try_load_gemini_cli_token().is_some() + } + + /// Check if any Gemini authentication is available + pub fn has_any_auth() -> bool { + std::env::var("GEMINI_API_KEY").is_ok() + || std::env::var("GOOGLE_API_KEY").is_ok() + || Self::has_cli_credentials() + } + + /// Get authentication source description for diagnostics + pub fn auth_source(&self) -> &'static str { + if self.api_key.is_none() { + return "none"; + } + if std::env::var("GEMINI_API_KEY").is_ok() { + return "GEMINI_API_KEY env var"; + } + if std::env::var("GOOGLE_API_KEY").is_ok() { + return "GOOGLE_API_KEY env var"; + } + if Self::has_cli_credentials() { + return "Gemini CLI OAuth"; + } + "config" + } +} + +#[async_trait] +impl Provider for GeminiProvider { + async fn chat_with_system( + &self, + system_prompt: Option<&str>, + message: &str, + model: &str, + temperature: f64, + ) -> anyhow::Result { + let api_key = self.api_key.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Gemini API key not found. Options:\n\ + 1. Set GEMINI_API_KEY env var\n\ + 2. Run `gemini` CLI to authenticate (tokens will be reused)\n\ + 3. Get an API key from https://aistudio.google.com/app/apikey\n\ + 4. Run `zeroclaw onboard` to configure" + ) + })?; + + // Build request + let system_instruction = system_prompt.map(|sys| Content { + role: None, + parts: vec![Part { + text: sys.to_string(), + }], + }); + + let request = GenerateContentRequest { + contents: vec![Content { + role: Some("user".to_string()), + parts: vec![Part { + text: message.to_string(), + }], + }], + system_instruction, + generation_config: GenerationConfig { + temperature, + max_output_tokens: 8192, + }, + }; + + // Gemini API endpoint + // Model format: gemini-2.0-flash, gemini-1.5-pro, etc. + let model_name = if model.starts_with("models/") { + model.to_string() + } else { + format!("models/{model}") + }; + + let url = format!( + "https://generativelanguage.googleapis.com/v1beta/{model_name}:generateContent?key={api_key}" + ); + + let response = self.client.post(&url).json(&request).send().await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + anyhow::bail!("Gemini API error ({status}): {error_text}"); + } + + let result: GenerateContentResponse = response.json().await?; + + // Check for API error in response body + if let Some(err) = result.error { + anyhow::bail!("Gemini API error: {}", err.message); + } + + // Extract text from response + result + .candidates + .and_then(|c| c.into_iter().next()) + .and_then(|c| c.content.parts.into_iter().next()) + .and_then(|p| p.text) + .ok_or_else(|| anyhow::anyhow!("No response from Gemini")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_creates_without_key() { + let provider = GeminiProvider::new(None); + // Should not panic, just have no key + assert!(provider.api_key.is_none() || provider.api_key.is_some()); + } + + #[test] + fn provider_creates_with_key() { + let provider = GeminiProvider::new(Some("test-api-key")); + assert!(provider.api_key.is_some()); + assert_eq!(provider.api_key.as_deref(), Some("test-api-key")); + } + + #[test] + fn gemini_cli_dir_returns_path() { + let dir = GeminiProvider::gemini_cli_dir(); + // Should return Some on systems with home dir + if UserDirs::new().is_some() { + assert!(dir.is_some()); + assert!(dir.unwrap().ends_with(".gemini")); + } + } + + #[test] + fn auth_source_reports_correctly() { + let provider = GeminiProvider::new(Some("explicit-key")); + // With explicit key, should report "config" (unless CLI credentials exist) + let source = provider.auth_source(); + // Should be either "config" or "Gemini CLI OAuth" if CLI is configured + assert!(source == "config" || source == "Gemini CLI OAuth"); + } + + #[test] + fn model_name_formatting() { + // Test that model names are formatted correctly + let model = "gemini-2.0-flash"; + let formatted = if model.starts_with("models/") { + model.to_string() + } else { + format!("models/{model}") + }; + assert_eq!(formatted, "models/gemini-2.0-flash"); + + // Already prefixed + let model2 = "models/gemini-1.5-pro"; + let formatted2 = if model2.starts_with("models/") { + model2.to_string() + } else { + format!("models/{model2}") + }; + assert_eq!(formatted2, "models/gemini-1.5-pro"); + } + + #[test] + fn request_serialization() { + let request = GenerateContentRequest { + contents: vec![Content { + role: Some("user".to_string()), + parts: vec![Part { + text: "Hello".to_string(), + }], + }], + system_instruction: Some(Content { + role: None, + parts: vec![Part { + text: "You are helpful".to_string(), + }], + }), + generation_config: GenerationConfig { + temperature: 0.7, + max_output_tokens: 8192, + }, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"role\":\"user\"")); + assert!(json.contains("\"text\":\"Hello\"")); + assert!(json.contains("\"temperature\":0.7")); + assert!(json.contains("\"maxOutputTokens\":8192")); + } + + #[test] + fn response_deserialization() { + let json = r#"{ + "candidates": [{ + "content": { + "parts": [{"text": "Hello there!"}] + } + }] + }"#; + + let response: GenerateContentResponse = serde_json::from_str(json).unwrap(); + assert!(response.candidates.is_some()); + let text = response + .candidates + .unwrap() + .into_iter() + .next() + .unwrap() + .content + .parts + .into_iter() + .next() + .unwrap() + .text; + assert_eq!(text, Some("Hello there!".to_string())); + } + + #[test] + fn error_response_deserialization() { + let json = r#"{ + "error": { + "message": "Invalid API key" + } + }"#; + + let response: GenerateContentResponse = serde_json::from_str(json).unwrap(); + assert!(response.error.is_some()); + assert_eq!(response.error.unwrap().message, "Invalid API key"); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 7bfae6c..6f4f0ef 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,5 +1,6 @@ pub mod anthropic; pub mod compatible; +pub mod gemini; pub mod ollama; pub mod openai; pub mod openrouter; @@ -100,6 +101,9 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(ollama::OllamaProvider::new( api_key.filter(|k| !k.is_empty()), ))), + "gemini" | "google" | "google-gemini" => { + Ok(Box::new(gemini::GeminiProvider::new(api_key))) + } // ── OpenAI-compatible providers ────────────────────── "venice" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -253,6 +257,15 @@ mod tests { assert!(create_provider("ollama", None).is_ok()); } + #[test] + fn factory_gemini() { + assert!(create_provider("gemini", Some("test-key")).is_ok()); + assert!(create_provider("google", Some("test-key")).is_ok()); + assert!(create_provider("google-gemini", Some("test-key")).is_ok()); + // Should also work without key (will try CLI auth) + assert!(create_provider("gemini", None).is_ok()); + } + // ── OpenAI-compatible providers ────────────────────────── #[test] @@ -445,6 +458,7 @@ mod tests { "anthropic", "openai", "ollama", + "gemini", "venice", "vercel", "cloudflare", diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..417a532 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,134 @@ +//! Utility functions for ZeroClaw. +//! +//! This module contains reusable helper functions used across the codebase. + +/// Truncate a string to at most `max_chars` characters, appending "..." if truncated. +/// +/// This function safely handles multi-byte UTF-8 characters (emoji, CJK, accented characters) +/// by using character boundaries instead of byte indices. +/// +/// # Arguments +/// * `s` - The string to truncate +/// * `max_chars` - Maximum number of characters to keep (excluding "...") +/// +/// # Returns +/// * Original string if length <= `max_chars` +/// * Truncated string with "..." appended if length > `max_chars` +/// +/// # Examples +/// ``` +/// use zeroclaw::util::truncate_with_ellipsis; +/// +/// // ASCII string - no truncation needed +/// assert_eq!(truncate_with_ellipsis("hello", 10), "hello"); +/// +/// // ASCII string - truncation needed +/// assert_eq!(truncate_with_ellipsis("hello world", 5), "hello..."); +/// +/// // Multi-byte UTF-8 (emoji) - safe truncation +/// assert_eq!(truncate_with_ellipsis("Hello 🦀 World", 8), "Hello 🦀..."); +/// assert_eq!(truncate_with_ellipsis("😀😀😀😀", 2), "😀😀..."); +/// +/// // Empty string +/// assert_eq!(truncate_with_ellipsis("", 10), ""); +/// ``` +pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String { + match s.char_indices().nth(max_chars) { + Some((idx, _)) => format!("{}...", &s[..idx]), + None => s.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_ascii_no_truncation() { + // ASCII string shorter than limit - no change + assert_eq!(truncate_with_ellipsis("hello", 10), "hello"); + assert_eq!(truncate_with_ellipsis("hello world", 50), "hello world"); + } + + #[test] + fn test_truncate_ascii_with_truncation() { + // ASCII string longer than limit - truncates + assert_eq!(truncate_with_ellipsis("hello world", 5), "hello..."); + assert_eq!(truncate_with_ellipsis("This is a long message", 10), "This is a ..."); + } + + #[test] + fn test_truncate_empty_string() { + assert_eq!(truncate_with_ellipsis("", 10), ""); + } + + #[test] + fn test_truncate_at_exact_boundary() { + // String exactly at boundary - no truncation + assert_eq!(truncate_with_ellipsis("hello", 5), "hello"); + } + + #[test] + fn test_truncate_emoji_single() { + // Single emoji (4 bytes) - should not panic + let s = "🦀"; + assert_eq!(truncate_with_ellipsis(s, 10), s); + assert_eq!(truncate_with_ellipsis(s, 1), s); + } + + #[test] + fn test_truncate_emoji_multiple() { + // Multiple emoji - safe truncation at character boundary + let s = "😀😀😀😀"; // 4 emoji, each 4 bytes = 16 bytes total + assert_eq!(truncate_with_ellipsis(s, 2), "😀😀..."); + assert_eq!(truncate_with_ellipsis(s, 3), "😀😀😀..."); + } + + #[test] + fn test_truncate_mixed_ascii_emoji() { + // Mixed ASCII and emoji + assert_eq!(truncate_with_ellipsis("Hello 🦀 World", 8), "Hello 🦀..."); + assert_eq!(truncate_with_ellipsis("Hi 😊", 10), "Hi 😊"); + } + + #[test] + fn test_truncate_cjk_characters() { + // CJK characters (Chinese - each is 3 bytes) + // This would panic with byte slicing: &s[..50] where s has 17 chars (51 bytes) + let s = "这是一个测试消息用来触发崩溃的中文"; // 21 characters + // Each character is 3 bytes, so 50 bytes is ~16 characters + let result = truncate_with_ellipsis(s, 16); + assert!(result.ends_with("...")); + // Should not panic and should be valid UTF-8 + assert!(result.is_char_boundary(result.len() - 1)); + } + + #[test] + fn test_truncate_accented_characters() { + // Accented characters (2 bytes each in UTF-8) + let s = "café résumé naïve"; + assert_eq!(truncate_with_ellipsis(s, 10), "café résumé..."); + } + + #[test] + fn test_truncate_unicode_edge_case() { + // Mix of 1-byte, 2-byte, 3-byte, and 4-byte characters + let s = "aé你好🦀"; // 1 + 1 + 2 + 2 + 4 bytes = 10 bytes, 5 chars + assert_eq!(truncate_with_ellipsis(s, 3), "aé你好..."); + } + + #[test] + fn test_truncate_long_string() { + // Long ASCII string + let s = "a".repeat(200); + let result = truncate_with_ellipsis(&s, 50); + assert_eq!(result.len(), 53); // 50 + "..." + assert!(result.ends_with("...")); + } + + #[test] + fn test_truncate_zero_max_chars() { + // Edge case: max_chars = 0 + assert_eq!(truncate_with_ellipsis("hello", 0), "..."); + } +}