Merge remote-tracking branch 'origin/feat/whatsapp-email-channels'

# Conflicts:
#	Cargo.lock
#	src/config/schema.rs
#	src/cron/mod.rs
#	src/security/secrets.rs
#	src/service/mod.rs
This commit is contained in:
argenis de la rosa 2026-02-15 06:37:51 -05:00
commit 47c5006de4
12 changed files with 1689 additions and 143 deletions

524
Cargo.lock generated
View file

@ -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]]

View file

@ -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"] }

View file

@ -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;

View file

@ -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<String>,
}
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<HashSet<String>>,
}
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::<Vec<_>>().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<Vec<(String, String, String, u64)>> {
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<rustls::ClientConnection, TcpStream>| -> Result<String> {
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<rustls::ClientConnection, TcpStream>,
tag: &str,
cmd: &str|
-> Result<Vec<String>> {
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<SmtpTransport> {
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<ChannelMessage>) -> 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()
}
}

View file

@ -1,5 +1,6 @@
pub mod cli;
pub mod discord;
pub mod email_channel;
pub mod imessage;
pub mod matrix;
pub mod slack;

View file

@ -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<String>,
/// Session name for agent-browser (persists state across commands)
/// Browser session name (for agent-browser automation)
#[serde(default)]
pub session_name: Option<String>,
}
@ -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<String>,
/// 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<Self> {
// 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 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)
}
}
/// 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::<f64>() {
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::<f64>() {
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();

View file

@ -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

View file

@ -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;

View file

@ -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<ChannelsConfig> {
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 => {

385
src/providers/gemini.rs Normal file
View file

@ -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<String>,
client: Client,
}
// ══════════════════════════════════════════════════════════════════════════════
// API REQUEST/RESPONSE TYPES
// ══════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Serialize)]
struct GenerateContentRequest {
contents: Vec<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
system_instruction: Option<Content>,
#[serde(rename = "generationConfig")]
generation_config: GenerationConfig,
}
#[derive(Debug, Serialize)]
struct Content {
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<String>,
parts: Vec<Part>,
}
#[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<Vec<Candidate>>,
error: Option<ApiError>,
}
#[derive(Debug, Deserialize)]
struct Candidate {
content: CandidateContent,
}
#[derive(Debug, Deserialize)]
struct CandidateContent {
parts: Vec<ResponsePart>,
}
#[derive(Debug, Deserialize)]
struct ResponsePart {
text: Option<String>,
}
#[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<String>,
refresh_token: Option<String>,
expiry: Option<String>,
}
/// Settings stored by Gemini CLI in ~/.gemini/settings.json
#[derive(Debug, Deserialize)]
struct GeminiCliSettings {
#[serde(rename = "selectedAuthType")]
selected_auth_type: Option<String>,
}
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<String> {
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<PathBuf> {
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<String> {
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");
}
}

View file

@ -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<Box<
"ollama" => 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",

134
src/util.rs Normal file
View file

@ -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), "...");
}
}